detached c050b9a

feat(screencast): serve Mutter screencast and Shell screenshot over D-Bus

commit
c050b9af2ea1df0a641ce942f2b2a23fe69e495d
author
brice · · 2026-10-14 20:34 UTC
committer
brice · · 2026-10-14 21:39 UTC
parent
9823f75
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +464 -11
M internal/adapters/logging/logging.go +1 -1
@@ -22,7 +22,7 @@ import (
2222 var components = map[string]bool{
2323 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
2424 "render": true, "sync": true, "config": true, "app": true,
25- "screensaver": true, "powerkey": true, "sched": true,
25+ "screensaver": true, "powerkey": true, "sched": true, "screencast": true,
2626 }
2727
2828 // categories are debug switches narrower than a component, only on when
@@ -22,7 +22,7 @@ import (
22 var components = map[string]bool{22 var components = map[string]bool{
23 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,23 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
24 "render": true, "sync": true, "config": true, "app": true,24 "render": true, "sync": true, "config": true, "app": true,
25- "screensaver": true, "powerkey": true, "sched": true,25+ "screensaver": true, "powerkey": true, "sched": true, "screencast": true,
26 }26 }
27 27
28 // categories are debug switches narrower than a component, only on when28 // categories are debug switches narrower than a component, only on when
M internal/adapters/screencast/bus.go +3 -3
@@ -112,8 +112,8 @@ func capStr(s string) string {
112112 return strings.Clone(s)
113113 }
114114
115-// resolve finds the object of a path. A served object needs its bus name.
116-func (s *Service) resolve(path string) (objKind, uint32) {
115+// objectOf finds the object of a path. A served object needs its bus name.
116+func (s *Service) objectOf(path string) (objKind, uint32) {
117117 switch path {
118118 case pathScreenCast:
119119 return objScreenCast, 0
@@ -161,7 +161,7 @@ func (s *Service) served(k objKind) bool {
161161 // decode reads a call completely: nothing of m is used after it returns.
162162 func (s *Service) decode(m *zerobus.Message) request {
163163 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)
164+ req.obj, req.id = s.objectOf(m.Path)
165165 if !s.served(req.obj) {
166166 req.method = mUnknownObject
167167 return req
@@ -112,8 +112,8 @@ func capStr(s string) string {
112 return strings.Clone(s)112 return strings.Clone(s)
113 }113 }
114 114
115-// resolve finds the object of a path. A served object needs its bus name.115+// objectOf finds the object of a path. A served object needs its bus name.
116-func (s *Service) resolve(path string) (objKind, uint32) {116+func (s *Service) objectOf(path string) (objKind, uint32) {
117 switch path {117 switch path {
118 case pathScreenCast:118 case pathScreenCast:
119 return objScreenCast, 0119 return objScreenCast, 0
@@ -161,7 +161,7 @@ func (s *Service) served(k objKind) bool {
161 // decode reads a call completely: nothing of m is used after it returns.161 // decode reads a call completely: nothing of m is used after it returns.
162 func (s *Service) decode(m *zerobus.Message) request {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}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)164+ req.obj, req.id = s.objectOf(m.Path)
165 if !s.served(req.obj) {165 if !s.served(req.obj) {
166 req.method = mUnknownObject166 req.method = mUnknownObject
167 return req167 return req
M internal/app/app.go +5 -1
@@ -27,7 +27,11 @@ type Options struct {
2727 HeadlessHDR bool
2828 // Session exports the displays to D-Bus and systemd user services
2929 // before any client starts, and withdraws them on exit.
30- Session bool
30+ Session bool
31+ // DBus serves the session D-Bus services (idle inhibition, screen
32+ // casting and screenshots) of a headless run, which otherwise leaves the
33+ // session bus alone. For tests under dbus-run-session.
34+ DBus bool
3135 ScreenshotDir string
3236 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
3337 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
@@ -27,7 +27,11 @@ type Options struct {
27 HeadlessHDR bool27 HeadlessHDR bool
28 // Session exports the displays to D-Bus and systemd user services28 // Session exports the displays to D-Bus and systemd user services
29 // before any client starts, and withdraws them on exit.29 // before any client starts, and withdraws them on exit.
30- Session bool30+ Session bool
31+ // DBus serves the session D-Bus services (idle inhibition, screen
32+ // casting and screenshots) of a headless run, which otherwise leaves the
33+ // session bus alone. For tests under dbus-run-session.
34+ DBus bool
31 ScreenshotDir string35 ScreenshotDir string
32 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a36 // ScreenshotRaw also writes latest-pq.png, the raw PQ codes of a
33 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).37 // virtual HDR output (test-only; needs ScreenshotDir and HeadlessHDR).
A internal/app/screencast_test.go +418 -0
@@ -0,0 +1,418 @@
1+//go:build screencast
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "runtime"
12+ "strconv"
13+ "strings"
14+ "sync/atomic"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/neferwl/internal/adapters/config"
19+ "github.com/bnema/neferwl/internal/ports"
20+ "github.com/bnema/zerobus"
21+)
22+
23+// The screencast tests need a session bus of their own (run them under
24+// dbus-run-session), the user's PipeWire and gst-launch-1.0 as the consumer.
25+
26+const (
27+ scName = "org.gnome.Mutter.ScreenCast"
28+ scPath = "/org/gnome/Mutter/ScreenCast"
29+ scSession = "org.gnome.Mutter.ScreenCast.Session"
30+ scStream = "org.gnome.Mutter.ScreenCast.Stream"
31+ scDenied = "org.freedesktop.DBus.Error.AccessDenied"
32+)
33+
34+// castApp is a headless NeferWL serving the D-Bus services.
35+type castApp struct {
36+ input chan<- ports.InputEvent
37+ stop func()
38+}
39+
40+func requireCastEnv(t *testing.T) {
41+ t.Helper()
42+ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
43+ t.Skip("no session bus: run under dbus-run-session")
44+ }
45+ if _, err := exec.LookPath("gst-launch-1.0"); err != nil {
46+ t.Skip("gst-launch-1.0 unavailable")
47+ }
48+ if err := exec.Command("pw-dump").Run(); err != nil {
49+ t.Skipf("PipeWire is not running: %v", err)
50+ }
51+}
52+
53+// startCastApp runs the compositor with the capture allowlist content. The
54+// PipeWire socket stays where it is: only the Wayland socket and the state
55+// move to temporary directories.
56+func startCastApp(t *testing.T, allow string) *castApp {
57+ t.Helper()
58+ t.Setenv("PIPEWIRE_RUNTIME_DIR", os.Getenv("XDG_RUNTIME_DIR"))
59+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
60+ t.Setenv("XDG_STATE_HOME", t.TempDir())
61+ t.Setenv("HOME", t.TempDir())
62+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
63+ if err := os.WriteFile(allowPath, []byte(allow), 0o644); err != nil {
64+ t.Fatal(err)
65+ }
66+ ctx, cancel := context.WithCancel(context.Background())
67+ inputReady := make(chan chan<- ports.InputEvent, 1)
68+ done := make(chan error, 1)
69+ go func() {
70+ done <- run(ctx, Options{Backend: "headless", DBus: true, NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{320, 240}}, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())},
71+ func(in chan<- ports.InputEvent) { inputReady <- in })
72+ }()
73+ a := &castApp{}
74+ select {
75+ case a.input = <-inputReady:
76+ case <-time.After(10 * time.Second):
77+ cancel()
78+ t.Fatal("app did not start")
79+ }
80+ a.stop = func() {
81+ cancel()
82+ select {
83+ case err := <-done:
84+ if err != nil && !strings.Contains(err.Error(), "context canceled") {
85+ t.Errorf("Run: %v", err)
86+ }
87+ case <-time.After(10 * time.Second):
88+ t.Error("Run did not stop")
89+ }
90+ }
91+ t.Cleanup(a.stop)
92+ return a
93+}
94+
95+// castClient is a client of the D-Bus services.
96+func castClient(t *testing.T) *zerobus.Conn {
97+ t.Helper()
98+ c, err := zerobus.SessionBus()
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ t.Cleanup(func() { _ = c.Close() })
103+ return c
104+}
105+
106+func waitOwner(t *testing.T, c *zerobus.Conn, name string) {
107+ t.Helper()
108+ for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
109+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
110+ e.Str(name)
111+ m, err := c.Call()
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if m.Body().Bool() {
116+ return
117+ }
118+ }
119+ t.Fatalf("%s never appeared", name)
120+}
121+
122+func emptyProps(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }
123+
124+// call returns the error name, "" on success.
125+func call(c *zerobus.Conn, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
126+ e := c.NewCall(scName, path, iface, member, sig)
127+ if body != nil {
128+ body(e)
129+ }
130+ m, err := c.Call()
131+ if err == nil {
132+ return "", m
133+ }
134+ if de, ok := err.(*zerobus.Error); ok {
135+ return de.Name, m
136+ }
137+ return err.Error(), m
138+}
139+
140+func recordMonitor(c *zerobus.Conn, session, connector string) (string, string) {
141+ name, m := call(c, session, scSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
142+ e.Str(connector)
143+ a := e.BeginArray('{')
144+ e.Struct()
145+ e.Str("cursor-mode")
146+ e.Variant("u")
147+ e.Uint32(1)
148+ e.EndArray(a)
149+ })
150+ if name != "" {
151+ return name, ""
152+ }
153+ return "", strings.Clone(m.Body().ObjectPath())
154+}
155+
156+// A monitor cast streams live to a PipeWire consumer, allocates next to
157+// nothing per frame, refuses another client's calls and ends on Stop.
158+func TestScreencastStreamsMonitor(t *testing.T) {
159+ requireCastEnv(t)
160+ app := startCastApp(t, "*\n")
161+ c := castClient(t)
162+ waitOwner(t, c, scName)
163+ // The inventory reaches the service after the outputs appear.
164+ var session, stream string
165+ for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(50 * time.Millisecond) {
166+ name, m := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps)
167+ if name != "" {
168+ t.Fatalf("CreateSession: %s", name)
169+ }
170+ session = strings.Clone(m.Body().ObjectPath())
171+ var errName string
172+ if errName, stream = recordMonitor(c, session, "HEADLESS-1"); errName == "" {
173+ break
174+ }
175+ call(c, session, scSession, "Stop", "", nil)
176+ if time.Now().After(deadline) {
177+ t.Fatalf("RecordMonitor: %s", errName)
178+ }
179+ }
180+ rule := "type='signal',interface='" + scStream + "'"
181+ if err := c.AddMatch(rule); err != nil {
182+ t.Fatal(err)
183+ }
184+ if err := c.AddMatch("type='signal',interface='" + scSession + "'"); err != nil {
185+ t.Fatal(err)
186+ }
187+ if name, _ := call(c, session, scSession, "Start", "", nil); name != "" {
188+ t.Fatalf("Start: %s", name)
189+ }
190+ node := uint32(0)
191+ for node == 0 {
192+ m, err := c.ReadMessage()
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ if m.Member == "PipeWireStreamAdded" && m.Path == stream && m.Destination == c.UniqueName() {
197+ node = m.Body().Uint32()
198+ }
199+ }
200+
201+ // Pointer motion damages the output while the consumer runs. The events
202+ // are boxed once: sending them allocates nothing.
203+ var moving atomic.Bool
204+ moving.Store(true)
205+ a := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 40, Y: 40}})
206+ b := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 200, Y: 160}})
207+ moved := make(chan struct{})
208+ go func() {
209+ defer close(moved)
210+ for i := 0; moving.Load(); i++ {
211+ ev := a
212+ if i%2 == 1 {
213+ ev = b
214+ }
215+ app.input <- ev
216+ time.Sleep(16 * time.Millisecond)
217+ }
218+ }()
219+ defer func() { moving.Store(false); <-moved }()
220+
221+ gst := exec.Command("gst-launch-1.0", "-v", "pipewiresrc", "path="+itoa(node), "stream-properties=props,media.type=Video", "num-buffers=300", "!", "fakesink", "silent=false")
222+ out, err := gst.StdoutPipe()
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ if err := gst.Start(); err != nil {
227+ t.Fatal(err)
228+ }
229+ var frames atomic.Int64
230+ go func() {
231+ // Counted on raw reads: a line per frame would allocate a string.
232+ buf := make([]byte, 64<<10)
233+ for {
234+ n, err := out.Read(buf)
235+ frames.Add(int64(bytes.Count(buf[:n], []byte("chain "))))
236+ if err != nil {
237+ return
238+ }
239+ }
240+ }()
241+ exited := make(chan error, 1)
242+ go func() { exited <- gst.Wait() }()
243+
244+ // Steady state: 5 seconds after the stream warmed up. Every allocation is
245+ // recorded (MemProfileRate 1) and attributed by its stack: the cast path
246+ // (screencast, wayland casts, purego-pipewire, zerobus) must allocate
247+ // less than once per frame. The renderer's capture path is shared with
248+ // ext-image-copy-capture and is reported, not asserted.
249+ time.Sleep(2 * time.Second)
250+ runtime.MemProfileRate = 1
251+ f0 := frames.Load()
252+ before := allocSnapshot()
253+ var m0, m1 runtime.MemStats
254+ runtime.ReadMemStats(&m0)
255+ time.Sleep(5 * time.Second)
256+ runtime.ReadMemStats(&m1)
257+ after := allocSnapshot()
258+ f1 := frames.Load()
259+ runtime.MemProfileRate = 512 * 1024
260+ n := f1 - f0
261+ if n < 60 {
262+ t.Errorf("only %d frames in 5 s", n)
263+ } else {
264+ cast, other := after.sub(before)
265+ per := float64(cast) / float64(n)
266+ t.Logf("%d frames: cast path %.3f mallocs per frame, whole process %.2f (renderer and runtime included; %d other profiled)",
267+ n, per, float64(m1.Mallocs-m0.Mallocs)/float64(n), other)
268+ if per >= 1 {
269+ t.Errorf("cast path: %.2f mallocs per frame over %d frames, want < 1", per, n)
270+ }
271+ }
272+
273+ // The consumer got its 300 buffers and left.
274+ select {
275+ case err := <-exited:
276+ if err != nil {
277+ t.Errorf("gst-launch-1.0: %v", err)
278+ }
279+ case <-time.After(60 * time.Second):
280+ _ = gst.Process.Kill()
281+ t.Fatal("gst-launch-1.0 did not finish")
282+ }
283+ moving.Store(false)
284+
285+ // A second client is another unique name: it is allowed (the list is
286+ // off) but the session is not its own.
287+ other := castClient(t)
288+ if name, _ := call(other, session, scSession, "Stop", "", nil); name != scDenied {
289+ t.Errorf("Stop by another client: %q", name)
290+ }
291+ if name, _ := call(other, stream, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str(scStream) }); name != scDenied {
292+ t.Errorf("stream GetAll by another client: %q", name)
293+ }
294+ // A window that is not mapped cannot be recorded.
295+ if name, _ := call(c, session, scSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
296+ a := e.BeginArray('{')
297+ e.Struct()
298+ e.Str("window-id")
299+ e.Variant("t")
300+ e.Uint64(99999)
301+ e.EndArray(a)
302+ }); name == "" {
303+ t.Error("RecordWindow of a closed window succeeded")
304+ }
305+ if name, _ := call(c, session, scSession, "Stop", "", nil); name != "" {
306+ t.Fatalf("Stop: %s", name)
307+ }
308+ for closed := false; !closed; {
309+ m, err := c.ReadMessage()
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ closed = m.Member == "Closed" && m.Path == session
314+ }
315+}
316+
317+// A caller whose executable is not on the list gets AccessDenied from
318+// every object, and nothing is captured.
319+func TestScreencastAllowlistDenies(t *testing.T) {
320+ requireCastEnv(t)
321+ startCastApp(t, "/usr/bin/grim\n")
322+ c := castClient(t)
323+ waitOwner(t, c, scName)
324+ if name, _ := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps); name != scDenied {
325+ t.Errorf("CreateSession: %q", name)
326+ }
327+ if name, _ := call(c, scPath, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str("org.gnome.Mutter.ScreenCast") }); name != scDenied {
328+ t.Errorf("GetAll: %q", name)
329+ }
330+ for _, x := range []struct{ dest, path, iface, member string }{
331+ {"org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"},
332+ {"org.gnome.Shell.Introspect", "/org/gnome/Shell/Introspect", "org.gnome.Shell.Introspect", "GetWindows"},
333+ {"org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", "InteractiveScreenshot"},
334+ } {
335+ e := c.NewCall(x.dest, x.path, x.iface, x.member, "")
336+ _ = e
337+ if _, err := c.Call(); err == nil || !strings.Contains(err.Error(), scDenied) {
338+ t.Errorf("%s: %v", x.member, err)
339+ }
340+ }
341+}
342+
343+func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
344+
345+// castPath names the packages whose allocations the cast path owns.
346+var castPath = []string{
347+ "internal/adapters/screencast.",
348+ "purego-pipewire",
349+ "bnema/zerobus",
350+ "wayland.(*castSession)",
351+ "wayland.(*Server).requestCast",
352+ "wayland.(*Server).castFlip",
353+ "wayland.(*Server).forwardCasts",
354+ "wayland.(*Server).forwardCaptured",
355+ "wayland.postRing",
356+ "outputkit/capture.writeFrame",
357+}
358+
359+// allocs counts profiled allocation objects by stack, for the cast path and
360+// for the rest.
361+type allocs struct {
362+ cast, other map[string]int64
363+}
364+
365+func allocSnapshot() allocs {
366+ runtime.GC() // the memory profile is as of the last completed GC
367+ var recs []runtime.MemProfileRecord
368+ for {
369+ recs = make([]runtime.MemProfileRecord, 4096)
370+ n, ok := runtime.MemProfile(recs, true)
371+ if ok {
372+ recs = recs[:n]
373+ break
374+ }
375+ recs = make([]runtime.MemProfileRecord, n+256)
376+ if n, ok = runtime.MemProfile(recs, true); ok {
377+ recs = recs[:n]
378+ break
379+ }
380+ }
381+ a := allocs{cast: map[string]int64{}, other: map[string]int64{}}
382+ for i := range recs {
383+ pcs := recs[i].Stack()
384+ frames := runtime.CallersFrames(pcs)
385+ var key strings.Builder
386+ isCast := false
387+ for {
388+ f, more := frames.Next()
389+ key.WriteString(f.Function)
390+ key.WriteByte(';')
391+ for _, p := range castPath {
392+ if strings.Contains(f.Function, p) {
393+ isCast = true
394+ }
395+ }
396+ if !more {
397+ break
398+ }
399+ }
400+ if isCast {
401+ a.cast[key.String()] += recs[i].AllocObjects
402+ } else {
403+ a.other[key.String()] += recs[i].AllocObjects
404+ }
405+ }
406+ return a
407+}
408+
409+// sub returns the objects allocated between two snapshots.
410+func (a allocs) sub(b allocs) (cast, other int64) {
411+ for k, v := range a.cast {
412+ cast += v - b.cast[k]
413+ }
414+ for k, v := range a.other {
415+ other += v - b.other[k]
416+ }
417+ return cast, other
418+}
@@ -0,0 +1,418 @@
1+//go:build screencast
2+
3+package app
4+
5+import (
6+ "bytes"
7+ "context"
8+ "os"
9+ "os/exec"
10+ "path/filepath"
11+ "runtime"
12+ "strconv"
13+ "strings"
14+ "sync/atomic"
15+ "testing"
16+ "time"
17+
18+ "github.com/bnema/neferwl/internal/adapters/config"
19+ "github.com/bnema/neferwl/internal/ports"
20+ "github.com/bnema/zerobus"
21+)
22+
23+// The screencast tests need a session bus of their own (run them under
24+// dbus-run-session), the user's PipeWire and gst-launch-1.0 as the consumer.
25+
26+const (
27+ scName = "org.gnome.Mutter.ScreenCast"
28+ scPath = "/org/gnome/Mutter/ScreenCast"
29+ scSession = "org.gnome.Mutter.ScreenCast.Session"
30+ scStream = "org.gnome.Mutter.ScreenCast.Stream"
31+ scDenied = "org.freedesktop.DBus.Error.AccessDenied"
32+)
33+
34+// castApp is a headless NeferWL serving the D-Bus services.
35+type castApp struct {
36+ input chan<- ports.InputEvent
37+ stop func()
38+}
39+
40+func requireCastEnv(t *testing.T) {
41+ t.Helper()
42+ if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" {
43+ t.Skip("no session bus: run under dbus-run-session")
44+ }
45+ if _, err := exec.LookPath("gst-launch-1.0"); err != nil {
46+ t.Skip("gst-launch-1.0 unavailable")
47+ }
48+ if err := exec.Command("pw-dump").Run(); err != nil {
49+ t.Skipf("PipeWire is not running: %v", err)
50+ }
51+}
52+
53+// startCastApp runs the compositor with the capture allowlist content. The
54+// PipeWire socket stays where it is: only the Wayland socket and the state
55+// move to temporary directories.
56+func startCastApp(t *testing.T, allow string) *castApp {
57+ t.Helper()
58+ t.Setenv("PIPEWIRE_RUNTIME_DIR", os.Getenv("XDG_RUNTIME_DIR"))
59+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
60+ t.Setenv("XDG_STATE_HOME", t.TempDir())
61+ t.Setenv("HOME", t.TempDir())
62+ allowPath := filepath.Join(t.TempDir(), "capture-allow")
63+ if err := os.WriteFile(allowPath, []byte(allow), 0o644); err != nil {
64+ t.Fatal(err)
65+ }
66+ ctx, cancel := context.WithCancel(context.Background())
67+ inputReady := make(chan chan<- ports.InputEvent, 1)
68+ done := make(chan error, 1)
69+ go func() {
70+ done <- run(ctx, Options{Backend: "headless", DBus: true, NoXwayland: true, NoTerminal: true, Config: config.Defaults(), Sizes: [][2]int{{320, 240}}, captureAllowPath: allowPath, captureAllowOwner: uint32(os.Getuid())},
71+ func(in chan<- ports.InputEvent) { inputReady <- in })
72+ }()
73+ a := &castApp{}
74+ select {
75+ case a.input = <-inputReady:
76+ case <-time.After(10 * time.Second):
77+ cancel()
78+ t.Fatal("app did not start")
79+ }
80+ a.stop = func() {
81+ cancel()
82+ select {
83+ case err := <-done:
84+ if err != nil && !strings.Contains(err.Error(), "context canceled") {
85+ t.Errorf("Run: %v", err)
86+ }
87+ case <-time.After(10 * time.Second):
88+ t.Error("Run did not stop")
89+ }
90+ }
91+ t.Cleanup(a.stop)
92+ return a
93+}
94+
95+// castClient is a client of the D-Bus services.
96+func castClient(t *testing.T) *zerobus.Conn {
97+ t.Helper()
98+ c, err := zerobus.SessionBus()
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ t.Cleanup(func() { _ = c.Close() })
103+ return c
104+}
105+
106+func waitOwner(t *testing.T, c *zerobus.Conn, name string) {
107+ t.Helper()
108+ for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(20 * time.Millisecond) {
109+ e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s")
110+ e.Str(name)
111+ m, err := c.Call()
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ if m.Body().Bool() {
116+ return
117+ }
118+ }
119+ t.Fatalf("%s never appeared", name)
120+}
121+
122+func emptyProps(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }
123+
124+// call returns the error name, "" on success.
125+func call(c *zerobus.Conn, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) {
126+ e := c.NewCall(scName, path, iface, member, sig)
127+ if body != nil {
128+ body(e)
129+ }
130+ m, err := c.Call()
131+ if err == nil {
132+ return "", m
133+ }
134+ if de, ok := err.(*zerobus.Error); ok {
135+ return de.Name, m
136+ }
137+ return err.Error(), m
138+}
139+
140+func recordMonitor(c *zerobus.Conn, session, connector string) (string, string) {
141+ name, m := call(c, session, scSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) {
142+ e.Str(connector)
143+ a := e.BeginArray('{')
144+ e.Struct()
145+ e.Str("cursor-mode")
146+ e.Variant("u")
147+ e.Uint32(1)
148+ e.EndArray(a)
149+ })
150+ if name != "" {
151+ return name, ""
152+ }
153+ return "", strings.Clone(m.Body().ObjectPath())
154+}
155+
156+// A monitor cast streams live to a PipeWire consumer, allocates next to
157+// nothing per frame, refuses another client's calls and ends on Stop.
158+func TestScreencastStreamsMonitor(t *testing.T) {
159+ requireCastEnv(t)
160+ app := startCastApp(t, "*\n")
161+ c := castClient(t)
162+ waitOwner(t, c, scName)
163+ // The inventory reaches the service after the outputs appear.
164+ var session, stream string
165+ for deadline := time.Now().Add(15 * time.Second); ; time.Sleep(50 * time.Millisecond) {
166+ name, m := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps)
167+ if name != "" {
168+ t.Fatalf("CreateSession: %s", name)
169+ }
170+ session = strings.Clone(m.Body().ObjectPath())
171+ var errName string
172+ if errName, stream = recordMonitor(c, session, "HEADLESS-1"); errName == "" {
173+ break
174+ }
175+ call(c, session, scSession, "Stop", "", nil)
176+ if time.Now().After(deadline) {
177+ t.Fatalf("RecordMonitor: %s", errName)
178+ }
179+ }
180+ rule := "type='signal',interface='" + scStream + "'"
181+ if err := c.AddMatch(rule); err != nil {
182+ t.Fatal(err)
183+ }
184+ if err := c.AddMatch("type='signal',interface='" + scSession + "'"); err != nil {
185+ t.Fatal(err)
186+ }
187+ if name, _ := call(c, session, scSession, "Start", "", nil); name != "" {
188+ t.Fatalf("Start: %s", name)
189+ }
190+ node := uint32(0)
191+ for node == 0 {
192+ m, err := c.ReadMessage()
193+ if err != nil {
194+ t.Fatal(err)
195+ }
196+ if m.Member == "PipeWireStreamAdded" && m.Path == stream && m.Destination == c.UniqueName() {
197+ node = m.Body().Uint32()
198+ }
199+ }
200+
201+ // Pointer motion damages the output while the consumer runs. The events
202+ // are boxed once: sending them allocates nothing.
203+ var moving atomic.Bool
204+ moving.Store(true)
205+ a := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 40, Y: 40}})
206+ b := ports.InputEvent(ports.SecurityInput{Event: ports.PointerMotion{X: 200, Y: 160}})
207+ moved := make(chan struct{})
208+ go func() {
209+ defer close(moved)
210+ for i := 0; moving.Load(); i++ {
211+ ev := a
212+ if i%2 == 1 {
213+ ev = b
214+ }
215+ app.input <- ev
216+ time.Sleep(16 * time.Millisecond)
217+ }
218+ }()
219+ defer func() { moving.Store(false); <-moved }()
220+
221+ gst := exec.Command("gst-launch-1.0", "-v", "pipewiresrc", "path="+itoa(node), "stream-properties=props,media.type=Video", "num-buffers=300", "!", "fakesink", "silent=false")
222+ out, err := gst.StdoutPipe()
223+ if err != nil {
224+ t.Fatal(err)
225+ }
226+ if err := gst.Start(); err != nil {
227+ t.Fatal(err)
228+ }
229+ var frames atomic.Int64
230+ go func() {
231+ // Counted on raw reads: a line per frame would allocate a string.
232+ buf := make([]byte, 64<<10)
233+ for {
234+ n, err := out.Read(buf)
235+ frames.Add(int64(bytes.Count(buf[:n], []byte("chain "))))
236+ if err != nil {
237+ return
238+ }
239+ }
240+ }()
241+ exited := make(chan error, 1)
242+ go func() { exited <- gst.Wait() }()
243+
244+ // Steady state: 5 seconds after the stream warmed up. Every allocation is
245+ // recorded (MemProfileRate 1) and attributed by its stack: the cast path
246+ // (screencast, wayland casts, purego-pipewire, zerobus) must allocate
247+ // less than once per frame. The renderer's capture path is shared with
248+ // ext-image-copy-capture and is reported, not asserted.
249+ time.Sleep(2 * time.Second)
250+ runtime.MemProfileRate = 1
251+ f0 := frames.Load()
252+ before := allocSnapshot()
253+ var m0, m1 runtime.MemStats
254+ runtime.ReadMemStats(&m0)
255+ time.Sleep(5 * time.Second)
256+ runtime.ReadMemStats(&m1)
257+ after := allocSnapshot()
258+ f1 := frames.Load()
259+ runtime.MemProfileRate = 512 * 1024
260+ n := f1 - f0
261+ if n < 60 {
262+ t.Errorf("only %d frames in 5 s", n)
263+ } else {
264+ cast, other := after.sub(before)
265+ per := float64(cast) / float64(n)
266+ t.Logf("%d frames: cast path %.3f mallocs per frame, whole process %.2f (renderer and runtime included; %d other profiled)",
267+ n, per, float64(m1.Mallocs-m0.Mallocs)/float64(n), other)
268+ if per >= 1 {
269+ t.Errorf("cast path: %.2f mallocs per frame over %d frames, want < 1", per, n)
270+ }
271+ }
272+
273+ // The consumer got its 300 buffers and left.
274+ select {
275+ case err := <-exited:
276+ if err != nil {
277+ t.Errorf("gst-launch-1.0: %v", err)
278+ }
279+ case <-time.After(60 * time.Second):
280+ _ = gst.Process.Kill()
281+ t.Fatal("gst-launch-1.0 did not finish")
282+ }
283+ moving.Store(false)
284+
285+ // A second client is another unique name: it is allowed (the list is
286+ // off) but the session is not its own.
287+ other := castClient(t)
288+ if name, _ := call(other, session, scSession, "Stop", "", nil); name != scDenied {
289+ t.Errorf("Stop by another client: %q", name)
290+ }
291+ if name, _ := call(other, stream, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str(scStream) }); name != scDenied {
292+ t.Errorf("stream GetAll by another client: %q", name)
293+ }
294+ // A window that is not mapped cannot be recorded.
295+ if name, _ := call(c, session, scSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) {
296+ a := e.BeginArray('{')
297+ e.Struct()
298+ e.Str("window-id")
299+ e.Variant("t")
300+ e.Uint64(99999)
301+ e.EndArray(a)
302+ }); name == "" {
303+ t.Error("RecordWindow of a closed window succeeded")
304+ }
305+ if name, _ := call(c, session, scSession, "Stop", "", nil); name != "" {
306+ t.Fatalf("Stop: %s", name)
307+ }
308+ for closed := false; !closed; {
309+ m, err := c.ReadMessage()
310+ if err != nil {
311+ t.Fatal(err)
312+ }
313+ closed = m.Member == "Closed" && m.Path == session
314+ }
315+}
316+
317+// A caller whose executable is not on the list gets AccessDenied from
318+// every object, and nothing is captured.
319+func TestScreencastAllowlistDenies(t *testing.T) {
320+ requireCastEnv(t)
321+ startCastApp(t, "/usr/bin/grim\n")
322+ c := castClient(t)
323+ waitOwner(t, c, scName)
324+ if name, _ := call(c, scPath, "org.gnome.Mutter.ScreenCast", "CreateSession", "a{sv}", emptyProps); name != scDenied {
325+ t.Errorf("CreateSession: %q", name)
326+ }
327+ if name, _ := call(c, scPath, "org.freedesktop.DBus.Properties", "GetAll", "s", func(e *zerobus.Encoder) { e.Str("org.gnome.Mutter.ScreenCast") }); name != scDenied {
328+ t.Errorf("GetAll: %q", name)
329+ }
330+ for _, x := range []struct{ dest, path, iface, member string }{
331+ {"org.gnome.Mutter.DisplayConfig", "/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig", "GetCurrentState"},
332+ {"org.gnome.Shell.Introspect", "/org/gnome/Shell/Introspect", "org.gnome.Shell.Introspect", "GetWindows"},
333+ {"org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", "org.gnome.Shell.Screenshot", "InteractiveScreenshot"},
334+ } {
335+ e := c.NewCall(x.dest, x.path, x.iface, x.member, "")
336+ _ = e
337+ if _, err := c.Call(); err == nil || !strings.Contains(err.Error(), scDenied) {
338+ t.Errorf("%s: %v", x.member, err)
339+ }
340+ }
341+}
342+
343+func itoa(n uint32) string { return strconv.FormatUint(uint64(n), 10) }
344+
345+// castPath names the packages whose allocations the cast path owns.
346+var castPath = []string{
347+ "internal/adapters/screencast.",
348+ "purego-pipewire",
349+ "bnema/zerobus",
350+ "wayland.(*castSession)",
351+ "wayland.(*Server).requestCast",
352+ "wayland.(*Server).castFlip",
353+ "wayland.(*Server).forwardCasts",
354+ "wayland.(*Server).forwardCaptured",
355+ "wayland.postRing",
356+ "outputkit/capture.writeFrame",
357+}
358+
359+// allocs counts profiled allocation objects by stack, for the cast path and
360+// for the rest.
361+type allocs struct {
362+ cast, other map[string]int64
363+}
364+
365+func allocSnapshot() allocs {
366+ runtime.GC() // the memory profile is as of the last completed GC
367+ var recs []runtime.MemProfileRecord
368+ for {
369+ recs = make([]runtime.MemProfileRecord, 4096)
370+ n, ok := runtime.MemProfile(recs, true)
371+ if ok {
372+ recs = recs[:n]
373+ break
374+ }
375+ recs = make([]runtime.MemProfileRecord, n+256)
376+ if n, ok = runtime.MemProfile(recs, true); ok {
377+ recs = recs[:n]
378+ break
379+ }
380+ }
381+ a := allocs{cast: map[string]int64{}, other: map[string]int64{}}
382+ for i := range recs {
383+ pcs := recs[i].Stack()
384+ frames := runtime.CallersFrames(pcs)
385+ var key strings.Builder
386+ isCast := false
387+ for {
388+ f, more := frames.Next()
389+ key.WriteString(f.Function)
390+ key.WriteByte(';')
391+ for _, p := range castPath {
392+ if strings.Contains(f.Function, p) {
393+ isCast = true
394+ }
395+ }
396+ if !more {
397+ break
398+ }
399+ }
400+ if isCast {
401+ a.cast[key.String()] += recs[i].AllocObjects
402+ } else {
403+ a.other[key.String()] += recs[i].AllocObjects
404+ }
405+ }
406+ return a
407+}
408+
409+// sub returns the objects allocated between two snapshots.
410+func (a allocs) sub(b allocs) (cast, other int64) {
411+ for k, v := range a.cast {
412+ cast += v - b.cast[k]
413+ }
414+ for k, v := range a.other {
415+ other += v - b.other[k]
416+ }
417+ return cast, other
418+}
M internal/app/session.go +23 -3
@@ -19,6 +19,7 @@ import (
1919 "github.com/bnema/neferwl/internal/adapters/logging"
2020 "github.com/bnema/neferwl/internal/adapters/powerkey"
2121 "github.com/bnema/neferwl/internal/adapters/sched"
22+ "github.com/bnema/neferwl/internal/adapters/screencast"
2223 "github.com/bnema/neferwl/internal/adapters/screensaver"
2324 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
2425 "github.com/bnema/neferwl/internal/adapters/statefile"
@@ -52,6 +53,7 @@ type session struct {
5253 dmabuf ports.DMABufSupport
5354 wsIDs *workspaceid.IDs
5455 captureAllow *captureallow.Store
56+ cast *screencast.Service // nil without the D-Bus services
5557 server *wayland.Server
5658 childEnv []string
5759 statePath string
@@ -113,8 +115,16 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
113115 } else {
114116 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
115117 }
118+ // Only a real session (or a test that asks) serves the session bus:
119+ // headless runs would take the names from the desktop they run in.
120+ var casts ports.CastSink
121+ if s.hw != nil || opts.DBus {
122+ s.cast = screencast.New(s.captureAllow.Policy, s.p.castCommands, logging.For(ctx, "screencast"))
123+ casts = s.cast
124+ }
116125 if s.server, err = wayland.New(wayland.Options{
117126 CaptureAllow: s.captureAllow,
127+ Casts: casts,
118128 WorkspaceIDs: s.wsIDs,
119129 Security: s.security,
120130 RuntimeDir: runtimeDir,
@@ -374,14 +384,24 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
374384 if hw != nil {
375385 workers.Add(1)
376386 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
377- // Only a real session serves the session bus: headless runs would take
378- // the name from the desktop they run in.
379- workers.Add(1)
387+ }
388+ // Only a real session serves the session bus: headless runs would take
389+ // the names from the desktop they run in, unless a test asks (DBus).
390+ if hw != nil || opts.DBus {
391+ workers.Add(2)
380392 go func() {
381393 defer workers.Done()
382394 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
383395 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
384396 }()
397+ go func() {
398+ defer workers.Done()
399+ // nefercap finds this compositor through the session environment.
400+ s.cast.SetEnv(childEnv)
401+ s.cast.Run(ctx, busRetry)
402+ }()
403+ }
404+ if hw != nil {
385405 // The same goes for the system bus: logind's locks keep the power
386406 // keys from acting before a bind sees them.
387407 workers.Add(1)
@@ -19,6 +19,7 @@ import (
19 "github.com/bnema/neferwl/internal/adapters/logging"19 "github.com/bnema/neferwl/internal/adapters/logging"
20 "github.com/bnema/neferwl/internal/adapters/powerkey"20 "github.com/bnema/neferwl/internal/adapters/powerkey"
21 "github.com/bnema/neferwl/internal/adapters/sched"21 "github.com/bnema/neferwl/internal/adapters/sched"
22+ "github.com/bnema/neferwl/internal/adapters/screencast"
22 "github.com/bnema/neferwl/internal/adapters/screensaver"23 "github.com/bnema/neferwl/internal/adapters/screensaver"
23 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"24 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
24 "github.com/bnema/neferwl/internal/adapters/statefile"25 "github.com/bnema/neferwl/internal/adapters/statefile"
@@ -52,6 +53,7 @@ type session struct {
52 dmabuf ports.DMABufSupport53 dmabuf ports.DMABufSupport
53 wsIDs *workspaceid.IDs54 wsIDs *workspaceid.IDs
54 captureAllow *captureallow.Store55 captureAllow *captureallow.Store
56+ cast *screencast.Service // nil without the D-Bus services
55 server *wayland.Server57 server *wayland.Server
56 childEnv []string58 childEnv []string
57 statePath string59 statePath string
@@ -113,8 +115,16 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error)
113 } else {115 } else {
114 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)116 s.captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
115 }117 }
118+ // Only a real session (or a test that asks) serves the session bus:
119+ // headless runs would take the names from the desktop they run in.
120+ var casts ports.CastSink
121+ if s.hw != nil || opts.DBus {
122+ s.cast = screencast.New(s.captureAllow.Policy, s.p.castCommands, logging.For(ctx, "screencast"))
123+ casts = s.cast
124+ }
116 if s.server, err = wayland.New(wayland.Options{125 if s.server, err = wayland.New(wayland.Options{
117 CaptureAllow: s.captureAllow,126 CaptureAllow: s.captureAllow,
127+ Casts: casts,
118 WorkspaceIDs: s.wsIDs,128 WorkspaceIDs: s.wsIDs,
119 Security: s.security,129 Security: s.security,
120 RuntimeDir: runtimeDir,130 RuntimeDir: runtimeDir,
@@ -374,14 +384,24 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent))
374 if hw != nil {384 if hw != nil {
375 workers.Add(1)385 workers.Add(1)
376 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()386 go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }()
377- // Only a real session serves the session bus: headless runs would take387+ }
378- // the name from the desktop they run in.388+ // Only a real session serves the session bus: headless runs would take
379- workers.Add(1)389+ // the names from the desktop they run in, unless a test asks (DBus).
390+ if hw != nil || opts.DBus {
391+ workers.Add(2)
380 go func() {392 go func() {
381 defer workers.Done()393 defer workers.Done()
382 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}394 reports := screensaver.Reports{Held: p.idleInhibited, Activity: p.idleActivity}
383 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))395 screensaver.Serve(ctx, "", reports, busRetry, logging.For(ctx, "screensaver"))
384 }()396 }()
397+ go func() {
398+ defer workers.Done()
399+ // nefercap finds this compositor through the session environment.
400+ s.cast.SetEnv(childEnv)
401+ s.cast.Run(ctx, busRetry)
402+ }()
403+ }
404+ if hw != nil {
385 // The same goes for the system bus: logind's locks keep the power405 // The same goes for the system bus: logind's locks keep the power
386 // keys from acting before a bind sees them.406 // keys from acting before a bind sees them.
387 workers.Add(1)407 workers.Add(1)
M internal/app/wiring.go +7 -0
@@ -63,6 +63,10 @@ type pipes struct {
6363 // coalesces them.
6464 idleInhibited chan bool
6565 idleActivity chan struct{}
66+
67+ // Screencast commands to wayland (the screencast adapter's side is the
68+ // sender); the events come back through wayland.Options.Casts.
69+ castCommands chan ports.CastCommand
6670 }
6771
6872 func newPipes(realSession bool) *pipes {
@@ -105,6 +109,8 @@ func newPipes(realSession bool) *pipes {
105109
106110 idleInhibited: make(chan bool, 1),
107111 idleActivity: make(chan struct{}, 1),
112+
113+ castCommands: make(chan ports.CastCommand, 64),
108114 }
109115 if realSession {
110116 p.scales = make(chan ports.ScaleChanged, 8)
@@ -152,6 +158,7 @@ func (p *pipes) wayland() wayland.Channels {
152158 OutputApplied: p.appliedOutput,
153159 IdleInhibited: p.idleInhibited,
154160 IdleActivity: p.idleActivity,
161+ CastCommands: p.castCommands,
155162 }
156163 }
157164
@@ -63,6 +63,10 @@ type pipes struct {
63 // coalesces them.63 // coalesces them.
64 idleInhibited chan bool64 idleInhibited chan bool
65 idleActivity chan struct{}65 idleActivity chan struct{}
66+
67+ // Screencast commands to wayland (the screencast adapter's side is the
68+ // sender); the events come back through wayland.Options.Casts.
69+ castCommands chan ports.CastCommand
66 }70 }
67 71
68 func newPipes(realSession bool) *pipes {72 func newPipes(realSession bool) *pipes {
@@ -105,6 +109,8 @@ func newPipes(realSession bool) *pipes {
105 109
106 idleInhibited: make(chan bool, 1),110 idleInhibited: make(chan bool, 1),
107 idleActivity: make(chan struct{}, 1),111 idleActivity: make(chan struct{}, 1),
112+
113+ castCommands: make(chan ports.CastCommand, 64),
108 }114 }
109 if realSession {115 if realSession {
110 p.scales = make(chan ports.ScaleChanged, 8)116 p.scales = make(chan ports.ScaleChanged, 8)
@@ -152,6 +158,7 @@ func (p *pipes) wayland() wayland.Channels {
152 OutputApplied: p.appliedOutput,158 OutputApplied: p.appliedOutput,
153 IdleInhibited: p.idleInhibited,159 IdleInhibited: p.idleInhibited,
154 IdleActivity: p.idleActivity,160 IdleActivity: p.idleActivity,
161+ CastCommands: p.castCommands,
155 }162 }
156 }163 }
157 164
M internal/cli/run.go +7 -3
@@ -24,9 +24,9 @@ import (
2424
2525 // runFlags are the root command flags.
2626 type runFlags struct {
27- backend, screenshot, size, input, debug, config, pprof string
28- noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool
29- timeout time.Duration
27+ backend, screenshot, size, input, debug, config, pprof string
28+ noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool
29+ timeout time.Duration
3030 }
3131
3232 func (f *runFlags) register(fs *flag.FlagSet) {
@@ -42,6 +42,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
4242 fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
4343 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
4444 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45+ fs.BoolVar(&f.dbus, "dbus", false, "headless: serve the session D-Bus services; for tests under dbus-run-session")
4546 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
4647 }
4748
@@ -51,6 +52,8 @@ func (f *runFlags) validate(args []string) ([][2]int, error) {
5152 switch {
5253 case f.backend != "drm" && !headless:
5354 return nil, usagef("invalid backend %q", f.backend)
55+ case f.dbus && !headless:
56+ return nil, usagef("--dbus requires --backend=headless")
5457 case f.headlessHDR && !headless:
5558 return nil, usagef("--headless-hdr requires --backend=headless")
5659 case f.screenshot != "" && !headless:
@@ -154,6 +157,7 @@ func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
154157 NoXwayland: f.noXwayland,
155158 HeadlessHDR: f.headlessHDR,
156159 Session: f.session,
160+ DBus: f.dbus,
157161 ScreenshotDir: f.screenshot,
158162 ScreenshotRaw: f.screenshotRaw,
159163 Sizes: sizes,
@@ -24,9 +24,9 @@ import (
24 24
25 // runFlags are the root command flags.25 // runFlags are the root command flags.
26 type runFlags struct {26 type runFlags struct {
27- backend, screenshot, size, input, debug, config, pprof string27+ backend, screenshot, size, input, debug, config, pprof string
28- noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool28+ noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool
29- timeout time.Duration29+ timeout time.Duration
30 }30 }
31 31
32 func (f *runFlags) register(fs *flag.FlagSet) {32 func (f *runFlags) register(fs *flag.FlagSet) {
@@ -42,6 +42,7 @@ func (f *runFlags) register(fs *flag.FlagSet) {
42 fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")42 fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")43 fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")44 fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45+ fs.BoolVar(&f.dbus, "dbus", false, "headless: serve the session D-Bus services; for tests under dbus-run-session")
45 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")46 fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
46 }47 }
47 48
@@ -51,6 +52,8 @@ func (f *runFlags) validate(args []string) ([][2]int, error) {
51 switch {52 switch {
52 case f.backend != "drm" && !headless:53 case f.backend != "drm" && !headless:
53 return nil, usagef("invalid backend %q", f.backend)54 return nil, usagef("invalid backend %q", f.backend)
55+ case f.dbus && !headless:
56+ return nil, usagef("--dbus requires --backend=headless")
54 case f.headlessHDR && !headless:57 case f.headlessHDR && !headless:
55 return nil, usagef("--headless-hdr requires --backend=headless")58 return nil, usagef("--headless-hdr requires --backend=headless")
56 case f.screenshot != "" && !headless:59 case f.screenshot != "" && !headless:
@@ -154,6 +157,7 @@ func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
154 NoXwayland: f.noXwayland,157 NoXwayland: f.noXwayland,
155 HeadlessHDR: f.headlessHDR,158 HeadlessHDR: f.headlessHDR,
156 Session: f.session,159 Session: f.session,
160+ DBus: f.dbus,
157 ScreenshotDir: f.screenshot,161 ScreenshotDir: f.screenshot,
158 ScreenshotRaw: f.screenshotRaw,162 ScreenshotRaw: f.screenshotRaw,
159 Sizes: sizes,163 Sizes: sizes,

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