detached b413735

feat(screencast): serve the Mutter and Shell D-Bus objects behind a pidfd caller check

commit
b413735def9a539ecd83b3192daa403795ce09ed
author
brice · · 2026-10-14 20:27 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
8bce9bf
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +2464 -40
M internal/adapters/screencast/bus.go +416 -16
@@ -1,29 +1,429 @@
11 package screencast
22
3-import "github.com/bnema/zerobus"
3+import (
4+ "errors"
5+ "strconv"
6+ "strings"
47
5-// request is a decoded method call: every string is copied and capped, so it
6-// outlives the receive buffer while it waits for the caller check.
8+ "github.com/bnema/zerobus"
9+)
10+
11+// Object paths, interfaces and errors of the served contract.
12+const (
13+ pathScreenCast = "/org/gnome/Mutter/ScreenCast"
14+ prefixSession = pathScreenCast + "/Session/u"
15+ prefixStream = pathScreenCast + "/Stream/u"
16+ pathDisplayConfig = "/org/gnome/Mutter/DisplayConfig"
17+ pathIntrospect = "/org/gnome/Shell/Introspect"
18+ pathScreenshot = "/org/gnome/Shell/Screenshot"
19+
20+ ifaceScreenCast = "org.gnome.Mutter.ScreenCast"
21+ ifaceSession = ifaceScreenCast + ".Session"
22+ ifaceStream = ifaceScreenCast + ".Stream"
23+ ifaceDisplayConfig = "org.gnome.Mutter.DisplayConfig"
24+ ifaceIntrospect = "org.gnome.Shell.Introspect"
25+ ifaceScreenshot = "org.gnome.Shell.Screenshot"
26+ ifaceProps = "org.freedesktop.DBus.Properties"
27+ ifacePeer = "org.freedesktop.DBus.Peer"
28+ ifaceIntrospectable = "org.freedesktop.DBus.Introspectable"
29+
30+ errAccessDenied = "org.freedesktop.DBus.Error.AccessDenied"
31+ errLimits = "org.freedesktop.DBus.Error.LimitsExceeded"
32+ errNotSupported = "org.freedesktop.DBus.Error.NotSupported"
33+ errInvalidArgs = "org.freedesktop.DBus.Error.InvalidArgs"
34+ errUnknownObject = "org.freedesktop.DBus.Error.UnknownObject"
35+ errUnknownProperty = "org.freedesktop.DBus.Error.UnknownProperty"
36+ errUnknownInterface = "org.freedesktop.DBus.Error.UnknownInterface"
37+ errPropertyReadOnly = "org.freedesktop.DBus.Error.PropertyReadOnly"
38+ errFailed = "org.freedesktop.DBus.Error.Failed"
39+)
40+
41+// objKind is the object a call is addressed to.
42+type objKind uint8
43+
44+const (
45+ objNone objKind = iota
46+ objScreenCast
47+ objSession
48+ objStream
49+ objDisplayConfig
50+ objIntrospect
51+ objScreenshot
52+)
53+
54+// method is a call the service understands, after its (path, interface,
55+// member) were resolved.
56+type method uint8
57+
58+const (
59+ mNotSupported method = iota
60+ mCreateSession
61+ mStart
62+ mStop
63+ mRecordMonitor
64+ mRecordWindow
65+ mGet
66+ mGetAll
67+ mSet
68+ mPing
69+ mIntrospect
70+ mGetState
71+ mGetWindows
72+ mShot
73+ mShotArea
74+ mShotInteractive
75+ mUnknownObject
76+)
77+
78+// request is a decoded method call. Every value is copied out of the receive
79+// buffer with a length cap, so the request can wait for the caller check and
80+// the reply can be built after other messages were read.
781 type request struct {
8- sender string
9- serial uint32
10- noWait bool // the caller expects no reply
82+ sender string
83+ serial uint32
84+ noReply bool
85+ obj objKind
86+ id uint32 // session or stream number from the path
87+ method method
88+ bad bool // the body does not match the method
89+
90+ text, text2 string // connector; Properties interface and name
91+ window uint64
92+ cursor uint32
93+ remote bool // CreateSession: remote-desktop-session-id given
94+ x, y, w, h int32
95+ flag bool
96+}
97+
98+// privileged reports the calls that read the caller's executable again.
99+func (r *request) privileged() bool {
100+ switch r.method {
101+ case mCreateSession, mStart, mShot, mShotArea, mShotInteractive:
102+ return true
103+ }
104+ return false
105+}
106+
107+// capStr copies s, cut to maxArg bytes.
108+func capStr(s string) string {
109+ if len(s) > maxArg {
110+ s = s[:maxArg]
111+ }
112+ return strings.Clone(s)
113+}
114+
115+// resolve finds the object of a path. A served object needs its bus name.
116+func (s *Service) resolve(path string) (objKind, uint32) {
117+ switch path {
118+ case pathScreenCast:
119+ return objScreenCast, 0
120+ case pathDisplayConfig:
121+ return objDisplayConfig, 0
122+ case pathIntrospect:
123+ return objIntrospect, 0
124+ case pathScreenshot:
125+ return objScreenshot, 0
126+ }
127+ if n, ok := strings.CutPrefix(path, prefixSession); ok {
128+ id, err := strconv.ParseUint(n, 10, 32)
129+ return objSession, uint32(id) | boolBit(err != nil)
130+ }
131+ if n, ok := strings.CutPrefix(path, prefixStream); ok {
132+ id, err := strconv.ParseUint(n, 10, 32)
133+ return objStream, uint32(id) | boolBit(err != nil)
134+ }
135+ return objNone, 0
11136 }
12137
13-// session is one org.gnome.Mutter.ScreenCast.Session.
14-type session struct {
15- used bool
16- owner string
138+// boolBit marks an unparsable number with an id no object has.
139+func boolBit(bad bool) uint32 {
140+ if bad {
141+ return 0xffffffff
142+ }
143+ return 0
17144 }
18145
19-func (s *Service) onMessage(m *zerobus.Message) { _ = m }
146+// served reports whether the bus name of the object is ours.
147+func (s *Service) served(k objKind) bool {
148+ switch k {
149+ case objScreenCast, objSession, objStream:
150+ return s.owns[0]
151+ case objDisplayConfig:
152+ return s.owns[1]
153+ case objIntrospect:
154+ return s.owns[2]
155+ case objScreenshot:
156+ return s.owns[3]
157+ }
158+ return false
159+}
20160
21-func (s *Service) forEachSession(f func(*session)) {
22- for i := range s.sessions {
23- if s.sessions[i].used {
24- f(&s.sessions[i])
161+// decode reads a call completely: nothing of m is used after it returns.
162+func (s *Service) decode(m *zerobus.Message) request {
163+ req := request{sender: strings.Clone(m.Sender), serial: m.Serial, noReply: m.Flags&zerobus.FlagNoReplyExpected != 0}
164+ req.obj, req.id = s.resolve(m.Path)
165+ if !s.served(req.obj) {
166+ req.method = mUnknownObject
167+ return req
168+ }
169+ r := m.Body()
170+ sig := m.Signature
171+ want := func(w string) bool {
172+ if sig != w {
173+ req.bad = true
174+ return false
175+ }
176+ return true
177+ }
178+ switch m.Interface {
179+ case ifaceProps:
180+ switch m.Member {
181+ case "Get":
182+ req.method = mGet
183+ if want("ss") {
184+ req.text, req.text2 = capStr(r.Str()), capStr(r.Str())
185+ }
186+ case "GetAll":
187+ req.method = mGetAll
188+ if want("s") {
189+ req.text = capStr(r.Str())
190+ }
191+ case "Set":
192+ req.method = mSet
193+ }
194+ case ifacePeer:
195+ if m.Member == "Ping" {
196+ req.method = mPing
197+ want("")
25198 }
199+ case ifaceIntrospectable:
200+ if m.Member == "Introspect" {
201+ req.method = mIntrospect
202+ want("")
203+ }
204+ case ifaceScreenCast:
205+ if req.obj == objScreenCast && m.Member == "CreateSession" {
206+ req.method = mCreateSession
207+ if want("a{sv}") {
208+ s.readProps(r, &req)
209+ }
210+ }
211+ case ifaceSession:
212+ if req.obj == objSession {
213+ switch m.Member {
214+ case "Start":
215+ req.method = mStart
216+ want("")
217+ case "Stop":
218+ req.method = mStop
219+ want("")
220+ case "RecordMonitor":
221+ req.method = mRecordMonitor
222+ if want("sa{sv}") {
223+ req.text = capStr(r.Str())
224+ s.readProps(r, &req)
225+ }
226+ case "RecordWindow":
227+ req.method = mRecordWindow
228+ if want("a{sv}") {
229+ s.readProps(r, &req)
230+ }
231+ }
232+ }
233+ case ifaceDisplayConfig:
234+ if req.obj == objDisplayConfig && m.Member == "GetCurrentState" {
235+ req.method = mGetState
236+ want("")
237+ }
238+ case ifaceIntrospect:
239+ if req.obj == objIntrospect && m.Member == "GetWindows" {
240+ req.method = mGetWindows
241+ want("")
242+ }
243+ case ifaceScreenshot:
244+ if req.obj == objScreenshot {
245+ switch m.Member {
246+ case "Screenshot":
247+ req.method = mShot
248+ if want("bbs") {
249+ _ = r.Bool()
250+ req.flag = r.Bool()
251+ _ = r.Str()
252+ }
253+ case "ScreenshotArea":
254+ req.method = mShotArea
255+ if want("iiiibs") {
256+ req.x, req.y, req.w, req.h = r.Int32(), r.Int32(), r.Int32(), r.Int32()
257+ _ = r.Bool()
258+ _ = r.Str()
259+ }
260+ case "InteractiveScreenshot":
261+ req.method = mShotInteractive
262+ want("")
263+ }
264+ }
265+ }
266+ if r.Err() != nil {
267+ req.bad = true
26268 }
269+ return req
27270 }
28271
29-func (s *Service) closeSession(sess *session, signal bool) { _, _ = sess, signal }
272+// readProps reads an a{sv} and keeps the keys the service knows.
273+func (s *Service) readProps(r *zerobus.Reader, req *request) {
274+ end := r.Array('{')
275+ for r.More(end) {
276+ r.Struct()
277+ key := r.Str()
278+ sig := r.Variant()
279+ switch {
280+ case key == "cursor-mode" && sig == "u":
281+ req.cursor = r.Uint32()
282+ case key == "window-id" && sig == "t":
283+ req.window = r.Uint64()
284+ case key == "remote-desktop-session-id":
285+ req.remote = true
286+ r.Skip(sig)
287+ default:
288+ r.Skip(sig)
289+ }
290+ }
291+}
292+
293+// onMessage handles one message from the bus.
294+func (s *Service) onMessage(m *zerobus.Message) {
295+ switch m.Type {
296+ case zerobus.TypeMethodCall:
297+ if m.Sender != "" {
298+ req := s.decode(m)
299+ s.admit(&req)
300+ }
301+ case zerobus.TypeMethodReturn, zerobus.TypeError:
302+ if m.Sender == busDaemon {
303+ s.onBusReply(m)
304+ }
305+ case zerobus.TypeSignal:
306+ if m.Sender == busDaemon && m.Interface == busDaemon && m.Member == "NameOwnerChanged" {
307+ r := m.Body()
308+ name := r.Str()
309+ _ = r.Str()
310+ owner := r.Str()
311+ if r.Err() == nil && owner == "" && strings.HasPrefix(name, ":") {
312+ s.onGone(name)
313+ }
314+ }
315+ }
316+}
317+
318+// send sends the message that was built. A broken connection ends the
319+// attempt; any other failure drops that one message.
320+func (s *Service) send() {
321+ if _, err := s.conn.Send(); err != nil {
322+ if errors.Is(err, zerobus.ErrClosed) {
323+ s.fatal = err
324+ return
325+ }
326+ s.log.Warn().Err(err).Msg("message not sent")
327+ }
328+}
329+
330+// replyTo builds the reply of req, or nil when the caller wants none.
331+func (s *Service) replyTo(req *request, sig string) *zerobus.Encoder {
332+ if req.noReply {
333+ return nil
334+ }
335+ return s.conn.NewReply(&zerobus.Message{Sender: req.sender, Serial: req.serial}, sig)
336+}
337+
338+// reply sends an empty reply.
339+func (s *Service) reply(req *request) {
340+ if s.replyTo(req, "") != nil {
341+ s.send()
342+ }
343+}
344+
345+// replyErr sends an error reply.
346+func (s *Service) replyErr(req *request, name, text string) {
347+ if req.noReply {
348+ return
349+ }
350+ s.conn.NewError(&zerobus.Message{Sender: req.sender, Serial: req.serial}, name, "s").Str(text)
351+ s.send()
352+}
353+
354+// emit sends a signal to one peer.
355+func (s *Service) emitTo(dest, path, iface, member string) *zerobus.Encoder {
356+ return s.conn.NewSignalTo(dest, path, iface, member, "")
357+}
358+
359+// dispatch runs a call of a checked caller.
360+func (s *Service) dispatch(c *caller, req *request) {
361+ if !s.allowed(c.exe) {
362+ s.deny(req)
363+ return
364+ }
365+ if req.privileged() && !s.recheck(c) {
366+ s.deny(req)
367+ return
368+ }
369+ switch {
370+ case req.method == mUnknownObject:
371+ s.replyErr(req, errUnknownObject, "no such object")
372+ return
373+ case req.bad:
374+ s.replyErr(req, errInvalidArgs, "unexpected arguments")
375+ return
376+ }
377+ var sess *session
378+ var st *stream
379+ switch req.obj {
380+ case objSession:
381+ if sess = s.sessionByID(req.id); sess == nil {
382+ s.replyErr(req, errUnknownObject, "no such session")
383+ return
384+ }
385+ case objStream:
386+ if st = s.streamByID(req.id); st == nil {
387+ s.replyErr(req, errUnknownObject, "no such stream")
388+ return
389+ }
390+ sess = st.sess
391+ }
392+ if sess != nil && sess.owner != req.sender {
393+ s.deny(req)
394+ return
395+ }
396+ switch req.method {
397+ case mPing:
398+ s.reply(req)
399+ case mIntrospect:
400+ if e := s.replyTo(req, "s"); e != nil {
401+ e.Str("<node/>")
402+ s.send()
403+ }
404+ case mGet, mGetAll, mSet:
405+ s.properties(req, sess, st)
406+ case mCreateSession:
407+ s.createSession(req)
408+ case mStart:
409+ s.start(req, sess)
410+ case mStop:
411+ s.reply(req)
412+ s.closeSession(sess, true)
413+ case mRecordMonitor, mRecordWindow:
414+ s.record(req, sess)
415+ case mGetState:
416+ s.replyState(req)
417+ case mGetWindows:
418+ s.replyWindows(req)
419+ case mShot, mShotArea, mShotInteractive:
420+ s.screenshot(req)
421+ default:
422+ s.replyErr(req, errNotSupported, "not supported")
423+ }
424+}
425+
426+// deny refuses a call.
427+func (s *Service) deny(req *request) {
428+ s.replyErr(req, errAccessDenied, "caller not allowed to capture the screen")
429+}
@@ -1,29 +1,429 @@
1 package screencast1 package screencast
2 2
3-import "github.com/bnema/zerobus"3+import (
4+ "errors"
5+ "strconv"
6+ "strings"
4 7
5-// request is a decoded method call: every string is copied and capped, so it8+ "github.com/bnema/zerobus"
6-// outlives the receive buffer while it waits for the caller check.9+)
10+
11+// Object paths, interfaces and errors of the served contract.
12+const (
13+ pathScreenCast = "/org/gnome/Mutter/ScreenCast"
14+ prefixSession = pathScreenCast + "/Session/u"
15+ prefixStream = pathScreenCast + "/Stream/u"
16+ pathDisplayConfig = "/org/gnome/Mutter/DisplayConfig"
17+ pathIntrospect = "/org/gnome/Shell/Introspect"
18+ pathScreenshot = "/org/gnome/Shell/Screenshot"
19+
20+ ifaceScreenCast = "org.gnome.Mutter.ScreenCast"
21+ ifaceSession = ifaceScreenCast + ".Session"
22+ ifaceStream = ifaceScreenCast + ".Stream"
23+ ifaceDisplayConfig = "org.gnome.Mutter.DisplayConfig"
24+ ifaceIntrospect = "org.gnome.Shell.Introspect"
25+ ifaceScreenshot = "org.gnome.Shell.Screenshot"
26+ ifaceProps = "org.freedesktop.DBus.Properties"
27+ ifacePeer = "org.freedesktop.DBus.Peer"
28+ ifaceIntrospectable = "org.freedesktop.DBus.Introspectable"
29+
30+ errAccessDenied = "org.freedesktop.DBus.Error.AccessDenied"
31+ errLimits = "org.freedesktop.DBus.Error.LimitsExceeded"
32+ errNotSupported = "org.freedesktop.DBus.Error.NotSupported"
33+ errInvalidArgs = "org.freedesktop.DBus.Error.InvalidArgs"
34+ errUnknownObject = "org.freedesktop.DBus.Error.UnknownObject"
35+ errUnknownProperty = "org.freedesktop.DBus.Error.UnknownProperty"
36+ errUnknownInterface = "org.freedesktop.DBus.Error.UnknownInterface"
37+ errPropertyReadOnly = "org.freedesktop.DBus.Error.PropertyReadOnly"
38+ errFailed = "org.freedesktop.DBus.Error.Failed"
39+)
40+
41+// objKind is the object a call is addressed to.
42+type objKind uint8
43+
44+const (
45+ objNone objKind = iota
46+ objScreenCast
47+ objSession
48+ objStream
49+ objDisplayConfig
50+ objIntrospect
51+ objScreenshot
52+)
53+
54+// method is a call the service understands, after its (path, interface,
55+// member) were resolved.
56+type method uint8
57+
58+const (
59+ mNotSupported method = iota
60+ mCreateSession
61+ mStart
62+ mStop
63+ mRecordMonitor
64+ mRecordWindow
65+ mGet
66+ mGetAll
67+ mSet
68+ mPing
69+ mIntrospect
70+ mGetState
71+ mGetWindows
72+ mShot
73+ mShotArea
74+ mShotInteractive
75+ mUnknownObject
76+)
77+
78+// request is a decoded method call. Every value is copied out of the receive
79+// buffer with a length cap, so the request can wait for the caller check and
80+// the reply can be built after other messages were read.
7 type request struct {81 type request struct {
8- sender string82+ sender string
9- serial uint3283+ serial uint32
10- noWait bool // the caller expects no reply84+ noReply bool
85+ obj objKind
86+ id uint32 // session or stream number from the path
87+ method method
88+ bad bool // the body does not match the method
89+
90+ text, text2 string // connector; Properties interface and name
91+ window uint64
92+ cursor uint32
93+ remote bool // CreateSession: remote-desktop-session-id given
94+ x, y, w, h int32
95+ flag bool
96+}
97+
98+// privileged reports the calls that read the caller's executable again.
99+func (r *request) privileged() bool {
100+ switch r.method {
101+ case mCreateSession, mStart, mShot, mShotArea, mShotInteractive:
102+ return true
103+ }
104+ return false
105+}
106+
107+// capStr copies s, cut to maxArg bytes.
108+func capStr(s string) string {
109+ if len(s) > maxArg {
110+ s = s[:maxArg]
111+ }
112+ return strings.Clone(s)
113+}
114+
115+// resolve finds the object of a path. A served object needs its bus name.
116+func (s *Service) resolve(path string) (objKind, uint32) {
117+ switch path {
118+ case pathScreenCast:
119+ return objScreenCast, 0
120+ case pathDisplayConfig:
121+ return objDisplayConfig, 0
122+ case pathIntrospect:
123+ return objIntrospect, 0
124+ case pathScreenshot:
125+ return objScreenshot, 0
126+ }
127+ if n, ok := strings.CutPrefix(path, prefixSession); ok {
128+ id, err := strconv.ParseUint(n, 10, 32)
129+ return objSession, uint32(id) | boolBit(err != nil)
130+ }
131+ if n, ok := strings.CutPrefix(path, prefixStream); ok {
132+ id, err := strconv.ParseUint(n, 10, 32)
133+ return objStream, uint32(id) | boolBit(err != nil)
134+ }
135+ return objNone, 0
11 }136 }
12 137
13-// session is one org.gnome.Mutter.ScreenCast.Session.138+// boolBit marks an unparsable number with an id no object has.
14-type session struct {139+func boolBit(bad bool) uint32 {
15- used bool140+ if bad {
16- owner string141+ return 0xffffffff
142+ }
143+ return 0
17 }144 }
18 145
19-func (s *Service) onMessage(m *zerobus.Message) { _ = m }146+// served reports whether the bus name of the object is ours.
147+func (s *Service) served(k objKind) bool {
148+ switch k {
149+ case objScreenCast, objSession, objStream:
150+ return s.owns[0]
151+ case objDisplayConfig:
152+ return s.owns[1]
153+ case objIntrospect:
154+ return s.owns[2]
155+ case objScreenshot:
156+ return s.owns[3]
157+ }
158+ return false
159+}
20 160
21-func (s *Service) forEachSession(f func(*session)) {161+// decode reads a call completely: nothing of m is used after it returns.
22- for i := range s.sessions {162+func (s *Service) decode(m *zerobus.Message) request {
23- if s.sessions[i].used {163+ req := request{sender: strings.Clone(m.Sender), serial: m.Serial, noReply: m.Flags&zerobus.FlagNoReplyExpected != 0}
24- f(&s.sessions[i])164+ req.obj, req.id = s.resolve(m.Path)
165+ if !s.served(req.obj) {
166+ req.method = mUnknownObject
167+ return req
168+ }
169+ r := m.Body()
170+ sig := m.Signature
171+ want := func(w string) bool {
172+ if sig != w {
173+ req.bad = true
174+ return false
175+ }
176+ return true
177+ }
178+ switch m.Interface {
179+ case ifaceProps:
180+ switch m.Member {
181+ case "Get":
182+ req.method = mGet
183+ if want("ss") {
184+ req.text, req.text2 = capStr(r.Str()), capStr(r.Str())
185+ }
186+ case "GetAll":
187+ req.method = mGetAll
188+ if want("s") {
189+ req.text = capStr(r.Str())
190+ }
191+ case "Set":
192+ req.method = mSet
193+ }
194+ case ifacePeer:
195+ if m.Member == "Ping" {
196+ req.method = mPing
197+ want("")
25 }198 }
199+ case ifaceIntrospectable:
200+ if m.Member == "Introspect" {
201+ req.method = mIntrospect
202+ want("")
203+ }
204+ case ifaceScreenCast:
205+ if req.obj == objScreenCast && m.Member == "CreateSession" {
206+ req.method = mCreateSession
207+ if want("a{sv}") {
208+ s.readProps(r, &req)
209+ }
210+ }
211+ case ifaceSession:
212+ if req.obj == objSession {
213+ switch m.Member {
214+ case "Start":
215+ req.method = mStart
216+ want("")
217+ case "Stop":
218+ req.method = mStop
219+ want("")
220+ case "RecordMonitor":
221+ req.method = mRecordMonitor
222+ if want("sa{sv}") {
223+ req.text = capStr(r.Str())
224+ s.readProps(r, &req)
225+ }
226+ case "RecordWindow":
227+ req.method = mRecordWindow
228+ if want("a{sv}") {
229+ s.readProps(r, &req)
230+ }
231+ }
232+ }
233+ case ifaceDisplayConfig:
234+ if req.obj == objDisplayConfig && m.Member == "GetCurrentState" {
235+ req.method = mGetState
236+ want("")
237+ }
238+ case ifaceIntrospect:
239+ if req.obj == objIntrospect && m.Member == "GetWindows" {
240+ req.method = mGetWindows
241+ want("")
242+ }
243+ case ifaceScreenshot:
244+ if req.obj == objScreenshot {
245+ switch m.Member {
246+ case "Screenshot":
247+ req.method = mShot
248+ if want("bbs") {
249+ _ = r.Bool()
250+ req.flag = r.Bool()
251+ _ = r.Str()
252+ }
253+ case "ScreenshotArea":
254+ req.method = mShotArea
255+ if want("iiiibs") {
256+ req.x, req.y, req.w, req.h = r.Int32(), r.Int32(), r.Int32(), r.Int32()
257+ _ = r.Bool()
258+ _ = r.Str()
259+ }
260+ case "InteractiveScreenshot":
261+ req.method = mShotInteractive
262+ want("")
263+ }
264+ }
265+ }
266+ if r.Err() != nil {
267+ req.bad = true
26 }268 }
269+ return req
27 }270 }
28 271
29-func (s *Service) closeSession(sess *session, signal bool) { _, _ = sess, signal }272+// readProps reads an a{sv} and keeps the keys the service knows.
273+func (s *Service) readProps(r *zerobus.Reader, req *request) {
274+ end := r.Array('{')
275+ for r.More(end) {
276+ r.Struct()
277+ key := r.Str()
278+ sig := r.Variant()
279+ switch {
280+ case key == "cursor-mode" && sig == "u":
281+ req.cursor = r.Uint32()
282+ case key == "window-id" && sig == "t":
283+ req.window = r.Uint64()
284+ case key == "remote-desktop-session-id":
285+ req.remote = true
286+ r.Skip(sig)
287+ default:
288+ r.Skip(sig)
289+ }
290+ }
291+}
292+
293+// onMessage handles one message from the bus.
294+func (s *Service) onMessage(m *zerobus.Message) {
295+ switch m.Type {
296+ case zerobus.TypeMethodCall:
297+ if m.Sender != "" {
298+ req := s.decode(m)
299+ s.admit(&req)
300+ }
301+ case zerobus.TypeMethodReturn, zerobus.TypeError:
302+ if m.Sender == busDaemon {
303+ s.onBusReply(m)
304+ }
305+ case zerobus.TypeSignal:
306+ if m.Sender == busDaemon && m.Interface == busDaemon && m.Member == "NameOwnerChanged" {
307+ r := m.Body()
308+ name := r.Str()
309+ _ = r.Str()
310+ owner := r.Str()
311+ if r.Err() == nil && owner == "" && strings.HasPrefix(name, ":") {
312+ s.onGone(name)
313+ }
314+ }
315+ }
316+}
317+
318+// send sends the message that was built. A broken connection ends the
319+// attempt; any other failure drops that one message.
320+func (s *Service) send() {
321+ if _, err := s.conn.Send(); err != nil {
322+ if errors.Is(err, zerobus.ErrClosed) {
323+ s.fatal = err
324+ return
325+ }
326+ s.log.Warn().Err(err).Msg("message not sent")
327+ }
328+}
329+
330+// replyTo builds the reply of req, or nil when the caller wants none.
331+func (s *Service) replyTo(req *request, sig string) *zerobus.Encoder {
332+ if req.noReply {
333+ return nil
334+ }
335+ return s.conn.NewReply(&zerobus.Message{Sender: req.sender, Serial: req.serial}, sig)
336+}
337+
338+// reply sends an empty reply.
339+func (s *Service) reply(req *request) {
340+ if s.replyTo(req, "") != nil {
341+ s.send()
342+ }
343+}
344+
345+// replyErr sends an error reply.
346+func (s *Service) replyErr(req *request, name, text string) {
347+ if req.noReply {
348+ return
349+ }
350+ s.conn.NewError(&zerobus.Message{Sender: req.sender, Serial: req.serial}, name, "s").Str(text)
351+ s.send()
352+}
353+
354+// emit sends a signal to one peer.
355+func (s *Service) emitTo(dest, path, iface, member string) *zerobus.Encoder {
356+ return s.conn.NewSignalTo(dest, path, iface, member, "")
357+}
358+
359+// dispatch runs a call of a checked caller.
360+func (s *Service) dispatch(c *caller, req *request) {
361+ if !s.allowed(c.exe) {
362+ s.deny(req)
363+ return
364+ }
365+ if req.privileged() && !s.recheck(c) {
366+ s.deny(req)
367+ return
368+ }
369+ switch {
370+ case req.method == mUnknownObject:
371+ s.replyErr(req, errUnknownObject, "no such object")
372+ return
373+ case req.bad:
374+ s.replyErr(req, errInvalidArgs, "unexpected arguments")
375+ return
376+ }
377+ var sess *session
378+ var st *stream
379+ switch req.obj {
380+ case objSession:
381+ if sess = s.sessionByID(req.id); sess == nil {
382+ s.replyErr(req, errUnknownObject, "no such session")
383+ return
384+ }
385+ case objStream:
386+ if st = s.streamByID(req.id); st == nil {
387+ s.replyErr(req, errUnknownObject, "no such stream")
388+ return
389+ }
390+ sess = st.sess
391+ }
392+ if sess != nil && sess.owner != req.sender {
393+ s.deny(req)
394+ return
395+ }
396+ switch req.method {
397+ case mPing:
398+ s.reply(req)
399+ case mIntrospect:
400+ if e := s.replyTo(req, "s"); e != nil {
401+ e.Str("<node/>")
402+ s.send()
403+ }
404+ case mGet, mGetAll, mSet:
405+ s.properties(req, sess, st)
406+ case mCreateSession:
407+ s.createSession(req)
408+ case mStart:
409+ s.start(req, sess)
410+ case mStop:
411+ s.reply(req)
412+ s.closeSession(sess, true)
413+ case mRecordMonitor, mRecordWindow:
414+ s.record(req, sess)
415+ case mGetState:
416+ s.replyState(req)
417+ case mGetWindows:
418+ s.replyWindows(req)
419+ case mShot, mShotArea, mShotInteractive:
420+ s.screenshot(req)
421+ default:
422+ s.replyErr(req, errNotSupported, "not supported")
423+ }
424+}
425+
426+// deny refuses a call.
427+func (s *Service) deny(req *request) {
428+ s.replyErr(req, errAccessDenied, "caller not allowed to capture the screen")
429+}
A internal/adapters/screencast/bus_test.go +596 -0
@@ -0,0 +1,596 @@
1+package screencast
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/captureallow"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/zerobus"
12+)
13+
14+func testInventory() ports.CastInventory {
15+ return ports.CastInventory{
16+ Focused: "HEADLESS-1",
17+ Outputs: []ports.CastOutput{
18+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1},
19+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1},
20+ },
21+ Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}},
22+ }
23+}
24+
25+// ready starts the service, waits for its names and publishes the inventory.
26+func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) {
27+ t.Helper()
28+ h := newSvcHarness(t, o)
29+ c := h.client()
30+ for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} {
31+ h.waitName(c, n)
32+ }
33+ if !o.noPW {
34+ h.waitName(c, nameScreenCast)
35+ }
36+ h.svc.Inventory(testInventory())
37+ // The inventory is applied by the owner before it serves the next call
38+ // that needs it: a round trip through the service orders them.
39+ deadline := time.Now().Add(5 * time.Second)
40+ for time.Now().Before(deadline) {
41+ _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
42+ if m != nil && m.Signature == "a{ta{sv}}" {
43+ r := m.Body()
44+ if end := r.Array('{'); r.More(end) {
45+ return h, c
46+ }
47+ }
48+ time.Sleep(5 * time.Millisecond)
49+ }
50+ t.Fatal("inventory not applied")
51+ return h, c
52+}
53+
54+func createSession(t *testing.T, c *zerobus.Conn) string {
55+ t.Helper()
56+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
57+ a := e.BeginArray('{')
58+ e.EndArray(a)
59+ })
60+ if name != "" {
61+ t.Fatalf("CreateSession: %s", name)
62+ }
63+ return strings.Clone(m.Body().ObjectPath())
64+}
65+
66+func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) {
67+ name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
68+ e.Str(connector)
69+ a := e.BeginArray('{')
70+ e.Struct()
71+ e.Str("cursor-mode")
72+ e.Variant("u")
73+ e.Uint32(1)
74+ e.EndArray(a)
75+ })
76+ if name != "" {
77+ return name, ""
78+ }
79+ return "", strings.Clone(m.Body().ObjectPath())
80+}
81+
82+// Every method of the contract answers a caller on the allowlist.
83+func TestMethodMatrix(t *testing.T) {
84+ h, c := ready(t, harnessOpts{})
85+ _ = h
86+ sess := createSession(t, c)
87+ if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" {
88+ t.Fatalf("session path %q", sess)
89+ }
90+
91+ // Properties of the root object.
92+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") })
93+ if name != "" || m.Signature != "v" {
94+ t.Fatalf("Get Version: %s %v", name, m)
95+ }
96+ r := m.Body()
97+ if sig := r.Variant(); sig != "i" || r.Int32() != 4 {
98+ t.Fatal("Version is not 4")
99+ }
100+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty {
101+ t.Fatalf("unknown property: %q", name)
102+ }
103+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) {
104+ e.Str(ifaceScreenCast)
105+ e.Str("Version")
106+ e.Variant("i")
107+ e.Int32(1)
108+ }); name != errPropertyReadOnly {
109+ t.Fatalf("Set: %q", name)
110+ }
111+ name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) })
112+ if name != "" || m.Signature != "a{sv}" {
113+ t.Fatalf("GetAll: %s", name)
114+ }
115+
116+ // Streams: the monitor, the window, and what is not supported.
117+ stream, got := "", ""
118+ if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" {
119+ t.Fatalf("RecordMonitor: %s", name)
120+ }
121+ if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" {
122+ t.Fatalf("stream path %q", stream)
123+ }
124+ if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs {
125+ t.Fatalf("RecordMonitor of a missing monitor: %q", name)
126+ }
127+ name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
128+ a := e.BeginArray('{')
129+ e.Struct()
130+ e.Str("window-id")
131+ e.Variant("t")
132+ e.Uint64(7)
133+ e.EndArray(a)
134+ })
135+ if name != "" {
136+ t.Fatalf("RecordWindow: %s", name)
137+ }
138+ got = strings.Clone(m.Body().ObjectPath())
139+ if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" {
140+ t.Fatalf("window stream path %q", got)
141+ }
142+ name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
143+ a := e.BeginArray('{')
144+ e.Struct()
145+ e.Str("window-id")
146+ e.Variant("t")
147+ e.Uint64(99)
148+ e.EndArray(a)
149+ })
150+ if name != errInvalidArgs {
151+ t.Fatalf("RecordWindow of a closed window: %q", name)
152+ }
153+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) {
154+ e.Int32(0)
155+ e.Int32(0)
156+ e.Int32(1)
157+ e.Int32(1)
158+ e.EndArray(e.BeginArray('{'))
159+ }); name == "" {
160+ t.Fatal("RecordArea is not supported")
161+ }
162+
163+ // The stream answers GetAll with its parameters before Start.
164+ name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) })
165+ if name != "" {
166+ t.Fatalf("stream GetAll: %s", name)
167+ }
168+ r = m.Body()
169+ end := r.Array('{')
170+ r.Struct()
171+ if r.Str() != "Parameters" || r.Variant() != "a{sv}" {
172+ t.Fatal("no Parameters")
173+ }
174+ pend := r.Array('{')
175+ seen := map[string][2]int32{}
176+ for r.More(pend) {
177+ r.Struct()
178+ key := r.Str()
179+ if sig := r.Variant(); sig != "(ii)" {
180+ t.Fatalf("%s is %s", key, sig)
181+ }
182+ r.Struct()
183+ seen[key] = [2]int32{r.Int32(), r.Int32()}
184+ }
185+ _ = end
186+ if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} {
187+ t.Fatalf("parameters %v", seen)
188+ }
189+
190+ // Introspect and DisplayConfig properties.
191+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") })
192+ if name != "" {
193+ t.Fatalf("version: %s", name)
194+ }
195+ r = m.Body()
196+ if r.Variant() != "u" || r.Uint32() != 1 {
197+ t.Fatal("Introspect version is not 1")
198+ }
199+ if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" {
200+ t.Fatal("ApplyMonitorsConfig is not supported")
201+ }
202+ if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject {
203+ t.Fatalf("unknown session: %q", name)
204+ }
205+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
206+ t.Fatalf("Stop: %q", name)
207+ }
208+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject {
209+ t.Fatalf("Stop twice: %q", name)
210+ }
211+}
212+
213+// A signal from the session reaches only its owner: Stop is answered, then
214+// the owner hears Closed.
215+func TestStopSignalsClosedToOwner(t *testing.T) {
216+ h, c := ready(t, harnessOpts{})
217+ other := h.client()
218+ rule := "type='signal',interface='" + ifaceSession + "'"
219+ for _, x := range []*zerobus.Conn{c, other} {
220+ if err := x.AddMatch(rule); err != nil {
221+ t.Fatal(err)
222+ }
223+ }
224+ sess := createSession(t, c)
225+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
226+ t.Fatal(name)
227+ }
228+ m := readUntil(t, c, "Closed")
229+ if m.Path != sess || m.Type != zerobus.TypeSignal {
230+ t.Fatalf("closed %+v", m)
231+ }
232+ // The other client would have heard it before the answer of this call.
233+ other.Handle = func(m *zerobus.Message) {
234+ if m.Member == "Closed" {
235+ t.Error("Closed reached a client that is not the owner")
236+ }
237+ }
238+ other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
239+ if _, err := other.Call(); err != nil {
240+ t.Fatal(err)
241+ }
242+}
243+
244+func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message {
245+ t.Helper()
246+ for {
247+ m, err := c.ReadMessage()
248+ if err != nil {
249+ t.Fatal(err)
250+ }
251+ if m.Member == member {
252+ return m
253+ }
254+ }
255+}
256+
257+// A caller whose executable is not on the allowlist is refused by every
258+// object and every method, and nothing is created.
259+func TestDisallowedCallerGetsAccessDenied(t *testing.T) {
260+ h, c := ready(t, harnessOpts{})
261+ h.peers.exe.Store("/usr/bin/evil")
262+ for _, tc := range []struct {
263+ dest, path, iface, member, sig string
264+ body func(*zerobus.Encoder)
265+ }{
266+ {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }},
267+ {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }},
268+ {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }},
269+ {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil},
270+ {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil},
271+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil},
272+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }},
273+ {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil},
274+ } {
275+ if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied {
276+ t.Errorf("%s.%s: %q", tc.iface, tc.member, name)
277+ }
278+ }
279+ select {
280+ case cmd := <-h.cmds:
281+ t.Fatalf("command sent for a refused caller: %+v", cmd)
282+ default:
283+ }
284+}
285+
286+// A policy that closes is applied at once: the cache keeps the executable,
287+// not the verdict.
288+func TestPolicyChangeApplies(t *testing.T) {
289+ h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()})
290+ c := h.client()
291+ h.waitName(c, nameIntrospect)
292+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
293+ t.Fatalf("closed policy: %q", name)
294+ }
295+}
296+
297+// The executable is read again where it matters: an exec after the first
298+// check is caught at CreateSession.
299+func TestExecAfterFirstCheckIsCaught(t *testing.T) {
300+ h, c := ready(t, harnessOpts{})
301+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
302+ t.Fatalf("first call: %q", name)
303+ }
304+ h.peers.exe.Store("/usr/bin/evil")
305+ // Not a privileged entry point: the cached executable still serves it.
306+ if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" {
307+ t.Fatalf("cached call: %q", name)
308+ }
309+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
310+ if name != errAccessDenied {
311+ t.Fatalf("CreateSession after exec: %q", name)
312+ }
313+}
314+
315+// A pidfd that names another process than the one the bus named is refused.
316+func TestPidMismatchIsRefused(t *testing.T) {
317+ h := newSvcHarness(t, harnessOpts{})
318+ h.peers.exePid = func(open int) int { return open + 1 }
319+ c := h.client()
320+ h.waitName(c, nameIntrospect)
321+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
322+ t.Fatalf("pid mismatch: %q", name)
323+ }
324+}
325+
326+func TestPidfdOpenFailureIsRefused(t *testing.T) {
327+ h := newSvcHarness(t, harnessOpts{})
328+ h.peers.openErr = errors.New("no such process")
329+ c := h.client()
330+ h.waitName(c, nameIntrospect)
331+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
332+ t.Fatalf("pidfd_open failure: %q", name)
333+ }
334+}
335+
336+// The session and its streams answer only the unique name that created it.
337+func TestNonOwnerIsDenied(t *testing.T) {
338+ h, c := ready(t, harnessOpts{})
339+ other := h.client()
340+ sess := createSession(t, c)
341+ _, stream := recordMonitor(c, sess, "HEADLESS-1")
342+ for _, tc := range []struct{ path, iface, member, sig string }{
343+ {sess, ifaceSession, "Stop", ""},
344+ {sess, ifaceSession, "Start", ""},
345+ {sess, ifaceProps, "GetAll", "s"},
346+ {stream, ifaceProps, "GetAll", "s"},
347+ {stream, ifaceProps, "Get", "ss"},
348+ } {
349+ name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) {
350+ switch tc.sig {
351+ case "s":
352+ e.Str(ifaceSession)
353+ case "ss":
354+ e.Str(ifaceStream)
355+ e.Str("Parameters")
356+ }
357+ })
358+ if name != errAccessDenied {
359+ t.Errorf("%s %s: %q", tc.path, tc.member, name)
360+ }
361+ }
362+ if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied {
363+ t.Errorf("RecordMonitor by a non-owner: %q", name)
364+ }
365+ // The owner is unaffected.
366+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
367+ t.Fatalf("owner Stop: %q", name)
368+ }
369+}
370+
371+// Requests wait in a fixed table while their sender is checked: when it is
372+// full, the next one gets LimitsExceeded, and the parked ones are answered
373+// once the check ends.
374+func TestPendingTableFull(t *testing.T) {
375+ h := newSvcHarness(t, harnessOpts{holdOp: true})
376+ c := h.client()
377+ h.waitName(c, nameIntrospect)
378+ serials := make([]uint32, 0, maxPending+1)
379+ for range maxPending + 1 {
380+ c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "")
381+ serial, err := c.Send()
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ serials = append(serials, serial)
386+ }
387+ m, err := c.ReadMessage()
388+ if err != nil {
389+ t.Fatal(err)
390+ }
391+ if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits {
392+ t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial)
393+ }
394+ close(h.peers.openGo)
395+ for range maxPending {
396+ m, err := c.ReadMessage()
397+ if err != nil {
398+ t.Fatal(err)
399+ }
400+ if m.Type != zerobus.TypeMethodReturn {
401+ t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName)
402+ }
403+ }
404+}
405+
406+// Closing the owner's connection stops its session: its slot is freed, so
407+// a new session starts again from the table.
408+func TestOwnerVanishFreesSessions(t *testing.T) {
409+ h, c := ready(t, harnessOpts{})
410+ owner := h.client()
411+ for range maxSessions {
412+ createSession(t, owner)
413+ }
414+ if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits {
415+ t.Fatalf("over the limit: %q", name)
416+ }
417+ _ = owner.Close()
418+ deadline := time.Now().Add(5 * time.Second)
419+ for {
420+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
421+ if name == "" {
422+ return
423+ }
424+ if time.Now().After(deadline) {
425+ t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String())
426+ }
427+ time.Sleep(10 * time.Millisecond)
428+ }
429+}
430+
431+// The remote desktop key is refused, and a service without PipeWire does not
432+// take the ScreenCast name.
433+func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) {
434+ _, c := ready(t, harnessOpts{})
435+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
436+ a := e.BeginArray('{')
437+ e.Struct()
438+ e.Str("remote-desktop-session-id")
439+ e.Variant("s")
440+ e.Str("x")
441+ e.EndArray(a)
442+ })
443+ if name != errNotSupported {
444+ t.Fatalf("remote desktop: %q", name)
445+ }
446+
447+ h := newSvcHarness(t, harnessOpts{noPW: true})
448+ c2 := h.client()
449+ h.waitName(c2, nameIntrospect)
450+ e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
451+ e.Str(nameScreenCast)
452+ m, err := c2.Call()
453+ if err != nil || m.Body().Bool() {
454+ t.Fatalf("ScreenCast name taken without PipeWire (%v)", err)
455+ }
456+}
457+
458+// The encoding of GetCurrentState and GetWindows from an inventory.
459+func TestDisplayConfigAndWindows(t *testing.T) {
460+ _, c := ready(t, harnessOpts{})
461+ name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil)
462+ if name != "" {
463+ t.Fatal(name)
464+ }
465+ r := m.Body()
466+ if r.Uint32() != 0 {
467+ t.Fatal("serial")
468+ }
469+ type mon struct {
470+ connector string
471+ current int
472+ modes int
473+ display string
474+ builtin bool
475+ }
476+ var mons []mon
477+ end := r.Array('(')
478+ for r.More(end) {
479+ r.Struct()
480+ r.Struct()
481+ x := mon{connector: r.Str()}
482+ r.Str()
483+ r.Str()
484+ r.Str()
485+ mend := r.Array('(')
486+ for r.More(mend) {
487+ r.Struct()
488+ r.Str()
489+ r.Int32()
490+ r.Int32()
491+ r.Float64()
492+ r.Float64()
493+ send := r.Array('d')
494+ for r.More(send) {
495+ r.Float64()
496+ }
497+ pend := r.Array('{')
498+ x.modes++
499+ for r.More(pend) {
500+ r.Struct()
501+ key := r.Str()
502+ sig := r.Variant()
503+ if key == "is-current" && r.Bool() {
504+ x.current++
505+ } else if sig == "b" {
506+ r.Bool()
507+ }
508+ }
509+ }
510+ pend := r.Array('{')
511+ for r.More(pend) {
512+ r.Struct()
513+ key := r.Str()
514+ switch r.Variant() {
515+ case "s":
516+ if v := r.Str(); key == "display-name" {
517+ x.display = v
518+ }
519+ case "b":
520+ x.builtin = r.Bool()
521+ }
522+ }
523+ mons = append(mons, x)
524+ }
525+ logicals := 0
526+ lend := r.Array('(')
527+ for r.More(lend) {
528+ r.Struct()
529+ r.Int32()
530+ r.Int32()
531+ r.Float64()
532+ r.Uint32()
533+ r.Bool()
534+ mend := r.Array('(')
535+ for r.More(mend) {
536+ r.Struct()
537+ r.Str()
538+ r.Str()
539+ r.Str()
540+ r.Str()
541+ }
542+ pend := r.Array('{')
543+ r.SkipTo(pend)
544+ logicals++
545+ }
546+ pend := r.Array('{')
547+ r.Struct()
548+ if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 {
549+ t.Fatal("layout-mode")
550+ }
551+ _ = pend
552+ if !r.Done() {
553+ t.Fatalf("trailing data: %v", r.Err())
554+ }
555+ if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" {
556+ t.Fatalf("monitors %+v logical %d", mons, logicals)
557+ }
558+ for _, x := range mons {
559+ if x.current != 1 {
560+ t.Errorf("%s has %d current modes", x.connector, x.current)
561+ }
562+ }
563+ if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" {
564+ t.Fatalf("monitors %+v", mons)
565+ }
566+
567+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
568+ if name != "" {
569+ t.Fatal(name)
570+ }
571+ r = m.Body()
572+ got := map[uint64][2]string{}
573+ wend := r.Array('{')
574+ for r.More(wend) {
575+ r.Struct()
576+ id := r.Uint64()
577+ var v [2]string
578+ pend := r.Array('{')
579+ for r.More(pend) {
580+ r.Struct()
581+ key := r.Str()
582+ r.Variant()
583+ val := r.Str()
584+ switch key {
585+ case "app-id":
586+ v[0] = val
587+ case "title":
588+ v[1] = val
589+ }
590+ }
591+ got[id] = v
592+ }
593+ if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} {
594+ t.Fatalf("windows %v", got)
595+ }
596+}
@@ -0,0 +1,596 @@
1+package screencast
2+
3+import (
4+ "errors"
5+ "strings"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/neferwl/internal/adapters/captureallow"
10+ "github.com/bnema/neferwl/internal/ports"
11+ "github.com/bnema/zerobus"
12+)
13+
14+func testInventory() ports.CastInventory {
15+ return ports.CastInventory{
16+ Focused: "HEADLESS-1",
17+ Outputs: []ports.CastOutput{
18+ {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1},
19+ {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1},
20+ },
21+ Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}},
22+ }
23+}
24+
25+// ready starts the service, waits for its names and publishes the inventory.
26+func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) {
27+ t.Helper()
28+ h := newSvcHarness(t, o)
29+ c := h.client()
30+ for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} {
31+ h.waitName(c, n)
32+ }
33+ if !o.noPW {
34+ h.waitName(c, nameScreenCast)
35+ }
36+ h.svc.Inventory(testInventory())
37+ // The inventory is applied by the owner before it serves the next call
38+ // that needs it: a round trip through the service orders them.
39+ deadline := time.Now().Add(5 * time.Second)
40+ for time.Now().Before(deadline) {
41+ _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
42+ if m != nil && m.Signature == "a{ta{sv}}" {
43+ r := m.Body()
44+ if end := r.Array('{'); r.More(end) {
45+ return h, c
46+ }
47+ }
48+ time.Sleep(5 * time.Millisecond)
49+ }
50+ t.Fatal("inventory not applied")
51+ return h, c
52+}
53+
54+func createSession(t *testing.T, c *zerobus.Conn) string {
55+ t.Helper()
56+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
57+ a := e.BeginArray('{')
58+ e.EndArray(a)
59+ })
60+ if name != "" {
61+ t.Fatalf("CreateSession: %s", name)
62+ }
63+ return strings.Clone(m.Body().ObjectPath())
64+}
65+
66+func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) {
67+ name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
68+ e.Str(connector)
69+ a := e.BeginArray('{')
70+ e.Struct()
71+ e.Str("cursor-mode")
72+ e.Variant("u")
73+ e.Uint32(1)
74+ e.EndArray(a)
75+ })
76+ if name != "" {
77+ return name, ""
78+ }
79+ return "", strings.Clone(m.Body().ObjectPath())
80+}
81+
82+// Every method of the contract answers a caller on the allowlist.
83+func TestMethodMatrix(t *testing.T) {
84+ h, c := ready(t, harnessOpts{})
85+ _ = h
86+ sess := createSession(t, c)
87+ if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" {
88+ t.Fatalf("session path %q", sess)
89+ }
90+
91+ // Properties of the root object.
92+ name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") })
93+ if name != "" || m.Signature != "v" {
94+ t.Fatalf("Get Version: %s %v", name, m)
95+ }
96+ r := m.Body()
97+ if sig := r.Variant(); sig != "i" || r.Int32() != 4 {
98+ t.Fatal("Version is not 4")
99+ }
100+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty {
101+ t.Fatalf("unknown property: %q", name)
102+ }
103+ if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) {
104+ e.Str(ifaceScreenCast)
105+ e.Str("Version")
106+ e.Variant("i")
107+ e.Int32(1)
108+ }); name != errPropertyReadOnly {
109+ t.Fatalf("Set: %q", name)
110+ }
111+ name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) })
112+ if name != "" || m.Signature != "a{sv}" {
113+ t.Fatalf("GetAll: %s", name)
114+ }
115+
116+ // Streams: the monitor, the window, and what is not supported.
117+ stream, got := "", ""
118+ if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" {
119+ t.Fatalf("RecordMonitor: %s", name)
120+ }
121+ if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" {
122+ t.Fatalf("stream path %q", stream)
123+ }
124+ if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs {
125+ t.Fatalf("RecordMonitor of a missing monitor: %q", name)
126+ }
127+ name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
128+ a := e.BeginArray('{')
129+ e.Struct()
130+ e.Str("window-id")
131+ e.Variant("t")
132+ e.Uint64(7)
133+ e.EndArray(a)
134+ })
135+ if name != "" {
136+ t.Fatalf("RecordWindow: %s", name)
137+ }
138+ got = strings.Clone(m.Body().ObjectPath())
139+ if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" {
140+ t.Fatalf("window stream path %q", got)
141+ }
142+ name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
143+ a := e.BeginArray('{')
144+ e.Struct()
145+ e.Str("window-id")
146+ e.Variant("t")
147+ e.Uint64(99)
148+ e.EndArray(a)
149+ })
150+ if name != errInvalidArgs {
151+ t.Fatalf("RecordWindow of a closed window: %q", name)
152+ }
153+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) {
154+ e.Int32(0)
155+ e.Int32(0)
156+ e.Int32(1)
157+ e.Int32(1)
158+ e.EndArray(e.BeginArray('{'))
159+ }); name == "" {
160+ t.Fatal("RecordArea is not supported")
161+ }
162+
163+ // The stream answers GetAll with its parameters before Start.
164+ name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) })
165+ if name != "" {
166+ t.Fatalf("stream GetAll: %s", name)
167+ }
168+ r = m.Body()
169+ end := r.Array('{')
170+ r.Struct()
171+ if r.Str() != "Parameters" || r.Variant() != "a{sv}" {
172+ t.Fatal("no Parameters")
173+ }
174+ pend := r.Array('{')
175+ seen := map[string][2]int32{}
176+ for r.More(pend) {
177+ r.Struct()
178+ key := r.Str()
179+ if sig := r.Variant(); sig != "(ii)" {
180+ t.Fatalf("%s is %s", key, sig)
181+ }
182+ r.Struct()
183+ seen[key] = [2]int32{r.Int32(), r.Int32()}
184+ }
185+ _ = end
186+ if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} {
187+ t.Fatalf("parameters %v", seen)
188+ }
189+
190+ // Introspect and DisplayConfig properties.
191+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") })
192+ if name != "" {
193+ t.Fatalf("version: %s", name)
194+ }
195+ r = m.Body()
196+ if r.Variant() != "u" || r.Uint32() != 1 {
197+ t.Fatal("Introspect version is not 1")
198+ }
199+ if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" {
200+ t.Fatal("ApplyMonitorsConfig is not supported")
201+ }
202+ if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject {
203+ t.Fatalf("unknown session: %q", name)
204+ }
205+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
206+ t.Fatalf("Stop: %q", name)
207+ }
208+ if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject {
209+ t.Fatalf("Stop twice: %q", name)
210+ }
211+}
212+
213+// A signal from the session reaches only its owner: Stop is answered, then
214+// the owner hears Closed.
215+func TestStopSignalsClosedToOwner(t *testing.T) {
216+ h, c := ready(t, harnessOpts{})
217+ other := h.client()
218+ rule := "type='signal',interface='" + ifaceSession + "'"
219+ for _, x := range []*zerobus.Conn{c, other} {
220+ if err := x.AddMatch(rule); err != nil {
221+ t.Fatal(err)
222+ }
223+ }
224+ sess := createSession(t, c)
225+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
226+ t.Fatal(name)
227+ }
228+ m := readUntil(t, c, "Closed")
229+ if m.Path != sess || m.Type != zerobus.TypeSignal {
230+ t.Fatalf("closed %+v", m)
231+ }
232+ // The other client would have heard it before the answer of this call.
233+ other.Handle = func(m *zerobus.Message) {
234+ if m.Member == "Closed" {
235+ t.Error("Closed reached a client that is not the owner")
236+ }
237+ }
238+ other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "")
239+ if _, err := other.Call(); err != nil {
240+ t.Fatal(err)
241+ }
242+}
243+
244+func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message {
245+ t.Helper()
246+ for {
247+ m, err := c.ReadMessage()
248+ if err != nil {
249+ t.Fatal(err)
250+ }
251+ if m.Member == member {
252+ return m
253+ }
254+ }
255+}
256+
257+// A caller whose executable is not on the allowlist is refused by every
258+// object and every method, and nothing is created.
259+func TestDisallowedCallerGetsAccessDenied(t *testing.T) {
260+ h, c := ready(t, harnessOpts{})
261+ h.peers.exe.Store("/usr/bin/evil")
262+ for _, tc := range []struct {
263+ dest, path, iface, member, sig string
264+ body func(*zerobus.Encoder)
265+ }{
266+ {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }},
267+ {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }},
268+ {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }},
269+ {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil},
270+ {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil},
271+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil},
272+ {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }},
273+ {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil},
274+ } {
275+ if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied {
276+ t.Errorf("%s.%s: %q", tc.iface, tc.member, name)
277+ }
278+ }
279+ select {
280+ case cmd := <-h.cmds:
281+ t.Fatalf("command sent for a refused caller: %+v", cmd)
282+ default:
283+ }
284+}
285+
286+// A policy that closes is applied at once: the cache keeps the executable,
287+// not the verdict.
288+func TestPolicyChangeApplies(t *testing.T) {
289+ h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()})
290+ c := h.client()
291+ h.waitName(c, nameIntrospect)
292+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
293+ t.Fatalf("closed policy: %q", name)
294+ }
295+}
296+
297+// The executable is read again where it matters: an exec after the first
298+// check is caught at CreateSession.
299+func TestExecAfterFirstCheckIsCaught(t *testing.T) {
300+ h, c := ready(t, harnessOpts{})
301+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" {
302+ t.Fatalf("first call: %q", name)
303+ }
304+ h.peers.exe.Store("/usr/bin/evil")
305+ // Not a privileged entry point: the cached executable still serves it.
306+ if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" {
307+ t.Fatalf("cached call: %q", name)
308+ }
309+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
310+ if name != errAccessDenied {
311+ t.Fatalf("CreateSession after exec: %q", name)
312+ }
313+}
314+
315+// A pidfd that names another process than the one the bus named is refused.
316+func TestPidMismatchIsRefused(t *testing.T) {
317+ h := newSvcHarness(t, harnessOpts{})
318+ h.peers.exePid = func(open int) int { return open + 1 }
319+ c := h.client()
320+ h.waitName(c, nameIntrospect)
321+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
322+ t.Fatalf("pid mismatch: %q", name)
323+ }
324+}
325+
326+func TestPidfdOpenFailureIsRefused(t *testing.T) {
327+ h := newSvcHarness(t, harnessOpts{})
328+ h.peers.openErr = errors.New("no such process")
329+ c := h.client()
330+ h.waitName(c, nameIntrospect)
331+ if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied {
332+ t.Fatalf("pidfd_open failure: %q", name)
333+ }
334+}
335+
336+// The session and its streams answer only the unique name that created it.
337+func TestNonOwnerIsDenied(t *testing.T) {
338+ h, c := ready(t, harnessOpts{})
339+ other := h.client()
340+ sess := createSession(t, c)
341+ _, stream := recordMonitor(c, sess, "HEADLESS-1")
342+ for _, tc := range []struct{ path, iface, member, sig string }{
343+ {sess, ifaceSession, "Stop", ""},
344+ {sess, ifaceSession, "Start", ""},
345+ {sess, ifaceProps, "GetAll", "s"},
346+ {stream, ifaceProps, "GetAll", "s"},
347+ {stream, ifaceProps, "Get", "ss"},
348+ } {
349+ name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) {
350+ switch tc.sig {
351+ case "s":
352+ e.Str(ifaceSession)
353+ case "ss":
354+ e.Str(ifaceStream)
355+ e.Str("Parameters")
356+ }
357+ })
358+ if name != errAccessDenied {
359+ t.Errorf("%s %s: %q", tc.path, tc.member, name)
360+ }
361+ }
362+ if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied {
363+ t.Errorf("RecordMonitor by a non-owner: %q", name)
364+ }
365+ // The owner is unaffected.
366+ if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" {
367+ t.Fatalf("owner Stop: %q", name)
368+ }
369+}
370+
371+// Requests wait in a fixed table while their sender is checked: when it is
372+// full, the next one gets LimitsExceeded, and the parked ones are answered
373+// once the check ends.
374+func TestPendingTableFull(t *testing.T) {
375+ h := newSvcHarness(t, harnessOpts{holdOp: true})
376+ c := h.client()
377+ h.waitName(c, nameIntrospect)
378+ serials := make([]uint32, 0, maxPending+1)
379+ for range maxPending + 1 {
380+ c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "")
381+ serial, err := c.Send()
382+ if err != nil {
383+ t.Fatal(err)
384+ }
385+ serials = append(serials, serial)
386+ }
387+ m, err := c.ReadMessage()
388+ if err != nil {
389+ t.Fatal(err)
390+ }
391+ if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits {
392+ t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial)
393+ }
394+ close(h.peers.openGo)
395+ for range maxPending {
396+ m, err := c.ReadMessage()
397+ if err != nil {
398+ t.Fatal(err)
399+ }
400+ if m.Type != zerobus.TypeMethodReturn {
401+ t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName)
402+ }
403+ }
404+}
405+
406+// Closing the owner's connection stops its session: its slot is freed, so
407+// a new session starts again from the table.
408+func TestOwnerVanishFreesSessions(t *testing.T) {
409+ h, c := ready(t, harnessOpts{})
410+ owner := h.client()
411+ for range maxSessions {
412+ createSession(t, owner)
413+ }
414+ if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits {
415+ t.Fatalf("over the limit: %q", name)
416+ }
417+ _ = owner.Close()
418+ deadline := time.Now().Add(5 * time.Second)
419+ for {
420+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) })
421+ if name == "" {
422+ return
423+ }
424+ if time.Now().After(deadline) {
425+ t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String())
426+ }
427+ time.Sleep(10 * time.Millisecond)
428+ }
429+}
430+
431+// The remote desktop key is refused, and a service without PipeWire does not
432+// take the ScreenCast name.
433+func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) {
434+ _, c := ready(t, harnessOpts{})
435+ name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) {
436+ a := e.BeginArray('{')
437+ e.Struct()
438+ e.Str("remote-desktop-session-id")
439+ e.Variant("s")
440+ e.Str("x")
441+ e.EndArray(a)
442+ })
443+ if name != errNotSupported {
444+ t.Fatalf("remote desktop: %q", name)
445+ }
446+
447+ h := newSvcHarness(t, harnessOpts{noPW: true})
448+ c2 := h.client()
449+ h.waitName(c2, nameIntrospect)
450+ e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
451+ e.Str(nameScreenCast)
452+ m, err := c2.Call()
453+ if err != nil || m.Body().Bool() {
454+ t.Fatalf("ScreenCast name taken without PipeWire (%v)", err)
455+ }
456+}
457+
458+// The encoding of GetCurrentState and GetWindows from an inventory.
459+func TestDisplayConfigAndWindows(t *testing.T) {
460+ _, c := ready(t, harnessOpts{})
461+ name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil)
462+ if name != "" {
463+ t.Fatal(name)
464+ }
465+ r := m.Body()
466+ if r.Uint32() != 0 {
467+ t.Fatal("serial")
468+ }
469+ type mon struct {
470+ connector string
471+ current int
472+ modes int
473+ display string
474+ builtin bool
475+ }
476+ var mons []mon
477+ end := r.Array('(')
478+ for r.More(end) {
479+ r.Struct()
480+ r.Struct()
481+ x := mon{connector: r.Str()}
482+ r.Str()
483+ r.Str()
484+ r.Str()
485+ mend := r.Array('(')
486+ for r.More(mend) {
487+ r.Struct()
488+ r.Str()
489+ r.Int32()
490+ r.Int32()
491+ r.Float64()
492+ r.Float64()
493+ send := r.Array('d')
494+ for r.More(send) {
495+ r.Float64()
496+ }
497+ pend := r.Array('{')
498+ x.modes++
499+ for r.More(pend) {
500+ r.Struct()
501+ key := r.Str()
502+ sig := r.Variant()
503+ if key == "is-current" && r.Bool() {
504+ x.current++
505+ } else if sig == "b" {
506+ r.Bool()
507+ }
508+ }
509+ }
510+ pend := r.Array('{')
511+ for r.More(pend) {
512+ r.Struct()
513+ key := r.Str()
514+ switch r.Variant() {
515+ case "s":
516+ if v := r.Str(); key == "display-name" {
517+ x.display = v
518+ }
519+ case "b":
520+ x.builtin = r.Bool()
521+ }
522+ }
523+ mons = append(mons, x)
524+ }
525+ logicals := 0
526+ lend := r.Array('(')
527+ for r.More(lend) {
528+ r.Struct()
529+ r.Int32()
530+ r.Int32()
531+ r.Float64()
532+ r.Uint32()
533+ r.Bool()
534+ mend := r.Array('(')
535+ for r.More(mend) {
536+ r.Struct()
537+ r.Str()
538+ r.Str()
539+ r.Str()
540+ r.Str()
541+ }
542+ pend := r.Array('{')
543+ r.SkipTo(pend)
544+ logicals++
545+ }
546+ pend := r.Array('{')
547+ r.Struct()
548+ if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 {
549+ t.Fatal("layout-mode")
550+ }
551+ _ = pend
552+ if !r.Done() {
553+ t.Fatalf("trailing data: %v", r.Err())
554+ }
555+ if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" {
556+ t.Fatalf("monitors %+v logical %d", mons, logicals)
557+ }
558+ for _, x := range mons {
559+ if x.current != 1 {
560+ t.Errorf("%s has %d current modes", x.connector, x.current)
561+ }
562+ }
563+ if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" {
564+ t.Fatalf("monitors %+v", mons)
565+ }
566+
567+ name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil)
568+ if name != "" {
569+ t.Fatal(name)
570+ }
571+ r = m.Body()
572+ got := map[uint64][2]string{}
573+ wend := r.Array('{')
574+ for r.More(wend) {
575+ r.Struct()
576+ id := r.Uint64()
577+ var v [2]string
578+ pend := r.Array('{')
579+ for r.More(pend) {
580+ r.Struct()
581+ key := r.Str()
582+ r.Variant()
583+ val := r.Str()
584+ switch key {
585+ case "app-id":
586+ v[0] = val
587+ case "title":
588+ v[1] = val
589+ }
590+ }
591+ got[id] = v
592+ }
593+ if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} {
594+ t.Fatalf("windows %v", got)
595+ }
596+}
M internal/adapters/screencast/caller.go +203 -2
@@ -1,6 +1,24 @@
11 package screencast
22
3-import "os"
3+import (
4+ "os"
5+
6+ "github.com/bnema/zerobus"
7+)
8+
9+// The caller check proves which executable a D-Bus peer runs, with the pidfd
10+// proof the wayland adapter uses for its clients (package peerexe):
11+//
12+// 1. GetConnectionUnixProcessID(name) names a pid;
13+// 2. pidfd_open(pid) pins that process;
14+// 3. a second GetConnectionUnixProcessID must name the same pid: the name
15+// was connected throughout, so the pidfd names the peer and not a process
16+// that recycled the pid;
17+// 4. the exe is read through the pidfd.
18+//
19+// The bus calls are sent with Send and their replies come back through
20+// ReadMessage, matched by serial: nothing here waits. A request that arrives
21+// while its sender is being checked waits in the pending table.
422
523 // callerState is how far the proof of a D-Bus peer's executable got.
624 type callerState uint8
@@ -10,7 +28,7 @@ const (
1028 // callerPid: waiting for the bus to name the peer's pid.
1129 callerPid
1230 // callerConfirm: a pidfd was opened; waiting for the bus to name the same
13- // pid again, which proves the pidfd names the peer.
31+ // pid again.
1432 callerConfirm
1533 // callerKnown: exe is the executable of the peer.
1634 callerKnown
@@ -40,3 +58,186 @@ type pending struct {
4058 used bool
4159 req request
4260 }
61+
62+// callerOf returns the cache entry of a unique name.
63+func (s *Service) callerOf(name string) *caller {
64+ for i := range s.callers {
65+ if s.callers[i].state != callerFree && s.callers[i].name == name {
66+ return &s.callers[i]
67+ }
68+ }
69+ return nil
70+}
71+
72+// admit takes a decoded call: a checked sender is served at once, any other
73+// waits for its check.
74+func (s *Service) admit(req *request) {
75+ c := s.callerOf(req.sender)
76+ if c != nil && c.state == callerKnown {
77+ s.dispatch(c, req)
78+ return
79+ }
80+ slot := -1
81+ for i := range s.pending {
82+ if !s.pending[i].used {
83+ slot = i
84+ break
85+ }
86+ }
87+ if slot < 0 {
88+ s.replyErr(req, errLimits, "too many requests wait for a caller check")
89+ return
90+ }
91+ if c == nil {
92+ for i := range s.callers {
93+ if s.callers[i].state == callerFree {
94+ c = &s.callers[i]
95+ break
96+ }
97+ }
98+ if c == nil {
99+ s.replyErr(req, errLimits, "too many callers")
100+ return
101+ }
102+ *c = caller{state: callerPid, name: req.sender}
103+ if !s.askPid(c) {
104+ c.release()
105+ s.deny(req)
106+ return
107+ }
108+ }
109+ s.pending[slot] = pending{used: true, req: *req}
110+}
111+
112+// askPid sends GetConnectionUnixProcessID for c and keeps its serial.
113+func (s *Service) askPid(c *caller) bool {
114+ e, serial := s.conn.NewMethodCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s")
115+ e.Str(c.name)
116+ if _, err := s.conn.Send(); err != nil {
117+ s.fatal = err
118+ return false
119+ }
120+ c.serial = serial
121+ return true
122+}
123+
124+// onBusReply advances the check of the caller the reply answers; any other
125+// reply of the bus is not ours.
126+func (s *Service) onBusReply(m *zerobus.Message) {
127+ var c *caller
128+ for i := range s.callers {
129+ cc := &s.callers[i]
130+ if (cc.state == callerPid || cc.state == callerConfirm) && cc.serial == m.ReplySerial {
131+ c = cc
132+ break
133+ }
134+ }
135+ if c == nil {
136+ return
137+ }
138+ pid := -1
139+ if m.Type == zerobus.TypeMethodReturn && m.Signature == "u" {
140+ r := m.Body()
141+ v := r.Uint32()
142+ if r.Err() == nil && v > 0 && v < 1<<31 {
143+ pid = int(v)
144+ }
145+ }
146+ switch {
147+ case pid < 0:
148+ s.failCaller(c, "pid lookup failed")
149+ case c.state == callerPid:
150+ pidfd, err := s.peers.Open(pid)
151+ if err != nil {
152+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: no pidfd")
153+ s.failCaller(c, "")
154+ return
155+ }
156+ c.pid, c.pidfd, c.state = pid, pidfd, callerConfirm
157+ if !s.askPid(c) {
158+ s.failCaller(c, "")
159+ }
160+ default:
161+ if pid != c.pid {
162+ s.log.Info().Str("sender", c.name).Msg("caller refused: pid changed during the check")
163+ s.failCaller(c, "")
164+ return
165+ }
166+ exe, got, err := s.peers.Exe(c.pidfd)
167+ if err != nil || got != c.pid {
168+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
169+ s.failCaller(c, "")
170+ return
171+ }
172+ c.exe, c.state = exe, callerKnown
173+ s.flush(c)
174+ }
175+}
176+
177+// failCaller refuses every request that waits for c and forgets it.
178+func (s *Service) failCaller(c *caller, why string) {
179+ if why != "" {
180+ s.log.Info().Str("sender", c.name).Msg("caller refused: " + why)
181+ }
182+ name := c.name
183+ c.release()
184+ for i := range s.pending {
185+ if p := &s.pending[i]; p.used && p.req.sender == name {
186+ req := p.req
187+ *p = pending{}
188+ s.deny(&req)
189+ }
190+ }
191+}
192+
193+// flush serves the requests that waited for c, in arrival order.
194+func (s *Service) flush(c *caller) {
195+ for i := range s.pending {
196+ p := &s.pending[i]
197+ if !p.used || p.req.sender != c.name {
198+ continue
199+ }
200+ req := p.req
201+ *p = pending{}
202+ s.dispatch(c, &req)
203+ }
204+}
205+
206+// onGone forgets a peer that left the bus: its check, its parked requests
207+// and its sessions.
208+func (s *Service) onGone(name string) {
209+ if c := s.callerOf(name); c != nil {
210+ c.release()
211+ }
212+ for i := range s.pending {
213+ if s.pending[i].used && s.pending[i].req.sender == name {
214+ s.pending[i] = pending{}
215+ }
216+ }
217+ for i := range s.sessions {
218+ if sess := &s.sessions[i]; sess.used && sess.owner == name {
219+ s.closeSession(sess, false)
220+ }
221+ }
222+}
223+
224+// allowed reports whether the allowlist in force admits exe.
225+func (s *Service) allowed(exe string) bool {
226+ p := s.policy()
227+ return p != nil && p.Allows(exe)
228+}
229+
230+// recheck reads the executable behind the pidfd again, so an exec after the
231+// first check is caught, and applies the allowlist to it.
232+func (s *Service) recheck(c *caller) bool {
233+ exe, pid, err := s.peers.Exe(c.pidfd)
234+ if err != nil || pid != c.pid {
235+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
236+ return false
237+ }
238+ if exe != c.exe {
239+ s.log.Info().Str("sender", c.name).Str("was", c.exe).Str("now", exe).Msg("caller executable changed")
240+ c.exe = exe
241+ }
242+ return s.allowed(exe)
243+}
@@ -1,6 +1,24 @@
1 package screencast1 package screencast
2 2
3-import "os"3+import (
4+ "os"
5+
6+ "github.com/bnema/zerobus"
7+)
8+
9+// The caller check proves which executable a D-Bus peer runs, with the pidfd
10+// proof the wayland adapter uses for its clients (package peerexe):
11+//
12+// 1. GetConnectionUnixProcessID(name) names a pid;
13+// 2. pidfd_open(pid) pins that process;
14+// 3. a second GetConnectionUnixProcessID must name the same pid: the name
15+// was connected throughout, so the pidfd names the peer and not a process
16+// that recycled the pid;
17+// 4. the exe is read through the pidfd.
18+//
19+// The bus calls are sent with Send and their replies come back through
20+// ReadMessage, matched by serial: nothing here waits. A request that arrives
21+// while its sender is being checked waits in the pending table.
4 22
5 // callerState is how far the proof of a D-Bus peer's executable got.23 // callerState is how far the proof of a D-Bus peer's executable got.
6 type callerState uint824 type callerState uint8
@@ -10,7 +28,7 @@ const (
10 // callerPid: waiting for the bus to name the peer's pid.28 // callerPid: waiting for the bus to name the peer's pid.
11 callerPid29 callerPid
12 // callerConfirm: a pidfd was opened; waiting for the bus to name the same30 // callerConfirm: a pidfd was opened; waiting for the bus to name the same
13- // pid again, which proves the pidfd names the peer.31+ // pid again.
14 callerConfirm32 callerConfirm
15 // callerKnown: exe is the executable of the peer.33 // callerKnown: exe is the executable of the peer.
16 callerKnown34 callerKnown
@@ -40,3 +58,186 @@ type pending struct {
40 used bool58 used bool
41 req request59 req request
42 }60 }
61+
62+// callerOf returns the cache entry of a unique name.
63+func (s *Service) callerOf(name string) *caller {
64+ for i := range s.callers {
65+ if s.callers[i].state != callerFree && s.callers[i].name == name {
66+ return &s.callers[i]
67+ }
68+ }
69+ return nil
70+}
71+
72+// admit takes a decoded call: a checked sender is served at once, any other
73+// waits for its check.
74+func (s *Service) admit(req *request) {
75+ c := s.callerOf(req.sender)
76+ if c != nil && c.state == callerKnown {
77+ s.dispatch(c, req)
78+ return
79+ }
80+ slot := -1
81+ for i := range s.pending {
82+ if !s.pending[i].used {
83+ slot = i
84+ break
85+ }
86+ }
87+ if slot < 0 {
88+ s.replyErr(req, errLimits, "too many requests wait for a caller check")
89+ return
90+ }
91+ if c == nil {
92+ for i := range s.callers {
93+ if s.callers[i].state == callerFree {
94+ c = &s.callers[i]
95+ break
96+ }
97+ }
98+ if c == nil {
99+ s.replyErr(req, errLimits, "too many callers")
100+ return
101+ }
102+ *c = caller{state: callerPid, name: req.sender}
103+ if !s.askPid(c) {
104+ c.release()
105+ s.deny(req)
106+ return
107+ }
108+ }
109+ s.pending[slot] = pending{used: true, req: *req}
110+}
111+
112+// askPid sends GetConnectionUnixProcessID for c and keeps its serial.
113+func (s *Service) askPid(c *caller) bool {
114+ e, serial := s.conn.NewMethodCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s")
115+ e.Str(c.name)
116+ if _, err := s.conn.Send(); err != nil {
117+ s.fatal = err
118+ return false
119+ }
120+ c.serial = serial
121+ return true
122+}
123+
124+// onBusReply advances the check of the caller the reply answers; any other
125+// reply of the bus is not ours.
126+func (s *Service) onBusReply(m *zerobus.Message) {
127+ var c *caller
128+ for i := range s.callers {
129+ cc := &s.callers[i]
130+ if (cc.state == callerPid || cc.state == callerConfirm) && cc.serial == m.ReplySerial {
131+ c = cc
132+ break
133+ }
134+ }
135+ if c == nil {
136+ return
137+ }
138+ pid := -1
139+ if m.Type == zerobus.TypeMethodReturn && m.Signature == "u" {
140+ r := m.Body()
141+ v := r.Uint32()
142+ if r.Err() == nil && v > 0 && v < 1<<31 {
143+ pid = int(v)
144+ }
145+ }
146+ switch {
147+ case pid < 0:
148+ s.failCaller(c, "pid lookup failed")
149+ case c.state == callerPid:
150+ pidfd, err := s.peers.Open(pid)
151+ if err != nil {
152+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: no pidfd")
153+ s.failCaller(c, "")
154+ return
155+ }
156+ c.pid, c.pidfd, c.state = pid, pidfd, callerConfirm
157+ if !s.askPid(c) {
158+ s.failCaller(c, "")
159+ }
160+ default:
161+ if pid != c.pid {
162+ s.log.Info().Str("sender", c.name).Msg("caller refused: pid changed during the check")
163+ s.failCaller(c, "")
164+ return
165+ }
166+ exe, got, err := s.peers.Exe(c.pidfd)
167+ if err != nil || got != c.pid {
168+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
169+ s.failCaller(c, "")
170+ return
171+ }
172+ c.exe, c.state = exe, callerKnown
173+ s.flush(c)
174+ }
175+}
176+
177+// failCaller refuses every request that waits for c and forgets it.
178+func (s *Service) failCaller(c *caller, why string) {
179+ if why != "" {
180+ s.log.Info().Str("sender", c.name).Msg("caller refused: " + why)
181+ }
182+ name := c.name
183+ c.release()
184+ for i := range s.pending {
185+ if p := &s.pending[i]; p.used && p.req.sender == name {
186+ req := p.req
187+ *p = pending{}
188+ s.deny(&req)
189+ }
190+ }
191+}
192+
193+// flush serves the requests that waited for c, in arrival order.
194+func (s *Service) flush(c *caller) {
195+ for i := range s.pending {
196+ p := &s.pending[i]
197+ if !p.used || p.req.sender != c.name {
198+ continue
199+ }
200+ req := p.req
201+ *p = pending{}
202+ s.dispatch(c, &req)
203+ }
204+}
205+
206+// onGone forgets a peer that left the bus: its check, its parked requests
207+// and its sessions.
208+func (s *Service) onGone(name string) {
209+ if c := s.callerOf(name); c != nil {
210+ c.release()
211+ }
212+ for i := range s.pending {
213+ if s.pending[i].used && s.pending[i].req.sender == name {
214+ s.pending[i] = pending{}
215+ }
216+ }
217+ for i := range s.sessions {
218+ if sess := &s.sessions[i]; sess.used && sess.owner == name {
219+ s.closeSession(sess, false)
220+ }
221+ }
222+}
223+
224+// allowed reports whether the allowlist in force admits exe.
225+func (s *Service) allowed(exe string) bool {
226+ p := s.policy()
227+ return p != nil && p.Allows(exe)
228+}
229+
230+// recheck reads the executable behind the pidfd again, so an exec after the
231+// first check is caught, and applies the allowlist to it.
232+func (s *Service) recheck(c *caller) bool {
233+ exe, pid, err := s.peers.Exe(c.pidfd)
234+ if err != nil || pid != c.pid {
235+ s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven")
236+ return false
237+ }
238+ if exe != c.exe {
239+ s.log.Info().Str("sender", c.name).Str("was", c.exe).Str("now", exe).Msg("caller executable changed")
240+ c.exe = exe
241+ }
242+ return s.allowed(exe)
243+}
A internal/adapters/screencast/harness_test.go +214 -0
@@ -0,0 +1,214 @@
1+package screencast
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/zerobus"
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/mock"
20+)
21+
22+// privateBus starts a dbus-daemon of its own, so tests never touch the
23+// desktop's session bus. CI must run these tests: there, a missing daemon
24+// fails.
25+func privateBus(t *testing.T) string {
26+ t.Helper()
27+ bin, err := exec.LookPath("dbus-daemon")
28+ if err != nil {
29+ if os.Getenv("CI") != "" {
30+ t.Fatal("dbus-daemon not installed")
31+ }
32+ t.Skip("dbus-daemon not installed")
33+ }
34+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1")
35+ out, err := cmd.StdoutPipe()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := cmd.Start(); err != nil {
40+ t.Fatal(err)
41+ }
42+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
43+ line, err := bufio.NewReader(out).ReadString('\n')
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ return strings.TrimSpace(line)
48+}
49+
50+// logBuf is a locked bytes.Buffer, a real io.Writer.
51+type logBuf struct {
52+ mu sync.Mutex
53+ b bytes.Buffer
54+}
55+
56+func (l *logBuf) Write(p []byte) (int, error) {
57+ l.mu.Lock()
58+ defer l.mu.Unlock()
59+ return l.b.Write(p)
60+}
61+
62+func (l *logBuf) String() string {
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ return l.b.String()
66+}
67+
68+func testLog(out *logBuf) zerowrap.Logger {
69+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "screencast")
70+}
71+
72+// peers is what the mocked peerResolver answers: the executable of every
73+// peer, and a hook to hold Open.
74+type peers struct {
75+ exe atomic.Value // string
76+ exeNow func() string
77+ openGo chan struct{} // when set, Open waits for it
78+ pid atomic.Int64
79+ openErr error
80+ exePid func(open int) int // pid Exe reports; the opened one when nil
81+}
82+
83+// svcHarness runs a Service on a private bus with mocked executables and
84+// PipeWire.
85+type svcHarness struct {
86+ t *testing.T
87+ addr string
88+ svc *Service
89+ cmds chan ports.CastCommand
90+ peers *peers
91+ core *mockvideoCore
92+ log *logBuf
93+ done chan struct{}
94+}
95+
96+type harnessOpts struct {
97+ allow *captureallow.Policy
98+ noPW bool
99+ holdOp bool
100+}
101+
102+func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness {
103+ t.Helper()
104+ h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})}
105+ h.peers.exe.Store("/usr/bin/grim")
106+ if o.holdOp {
107+ h.peers.openGo = make(chan struct{})
108+ }
109+ allow := o.allow
110+ if allow == nil {
111+ allow = captureallow.Builtin()
112+ }
113+ h.svc = New(func() *captureallow.Policy { return allow }, h.cmds, testLog(h.log))
114+ h.svc.address = h.addr
115+
116+ res := newMockpeerResolver(t)
117+ res.EXPECT().Open(mock.Anything).RunAndReturn(func(pid int) (*os.File, error) {
118+ if h.peers.openGo != nil {
119+ <-h.peers.openGo
120+ }
121+ if h.peers.openErr != nil {
122+ return nil, h.peers.openErr
123+ }
124+ h.peers.pid.Store(int64(pid))
125+ return os.Open(os.DevNull)
126+ }).Maybe()
127+ res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) {
128+ open := int(h.peers.pid.Load())
129+ pid := open
130+ if h.peers.exePid != nil {
131+ pid = h.peers.exePid(open)
132+ }
133+ if h.peers.exeNow != nil {
134+ return h.peers.exeNow(), pid, nil
135+ }
136+ return h.peers.exe.Load().(string), pid, nil
137+ }).Maybe()
138+ h.svc.peers = res
139+
140+ pwOpen := newMockpipewireOpener(t)
141+ if o.noPW {
142+ pwOpen.EXPECT().Open().Return(nil, os.ErrNotExist)
143+ } else {
144+ r, w, err := os.Pipe()
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ t.Cleanup(func() { r.Close(); w.Close() })
149+ h.core = newMockvideoCore(t)
150+ h.core.EXPECT().Fd().Return(int(r.Fd()))
151+ h.core.EXPECT().Iterate().Maybe()
152+ h.core.EXPECT().Close().Return().Maybe()
153+ pwOpen.EXPECT().Open().Return(h.core, nil)
154+ }
155+ h.svc.pw = pwOpen
156+
157+ ctx, cancel := context.WithCancel(context.Background())
158+ go func() { defer close(h.done); h.svc.Run(ctx, 10*time.Millisecond) }()
159+ t.Cleanup(func() {
160+ cancel()
161+ select {
162+ case <-h.done:
163+ case <-time.After(5 * time.Second):
164+ t.Error("Run did not stop")
165+ }
166+ })
167+ return h
168+}
169+
170+// client is a bus connection of the test.
171+func (h *svcHarness) client() *zerobus.Conn {
172+ h.t.Helper()
173+ c, err := zerobus.Dial(h.addr)
174+ if err != nil {
175+ h.t.Fatal(err)
176+ }
177+ h.t.Cleanup(func() { _ = c.Close() })
178+ return c
179+}
180+
181+// waitName waits until the service owns name.
182+func (h *svcHarness) waitName(c *zerobus.Conn, name string) {
183+ h.t.Helper()
184+ deadline := time.Now().Add(5 * time.Second)
185+ for time.Now().Before(deadline) {
186+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
187+ e.Str(name)
188+ m, err := c.Call()
189+ if err != nil {
190+ h.t.Fatal(err)
191+ }
192+ if m.Body().Bool() {
193+ return
194+ }
195+ time.Sleep(5 * time.Millisecond)
196+ }
197+ h.t.Fatalf("%s not owned", name)
198+}
199+
200+// callErr calls and returns the D-Bus error name, "" on success.
201+func callErr(c *zerobus.Conn, dest, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
202+ e := c.NewCall(dest, path, iface, member, sig)
203+ if body != nil {
204+ body(e)
205+ }
206+ m, err := c.Call()
207+ if err == nil {
208+ return "", m
209+ }
210+ if de, ok := err.(*zerobus.Error); ok {
211+ return de.Name, m
212+ }
213+ return err.Error(), m
214+}
@@ -0,0 +1,214 @@
1+package screencast
2+
3+import (
4+ "bufio"
5+ "bytes"
6+ "context"
7+ "os"
8+ "os/exec"
9+ "strings"
10+ "sync"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/bnema/neferwl/internal/adapters/captureallow"
16+ "github.com/bnema/neferwl/internal/ports"
17+ "github.com/bnema/zerobus"
18+ "github.com/bnema/zerowrap"
19+ "github.com/stretchr/testify/mock"
20+)
21+
22+// privateBus starts a dbus-daemon of its own, so tests never touch the
23+// desktop's session bus. CI must run these tests: there, a missing daemon
24+// fails.
25+func privateBus(t *testing.T) string {
26+ t.Helper()
27+ bin, err := exec.LookPath("dbus-daemon")
28+ if err != nil {
29+ if os.Getenv("CI") != "" {
30+ t.Fatal("dbus-daemon not installed")
31+ }
32+ t.Skip("dbus-daemon not installed")
33+ }
34+ cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1")
35+ out, err := cmd.StdoutPipe()
36+ if err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := cmd.Start(); err != nil {
40+ t.Fatal(err)
41+ }
42+ t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
43+ line, err := bufio.NewReader(out).ReadString('\n')
44+ if err != nil {
45+ t.Fatal(err)
46+ }
47+ return strings.TrimSpace(line)
48+}
49+
50+// logBuf is a locked bytes.Buffer, a real io.Writer.
51+type logBuf struct {
52+ mu sync.Mutex
53+ b bytes.Buffer
54+}
55+
56+func (l *logBuf) Write(p []byte) (int, error) {
57+ l.mu.Lock()
58+ defer l.mu.Unlock()
59+ return l.b.Write(p)
60+}
61+
62+func (l *logBuf) String() string {
63+ l.mu.Lock()
64+ defer l.mu.Unlock()
65+ return l.b.String()
66+}
67+
68+func testLog(out *logBuf) zerowrap.Logger {
69+ return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "screencast")
70+}
71+
72+// peers is what the mocked peerResolver answers: the executable of every
73+// peer, and a hook to hold Open.
74+type peers struct {
75+ exe atomic.Value // string
76+ exeNow func() string
77+ openGo chan struct{} // when set, Open waits for it
78+ pid atomic.Int64
79+ openErr error
80+ exePid func(open int) int // pid Exe reports; the opened one when nil
81+}
82+
83+// svcHarness runs a Service on a private bus with mocked executables and
84+// PipeWire.
85+type svcHarness struct {
86+ t *testing.T
87+ addr string
88+ svc *Service
89+ cmds chan ports.CastCommand
90+ peers *peers
91+ core *mockvideoCore
92+ log *logBuf
93+ done chan struct{}
94+}
95+
96+type harnessOpts struct {
97+ allow *captureallow.Policy
98+ noPW bool
99+ holdOp bool
100+}
101+
102+func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness {
103+ t.Helper()
104+ h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})}
105+ h.peers.exe.Store("/usr/bin/grim")
106+ if o.holdOp {
107+ h.peers.openGo = make(chan struct{})
108+ }
109+ allow := o.allow
110+ if allow == nil {
111+ allow = captureallow.Builtin()
112+ }
113+ h.svc = New(func() *captureallow.Policy { return allow }, h.cmds, testLog(h.log))
114+ h.svc.address = h.addr
115+
116+ res := newMockpeerResolver(t)
117+ res.EXPECT().Open(mock.Anything).RunAndReturn(func(pid int) (*os.File, error) {
118+ if h.peers.openGo != nil {
119+ <-h.peers.openGo
120+ }
121+ if h.peers.openErr != nil {
122+ return nil, h.peers.openErr
123+ }
124+ h.peers.pid.Store(int64(pid))
125+ return os.Open(os.DevNull)
126+ }).Maybe()
127+ res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) {
128+ open := int(h.peers.pid.Load())
129+ pid := open
130+ if h.peers.exePid != nil {
131+ pid = h.peers.exePid(open)
132+ }
133+ if h.peers.exeNow != nil {
134+ return h.peers.exeNow(), pid, nil
135+ }
136+ return h.peers.exe.Load().(string), pid, nil
137+ }).Maybe()
138+ h.svc.peers = res
139+
140+ pwOpen := newMockpipewireOpener(t)
141+ if o.noPW {
142+ pwOpen.EXPECT().Open().Return(nil, os.ErrNotExist)
143+ } else {
144+ r, w, err := os.Pipe()
145+ if err != nil {
146+ t.Fatal(err)
147+ }
148+ t.Cleanup(func() { r.Close(); w.Close() })
149+ h.core = newMockvideoCore(t)
150+ h.core.EXPECT().Fd().Return(int(r.Fd()))
151+ h.core.EXPECT().Iterate().Maybe()
152+ h.core.EXPECT().Close().Return().Maybe()
153+ pwOpen.EXPECT().Open().Return(h.core, nil)
154+ }
155+ h.svc.pw = pwOpen
156+
157+ ctx, cancel := context.WithCancel(context.Background())
158+ go func() { defer close(h.done); h.svc.Run(ctx, 10*time.Millisecond) }()
159+ t.Cleanup(func() {
160+ cancel()
161+ select {
162+ case <-h.done:
163+ case <-time.After(5 * time.Second):
164+ t.Error("Run did not stop")
165+ }
166+ })
167+ return h
168+}
169+
170+// client is a bus connection of the test.
171+func (h *svcHarness) client() *zerobus.Conn {
172+ h.t.Helper()
173+ c, err := zerobus.Dial(h.addr)
174+ if err != nil {
175+ h.t.Fatal(err)
176+ }
177+ h.t.Cleanup(func() { _ = c.Close() })
178+ return c
179+}
180+
181+// waitName waits until the service owns name.
182+func (h *svcHarness) waitName(c *zerobus.Conn, name string) {
183+ h.t.Helper()
184+ deadline := time.Now().Add(5 * time.Second)
185+ for time.Now().Before(deadline) {
186+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
187+ e.Str(name)
188+ m, err := c.Call()
189+ if err != nil {
190+ h.t.Fatal(err)
191+ }
192+ if m.Body().Bool() {
193+ return
194+ }
195+ time.Sleep(5 * time.Millisecond)
196+ }
197+ h.t.Fatalf("%s not owned", name)
198+}
199+
200+// callErr calls and returns the D-Bus error name, "" on success.
201+func callErr(c *zerobus.Conn, dest, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
202+ e := c.NewCall(dest, path, iface, member, sig)
203+ if body != nil {
204+ body(e)
205+ }
206+ m, err := c.Call()
207+ if err == nil {
208+ return "", m
209+ }
210+ if de, ok := err.(*zerobus.Error); ok {
211+ return de.Name, m
212+ }
213+ return err.Error(), m
214+}
M internal/adapters/screencast/inventory.go +184 -2
@@ -1,5 +1,187 @@
11 package screencast
22
3-import "github.com/bnema/neferwl/internal/ports"
3+import (
4+ "reflect"
5+ "slices"
6+ "strings"
47
5-func (s *Service) applyInventory(inv ports.CastInventory) { s.inv, s.haveInv = inv, true }
8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerobus"
10+)
11+
12+// applyInventory takes the outputs and windows wayland published. The
13+// receiver owns the slices. Departures of outputs and windows are signalled
14+// to the clients that watch them (the first inventory is not a change).
15+func (s *Service) applyInventory(inv ports.CastInventory) {
16+ slices.SortFunc(inv.Outputs, func(a, b ports.CastOutput) int { return strings.Compare(a.Info.Name, b.Info.Name) })
17+ slices.SortFunc(inv.Windows, func(a, b ports.CastWindow) int {
18+ switch {
19+ case a.ID < b.ID:
20+ return -1
21+ case a.ID > b.ID:
22+ return 1
23+ }
24+ return 0
25+ })
26+ old, had := s.inv, s.haveInv
27+ s.inv, s.haveInv = inv, true
28+ if !had {
29+ return
30+ }
31+ if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) {
32+ s.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "")
33+ s.send()
34+ }
35+ if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) {
36+ s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "")
37+ s.send()
38+ }
39+}
40+
41+// isBuiltin follows Mutter: the connector of a laptop panel.
42+func isBuiltin(connector string) bool {
43+ for _, p := range [...]string{"eDP", "LVDS", "DSI"} {
44+ if strings.HasPrefix(connector, p) {
45+ return true
46+ }
47+ }
48+ return false
49+}
50+
51+// refreshHz is a refresh rate in Hz from millihertz.
52+func refreshHz(milli int) float64 { return float64(milli) / 1000 }
53+
54+// replyState answers DisplayConfig.GetCurrentState:
55+// (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}).
56+// layout-mode 1 is physical: logical sizes are scaled sizes of the modes.
57+// Exactly one mode of each monitor is current, as x-d-p-g requires.
58+func (s *Service) replyState(req *request) {
59+ e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}")
60+ if e == nil {
61+ return
62+ }
63+ e.Uint32(0)
64+ monitors := e.BeginArray('(')
65+ for i := range s.inv.Outputs {
66+ o := &s.inv.Outputs[i]
67+ e.Struct()
68+ s.encodeNames(e, o)
69+ modes := e.BeginArray('(')
70+ cur := o.Current
71+ if cur < 0 || cur >= len(o.Modes) {
72+ cur = 0
73+ }
74+ for j, m := range o.Modes {
75+ e.Struct()
76+ e.Str(modeID(m))
77+ e.Int32(int32(m.Width))
78+ e.Int32(int32(m.Height))
79+ e.Float64(refreshHz(m.RefreshMilli))
80+ e.Float64(1)
81+ sc := e.BeginArray('d')
82+ e.Float64(1)
83+ e.EndArray(sc)
84+ props := e.BeginArray('{')
85+ e.Struct()
86+ e.Str("is-preferred")
87+ e.Variant("b")
88+ e.Bool(m.Preferred)
89+ if j == cur {
90+ e.Struct()
91+ e.Str("is-current")
92+ e.Variant("b")
93+ e.Bool(true)
94+ }
95+ e.EndArray(props)
96+ }
97+ e.EndArray(modes)
98+ props := e.BeginArray('{')
99+ display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model)
100+ if display == "" {
101+ display = o.Info.Name
102+ }
103+ e.Struct()
104+ e.Str("display-name")
105+ e.Variant("s")
106+ e.Str(display)
107+ e.Struct()
108+ e.Str("is-builtin")
109+ e.Variant("b")
110+ e.Bool(isBuiltin(o.Info.Name))
111+ e.EndArray(props)
112+ }
113+ e.EndArray(monitors)
114+ logical := e.BeginArray('(')
115+ for i := range s.inv.Outputs {
116+ o := &s.inv.Outputs[i]
117+ e.Struct()
118+ e.Int32(int32(o.X))
119+ e.Int32(int32(o.Y))
120+ scale := o.Scale
121+ if scale <= 0 {
122+ scale = 1
123+ }
124+ e.Float64(scale)
125+ e.Uint32(uint32(o.Transform))
126+ e.Bool(i == 0)
127+ mons := e.BeginArray('(')
128+ e.Struct()
129+ s.encodeNames(e, o)
130+ e.EndArray(mons)
131+ props := e.BeginArray('{')
132+ e.EndArray(props)
133+ }
134+ e.EndArray(logical)
135+ props := e.BeginArray('{')
136+ e.Struct()
137+ e.Str("layout-mode")
138+ e.Variant("u")
139+ e.Uint32(1)
140+ e.EndArray(props)
141+ s.send()
142+}
143+
144+// encodeNames writes the monitor spec (connector, vendor, product, serial).
145+// The serial falls back to the connector, as session restore needs one.
146+func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) {
147+ e.Struct()
148+ serial := o.Info.Serial
149+ if serial == "" {
150+ serial = o.Info.Name
151+ }
152+ e.Str(o.Info.Name)
153+ e.Str(o.Info.Make)
154+ e.Str(o.Info.Model)
155+ e.Str(serial)
156+}
157+
158+func modeID(m ports.OutputMode) string {
159+ return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli))
160+}
161+
162+// replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title.
163+// The app id is the raw Wayland app id.
164+func (s *Service) replyWindows(req *request) {
165+ e := s.replyTo(req, "a{ta{sv}}")
166+ if e == nil {
167+ return
168+ }
169+ a := e.BeginArray('{')
170+ for i := range s.inv.Windows {
171+ w := &s.inv.Windows[i]
172+ e.Struct()
173+ e.Uint64(uint64(w.ID))
174+ props := e.BeginArray('{')
175+ e.Struct()
176+ e.Str("app-id")
177+ e.Variant("s")
178+ e.Str(w.AppID)
179+ e.Struct()
180+ e.Str("title")
181+ e.Variant("s")
182+ e.Str(w.Title)
183+ e.EndArray(props)
184+ }
185+ e.EndArray(a)
186+ s.send()
187+}
@@ -1,5 +1,187 @@
1 package screencast1 package screencast
2 2
3-import "github.com/bnema/neferwl/internal/ports"3+import (
4+ "reflect"
5+ "slices"
6+ "strings"
4 7
5-func (s *Service) applyInventory(inv ports.CastInventory) { s.inv, s.haveInv = inv, true }8+ "github.com/bnema/neferwl/internal/ports"
9+ "github.com/bnema/zerobus"
10+)
11+
12+// applyInventory takes the outputs and windows wayland published. The
13+// receiver owns the slices. Departures of outputs and windows are signalled
14+// to the clients that watch them (the first inventory is not a change).
15+func (s *Service) applyInventory(inv ports.CastInventory) {
16+ slices.SortFunc(inv.Outputs, func(a, b ports.CastOutput) int { return strings.Compare(a.Info.Name, b.Info.Name) })
17+ slices.SortFunc(inv.Windows, func(a, b ports.CastWindow) int {
18+ switch {
19+ case a.ID < b.ID:
20+ return -1
21+ case a.ID > b.ID:
22+ return 1
23+ }
24+ return 0
25+ })
26+ old, had := s.inv, s.haveInv
27+ s.inv, s.haveInv = inv, true
28+ if !had {
29+ return
30+ }
31+ if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) {
32+ s.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "")
33+ s.send()
34+ }
35+ if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) {
36+ s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "")
37+ s.send()
38+ }
39+}
40+
41+// isBuiltin follows Mutter: the connector of a laptop panel.
42+func isBuiltin(connector string) bool {
43+ for _, p := range [...]string{"eDP", "LVDS", "DSI"} {
44+ if strings.HasPrefix(connector, p) {
45+ return true
46+ }
47+ }
48+ return false
49+}
50+
51+// refreshHz is a refresh rate in Hz from millihertz.
52+func refreshHz(milli int) float64 { return float64(milli) / 1000 }
53+
54+// replyState answers DisplayConfig.GetCurrentState:
55+// (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}).
56+// layout-mode 1 is physical: logical sizes are scaled sizes of the modes.
57+// Exactly one mode of each monitor is current, as x-d-p-g requires.
58+func (s *Service) replyState(req *request) {
59+ e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}")
60+ if e == nil {
61+ return
62+ }
63+ e.Uint32(0)
64+ monitors := e.BeginArray('(')
65+ for i := range s.inv.Outputs {
66+ o := &s.inv.Outputs[i]
67+ e.Struct()
68+ s.encodeNames(e, o)
69+ modes := e.BeginArray('(')
70+ cur := o.Current
71+ if cur < 0 || cur >= len(o.Modes) {
72+ cur = 0
73+ }
74+ for j, m := range o.Modes {
75+ e.Struct()
76+ e.Str(modeID(m))
77+ e.Int32(int32(m.Width))
78+ e.Int32(int32(m.Height))
79+ e.Float64(refreshHz(m.RefreshMilli))
80+ e.Float64(1)
81+ sc := e.BeginArray('d')
82+ e.Float64(1)
83+ e.EndArray(sc)
84+ props := e.BeginArray('{')
85+ e.Struct()
86+ e.Str("is-preferred")
87+ e.Variant("b")
88+ e.Bool(m.Preferred)
89+ if j == cur {
90+ e.Struct()
91+ e.Str("is-current")
92+ e.Variant("b")
93+ e.Bool(true)
94+ }
95+ e.EndArray(props)
96+ }
97+ e.EndArray(modes)
98+ props := e.BeginArray('{')
99+ display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model)
100+ if display == "" {
101+ display = o.Info.Name
102+ }
103+ e.Struct()
104+ e.Str("display-name")
105+ e.Variant("s")
106+ e.Str(display)
107+ e.Struct()
108+ e.Str("is-builtin")
109+ e.Variant("b")
110+ e.Bool(isBuiltin(o.Info.Name))
111+ e.EndArray(props)
112+ }
113+ e.EndArray(monitors)
114+ logical := e.BeginArray('(')
115+ for i := range s.inv.Outputs {
116+ o := &s.inv.Outputs[i]
117+ e.Struct()
118+ e.Int32(int32(o.X))
119+ e.Int32(int32(o.Y))
120+ scale := o.Scale
121+ if scale <= 0 {
122+ scale = 1
123+ }
124+ e.Float64(scale)
125+ e.Uint32(uint32(o.Transform))
126+ e.Bool(i == 0)
127+ mons := e.BeginArray('(')
128+ e.Struct()
129+ s.encodeNames(e, o)
130+ e.EndArray(mons)
131+ props := e.BeginArray('{')
132+ e.EndArray(props)
133+ }
134+ e.EndArray(logical)
135+ props := e.BeginArray('{')
136+ e.Struct()
137+ e.Str("layout-mode")
138+ e.Variant("u")
139+ e.Uint32(1)
140+ e.EndArray(props)
141+ s.send()
142+}
143+
144+// encodeNames writes the monitor spec (connector, vendor, product, serial).
145+// The serial falls back to the connector, as session restore needs one.
146+func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) {
147+ e.Struct()
148+ serial := o.Info.Serial
149+ if serial == "" {
150+ serial = o.Info.Name
151+ }
152+ e.Str(o.Info.Name)
153+ e.Str(o.Info.Make)
154+ e.Str(o.Info.Model)
155+ e.Str(serial)
156+}
157+
158+func modeID(m ports.OutputMode) string {
159+ return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli))
160+}
161+
162+// replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title.
163+// The app id is the raw Wayland app id.
164+func (s *Service) replyWindows(req *request) {
165+ e := s.replyTo(req, "a{ta{sv}}")
166+ if e == nil {
167+ return
168+ }
169+ a := e.BeginArray('{')
170+ for i := range s.inv.Windows {
171+ w := &s.inv.Windows[i]
172+ e.Struct()
173+ e.Uint64(uint64(w.ID))
174+ props := e.BeginArray('{')
175+ e.Struct()
176+ e.Str("app-id")
177+ e.Variant("s")
178+ e.Str(w.AppID)
179+ e.Struct()
180+ e.Str("title")
181+ e.Variant("s")
182+ e.Str(w.Title)
183+ e.EndArray(props)
184+ }
185+ e.EndArray(a)
186+ s.send()
187+}
A internal/adapters/screencast/producer.go +314 -0
@@ -0,0 +1,314 @@
1+package screencast
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+)
9+
10+// The producer turns wayland's damage into PipeWire frames, one stream at a
11+// time and without allocating in steady state:
12+//
13+// CastDamage -> dirty
14+// pump: STREAMING, dirty or first frame, no frame in flight, interval
15+// elapsed, a buffer free -> Dequeue, CastFrame{Data: buffer}
16+// CastFrameDone ok -> Queue, then Trigger (the order PipeWire needs)
17+// CastFrameDone with an error -> Release, retry paced
18+//
19+// The buffer's mapping is lent to the wayland server until the CastFrameDone
20+// of that buffer, so the source is never resized or destroyed while a frame
21+// is in flight.
22+
23+// wl_shm formats of the supported video formats.
24+const (
25+ shmARGB8888 = 0
26+ shmXRGB8888 = 1
27+)
28+
29+// maxFrameErrors is how many frames in a row may fail before the stream
30+// stops.
31+const maxFrameErrors = 3
32+
33+// retryOpen is how long a command waits when the wayland channel is full.
34+const retryOpen = 10 * time.Millisecond
35+
36+func shmFormat(f pipewire.VideoFormat) uint32 {
37+ if f == pipewire.VideoFormatBGRA {
38+ return shmARGB8888
39+ }
40+ return shmXRGB8888
41+}
42+
43+// streamOfCast finds the stream of a cast ID.
44+func (s *Service) streamOfCast(id uint64) *stream {
45+ for i := range s.sessions {
46+ sess := &s.sessions[i]
47+ if !sess.used {
48+ continue
49+ }
50+ for j := range sess.streams {
51+ if st := &sess.streams[j]; st.used && st.cast == id {
52+ return st
53+ }
54+ }
55+ }
56+ return nil
57+}
58+
59+// onCastEvent applies one event of the wayland server.
60+func (s *Service) onCastEvent(ev *ports.CastEvent) {
61+ st := s.streamOfCast(ev.ID)
62+ if st == nil {
63+ return
64+ }
65+ switch ev.Kind {
66+ case ports.CastDamage:
67+ st.dirty = true
68+ case ports.CastSize:
69+ if st.closing {
70+ return
71+ }
72+ if st.haveSize && st.w == ev.Width && st.h == ev.Height {
73+ return
74+ }
75+ st.w, st.h, st.haveSize = ev.Width, ev.Height, true
76+ if st.src != nil {
77+ st.needResize = true
78+ }
79+ case ports.CastFrameDone:
80+ s.frameDone(st, ev)
81+ case ports.CastStopped:
82+ st.stopped, st.needClose = true, false
83+ if st.inflight {
84+ // Wayland orders CastStopped after the frame's CastFrameDone.
85+ s.log.Warn().Str("stream", st.path).Msg("cast stopped with a frame in flight")
86+ st.inflight = false
87+ s.giveBack(st)
88+ }
89+ if !st.closing {
90+ s.log.Info().Str("stream", st.path).Str("reason", string(ev.Reason)).Err(ev.Err).Msg("cast stopped")
91+ s.closeSession(st.sess, true)
92+ }
93+ }
94+}
95+
96+// giveBack releases the buffer a frame borrowed.
97+func (s *Service) giveBack(st *stream) {
98+ if st.buf != nil && st.src != nil {
99+ st.src.Release(st.buf)
100+ }
101+ st.buf = nil
102+}
103+
104+// frameDone settles the frame in flight: a complete image is queued and
105+// triggered, anything else gives the buffer back.
106+func (s *Service) frameDone(st *stream, ev *ports.CastEvent) {
107+ if !st.inflight || st.buf == nil || st.buf.Index != ev.Buf {
108+ s.log.Warn().Str("stream", st.path).Int("buf", ev.Buf).Msg("frame done for no frame in flight")
109+ return
110+ }
111+ st.inflight = false
112+ if ev.Err != nil || st.closing {
113+ s.giveBack(st)
114+ if ev.Err == nil {
115+ return
116+ }
117+ st.errs++
118+ st.dirty = true
119+ if !st.closing {
120+ s.log.Info().Str("stream", st.path).Err(ev.Err).Int("errors", st.errs).Msg("cast frame failed")
121+ if st.errs >= maxFrameErrors {
122+ s.closeSession(st.sess, true)
123+ }
124+ }
125+ return
126+ }
127+ st.errs = 0
128+ st.seq++
129+ var pts time.Duration
130+ if !ev.Time.IsZero() {
131+ pts = time.Duration(ev.Time.UnixNano())
132+ }
133+ b := st.buf
134+ st.buf = nil
135+ st.src.Queue(b, st.seq, pts, false)
136+ st.src.Trigger()
137+}
138+
139+// pumpAll runs every stream: commands, negotiation and frames.
140+func (s *Service) pumpAll(now time.Time) {
141+ s.waitFor = 0
142+ for i := range s.sessions {
143+ sess := &s.sessions[i]
144+ if !sess.used {
145+ continue
146+ }
147+ for j := range sess.streams {
148+ if st := &sess.streams[j]; st.used {
149+ s.pump(st, now)
150+ }
151+ }
152+ }
153+}
154+
155+// wait asks for a poll timeout no longer than d.
156+func (s *Service) wait(d time.Duration) {
157+ if d <= 0 {
158+ d = time.Millisecond
159+ }
160+ if s.waitFor == 0 || d < s.waitFor {
161+ s.waitFor = d
162+ }
163+}
164+
165+// send puts a command on the wayland channel without blocking.
166+func (s *Service) sendCmd(cmd ports.CastCommand) bool {
167+ select {
168+ case s.cmds <- cmd:
169+ return true
170+ default:
171+ return false
172+ }
173+}
174+
175+func (s *Service) pump(st *stream, now time.Time) {
176+ if st.closing {
177+ s.pumpClosing(st)
178+ return
179+ }
180+ if st.needOpen {
181+ cmd := ports.CastCommand{Kind: ports.CastOpen, ID: st.cast, Output: st.output, Window: st.window, Cursor: st.cursor}
182+ if !s.sendCmd(cmd) {
183+ s.wait(retryOpen)
184+ return
185+ }
186+ st.needOpen, st.opened = false, true
187+ }
188+ if !st.haveSize {
189+ return
190+ }
191+ if st.src == nil {
192+ s.openSource(st)
193+ if st.src == nil {
194+ return
195+ }
196+ }
197+ if st.pwState == pipewire.StreamStateError || st.pwState == pipewire.StreamStateUnconnected && st.seenState {
198+ s.log.Info().Str("stream", st.path).Int32("state", st.pwState).Msg("PipeWire stream ended")
199+ s.closeSession(st.sess, true)
200+ return
201+ }
202+ if st.needResize && !st.inflight {
203+ st.needResize = false
204+ st.fw, st.fh = 0, 0
205+ if err := st.src.Resize(st.w, st.h); err != nil {
206+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire resize failed")
207+ s.closeSession(st.sess, true)
208+ return
209+ }
210+ }
211+ if !st.announced && st.pwState >= pipewire.StreamStatePaused && st.nodeID != 0 {
212+ st.announced = true
213+ s.conn.NewSignalTo(st.sess.owner, st.path, ifaceStream, "PipeWireStreamAdded", "u").Uint32(st.nodeID)
214+ s.send()
215+ }
216+ s.pumpFrame(st, now)
217+}
218+
219+// openSource creates the PipeWire node once the target size is known.
220+func (s *Service) openSource(st *stream) {
221+ if s.pwc == nil {
222+ s.closeSession(st.sess, true)
223+ return
224+ }
225+ cfg := pipewire.VideoConfig{Name: "neferwl-screencast", Width: st.w, Height: st.h, MaxFPS: st.maxFPS, Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA}}
226+ src, err := s.pwc.NewVideoSource(cfg, &st.h2)
227+ if err != nil {
228+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire stream not created")
229+ s.closeSession(st.sess, true)
230+ return
231+ }
232+ st.src = src
233+}
234+
235+// pumpFrame requests the next frame when one is due.
236+func (s *Service) pumpFrame(st *stream, now time.Time) {
237+ if st.inflight || st.pwState != pipewire.StreamStateStreaming || st.needResize ||
238+ st.fw != st.w || st.fh != st.h || st.fw == 0 {
239+ return
240+ }
241+ if !st.dirty && !st.forced {
242+ return
243+ }
244+ interval := time.Second / time.Duration(max(st.maxFPS, 1))
245+ if since := now.Sub(st.lastReq); !st.lastReq.IsZero() && since < interval {
246+ s.wait(interval - since)
247+ return
248+ }
249+ b := st.src.Dequeue()
250+ if b == nil {
251+ // Every buffer is with the consumer: look again shortly.
252+ s.wait(interval)
253+ return
254+ }
255+ if b.Width != st.w || b.Height != st.h || len(b.Data) < b.Stride*b.Height {
256+ st.src.Release(b)
257+ s.wait(interval)
258+ return
259+ }
260+ cmd := ports.CastCommand{Kind: ports.CastFrame, ID: st.cast, Buf: b.Index, Data: b.Data, Stride: b.Stride, Width: b.Width, Height: b.Height, Format: shmFormat(b.Format)}
261+ if !s.sendCmd(cmd) {
262+ st.src.Release(b)
263+ s.wait(interval)
264+ return
265+ }
266+ st.inflight, st.buf = true, b
267+ st.lastReq, st.dirty, st.forced = now, false, false
268+}
269+
270+// pumpClosing finishes a stream of an ended session: CastClose, then the
271+// PipeWire node once no frame is in flight.
272+func (s *Service) pumpClosing(st *stream) {
273+ if st.needClose {
274+ if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) {
275+ s.wait(retryOpen)
276+ return
277+ }
278+ st.needClose = false
279+ }
280+ if st.inflight {
281+ return
282+ }
283+ if st.src != nil {
284+ s.giveBack(st)
285+ st.src.Destroy()
286+ st.src = nil
287+ }
288+ *st = stream{}
289+}
290+
291+// reap frees the dying sessions once every stream released its resources.
292+func (s *Service) reap() {
293+ for i := range s.sessions {
294+ sess := &s.sessions[i]
295+ if !sess.used || !sess.dying {
296+ continue
297+ }
298+ live := false
299+ for j := range sess.streams {
300+ live = live || sess.streams[j].used
301+ }
302+ if !live {
303+ *sess = session{}
304+ }
305+ }
306+}
307+
308+// streamsLive reports whether a stream still holds PipeWire resources or a
309+// lent buffer.
310+func (s *Service) streamsLive() bool {
311+ live := false
312+ s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight })
313+ return live
314+}
@@ -0,0 +1,314 @@
1+package screencast
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+)
9+
10+// The producer turns wayland's damage into PipeWire frames, one stream at a
11+// time and without allocating in steady state:
12+//
13+// CastDamage -> dirty
14+// pump: STREAMING, dirty or first frame, no frame in flight, interval
15+// elapsed, a buffer free -> Dequeue, CastFrame{Data: buffer}
16+// CastFrameDone ok -> Queue, then Trigger (the order PipeWire needs)
17+// CastFrameDone with an error -> Release, retry paced
18+//
19+// The buffer's mapping is lent to the wayland server until the CastFrameDone
20+// of that buffer, so the source is never resized or destroyed while a frame
21+// is in flight.
22+
23+// wl_shm formats of the supported video formats.
24+const (
25+ shmARGB8888 = 0
26+ shmXRGB8888 = 1
27+)
28+
29+// maxFrameErrors is how many frames in a row may fail before the stream
30+// stops.
31+const maxFrameErrors = 3
32+
33+// retryOpen is how long a command waits when the wayland channel is full.
34+const retryOpen = 10 * time.Millisecond
35+
36+func shmFormat(f pipewire.VideoFormat) uint32 {
37+ if f == pipewire.VideoFormatBGRA {
38+ return shmARGB8888
39+ }
40+ return shmXRGB8888
41+}
42+
43+// streamOfCast finds the stream of a cast ID.
44+func (s *Service) streamOfCast(id uint64) *stream {
45+ for i := range s.sessions {
46+ sess := &s.sessions[i]
47+ if !sess.used {
48+ continue
49+ }
50+ for j := range sess.streams {
51+ if st := &sess.streams[j]; st.used && st.cast == id {
52+ return st
53+ }
54+ }
55+ }
56+ return nil
57+}
58+
59+// onCastEvent applies one event of the wayland server.
60+func (s *Service) onCastEvent(ev *ports.CastEvent) {
61+ st := s.streamOfCast(ev.ID)
62+ if st == nil {
63+ return
64+ }
65+ switch ev.Kind {
66+ case ports.CastDamage:
67+ st.dirty = true
68+ case ports.CastSize:
69+ if st.closing {
70+ return
71+ }
72+ if st.haveSize && st.w == ev.Width && st.h == ev.Height {
73+ return
74+ }
75+ st.w, st.h, st.haveSize = ev.Width, ev.Height, true
76+ if st.src != nil {
77+ st.needResize = true
78+ }
79+ case ports.CastFrameDone:
80+ s.frameDone(st, ev)
81+ case ports.CastStopped:
82+ st.stopped, st.needClose = true, false
83+ if st.inflight {
84+ // Wayland orders CastStopped after the frame's CastFrameDone.
85+ s.log.Warn().Str("stream", st.path).Msg("cast stopped with a frame in flight")
86+ st.inflight = false
87+ s.giveBack(st)
88+ }
89+ if !st.closing {
90+ s.log.Info().Str("stream", st.path).Str("reason", string(ev.Reason)).Err(ev.Err).Msg("cast stopped")
91+ s.closeSession(st.sess, true)
92+ }
93+ }
94+}
95+
96+// giveBack releases the buffer a frame borrowed.
97+func (s *Service) giveBack(st *stream) {
98+ if st.buf != nil && st.src != nil {
99+ st.src.Release(st.buf)
100+ }
101+ st.buf = nil
102+}
103+
104+// frameDone settles the frame in flight: a complete image is queued and
105+// triggered, anything else gives the buffer back.
106+func (s *Service) frameDone(st *stream, ev *ports.CastEvent) {
107+ if !st.inflight || st.buf == nil || st.buf.Index != ev.Buf {
108+ s.log.Warn().Str("stream", st.path).Int("buf", ev.Buf).Msg("frame done for no frame in flight")
109+ return
110+ }
111+ st.inflight = false
112+ if ev.Err != nil || st.closing {
113+ s.giveBack(st)
114+ if ev.Err == nil {
115+ return
116+ }
117+ st.errs++
118+ st.dirty = true
119+ if !st.closing {
120+ s.log.Info().Str("stream", st.path).Err(ev.Err).Int("errors", st.errs).Msg("cast frame failed")
121+ if st.errs >= maxFrameErrors {
122+ s.closeSession(st.sess, true)
123+ }
124+ }
125+ return
126+ }
127+ st.errs = 0
128+ st.seq++
129+ var pts time.Duration
130+ if !ev.Time.IsZero() {
131+ pts = time.Duration(ev.Time.UnixNano())
132+ }
133+ b := st.buf
134+ st.buf = nil
135+ st.src.Queue(b, st.seq, pts, false)
136+ st.src.Trigger()
137+}
138+
139+// pumpAll runs every stream: commands, negotiation and frames.
140+func (s *Service) pumpAll(now time.Time) {
141+ s.waitFor = 0
142+ for i := range s.sessions {
143+ sess := &s.sessions[i]
144+ if !sess.used {
145+ continue
146+ }
147+ for j := range sess.streams {
148+ if st := &sess.streams[j]; st.used {
149+ s.pump(st, now)
150+ }
151+ }
152+ }
153+}
154+
155+// wait asks for a poll timeout no longer than d.
156+func (s *Service) wait(d time.Duration) {
157+ if d <= 0 {
158+ d = time.Millisecond
159+ }
160+ if s.waitFor == 0 || d < s.waitFor {
161+ s.waitFor = d
162+ }
163+}
164+
165+// send puts a command on the wayland channel without blocking.
166+func (s *Service) sendCmd(cmd ports.CastCommand) bool {
167+ select {
168+ case s.cmds <- cmd:
169+ return true
170+ default:
171+ return false
172+ }
173+}
174+
175+func (s *Service) pump(st *stream, now time.Time) {
176+ if st.closing {
177+ s.pumpClosing(st)
178+ return
179+ }
180+ if st.needOpen {
181+ cmd := ports.CastCommand{Kind: ports.CastOpen, ID: st.cast, Output: st.output, Window: st.window, Cursor: st.cursor}
182+ if !s.sendCmd(cmd) {
183+ s.wait(retryOpen)
184+ return
185+ }
186+ st.needOpen, st.opened = false, true
187+ }
188+ if !st.haveSize {
189+ return
190+ }
191+ if st.src == nil {
192+ s.openSource(st)
193+ if st.src == nil {
194+ return
195+ }
196+ }
197+ if st.pwState == pipewire.StreamStateError || st.pwState == pipewire.StreamStateUnconnected && st.seenState {
198+ s.log.Info().Str("stream", st.path).Int32("state", st.pwState).Msg("PipeWire stream ended")
199+ s.closeSession(st.sess, true)
200+ return
201+ }
202+ if st.needResize && !st.inflight {
203+ st.needResize = false
204+ st.fw, st.fh = 0, 0
205+ if err := st.src.Resize(st.w, st.h); err != nil {
206+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire resize failed")
207+ s.closeSession(st.sess, true)
208+ return
209+ }
210+ }
211+ if !st.announced && st.pwState >= pipewire.StreamStatePaused && st.nodeID != 0 {
212+ st.announced = true
213+ s.conn.NewSignalTo(st.sess.owner, st.path, ifaceStream, "PipeWireStreamAdded", "u").Uint32(st.nodeID)
214+ s.send()
215+ }
216+ s.pumpFrame(st, now)
217+}
218+
219+// openSource creates the PipeWire node once the target size is known.
220+func (s *Service) openSource(st *stream) {
221+ if s.pwc == nil {
222+ s.closeSession(st.sess, true)
223+ return
224+ }
225+ cfg := pipewire.VideoConfig{Name: "neferwl-screencast", Width: st.w, Height: st.h, MaxFPS: st.maxFPS, Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA}}
226+ src, err := s.pwc.NewVideoSource(cfg, &st.h2)
227+ if err != nil {
228+ s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire stream not created")
229+ s.closeSession(st.sess, true)
230+ return
231+ }
232+ st.src = src
233+}
234+
235+// pumpFrame requests the next frame when one is due.
236+func (s *Service) pumpFrame(st *stream, now time.Time) {
237+ if st.inflight || st.pwState != pipewire.StreamStateStreaming || st.needResize ||
238+ st.fw != st.w || st.fh != st.h || st.fw == 0 {
239+ return
240+ }
241+ if !st.dirty && !st.forced {
242+ return
243+ }
244+ interval := time.Second / time.Duration(max(st.maxFPS, 1))
245+ if since := now.Sub(st.lastReq); !st.lastReq.IsZero() && since < interval {
246+ s.wait(interval - since)
247+ return
248+ }
249+ b := st.src.Dequeue()
250+ if b == nil {
251+ // Every buffer is with the consumer: look again shortly.
252+ s.wait(interval)
253+ return
254+ }
255+ if b.Width != st.w || b.Height != st.h || len(b.Data) < b.Stride*b.Height {
256+ st.src.Release(b)
257+ s.wait(interval)
258+ return
259+ }
260+ cmd := ports.CastCommand{Kind: ports.CastFrame, ID: st.cast, Buf: b.Index, Data: b.Data, Stride: b.Stride, Width: b.Width, Height: b.Height, Format: shmFormat(b.Format)}
261+ if !s.sendCmd(cmd) {
262+ st.src.Release(b)
263+ s.wait(interval)
264+ return
265+ }
266+ st.inflight, st.buf = true, b
267+ st.lastReq, st.dirty, st.forced = now, false, false
268+}
269+
270+// pumpClosing finishes a stream of an ended session: CastClose, then the
271+// PipeWire node once no frame is in flight.
272+func (s *Service) pumpClosing(st *stream) {
273+ if st.needClose {
274+ if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) {
275+ s.wait(retryOpen)
276+ return
277+ }
278+ st.needClose = false
279+ }
280+ if st.inflight {
281+ return
282+ }
283+ if st.src != nil {
284+ s.giveBack(st)
285+ st.src.Destroy()
286+ st.src = nil
287+ }
288+ *st = stream{}
289+}
290+
291+// reap frees the dying sessions once every stream released its resources.
292+func (s *Service) reap() {
293+ for i := range s.sessions {
294+ sess := &s.sessions[i]
295+ if !sess.used || !sess.dying {
296+ continue
297+ }
298+ live := false
299+ for j := range sess.streams {
300+ live = live || sess.streams[j].used
301+ }
302+ if !live {
303+ *sess = session{}
304+ }
305+ }
306+}
307+
308+// streamsLive reports whether a stream still holds PipeWire resources or a
309+// lent buffer.
310+func (s *Service) streamsLive() bool {
311+ live := false
312+ s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight })
313+ return live
314+}
M internal/adapters/screencast/screencast.go +3 -7
@@ -16,7 +16,6 @@ package screencast
1616 import (
1717 "context"
1818 "errors"
19- "os"
2019 "runtime"
2120 "sync"
2221 "time"
@@ -211,7 +210,7 @@ func (s *Service) attempt(ctx context.Context) (err error) {
211210 s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered")
212211 }
213212 defer s.shutdown()
214- if err = s.start(); err != nil {
213+ if err = s.acquire(); err != nil {
215214 return err
216215 }
217216 return s.loop(ctx)
@@ -228,8 +227,8 @@ func (s *Service) reset(ctx context.Context, conn bus) {
228227 s.shot = shotState{}
229228 }
230229
231-// start watches departures and takes the names.
232-func (s *Service) start() error {
230+// acquire watches departures and takes the names.
231+func (s *Service) acquire() error {
233232 rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''"
234233 if err := s.conn.AddMatch(rule); err != nil {
235234 return err
@@ -362,6 +361,3 @@ func (s *Service) timeout() int {
362361 }
363362 return int((wait + time.Millisecond - 1) / time.Millisecond)
364363 }
365-
366-// runtimeDir is where screenshots are written.
367-func runtimeDir() string { return os.Getenv("XDG_RUNTIME_DIR") }
@@ -16,7 +16,6 @@ package screencast
16 import (16 import (
17 "context"17 "context"
18 "errors"18 "errors"
19- "os"
20 "runtime"19 "runtime"
21 "sync"20 "sync"
22 "time"21 "time"
@@ -211,7 +210,7 @@ func (s *Service) attempt(ctx context.Context) (err error) {
211 s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered")210 s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered")
212 }211 }
213 defer s.shutdown()212 defer s.shutdown()
214- if err = s.start(); err != nil {213+ if err = s.acquire(); err != nil {
215 return err214 return err
216 }215 }
217 return s.loop(ctx)216 return s.loop(ctx)
@@ -228,8 +227,8 @@ func (s *Service) reset(ctx context.Context, conn bus) {
228 s.shot = shotState{}227 s.shot = shotState{}
229 }228 }
230 229
231-// start watches departures and takes the names.230+// acquire watches departures and takes the names.
232-func (s *Service) start() error {231+func (s *Service) acquire() error {
233 rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''"232 rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''"
234 if err := s.conn.AddMatch(rule); err != nil {233 if err := s.conn.AddMatch(rule); err != nil {
235 return err234 return err
@@ -362,6 +361,3 @@ func (s *Service) timeout() int {
362 }361 }
363 return int((wait + time.Millisecond - 1) / time.Millisecond)362 return int((wait + time.Millisecond - 1) / time.Millisecond)
364 }363 }
365-
366-// runtimeDir is where screenshots are written.
367-func runtimeDir() string { return os.Getenv("XDG_RUNTIME_DIR") }
M internal/adapters/screencast/screenshot.go +138 -2
@@ -1,7 +1,33 @@
11 package screencast
22
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "time"
9+)
10+
11+// Screenshots are taken by nefercap, run as a child process:
12+//
13+// Screenshot nefercap screenshot -output FOCUSED -file F
14+// ScreenshotArea nefercap screenshot -output O -region X,Y,WxH -file F
15+// InteractiveScreenshot nefercap shot -file F
16+//
17+// F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most
18+// is in flight. The run is a goroutine; its result goes back through the
19+// service's transport and the owner sends the reply, so the bus is still
20+// used by the owner only. SelectArea and ScreenshotWindow (the "Select
21+// area" and "Window" choices of the GNOME dialog) are not supported;
22+// include_cursor and flash are ignored.
23+
324 // shotState is the screenshot in flight; one at most.
4-type shotState struct{ busy bool }
25+type shotState struct {
26+ busy bool
27+ req request
28+ uri bool // reply with a file:// URI
29+ path string
30+}
531
632 // shotResult is what a finished nefercap run leaves for the owner.
733 type shotResult struct {
@@ -9,4 +35,114 @@ type shotResult struct {
935 err error
1036 }
1137
12-func (s *Service) onShotDone(r *shotResult) { _ = r }
38+// screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot.
39+func (s *Service) screenshot(req *request) {
40+ if s.shot.busy {
41+ s.replyErr(req, errLimits, "a screenshot is already being taken")
42+ return
43+ }
44+ var args []string
45+ dir := shotDir()
46+ if dir == "" {
47+ s.replyErr(req, errFailed, "no runtime directory")
48+ return
49+ }
50+ path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png")
51+ switch req.method {
52+ case mShot:
53+ out := s.inv.Focused
54+ if out == "" && len(s.inv.Outputs) > 0 {
55+ out = s.inv.Outputs[0].Info.Name
56+ }
57+ if out == "" {
58+ s.replyErr(req, errFailed, "no output")
59+ return
60+ }
61+ args = []string{"screenshot", "-output", out, "-file", path}
62+ case mShotArea:
63+ out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h)
64+ if !ok {
65+ s.replyErr(req, errInvalidArgs, "area outside every output")
66+ return
67+ }
68+ args = []string{"screenshot", "-output", out, "-region", region, "-file", path}
69+ default:
70+ args = []string{"shot", "-file", path}
71+ }
72+ if err := os.MkdirAll(dir, 0o700); err != nil {
73+ s.log.Warn().Err(err).Msg("screenshot directory not created")
74+ s.replyErr(req, errFailed, "screenshot directory not created")
75+ return
76+ }
77+ s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path}
78+ s.runShot(s.ctx, args)
79+}
80+
81+// shotDir is $XDG_RUNTIME_DIR/neferwl.
82+func shotDir() string {
83+ rt := os.Getenv("XDG_RUNTIME_DIR")
84+ if rt == "" {
85+ return ""
86+ }
87+ return filepath.Join(rt, "neferwl")
88+}
89+
90+// areaTarget finds the output that contains the corner (x, y) of an area in
91+// the logical space and the output-local region, clipped to the output.
92+func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) {
93+ if w <= 0 || h <= 0 {
94+ return "", "", false
95+ }
96+ for i := range s.inv.Outputs {
97+ o := &s.inv.Outputs[i]
98+ lx, ly := int(x)-o.X, int(y)-o.Y
99+ if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height {
100+ continue
101+ }
102+ cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly)
103+ return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true
104+ }
105+ return "", "", false
106+}
107+
108+// runShot runs nefercap in a goroutine and leaves the result for the owner.
109+func (s *Service) runShot(parent context.Context, args []string) {
110+ s.jobs.Add(1)
111+ go func() {
112+ defer s.jobs.Done()
113+ ctx, cancel := context.WithTimeout(parent, shotTimeout)
114+ defer cancel()
115+ err := s.runner.Run(ctx, args)
116+ s.mu.Lock()
117+ s.shotRes = shotResult{ready: true, err: err}
118+ s.woken = true
119+ s.mu.Unlock()
120+ s.wake()
121+ }()
122+}
123+
124+// onShotDone sends the reply of the screenshot that finished.
125+func (s *Service) onShotDone(r *shotResult) {
126+ st := s.shot
127+ s.shot = shotState{}
128+ if !st.busy {
129+ return
130+ }
131+ if r.err != nil {
132+ s.log.Warn().Err(r.err).Msg("screenshot failed")
133+ _ = os.Remove(st.path)
134+ s.replyErr(&st.req, errFailed, "screenshot failed")
135+ return
136+ }
137+ out := st.path
138+ if st.uri {
139+ out = "file://" + out
140+ }
141+ e := s.replyTo(&st.req, "bs")
142+ if e == nil {
143+ return
144+ }
145+ e.Bool(true)
146+ e.Str(out)
147+ s.send()
148+}
@@ -1,7 +1,33 @@
1 package screencast1 package screencast
2 2
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "strconv"
8+ "time"
9+)
10+
11+// Screenshots are taken by nefercap, run as a child process:
12+//
13+// Screenshot nefercap screenshot -output FOCUSED -file F
14+// ScreenshotArea nefercap screenshot -output O -region X,Y,WxH -file F
15+// InteractiveScreenshot nefercap shot -file F
16+//
17+// F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most
18+// is in flight. The run is a goroutine; its result goes back through the
19+// service's transport and the owner sends the reply, so the bus is still
20+// used by the owner only. SelectArea and ScreenshotWindow (the "Select
21+// area" and "Window" choices of the GNOME dialog) are not supported;
22+// include_cursor and flash are ignored.
23+
3 // shotState is the screenshot in flight; one at most.24 // shotState is the screenshot in flight; one at most.
4-type shotState struct{ busy bool }25+type shotState struct {
26+ busy bool
27+ req request
28+ uri bool // reply with a file:// URI
29+ path string
30+}
5 31
6 // shotResult is what a finished nefercap run leaves for the owner.32 // shotResult is what a finished nefercap run leaves for the owner.
7 type shotResult struct {33 type shotResult struct {
@@ -9,4 +35,114 @@ type shotResult struct {
9 err error35 err error
10 }36 }
11 37
12-func (s *Service) onShotDone(r *shotResult) { _ = r }38+// screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot.
39+func (s *Service) screenshot(req *request) {
40+ if s.shot.busy {
41+ s.replyErr(req, errLimits, "a screenshot is already being taken")
42+ return
43+ }
44+ var args []string
45+ dir := shotDir()
46+ if dir == "" {
47+ s.replyErr(req, errFailed, "no runtime directory")
48+ return
49+ }
50+ path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png")
51+ switch req.method {
52+ case mShot:
53+ out := s.inv.Focused
54+ if out == "" && len(s.inv.Outputs) > 0 {
55+ out = s.inv.Outputs[0].Info.Name
56+ }
57+ if out == "" {
58+ s.replyErr(req, errFailed, "no output")
59+ return
60+ }
61+ args = []string{"screenshot", "-output", out, "-file", path}
62+ case mShotArea:
63+ out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h)
64+ if !ok {
65+ s.replyErr(req, errInvalidArgs, "area outside every output")
66+ return
67+ }
68+ args = []string{"screenshot", "-output", out, "-region", region, "-file", path}
69+ default:
70+ args = []string{"shot", "-file", path}
71+ }
72+ if err := os.MkdirAll(dir, 0o700); err != nil {
73+ s.log.Warn().Err(err).Msg("screenshot directory not created")
74+ s.replyErr(req, errFailed, "screenshot directory not created")
75+ return
76+ }
77+ s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path}
78+ s.runShot(s.ctx, args)
79+}
80+
81+// shotDir is $XDG_RUNTIME_DIR/neferwl.
82+func shotDir() string {
83+ rt := os.Getenv("XDG_RUNTIME_DIR")
84+ if rt == "" {
85+ return ""
86+ }
87+ return filepath.Join(rt, "neferwl")
88+}
89+
90+// areaTarget finds the output that contains the corner (x, y) of an area in
91+// the logical space and the output-local region, clipped to the output.
92+func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) {
93+ if w <= 0 || h <= 0 {
94+ return "", "", false
95+ }
96+ for i := range s.inv.Outputs {
97+ o := &s.inv.Outputs[i]
98+ lx, ly := int(x)-o.X, int(y)-o.Y
99+ if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height {
100+ continue
101+ }
102+ cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly)
103+ return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true
104+ }
105+ return "", "", false
106+}
107+
108+// runShot runs nefercap in a goroutine and leaves the result for the owner.
109+func (s *Service) runShot(parent context.Context, args []string) {
110+ s.jobs.Add(1)
111+ go func() {
112+ defer s.jobs.Done()
113+ ctx, cancel := context.WithTimeout(parent, shotTimeout)
114+ defer cancel()
115+ err := s.runner.Run(ctx, args)
116+ s.mu.Lock()
117+ s.shotRes = shotResult{ready: true, err: err}
118+ s.woken = true
119+ s.mu.Unlock()
120+ s.wake()
121+ }()
122+}
123+
124+// onShotDone sends the reply of the screenshot that finished.
125+func (s *Service) onShotDone(r *shotResult) {
126+ st := s.shot
127+ s.shot = shotState{}
128+ if !st.busy {
129+ return
130+ }
131+ if r.err != nil {
132+ s.log.Warn().Err(r.err).Msg("screenshot failed")
133+ _ = os.Remove(st.path)
134+ s.replyErr(&st.req, errFailed, "screenshot failed")
135+ return
136+ }
137+ out := st.path
138+ if st.uri {
139+ out = "file://" + out
140+ }
141+ e := s.replyTo(&st.req, "bs")
142+ if e == nil {
143+ return
144+ }
145+ e.Bool(true)
146+ e.Str(out)
147+ s.send()
148+}
A internal/adapters/screencast/session.go +298 -0
@@ -0,0 +1,298 @@
1+package screencast
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// session is one org.gnome.Mutter.ScreenCast.Session. The sessions live in a
10+// fixed array of Service, so pointers into it (the streams' back references,
11+// the PipeWire handlers) stay valid.
12+type session struct {
13+ used bool
14+ id uint32
15+ path string
16+ owner string // unique name that called CreateSession
17+ // started: Start was called, the streams were opened.
18+ started bool
19+ // dying: the session is closed to the bus; the slot is freed once every
20+ // stream released its PipeWire resources.
21+ dying bool
22+ streams [maxStreams]stream
23+}
24+
25+func (s *Service) sessionByID(id uint32) *session {
26+ for i := range s.sessions {
27+ if sess := &s.sessions[i]; sess.used && !sess.dying && sess.id == id {
28+ return sess
29+ }
30+ }
31+ return nil
32+}
33+
34+func (s *Service) streamByID(id uint32) *stream {
35+ for i := range s.sessions {
36+ sess := &s.sessions[i]
37+ if !sess.used || sess.dying {
38+ continue
39+ }
40+ for j := range sess.streams {
41+ if st := &sess.streams[j]; st.used && st.id == id {
42+ return st
43+ }
44+ }
45+ }
46+ return nil
47+}
48+
49+func (s *Service) forEachSession(f func(*session)) {
50+ for i := range s.sessions {
51+ if s.sessions[i].used {
52+ f(&s.sessions[i])
53+ }
54+ }
55+}
56+
57+func (s *Service) forEachStream(f func(*stream)) {
58+ for i := range s.sessions {
59+ sess := &s.sessions[i]
60+ if !sess.used {
61+ continue
62+ }
63+ for j := range sess.streams {
64+ if sess.streams[j].used {
65+ f(&sess.streams[j])
66+ }
67+ }
68+ }
69+}
70+
71+// createSession answers CreateSession.
72+func (s *Service) createSession(req *request) {
73+ if req.remote {
74+ s.replyErr(req, errNotSupported, "remote desktop sessions are not supported")
75+ return
76+ }
77+ var sess *session
78+ for i := range s.sessions {
79+ if !s.sessions[i].used {
80+ sess = &s.sessions[i]
81+ break
82+ }
83+ }
84+ if sess == nil {
85+ s.replyErr(req, errLimits, "too many screen cast sessions")
86+ return
87+ }
88+ s.nextSess++
89+ *sess = session{used: true, id: s.nextSess, owner: req.sender}
90+ sess.path = prefixSession + strconv.FormatUint(uint64(sess.id), 10)
91+ s.log.Info().Str("sender", req.sender).Str("session", sess.path).Msg("screen cast session created")
92+ if e := s.replyTo(req, "o"); e != nil {
93+ e.ObjectPath(sess.path)
94+ s.send()
95+ }
96+}
97+
98+// record answers RecordMonitor and RecordWindow: it adds a stream.
99+func (s *Service) record(req *request, sess *session) {
100+ if sess.started {
101+ s.replyErr(req, errFailed, "session already started")
102+ return
103+ }
104+ var st *stream
105+ for i := range sess.streams {
106+ if !sess.streams[i].used {
107+ st = &sess.streams[i]
108+ break
109+ }
110+ }
111+ if st == nil {
112+ s.replyErr(req, errLimits, "too many streams in the session")
113+ return
114+ }
115+ *st = stream{used: true, sess: sess, cursor: req.cursor != 0, maxFPS: defaultFPS, pwState: -2}
116+ if req.method == mRecordWindow {
117+ if !s.haveWindow(ports.WindowID(req.window)) {
118+ *st = stream{}
119+ s.replyErr(req, errInvalidArgs, "no such window")
120+ return
121+ }
122+ st.window = ports.WindowID(req.window)
123+ // A window moves and resizes: the stream carries no position.
124+ st.px, st.py, st.pw, st.ph = 0, 0, 1, 1
125+ } else {
126+ o := s.outputByName(req.text)
127+ if o == nil {
128+ *st = stream{}
129+ s.replyErr(req, errInvalidArgs, "no such monitor")
130+ return
131+ }
132+ st.output = o.Info.Name
133+ st.px, st.py, st.pw, st.ph = int32(o.X), int32(o.Y), int32(o.Width), int32(o.Height)
134+ if o.Current >= 0 && o.Current < len(o.Modes) {
135+ if hz := o.Modes[o.Current].RefreshMilli / 1000; hz > 0 {
136+ st.maxFPS = min(hz, 240)
137+ }
138+ }
139+ }
140+ s.nextStrm++
141+ s.nextCast++
142+ st.id, st.cast = s.nextStrm, s.nextCast
143+ st.path = prefixStream + strconv.FormatUint(uint64(st.id), 10)
144+ st.h2 = streamHandler{st: st}
145+ if e := s.replyTo(req, "o"); e != nil {
146+ e.ObjectPath(st.path)
147+ s.send()
148+ }
149+}
150+
151+// start answers Start: the streams are opened on the wayland side.
152+func (s *Service) start(req *request, sess *session) {
153+ if sess.started {
154+ s.replyErr(req, errFailed, "session already started")
155+ return
156+ }
157+ n := 0
158+ for i := range sess.streams {
159+ if sess.streams[i].used {
160+ sess.streams[i].needOpen = true
161+ n++
162+ }
163+ }
164+ if n == 0 {
165+ s.replyErr(req, errFailed, "session has no stream")
166+ return
167+ }
168+ sess.started = true
169+ s.reply(req)
170+}
171+
172+// closeSession ends a session: its streams are closed on the wayland side
173+// and, with signal set, the owner is told. The slot is freed by reap.
174+func (s *Service) closeSession(sess *session, signal bool) {
175+ if sess.dying {
176+ return
177+ }
178+ sess.dying = true
179+ for i := range sess.streams {
180+ if st := &sess.streams[i]; st.used {
181+ st.close()
182+ if !st.opened && st.src == nil {
183+ *st = stream{}
184+ }
185+ }
186+ }
187+ if signal {
188+ s.emitTo(sess.owner, sess.path, ifaceSession, "Closed")
189+ s.send()
190+ }
191+ s.log.Info().Str("session", sess.path).Msg("screen cast session closed")
192+}
193+
194+// outputByName finds an output of the inventory by connector.
195+func (s *Service) outputByName(name string) *ports.CastOutput {
196+ for i := range s.inv.Outputs {
197+ if s.inv.Outputs[i].Info.Name == name {
198+ return &s.inv.Outputs[i]
199+ }
200+ }
201+ return nil
202+}
203+
204+func (s *Service) haveWindow(id ports.WindowID) bool {
205+ for i := range s.inv.Windows {
206+ if s.inv.Windows[i].ID == id {
207+ return true
208+ }
209+ }
210+ return false
211+}
212+
213+// properties answers Properties.Get, GetAll and Set.
214+func (s *Service) properties(req *request, sess *session, st *stream) {
215+ if req.method == mSet {
216+ s.replyErr(req, errPropertyReadOnly, "no writable property")
217+ return
218+ }
219+ iface := ""
220+ switch req.obj {
221+ case objScreenCast:
222+ iface = ifaceScreenCast
223+ case objSession:
224+ iface = ifaceSession
225+ case objStream:
226+ iface = ifaceStream
227+ case objDisplayConfig:
228+ iface = ifaceDisplayConfig
229+ case objIntrospect:
230+ iface = ifaceIntrospect
231+ case objScreenshot:
232+ iface = ifaceScreenshot
233+ }
234+ if req.text != iface && !(req.method == mGetAll && req.text == "") {
235+ s.replyErr(req, errUnknownInterface, "no such interface")
236+ return
237+ }
238+ if req.method == mGetAll {
239+ e := s.replyTo(req, "a{sv}")
240+ if e == nil {
241+ return
242+ }
243+ a := e.BeginArray('{')
244+ switch req.obj {
245+ case objScreenCast:
246+ e.Struct()
247+ e.Str("Version")
248+ e.Variant("i")
249+ e.Int32(screenCastVersion)
250+ case objStream:
251+ e.Struct()
252+ e.Str("Parameters")
253+ e.Variant("a{sv}")
254+ encodeParameters(e, st)
255+ case objIntrospect:
256+ e.Struct()
257+ e.Str("version")
258+ e.Variant("u")
259+ e.Uint32(introspectVersion)
260+ }
261+ e.EndArray(a)
262+ s.send()
263+ return
264+ }
265+ switch {
266+ case req.obj == objScreenCast && req.text2 == "Version":
267+ if e := s.replyTo(req, "v"); e != nil {
268+ e.Variant("i")
269+ e.Int32(screenCastVersion)
270+ s.send()
271+ }
272+ case req.obj == objStream && req.text2 == "Parameters":
273+ if e := s.replyTo(req, "v"); e != nil {
274+ e.Variant("a{sv}")
275+ encodeParameters(e, st)
276+ s.send()
277+ }
278+ case req.obj == objIntrospect && req.text2 == "version":
279+ if e := s.replyTo(req, "v"); e != nil {
280+ e.Variant("u")
281+ e.Uint32(introspectVersion)
282+ s.send()
283+ }
284+ default:
285+ s.replyErr(req, errUnknownProperty, "no such property")
286+ }
287+}
288+
289+// Versions served. Introspect version 2 would make x-d-p-g read
290+// AnimationsEnabled and ScreenSize, which are not served.
291+const (
292+ screenCastVersion = 4
293+ introspectVersion = 1
294+ defaultFPS = 60
295+)
296+
297+func itoa(n int) string { return strconv.Itoa(n) }
298+func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 3, 64) }
@@ -0,0 +1,298 @@
1+package screencast
2+
3+import (
4+ "strconv"
5+
6+ "github.com/bnema/neferwl/internal/ports"
7+)
8+
9+// session is one org.gnome.Mutter.ScreenCast.Session. The sessions live in a
10+// fixed array of Service, so pointers into it (the streams' back references,
11+// the PipeWire handlers) stay valid.
12+type session struct {
13+ used bool
14+ id uint32
15+ path string
16+ owner string // unique name that called CreateSession
17+ // started: Start was called, the streams were opened.
18+ started bool
19+ // dying: the session is closed to the bus; the slot is freed once every
20+ // stream released its PipeWire resources.
21+ dying bool
22+ streams [maxStreams]stream
23+}
24+
25+func (s *Service) sessionByID(id uint32) *session {
26+ for i := range s.sessions {
27+ if sess := &s.sessions[i]; sess.used && !sess.dying && sess.id == id {
28+ return sess
29+ }
30+ }
31+ return nil
32+}
33+
34+func (s *Service) streamByID(id uint32) *stream {
35+ for i := range s.sessions {
36+ sess := &s.sessions[i]
37+ if !sess.used || sess.dying {
38+ continue
39+ }
40+ for j := range sess.streams {
41+ if st := &sess.streams[j]; st.used && st.id == id {
42+ return st
43+ }
44+ }
45+ }
46+ return nil
47+}
48+
49+func (s *Service) forEachSession(f func(*session)) {
50+ for i := range s.sessions {
51+ if s.sessions[i].used {
52+ f(&s.sessions[i])
53+ }
54+ }
55+}
56+
57+func (s *Service) forEachStream(f func(*stream)) {
58+ for i := range s.sessions {
59+ sess := &s.sessions[i]
60+ if !sess.used {
61+ continue
62+ }
63+ for j := range sess.streams {
64+ if sess.streams[j].used {
65+ f(&sess.streams[j])
66+ }
67+ }
68+ }
69+}
70+
71+// createSession answers CreateSession.
72+func (s *Service) createSession(req *request) {
73+ if req.remote {
74+ s.replyErr(req, errNotSupported, "remote desktop sessions are not supported")
75+ return
76+ }
77+ var sess *session
78+ for i := range s.sessions {
79+ if !s.sessions[i].used {
80+ sess = &s.sessions[i]
81+ break
82+ }
83+ }
84+ if sess == nil {
85+ s.replyErr(req, errLimits, "too many screen cast sessions")
86+ return
87+ }
88+ s.nextSess++
89+ *sess = session{used: true, id: s.nextSess, owner: req.sender}
90+ sess.path = prefixSession + strconv.FormatUint(uint64(sess.id), 10)
91+ s.log.Info().Str("sender", req.sender).Str("session", sess.path).Msg("screen cast session created")
92+ if e := s.replyTo(req, "o"); e != nil {
93+ e.ObjectPath(sess.path)
94+ s.send()
95+ }
96+}
97+
98+// record answers RecordMonitor and RecordWindow: it adds a stream.
99+func (s *Service) record(req *request, sess *session) {
100+ if sess.started {
101+ s.replyErr(req, errFailed, "session already started")
102+ return
103+ }
104+ var st *stream
105+ for i := range sess.streams {
106+ if !sess.streams[i].used {
107+ st = &sess.streams[i]
108+ break
109+ }
110+ }
111+ if st == nil {
112+ s.replyErr(req, errLimits, "too many streams in the session")
113+ return
114+ }
115+ *st = stream{used: true, sess: sess, cursor: req.cursor != 0, maxFPS: defaultFPS, pwState: -2}
116+ if req.method == mRecordWindow {
117+ if !s.haveWindow(ports.WindowID(req.window)) {
118+ *st = stream{}
119+ s.replyErr(req, errInvalidArgs, "no such window")
120+ return
121+ }
122+ st.window = ports.WindowID(req.window)
123+ // A window moves and resizes: the stream carries no position.
124+ st.px, st.py, st.pw, st.ph = 0, 0, 1, 1
125+ } else {
126+ o := s.outputByName(req.text)
127+ if o == nil {
128+ *st = stream{}
129+ s.replyErr(req, errInvalidArgs, "no such monitor")
130+ return
131+ }
132+ st.output = o.Info.Name
133+ st.px, st.py, st.pw, st.ph = int32(o.X), int32(o.Y), int32(o.Width), int32(o.Height)
134+ if o.Current >= 0 && o.Current < len(o.Modes) {
135+ if hz := o.Modes[o.Current].RefreshMilli / 1000; hz > 0 {
136+ st.maxFPS = min(hz, 240)
137+ }
138+ }
139+ }
140+ s.nextStrm++
141+ s.nextCast++
142+ st.id, st.cast = s.nextStrm, s.nextCast
143+ st.path = prefixStream + strconv.FormatUint(uint64(st.id), 10)
144+ st.h2 = streamHandler{st: st}
145+ if e := s.replyTo(req, "o"); e != nil {
146+ e.ObjectPath(st.path)
147+ s.send()
148+ }
149+}
150+
151+// start answers Start: the streams are opened on the wayland side.
152+func (s *Service) start(req *request, sess *session) {
153+ if sess.started {
154+ s.replyErr(req, errFailed, "session already started")
155+ return
156+ }
157+ n := 0
158+ for i := range sess.streams {
159+ if sess.streams[i].used {
160+ sess.streams[i].needOpen = true
161+ n++
162+ }
163+ }
164+ if n == 0 {
165+ s.replyErr(req, errFailed, "session has no stream")
166+ return
167+ }
168+ sess.started = true
169+ s.reply(req)
170+}
171+
172+// closeSession ends a session: its streams are closed on the wayland side
173+// and, with signal set, the owner is told. The slot is freed by reap.
174+func (s *Service) closeSession(sess *session, signal bool) {
175+ if sess.dying {
176+ return
177+ }
178+ sess.dying = true
179+ for i := range sess.streams {
180+ if st := &sess.streams[i]; st.used {
181+ st.close()
182+ if !st.opened && st.src == nil {
183+ *st = stream{}
184+ }
185+ }
186+ }
187+ if signal {
188+ s.emitTo(sess.owner, sess.path, ifaceSession, "Closed")
189+ s.send()
190+ }
191+ s.log.Info().Str("session", sess.path).Msg("screen cast session closed")
192+}
193+
194+// outputByName finds an output of the inventory by connector.
195+func (s *Service) outputByName(name string) *ports.CastOutput {
196+ for i := range s.inv.Outputs {
197+ if s.inv.Outputs[i].Info.Name == name {
198+ return &s.inv.Outputs[i]
199+ }
200+ }
201+ return nil
202+}
203+
204+func (s *Service) haveWindow(id ports.WindowID) bool {
205+ for i := range s.inv.Windows {
206+ if s.inv.Windows[i].ID == id {
207+ return true
208+ }
209+ }
210+ return false
211+}
212+
213+// properties answers Properties.Get, GetAll and Set.
214+func (s *Service) properties(req *request, sess *session, st *stream) {
215+ if req.method == mSet {
216+ s.replyErr(req, errPropertyReadOnly, "no writable property")
217+ return
218+ }
219+ iface := ""
220+ switch req.obj {
221+ case objScreenCast:
222+ iface = ifaceScreenCast
223+ case objSession:
224+ iface = ifaceSession
225+ case objStream:
226+ iface = ifaceStream
227+ case objDisplayConfig:
228+ iface = ifaceDisplayConfig
229+ case objIntrospect:
230+ iface = ifaceIntrospect
231+ case objScreenshot:
232+ iface = ifaceScreenshot
233+ }
234+ if req.text != iface && !(req.method == mGetAll && req.text == "") {
235+ s.replyErr(req, errUnknownInterface, "no such interface")
236+ return
237+ }
238+ if req.method == mGetAll {
239+ e := s.replyTo(req, "a{sv}")
240+ if e == nil {
241+ return
242+ }
243+ a := e.BeginArray('{')
244+ switch req.obj {
245+ case objScreenCast:
246+ e.Struct()
247+ e.Str("Version")
248+ e.Variant("i")
249+ e.Int32(screenCastVersion)
250+ case objStream:
251+ e.Struct()
252+ e.Str("Parameters")
253+ e.Variant("a{sv}")
254+ encodeParameters(e, st)
255+ case objIntrospect:
256+ e.Struct()
257+ e.Str("version")
258+ e.Variant("u")
259+ e.Uint32(introspectVersion)
260+ }
261+ e.EndArray(a)
262+ s.send()
263+ return
264+ }
265+ switch {
266+ case req.obj == objScreenCast && req.text2 == "Version":
267+ if e := s.replyTo(req, "v"); e != nil {
268+ e.Variant("i")
269+ e.Int32(screenCastVersion)
270+ s.send()
271+ }
272+ case req.obj == objStream && req.text2 == "Parameters":
273+ if e := s.replyTo(req, "v"); e != nil {
274+ e.Variant("a{sv}")
275+ encodeParameters(e, st)
276+ s.send()
277+ }
278+ case req.obj == objIntrospect && req.text2 == "version":
279+ if e := s.replyTo(req, "v"); e != nil {
280+ e.Variant("u")
281+ e.Uint32(introspectVersion)
282+ s.send()
283+ }
284+ default:
285+ s.replyErr(req, errUnknownProperty, "no such property")
286+ }
287+}
288+
289+// Versions served. Introspect version 2 would make x-d-p-g read
290+// AnimationsEnabled and ScreenSize, which are not served.
291+const (
292+ screenCastVersion = 4
293+ introspectVersion = 1
294+ defaultFPS = 60
295+)
296+
297+func itoa(n int) string { return strconv.Itoa(n) }
298+func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 3, 64) }
M internal/adapters/screencast/stream.go +98 -11
@@ -4,23 +4,110 @@ import (
44 "time"
55
66 "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+ "github.com/bnema/zerobus"
79 )
810
9-// stream is one cast of a session.
11+// stream is one cast of a session: a PipeWire node fed with frames that the
12+// wayland server renders. Everything here is touched by the owner goroutine
13+// only; the PipeWire handler runs on it too, inside Loop.Iterate.
1014 type stream struct {
11- used bool
12- dirty bool
15+ used bool
16+ sess *session
17+ id uint32 // number in the object path
18+ cast uint64 // ID in the ports.CastCommands
19+ path string
20+
21+ // Target.
22+ output string
23+ window ports.WindowID
24+ cursor bool
25+ maxFPS int
26+ // Stream.Parameters: position and size in the compositor space.
27+ px, py, pw, ph int32
28+
29+ // Lifecycle.
30+ needOpen bool // CastOpen is yet to be sent
31+ opened bool // CastOpen was sent
32+ needClose bool // CastClose is yet to be sent
33+ closing bool // the session ended: free once no frame is in flight
34+ stopped bool // CastStopped came: the wayland side is done
35+
36+ // Negotiation.
37+ w, h int // size of the target, from CastSize
38+ haveSize bool
39+ needResize bool
40+ src videoSource
41+ h2 streamHandler
42+ pwState int32 // last state of the PipeWire stream; -2 before the first
43+ seenState bool // a state above UNCONNECTED was seen
44+ nodeID uint32
45+ announced bool // PipeWireStreamAdded was sent
46+ format pipewire.VideoFormat
47+ fw, fh int // size the format was negotiated at; 0 while renegotiating
48+
49+ // Frames.
50+ dirty bool
51+ forced bool // the first frame after STREAMING is not paced by damage
52+ inflight bool
53+ buf *pipewire.VideoBuffer
54+ lastReq time.Time
55+ seq uint64
56+ errs int // consecutive failed frames
1357 }
1458
15-func (s *Service) onCastEvent(ev *ports.CastEvent) { _ = ev }
59+// streamHandler is the pipewire.VideoHandler of a stream. Its callbacks run
60+// inside Loop.Iterate: they only record, the pump acts.
61+type streamHandler struct{ st *stream }
1662
17-func (s *Service) forEachStream(f func(*stream)) {
18- for i := range s.sessions {
19- _ = f
20- _ = i
63+func (h *streamHandler) StateChanged(state int32, nodeID uint32, _ string) {
64+ st := h.st
65+ st.pwState = state
66+ if nodeID != 0 {
67+ st.nodeID = nodeID
68+ }
69+ if state > pipewire.StreamStateUnconnected {
70+ st.seenState = true
71+ }
72+ if state == pipewire.StreamStateStreaming {
73+ st.forced = true
2174 }
2275 }
2376
24-func (s *Service) pumpAll(now time.Time) {}
25-func (s *Service) reap() {}
26-func (s *Service) streamsLive() bool { return false }
77+func (h *streamHandler) FormatChanged(f pipewire.VideoFormat, w, hh, _ int) {
78+ h.st.format, h.st.fw, h.st.fh = f, w, hh
79+ // A new format shows a new picture: the first frame is not paced by damage.
80+ h.st.forced = true
81+}
82+
83+// BuffersChanged: the next pump looks for a free buffer anyway.
84+func (h *streamHandler) BuffersChanged() {}
85+
86+// close ends the stream on the wayland side; the slot is freed by reap once
87+// no frame is in flight.
88+func (st *stream) close() {
89+ if st.closing {
90+ return
91+ }
92+ st.closing = true
93+ st.needOpen = false
94+ st.needClose = st.opened && !st.stopped
95+}
96+
97+// encodeParameters writes the a{sv} of Stream.Parameters.
98+func encodeParameters(e *zerobus.Encoder, st *stream) {
99+ a := e.BeginArray('{')
100+ e.Struct()
101+ e.Str("position")
102+ e.Variant("(ii)")
103+ e.Struct()
104+ e.Int32(st.px)
105+ e.Int32(st.py)
106+ e.Struct()
107+ e.Str("size")
108+ e.Variant("(ii)")
109+ e.Struct()
110+ e.Int32(st.pw)
111+ e.Int32(st.ph)
112+ e.EndArray(a)
113+}
@@ -4,23 +4,110 @@ import (
4 "time"4 "time"
5 5
6 "github.com/bnema/neferwl/internal/ports"6 "github.com/bnema/neferwl/internal/ports"
7+ "github.com/bnema/purego-pipewire/pipewire"
8+ "github.com/bnema/zerobus"
7 )9 )
8 10
9-// stream is one cast of a session.11+// stream is one cast of a session: a PipeWire node fed with frames that the
12+// wayland server renders. Everything here is touched by the owner goroutine
13+// only; the PipeWire handler runs on it too, inside Loop.Iterate.
10 type stream struct {14 type stream struct {
11- used bool15+ used bool
12- dirty bool16+ sess *session
17+ id uint32 // number in the object path
18+ cast uint64 // ID in the ports.CastCommands
19+ path string
20+
21+ // Target.
22+ output string
23+ window ports.WindowID
24+ cursor bool
25+ maxFPS int
26+ // Stream.Parameters: position and size in the compositor space.
27+ px, py, pw, ph int32
28+
29+ // Lifecycle.
30+ needOpen bool // CastOpen is yet to be sent
31+ opened bool // CastOpen was sent
32+ needClose bool // CastClose is yet to be sent
33+ closing bool // the session ended: free once no frame is in flight
34+ stopped bool // CastStopped came: the wayland side is done
35+
36+ // Negotiation.
37+ w, h int // size of the target, from CastSize
38+ haveSize bool
39+ needResize bool
40+ src videoSource
41+ h2 streamHandler
42+ pwState int32 // last state of the PipeWire stream; -2 before the first
43+ seenState bool // a state above UNCONNECTED was seen
44+ nodeID uint32
45+ announced bool // PipeWireStreamAdded was sent
46+ format pipewire.VideoFormat
47+ fw, fh int // size the format was negotiated at; 0 while renegotiating
48+
49+ // Frames.
50+ dirty bool
51+ forced bool // the first frame after STREAMING is not paced by damage
52+ inflight bool
53+ buf *pipewire.VideoBuffer
54+ lastReq time.Time
55+ seq uint64
56+ errs int // consecutive failed frames
13 }57 }
14 58
15-func (s *Service) onCastEvent(ev *ports.CastEvent) { _ = ev }59+// streamHandler is the pipewire.VideoHandler of a stream. Its callbacks run
60+// inside Loop.Iterate: they only record, the pump acts.
61+type streamHandler struct{ st *stream }
16 62
17-func (s *Service) forEachStream(f func(*stream)) {63+func (h *streamHandler) StateChanged(state int32, nodeID uint32, _ string) {
18- for i := range s.sessions {64+ st := h.st
19- _ = f65+ st.pwState = state
20- _ = i66+ if nodeID != 0 {
67+ st.nodeID = nodeID
68+ }
69+ if state > pipewire.StreamStateUnconnected {
70+ st.seenState = true
71+ }
72+ if state == pipewire.StreamStateStreaming {
73+ st.forced = true
21 }74 }
22 }75 }
23 76
24-func (s *Service) pumpAll(now time.Time) {}77+func (h *streamHandler) FormatChanged(f pipewire.VideoFormat, w, hh, _ int) {
25-func (s *Service) reap() {}78+ h.st.format, h.st.fw, h.st.fh = f, w, hh
26-func (s *Service) streamsLive() bool { return false }79+ // A new format shows a new picture: the first frame is not paced by damage.
80+ h.st.forced = true
81+}
82+
83+// BuffersChanged: the next pump looks for a free buffer anyway.
84+func (h *streamHandler) BuffersChanged() {}
85+
86+// close ends the stream on the wayland side; the slot is freed by reap once
87+// no frame is in flight.
88+func (st *stream) close() {
89+ if st.closing {
90+ return
91+ }
92+ st.closing = true
93+ st.needOpen = false
94+ st.needClose = st.opened && !st.stopped
95+}
96+
97+// encodeParameters writes the a{sv} of Stream.Parameters.
98+func encodeParameters(e *zerobus.Encoder, st *stream) {
99+ a := e.BeginArray('{')
100+ e.Struct()
101+ e.Str("position")
102+ e.Variant("(ii)")
103+ e.Struct()
104+ e.Int32(st.px)
105+ e.Int32(st.py)
106+ e.Struct()
107+ e.Str("size")
108+ e.Variant("(ii)")
109+ e.Struct()
110+ e.Int32(st.pw)
111+ e.Int32(st.ph)
112+ e.EndArray(a)
113+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close