feat(capture): add authorized recording sessions and offscreen workspace capture (#70)
* feat(capture): add authorized recording sessions and offscreen workspace capture * fix(wayland): announce layer target scale before configure * fix(capture): hold client buffers until child GPU reads complete Carry immutable outstanding child-read sequences in output reports so the stale display-report timeout cannot release buffers or explicit sync points while the separate child device still samples them. Publish holds before DRM flips and headless waits without advancing display reads. Cover a fence beyond 100 ms, completion, failed readback, per-window isolation, scanout preservation and zero-allocation steady-state reports. * docs(capture): clarify hidden pipeline budgets and session teardown Describe separate child renderer and readback budgets, demand-driven lifetime, display scanout preservation and whole-workspace geometry limits. Correct the stopped event documentation to match attachment teardown and layer closure; regenerated bindings are unchanged. * build(deps): pin purego-libwayland peer credentials with effective-ID test * fix(capture): close child device on terminal fence error A failed child fence (POLLERR or a broken poll) never signals, so its non-expiring ChildReads hold lasted forever and busy() blocked session retirement and replacement. Distinguish pending from terminal fences: on a terminal error cancel and join the child pipeline, close the renderer, then clear the holds so the next report lifts them. * fix(drm): report child read holds independently of display fences Child holds do not expire, but their completion was reported only when no display commit was pending and no uncommitted frame fence was outstanding, so a stuck display fence kept a finished child read held. Report child-hold changes alone at the last safe display Seen while the display is busy. * perf(headless): cache child-hold report snapshots Every pending hidden-capture frame cloned Seen and ChildReads even when unchanged. Keep immutable snapshots and clone only on change, as the DRM output does.
- commit
- a646b50696ff0b2ba6d1b0fb044f38ad6466f2f0
- author
- bnema · · 2026-09-30 16:23 UTC
- committer
- GitHub · · 2026-09-30 16:23 UTC
- parent
- a61214e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
53 files · +5949 -46
M docs/headless.md +7 -1
| @@ -9,7 +9,13 @@ WAYLAND_DISPLAY=<name from the log> foot | |||
| 9 | 9 | ||
| 10 | 10 | Screenshots through `grim` use `zwlr_screencopy_v1` or `ext_image_copy_capture_v1`. Both deliver opaque 8-bit sRGB, including GPU tone mapping of HDR content. Protocol capture requires Vulkan sync-file export; unsupported devices fail the request rather than wait for GPU completion on the output goroutine. The cursor is not included in captures: `overlay_cursor` and `paint_cursors` are ignored. Headless screenshot files include the cursor. If readback is temporarily unavailable, that screenshot frame is skipped without stopping the output or writing a black PNG. | |
| 11 | 11 | ||
| 12 | - | `/tmp/neferwl-shots/latest.png` shows the current frame. `--timeout 5s` stops NeferWL after 5 seconds. | |
| 12 | + | Nefercap uses the private `neferwl_capture_manager_v1` protocol for recording sessions. A session targets an output, a fixed output-local region or a workspace's stable identity. Workspace recording uses the workspace's real layout rather than overview previews. While hidden, it uses a separate, demand-driven child renderer and capture pipeline, with its own worker and two staging slots (up to 128 MiB host-visible memory) plus render targets (each image up to 256 MiB, 16384 pixels per side). Hidden capture can leave the displayed workspace in direct scanout. Offscreen capture currently requires a whole-workspace target and requests matching its exact physical dimensions; cropped targets or requests are unsupported. | |
| 13 | + | ||
| 14 | + | The compositor draws a red border around the visible recording target. An explicitly authorized HUD stays visible over fullscreen without keyboard focus. The session's captures omit the border, HUD and its popups; other clients' standard captures include them. HUD authorization requires the session's random token and a matching socket peer UID, never a layer namespace or app-id. Output removal, power-off, owner disconnect and a five-second keepalive timeout stop the session. | |
| 15 | + | ||
| 16 | + | Offscreen frame callbacks keep clients running, but no physical presentation feedback is invented for hidden windows. The child renderer and its readback storage retire when unused and idle, are recreated for session or frame-size changes, and close with the output. Clean capture can require two compositions to preserve the displayed z-order. | |
| 17 | + | ||
| 18 | + | `/tmp/neferwl-shots/latest.png` shows the current frame, including the recording border and HUD. `--timeout 5s` stops NeferWL after 5 seconds. | |
| 13 | 19 | ||
| 14 | 20 | `--input <path>`, or `--input -` for stdin, plays an input script. One command per line: | |
| 15 | 21 | ||
M docs/performance.md +1 -1
| @@ -8,7 +8,7 @@ How NeferWL keeps its CPU and memory use low, how to measure it, and the referen | |||
| 8 | 8 | - **Allocation-free steady-state presentation.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them. | |
| 9 | 9 | - **Cursor rides on game frames under VRR.** While a fullscreen game drives VRR, a cursor move waits for the game's next frame instead of its own commit: a cursor-only commit refreshes the panel at its slowest rate (~21 ms at 48 Hz) and delays the next game frame by as much. If the game stops drawing, the cursor still commits alone at 24 Hz. | |
| 10 | 10 | - **Game frames keep a short gap after each VRR flip.** On amdgpu, a flip committed right after the previous flip event can miss the early refresh and wait for the panel's slowest rate (~21 ms at 48 Hz); the next frame then lands at the same point, and the screen stays at 48 Hz while the game renders at 150 fps. Game frames under VRR therefore commit at least `render.vrr-flip-gap` (default 1 ms) after the previous flip. `internal/adapters/drm/vrr_flip_gap.go` has the measurements. | |
| 11 | - | - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. GPU resources are allocated on first capture and retained until the output stops. Copies have a separate completion fence but still cost GPU time and bandwidth; capture forces composition instead of scanout. There is no per-client reservation of the shared request limit. | |
| 11 | + | - **Bounded capture.** Protocol requests and sync-file handles still need small allocations; pixel storage and worker batches are reused. At most 32 protocol requests are outstanding globally, with up to eight per output frame, one SHM-copy worker and two reusable GPU staging slots per output. Saturation fails requests instead of waiting or growing a queue. Two 4K slots use about 63 MiB of host-visible memory, capped at 128 MiB per output; HDR adds one GPU-local 8-bit conversion image (about 32 MiB at 4K, up to 64 MiB), outside the host-memory cap. Display capture resources are allocated on first capture and retained until the output stops. Hidden workspace capture uses a separate, demand-driven child renderer and pipeline with its own worker and two staging slots (up to 128 MiB host-visible memory), plus child render targets; each child image is capped at 256 MiB and 16384 pixels per side. The child retires once unused and idle, and is recreated for session or size changes. Copies cost GPU time and bandwidth. Capture of the displayed frame forces composition; hidden workspace capture can keep display scanout. Offscreen capture currently serves only a whole-workspace target and requests matching its exact physical dimensions, not crops. There is no per-client reservation of the shared request limit. | |
| 12 | 12 | - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win. | |
| 13 | 13 | ||
| 14 | 14 | Output and input threads request real-time scheduling, and the Vulkan queue requests elevated priority. Both require CAP_SYS_NICE, which the Arch packages set on `/usr/bin/neferwl` at install. Apps launched by NeferWL inherit neither. Set `performance.realtime = false` to opt out. A binary built with `make bin` runs at normal priority unless you grant it: `sudo setcap cap_sys_nice+ep bin/neferwl`. | |
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.27 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego v0.13.0-bnema.1 | |
| 7 | - | github.com/bnema/purego-libwayland v0.6.0 | |
| 7 | + | github.com/bnema/purego-libwayland v0.6.1-0.20260930144645-155613350d00 | |
| 8 | 8 | github.com/bnema/purego-vulkan v0.6.0 | |
| 9 | 9 | github.com/bnema/wlturbo v0.2.0 | |
| 10 | 10 | github.com/bnema/zerowrap v1.4.1 | |
M go.sum +2 -2 generated file: collapsed
A internal/adapters/capture/clean.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "slices" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ErrSessionInactive fails a Clean request whose session is not the one the | |
| 11 | + | // scene carries. Serving it as a normal capture would leak the session's | |
| 12 | + | // HUD, its native border, or a workspace it no longer targets. | |
| 13 | + | var ErrSessionInactive = errors.New("capture session is not active") | |
| 14 | + | ||
| 15 | + | // ErrSessionStale fails a Clean request stamped with a session revision newer | |
| 16 | + | // than the scene's: the scene predates a change of what a clean capture must | |
| 17 | + | // hide (a new HUD layer, popup, target), so its exclusion list could leak. | |
| 18 | + | var ErrSessionStale = errors.New("capture session scene is older than the request") | |
| 19 | + | ||
| 20 | + | // cleaner derives the scene of clean captures: the frame without the native | |
| 21 | + | // session border and without the excluded layers and popups. Its scratch | |
| 22 | + | // slices are reused, so a warmed output allocates nothing. | |
| 23 | + | type cleaner struct { | |
| 24 | + | windows []ports.SceneWindow | |
| 25 | + | layers []ports.SceneLayer | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Shows reports whether a content change of id needs a new frame: the | |
| 29 | + | // displayed scene draws it, or the hidden workspace of a capture session does | |
| 30 | + | // (its child render and the reads it releases). Flips and Shown still count | |
| 31 | + | // the displayed scene only. | |
| 32 | + | func Shows(s ports.Scene, id ports.WindowID) bool { | |
| 33 | + | return s.Shows(id) || s.CaptureScene != nil && s.CaptureScene.Shows(id) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // BorderVisible reports whether the scene's native session border draws at | |
| 37 | + | // least one pixel: a color, and a width that survives the renderer's clamp to | |
| 38 | + | // half the smaller side of the target. The renderer draws by the same rule. | |
| 39 | + | func BorderVisible(s ports.Scene) bool { | |
| 40 | + | c := s.Capture | |
| 41 | + | return c != nil && c.BorderColor != "" && min(c.BorderWidth, c.TargetRect.W/2, c.TargetRect.H/2) > 0 | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // cleanDiffers reports whether the clean scene differs from the displayed | |
| 45 | + | // one: a native border is drawn, or an excluded surface is in the scene. | |
| 46 | + | // When it does not, clean requests are served from the displayed frame. | |
| 47 | + | func cleanDiffers(s ports.Scene) bool { | |
| 48 | + | c := s.Capture | |
| 49 | + | if c == nil { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | if BorderVisible(s) { | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | if len(c.Excluded) == 0 { | |
| 56 | + | return false | |
| 57 | + | } | |
| 58 | + | for _, w := range s.Windows { | |
| 59 | + | if slices.Contains(c.Excluded, w.ID) { | |
| 60 | + | return true | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | for _, l := range s.Layers { | |
| 64 | + | if slices.Contains(c.Excluded, l.ID) { | |
| 65 | + | return true | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // scene returns s without Capture (so no border) and without the excluded | |
| 72 | + | // windows (popups included) and layers. Seq is 0, so the renderer redraws | |
| 73 | + | // the whole target instead of reusing a displayed frame. The result is valid | |
| 74 | + | // until the next call. Without a session s is returned unchanged. | |
| 75 | + | func (c *cleaner) scene(s ports.Scene) ports.Scene { | |
| 76 | + | if s.Capture == nil { | |
| 77 | + | return s | |
| 78 | + | } | |
| 79 | + | ex := s.Capture.Excluded | |
| 80 | + | s.Capture, s.Seq = nil, 0 | |
| 81 | + | if len(ex) == 0 { | |
| 82 | + | return s | |
| 83 | + | } | |
| 84 | + | c.windows = c.windows[:0] | |
| 85 | + | for _, w := range s.Windows { | |
| 86 | + | if !slices.Contains(ex, w.ID) { | |
| 87 | + | c.windows = append(c.windows, w) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | c.layers = c.layers[:0] | |
| 91 | + | for _, l := range s.Layers { | |
| 92 | + | if !slices.Contains(ex, l.ID) { | |
| 93 | + | c.layers = append(c.layers, l) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | s.Windows, s.Layers = c.windows, c.layers | |
| 97 | + | return s | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // CleanScene is the scene a Clean capture is taken from; see Split. | |
| 101 | + | func (p *Pipeline) CleanScene(s ports.Scene) ports.Scene { return p.clean.scene(s) } | |
| 102 | + | ||
| 103 | + | // Split answers or orders the requests of one output frame. Clean requests | |
| 104 | + | // of a session other than the scene's, or with no session in the scene, are | |
| 105 | + | // failed with ErrSessionInactive, those newer than the scene's Revision with | |
| 106 | + | // ErrSessionStale (never served as normal captures), and removed. The rest is reordered in place: normal is the prefix, drawn from | |
| 107 | + | // the displayed frame; clean is the following requests, drawn from CleanScene | |
| 108 | + | // rendered first, or from the child scene when s.CaptureScene is set (see | |
| 109 | + | // Offscreen). clean is empty when the clean frame would equal the displayed | |
| 110 | + | // one, its requests then stay in normal. The entries past | |
| 111 | + | // len(normal)+len(clean) are zeroed. No allocation. | |
| 112 | + | func (p *Pipeline) Split(s ports.Scene, reqs []ports.CaptureRequest) (normal, clean []ports.CaptureRequest) { | |
| 113 | + | n := 0 | |
| 114 | + | for _, q := range reqs { | |
| 115 | + | if q.Clean { | |
| 116 | + | var err error | |
| 117 | + | switch { | |
| 118 | + | case s.Capture == nil || q.Session != s.Capture.Session: | |
| 119 | + | err = ErrSessionInactive | |
| 120 | + | case s.Capture.Revision < q.CaptureRevision: | |
| 121 | + | err = ErrSessionStale | |
| 122 | + | } | |
| 123 | + | if err != nil { | |
| 124 | + | Fail(p.ctx, q, err, p.replies) | |
| 125 | + | continue | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | reqs[n] = q | |
| 129 | + | n++ | |
| 130 | + | } | |
| 131 | + | clear(reqs[n:]) | |
| 132 | + | // A hidden workspace (CaptureScene) is always a separate frame. | |
| 133 | + | if s.CaptureScene == nil && !cleanDiffers(s) { | |
| 134 | + | return reqs[:n], nil | |
| 135 | + | } | |
| 136 | + | j := 0 | |
| 137 | + | for i := range n { | |
| 138 | + | if !reqs[i].Clean { | |
| 139 | + | reqs[i], reqs[j] = reqs[j], reqs[i] | |
| 140 | + | j++ | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | return reqs[:j], reqs[j:n] | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Handed reports a zeroed request: one Split failed, or one already given to | |
| 147 | + | // the worker. The owner never answers it again. | |
| 148 | + | func Handed(q ports.CaptureRequest) bool { return q.ID == 0 && q.Dst.File == nil } | |
A internal/adapters/capture/clean_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func cleanTestScene() ports.Scene { | |
| 12 | + | return ports.Scene{ | |
| 13 | + | Seq: 5, | |
| 14 | + | Windows: []ports.SceneWindow{ | |
| 15 | + | {ID: 1}, {ID: 20, Popup: true, OverLayers: true}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3}, | |
| 16 | + | }, | |
| 17 | + | Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}, {ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}}, | |
| 18 | + | Capture: &ports.SceneCapture{ | |
| 19 | + | Session: 7, BorderWidth: 2, BorderColor: ports.CaptureBorderColor, | |
| 20 | + | Excluded: []ports.WindowID{10, 20}, | |
| 21 | + | }, | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestCleanSceneDropsOnlyExcludedAndBorder(t *testing.T) { | |
| 26 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 27 | + | defer p.Close(nil) | |
| 28 | + | s := cleanTestScene() | |
| 29 | + | c := p.CleanScene(s) | |
| 30 | + | require.Nil(t, c.Capture) | |
| 31 | + | require.Zero(t, c.Seq) | |
| 32 | + | require.Equal(t, []ports.SceneWindow{{ID: 1}, {ID: 21, Popup: true, OverLayers: true}, {ID: 3}}, c.Windows) | |
| 33 | + | require.Equal(t, []ports.SceneLayer{{ID: 11, Layer: ports.LayerTop}, {ID: 12, Layer: ports.LayerOverlay}}, c.Layers) | |
| 34 | + | // The displayed scene is not mutated. | |
| 35 | + | require.Len(t, s.Windows, 4) | |
| 36 | + | require.Len(t, s.Layers, 3) | |
| 37 | + | require.NotNil(t, s.Capture) | |
| 38 | + | require.Equal(t, uint64(5), s.Seq) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCleanSceneWithoutSessionUnchangedNoAlloc(t *testing.T) { | |
| 42 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 43 | + | defer p.Close(nil) | |
| 44 | + | s := cleanTestScene() | |
| 45 | + | s.Capture = nil | |
| 46 | + | require.Equal(t, s, p.CleanScene(s)) | |
| 47 | + | reqs := make([]ports.CaptureRequest, 3) | |
| 48 | + | for i := range reqs { | |
| 49 | + | reqs[i].ID = uint64(i + 1) | |
| 50 | + | } | |
| 51 | + | if allocs := testing.AllocsPerRun(100, func() { | |
| 52 | + | _ = p.CleanScene(s) | |
| 53 | + | p.Split(s, reqs) | |
| 54 | + | }); allocs != 0 { | |
| 55 | + | t.Fatalf("no session: %.1f allocs, want 0", allocs) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestCleanSceneWarmedNoAlloc(t *testing.T) { | |
| 60 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 61 | + | defer p.Close(nil) | |
| 62 | + | s := cleanTestScene() | |
| 63 | + | p.CleanScene(s) | |
| 64 | + | if allocs := testing.AllocsPerRun(100, func() { _ = p.CleanScene(s) }); allocs != 0 { | |
| 65 | + | t.Fatalf("warmed clean scene: %.1f allocs, want 0", allocs) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestSplitOrdersAndFailsClosed(t *testing.T) { | |
| 70 | + | replies := make(chan ports.CaptureDone, 8) | |
| 71 | + | p := NewPipeline(context.Background(), replies) | |
| 72 | + | defer p.Close(nil) | |
| 73 | + | s := cleanTestScene() | |
| 74 | + | reqs := []ports.CaptureRequest{ | |
| 75 | + | {ID: 1, Clean: true, Session: 7}, | |
| 76 | + | {ID: 2}, | |
| 77 | + | {ID: 3, Clean: true, Session: 8}, // another session: never served | |
| 78 | + | {ID: 4, Clean: true, Session: 7}, | |
| 79 | + | {ID: 5}, | |
| 80 | + | } | |
| 81 | + | normal, clean := p.Split(s, reqs) | |
| 82 | + | ids := func(q []ports.CaptureRequest) (out []uint64) { | |
| 83 | + | for _, r := range q { | |
| 84 | + | out = append(out, r.ID) | |
| 85 | + | } | |
| 86 | + | return out | |
| 87 | + | } | |
| 88 | + | require.ElementsMatch(t, []uint64{2, 5}, ids(normal)) | |
| 89 | + | require.ElementsMatch(t, []uint64{1, 4}, ids(clean)) | |
| 90 | + | for _, q := range clean { | |
| 91 | + | require.True(t, q.Clean) | |
| 92 | + | } | |
| 93 | + | for _, q := range normal { | |
| 94 | + | require.False(t, q.Clean) | |
| 95 | + | } | |
| 96 | + | got := awaitCapture(t, replies) | |
| 97 | + | require.Equal(t, uint64(3), got.ID) | |
| 98 | + | require.ErrorIs(t, got.Err, ErrSessionInactive) | |
| 99 | + | for _, q := range reqs[len(normal)+len(clean):] { | |
| 100 | + | require.True(t, Handed(q), "tail must be zeroed") | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestSplitCleanWithoutSceneSessionFails(t *testing.T) { | |
| 105 | + | replies := make(chan ports.CaptureDone, 4) | |
| 106 | + | p := NewPipeline(context.Background(), replies) | |
| 107 | + | defer p.Close(nil) | |
| 108 | + | s := cleanTestScene() | |
| 109 | + | s.Capture = nil | |
| 110 | + | normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}) | |
| 111 | + | require.Len(t, normal, 1) | |
| 112 | + | require.Equal(t, uint64(2), normal[0].ID) | |
| 113 | + | require.Empty(t, clean) | |
| 114 | + | got := awaitCapture(t, replies) | |
| 115 | + | require.Equal(t, uint64(1), got.ID) | |
| 116 | + | require.ErrorIs(t, got.Err, ErrSessionInactive) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // A session with nothing to hide (no border, no excluded surface in the | |
| 120 | + | // scene) is served from the displayed frame: no second composition. | |
| 121 | + | func TestSplitNoSecondCompositionWhenCleanEqualsDisplayed(t *testing.T) { | |
| 122 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 123 | + | defer p.Close(nil) | |
| 124 | + | s := cleanTestScene() | |
| 125 | + | s.Capture.BorderWidth, s.Capture.Excluded = 0, []ports.WindowID{99} | |
| 126 | + | normal, clean := p.Split(s, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}) | |
| 127 | + | require.Len(t, normal, 2) | |
| 128 | + | require.Empty(t, clean) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func TestSplitStandardCapturesKeepSessionScene(t *testing.T) { | |
| 132 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 133 | + | defer p.Close(nil) | |
| 134 | + | normal, clean := p.Split(cleanTestScene(), []ports.CaptureRequest{{ID: 1}, {ID: 2}}) | |
| 135 | + | require.Len(t, normal, 2) | |
| 136 | + | require.Empty(t, clean) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // A request stamped with a revision the scene has not reached is refused: | |
| 140 | + | // the scene's exclusion list may predate a newly mapped HUD layer. | |
| 141 | + | func TestSplitStaleRevisionFailsClosed(t *testing.T) { | |
| 142 | + | replies := make(chan ports.CaptureDone, 4) | |
| 143 | + | p := NewPipeline(context.Background(), replies) | |
| 144 | + | defer p.Close(nil) | |
| 145 | + | s := cleanTestScene() | |
| 146 | + | s.Capture.Revision = 3 | |
| 147 | + | normal, clean := p.Split(s, []ports.CaptureRequest{ | |
| 148 | + | {ID: 1, Clean: true, Session: 7, CaptureRevision: 4}, | |
| 149 | + | {ID: 2, Clean: true, Session: 7, CaptureRevision: 3}, | |
| 150 | + | {ID: 3, Clean: true, Session: 7, CaptureRevision: 2}, | |
| 151 | + | }) | |
| 152 | + | require.Empty(t, normal) | |
| 153 | + | require.Len(t, clean, 2) | |
| 154 | + | got := awaitCapture(t, replies) | |
| 155 | + | require.Equal(t, uint64(1), got.ID) | |
| 156 | + | require.ErrorIs(t, got.Err, ErrSessionStale) | |
| 157 | + | } | |
A internal/adapters/capture/offscreen.go +429 -0
| @@ -0,0 +1,429 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "image" | |
| 8 | + | "maps" | |
| 9 | + | "math" | |
| 10 | + | "os" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/syncfile" | |
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | "golang.org/x/sys/unix" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Offscreen capture: a session of a workspace that is not on screen is drawn | |
| 18 | + | // by a dedicated child renderer from Scene.CaptureScene, only for clean | |
| 19 | + | // requests and never shown. The child is a separate renderer instance: it has | |
| 20 | + | // its own Vulkan device, so its fences are not ordered against the display's. | |
| 21 | + | // It is created on the first clean request of the session and closed when the | |
| 22 | + | // scene stops carrying CaptureScene (once idle), when the session or the frame | |
| 23 | + | // size changes, and with the output. | |
| 24 | + | ||
| 25 | + | const ( | |
| 26 | + | // MaxOffscreenDim bounds either side of the child image, in pixels. | |
| 27 | + | MaxOffscreenDim = 16384 | |
| 28 | + | // MaxOffscreenBytes bounds one child image (w*h*4). | |
| 29 | + | MaxOffscreenBytes = 256 << 20 | |
| 30 | + | // maxChildFences bounds child renders whose GPU reads are not finished. | |
| 31 | + | // It equals the renderer's frame slots (two): a third Render would make | |
| 32 | + | // the owner wait for the GPU, so it is refused before it is recorded. | |
| 33 | + | maxChildFences = 2 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | var ( | |
| 37 | + | // ErrOffscreenUnavailable fails a clean request whose source is a hidden | |
| 38 | + | // workspace that this output cannot draw (no child renderer factory). | |
| 39 | + | ErrOffscreenUnavailable = errors.New("workspace capture source is unavailable") | |
| 40 | + | // ErrOffscreenGeometry fails a request the child frame cannot serve. | |
| 41 | + | ErrOffscreenGeometry = errors.New("workspace capture geometry is unsupported") | |
| 42 | + | // ErrOffscreenBusy fails a request while the child is still busy with | |
| 43 | + | // another session or size, or has too many renders in flight. | |
| 44 | + | ErrOffscreenBusy = errors.New("workspace capture is busy") | |
| 45 | + | ) | |
| 46 | + | ||
| 47 | + | // fenceSlot is one child render whose GPU reads may not be finished: its sync | |
| 48 | + | // file and the read ceiling it implies. ceil holds, for each window the render | |
| 49 | + | // drew, the content Seq the renderer was given. The map is reused for the | |
| 50 | + | // output's lifetime (cleared and refilled), so a steady state allocates | |
| 51 | + | // nothing. | |
| 52 | + | type fenceSlot struct { | |
| 53 | + | fd *os.File | |
| 54 | + | ceil map[ports.WindowID]uint64 | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // release closes the fence and forgets the ceiling, keeping the map. | |
| 58 | + | func (s *fenceSlot) release() { | |
| 59 | + | if s.fd != nil { | |
| 60 | + | s.fd.Close() | |
| 61 | + | s.fd = nil | |
| 62 | + | } | |
| 63 | + | clear(s.ceil) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // offscreen is owned by the goroutine of its Pipeline. | |
| 67 | + | type offscreen struct { | |
| 68 | + | factory func(w, h int) (ports.Renderer, error) | |
| 69 | + | r ports.Renderer | |
| 70 | + | p *Pipeline // leases of r | |
| 71 | + | w, h int | |
| 72 | + | session uint64 | |
| 73 | + | // slots[:nf] are the unfinished child renders, oldest first. | |
| 74 | + | slots [maxChildFences]fenceSlot | |
| 75 | + | nf int | |
| 76 | + | clean cleaner | |
| 77 | + | // capped is the scratch map CapHiddenSeen returns. | |
| 78 | + | capped map[ports.WindowID]uint64 | |
| 79 | + | // reads is the scratch map of the oldest outstanding child reads. | |
| 80 | + | reads map[ports.WindowID]uint64 | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // EnableOffscreen lets the pipeline serve clean requests of a hidden | |
| 84 | + | // workspace with renderers made by factory. Without it they fail closed | |
| 85 | + | // with ErrOffscreenUnavailable. | |
| 86 | + | func (p *Pipeline) EnableOffscreen(factory func(w, h int) (ports.Renderer, error)) { | |
| 87 | + | p.off = &offscreen{factory: factory} | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // HiddenCompleted wakes the owner when a child capture finished; nil (blocks | |
| 91 | + | // forever in a select) while there is no child. | |
| 92 | + | func (p *Pipeline) HiddenCompleted() <-chan *batch { | |
| 93 | + | if p == nil || p.off == nil || p.off.p == nil { | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | return p.off.p.completed | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // RecycleHidden returns a completed child lease. | |
| 100 | + | func (p *Pipeline) RecycleHidden(b *batch) { | |
| 101 | + | if p != nil && p.off != nil && p.off.p != nil { | |
| 102 | + | p.off.p.Recycle(b, p.off.r) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // HiddenReading reports whether the child may still read client buffers. The | |
| 107 | + | // display's fences do not cover it. | |
| 108 | + | func (p *Pipeline) HiddenReading() bool { | |
| 109 | + | if p == nil || p.off == nil { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | p.off.reap() | |
| 113 | + | return p.off.nf > 0 | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // CapHiddenSeen limits seen, the content Seq per window an output has read, | |
| 117 | + | // to what the unfinished child renders were given: a client buffer newer than | |
| 118 | + | // that may not be released yet on their account. Only windows a render drew | |
| 119 | + | // are capped, each by the lowest ceiling among the unfinished renders that | |
| 120 | + | // drew it (a window the render drew without content is capped at 0); every | |
| 121 | + | // other window is untouched, so the display's own reads are never held back. | |
| 122 | + | // The second result records the oldest outstanding read per window, even | |
| 123 | + | // when Seen needs no cap: those buffers must not be released by a stale-report | |
| 124 | + | // timeout. The last result says whether child reads remain, so the owner keeps | |
| 125 | + | // reporting until they finish. Both maps are pipeline scratch, valid until | |
| 126 | + | // the next call; copy them to keep them. | |
| 127 | + | func (p *Pipeline) CapHiddenSeen(seen map[ports.WindowID]uint64) (map[ports.WindowID]uint64, map[ports.WindowID]uint64, bool) { | |
| 128 | + | if p == nil || p.off == nil { | |
| 129 | + | return seen, nil, false | |
| 130 | + | } | |
| 131 | + | o := p.off | |
| 132 | + | o.reap() | |
| 133 | + | if o.nf == 0 { | |
| 134 | + | return seen, nil, false | |
| 135 | + | } | |
| 136 | + | if o.reads == nil { | |
| 137 | + | o.reads = make(map[ports.WindowID]uint64) | |
| 138 | + | } | |
| 139 | + | clear(o.reads) | |
| 140 | + | hit := false | |
| 141 | + | for i := range o.nf { | |
| 142 | + | for id, c := range o.slots[i].ceil { | |
| 143 | + | if prev, ok := o.reads[id]; !ok || c < prev { | |
| 144 | + | o.reads[id] = c | |
| 145 | + | } | |
| 146 | + | if seen[id] > c { | |
| 147 | + | hit = true | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | if !hit { | |
| 152 | + | return seen, o.reads, true | |
| 153 | + | } | |
| 154 | + | if o.capped == nil { | |
| 155 | + | o.capped = make(map[ports.WindowID]uint64, len(seen)) | |
| 156 | + | } | |
| 157 | + | clear(o.capped) | |
| 158 | + | maps.Copy(o.capped, seen) | |
| 159 | + | for i := range o.nf { | |
| 160 | + | for id, c := range o.slots[i].ceil { | |
| 161 | + | if v, ok := o.capped[id]; ok && v > c { | |
| 162 | + | o.capped[id] = c | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | return o.capped, o.reads, true | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // WaitHidden blocks until the child finished reading (headless outputs, which | |
| 170 | + | // have no screen pacing frames anyway). Whatever happens, every fence is | |
| 171 | + | // closed on return: after a failure or a cancelled ctx the rest is not waited | |
| 172 | + | // for (the renderer's Close waits for the device before it frees anything). | |
| 173 | + | func (p *Pipeline) WaitHidden(ctx context.Context) error { | |
| 174 | + | if p == nil || p.off == nil { | |
| 175 | + | return nil | |
| 176 | + | } | |
| 177 | + | o := p.off | |
| 178 | + | var first error | |
| 179 | + | for i := range o.nf { | |
| 180 | + | if first == nil { | |
| 181 | + | first = syncfile.Wait(ctx, o.slots[i].fd) | |
| 182 | + | } | |
| 183 | + | o.slots[i].release() | |
| 184 | + | } | |
| 185 | + | o.nf = 0 | |
| 186 | + | return first | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Retire closes the child once the scene no longer carries CaptureScene and | |
| 190 | + | // nothing of it is in flight. Call it when a frame is prepared, when a child | |
| 191 | + | // lease is recycled and on the idle housekeeping tick. | |
| 192 | + | func (p *Pipeline) Retire(s ports.Scene) { | |
| 193 | + | if p != nil && p.off != nil && s.CaptureScene == nil && p.off.r != nil && !p.off.busy() { | |
| 194 | + | p.off.close() | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // fenceState is what a zero-timeout poll says about a child render fence. | |
| 199 | + | type fenceState int | |
| 200 | + | ||
| 201 | + | const ( | |
| 202 | + | fencePending fenceState = iota // GPU work still running (or poll interrupted) | |
| 203 | + | fenceDone // finished: nothing is left to read | |
| 204 | + | fenceFailed // terminal: the GPU failed the work or poll broke | |
| 205 | + | ) | |
| 206 | + | ||
| 207 | + | // reap forgets the renders whose fence signalled, oldest first kept in order. | |
| 208 | + | // A terminal fence error cannot be waited out, and its hold does not expire: | |
| 209 | + | // the child pipeline is cancelled and joined, then the device is closed (which | |
| 210 | + | // waits for the GPU), and only then are the holds cleared, so every later | |
| 211 | + | // report lifts them. The next clean request builds a new child. | |
| 212 | + | func (o *offscreen) reap() { | |
| 213 | + | k := 0 | |
| 214 | + | failed := false | |
| 215 | + | for i := range o.nf { | |
| 216 | + | switch pollFence(o.slots[i].fd) { | |
| 217 | + | case fenceDone: | |
| 218 | + | o.slots[i].release() | |
| 219 | + | continue | |
| 220 | + | case fenceFailed: | |
| 221 | + | failed = true | |
| 222 | + | } | |
| 223 | + | if k != i { | |
| 224 | + | o.slots[k], o.slots[i] = o.slots[i], o.slots[k] | |
| 225 | + | } | |
| 226 | + | k++ | |
| 227 | + | } | |
| 228 | + | o.nf = k | |
| 229 | + | if failed { | |
| 230 | + | o.close() | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | // pollFence polls a sync file without waiting. It does not allocate. | |
| 235 | + | func pollFence(f *os.File) fenceState { | |
| 236 | + | fd := int32(f.Fd()) | |
| 237 | + | if fd < 0 { | |
| 238 | + | return fenceDone // closed: poll would skip it, and nothing is left to wait for | |
| 239 | + | } | |
| 240 | + | var pfd [1]unix.PollFd | |
| 241 | + | pfd[0] = unix.PollFd{Fd: fd, Events: unix.POLLIN} | |
| 242 | + | n, err := unix.Poll(pfd[:], 0) | |
| 243 | + | return pollResult(n, err, pfd[0].Revents) | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | // pollResult decides from a zero-timeout poll. A readable fence (or an invalid | |
| 247 | + | // descriptor, which has nothing left to wait for) is done. An interrupted poll | |
| 248 | + | // and a timeout are pending. An error status (POLLERR: the GPU failed the | |
| 249 | + | // work) or any other poll error is terminal: it will never signal. | |
| 250 | + | func pollResult(n int, err error, revents int16) fenceState { | |
| 251 | + | switch { | |
| 252 | + | case errors.Is(err, unix.EBADF): | |
| 253 | + | return fenceDone | |
| 254 | + | case errors.Is(err, unix.EINTR): | |
| 255 | + | return fencePending | |
| 256 | + | case err != nil: | |
| 257 | + | return fenceFailed | |
| 258 | + | case n <= 0: | |
| 259 | + | return fencePending | |
| 260 | + | case revents&unix.POLLERR != 0: | |
| 261 | + | return fenceFailed | |
| 262 | + | case revents&(unix.POLLIN|unix.POLLNVAL) != 0: | |
| 263 | + | return fenceDone | |
| 264 | + | } | |
| 265 | + | return fencePending | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func (o *offscreen) busy() bool { | |
| 269 | + | o.reap() | |
| 270 | + | if o.nf > 0 { | |
| 271 | + | return true | |
| 272 | + | } | |
| 273 | + | if o.p != nil { | |
| 274 | + | o.p.reclaim(o.r) | |
| 275 | + | for i := range o.p.batches { | |
| 276 | + | if o.p.batches[i].frame != nil { | |
| 277 | + | return true | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | return false | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func (o *offscreen) close() { | |
| 285 | + | if o.p != nil { | |
| 286 | + | o.p.Close(o.r) | |
| 287 | + | o.p = nil | |
| 288 | + | } | |
| 289 | + | if o.r != nil { | |
| 290 | + | o.r.Close() | |
| 291 | + | o.r = nil | |
| 292 | + | } | |
| 293 | + | for i := range o.slots { | |
| 294 | + | o.slots[i].release() | |
| 295 | + | } | |
| 296 | + | o.nf, o.w, o.h, o.session = 0, 0, 0, 0 | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // childSize is the physical child image: the workspace frame at the scene | |
| 300 | + | // scale, rounded up. | |
| 301 | + | func childSize(cs *ports.Scene) (w, h int, err error) { | |
| 302 | + | scale := cs.Scale | |
| 303 | + | if scale <= 0 { | |
| 304 | + | scale = 1 | |
| 305 | + | } | |
| 306 | + | fw, fh := math.Ceil(float64(cs.OutputWidth)*scale), math.Ceil(float64(cs.OutputHeight)*scale) | |
| 307 | + | if !(fw >= 1 && fh >= 1 && fw <= MaxOffscreenDim && fh <= MaxOffscreenDim) || fw*fh*4 > MaxOffscreenBytes { | |
| 308 | + | return 0, 0, fmt.Errorf("%w: frame %.0fx%.0f", ErrOffscreenGeometry, fw, fh) | |
| 309 | + | } | |
| 310 | + | return int(fw), int(fh), nil | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // track records the ceiling of a child render on the next free slot: every | |
| 314 | + | // window and layer the render drew, with the content Seq it was given. | |
| 315 | + | func (o *offscreen) track(done *os.File, drawn ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent) { | |
| 316 | + | slot := &o.slots[o.nf] | |
| 317 | + | if slot.ceil == nil { | |
| 318 | + | slot.ceil = make(map[ports.WindowID]uint64, len(drawn.Windows)+len(drawn.Layers)) | |
| 319 | + | } | |
| 320 | + | clear(slot.ceil) | |
| 321 | + | for _, w := range drawn.Windows { | |
| 322 | + | slot.ceil[w.ID] = surfaces[w.ID].Seq | |
| 323 | + | } | |
| 324 | + | for _, l := range drawn.Layers { | |
| 325 | + | slot.ceil[l.ID] = surfaces[l.ID].Seq | |
| 326 | + | } | |
| 327 | + | slot.fd = done | |
| 328 | + | o.nf++ | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | // SubmitHidden serves the clean requests of s from the child renderer, which | |
| 332 | + | // draws s.CaptureScene without the session's excluded surfaces. It consumes | |
| 333 | + | // every request: answered by the worker, or failed here (the display is not | |
| 334 | + | // stopped by a child failure). Request regions are physical output pixels and | |
| 335 | + | // must cover the whole child frame; they are rebased to its origin. The target | |
| 336 | + | // must be the whole workspace frame too. Anything smaller needs the viewport | |
| 337 | + | // origin in output coordinates, which the scene does not carry, and fails with | |
| 338 | + | // ErrOffscreenGeometry. | |
| 339 | + | func (p *Pipeline) SubmitHidden(s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, reqs []ports.CaptureRequest) { | |
| 340 | + | fail := func(err error, qs []ports.CaptureRequest) { | |
| 341 | + | for _, q := range qs { | |
| 342 | + | Fail(p.ctx, q, err, p.replies) | |
| 343 | + | } | |
| 344 | + | } | |
| 345 | + | o := p.off | |
| 346 | + | switch { | |
| 347 | + | case len(reqs) == 0: | |
| 348 | + | return | |
| 349 | + | case s.Off: | |
| 350 | + | fail(errors.New("output off"), reqs) | |
| 351 | + | return | |
| 352 | + | case s.CaptureScene == nil || s.Capture == nil: | |
| 353 | + | fail(ErrOffscreenUnavailable, reqs) | |
| 354 | + | return | |
| 355 | + | case o == nil || o.factory == nil: | |
| 356 | + | fail(ErrOffscreenUnavailable, reqs) | |
| 357 | + | return | |
| 358 | + | case p.ctx.Err() != nil: | |
| 359 | + | fail(p.ctx.Err(), reqs) | |
| 360 | + | return | |
| 361 | + | } | |
| 362 | + | cs := s.CaptureScene | |
| 363 | + | w, h, err := childSize(cs) | |
| 364 | + | if err == nil { | |
| 365 | + | t := s.Capture.TargetRect | |
| 366 | + | if t.W != cs.OutputWidth || t.H != cs.OutputHeight { | |
| 367 | + | err = fmt.Errorf("%w: target %dx%d is not the frame %dx%d", ErrOffscreenGeometry, t.W, t.H, cs.OutputWidth, cs.OutputHeight) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | if err != nil { | |
| 371 | + | fail(err, reqs) | |
| 372 | + | return | |
| 373 | + | } | |
| 374 | + | if o.r != nil && (o.session != s.Capture.Session || o.w != w || o.h != h) { | |
| 375 | + | if o.busy() { | |
| 376 | + | fail(ErrOffscreenBusy, reqs) | |
| 377 | + | return | |
| 378 | + | } | |
| 379 | + | o.close() | |
| 380 | + | } | |
| 381 | + | o.reap() | |
| 382 | + | if o.nf == len(o.slots) { | |
| 383 | + | // Refused before Render: a third frame would make the owner wait. | |
| 384 | + | fail(ErrOffscreenBusy, reqs) | |
| 385 | + | return | |
| 386 | + | } | |
| 387 | + | if o.r == nil { | |
| 388 | + | r, err := o.factory(w, h) | |
| 389 | + | if err != nil { | |
| 390 | + | fail(fmt.Errorf("%w: %v", ErrOffscreenUnavailable, err), reqs) | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | o.r, o.p, o.w, o.h, o.session = r, NewPipeline(p.ctx, p.replies), w, h, s.Capture.Session | |
| 394 | + | } | |
| 395 | + | // Request regions are physical pixels of the output; TargetRect is | |
| 396 | + | // relative to the workspace viewport, so it cannot place them. The child | |
| 397 | + | // image is the whole target (checked above): a request for all of it has | |
| 398 | + | // the child's size and starts at the child's origin. A smaller region | |
| 399 | + | // would need the viewport origin in output coordinates, which the scene | |
| 400 | + | // does not carry; guessing it would return the wrong crop, so it fails. | |
| 401 | + | k := 0 | |
| 402 | + | for _, q := range reqs { | |
| 403 | + | if q.Region.Dx() != w || q.Region.Dy() != h { | |
| 404 | + | Fail(p.ctx, q, fmt.Errorf("%w: region %v is not the whole %dx%d workspace frame", ErrOffscreenGeometry, q.Region, w, h), p.replies) | |
| 405 | + | continue | |
| 406 | + | } | |
| 407 | + | q.Region = image.Rect(0, 0, w, h) | |
| 408 | + | reqs[k] = q | |
| 409 | + | k++ | |
| 410 | + | } | |
| 411 | + | clear(reqs[k:]) | |
| 412 | + | reqs = reqs[:k] | |
| 413 | + | if k == 0 { | |
| 414 | + | return | |
| 415 | + | } | |
| 416 | + | // The child never shows the session's own surfaces or border. | |
| 417 | + | child := *cs | |
| 418 | + | child.Capture = &ports.SceneCapture{Excluded: s.Capture.Excluded} | |
| 419 | + | child = o.clean.scene(child) | |
| 420 | + | done, err := o.r.Render(child, surfaces) | |
| 421 | + | if err != nil { | |
| 422 | + | fail(fmt.Errorf("render workspace: %w", err), reqs) | |
| 423 | + | return | |
| 424 | + | } | |
| 425 | + | if done != nil { | |
| 426 | + | o.track(done, child, surfaces) | |
| 427 | + | } | |
| 428 | + | o.p.Submit(o.r, reqs) | |
| 429 | + | } | |
A internal/adapters/capture/offscreen_fence_test.go +294 -0
| @@ -0,0 +1,294 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "image" | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | "golang.org/x/sys/unix" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // fencePipe is a sync file stand-in from the kernel: readable once written. | |
| 17 | + | // fencePipe comes from pipeline_fence_test.go. | |
| 18 | + | ||
| 19 | + | func signalFence(t *testing.T, w *os.File) { | |
| 20 | + | t.Helper() | |
| 21 | + | _, err := w.Write([]byte{1}) | |
| 22 | + | require.NoError(t, err) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func fenceClosed(f *os.File) bool { | |
| 26 | + | _, err := f.Stat() | |
| 27 | + | return err != nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // hiddenPipeline returns a pipeline whose child renders return the given | |
| 31 | + | // fences in order, and the scene/surfaces to submit. | |
| 32 | + | func hiddenPipeline(t *testing.T, fences ...*os.File) (*Pipeline, chan ports.CaptureDone, *portsmocks.MockRenderer) { | |
| 33 | + | t.Helper() | |
| 34 | + | replies := make(chan ports.CaptureDone, 8) | |
| 35 | + | p := NewPipeline(context.Background(), replies) | |
| 36 | + | child := portsmocks.NewMockRenderer(t) | |
| 37 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 38 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 39 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 40 | + | child.EXPECT().EndCapture(frame).Return().Maybe() | |
| 41 | + | child.EXPECT().BeginCapture().Return(frame, nil).Maybe() | |
| 42 | + | child.EXPECT().Close().Return().Maybe() | |
| 43 | + | for _, f := range fences { | |
| 44 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(f, nil).Once() | |
| 45 | + | } | |
| 46 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 47 | + | return p, replies, child | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func hiddenSceneWindows(ids ...ports.WindowID) ports.Scene { | |
| 51 | + | s := hiddenScene() | |
| 52 | + | s.CaptureScene.Windows, s.CaptureScene.Layers = nil, nil | |
| 53 | + | for _, id := range ids { | |
| 54 | + | s.CaptureScene.Windows = append(s.CaptureScene.Windows, ports.SceneWindow{ID: id}) | |
| 55 | + | } | |
| 56 | + | return s | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func submitHiddenOne(t *testing.T, p *Pipeline, s ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, id uint64) { | |
| 60 | + | t.Helper() | |
| 61 | + | p.SubmitHidden(s, surfaces, []ports.CaptureRequest{hiddenRequest(t, id, image.Rect(0, 0, 200, 100))}) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // GO001: a failing or cancelled wait still closes every fence, the ones not | |
| 65 | + | // waited for included. | |
| 66 | + | func TestWaitHiddenClosesEveryFenceOnCancel(t *testing.T) { | |
| 67 | + | f1, _ := fencePipe(t) | |
| 68 | + | f2, _ := fencePipe(t) | |
| 69 | + | p, replies, _ := hiddenPipeline(t, f1, f2) | |
| 70 | + | defer p.Close(nil) | |
| 71 | + | s := hiddenSceneWindows(5) | |
| 72 | + | submitHiddenOne(t, p, s, nil, 1) | |
| 73 | + | submitHiddenOne(t, p, s, nil, 2) | |
| 74 | + | require.Equal(t, 2, p.off.nf) | |
| 75 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 76 | + | cancel() | |
| 77 | + | require.Error(t, p.WaitHidden(ctx)) | |
| 78 | + | require.Zero(t, p.off.nf) | |
| 79 | + | require.True(t, fenceClosed(f1), "first fence closed") | |
| 80 | + | require.True(t, fenceClosed(f2), "second fence, never waited for, closed too") | |
| 81 | + | for range 2 { | |
| 82 | + | awaitCapture(t, replies) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // A terminal fence error must cancel and join readback before closing the | |
| 87 | + | // device. Holds remain in place until Close has made GPU reads impossible. | |
| 88 | + | func TestTerminalChildFenceClosesPipelineBeforeClearingHolds(t *testing.T) { | |
| 89 | + | read, failed, err := os.Pipe() | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | defer failed.Close() | |
| 92 | + | pending, _ := fencePipe(t) | |
| 93 | + | p := NewPipeline(context.Background(), make(chan ports.CaptureDone, 2)) | |
| 94 | + | defer p.Close(nil) | |
| 95 | + | child := portsmocks.NewMockRenderer(t) | |
| 96 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 97 | + | frame.EXPECT().Done().Return(pending).Maybe() | |
| 98 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(failed, nil).Once() | |
| 99 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 100 | + | ended := false | |
| 101 | + | child.EXPECT().EndCapture(frame).Run(func(ports.CaptureFrame) { ended = true }).Once() | |
| 102 | + | child.EXPECT().Close().Run(func() { | |
| 103 | + | require.True(t, ended, "worker joined and lease returned before device closes") | |
| 104 | + | require.Equal(t, 1, p.off.nf, "hold retained until device closes") | |
| 105 | + | }).Once() | |
| 106 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 107 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {Seq: 2}}, 1) | |
| 108 | + | _, reads, busy := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 7}) | |
| 109 | + | require.True(t, busy) | |
| 110 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2}, reads) | |
| 111 | + | require.NoError(t, read.Close()) // write end now polls POLLERR | |
| 112 | + | p.Retire(ports.Scene{}) // session end must not wait forever on the error | |
| 113 | + | require.Nil(t, p.off.r, "failed device closed") | |
| 114 | + | require.Nil(t, p.HiddenCompleted(), "failed worker joined") | |
| 115 | + | seen := map[ports.WindowID]uint64{5: 7} | |
| 116 | + | got, reads, busy := p.CapHiddenSeen(seen) | |
| 117 | + | require.False(t, busy) | |
| 118 | + | require.Empty(t, reads, "closed device lifts non-expiring holds") | |
| 119 | + | require.Equal(t, seen, got) | |
| 120 | + | require.True(t, fenceClosed(failed)) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestWaitHiddenWaitsForSignalledFences(t *testing.T) { | |
| 124 | + | f1, w1 := fencePipe(t) | |
| 125 | + | p, replies, _ := hiddenPipeline(t, f1) | |
| 126 | + | defer p.Close(nil) | |
| 127 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1) | |
| 128 | + | signalFence(t, w1) | |
| 129 | + | require.NoError(t, p.WaitHidden(context.Background())) | |
| 130 | + | require.True(t, fenceClosed(f1)) | |
| 131 | + | awaitCapture(t, replies) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // GO002: the ceiling of a fence covers only the windows its render drew, per | |
| 135 | + | // fence, and a signalled fence lifts it. | |
| 136 | + | func TestCapHiddenSeenPerFenceCeilings(t *testing.T) { | |
| 137 | + | f1, w1 := fencePipe(t) | |
| 138 | + | f2, w2 := fencePipe(t) | |
| 139 | + | p, replies, _ := hiddenPipeline(t, f1, f2) | |
| 140 | + | defer p.Close(nil) | |
| 141 | + | ||
| 142 | + | // Render 1 drew window 5 (content Seq 2); render 2 drew 5 (Seq 6) and 6 (Seq 3). | |
| 143 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1) | |
| 144 | + | submitHiddenOne(t, p, hiddenSceneWindows(5, 6), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 6}, 6: {ID: 6, Seq: 3}}, 2) | |
| 145 | + | ||
| 146 | + | // 5 is capped by the lowest ceiling among the unfinished renders that drew | |
| 147 | + | // it; 6 by the second; the display's window 1 and an unrelated 9 are free. | |
| 148 | + | seen := map[ports.WindowID]uint64{1: 40, 5: 8, 6: 8, 9: 50} | |
| 149 | + | got, reads, capped := p.CapHiddenSeen(seen) | |
| 150 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2, 6: 3}, reads) | |
| 151 | + | require.True(t, capped) | |
| 152 | + | require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 2, 6: 3, 9: 50}, got) | |
| 153 | + | require.Equal(t, uint64(8), seen[5], "input untouched") | |
| 154 | + | ||
| 155 | + | // Render 1 finishes: window 5 is now limited by render 2 only. | |
| 156 | + | signalFence(t, w1) | |
| 157 | + | got, reads, capped = p.CapHiddenSeen(seen) | |
| 158 | + | require.Equal(t, map[ports.WindowID]uint64{5: 6, 6: 3}, reads) | |
| 159 | + | require.True(t, capped) | |
| 160 | + | require.Equal(t, map[ports.WindowID]uint64{1: 40, 5: 6, 6: 3, 9: 50}, got) | |
| 161 | + | ||
| 162 | + | // A window the earlier render never saw is not capped by it: a display | |
| 163 | + | // window that is new to the child releases at once. | |
| 164 | + | signalFence(t, w2) | |
| 165 | + | got, reads, capped = p.CapHiddenSeen(seen) | |
| 166 | + | require.Empty(t, reads) | |
| 167 | + | require.False(t, capped) | |
| 168 | + | require.Equal(t, seen, got) | |
| 169 | + | for range 2 { | |
| 170 | + | awaitCapture(t, replies) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // A window the child drew with no content yet is capped at 0; one it never | |
| 175 | + | // drew is not capped at all. | |
| 176 | + | func TestCapHiddenSeenMissingContentCapsToZero(t *testing.T) { | |
| 177 | + | f1, _ := fencePipe(t) | |
| 178 | + | p, replies, _ := hiddenPipeline(t, f1) | |
| 179 | + | defer p.Close(nil) | |
| 180 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{}, 1) | |
| 181 | + | got, reads, capped := p.CapHiddenSeen(map[ports.WindowID]uint64{5: 4, 7: 4}) | |
| 182 | + | require.Equal(t, map[ports.WindowID]uint64{5: 0}, reads) | |
| 183 | + | require.True(t, capped) | |
| 184 | + | require.Equal(t, map[ports.WindowID]uint64{5: 0, 7: 4}, got) | |
| 185 | + | awaitCapture(t, replies) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestCapHiddenSeenNoAllocationInSteadyState(t *testing.T) { | |
| 189 | + | f1, _ := fencePipe(t) | |
| 190 | + | p, replies, _ := hiddenPipeline(t, f1) | |
| 191 | + | defer p.Close(nil) | |
| 192 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}}, 1) | |
| 193 | + | seen := map[ports.WindowID]uint64{1: 40, 5: 8} | |
| 194 | + | p.CapHiddenSeen(seen) // warm the scratch map | |
| 195 | + | allocs := testing.AllocsPerRun(100, func() { p.CapHiddenSeen(seen) }) | |
| 196 | + | require.LessOrEqual(t, allocs, float64(1), "only the poll descriptor may allocate") | |
| 197 | + | t.Logf("outstanding child report: %.1f allocs", allocs) | |
| 198 | + | awaitCapture(t, replies) | |
| 199 | + | var none *Pipeline | |
| 200 | + | got, reads, capped := none.CapHiddenSeen(seen) | |
| 201 | + | require.Nil(t, reads) | |
| 202 | + | require.False(t, capped) | |
| 203 | + | require.Equal(t, seen, got) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // GO003: a third render is refused before the renderer is asked to draw it. | |
| 207 | + | func TestSubmitHiddenThirdRenderRefusedBeforeRender(t *testing.T) { | |
| 208 | + | f1, _ := fencePipe(t) | |
| 209 | + | f2, _ := fencePipe(t) | |
| 210 | + | p, replies, _ := hiddenPipeline(t, f1, f2) // only two Render expectations | |
| 211 | + | defer p.Close(nil) | |
| 212 | + | s := hiddenSceneWindows(5) | |
| 213 | + | submitHiddenOne(t, p, s, nil, 1) | |
| 214 | + | submitHiddenOne(t, p, s, nil, 2) | |
| 215 | + | submitHiddenOne(t, p, s, nil, 3) | |
| 216 | + | byID := map[uint64]error{} | |
| 217 | + | for range 3 { | |
| 218 | + | d := awaitCapture(t, replies) | |
| 219 | + | byID[d.ID] = d.Err | |
| 220 | + | } | |
| 221 | + | require.NoError(t, byID[1]) | |
| 222 | + | require.NoError(t, byID[2]) | |
| 223 | + | require.ErrorIs(t, byID[3], ErrOffscreenBusy) | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // GO005: an idle child of an ended session is closed once, whether its lease | |
| 227 | + | // or its fence came back last. | |
| 228 | + | func TestRetireClosesIdleChildOnce(t *testing.T) { | |
| 229 | + | f1, w1 := fencePipe(t) | |
| 230 | + | replies := make(chan ports.CaptureDone, 4) | |
| 231 | + | p := NewPipeline(context.Background(), replies) | |
| 232 | + | child := portsmocks.NewMockRenderer(t) | |
| 233 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 234 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 235 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 236 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(f1, nil).Once() | |
| 237 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 238 | + | child.EXPECT().EndCapture(frame).Return().Once() | |
| 239 | + | child.EXPECT().Close().Return().Once() // exactly once | |
| 240 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 241 | + | ||
| 242 | + | submitHiddenOne(t, p, hiddenSceneWindows(5), nil, 1) | |
| 243 | + | awaitCapture(t, replies) | |
| 244 | + | p.RecycleHidden(<-p.HiddenCompleted()) | |
| 245 | + | ||
| 246 | + | // The fence is still pending: the child stays. | |
| 247 | + | p.Retire(ports.Scene{}) | |
| 248 | + | require.NotNil(t, p.off.r) | |
| 249 | + | signalFence(t, w1) | |
| 250 | + | p.Retire(ports.Scene{}) | |
| 251 | + | require.Nil(t, p.off.r, "closed once idle") | |
| 252 | + | require.Nil(t, p.HiddenCompleted(), "no child, no channel") | |
| 253 | + | p.Retire(ports.Scene{}) // nothing left to close | |
| 254 | + | p.Close(nil) | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // GO006: a readable fence (or an invalid descriptor) is done; POLLERR and poll | |
| 258 | + | // failures are terminal; timeouts and interrupts stay pending. | |
| 259 | + | func TestPollResult(t *testing.T) { | |
| 260 | + | for _, tc := range []struct { | |
| 261 | + | name string | |
| 262 | + | n int | |
| 263 | + | err error | |
| 264 | + | revents int16 | |
| 265 | + | want fenceState | |
| 266 | + | }{ | |
| 267 | + | {"readable", 1, nil, unix.POLLIN, fenceDone}, | |
| 268 | + | {"timeout", 0, nil, 0, fencePending}, | |
| 269 | + | {"interrupted", -1, unix.EINTR, 0, fencePending}, | |
| 270 | + | {"other poll error", -1, unix.EIO, 0, fenceFailed}, | |
| 271 | + | {"invalid poll descriptor", -1, unix.EBADF, 0, fenceDone}, | |
| 272 | + | {"invalid descriptor event", 1, nil, unix.POLLNVAL, fenceDone}, | |
| 273 | + | {"gpu error status", 1, nil, unix.POLLERR, fenceFailed}, | |
| 274 | + | {"error with data", 1, nil, unix.POLLIN | unix.POLLERR, fenceFailed}, | |
| 275 | + | {"hangup only", 1, nil, unix.POLLHUP, fencePending}, | |
| 276 | + | } { | |
| 277 | + | t.Run(tc.name, func(t *testing.T) { | |
| 278 | + | require.Equal(t, tc.want, pollResult(tc.n, tc.err, tc.revents)) | |
| 279 | + | }) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | func TestPollFenceOnRealDescriptors(t *testing.T) { | |
| 284 | + | r, w := fencePipe(t) | |
| 285 | + | require.Equal(t, fencePending, pollFence(r), "nothing written yet") | |
| 286 | + | signalFence(t, w) | |
| 287 | + | require.Equal(t, fenceDone, pollFence(r)) | |
| 288 | + | closed, _ := fencePipe(t) | |
| 289 | + | require.NoError(t, closed.Close()) | |
| 290 | + | require.Equal(t, fenceDone, pollFence(closed), "a closed descriptor has nothing to wait for") | |
| 291 | + | allocs := testing.AllocsPerRun(100, func() { pollFence(r) }) | |
| 292 | + | require.Zero(t, allocs, "polling a fence does not allocate") | |
| 293 | + | t.Logf("pollFence: %.1f allocs", allocs) | |
| 294 | + | } | |
A internal/adapters/capture/offscreen_test.go +149 -0
| @@ -0,0 +1,149 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "os" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 13 | + | "github.com/stretchr/testify/require" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func hiddenScene() ports.Scene { | |
| 17 | + | return ports.Scene{ | |
| 18 | + | Scale: 2, OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1}}, | |
| 19 | + | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 100, H: 50}, Revision: 1, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, | |
| 20 | + | CaptureScene: &ports.Scene{ | |
| 21 | + | OutputWidth: 100, OutputHeight: 50, Scale: 2, Seq: 9, | |
| 22 | + | Windows: []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}}, | |
| 23 | + | Layers: []ports.SceneLayer{{ID: 10}, {ID: 11}}, | |
| 24 | + | }, | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func hiddenRequest(t *testing.T, id uint64, region image.Rectangle) ports.CaptureRequest { | |
| 29 | + | q := pipelineRequest(t, id) | |
| 30 | + | q.Clean, q.Session, q.CaptureRevision = true, 7, 1 | |
| 31 | + | q.Region, q.Width, q.Height, q.Stride = region, region.Dx(), region.Dy(), region.Dx()*4 | |
| 32 | + | require.NoError(t, q.Dst.File.Truncate(int64(q.Stride*q.Height))) | |
| 33 | + | return q | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func childMock(t *testing.T, w, h int) (*portsmocks.MockRenderer, *portsmocks.MockCaptureFrame) { | |
| 37 | + | r := portsmocks.NewMockRenderer(t) | |
| 38 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 39 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 40 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 41 | + | r.EXPECT().EndCapture(frame).Return().Maybe() | |
| 42 | + | r.EXPECT().Close().Return().Once() | |
| 43 | + | r.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 44 | + | return r, frame | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestSubmitHiddenDrawsChildWithoutExcludedAndRebasesRegion(t *testing.T) { | |
| 48 | + | replies := make(chan ports.CaptureDone, 4) | |
| 49 | + | p := NewPipeline(context.Background(), replies) | |
| 50 | + | child, _ := childMock(t, 200, 100) | |
| 51 | + | var scene ports.Scene | |
| 52 | + | child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 53 | + | scene = s | |
| 54 | + | return nil, nil | |
| 55 | + | }).Once() | |
| 56 | + | var made [2]int | |
| 57 | + | p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil }) | |
| 58 | + | s := hiddenScene() | |
| 59 | + | s.Capture.TargetRect = ports.Rect{W: 100, H: 50} | |
| 60 | + | p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(480, 360, 680, 460))}) | |
| 61 | + | require.Equal(t, [2]int{200, 100}, made, "child image is the frame in physical pixels") | |
| 62 | + | require.Nil(t, scene.Capture) | |
| 63 | + | require.Zero(t, scene.Seq) | |
| 64 | + | require.Equal(t, []ports.SceneWindow{{ID: 5}}, scene.Windows, "excluded popup left out") | |
| 65 | + | require.Equal(t, []ports.SceneLayer{{ID: 11}}, scene.Layers, "excluded HUD layer left out") | |
| 66 | + | require.NoError(t, awaitCapture(t, replies).Err) | |
| 67 | + | p.Close(nil) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestSubmitHiddenFailsClosed(t *testing.T) { | |
| 71 | + | for _, tc := range []struct { | |
| 72 | + | name string | |
| 73 | + | edit func(*ports.Scene) | |
| 74 | + | want error | |
| 75 | + | }{ | |
| 76 | + | {"output off", func(s *ports.Scene) { s.Off = true }, nil}, | |
| 77 | + | {"target is not the frame", func(s *ports.Scene) { s.Capture.TargetRect.W = 50 }, ErrOffscreenGeometry}, | |
| 78 | + | {"frame too large", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 20000, 20000 }, ErrOffscreenGeometry}, | |
| 79 | + | {"frame empty", func(s *ports.Scene) { s.CaptureScene.OutputWidth, s.Capture.TargetRect.W = 0, 0 }, ErrOffscreenGeometry}, | |
| 80 | + | } { | |
| 81 | + | t.Run(tc.name, func(t *testing.T) { | |
| 82 | + | replies := make(chan ports.CaptureDone, 2) | |
| 83 | + | p := NewPipeline(context.Background(), replies) | |
| 84 | + | defer p.Close(nil) | |
| 85 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { t.Fatal("factory called"); return nil, nil }) | |
| 86 | + | s := hiddenScene() | |
| 87 | + | tc.edit(&s) | |
| 88 | + | p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))}) | |
| 89 | + | got := awaitCapture(t, replies) | |
| 90 | + | require.Error(t, got.Err) | |
| 91 | + | if tc.want != nil { | |
| 92 | + | require.ErrorIs(t, got.Err, tc.want) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestSubmitHiddenFactoryFailureAndNoFactory(t *testing.T) { | |
| 99 | + | replies := make(chan ports.CaptureDone, 4) | |
| 100 | + | p := NewPipeline(context.Background(), replies) | |
| 101 | + | defer p.Close(nil) | |
| 102 | + | p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))}) | |
| 103 | + | require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable) | |
| 104 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return nil, errors.New("no gpu") }) | |
| 105 | + | p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 2, image.Rect(0, 0, 200, 100))}) | |
| 106 | + | require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestSubmitHiddenRegionOutsideFrameFailsOthersServed(t *testing.T) { | |
| 110 | + | replies := make(chan ports.CaptureDone, 4) | |
| 111 | + | p := NewPipeline(context.Background(), replies) | |
| 112 | + | child, _ := childMock(t, 200, 100) | |
| 113 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 114 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 115 | + | p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{ | |
| 116 | + | hiddenRequest(t, 1, image.Rect(0, 0, 300, 100)), // not the child's size | |
| 117 | + | hiddenRequest(t, 2, image.Rect(0, 0, 200, 100)), | |
| 118 | + | }) | |
| 119 | + | byID := map[uint64]error{} | |
| 120 | + | for range 2 { | |
| 121 | + | d := awaitCapture(t, replies) | |
| 122 | + | byID[d.ID] = d.Err | |
| 123 | + | } | |
| 124 | + | require.ErrorIs(t, byID[1], ErrOffscreenGeometry) | |
| 125 | + | require.NoError(t, byID[2]) | |
| 126 | + | p.Close(nil) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestSubmitHiddenChildClosesWhenSessionEnds(t *testing.T) { | |
| 130 | + | replies := make(chan ports.CaptureDone, 4) | |
| 131 | + | p := NewPipeline(context.Background(), replies) | |
| 132 | + | child, _ := childMock(t, 200, 100) | |
| 133 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 134 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 135 | + | p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))}) | |
| 136 | + | require.NoError(t, awaitCapture(t, replies).Err) | |
| 137 | + | // Wait for the worker to hand the lease back, then the session ends. | |
| 138 | + | b := <-p.HiddenCompleted() | |
| 139 | + | p.RecycleHidden(b) | |
| 140 | + | p.Retire(ports.Scene{}) | |
| 141 | + | require.Nil(t, p.off.r, "child closed once the scene has no CaptureScene") | |
| 142 | + | p.Close(nil) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func TestHiddenNilPipelineSafe(t *testing.T) { | |
| 146 | + | var p *Pipeline | |
| 147 | + | require.False(t, p.HiddenReading()) | |
| 148 | + | p.Retire(ports.Scene{}) | |
| 149 | + | } | |
M internal/adapters/capture/pipeline.go +5 -0
| @@ -33,6 +33,8 @@ type Pipeline struct { | |||
| 33 | 33 | jobs chan *batch | |
| 34 | 34 | completed chan *batch | |
| 35 | 35 | batches [pipelineSlots]batch | |
| 36 | + | clean cleaner // scratch of CleanScene, owner only | |
| 37 | + | off *offscreen | |
| 36 | 38 | worker sync.WaitGroup | |
| 37 | 39 | } | |
| 38 | 40 | ||
| @@ -146,4 +148,7 @@ func (p *Pipeline) Close(r ports.Renderer) { | |||
| 146 | 148 | p.cancel() | |
| 147 | 149 | p.worker.Wait() | |
| 148 | 150 | p.reclaim(r) | |
| 151 | + | if p.off != nil { | |
| 152 | + | p.off.close() | |
| 153 | + | } | |
| 149 | 154 | } | |
A internal/adapters/capture/shows_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package capture | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // A content change of a window only the hidden workspace draws needs a frame | |
| 11 | + | // (its child render and the reads it releases); the displayed scene alone | |
| 12 | + | // would say no. | |
| 13 | + | func TestShowsCountsHiddenWorkspace(t *testing.T) { | |
| 14 | + | s := ports.Scene{ | |
| 15 | + | OutputWidth: 100, OutputHeight: 50, | |
| 16 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 100, H: 50}}}, | |
| 17 | + | CaptureScene: &ports.Scene{ | |
| 18 | + | OutputWidth: 100, OutputHeight: 50, | |
| 19 | + | Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 100, H: 50}}}, | |
| 20 | + | }, | |
| 21 | + | } | |
| 22 | + | require.True(t, Shows(s, 1), "displayed window") | |
| 23 | + | require.True(t, Shows(s, 5), "hidden workspace window") | |
| 24 | + | require.False(t, s.Shows(5), "the displayed scene alone does not show it") | |
| 25 | + | require.False(t, Shows(s, 9), "unrelated window") | |
| 26 | + | s.CaptureScene = nil | |
| 27 | + | require.False(t, Shows(s, 5), "no hidden workspace, no frame") | |
| 28 | + | require.True(t, Shows(s, 1)) | |
| 29 | + | } | |
A internal/adapters/drm/capture_session_test.go +394 -0
| @@ -0,0 +1,394 @@ | |||
| 1 | + | package drm | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "os" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 13 | + | "github.com/bnema/neferwl/internal/ports" | |
| 14 | + | "github.com/stretchr/testify/mock" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | func sessionRequest(t *testing.T, id uint64, clean bool) ports.CaptureRequest { | |
| 19 | + | t.Helper() | |
| 20 | + | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 21 | + | require.NoError(t, err) | |
| 22 | + | t.Cleanup(func() { _ = f.Close() }) | |
| 23 | + | require.NoError(t, f.Truncate(16)) | |
| 24 | + | q := ports.CaptureRequest{ID: id, Dst: ports.SHMBuffer{File: f}, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1} | |
| 25 | + | if clean { | |
| 26 | + | q.Clean, q.Session = true, 7 | |
| 27 | + | } | |
| 28 | + | return q | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func drmSessionScene() ports.Scene { | |
| 32 | + | return ports.Scene{Seq: 4, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}}, | |
| 33 | + | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}} | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // The clean frame is drawn and copied before the displayed frame, on the | |
| 37 | + | // same target; the standard capture is taken after the displayed frame. | |
| 38 | + | func TestSubmitFrameCleanBeforeDisplayedThenStandard(t *testing.T) { | |
| 39 | + | o, _, _ := testOutput(t) | |
| 40 | + | o.cursor = nil | |
| 41 | + | var order []string | |
| 42 | + | r := portsmocks.NewMockRenderer(t) | |
| 43 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 44 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 45 | + | if s.Capture == nil { | |
| 46 | + | require.Zero(t, s.Seq) | |
| 47 | + | require.Len(t, s.Layers, 0, "excluded HUD layer left out of the clean frame") | |
| 48 | + | order = append(order, "clean") | |
| 49 | + | } else { | |
| 50 | + | require.Len(t, s.Layers, 1, "displayed frame keeps the HUD") | |
| 51 | + | order = append(order, "shown") | |
| 52 | + | } | |
| 53 | + | return nil, nil | |
| 54 | + | }).Twice() | |
| 55 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 56 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 57 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 58 | + | r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) { | |
| 59 | + | order = append(order, "copy") | |
| 60 | + | return frame, nil | |
| 61 | + | }).Twice() | |
| 62 | + | r.EXPECT().EndCapture(frame).Return().Twice() | |
| 63 | + | replies := make(chan ports.CaptureDone, 4) | |
| 64 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 65 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)} | |
| 66 | + | direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline) | |
| 67 | + | require.NoError(t, err) | |
| 68 | + | require.False(t, direct) | |
| 69 | + | require.Equal(t, []string{"clean", "copy", "shown", "copy"}, order) | |
| 70 | + | for _, q := range requests { | |
| 71 | + | require.True(t, capture.Handed(q), "requests handed to the worker") | |
| 72 | + | } | |
| 73 | + | pipeline.Close(r) | |
| 74 | + | require.Len(t, replies, 2) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // A fullscreen window would be scanned out (no border): a session forces | |
| 78 | + | // composition even when no capture is requested this frame. | |
| 79 | + | func TestSessionScenePreventsDirectScanoutDecision(t *testing.T) { | |
| 80 | + | o, _, _ := testOutput(t) | |
| 81 | + | o.cursor = nil | |
| 82 | + | r := portsmocks.NewMockRenderer(t) | |
| 83 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 84 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 85 | + | pipeline := capture.NewPipeline(context.Background(), nil) | |
| 86 | + | defer pipeline.Close(r) | |
| 87 | + | direct, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, nil, pipeline) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | require.False(t, direct) | |
| 90 | + | require.Equal(t, "capture", o.reason) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // A full render failing after the clean group was handed off: the caller | |
| 94 | + | // still owns only the standard request; the clean one is zeroed, so it is | |
| 95 | + | // never answered twice. | |
| 96 | + | func TestSubmitFrameRenderErrorKeepsOnlyUnhandedRequests(t *testing.T) { | |
| 97 | + | o, _, _ := testOutput(t) | |
| 98 | + | o.cursor = nil | |
| 99 | + | boom := errors.New("boom") | |
| 100 | + | r := portsmocks.NewMockRenderer(t) | |
| 101 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 102 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 103 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, boom).Once() | |
| 104 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 105 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 106 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 107 | + | r.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 108 | + | r.EXPECT().EndCapture(frame).Return().Once() | |
| 109 | + | replies := make(chan ports.CaptureDone, 4) | |
| 110 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 111 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true), sessionRequest(t, 2, false)} | |
| 112 | + | _, err := o.submitFrame(context.Background(), r, drmSessionScene(), nil, nil, requests, pipeline) | |
| 113 | + | var fatal renderError | |
| 114 | + | require.ErrorAs(t, err, &fatal) | |
| 115 | + | owned := 0 | |
| 116 | + | for _, q := range requests { | |
| 117 | + | if !capture.Handed(q) { | |
| 118 | + | owned++ | |
| 119 | + | require.Equal(t, uint64(2), q.ID) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | require.Equal(t, 1, owned) | |
| 123 | + | pipeline.Close(r) | |
| 124 | + | require.Len(t, replies, 1) // the clean capture, answered once by the worker | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // A clean request of a session the scene no longer carries fails closed | |
| 128 | + | // and is never captured from the displayed frame. | |
| 129 | + | func TestSubmitFrameCleanRequestWithoutSessionFails(t *testing.T) { | |
| 130 | + | o, _, _ := testOutput(t) | |
| 131 | + | o.cursor = nil | |
| 132 | + | r := portsmocks.NewMockRenderer(t) | |
| 133 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 134 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 135 | + | replies := make(chan ports.CaptureDone, 2) | |
| 136 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 137 | + | defer pipeline.Close(r) | |
| 138 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true)} | |
| 139 | + | scene := drmSessionScene() | |
| 140 | + | scene.Capture = nil | |
| 141 | + | _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline) | |
| 142 | + | require.NoError(t, err) | |
| 143 | + | got := <-replies | |
| 144 | + | require.ErrorIs(t, got.Err, capture.ErrSessionInactive) | |
| 145 | + | require.True(t, capture.Handed(requests[0])) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // A session with no visible border (and no HUD in the scene) must not cost | |
| 149 | + | // the fullscreen window its direct scanout; a visible border must. | |
| 150 | + | func TestSessionMetadataWithoutBorderKeepsDirectScanout(t *testing.T) { | |
| 151 | + | for _, tc := range []struct { | |
| 152 | + | name string | |
| 153 | + | capture *ports.SceneCapture | |
| 154 | + | wantDirect bool | |
| 155 | + | }{ | |
| 156 | + | {"no session", nil, true}, | |
| 157 | + | {"session, no border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Excluded: []ports.WindowID{10}}, true}, | |
| 158 | + | {"session, zero width", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderColor: ports.CaptureBorderColor}, true}, | |
| 159 | + | {"session, border wider than the target allows", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 1, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, true}, | |
| 160 | + | {"session, visible border", &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}, false}, | |
| 161 | + | } { | |
| 162 | + | t.Run(tc.name, func(t *testing.T) { | |
| 163 | + | o, k, _ := testOutput(t) | |
| 164 | + | o.cursor, o.tearing, o.vrrProp = nil, false, 0 | |
| 165 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 166 | + | r := portsmocks.NewMockRenderer(t) | |
| 167 | + | s, c := fullscreenScene() | |
| 168 | + | s.Capture = tc.capture | |
| 169 | + | if tc.wantDirect { | |
| 170 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | |
| 171 | + | } else { | |
| 172 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 173 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 174 | + | } | |
| 175 | + | pipeline := capture.NewPipeline(context.Background(), nil) | |
| 176 | + | defer pipeline.Close(r) | |
| 177 | + | direct, err := o.submitFrame(context.Background(), r, s, c, map[ports.WindowID]uint64{}, nil, pipeline) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | require.Equal(t, tc.wantDirect, direct) | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // A clean request for a hidden workspace with no child renderer factory | |
| 185 | + | // fails closed; it is neither served from the displayed frame nor does it | |
| 186 | + | // stop the output. | |
| 187 | + | func TestSubmitFrameHiddenWorkspaceWithoutFactoryFailsClosed(t *testing.T) { | |
| 188 | + | o, _, _ := testOutput(t) | |
| 189 | + | o.cursor = nil | |
| 190 | + | r := portsmocks.NewMockRenderer(t) | |
| 191 | + | r.EXPECT().UseTarget(0).Return().Once() | |
| 192 | + | r.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() // the display only | |
| 193 | + | replies := make(chan ports.CaptureDone, 2) | |
| 194 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 195 | + | defer pipeline.Close(r) | |
| 196 | + | scene := drmSessionScene() | |
| 197 | + | scene.CaptureScene = &ports.Scene{OutputWidth: 200, OutputHeight: 100, Scale: 1} | |
| 198 | + | requests := []ports.CaptureRequest{sessionRequest(t, 1, true)} | |
| 199 | + | _, err := o.submitFrame(context.Background(), r, scene, nil, nil, requests, pipeline) | |
| 200 | + | require.NoError(t, err) | |
| 201 | + | require.ErrorIs(t, (<-replies).Err, capture.ErrOffscreenUnavailable) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // A report caps only the windows an unfinished child render drew; the display's | |
| 205 | + | // own windows are reported at their full content Seq, and the report goes on | |
| 206 | + | // (nothing waits for the child). | |
| 207 | + | func TestReportCapsPerWindowWhileChildReads(t *testing.T) { | |
| 208 | + | o, _, _ := testOutput(t) | |
| 209 | + | readEnd, writeEnd, err := os.Pipe() // the child's sync file: readable once it signals | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | t.Cleanup(func() { _ = writeEnd.Close() }) | |
| 212 | + | child := portsmocks.NewMockRenderer(t) | |
| 213 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 214 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 215 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 216 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once() | |
| 217 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 218 | + | child.EXPECT().EndCapture(frame).Return().Maybe() | |
| 219 | + | child.EXPECT().Close().Return().Once() | |
| 220 | + | replies := make(chan ports.CaptureDone, 2) | |
| 221 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 222 | + | pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 223 | + | o.capHidden = pipeline.CapHiddenSeen | |
| 224 | + | scene := ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2, | |
| 225 | + | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}}, | |
| 226 | + | CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 2, H: 2}}}}} | |
| 227 | + | surfaces := map[ports.WindowID]ports.SurfaceContent{5: {ID: 5, Seq: 2}} | |
| 228 | + | req := sessionRequest(t, 1, true) | |
| 229 | + | req.Region, req.Width, req.Height = image.Rect(0, 0, 2, 2), 2, 2 | |
| 230 | + | pipeline.SubmitHidden(scene, surfaces, []ports.CaptureRequest{req}) | |
| 231 | + | ||
| 232 | + | seen := map[ports.WindowID]uint64{1: 9, 5: 7} // window 1 belongs to the display | |
| 233 | + | o.report(nil, seen) | |
| 234 | + | require.True(t, o.capped, "an outstanding child read is reported repeatedly") | |
| 235 | + | require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 2}, o.unsent[0].Seen) | |
| 236 | + | require.Equal(t, uint64(7), seen[5], "the caller's map is untouched") | |
| 237 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2}, o.unsent[0].ChildReads) | |
| 238 | + | previous := o.unsent[0].ChildReads | |
| 239 | + | allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) }) | |
| 240 | + | require.LessOrEqual(t, allocs, float64(1), "only the fence poll may allocate") | |
| 241 | + | t.Logf("unchanged child report: %.1f allocs", allocs) | |
| 242 | + | ||
| 243 | + | _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | o.report(nil, seen) | |
| 246 | + | require.False(t, o.capped) | |
| 247 | + | require.Equal(t, map[ports.WindowID]uint64{1: 9, 5: 7}, o.unsent[0].Seen, "limit lifted") | |
| 248 | + | require.Empty(t, o.unsent[0].ChildReads) | |
| 249 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2}, previous, "sent snapshot remains immutable") | |
| 250 | + | pipeline.Close(nil) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | // GO004: a hidden-workspace child is drawn on its own device: the display | |
| 254 | + | // keeps direct scanout of a fullscreen window when no capture reads the | |
| 255 | + | // displayed frame and the session draws no border on screen. | |
| 256 | + | func TestHiddenWorkspaceChildKeepsDirectScanout(t *testing.T) { | |
| 257 | + | o, k, _ := testOutput(t) | |
| 258 | + | o.cursor, o.tearing, o.vrrProp = nil, false, 0 | |
| 259 | + | o.primary.formats = []ports.DMABufFormat{{Format: fourccXRGB}} | |
| 260 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(88, nil).Once() | |
| 261 | + | readEnd, writeEnd, err := os.Pipe() | |
| 262 | + | require.NoError(t, err) | |
| 263 | + | t.Cleanup(func() { _ = writeEnd.Close() }) | |
| 264 | + | child := portsmocks.NewMockRenderer(t) | |
| 265 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 266 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 267 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 268 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once() | |
| 269 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 270 | + | child.EXPECT().EndCapture(frame).Return().Maybe() | |
| 271 | + | child.EXPECT().Close().Return().Once() | |
| 272 | + | display := portsmocks.NewMockRenderer(t) // no Render: the display is scanned out | |
| 273 | + | replies := make(chan ports.CaptureDone, 2) | |
| 274 | + | pipeline := capture.NewPipeline(context.Background(), replies) | |
| 275 | + | pipeline.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 276 | + | o.capHidden = pipeline.CapHiddenSeen | |
| 277 | + | s, c := fullscreenScene() | |
| 278 | + | s.Capture = &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}, Revision: 1, BorderWidth: 2, BorderColor: ports.CaptureBorderColor} | |
| 279 | + | s.CaptureScene = &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}} | |
| 280 | + | // Hidden workspace: core sends the on-screen border off (BorderWidth 0). | |
| 281 | + | s.Capture.BorderWidth, s.Capture.BorderColor = 0, "" | |
| 282 | + | req := sessionRequest(t, 1, true) | |
| 283 | + | req.CaptureRevision = 1 | |
| 284 | + | req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800 | |
| 285 | + | require.NoError(t, req.Dst.File.Truncate(800*100)) | |
| 286 | + | direct, err := o.submitFrame(context.Background(), display, s, c, map[ports.WindowID]uint64{}, []ports.CaptureRequest{req}, pipeline) | |
| 287 | + | require.NoError(t, err) | |
| 288 | + | require.True(t, direct, "the displayed fullscreen window stays scanned out") | |
| 289 | + | require.True(t, o.frame.pendingCommit(), "display flip has not completed") | |
| 290 | + | require.Equal(t, map[ports.WindowID]uint64{5: 0}, o.unsent[0].ChildReads, "child hold published before the display flip") | |
| 291 | + | require.Empty(t, o.unsent[0].Seen, "display reads are not advanced early") | |
| 292 | + | require.NoError(t, (<-replies).Err) | |
| 293 | + | _, err = writeEnd.Write([]byte{1}) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | pipeline.Close(display) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | // GO-002: child holds do not expire, so a finished child read must lift its | |
| 299 | + | // hold even while the display's own commit never completes. The display's | |
| 300 | + | // Seen stays at the last safe value and the output is not stopped. | |
| 301 | + | func TestRunChildHoldLiftedWhileDisplayCommitStalls(t *testing.T) { | |
| 302 | + | o, k, commits, commitMu := testOutputMu(t, nil, nil) | |
| 303 | + | o.cursor, o.tearing, o.fbs = nil, false, [2]uint32{} | |
| 304 | + | o.frame.stuckAfter = 30 * time.Second // the display commit stays in flight | |
| 305 | + | o.flipped = make(chan flipEvent) // no flip event ever | |
| 306 | + | display := portsmocks.NewMockRenderer(t) | |
| 307 | + | buf := func() ports.DMABuf { | |
| 308 | + | f, w, _ := os.Pipe() | |
| 309 | + | w.Close() | |
| 310 | + | return ports.DMABuf{Planes: []ports.DMABufPlane{{File: f}}} | |
| 311 | + | } | |
| 312 | + | display.EXPECT().SetHDR(float64(0)).Return().Maybe() | |
| 313 | + | display.EXPECT().ExportTargets(2, mock.Anything).Return([]ports.DMABuf{buf(), buf()}, nil).Once() | |
| 314 | + | display.EXPECT().UseTarget(mock.Anything).Return() | |
| 315 | + | display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil) | |
| 316 | + | display.EXPECT().Close().Return().Once() | |
| 317 | + | k.EXPECT().addFB(mock.Anything, uint32(fourccXRGB)).Return(70, nil).Twice() | |
| 318 | + | k.EXPECT().createBlob(mock.Anything).Return(99, nil) | |
| 319 | + | k.EXPECT().destroyBlob(mock.Anything).Return(nil) | |
| 320 | + | k.EXPECT().rmFB(mock.Anything).Return(nil).Maybe() | |
| 321 | + | ||
| 322 | + | readEnd, writeEnd, err := os.Pipe() | |
| 323 | + | require.NoError(t, err) | |
| 324 | + | t.Cleanup(func() { _ = writeEnd.Close() }) | |
| 325 | + | child := portsmocks.NewMockRenderer(t) | |
| 326 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 327 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 328 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() | |
| 329 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(readEnd, nil).Once() | |
| 330 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 331 | + | child.EXPECT().EndCapture(frame).Return().Maybe() | |
| 332 | + | child.EXPECT().Close().Return().Once() | |
| 333 | + | o.NewCaptureRenderer = func(int, int) (ports.Renderer, error) { return child, nil } | |
| 334 | + | ||
| 335 | + | scenes := make(chan ports.Scene) | |
| 336 | + | contents := make(chan ports.SurfaceContent) // unbuffered: taken before the scene | |
| 337 | + | captures := make(chan ports.CaptureRequest) // unbuffered: a send returns once Run took it | |
| 338 | + | captured := make(chan ports.CaptureDone, 4) | |
| 339 | + | presented := make(chan ports.OutputPresented, 64) | |
| 340 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 341 | + | done := make(chan error, 1) | |
| 342 | + | go func() { | |
| 343 | + | done <- o.Run(ctx, func(int, int) (ports.Renderer, error) { return display, nil }, nil, make(chan bool), scenes, contents, nil, presented, captures, captured) | |
| 344 | + | }() | |
| 345 | + | count := func() int { | |
| 346 | + | commitMu.Lock() | |
| 347 | + | defer commitMu.Unlock() | |
| 348 | + | return len(*commits) | |
| 349 | + | } | |
| 350 | + | waitFor(t, func() bool { return count() == 2 }) | |
| 351 | + | ||
| 352 | + | contents <- ports.SurfaceContent{ID: 5, Seq: 2, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}} | |
| 353 | + | // The request waits for the scene; once a display frame is pending no | |
| 354 | + | // request is served, so it must be queued before the scene arrives. | |
| 355 | + | req := sessionRequest(t, 1, true) | |
| 356 | + | req.Region, req.Width, req.Height, req.Stride = image.Rect(0, 0, 200, 100), 200, 100, 800 | |
| 357 | + | require.NoError(t, req.Dst.File.Truncate(800*100)) | |
| 358 | + | captures <- req | |
| 359 | + | scenes <- ports.Scene{Seq: 1, Scale: 1, OutputWidth: 200, OutputHeight: 100, | |
| 360 | + | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 200, H: 100}}, | |
| 361 | + | CaptureScene: &ports.Scene{Scale: 1, OutputWidth: 200, OutputHeight: 100, Windows: []ports.SceneWindow{{ID: 5, Rect: ports.Rect{W: 200, H: 100}}}}} | |
| 362 | + | ||
| 363 | + | next := func(match func(ports.OutputPresented) bool) ports.OutputPresented { | |
| 364 | + | t.Helper() | |
| 365 | + | timeout := time.After(2 * time.Second) | |
| 366 | + | for { | |
| 367 | + | select { | |
| 368 | + | case r := <-presented: | |
| 369 | + | if match(r) { | |
| 370 | + | return r | |
| 371 | + | } | |
| 372 | + | case <-timeout: | |
| 373 | + | t.Fatal("no matching report") | |
| 374 | + | } | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | held := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) > 0 }) | |
| 378 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2}, held.ChildReads) | |
| 379 | + | require.Equal(t, 3, count(), "display frame committed, never completes") | |
| 380 | + | ||
| 381 | + | // A newer buffer of a display window arrives while the display commit is | |
| 382 | + | // pending: the GPU may still read the older one, so it is not reported. | |
| 383 | + | contents <- ports.SurfaceContent{ID: 1, Seq: 9, Width: 1, Height: 1, SHM: &ports.SHMBuffer{}} | |
| 384 | + | _, err = writeEnd.Write([]byte{1}) // the child's GPU work finished | |
| 385 | + | require.NoError(t, err) | |
| 386 | + | lifted := next(func(r ports.OutputPresented) bool { return len(r.ChildReads) == 0 }) | |
| 387 | + | require.Empty(t, lifted.ChildReads) | |
| 388 | + | require.NotContains(t, lifted.Seen, ports.WindowID(1), "display Seen does not advance while its commit is pending: only the child hold is lifted") | |
| 389 | + | require.Nil(t, lifted.Flip) | |
| 390 | + | require.Equal(t, 3, count(), "no extra commit") | |
| 391 | + | require.NoError(t, (<-captured).Err) | |
| 392 | + | cancel() | |
| 393 | + | require.NoError(t, <-done) | |
| 394 | + | } | |
M internal/adapters/drm/frame_submit.go +42 -5
| @@ -27,7 +27,29 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 27 | 27 | // windows it does not draw is discarded, not presented. | |
| 28 | 28 | f := pendingFrame{shows: o.shownBy(scene, seen)} | |
| 29 | 29 | o.wantContent(scene, surfaces) | |
| 30 | - | decision := o.decideFrame(scene, surfaces, len(requests) > 0) | |
| 30 | + | // Clean requests that cannot be served (their session is not the | |
| 31 | + | // scene's) are failed here, never answered from the displayed frame. | |
| 32 | + | normal, clean := pipeline.Split(scene, requests) | |
| 33 | + | pipeline.Retire(scene) | |
| 34 | + | hiddenClean := len(clean) > 0 && scene.CaptureScene != nil | |
| 35 | + | if hiddenClean { | |
| 36 | + | // A hidden workspace: the child renderer draws it, whatever the display | |
| 37 | + | // does (scanout included). Failures answer the requests and never stop | |
| 38 | + | // the output. | |
| 39 | + | pipeline.SubmitHidden(scene, surfaces, clean) | |
| 40 | + | // Publish child holds before queuing a display flip, which may stall | |
| 41 | + | // past the stale-report timeout. Do not advance the display's Seen | |
| 42 | + | // until its own GPU work finishes. | |
| 43 | + | if o.capHidden != nil { | |
| 44 | + | o.report(nil, o.seenSnapshot) | |
| 45 | + | } | |
| 46 | + | clear(clean) | |
| 47 | + | clean = nil | |
| 48 | + | } | |
| 49 | + | // Captures of the displayed frame, and a visible native session border, | |
| 50 | + | // force composition: scanout and the overlay plane would skip them. The | |
| 51 | + | // hidden-workspace child needs none, so it keeps direct scanout. | |
| 52 | + | decision := o.decideFrame(scene, surfaces, len(normal) > 0 || len(clean) > 0 || capture.BorderVisible(scene)) | |
| 31 | 53 | if decision.fb != 0 { | |
| 32 | 54 | c := decision.content | |
| 33 | 55 | if direct, err = o.commitScanoutRect(decision.fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}, decision.rect); direct { | |
| @@ -37,6 +59,21 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 37 | 59 | } | |
| 38 | 60 | ov, composed := decision.overlay, decision.composed | |
| 39 | 61 | r.UseTarget(o.back) | |
| 62 | + | if len(clean) > 0 { | |
| 63 | + | // The clean frame goes first to the same target and is copied by | |
| 64 | + | // the capture; the displayed frame is drawn over it in queue order. | |
| 65 | + | // Cost: a second composition on frames with a clean request. | |
| 66 | + | cdone, cerr := r.Render(pipeline.CleanScene(composed), surfaces) | |
| 67 | + | if cerr != nil { | |
| 68 | + | return false, renderError{cerr} | |
| 69 | + | } | |
| 70 | + | if cdone != nil { | |
| 71 | + | cdone.Close() | |
| 72 | + | } | |
| 73 | + | pipeline.Submit(r, clean) | |
| 74 | + | // Ownership moved to the worker (or an immediate failure reply). | |
| 75 | + | clear(clean) | |
| 76 | + | } | |
| 40 | 77 | done, rerr := r.Render(composed, surfaces) | |
| 41 | 78 | if rerr != nil { | |
| 42 | 79 | ov.close() | |
| @@ -53,13 +90,13 @@ func (o *Output) submitFrame(ctx context.Context, r ports.Renderer, scene ports. | |||
| 53 | 90 | return false, renderError{rerr} | |
| 54 | 91 | } | |
| 55 | 92 | } | |
| 56 | - | if len(requests) > 0 { | |
| 93 | + | if len(normal) > 0 { | |
| 57 | 94 | // Scanout and the overlay plane were skipped for this frame. | |
| 58 | - | o.log.Debug().Str("connector", o.conn.name).Int("captures", len(requests)).Msg("capture frame composed") | |
| 59 | - | pipeline.Submit(r, requests) | |
| 95 | + | o.log.Debug().Str("connector", o.conn.name).Int("captures", len(normal)).Msg("capture frame composed") | |
| 96 | + | pipeline.Submit(r, normal) | |
| 60 | 97 | // Ownership moved to the worker (or an immediate failure reply). | |
| 61 | 98 | // A later commit panic must not fail the same requests again. | |
| 62 | - | clear(requests) | |
| 99 | + | clear(normal) | |
| 63 | 100 | } | |
| 64 | 101 | // The overlay buffer is on screen like a scanned-out one: it is | |
| 65 | 102 | // reported shown, so it is not released under the plane. | |
M internal/adapters/drm/output.go +49 -6
| @@ -66,6 +66,16 @@ type Output struct { | |||
| 66 | 66 | back int | |
| 67 | 67 | frame frameLifecycle // pending commit, serials, deadlines and timer | |
| 68 | 68 | flips int | |
| 69 | + | // NewCaptureRenderer makes the child renderer of a hidden workspace | |
| 70 | + | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | |
| 71 | + | // Set before Run. | |
| 72 | + | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | |
| 73 | + | // capHidden limits a report to what the child renderer of a hidden | |
| 74 | + | // workspace was given, window by window, while it may still read (its | |
| 75 | + | // device is not ordered with the display's fences). capped is set when | |
| 76 | + | // the last report had child reads: it is repeated until they finish. | |
| 77 | + | capHidden func(map[ports.WindowID]uint64) (map[ports.WindowID]uint64, map[ports.WindowID]uint64, bool) | |
| 78 | + | capped bool | |
| 69 | 79 | // readFences are fences of frames rendered but not committed: the | |
| 70 | 80 | // GPU may still read client buffers until they signal, so what was | |
| 71 | 81 | // seen is reported only then (or after a later frame flipped). | |
| @@ -81,6 +91,7 @@ type Output struct { | |||
| 81 | 91 | // Last immutable snapshot sent in a report. Never mutate it: wayland may | |
| 82 | 92 | // still be reading a prior report on another goroutine. | |
| 83 | 93 | seenSnapshot map[ports.WindowID]uint64 | |
| 94 | + | childReadsSnapshot map[ports.WindowID]uint64 | |
| 84 | 95 | showsSnapshot map[ports.WindowID]uint64 | |
| 85 | 96 | directShowsSnapshot map[ports.WindowID]uint64 | |
| 86 | 97 | showsScratch map[ports.WindowID]uint64 | |
| @@ -981,6 +992,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 981 | 992 | requests := requestStorage[:0] | |
| 982 | 993 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 983 | 994 | pipeline := capture.NewPipeline(ctx, captured) | |
| 995 | + | if o.NewCaptureRenderer != nil { | |
| 996 | + | pipeline.EnableOffscreen(o.NewCaptureRenderer) | |
| 997 | + | o.capHidden = pipeline.CapHiddenSeen | |
| 998 | + | } | |
| 984 | 999 | defer func() { | |
| 985 | 1000 | cancelCaptures() | |
| 986 | 1001 | pipeline.Close(r) | |
| @@ -1031,10 +1046,22 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1031 | 1046 | } | |
| 1032 | 1047 | for { | |
| 1033 | 1048 | readWait := false | |
| 1034 | - | if reportDirty && !o.frame.pendingCommit() { | |
| 1035 | - | if readWait = !o.readDone(); !readWait { | |
| 1036 | - | o.report(nil, seen) | |
| 1037 | - | reportDirty = false | |
| 1049 | + | if reportDirty || o.capped { | |
| 1050 | + | published := false | |
| 1051 | + | if !o.frame.pendingCommit() { | |
| 1052 | + | if readWait = !o.readDone(); !readWait { | |
| 1053 | + | o.report(nil, seen) | |
| 1054 | + | reportDirty, published = false, true | |
| 1055 | + | // A report limited by the child's reads is repeated soon. | |
| 1056 | + | readWait = o.capped | |
| 1057 | + | } | |
| 1058 | + | } | |
| 1059 | + | if !published && o.capped { | |
| 1060 | + | // A display frame or fence that has not finished must not | |
| 1061 | + | // keep a finished child read held: child holds do not expire. | |
| 1062 | + | // Report them alone, at the last Seen that was safe. | |
| 1063 | + | o.report(nil, o.seenSnapshot) | |
| 1064 | + | readWait = readWait || o.capped | |
| 1038 | 1065 | } | |
| 1039 | 1066 | } | |
| 1040 | 1067 | o.flushReport(presented) | |
| @@ -1103,6 +1130,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1103 | 1130 | case b := <-pipeline.Completed(): | |
| 1104 | 1131 | pipeline.Recycle(b, r) | |
| 1105 | 1132 | continue | |
| 1133 | + | case b := <-pipeline.HiddenCompleted(): | |
| 1134 | + | pipeline.RecycleHidden(b) | |
| 1135 | + | pipeline.Retire(scene) | |
| 1136 | + | continue | |
| 1106 | 1137 | case q := <-captures: | |
| 1107 | 1138 | if !enabled || o.off || o.wantOff { | |
| 1108 | 1139 | capture.Fail(ctx, q, fmt.Errorf("output off"), captured) | |
| @@ -1173,9 +1204,10 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1173 | 1204 | reportDirty = true | |
| 1174 | 1205 | } | |
| 1175 | 1206 | // Windows on other outputs or workspaces do not need a frame. | |
| 1176 | - | dirty = dirty || scene.Shows(c.ID) | |
| 1207 | + | dirty = dirty || capture.Shows(scene, c.ID) | |
| 1177 | 1208 | case <-stats.C(): | |
| 1178 | 1209 | now := clk.Now() | |
| 1210 | + | pipeline.Retire(scene) // an idle child of an ended session goes | |
| 1179 | 1211 | o.dropClientFBs(now, false) | |
| 1180 | 1212 | // An idle output renders nothing: free what windows that left | |
| 1181 | 1213 | // it held. | |
| @@ -1240,6 +1272,9 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1240 | 1272 | if errors.As(err, &fatal) { | |
| 1241 | 1273 | // A failed render did not hand these requests to the worker. | |
| 1242 | 1274 | for _, q := range requests { | |
| 1275 | + | if capture.Handed(q) { | |
| 1276 | + | continue // already owned by the worker, or answered | |
| 1277 | + | } | |
| 1243 | 1278 | capture.Fail(ctx, q, err, captured) | |
| 1244 | 1279 | } | |
| 1245 | 1280 | } | |
| @@ -1361,10 +1396,18 @@ const maxUnsent = 16 | |||
| 1361 | 1396 | // buffers can be released. A flip-less report merges into the newest | |
| 1362 | 1397 | // queued one; flips are never merged unless maxUnsent is reached. | |
| 1363 | 1398 | func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |
| 1399 | + | o.capped = false | |
| 1400 | + | var reads map[ports.WindowID]uint64 | |
| 1401 | + | if o.capHidden != nil { | |
| 1402 | + | seen, reads, o.capped = o.capHidden(seen) | |
| 1403 | + | } | |
| 1364 | 1404 | if !maps.Equal(o.seenSnapshot, seen) { | |
| 1365 | 1405 | o.seenSnapshot = maps.Clone(seen) | |
| 1366 | 1406 | } | |
| 1367 | - | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot} | |
| 1407 | + | if !maps.Equal(o.childReadsSnapshot, reads) { | |
| 1408 | + | o.childReadsSnapshot = maps.Clone(reads) | |
| 1409 | + | } | |
| 1410 | + | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot, ChildReads: o.childReadsSnapshot} | |
| 1368 | 1411 | if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) { | |
| 1369 | 1412 | if flip == nil { | |
| 1370 | 1413 | r.Flip = o.unsent[n-1].Flip | |
A internal/adapters/headless/capture_session_test.go +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "os" | |
| 8 | + | "reflect" | |
| 9 | + | "sync" | |
| 10 | + | "testing" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 14 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | type events struct { | |
| 21 | + | mu sync.Mutex | |
| 22 | + | log []string | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (e *events) add(s string) { | |
| 26 | + | e.mu.Lock() | |
| 27 | + | defer e.mu.Unlock() | |
| 28 | + | e.log = append(e.log, s) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (e *events) get() []string { | |
| 32 | + | e.mu.Lock() | |
| 33 | + | defer e.mu.Unlock() | |
| 34 | + | return append([]string(nil), e.log...) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func sessionCaptureFile(t *testing.T) ports.SHMBuffer { | |
| 38 | + | t.Helper() | |
| 39 | + | f, err := os.CreateTemp(t.TempDir(), "capture") | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | t.Cleanup(func() { _ = f.Close() }) | |
| 42 | + | require.NoError(t, f.Truncate(16)) | |
| 43 | + | return ports.SHMBuffer{File: f} | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // runSession drives one frame with the given requests and returns the | |
| 47 | + | // renderer event order and the replies. | |
| 48 | + | func runSession(t *testing.T, scene ports.Scene, reqs []ports.CaptureRequest, wantReplies int) ([]string, []ports.CaptureDone) { | |
| 49 | + | t.Helper() | |
| 50 | + | ev := &events{} | |
| 51 | + | r := portsmocks.NewMockRenderer(t) | |
| 52 | + | r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 53 | + | // Clean scenes are renumbered 0; the tests' displayed scenes are not. | |
| 54 | + | if s.Seq == 0 { | |
| 55 | + | ev.add("render:clean") | |
| 56 | + | } else { | |
| 57 | + | ev.add("render:full") | |
| 58 | + | } | |
| 59 | + | return nil, nil | |
| 60 | + | }).Maybe() | |
| 61 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 62 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 63 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 64 | + | r.EXPECT().BeginCapture().RunAndReturn(func() (ports.CaptureFrame, error) { | |
| 65 | + | ev.add("begin") | |
| 66 | + | return frame, nil | |
| 67 | + | }).Maybe() | |
| 68 | + | r.EXPECT().EndCapture(frame).Return().Maybe() | |
| 69 | + | r.EXPECT().Close().Return().Once() | |
| 70 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 71 | + | defer cancel() | |
| 72 | + | scenes := make(chan ports.Scene, 1) | |
| 73 | + | incoming := make(chan ports.CaptureRequest) | |
| 74 | + | replies := make(chan ports.CaptureDone, 8) | |
| 75 | + | done := make(chan error, 1) | |
| 76 | + | go func() { | |
| 77 | + | done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }, Captured: replies}, scenes, nil, nil, incoming) | |
| 78 | + | }() | |
| 79 | + | // Requests wait for a scene: all of them belong to its first frame. | |
| 80 | + | for _, q := range reqs { | |
| 81 | + | q.Region, q.Width, q.Height, q.Stride, q.Format = image.Rect(0, 0, 2, 2), 2, 2, 8, 1 | |
| 82 | + | q.Dst = sessionCaptureFile(t) | |
| 83 | + | incoming <- q | |
| 84 | + | } | |
| 85 | + | scenes <- scene | |
| 86 | + | var got []ports.CaptureDone | |
| 87 | + | for range wantReplies { | |
| 88 | + | select { | |
| 89 | + | case d := <-replies: | |
| 90 | + | got = append(got, d) | |
| 91 | + | case <-time.After(3 * time.Second): | |
| 92 | + | t.Fatal("missing capture reply") | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | cancel() | |
| 96 | + | require.NoError(t, <-done) | |
| 97 | + | return ev.get(), got | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func sessionScene() ports.Scene { | |
| 101 | + | return ports.Scene{Seq: 2, Windows: []ports.SceneWindow{{ID: 1}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}}, | |
| 102 | + | Capture: &ports.SceneCapture{Session: 7, Excluded: []ports.WindowID{10}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor}} | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestCleanCaptureOrderedBeforeDisplayedFrame(t *testing.T) { | |
| 106 | + | order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2) | |
| 107 | + | // Clean composition and its copy come first; the displayed frame and | |
| 108 | + | // the standard capture (HUD and border included) follow. | |
| 109 | + | require.Equal(t, []string{"render:clean", "begin", "render:full", "begin"}, order) | |
| 110 | + | for _, d := range replies { | |
| 111 | + | require.NoError(t, d.Err) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestCleanCaptureFailsClosedWithoutMatchingSession(t *testing.T) { | |
| 116 | + | scene := sessionScene() | |
| 117 | + | scene.Capture = nil | |
| 118 | + | order, replies := runSession(t, scene, []ports.CaptureRequest{{ID: 1, Clean: true, Session: 7}, {ID: 2}}, 2) | |
| 119 | + | require.Equal(t, []string{"render:full", "begin"}, order) // one frame, the standard capture only | |
| 120 | + | byID := map[uint64]error{} | |
| 121 | + | for _, d := range replies { | |
| 122 | + | byID[d.ID] = d.Err | |
| 123 | + | } | |
| 124 | + | require.ErrorIs(t, byID[1], capture.ErrSessionInactive) | |
| 125 | + | require.NoError(t, byID[2]) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func TestStandardCaptureDuringSessionUsesOneComposition(t *testing.T) { | |
| 129 | + | order, replies := runSession(t, sessionScene(), []ports.CaptureRequest{{ID: 2}}, 1) | |
| 130 | + | require.Equal(t, []string{"render:full", "begin"}, order) | |
| 131 | + | require.NoError(t, replies[0].Err) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // A hidden workspace is drawn by the child renderer (made by the factory), | |
| 135 | + | // not by the display's; the display still renders the scene it shows. | |
| 136 | + | func TestHiddenWorkspaceCaptureUsesChildRenderer(t *testing.T) { | |
| 137 | + | read, write, err := os.Pipe() | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | defer write.Close() | |
| 140 | + | display := portsmocks.NewMockRenderer(t) | |
| 141 | + | display.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once() | |
| 142 | + | display.EXPECT().Close().Return().Once() | |
| 143 | + | child := portsmocks.NewMockRenderer(t) | |
| 144 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 145 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 146 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 147 | + | child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) { | |
| 148 | + | require.Nil(t, s.Capture) | |
| 149 | + | require.Len(t, s.Layers, 0, "HUD left out of the child frame") | |
| 150 | + | return read, nil | |
| 151 | + | }).Once() | |
| 152 | + | child.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 153 | + | child.EXPECT().EndCapture(frame).Return().Maybe() | |
| 154 | + | child.EXPECT().Close().Return().Once() | |
| 155 | + | scene := sessionScene() | |
| 156 | + | scene.OutputWidth, scene.OutputHeight, scene.Scale = 2, 2, 1 | |
| 157 | + | scene.Capture.TargetRect = ports.Rect{W: 2, H: 2} | |
| 158 | + | scene.CaptureScene = &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 5}}, Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop}}} | |
| 159 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 160 | + | defer cancel() | |
| 161 | + | scenes := make(chan ports.Scene, 1) | |
| 162 | + | incoming := make(chan ports.CaptureRequest) | |
| 163 | + | replies := make(chan ports.CaptureDone, 2) | |
| 164 | + | done := make(chan error, 1) | |
| 165 | + | var made [2]int | |
| 166 | + | presented := make(chan ports.OutputPresented, 4) | |
| 167 | + | go func() { | |
| 168 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, Presented: presented, | |
| 169 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }, | |
| 170 | + | NewCaptureRenderer: func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil }, | |
| 171 | + | }, scenes, nil, nil, incoming) | |
| 172 | + | }() | |
| 173 | + | q := ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)} | |
| 174 | + | incoming <- q | |
| 175 | + | scenes <- scene | |
| 176 | + | // The owner may report an idle frame before receiving the first scene. | |
| 177 | + | deadline := time.After(3 * time.Second) | |
| 178 | + | waitHold: | |
| 179 | + | for { | |
| 180 | + | select { | |
| 181 | + | case report := <-presented: | |
| 182 | + | if len(report.ChildReads) > 0 { | |
| 183 | + | require.Equal(t, map[ports.WindowID]uint64{5: 0}, report.ChildReads, "hold published before waiting") | |
| 184 | + | break waitHold | |
| 185 | + | } | |
| 186 | + | case <-deadline: | |
| 187 | + | t.Fatal("no child hold report") | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | select { | |
| 191 | + | case <-presented: | |
| 192 | + | t.Fatal("child hold lifted before its fence signalled") | |
| 193 | + | case <-time.After(120 * time.Millisecond): // beyond Wayland's 100ms timeout | |
| 194 | + | } | |
| 195 | + | _, err = write.Write([]byte{1}) | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | select { | |
| 198 | + | case report := <-presented: | |
| 199 | + | require.Empty(t, report.ChildReads, "completed render lifts child hold") | |
| 200 | + | case <-time.After(3 * time.Second): | |
| 201 | + | t.Fatal("no completed render report") | |
| 202 | + | } | |
| 203 | + | select { | |
| 204 | + | case d := <-replies: | |
| 205 | + | require.NoError(t, d.Err) | |
| 206 | + | case <-time.After(3 * time.Second): | |
| 207 | + | t.Fatal("no reply") | |
| 208 | + | } | |
| 209 | + | cancel() | |
| 210 | + | require.NoError(t, <-done) | |
| 211 | + | require.Equal(t, [2]int{2, 2}, made) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // GO007: a render error while a clean request was already handed to a worker | |
| 215 | + | // must not answer it a second time as request 0. | |
| 216 | + | func TestRenderErrorDoesNotAnswerHandedRequestsAgain(t *testing.T) { | |
| 217 | + | display := portsmocks.NewMockRenderer(t) | |
| 218 | + | boom := errors.New("boom") | |
| 219 | + | display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq == 0 }), mock.Anything).Return(nil, nil).Once() // clean frame | |
| 220 | + | display.EXPECT().Render(mock.MatchedBy(func(s ports.Scene) bool { return s.Seq != 0 }), mock.Anything).Return(nil, boom).Once() | |
| 221 | + | frame := portsmocks.NewMockCaptureFrame(t) | |
| 222 | + | frame.EXPECT().Done().Return(nil).Maybe() | |
| 223 | + | frame.EXPECT().Read(mock.Anything, mock.Anything, 8).Return(nil).Maybe() | |
| 224 | + | display.EXPECT().BeginCapture().Return(frame, nil).Once() | |
| 225 | + | display.EXPECT().EndCapture(frame).Return().Maybe() | |
| 226 | + | display.EXPECT().Close().Return().Once() | |
| 227 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 228 | + | defer cancel() | |
| 229 | + | scenes := make(chan ports.Scene, 1) | |
| 230 | + | incoming := make(chan ports.CaptureRequest) | |
| 231 | + | replies := make(chan ports.CaptureDone, 8) | |
| 232 | + | done := make(chan error, 1) | |
| 233 | + | go func() { | |
| 234 | + | done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, | |
| 235 | + | NewRenderer: func(int, int) (ports.Renderer, error) { return display, nil }}, scenes, nil, nil, incoming) | |
| 236 | + | }() | |
| 237 | + | incoming <- ports.CaptureRequest{ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Format: 1, Dst: sessionCaptureFile(t)} | |
| 238 | + | scenes <- sessionScene() | |
| 239 | + | select { | |
| 240 | + | case err := <-done: | |
| 241 | + | require.ErrorIs(t, err, boom) | |
| 242 | + | case <-time.After(3 * time.Second): | |
| 243 | + | t.Fatal("output did not stop on the render error") | |
| 244 | + | } | |
| 245 | + | var ids []uint64 | |
| 246 | + | for len(replies) > 0 { | |
| 247 | + | ids = append(ids, (<-replies).ID) | |
| 248 | + | } | |
| 249 | + | require.Equal(t, []uint64{1}, ids, "request 1 answered once by its worker, no pseudo request 0") | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // GO-003: an unchanged child-hold publication reuses its immutable snapshots; | |
| 253 | + | // a change clones, and earlier reports keep their maps. | |
| 254 | + | func TestHoldSnapshotsCloneOnlyOnChange(t *testing.T) { | |
| 255 | + | var h holdSnapshots | |
| 256 | + | seen := map[ports.WindowID]uint64{1: 4, 5: 7} | |
| 257 | + | reads := map[ports.WindowID]uint64{5: 2} | |
| 258 | + | first := h.report("HEADLESS-1", seen, reads) | |
| 259 | + | require.Equal(t, seen, first.Seen) | |
| 260 | + | require.Equal(t, reads, first.ChildReads) | |
| 261 | + | seen[1] = 99 // the caller's scratch changes: the report does not | |
| 262 | + | require.Equal(t, uint64(4), first.Seen[1]) | |
| 263 | + | ||
| 264 | + | seen[1] = 4 | |
| 265 | + | again := h.report("HEADLESS-1", seen, reads) | |
| 266 | + | require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(again.Seen).Pointer(), "unchanged seen is shared") | |
| 267 | + | require.Equal(t, reflect.ValueOf(first.ChildReads).Pointer(), reflect.ValueOf(again.ChildReads).Pointer(), "unchanged reads are shared") | |
| 268 | + | allocs := testing.AllocsPerRun(100, func() { _ = h.report("HEADLESS-1", seen, reads) }) | |
| 269 | + | require.Zero(t, allocs, "unchanged publication allocates nothing") | |
| 270 | + | t.Logf("unchanged hidden-capture publication: %.0f allocs", allocs) | |
| 271 | + | ||
| 272 | + | reads[5] = 3 | |
| 273 | + | changed := h.report("HEADLESS-1", seen, reads) | |
| 274 | + | require.Equal(t, map[ports.WindowID]uint64{5: 3}, changed.ChildReads) | |
| 275 | + | require.Equal(t, map[ports.WindowID]uint64{5: 2}, first.ChildReads, "sent snapshot stays immutable") | |
| 276 | + | require.Equal(t, reflect.ValueOf(first.Seen).Pointer(), reflect.ValueOf(changed.Seen).Pointer(), "seen still unchanged") | |
| 277 | + | } | |
M internal/adapters/headless/output.go +78 -5
| @@ -29,6 +29,9 @@ type Options struct { | |||
| 29 | 29 | Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting | |
| 30 | 30 | Log zerowrap.Logger | |
| 31 | 31 | NewRenderer func(w, h int) (ports.Renderer, error) | |
| 32 | + | // NewCaptureRenderer makes the child renderer of a hidden workspace | |
| 33 | + | // capture session (Scene.CaptureScene). Nil: such captures fail closed. | |
| 34 | + | NewCaptureRenderer func(w, h int) (ports.Renderer, error) | |
| 32 | 35 | // Name and Presented report what the output has read after each | |
| 33 | 36 | // frame, so wayland can release client buffers (nil: no reports). | |
| 34 | 37 | Name string | |
| @@ -80,10 +83,16 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 80 | 83 | requests := requestStorage[:0] | |
| 81 | 84 | ctx, cancelCaptures := context.WithCancel(ctx) | |
| 82 | 85 | pipeline := capture.NewPipeline(ctx, opts.Captured) | |
| 86 | + | if opts.NewCaptureRenderer != nil { | |
| 87 | + | pipeline.EnableOffscreen(opts.NewCaptureRenderer) | |
| 88 | + | } | |
| 83 | 89 | defer func() { | |
| 84 | 90 | cancelCaptures() | |
| 85 | 91 | pipeline.Close(r) | |
| 86 | 92 | for _, q := range requests { | |
| 93 | + | if capture.Handed(q) { | |
| 94 | + | continue // already answered, or owned by a worker | |
| 95 | + | } | |
| 87 | 96 | capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured) | |
| 88 | 97 | } | |
| 89 | 98 | for { | |
| @@ -98,11 +107,12 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 98 | 107 | var want ports.CursorChange | |
| 99 | 108 | cursorScale := -1.0 // not loaded yet | |
| 100 | 109 | seen := map[ports.WindowID]uint64{} | |
| 110 | + | var holds holdSnapshots | |
| 101 | 111 | // pending is a report the channel could not take, retried soon. | |
| 102 | 112 | var pending *ports.OutputPresented | |
| 103 | 113 | update := func(c ports.SurfaceContent) { | |
| 104 | 114 | seen[c.ID] = max(seen[c.ID], c.Seq) | |
| 105 | - | dirty = dirty || scene.Shows(c.ID) | |
| 115 | + | dirty = dirty || capture.Shows(scene, c.ID) | |
| 106 | 116 | if c.Empty() { | |
| 107 | 117 | delete(surfaces, c.ID) | |
| 108 | 118 | } else { | |
| @@ -129,6 +139,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 129 | 139 | case b := <-pipeline.Completed(): | |
| 130 | 140 | pipeline.Recycle(b, r) | |
| 131 | 141 | continue | |
| 142 | + | case b := <-pipeline.HiddenCompleted(): | |
| 143 | + | pipeline.RecycleHidden(b) | |
| 144 | + | pipeline.Retire(scene) | |
| 145 | + | continue | |
| 132 | 146 | case q := <-incoming: | |
| 133 | 147 | if scene.Off { | |
| 134 | 148 | capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured) | |
| @@ -143,7 +157,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 143 | 157 | continue | |
| 144 | 158 | case <-trim.C(): | |
| 145 | 159 | // An idle output renders nothing: free what windows that left | |
| 146 | - | // it held. | |
| 160 | + | // it held, and the child of an ended session. | |
| 161 | + | pipeline.Retire(scene) | |
| 147 | 162 | if err := r.Trim(clk.Now()); err != nil { | |
| 148 | 163 | return fmt.Errorf("trim renderer: %w", err) | |
| 149 | 164 | } | |
| @@ -207,6 +222,46 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 207 | 222 | } | |
| 208 | 223 | } | |
| 209 | 224 | start := time.Now() | |
| 225 | + | // Clean requests of a session the scene does not carry are failed | |
| 226 | + | // here. The others draw the clean frame first (same renderer, queue | |
| 227 | + | // order), then the displayed one; see capture.Pipeline.Split. | |
| 228 | + | normal, clean := pipeline.Split(scene, requests) | |
| 229 | + | pipeline.Retire(scene) | |
| 230 | + | if len(clean) > 0 && scene.CaptureScene != nil { | |
| 231 | + | // A hidden workspace: a child renderer draws it. The display's | |
| 232 | + | // fences do not cover the child, so wait for it before reporting. | |
| 233 | + | pipeline.SubmitHidden(scene, surfaces, clean) | |
| 234 | + | clear(clean) | |
| 235 | + | // A slow child may outlive Wayland's stale-report timeout even | |
| 236 | + | // though this owner waits. Publish its non-expiring holds first. | |
| 237 | + | if opts.Presented != nil { | |
| 238 | + | _, reads, _ := pipeline.CapHiddenSeen(seen) | |
| 239 | + | if len(reads) > 0 { | |
| 240 | + | r := holds.report(opts.Name, seen, reads) | |
| 241 | + | select { | |
| 242 | + | case opts.Presented <- r: | |
| 243 | + | case <-ctx.Done(): | |
| 244 | + | return nil | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | if err := pipeline.WaitHidden(ctx); err != nil { | |
| 249 | + | if ctx.Err() != nil { | |
| 250 | + | return nil | |
| 251 | + | } | |
| 252 | + | return fmt.Errorf("workspace frame fence: %w", err) | |
| 253 | + | } | |
| 254 | + | } else if len(clean) > 0 { | |
| 255 | + | cdone, err := r.Render(pipeline.CleanScene(scene), surfaces) | |
| 256 | + | if err != nil { | |
| 257 | + | return fmt.Errorf("render clean frame: %w", err) | |
| 258 | + | } | |
| 259 | + | if cdone != nil { | |
| 260 | + | cdone.Close() | |
| 261 | + | } | |
| 262 | + | pipeline.Submit(r, clean) | |
| 263 | + | clear(clean) | |
| 264 | + | } | |
| 210 | 265 | done, err := r.Render(scene, surfaces) | |
| 211 | 266 | if err != nil { | |
| 212 | 267 | return fmt.Errorf("render frame: %w", err) | |
| @@ -223,10 +278,10 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 223 | 278 | return fmt.Errorf("frame fence: %w", err) | |
| 224 | 279 | } | |
| 225 | 280 | } | |
| 226 | - | if len(requests) > 0 { | |
| 227 | - | opts.Log.Debug().Str("output", opts.Name).Int("captures", len(requests)).Msg("capture frame composed") | |
| 281 | + | if len(normal) > 0 { | |
| 282 | + | opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed") | |
| 228 | 283 | } | |
| 229 | - | pipeline.Submit(r, requests) | |
| 284 | + | pipeline.Submit(r, normal) | |
| 230 | 285 | clear(requests) | |
| 231 | 286 | requests = requestStorage[:0] | |
| 232 | 287 | frame++ | |
| @@ -251,6 +306,24 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents | |||
| 251 | 306 | } | |
| 252 | 307 | } | |
| 253 | 308 | ||
| 309 | + | // holdSnapshots caches the immutable maps of the child-hold reports. The | |
| 310 | + | // receiver may still read an earlier report on another goroutine, so a cached | |
| 311 | + | // map is never mutated: it is replaced by a clone when the content changes, | |
| 312 | + | // and an unchanged publication allocates nothing. | |
| 313 | + | type holdSnapshots struct { | |
| 314 | + | seen, reads map[ports.WindowID]uint64 | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | func (h *holdSnapshots) report(output string, seen, reads map[ports.WindowID]uint64) ports.OutputPresented { | |
| 318 | + | if !maps.Equal(h.seen, seen) { | |
| 319 | + | h.seen = maps.Clone(seen) | |
| 320 | + | } | |
| 321 | + | if !maps.Equal(h.reads, reads) { | |
| 322 | + | h.reads = maps.Clone(reads) | |
| 323 | + | } | |
| 324 | + | return ports.OutputPresented{Output: output, Seen: h.seen, ChildReads: h.reads} | |
| 325 | + | } | |
| 326 | + | ||
| 254 | 327 | func writePNG(path string, img *image.RGBA) error { | |
| 255 | 328 | f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png") | |
| 256 | 329 | if err != nil { | |
A internal/adapters/vulkan/capture_session_bench_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "image/color" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // BenchmarkCaptureSessionFrame compares what a frame costs (CPU recording plus | |
| 12 | + | // GPU completion, waited on the CPU) with no capture, one standard capture, and | |
| 13 | + | // a clean capture plus the displayed frame (the second composition of the | |
| 14 | + | // exclusion path). Scenes have Seq 0: every frame is a full redraw, as when | |
| 15 | + | // the scene changes each frame. | |
| 16 | + | // | |
| 17 | + | // go test ./internal/adapters/vulkan -run '^$' -bench CaptureSessionFrame -benchmem | |
| 18 | + | func BenchmarkCaptureSessionFrame(b *testing.B) { | |
| 19 | + | for _, size := range [][2]int{{320, 240}, {1920, 1080}} { | |
| 20 | + | w, h := size[0], size[1] | |
| 21 | + | r, err := New(w, h) | |
| 22 | + | if err != nil { | |
| 23 | + | b.Skipf("Vulkan unavailable: %v", err) | |
| 24 | + | } | |
| 25 | + | if !r.syncFD || !r.dd.HasGetSemaphoreFdKHR() { | |
| 26 | + | r.Close() | |
| 27 | + | b.Skip("Vulkan sync-file export unavailable") | |
| 28 | + | } | |
| 29 | + | win := solidContent(&testing.T{}, 64, 64, color.RGBA{G: 200, A: 255}) | |
| 30 | + | hud := solidContent(&testing.T{}, 64, 16, color.RGBA{B: 200, A: 255}) | |
| 31 | + | win.ID, win.Surface, win.Seq, win.Version = 1, 1, 1, 1 | |
| 32 | + | hud.ID, hud.Surface, hud.Seq, hud.Version = 10, 2, 1, 1 | |
| 33 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: win, 10: hud} | |
| 34 | + | shown := ports.Scene{ | |
| 35 | + | Scale: 1, Background: "#101010", | |
| 36 | + | Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: w, H: h}}}, | |
| 37 | + | Layers: []ports.SceneLayer{{ID: 10, Layer: ports.LayerTop, Rect: ports.Rect{X: 8, Y: 8, W: 64, H: 16}}}, | |
| 38 | + | Capture: &ports.SceneCapture{Session: 1, TargetRect: ports.Rect{W: w, H: h}, BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{10}}, | |
| 39 | + | } | |
| 40 | + | clean := shown | |
| 41 | + | clean.Capture, clean.Layers = nil, nil | |
| 42 | + | frame := func(b *testing.B, s ports.Scene, capture bool) { | |
| 43 | + | if err := render(r, s, contents); err != nil { | |
| 44 | + | b.Fatal(err) | |
| 45 | + | } | |
| 46 | + | if capture { | |
| 47 | + | cf, err := r.BeginCapture() | |
| 48 | + | if err != nil { | |
| 49 | + | b.Fatal(err) | |
| 50 | + | } | |
| 51 | + | defer r.EndCapture(cf) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | wait := func(b *testing.B) { | |
| 55 | + | if err := r.waitFrame(r.submitted); err != nil { | |
| 56 | + | b.Fatal(err) | |
| 57 | + | } | |
| 58 | + | for _, s := range r.captures { | |
| 59 | + | if s.pending { | |
| 60 | + | if err := checked("wait", r.dd.WaitForFences(r.device, 1, &s.fence, 1, 1<<62)); err != nil { | |
| 61 | + | b.Fatal(err) | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | for _, tc := range []struct { | |
| 67 | + | name string | |
| 68 | + | run func(b *testing.B) | |
| 69 | + | }{ | |
| 70 | + | {"render", func(b *testing.B) { frame(b, shown, false) }}, | |
| 71 | + | {"render+capture", func(b *testing.B) { frame(b, shown, true) }}, | |
| 72 | + | {"clean+capture+render", func(b *testing.B) { frame(b, clean, true); frame(b, shown, false) }}, | |
| 73 | + | {"clean+capture+render+capture", func(b *testing.B) { frame(b, clean, true); frame(b, shown, true) }}, | |
| 74 | + | } { | |
| 75 | + | b.Run(fmt.Sprintf("%dx%d/%s", w, h, tc.name), func(b *testing.B) { | |
| 76 | + | tc.run(b) | |
| 77 | + | wait(b) | |
| 78 | + | b.ReportAllocs() | |
| 79 | + | b.ResetTimer() | |
| 80 | + | for range b.N { | |
| 81 | + | tc.run(b) | |
| 82 | + | wait(b) | |
| 83 | + | } | |
| 84 | + | }) | |
| 85 | + | } | |
| 86 | + | r.Close() | |
| 87 | + | } | |
| 88 | + | } | |
A internal/adapters/vulkan/capture_session_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | package vulkan | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "image" | |
| 6 | + | "image/color" | |
| 7 | + | "math" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | var sessionRed = color.RGBA{R: 0xff, G: 0x3b, B: 0x30, A: 255} | |
| 16 | + | ||
| 17 | + | func sessionScene() ports.Scene { | |
| 18 | + | return ports.Scene{ | |
| 19 | + | Seq: 1, Scale: 1, Background: "#101010", | |
| 20 | + | Capture: &ports.SceneCapture{ | |
| 21 | + | Session: 7, TargetRect: ports.Rect{X: 8, Y: 4, W: 16, H: 8}, | |
| 22 | + | BorderWidth: 2, BorderColor: ports.CaptureBorderColor, Excluded: []ports.WindowID{9}, | |
| 23 | + | }, | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestCaptureBorderGeometryLogicalScaled(t *testing.T) { | |
| 28 | + | for _, tc := range []struct { | |
| 29 | + | scale float64 | |
| 30 | + | want []image.Rectangle | |
| 31 | + | }{ | |
| 32 | + | {1, []image.Rectangle{image.Rect(8, 4, 24, 6), image.Rect(8, 10, 24, 12), image.Rect(8, 6, 10, 10), image.Rect(22, 6, 24, 10)}}, | |
| 33 | + | {2, []image.Rectangle{image.Rect(16, 8, 48, 12), image.Rect(16, 20, 48, 24), image.Rect(16, 12, 20, 20), image.Rect(44, 12, 48, 20)}}, | |
| 34 | + | } { | |
| 35 | + | r := &Renderer{width: 64, height: 64} | |
| 36 | + | s := sessionScene() | |
| 37 | + | s.Scale = tc.scale | |
| 38 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |
| 39 | + | require.Len(t, ds, 4) | |
| 40 | + | for i, d := range ds { | |
| 41 | + | got := image.Rect(int(d.pc.rect[0]), int(d.pc.rect[1]), int(d.pc.rect[2]), int(d.pc.rect[3])) | |
| 42 | + | require.Equal(t, tc.want[i], got, "scale %v side %d", tc.scale, i) | |
| 43 | + | require.Equal(t, [4]float32{0xff / 255.0, 0x3b / 255.0, 0x30 / 255.0, 1}, d.pc.color) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | // On the output edge the border is still inside the output. | |
| 47 | + | r := &Renderer{width: 64, height: 64} | |
| 48 | + | s := sessionScene() | |
| 49 | + | s.Scale = 1 | |
| 50 | + | s.Capture.TargetRect = ports.Rect{W: 64, H: 64} | |
| 51 | + | ds := r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |
| 52 | + | require.Len(t, ds, 4) | |
| 53 | + | require.Equal(t, [4]int32{0, 0, 64, 2}, ds[0].pc.rect) | |
| 54 | + | require.Equal(t, [4]int32{62, 2, 64, 62}, ds[3].pc.rect) | |
| 55 | + | // A width larger than half the smaller side is clamped: no overdraw. | |
| 56 | + | s.Capture.TargetRect, s.Capture.BorderWidth = ports.Rect{X: 4, Y: 4, W: 10, H: 6}, 9 | |
| 57 | + | ds = r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64))) | |
| 58 | + | require.Equal(t, [4]int32{4, 4, 14, 7}, ds[0].pc.rect) | |
| 59 | + | require.Equal(t, [4]int32{4, 7, 14, 10}, ds[1].pc.rect) | |
| 60 | + | require.Len(t, ds, 2) // the side fills would be empty | |
| 61 | + | // No session, or no border: nothing extra is drawn. | |
| 62 | + | s = sessionScene() | |
| 63 | + | s.Capture.BorderWidth = 0 | |
| 64 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |
| 65 | + | s.Capture = nil | |
| 66 | + | require.Empty(t, r.draws(s, nil, newDamage(&target{}, s, image.Rect(0, 0, 64, 64)))) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func pixelAt(t *testing.T, r *Renderer, cf ports.CaptureFrame, x, y int) color.RGBA { | |
| 70 | + | t.Helper() | |
| 71 | + | dst := make([]byte, 4) | |
| 72 | + | require.NoError(t, cf.Read(image.Rect(x, y, x+1, y+1), dst, 4)) | |
| 73 | + | return color.RGBA{dst[2], dst[1], dst[0], 255} | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // The clean frame is rendered and copied first, then the displayed frame: | |
| 77 | + | // the first copy has no border, the second one does, and the displayed frame | |
| 78 | + | // (Pixels) matches the second. | |
| 79 | + | func testCleanThenDisplayed(t *testing.T, r *Renderer, tol int, red color.RGBA) { | |
| 80 | + | s := sessionScene() | |
| 81 | + | p := capture.NewPipeline(context.Background(), nil) | |
| 82 | + | defer p.Close(nil) | |
| 83 | + | clean := p.CleanScene(s) | |
| 84 | + | require.Nil(t, clean.Capture) | |
| 85 | + | require.Zero(t, clean.Seq) | |
| 86 | + | require.NoError(t, render(r, clean, nil)) | |
| 87 | + | cleanCopy := captureWait(t, r) | |
| 88 | + | defer r.EndCapture(cleanCopy) | |
| 89 | + | require.NoError(t, render(r, s, nil)) | |
| 90 | + | shownCopy := captureWait(t, r) | |
| 91 | + | defer r.EndCapture(shownCopy) | |
| 92 | + | near := func(a, b color.RGBA) bool { | |
| 93 | + | d := func(x, y uint8) int { return max(int(x)-int(y), int(y)-int(x)) } | |
| 94 | + | return max(d(a.R, b.R), d(a.G, b.G), d(a.B, b.B)) <= tol | |
| 95 | + | } | |
| 96 | + | bg := pixelAt(t, r, shownCopy, 1, 1) | |
| 97 | + | // (8, 8) and (23, 8) lie in the 2 px border on the target's edge, (12, 8) inside it. | |
| 98 | + | require.True(t, near(pixelAt(t, r, shownCopy, 8, 8), red), "displayed border") | |
| 99 | + | require.True(t, near(pixelAt(t, r, shownCopy, 23, 8), red), "displayed border") | |
| 100 | + | require.True(t, near(pixelAt(t, r, cleanCopy, 8, 8), bg), "clean copy has no border") | |
| 101 | + | require.True(t, near(pixelAt(t, r, cleanCopy, 23, 8), bg), "clean copy has no border") | |
| 102 | + | require.True(t, near(pixelAt(t, r, shownCopy, 7, 8), bg), "nothing outside the target") | |
| 103 | + | require.True(t, near(pixelAt(t, r, shownCopy, 12, 8), bg), "target interior untouched") | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestCaptureCleanBeforeDisplayedSDR(t *testing.T) { | |
| 107 | + | r, err := New(64, 16) | |
| 108 | + | if err != nil { | |
| 109 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 110 | + | } | |
| 111 | + | defer r.Close() | |
| 112 | + | testCleanThenDisplayed(t, r, 0, sessionRed) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestCaptureCleanBeforeDisplayedHDR(t *testing.T) { | |
| 116 | + | r := hdrTestRenderer(t) | |
| 117 | + | // The HDR capture pass tone-maps: the expectation is the same curve. | |
| 118 | + | lin := func(v uint8) float64 { | |
| 119 | + | f := float64(v) / 255 | |
| 120 | + | if f <= .04045 { | |
| 121 | + | return f / 12.92 | |
| 122 | + | } | |
| 123 | + | return math.Pow((f+.055)/1.055, 2.4) | |
| 124 | + | } | |
| 125 | + | want := captureSDR([3]float64{lin(sessionRed.R), lin(sessionRed.G), lin(sessionRed.B)}) | |
| 126 | + | testCleanThenDisplayed(t, r, 3, color.RGBA{want[0], want[1], want[2], 255}) | |
| 127 | + | } | |
M internal/adapters/vulkan/scene.go +23 -0
| @@ -43,10 +43,33 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 43 | 43 | w.bounds = outputBounds | |
| 44 | 44 | w.layers(true) | |
| 45 | 45 | w.popups(true) | |
| 46 | + | w.captureBorder() | |
| 46 | 47 | r.scratchDraws = w.draws | |
| 47 | 48 | return w.draws | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 51 | + | // captureBorder draws the native border of a recording session over | |
| 52 | + | // everything: four fills on the inside edge of Capture.TargetRect, so it stays | |
| 53 | + | // visible on a monitor edge. Sizes are logical like the rest of the scene and | |
| 54 | + | // scale with it; the width is at most half the smaller side. A scene without | |
| 55 | + | // Capture, or without border, draws nothing (clean captures never carry it). | |
| 56 | + | func (w *sceneWalk) captureBorder() { | |
| 57 | + | c := w.s.Capture | |
| 58 | + | if c == nil || c.BorderColor == "" { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | t := c.TargetRect | |
| 62 | + | b := min(c.BorderWidth, t.W/2, t.H/2) | |
| 63 | + | if b <= 0 { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | col := parseColor(c.BorderColor) | |
| 67 | + | w.fill(w.physRect(t.X, t.Y, t.W, b), col) | |
| 68 | + | w.fill(w.physRect(t.X, t.Y+t.H-b, t.W, b), col) | |
| 69 | + | w.fill(w.physRect(t.X, t.Y+b, b, t.H-2*b), col) | |
| 70 | + | w.fill(w.physRect(t.X+t.W-b, t.Y+b, b, t.H-2*b), col) | |
| 71 | + | } | |
| 72 | + | ||
| 50 | 73 | func (w *sceneWalk) phys(v int) int { return int(math.Round(float64(v) * w.scale)) } | |
| 51 | 74 | ||
| 52 | 75 | func (w *sceneWalk) physRect(x, y, width, height int) image.Rectangle { | |
A internal/adapters/wayland/capture_session.go +423 -0
| @@ -0,0 +1,423 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "crypto/subtle" | |
| 6 | + | "encoding/hex" | |
| 7 | + | "os" | |
| 8 | + | "slices" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/wayland/capturesession" | |
| 12 | + | "github.com/bnema/neferwl/internal/ports" | |
| 13 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 14 | + | "github.com/bnema/purego-libwayland/server" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Private capture sessions (neferwl_capture_manager_v1, capturesession/neferwl-capture-v1.xml). | |
| 18 | + | // Core owns the session state; this file relays requests, tells clients what | |
| 19 | + | // core decided and tags the capture requests of the session's own connection. | |
| 20 | + | // | |
| 21 | + | // Privacy: the session token never reaches a log, and neither does any peer | |
| 22 | + | // identity; only session lifecycle states are logged. | |
| 23 | + | ||
| 24 | + | // privateCapture is the server's private capture state; display goroutine only. | |
| 25 | + | type privateCapture struct { | |
| 26 | + | ownUID uint32 | |
| 27 | + | managers []*captureManager | |
| 28 | + | next uint64 | |
| 29 | + | session *privateSession // the one live session | |
| 30 | + | // dead are ended sessions whose owner has not destroyed the object yet. | |
| 31 | + | dead []*privateSession | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // minPingGap drops pings that follow the previous one too closely: the | |
| 35 | + | // timeout is seconds, so an owner cannot flood core with them. | |
| 36 | + | const minPingGap = ports.CaptureSessionTimeout / 50 | |
| 37 | + | ||
| 38 | + | type privateSession struct { | |
| 39 | + | s *Server | |
| 40 | + | id uint64 | |
| 41 | + | token string | |
| 42 | + | res *capturesession.NeferwlCaptureSessionV1 | |
| 43 | + | client server.Client // the owner's connection: its captures are clean | |
| 44 | + | output string | |
| 45 | + | // layers are the attached layer surfaces; attached their attachment | |
| 46 | + | // objects, pending until a core state lists the layer. | |
| 47 | + | layers []*layerSurface | |
| 48 | + | attached []*layerAttachment | |
| 49 | + | lastPing time.Time | |
| 50 | + | // confirmed is set by the first core state; active and revision are the | |
| 51 | + | // last state's: clean captures are tagged with them. | |
| 52 | + | confirmed, active bool | |
| 53 | + | revision uint64 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // layerAttachment is one neferwl_capture_layer_v1: it reports the result of | |
| 57 | + | // an attach_surface and the layer's end. | |
| 58 | + | type layerAttachment struct { | |
| 59 | + | res *capturesession.NeferwlCaptureLayerV1 | |
| 60 | + | layer *layerSurface | |
| 61 | + | confirmed bool // attached sent | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (a *layerAttachment) fail(reason capturesession.NeferwlCaptureLayerV1Failure) { | |
| 65 | + | if a.res.Alive() { | |
| 66 | + | a.res.SendFailed(uint32(reason)) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // finish ends an attachment: detached after attached, failed before it. | |
| 71 | + | func (a *layerAttachment) finish(failure capturesession.NeferwlCaptureLayerV1Failure, detach capturesession.NeferwlCaptureLayerV1DetachReason) { | |
| 72 | + | if !a.res.Alive() { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | if a.confirmed { | |
| 76 | + | a.res.SendDetached(uint32(detach)) | |
| 77 | + | } else { | |
| 78 | + | a.res.SendFailed(uint32(failure)) | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // trustedPeer reports whether a peer with these credentials may use the | |
| 83 | + | // private protocol: its credentials are readable and it runs as the | |
| 84 | + | // compositor's own user. | |
| 85 | + | func trustedPeer(own uint32, peer server.Credentials, err error) bool { | |
| 86 | + | return err == nil && peer.UID == own | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (s *Server) trusted(c server.Client) bool { | |
| 90 | + | cr, err := c.Credentials() | |
| 91 | + | return trustedPeer(s.private.ownUID, cr, err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func registerCaptureSession(d *server.Display, s *Server) error { | |
| 95 | + | s.private.ownUID = uint32(os.Geteuid()) | |
| 96 | + | return capturesession.NewNeferwlCaptureManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 97 | + | m := &captureManager{s: s, trusted: s.trusted(c), known: map[uint64]privateWorkspace{}} | |
| 98 | + | r, err := capturesession.NewNeferwlCaptureManagerV1(c, int32(v), id, m) | |
| 99 | + | if err != nil { | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | m.res = r | |
| 103 | + | s.private.managers = append(s.private.managers, m) | |
| 104 | + | r.OnDestroy = func() { s.private.managers = removeItem(s.private.managers, m) } | |
| 105 | + | m.update(s.workspaceSnapshot) | |
| 106 | + | }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | type privateWorkspace struct { | |
| 110 | + | output string | |
| 111 | + | info ports.WorkspaceInfo | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | type captureManager struct { | |
| 115 | + | s *Server | |
| 116 | + | res *capturesession.NeferwlCaptureManagerV1 | |
| 117 | + | trusted bool | |
| 118 | + | known map[uint64]privateWorkspace | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // update sends the workspaces that are new or changed, and the ones gone. | |
| 122 | + | // Untrusted peers get no inventory. | |
| 123 | + | func (m *captureManager) update(snapshot ports.Workspaces) { | |
| 124 | + | if !m.trusted || !m.res.Alive() { | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | now := map[uint64]privateWorkspace{} | |
| 128 | + | for _, out := range snapshot.Outputs { | |
| 129 | + | for _, w := range out.Workspaces { | |
| 130 | + | now[w.ID] = privateWorkspace{out.Name, w} | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | for id := range m.known { | |
| 134 | + | if _, ok := now[id]; !ok { | |
| 135 | + | m.res.SendWorkspaceRemoved(uint32(id>>32), uint32(id)) | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | for id, w := range now { | |
| 139 | + | if m.known[id] == w { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | f, active := w.info.Frame, uint32(0) | |
| 143 | + | if w.info.Active { | |
| 144 | + | active = 1 | |
| 145 | + | } | |
| 146 | + | m.res.SendWorkspace(uint32(id>>32), uint32(id), w.output, w.info.Name, int32(f.X), int32(f.Y), int32(f.W), int32(f.H), active) | |
| 147 | + | } | |
| 148 | + | m.known = now | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // updateCaptureWorkspaces relays a core workspace snapshot to the private managers. | |
| 152 | + | func (s *Server) updateCaptureWorkspaces(snapshot ports.Workspaces) { | |
| 153 | + | for _, m := range s.private.managers { | |
| 154 | + | m.update(snapshot) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (*captureManager) Destroy(*capturesession.NeferwlCaptureManagerV1) {} | |
| 159 | + | ||
| 160 | + | func (m *captureManager) BeginSession(r *capturesession.NeferwlCaptureManagerV1, id uint32, out *wayland.Output, x, y, w, h int32, hi, lo, record uint32) { | |
| 161 | + | s := m.s | |
| 162 | + | if out == nil { | |
| 163 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidOutput), "no output") | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | if w < 0 || h < 0 { | |
| 167 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion), "negative region size") | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | ps := &privateSession{s: s, client: r.Client()} | |
| 171 | + | res, err := capturesession.NewNeferwlCaptureSessionV1(r.Client(), r.Version(), id, ps) | |
| 172 | + | if err != nil { | |
| 173 | + | return | |
| 174 | + | } | |
| 175 | + | ps.res = res | |
| 176 | + | stop := func(reason capturesession.NeferwlCaptureSessionV1StopReason) { res.SendStopped(uint32(reason)) } | |
| 177 | + | o := s.outputOf(out) | |
| 178 | + | switch { | |
| 179 | + | case !m.trusted: | |
| 180 | + | s.log.Info().Str("reason", string(ports.CaptureReasonUnauthorized)).Msg("capture session refused") | |
| 181 | + | stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized) | |
| 182 | + | return | |
| 183 | + | case s.private.session != nil: | |
| 184 | + | s.log.Info().Str("reason", string(ports.CaptureReasonBusy)).Msg("capture session refused") | |
| 185 | + | stop(capturesession.NeferwlCaptureSessionV1StopReasonBusy) | |
| 186 | + | return | |
| 187 | + | case o == nil: | |
| 188 | + | stop(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone) | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | var raw [32]byte | |
| 192 | + | if _, err := rand.Read(raw[:]); err != nil { | |
| 193 | + | s.log.Warn().Msg("capture session refused: no randomness") | |
| 194 | + | stop(capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized) | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | s.private.next++ | |
| 198 | + | ps.id, ps.token, ps.output = s.private.next, hex.EncodeToString(raw[:]), o.name() | |
| 199 | + | s.private.session = ps | |
| 200 | + | res.OnDestroy = ps.release | |
| 201 | + | workspace := uint64(hi)<<32 | uint64(lo) | |
| 202 | + | s.log.Info().Uint64("session", ps.id).Str("output", ps.output).Uint64("workspace", workspace).Bool("record", record != 0).Msg("capture session begin") | |
| 203 | + | s.emit(ports.CaptureSessionBegin{ID: ps.id, Output: ps.output, Workspace: workspace, Region: ports.Rect{X: int(x), Y: int(y), W: int(w), H: int(h)}, Record: record != 0}) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (m *captureManager) AttachSurface(r *capturesession.NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface) { | |
| 207 | + | s := m.s | |
| 208 | + | if surface == nil || s.surfaces[surface.Resource] == nil || surface.Resource.Client() != r.Client() { | |
| 209 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "surface is not yours") | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | state := s.surfaces[surface.Resource] | |
| 213 | + | l := state.layer | |
| 214 | + | att := &layerAttachment{layer: l} | |
| 215 | + | res, err := capturesession.NewNeferwlCaptureLayerV1(r.Client(), r.Version(), id, att) | |
| 216 | + | if err != nil { | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | att.res = res | |
| 220 | + | // Same user as the compositor, and as the session's creator (who passed | |
| 221 | + | // the same check): nothing else can attach. | |
| 222 | + | ps := s.private.session | |
| 223 | + | switch { | |
| 224 | + | case !m.trusted: // the UID of this connection, checked once at bind | |
| 225 | + | att.fail(capturesession.NeferwlCaptureLayerV1FailureUnauthorized) | |
| 226 | + | return | |
| 227 | + | case ps == nil || subtle.ConstantTimeCompare([]byte(token), []byte(ps.token)) != 1: | |
| 228 | + | att.fail(capturesession.NeferwlCaptureLayerV1FailureUnknownToken) | |
| 229 | + | return | |
| 230 | + | case state.kind != roleLayer || l == nil: | |
| 231 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorSurfaceRole), "not a layer surface") | |
| 232 | + | return | |
| 233 | + | case l.pending.layer != ports.LayerTop && l.pending.layer != ports.LayerOverlay: | |
| 234 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer), "layer must be top or overlay") | |
| 235 | + | return | |
| 236 | + | case l.pending.keyboard == 1: | |
| 237 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard), "exclusive keyboard") | |
| 238 | + | return | |
| 239 | + | case l.mapped || l.closed: | |
| 240 | + | r.PostError(uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidSurface), "already mapped") | |
| 241 | + | return | |
| 242 | + | case l.capture != nil: | |
| 243 | + | att.fail(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached) | |
| 244 | + | return | |
| 245 | + | case len(ps.layers) >= ports.MaxCaptureSessionLayers: | |
| 246 | + | att.fail(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers) | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | l.capture, l.attachment = ps, att | |
| 250 | + | l.pending.keyboard, l.current.keyboard = 0, 0 | |
| 251 | + | ps.layers = append(ps.layers, l) | |
| 252 | + | ps.attached = append(ps.attached, att) | |
| 253 | + | // Queued before any LayerChanged of this layer: core excludes it from | |
| 254 | + | // the first scene that shows it. | |
| 255 | + | s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id, Attached: true}) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func (*layerAttachment) Destroy(*capturesession.NeferwlCaptureLayerV1) {} | |
| 259 | + | ||
| 260 | + | func (ps *privateSession) Destroy(*capturesession.NeferwlCaptureSessionV1) {} | |
| 261 | + | ||
| 262 | + | func (ps *privateSession) Ping(*capturesession.NeferwlCaptureSessionV1) { | |
| 263 | + | if ps.s.private.session != ps { | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | if now := time.Now(); now.Sub(ps.lastPing) >= minPingGap { | |
| 267 | + | ps.lastPing = now | |
| 268 | + | ps.s.emit(ports.CaptureSessionPing{ID: ps.id}) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // release runs when the session object goes: destroy request or client | |
| 273 | + | // disconnect. A live session ends and core is told to forget it. | |
| 274 | + | func (ps *privateSession) release() { | |
| 275 | + | s := ps.s | |
| 276 | + | if s.private.session == ps { | |
| 277 | + | s.log.Info().Uint64("session", ps.id).Str("reason", "owner-gone").Msg("capture session end") | |
| 278 | + | ps.end(true) | |
| 279 | + | } | |
| 280 | + | s.private.dead = removeItem(s.private.dead, ps) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // end ends the session: every attachment is told (detached, or failed before | |
| 284 | + | // it was confirmed), its layer surfaces are closed so none lingers into the | |
| 285 | + | // next session, and the owner's captures of the output stay refused until it | |
| 286 | + | // destroys the session object. notify tells core. | |
| 287 | + | func (ps *privateSession) end(notify bool) { | |
| 288 | + | s := ps.s | |
| 289 | + | if s.private.session != ps { | |
| 290 | + | return | |
| 291 | + | } | |
| 292 | + | s.private.session = nil | |
| 293 | + | if ps.res.Alive() { | |
| 294 | + | s.private.dead = append(s.private.dead, ps) | |
| 295 | + | } | |
| 296 | + | layers, attached := ps.layers, ps.attached | |
| 297 | + | ps.layers, ps.attached = nil, nil | |
| 298 | + | for _, a := range attached { | |
| 299 | + | a.finish(capturesession.NeferwlCaptureLayerV1FailureSessionEnded, capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded) | |
| 300 | + | } | |
| 301 | + | for _, l := range layers { | |
| 302 | + | l.capture, l.attachment = nil, nil | |
| 303 | + | // Closing unmaps the surface: the LayerChanged that removes it is | |
| 304 | + | // queued before the CaptureSessionEnd below. | |
| 305 | + | l.close() | |
| 306 | + | } | |
| 307 | + | if notify { | |
| 308 | + | s.emit(ports.CaptureSessionEnd{ID: ps.id}) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // captureDetach drops a layer surface whose role is gone from its session. | |
| 313 | + | // The caller unmapped it first, so the LayerChanged that removes the layer is | |
| 314 | + | // already queued: core keeps excluding the layer until it reads that. | |
| 315 | + | func (l *layerSurface) captureDetach() { | |
| 316 | + | ps, att := l.capture, l.attachment | |
| 317 | + | if ps == nil { | |
| 318 | + | return | |
| 319 | + | } | |
| 320 | + | l.capture, l.attachment = nil, nil | |
| 321 | + | ps.layers = removeItem(ps.layers, l) | |
| 322 | + | ps.attached = removeItem(ps.attached, att) | |
| 323 | + | att.finish(capturesession.NeferwlCaptureLayerV1FailureLayerDestroyed, capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed) | |
| 324 | + | if ps.s.private.session == ps { | |
| 325 | + | ps.s.emit(ports.CaptureSessionLayer{ID: ps.id, Layer: l.id}) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // stopReason maps a terminal core reason to the wire. | |
| 330 | + | func stopReason(r ports.CaptureReason) capturesession.NeferwlCaptureSessionV1StopReason { | |
| 331 | + | switch r { | |
| 332 | + | case ports.CaptureReasonOutputGone: | |
| 333 | + | return capturesession.NeferwlCaptureSessionV1StopReasonOutputGone | |
| 334 | + | case ports.CaptureReasonOutputOff: | |
| 335 | + | return capturesession.NeferwlCaptureSessionV1StopReasonOutputOff | |
| 336 | + | case ports.CaptureReasonPingTimeout: | |
| 337 | + | return capturesession.NeferwlCaptureSessionV1StopReasonPingTimeout | |
| 338 | + | case ports.CaptureReasonWorkspaceGone: | |
| 339 | + | return capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone | |
| 340 | + | case ports.CaptureReasonBusy: | |
| 341 | + | return capturesession.NeferwlCaptureSessionV1StopReasonBusy | |
| 342 | + | case ports.CaptureReasonInvalidRegion: | |
| 343 | + | return capturesession.NeferwlCaptureSessionV1StopReasonInvalidRegion | |
| 344 | + | case ports.CaptureReasonUnauthorized: | |
| 345 | + | return capturesession.NeferwlCaptureSessionV1StopReasonUnauthorized | |
| 346 | + | case ports.CaptureReasonTooManyExcluded: | |
| 347 | + | return capturesession.NeferwlCaptureSessionV1StopReasonTooManyExcluded | |
| 348 | + | } | |
| 349 | + | return capturesession.NeferwlCaptureSessionV1StopReasonRequested | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // captureState applies what core decided about the session. | |
| 353 | + | func (s *Server) captureState(c ports.CaptureSessionState) { | |
| 354 | + | ps := s.private.session | |
| 355 | + | if ps == nil || ps.id != c.ID { | |
| 356 | + | return | |
| 357 | + | } | |
| 358 | + | if c.Reason.Terminal() { | |
| 359 | + | s.log.Info().Uint64("session", ps.id).Str("reason", string(c.Reason)).Msg("capture session end") | |
| 360 | + | if ps.res.Alive() { | |
| 361 | + | ps.res.SendStopped(uint32(stopReason(c.Reason))) | |
| 362 | + | } | |
| 363 | + | ps.end(false) | |
| 364 | + | return | |
| 365 | + | } | |
| 366 | + | ps.confirmed, ps.active, ps.revision = true, c.Active, c.Revision | |
| 367 | + | s.log.Debug().Uint64("session", ps.id).Bool("active", c.Active).Uint64("revision", c.Revision).Str("reason", string(c.Reason)).Msg("capture session state") | |
| 368 | + | if ps.res.Alive() { | |
| 369 | + | active := uint32(0) | |
| 370 | + | if c.Active { | |
| 371 | + | active = 1 | |
| 372 | + | } | |
| 373 | + | ps.res.SendState(ps.token, active, int32(c.Rect.X), int32(c.Rect.Y), int32(c.Rect.W), int32(c.Rect.H), uint32(c.Workspace>>32), uint32(c.Workspace), uint32(c.Revision>>32), uint32(c.Revision)) | |
| 374 | + | } | |
| 375 | + | // A layer is confirmed once core lists it: only then is it safe for the | |
| 376 | + | // client to show it. | |
| 377 | + | for _, a := range ps.attached { | |
| 378 | + | if !a.confirmed && slices.Contains(c.Layers, a.layer.id) { | |
| 379 | + | a.confirmed = true | |
| 380 | + | if a.res.Alive() { | |
| 381 | + | a.res.SendAttached() | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // stopCaptureOnOutput ends the session of an unplugged output without waiting for core. | |
| 388 | + | func (s *Server) stopCaptureOnOutput(name string) { | |
| 389 | + | ps := s.private.session | |
| 390 | + | if ps == nil || ps.output != name { | |
| 391 | + | return | |
| 392 | + | } | |
| 393 | + | s.log.Info().Uint64("session", ps.id).Str("reason", string(ports.CaptureReasonOutputGone)).Msg("capture session end") | |
| 394 | + | if ps.res.Alive() { | |
| 395 | + | ps.res.SendStopped(uint32(capturesession.NeferwlCaptureSessionV1StopReasonOutputGone)) | |
| 396 | + | } | |
| 397 | + | ps.end(true) | |
| 398 | + | } | |
| 399 | + | ||
| 400 | + | // captureTag says how a capture of output o requested through life is | |
| 401 | + | // served. The owner's connection gets clean captures of the session's output, | |
| 402 | + | // fenced by the revision core last confirmed. While its session is unconfirmed, | |
| 403 | + | // or after it ended and until the owner destroys the object, those captures | |
| 404 | + | // are refused rather than served with the HUD in them. Every other connection, | |
| 405 | + | // and the owner's captures of another output, are untouched. | |
| 406 | + | func (s *Server) captureTag(life *server.Resource, o *output) (clean bool, session, revision uint64, ok bool) { | |
| 407 | + | if life == nil { | |
| 408 | + | return false, 0, 0, true | |
| 409 | + | } | |
| 410 | + | client := life.Client() | |
| 411 | + | if ps := s.private.session; ps != nil && client == ps.client && o.name() == ps.output { | |
| 412 | + | if !ps.confirmed || !ps.active { | |
| 413 | + | return false, 0, 0, false | |
| 414 | + | } | |
| 415 | + | return true, ps.id, ps.revision, true | |
| 416 | + | } | |
| 417 | + | for _, ps := range s.private.dead { | |
| 418 | + | if client == ps.client && o.name() == ps.output { | |
| 419 | + | return false, 0, 0, false | |
| 420 | + | } | |
| 421 | + | } | |
| 422 | + | return false, 0, 0, true | |
| 423 | + | } | |
A internal/adapters/wayland/capture_session_test.go +813 -0
| @@ -0,0 +1,813 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/wayland/capturesession" | |
| 10 | + | "github.com/bnema/neferwl/internal/logging" | |
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 12 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 13 | + | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | |
| 14 | + | wlr "github.com/bnema/purego-libwayland/protocol/wlrscreencopy" | |
| 15 | + | "github.com/bnema/purego-libwayland/server" | |
| 16 | + | "github.com/bnema/wlturbo" | |
| 17 | + | "golang.org/x/sys/unix" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // sessionMsg is one decoded event of the private protocol. | |
| 21 | + | type sessionMsg struct { | |
| 22 | + | op uint16 | |
| 23 | + | token string | |
| 24 | + | name string | |
| 25 | + | numbers []int64 | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // managerEvents and sessionEvents decode the events of the two interfaces. | |
| 29 | + | type managerEvents struct { | |
| 30 | + | wlturbo.BaseProxy | |
| 31 | + | msgs chan sessionMsg | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (p *managerEvents) Dispatch(e *wlturbo.Event) { | |
| 35 | + | m := sessionMsg{op: e.Opcode} | |
| 36 | + | switch uint32(e.Opcode) { | |
| 37 | + | case capturesession.NeferwlCaptureManagerV1EventWorkspace: | |
| 38 | + | m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32())) | |
| 39 | + | _ = e.String() | |
| 40 | + | m.name = e.String() | |
| 41 | + | m.numbers = append(m.numbers, int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Int32()), int64(e.Uint32())) | |
| 42 | + | case capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved: | |
| 43 | + | m.numbers = append(m.numbers, int64(e.Uint32()), int64(e.Uint32())) | |
| 44 | + | } | |
| 45 | + | p.msgs <- m | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // attachEvents decodes the events of a neferwl_capture_layer_v1. | |
| 49 | + | type attachEvents struct { | |
| 50 | + | wlturbo.BaseProxy | |
| 51 | + | msgs chan sessionMsg | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (p *attachEvents) Dispatch(e *wlturbo.Event) { | |
| 55 | + | m := sessionMsg{op: e.Opcode} | |
| 56 | + | if uint32(e.Opcode) != capturesession.NeferwlCaptureLayerV1EventAttached { | |
| 57 | + | m.numbers = append(m.numbers, int64(e.Uint32())) | |
| 58 | + | } | |
| 59 | + | p.msgs <- m | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | type sessionEvents struct { | |
| 63 | + | wlturbo.BaseProxy | |
| 64 | + | msgs chan sessionMsg | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (p *sessionEvents) Dispatch(e *wlturbo.Event) { | |
| 68 | + | m := sessionMsg{op: e.Opcode} | |
| 69 | + | switch uint32(e.Opcode) { | |
| 70 | + | case capturesession.NeferwlCaptureSessionV1EventState: | |
| 71 | + | m.token = e.String() | |
| 72 | + | for i := 0; i < 9; i++ { | |
| 73 | + | m.numbers = append(m.numbers, int64(e.Int32())) | |
| 74 | + | } | |
| 75 | + | case capturesession.NeferwlCaptureSessionV1EventStopped: | |
| 76 | + | m.numbers = append(m.numbers, int64(e.Uint32())) | |
| 77 | + | } | |
| 78 | + | p.msgs <- m | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | type privateHarness struct { | |
| 82 | + | s *Server | |
| 83 | + | dir string | |
| 84 | + | events chan ports.ClientEvent | |
| 85 | + | commands chan ports.ClientCommand | |
| 86 | + | workspaces chan ports.Workspaces | |
| 87 | + | captures chan ports.CaptureRequest | |
| 88 | + | captured chan ports.CaptureDone | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func newPrivateHarness(t *testing.T) *privateHarness { | |
| 92 | + | t.Helper() | |
| 93 | + | h := &privateHarness{ | |
| 94 | + | dir: t.TempDir(), | |
| 95 | + | events: make(chan ports.ClientEvent, 64), | |
| 96 | + | commands: make(chan ports.ClientCommand, 16), | |
| 97 | + | workspaces: make(chan ports.Workspaces, 4), | |
| 98 | + | captures: make(chan ports.CaptureRequest, 4), | |
| 99 | + | captured: make(chan ports.CaptureDone, 4), | |
| 100 | + | } | |
| 101 | + | out := ports.Layout{{Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 2, Height: 2}, Width: 2, Height: 2, Scale: 1}} | |
| 102 | + | s, err := New(Options{RuntimeDir: h.dir, Outputs: out}, Channels{Events: h.events, Commands: h.commands, Workspaces: h.workspaces, Captures: h.captures, Captured: h.captured}, logging.For(context.Background(), "wayland")) | |
| 103 | + | if err != nil { | |
| 104 | + | t.Fatal(err) | |
| 105 | + | } | |
| 106 | + | h.s = s | |
| 107 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 108 | + | done := make(chan error, 1) | |
| 109 | + | go func() { done <- s.Run(ctx) }() | |
| 110 | + | t.Cleanup(func() { | |
| 111 | + | cancel() | |
| 112 | + | if err := <-done; err != nil { | |
| 113 | + | t.Errorf("Run: %v", err) | |
| 114 | + | } | |
| 115 | + | for len(h.captures) > 0 { | |
| 116 | + | _ = (<-h.captures).Dst.File.Close() | |
| 117 | + | } | |
| 118 | + | }) | |
| 119 | + | return h | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | type privateClient struct { | |
| 123 | + | c *wlturbo.Display | |
| 124 | + | manager uint32 | |
| 125 | + | output uint32 | |
| 126 | + | mgr *managerEvents | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func (h *privateHarness) client(t *testing.T) *privateClient { | |
| 130 | + | t.Helper() | |
| 131 | + | c := protocolClient(t, h.s, h.dir) | |
| 132 | + | g, ok := c.Registry().FindGlobal("wl_output") | |
| 133 | + | if !ok { | |
| 134 | + | t.Fatal("output missing") | |
| 135 | + | } | |
| 136 | + | out, err := c.Registry().BindID(g.Name, g.Interface, 4) | |
| 137 | + | if err != nil { | |
| 138 | + | t.Fatal(err) | |
| 139 | + | } | |
| 140 | + | registerProtocol(t, c, out) | |
| 141 | + | pc := &privateClient{c: c, output: out, manager: bindProtocol(t, c, "neferwl_capture_manager_v1"), mgr: &managerEvents{msgs: make(chan sessionMsg, 32)}} | |
| 142 | + | pc.mgr.SetID(pc.manager) | |
| 143 | + | c.Context().Register(pc.mgr) | |
| 144 | + | return pc | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // begin opens a session and returns its event stream. | |
| 148 | + | func (pc *privateClient) begin(t *testing.T, x, y, w, h int32, workspace uint64, record uint32) (uint32, chan sessionMsg) { | |
| 149 | + | t.Helper() | |
| 150 | + | id := pc.c.AllocateID() | |
| 151 | + | ev := &sessionEvents{msgs: make(chan sessionMsg, 32)} | |
| 152 | + | ev.SetID(id) | |
| 153 | + | pc.c.Context().Register(ev) | |
| 154 | + | requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, id, pc.output, x, y, w, h, uint32(workspace>>32), uint32(workspace), record) | |
| 155 | + | if err := pc.c.Roundtrip(); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | return id, ev.msgs | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // waitMsg waits for a message on ch, dispatching the client meanwhile. | |
| 162 | + | func waitMsg(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) sessionMsg { | |
| 163 | + | t.Helper() | |
| 164 | + | deadline := time.Now().Add(2 * time.Second) | |
| 165 | + | for time.Now().Before(deadline) { | |
| 166 | + | select { | |
| 167 | + | case m := <-ch: | |
| 168 | + | return m | |
| 169 | + | default: | |
| 170 | + | } | |
| 171 | + | if err := c.Roundtrip(); err != nil { | |
| 172 | + | t.Fatal(err) | |
| 173 | + | } | |
| 174 | + | time.Sleep(time.Millisecond) | |
| 175 | + | } | |
| 176 | + | t.Fatal("timed out waiting for an event") | |
| 177 | + | return sessionMsg{} | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func noMessage(t *testing.T, c *wlturbo.Display, ch chan sessionMsg) { | |
| 181 | + | t.Helper() | |
| 182 | + | if err := c.Roundtrip(); err != nil { | |
| 183 | + | t.Fatal(err) | |
| 184 | + | } | |
| 185 | + | select { | |
| 186 | + | case m := <-ch: | |
| 187 | + | t.Fatalf("unexpected event %+v", m) | |
| 188 | + | default: | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func nextEvent[T ports.ClientEvent](t *testing.T, ch chan ports.ClientEvent) T { | |
| 193 | + | t.Helper() | |
| 194 | + | for { | |
| 195 | + | select { | |
| 196 | + | case ev := <-ch: | |
| 197 | + | if v, ok := ev.(T); ok { | |
| 198 | + | return v | |
| 199 | + | } | |
| 200 | + | case <-time.After(2 * time.Second): | |
| 201 | + | t.Fatal("timed out waiting for a client event") | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | func (h *privateHarness) send(cmd ports.ClientCommand) { h.commands <- cmd } | |
| 207 | + | ||
| 208 | + | func TestTrustedPeer(t *testing.T) { | |
| 209 | + | for _, tc := range []struct { | |
| 210 | + | name string | |
| 211 | + | peer server.Credentials | |
| 212 | + | err error | |
| 213 | + | want bool | |
| 214 | + | }{ | |
| 215 | + | {"same user", server.Credentials{UID: 1000}, nil, true}, | |
| 216 | + | {"other user", server.Credentials{UID: 1001}, nil, false}, | |
| 217 | + | {"root is not the user", server.Credentials{UID: 0}, nil, false}, | |
| 218 | + | {"overflow id", server.Credentials{UID: 65534}, nil, false}, | |
| 219 | + | {"unreadable", server.Credentials{UID: 1000}, context.Canceled, false}, | |
| 220 | + | } { | |
| 221 | + | if got := trustedPeer(1000, tc.peer, tc.err); got != tc.want { | |
| 222 | + | t.Errorf("%s: %v", tc.name, got) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func TestPrivateCaptureBeginAndState(t *testing.T) { | |
| 228 | + | h := newPrivateHarness(t) | |
| 229 | + | pc := h.client(t) | |
| 230 | + | id, msgs := pc.begin(t, 1, 2, 3, 4, 1<<33|7, 1) | |
| 231 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 232 | + | if begin.Output != "HEADLESS-1" || begin.Workspace != 1<<33|7 || begin.Region != (ports.Rect{X: 1, Y: 2, W: 3, H: 4}) || !begin.Record || begin.ID == 0 { | |
| 233 | + | t.Fatalf("begin %+v", begin) | |
| 234 | + | } | |
| 235 | + | // Nothing is sent, and above all no token, until core confirmed. | |
| 236 | + | noMessage(t, pc.c, msgs) | |
| 237 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{X: 1, Y: 2, W: 3, H: 4}, Workspace: 1<<33 | 7, Active: true, Revision: 1<<32 | 5}) | |
| 238 | + | m := waitMsg(t, pc.c, msgs) | |
| 239 | + | if uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventState || len(m.token) != 64 || strings.Trim(m.token, "0123456789abcdef") != "" { | |
| 240 | + | t.Fatalf("state %+v", m) | |
| 241 | + | } | |
| 242 | + | want := []int64{1, 1, 2, 3, 4, 2, 7, 1, 5} | |
| 243 | + | for i, v := range want { | |
| 244 | + | if m.numbers[i] != v { | |
| 245 | + | t.Fatalf("state numbers %v, want %v", m.numbers, want) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | // Ping reaches core, and a flood of them does not. | |
| 249 | + | for i := 0; i < 50; i++ { | |
| 250 | + | requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestPing) | |
| 251 | + | } | |
| 252 | + | if err := pc.c.Roundtrip(); err != nil { | |
| 253 | + | t.Fatal(err) | |
| 254 | + | } | |
| 255 | + | if p := nextEvent[ports.CaptureSessionPing](t, h.events); p.ID != begin.ID { | |
| 256 | + | t.Fatalf("ping %+v", p) | |
| 257 | + | } | |
| 258 | + | select { | |
| 259 | + | case ev := <-h.events: | |
| 260 | + | if _, ok := ev.(ports.CaptureSessionPing); ok { | |
| 261 | + | t.Fatalf("ping flood forwarded: %+v", ev) | |
| 262 | + | } | |
| 263 | + | default: | |
| 264 | + | } | |
| 265 | + | // Destroy ends the session in core. | |
| 266 | + | requestProtocol(t, pc.c, id, capturesession.NeferwlCaptureSessionV1RequestDestroy) | |
| 267 | + | if err := pc.c.Roundtrip(); err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID { | |
| 271 | + | t.Fatalf("end %+v", e) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func TestPrivateCaptureBusyAndTerminal(t *testing.T) { | |
| 276 | + | h := newPrivateHarness(t) | |
| 277 | + | pc := h.client(t) | |
| 278 | + | _, first := pc.begin(t, 0, 0, 0, 0, 0, 0) | |
| 279 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 280 | + | other := h.client(t) | |
| 281 | + | _, second := other.begin(t, 0, 0, 0, 0, 0, 0) | |
| 282 | + | if m := waitMsg(t, other.c, second); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonBusy) { | |
| 283 | + | t.Fatalf("second session: %+v", m) | |
| 284 | + | } | |
| 285 | + | // The refusal neither reaches core nor disturbs the first session. | |
| 286 | + | noMessage(t, pc.c, first) | |
| 287 | + | select { | |
| 288 | + | case ev := <-h.events: | |
| 289 | + | t.Fatalf("event for a refused session: %+v", ev) | |
| 290 | + | default: | |
| 291 | + | } | |
| 292 | + | // A terminal state from core stops the session with its reason. | |
| 293 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonWorkspaceGone}) | |
| 294 | + | if m := waitMsg(t, pc.c, first); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped || m.numbers[0] != int64(capturesession.NeferwlCaptureSessionV1StopReasonWorkspaceGone) { | |
| 295 | + | t.Fatalf("stopped: %+v", m) | |
| 296 | + | } | |
| 297 | + | // The slot is free again. | |
| 298 | + | _, third := other.begin(t, 0, 0, 0, 0, 0, 0) | |
| 299 | + | noMessage(t, other.c, third) | |
| 300 | + | nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func TestPrivateCaptureInvalidRequests(t *testing.T) { | |
| 304 | + | h := newPrivateHarness(t) | |
| 305 | + | pc := h.client(t) | |
| 306 | + | requestProtocol(t, pc.c, pc.manager, capturesession.NeferwlCaptureManagerV1RequestBeginSession, pc.c.AllocateID(), pc.output, int32(0), int32(0), int32(-1), int32(4), uint32(0), uint32(0), uint32(0)) | |
| 307 | + | expectProtocolError(t, pc.c, pc.manager, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidRegion)) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | // hud is a second connection: a layer surface it attaches with the token. | |
| 311 | + | type hud struct { | |
| 312 | + | *privateClient | |
| 313 | + | surface, layer uint32 | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func (h *privateHarness) hud(t *testing.T, layer ports.Layer, keyboard uint32) *hud { | |
| 317 | + | t.Helper() | |
| 318 | + | return h.hudVersion(t, layer, keyboard, 1) | |
| 319 | + | } | |
| 320 | + | ||
| 321 | + | func (h *privateHarness) hudVersion(t *testing.T, layer ports.Layer, keyboard, shellVersion uint32) *hud { | |
| 322 | + | t.Helper() | |
| 323 | + | pc := h.client(t) | |
| 324 | + | comp := bindProtocol(t, pc.c, "wl_compositor") | |
| 325 | + | shell := bindVersion(t, pc.c, "zwlr_layer_shell_v1", shellVersion) | |
| 326 | + | surf, l := pc.c.AllocateID(), pc.c.AllocateID() | |
| 327 | + | requestProtocol(t, pc.c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 328 | + | registerProtocol(t, pc.c, surf) | |
| 329 | + | requestProtocol(t, pc.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, pc.output, uint32(layer), "hud") | |
| 330 | + | registerProtocol(t, pc.c, l) | |
| 331 | + | if keyboard != 0 { | |
| 332 | + | requestProtocol(t, pc.c, l, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, keyboard) | |
| 333 | + | } | |
| 334 | + | if err := pc.c.Roundtrip(); err != nil { | |
| 335 | + | t.Fatal(err) | |
| 336 | + | } | |
| 337 | + | return &hud{privateClient: pc, surface: surf, layer: l} | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // attach sends attach_surface and returns the attachment's event stream. | |
| 341 | + | func (hd *hud) attach(t *testing.T, token string) chan sessionMsg { | |
| 342 | + | t.Helper() | |
| 343 | + | return hd.attachSurface(t, token, hd.surface) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func (hd *hud) attachSurface(t *testing.T, token string, surface uint32) chan sessionMsg { | |
| 347 | + | t.Helper() | |
| 348 | + | id := hd.c.AllocateID() | |
| 349 | + | ev := &attachEvents{msgs: make(chan sessionMsg, 8)} | |
| 350 | + | ev.SetID(id) | |
| 351 | + | hd.c.Context().Register(ev) | |
| 352 | + | requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, id, token, surface) | |
| 353 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 354 | + | t.Fatal(err) | |
| 355 | + | } | |
| 356 | + | return ev.msgs | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // expectAttachment waits for one attachment event of the given opcode and first argument. | |
| 360 | + | func expectAttachment(t *testing.T, c *wlturbo.Display, ch chan sessionMsg, op uint32, arg ...int64) { | |
| 361 | + | t.Helper() | |
| 362 | + | m := waitMsg(t, c, ch) | |
| 363 | + | if uint32(m.op) != op || (len(arg) > 0 && m.numbers[0] != arg[0]) { | |
| 364 | + | t.Fatalf("attachment event %+v, want op %d %v", m, op, arg) | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | // Attach works from another connection with the token, reaches core before | |
| 369 | + | // any content, and is confirmed to the client only once core lists the layer. | |
| 370 | + | func TestPrivateCaptureAttach(t *testing.T) { | |
| 371 | + | h := newPrivateHarness(t) | |
| 372 | + | owner := h.client(t) | |
| 373 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 374 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 375 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Rect: ports.Rect{W: 2, H: 2}, Active: true, Revision: 1}) | |
| 376 | + | token := waitMsg(t, owner.c, msgs).token | |
| 377 | + | ||
| 378 | + | hd := h.hud(t, ports.LayerOverlay, 2) | |
| 379 | + | // A wrong token fails that attachment without touching the session. | |
| 380 | + | bad := hd.attach(t, strings.Repeat("0", 64)) | |
| 381 | + | expectAttachment(t, hd.c, bad, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureUnknownToken)) | |
| 382 | + | good := hd.attach(t, token) | |
| 383 | + | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | |
| 384 | + | if layer.ID != begin.ID || !layer.Attached || layer.Layer == 0 { | |
| 385 | + | t.Fatalf("layer %+v", layer) | |
| 386 | + | } | |
| 387 | + | noMessage(t, hd.c, good) // not confirmed yet | |
| 388 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2}) | |
| 389 | + | noMessage(t, hd.c, good) // a state that does not list the layer confirms nothing | |
| 390 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 3, Layers: []ports.WindowID{layer.Layer}}) | |
| 391 | + | expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventAttached) | |
| 392 | + | // Attaching the same surface again fails on its own object. | |
| 393 | + | again := hd.attach(t, token) | |
| 394 | + | expectAttachment(t, hd.c, again, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureAlreadyAttached)) | |
| 395 | + | // Destroying the layer detaches it: LayerChanged (the unmap) first, then | |
| 396 | + | // the detach, on the same ordered channel, so core never drops the | |
| 397 | + | // exclusion before the layer is gone. | |
| 398 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy) | |
| 399 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 400 | + | t.Fatal(err) | |
| 401 | + | } | |
| 402 | + | expectAttachment(t, hd.c, good, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed)) | |
| 403 | + | if l := nextEvent[ports.CaptureSessionLayer](t, h.events); l.Attached || l.Layer != layer.Layer { | |
| 404 | + | t.Fatalf("detach %+v", l) | |
| 405 | + | } | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | // Order on the event channel: a mapped attached layer that is destroyed | |
| 409 | + | // queues its LayerChanged removal before the detach reaches core. | |
| 410 | + | func TestPrivateCaptureDetachOrdering(t *testing.T) { | |
| 411 | + | h := newPrivateHarness(t) | |
| 412 | + | owner := h.client(t) | |
| 413 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 414 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 415 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 416 | + | token := waitMsg(t, owner.c, msgs).token | |
| 417 | + | hd := h.hud(t, ports.LayerTop, 0) | |
| 418 | + | att := hd.attach(t, token) | |
| 419 | + | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | |
| 420 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | |
| 421 | + | expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached) | |
| 422 | + | mapLayer(t, hd) | |
| 423 | + | if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 { | |
| 424 | + | t.Fatalf("mapped %+v", l) | |
| 425 | + | } | |
| 426 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestDestroy) | |
| 427 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 428 | + | t.Fatal(err) | |
| 429 | + | } | |
| 430 | + | var order []string | |
| 431 | + | for len(order) < 2 { | |
| 432 | + | switch v := (<-h.events).(type) { | |
| 433 | + | case ports.LayerChanged: | |
| 434 | + | if len(v.Layers) == 0 { | |
| 435 | + | order = append(order, "unmap") | |
| 436 | + | } | |
| 437 | + | case ports.CaptureSessionLayer: | |
| 438 | + | if !v.Attached { | |
| 439 | + | order = append(order, "detach") | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | if order[0] != "unmap" || order[1] != "detach" { | |
| 444 | + | t.Fatalf("order %v: the exclusion must outlive the layer's last frame", order) | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | // mapLayer configures, acks and commits a buffer on the hud's layer. | |
| 449 | + | func mapLayer(t *testing.T, hd *hud) { | |
| 450 | + | t.Helper() | |
| 451 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(1), uint32(1)) | |
| 452 | + | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | |
| 453 | + | cfg := &layerProxy{configured: make(chan [3]uint32, 4)} | |
| 454 | + | cfg.SetID(hd.layer) | |
| 455 | + | hd.c.Context().Register(cfg) | |
| 456 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 457 | + | t.Fatal(err) | |
| 458 | + | } | |
| 459 | + | c := <-cfg.configured | |
| 460 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestAckConfigure, c[0]) | |
| 461 | + | _, buf, fd := captureSmallBuffer(t, hd.c) | |
| 462 | + | t.Cleanup(func() { _ = unix.Close(fd) }) | |
| 463 | + | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestAttach, buf, int32(0), int32(0)) | |
| 464 | + | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | |
| 465 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 466 | + | t.Fatal(err) | |
| 467 | + | } | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | // Stopping the session detaches every attachment, closes the layer surfaces | |
| 471 | + | // (so none lingers into the next session) and removes them before core is told. | |
| 472 | + | func TestPrivateCaptureStopClosesLayers(t *testing.T) { | |
| 473 | + | h := newPrivateHarness(t) | |
| 474 | + | owner := h.client(t) | |
| 475 | + | sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 476 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 477 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 478 | + | token := waitMsg(t, owner.c, msgs).token | |
| 479 | + | hd := h.hud(t, ports.LayerOverlay, 0) | |
| 480 | + | closed := make(chan sessionMsg, 4) | |
| 481 | + | lp := &layerClosedProxy{msgs: closed} | |
| 482 | + | lp.SetID(hd.layer) | |
| 483 | + | hd.c.Context().Register(lp) | |
| 484 | + | confirmed := hd.attach(t, token) | |
| 485 | + | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | |
| 486 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | |
| 487 | + | expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventAttached) | |
| 488 | + | pending := hd.attachSurfaceNew(t, token) | |
| 489 | + | _ = pending | |
| 490 | + | requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy) | |
| 491 | + | if err := owner.c.Roundtrip(); err != nil { | |
| 492 | + | t.Fatal(err) | |
| 493 | + | } | |
| 494 | + | expectAttachment(t, hd.c, confirmed, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonSessionEnded)) | |
| 495 | + | if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) { | |
| 496 | + | t.Fatalf("layer event %+v", m) | |
| 497 | + | } | |
| 498 | + | if e := nextEvent[ports.CaptureSessionEnd](t, h.events); e.ID != begin.ID { | |
| 499 | + | t.Fatalf("end %+v", e) | |
| 500 | + | } | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | type layerClosedProxy struct { | |
| 504 | + | wlturbo.BaseProxy | |
| 505 | + | msgs chan sessionMsg | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | func (p *layerClosedProxy) Dispatch(e *wlturbo.Event) { | |
| 509 | + | if e.Opcode == uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) { | |
| 510 | + | p.msgs <- sessionMsg{op: e.Opcode} | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | // attachSurfaceNew attaches a second, fresh layer surface of the hud and returns its stream. | |
| 515 | + | func (hd *hud) attachSurfaceNew(t *testing.T, token string) chan sessionMsg { | |
| 516 | + | t.Helper() | |
| 517 | + | comp := bindProtocol(t, hd.c, "wl_compositor") | |
| 518 | + | shell := bindProtocol(t, hd.c, "zwlr_layer_shell_v1") | |
| 519 | + | surf, l := hd.c.AllocateID(), hd.c.AllocateID() | |
| 520 | + | requestProtocol(t, hd.c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 521 | + | registerProtocol(t, hd.c, surf) | |
| 522 | + | requestProtocol(t, hd.c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, l, surf, hd.output, uint32(ports.LayerTop), "hud2") | |
| 523 | + | registerProtocol(t, hd.c, l) | |
| 524 | + | return hd.attachSurface(t, token, surf) | |
| 525 | + | } | |
| 526 | + | ||
| 527 | + | func TestPrivateCaptureAttachRefusals(t *testing.T) { | |
| 528 | + | for _, tc := range []struct { | |
| 529 | + | name string | |
| 530 | + | layer ports.Layer | |
| 531 | + | keyboard uint32 | |
| 532 | + | code uint32 | |
| 533 | + | }{ | |
| 534 | + | {"background layer", ports.LayerBackground, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)}, | |
| 535 | + | {"bottom layer", ports.LayerBottom, 0, uint32(capturesession.NeferwlCaptureManagerV1ErrorInvalidLayer)}, | |
| 536 | + | {"exclusive keyboard", ports.LayerTop, 1, uint32(capturesession.NeferwlCaptureManagerV1ErrorExclusiveKeyboard)}, | |
| 537 | + | } { | |
| 538 | + | t.Run(tc.name, func(t *testing.T) { | |
| 539 | + | h := newPrivateHarness(t) | |
| 540 | + | owner := h.client(t) | |
| 541 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 542 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 543 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 544 | + | token := waitMsg(t, owner.c, msgs).token | |
| 545 | + | hd := h.hud(t, tc.layer, tc.keyboard) | |
| 546 | + | requestProtocol(t, hd.c, hd.manager, capturesession.NeferwlCaptureManagerV1RequestAttachSurface, hd.c.AllocateID(), token, hd.surface) | |
| 547 | + | expectProtocolError(t, hd.c, hd.manager, tc.code) | |
| 548 | + | select { | |
| 549 | + | case ev := <-h.events: | |
| 550 | + | if _, ok := ev.(ports.CaptureSessionLayer); ok { | |
| 551 | + | t.Fatalf("a refused attach reached core: %+v", ev) | |
| 552 | + | } | |
| 553 | + | default: | |
| 554 | + | } | |
| 555 | + | }) | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func TestPrivateCaptureAttachLimit(t *testing.T) { | |
| 560 | + | h := newPrivateHarness(t) | |
| 561 | + | owner := h.client(t) | |
| 562 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 563 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 564 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 565 | + | token := waitMsg(t, owner.c, msgs).token | |
| 566 | + | hd := h.hud(t, ports.LayerTop, 0) | |
| 567 | + | hd.attach(t, token) | |
| 568 | + | for i := 1; i < ports.MaxCaptureSessionLayers; i++ { | |
| 569 | + | hd.attachSurfaceNew(t, token) | |
| 570 | + | } | |
| 571 | + | over := hd.attachSurfaceNew(t, token) | |
| 572 | + | expectAttachment(t, hd.c, over, capturesession.NeferwlCaptureLayerV1EventFailed, int64(capturesession.NeferwlCaptureLayerV1FailureTooManyLayers)) | |
| 573 | + | n := 0 | |
| 574 | + | for len(h.events) > 0 { | |
| 575 | + | if l, ok := (<-h.events).(ports.CaptureSessionLayer); ok && l.Attached { | |
| 576 | + | n++ | |
| 577 | + | } | |
| 578 | + | } | |
| 579 | + | if n != ports.MaxCaptureSessionLayers { | |
| 580 | + | t.Fatalf("%d layers reached core, want %d", n, ports.MaxCaptureSessionLayers) | |
| 581 | + | } | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | func TestPrivateCaptureWorkspaceInventory(t *testing.T) { | |
| 585 | + | h := newPrivateHarness(t) | |
| 586 | + | pc := h.client(t) | |
| 587 | + | h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 1<<32 | 3, Name: "dev", Active: true, Frame: ports.Rect{X: 5, Y: 6, W: 7, H: 8}}, {ID: 4, Name: "2"}}}}} | |
| 588 | + | seen := map[uint64]sessionMsg{} | |
| 589 | + | for len(seen) < 2 { | |
| 590 | + | m := waitMsg(t, pc.c, pc.mgr.msgs) | |
| 591 | + | if uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspace { | |
| 592 | + | t.Fatalf("event %+v", m) | |
| 593 | + | } | |
| 594 | + | seen[uint64(m.numbers[0])<<32|uint64(m.numbers[1])] = m | |
| 595 | + | } | |
| 596 | + | if m := seen[1<<32|3]; m.name != "dev" || m.numbers[2] != 5 || m.numbers[3] != 6 || m.numbers[4] != 7 || m.numbers[5] != 8 || m.numbers[6] != 1 { | |
| 597 | + | t.Fatalf("workspace %+v", m) | |
| 598 | + | } | |
| 599 | + | h.workspaces <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 4, Name: "2"}}}}} | |
| 600 | + | if m := waitMsg(t, pc.c, pc.mgr.msgs); uint32(m.op) != capturesession.NeferwlCaptureManagerV1EventWorkspaceRemoved || m.numbers[0] != 1 || m.numbers[1] != 3 { | |
| 601 | + | t.Fatalf("removed %+v", m) | |
| 602 | + | } | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | // captureOnce asks a wlr capture of the whole 2x2 output on client a. | |
| 606 | + | func captureOnce(t *testing.T, h *privateHarness, pc *privateClient) (*captureEvents, uint32) { | |
| 607 | + | t.Helper() | |
| 608 | + | _, buf, fd := captureSmallBuffer(t, pc.c) | |
| 609 | + | t.Cleanup(func() { _ = unix.Close(fd) }) | |
| 610 | + | mgr := bindVersion(t, pc.c, "zwlr_screencopy_manager_v1", 3) | |
| 611 | + | frame := pc.c.AllocateID() | |
| 612 | + | ev := &captureEvents{events: make(chan uint16, 16)} | |
| 613 | + | ev.SetID(frame) | |
| 614 | + | pc.c.Context().Register(ev) | |
| 615 | + | requestProtocol(t, pc.c, mgr, wlr.ZwlrScreencopyManagerV1RequestCaptureOutput, frame, int32(0), pc.output) | |
| 616 | + | requestProtocol(t, pc.c, frame, wlr.ZwlrScreencopyFrameV1RequestCopy, buf) | |
| 617 | + | if err := pc.c.Roundtrip(); err != nil { | |
| 618 | + | t.Fatal(err) | |
| 619 | + | } | |
| 620 | + | return ev, frame | |
| 621 | + | } | |
| 622 | + | ||
| 623 | + | // The owner's captures are tagged clean and fenced by the confirmed | |
| 624 | + | // revision; before confirmation or while paused they are refused, not served | |
| 625 | + | // with the HUD in them. Every other connection is untouched. | |
| 626 | + | func TestPrivateCaptureCleanTagging(t *testing.T) { | |
| 627 | + | h := newPrivateHarness(t) | |
| 628 | + | owner, other := h.client(t), h.client(t) | |
| 629 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 630 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 631 | + | ||
| 632 | + | // Unconfirmed: the owner's capture fails and never reaches the renderer. | |
| 633 | + | ev, _ := captureOnce(t, h, owner) | |
| 634 | + | if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) { | |
| 635 | + | t.Fatal("the capture of an unconfirmed session did not fail") | |
| 636 | + | } | |
| 637 | + | select { | |
| 638 | + | case r := <-h.captures: | |
| 639 | + | _ = r.Dst.File.Close() | |
| 640 | + | t.Fatalf("an unconfirmed session's capture reached the renderer: %+v", r) | |
| 641 | + | default: | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 9}) | |
| 645 | + | waitMsg(t, owner.c, msgs) | |
| 646 | + | captureOnce(t, h, owner) | |
| 647 | + | r := receive1(t, h.captures) | |
| 648 | + | if !r.Clean || r.Session != begin.ID || r.CaptureRevision != 9 { | |
| 649 | + | t.Fatalf("owner capture %+v", r) | |
| 650 | + | } | |
| 651 | + | _ = r.Dst.File.Close() | |
| 652 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | |
| 653 | + | ||
| 654 | + | captureOnce(t, h, other) | |
| 655 | + | r = receive1(t, h.captures) | |
| 656 | + | if r.Clean || r.Session != 0 || r.CaptureRevision != 0 { | |
| 657 | + | t.Fatalf("another connection's capture tagged: %+v", r) | |
| 658 | + | } | |
| 659 | + | _ = r.Dst.File.Close() | |
| 660 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | |
| 661 | + | ||
| 662 | + | // Paused: the owner is refused again. | |
| 663 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: false, Revision: 10}) | |
| 664 | + | waitMsg(t, owner.c, msgs) | |
| 665 | + | ev, _ = captureOnce(t, h, owner) | |
| 666 | + | if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) { | |
| 667 | + | t.Fatal("the capture of a paused session did not fail") | |
| 668 | + | } | |
| 669 | + | select { | |
| 670 | + | case r := <-h.captures: | |
| 671 | + | _ = r.Dst.File.Close() | |
| 672 | + | t.Fatalf("a paused session's capture reached the renderer: %+v", r) | |
| 673 | + | default: | |
| 674 | + | } | |
| 675 | + | } | |
| 676 | + | ||
| 677 | + | // eventsInclude drains the frame events received so far and reports op among them. | |
| 678 | + | func eventsInclude(ev *captureEvents, op uint16) bool { | |
| 679 | + | found := false | |
| 680 | + | for len(ev.events) > 0 { | |
| 681 | + | if <-ev.events == op { | |
| 682 | + | found = true | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | return found | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func receive1(t *testing.T, ch chan ports.CaptureRequest) ports.CaptureRequest { | |
| 689 | + | t.Helper() | |
| 690 | + | select { | |
| 691 | + | case r := <-ch: | |
| 692 | + | return r | |
| 693 | + | case <-time.After(2 * time.Second): | |
| 694 | + | t.Fatal("no capture request") | |
| 695 | + | return ports.CaptureRequest{} | |
| 696 | + | } | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | // After the session ended and until the owner destroys the object, its | |
| 700 | + | // captures of the output are refused, never served unfiltered; another | |
| 701 | + | // connection is unaffected. | |
| 702 | + | func TestPrivateCaptureOwnerRefusedAfterStop(t *testing.T) { | |
| 703 | + | h := newPrivateHarness(t) | |
| 704 | + | owner, other := h.client(t), h.client(t) | |
| 705 | + | sid, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 706 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 707 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 708 | + | waitMsg(t, owner.c, msgs) | |
| 709 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Reason: ports.CaptureReasonOutputOff}) | |
| 710 | + | if m := waitMsg(t, owner.c, msgs); uint32(m.op) != capturesession.NeferwlCaptureSessionV1EventStopped { | |
| 711 | + | t.Fatalf("stopped: %+v", m) | |
| 712 | + | } | |
| 713 | + | ev, _ := captureOnce(t, h, owner) | |
| 714 | + | if !eventsInclude(ev, uint16(wlr.ZwlrScreencopyFrameV1EventFailed)) { | |
| 715 | + | t.Fatal("the owner's capture after the stop did not fail") | |
| 716 | + | } | |
| 717 | + | select { | |
| 718 | + | case r := <-h.captures: | |
| 719 | + | _ = r.Dst.File.Close() | |
| 720 | + | t.Fatalf("a stopped session's capture reached the renderer: %+v", r) | |
| 721 | + | default: | |
| 722 | + | } | |
| 723 | + | captureOnce(t, h, other) | |
| 724 | + | r := receive1(t, h.captures) | |
| 725 | + | if r.Clean { | |
| 726 | + | t.Fatalf("another connection's capture is clean: %+v", r) | |
| 727 | + | } | |
| 728 | + | _ = r.Dst.File.Close() | |
| 729 | + | h.captured <- ports.CaptureDone{ID: r.ID, Output: r.Output, Time: time.Now()} | |
| 730 | + | // Destroying the object lifts the refusal. | |
| 731 | + | requestProtocol(t, owner.c, sid, capturesession.NeferwlCaptureSessionV1RequestDestroy) | |
| 732 | + | if err := owner.c.Roundtrip(); err != nil { | |
| 733 | + | t.Fatal(err) | |
| 734 | + | } | |
| 735 | + | captureOnce(t, h, owner) | |
| 736 | + | r = receive1(t, h.captures) | |
| 737 | + | if r.Clean { | |
| 738 | + | t.Fatalf("capture after destroy: %+v", r) | |
| 739 | + | } | |
| 740 | + | _ = r.Dst.File.Close() | |
| 741 | + | } | |
| 742 | + | ||
| 743 | + | // An attached layer that commits a layer below top is closed and detached | |
| 744 | + | // once, and its unmap reaches core before the detach. | |
| 745 | + | func TestPrivateCaptureLayerMovedBelowTop(t *testing.T) { | |
| 746 | + | h := newPrivateHarness(t) | |
| 747 | + | owner := h.client(t) | |
| 748 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 749 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 750 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 751 | + | token := waitMsg(t, owner.c, msgs).token | |
| 752 | + | hd := h.hudVersion(t, ports.LayerTop, 0, 2) | |
| 753 | + | att := hd.attach(t, token) | |
| 754 | + | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | |
| 755 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | |
| 756 | + | expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached) | |
| 757 | + | mapLayer(t, hd) | |
| 758 | + | if l := nextEvent[ports.LayerChanged](t, h.events); len(l.Layers) != 1 { | |
| 759 | + | t.Fatalf("mapped %+v", l) | |
| 760 | + | } | |
| 761 | + | closed := make(chan sessionMsg, 4) | |
| 762 | + | lp := &layerClosedProxy{msgs: closed} | |
| 763 | + | lp.SetID(hd.layer) | |
| 764 | + | hd.c.Context().Register(lp) | |
| 765 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetLayer, uint32(ports.LayerBottom)) | |
| 766 | + | requestProtocol(t, hd.c, hd.surface, wayland.SurfaceRequestCommit) | |
| 767 | + | if err := hd.c.Roundtrip(); err != nil { | |
| 768 | + | t.Fatal(err) | |
| 769 | + | } | |
| 770 | + | expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventDetached, int64(capturesession.NeferwlCaptureLayerV1DetachReasonLayerDestroyed)) | |
| 771 | + | if m := waitMsg(t, hd.c, closed); uint32(m.op) != uint32(wlrlayershell.ZwlrLayerSurfaceV1EventClosed) { | |
| 772 | + | t.Fatalf("layer event %+v", m) | |
| 773 | + | } | |
| 774 | + | noMessage(t, hd.c, att) // detached exactly once | |
| 775 | + | var order []string | |
| 776 | + | for len(order) < 2 { | |
| 777 | + | switch v := (<-h.events).(type) { | |
| 778 | + | case ports.LayerChanged: | |
| 779 | + | if len(v.Layers) == 0 { | |
| 780 | + | order = append(order, "unmap") | |
| 781 | + | } | |
| 782 | + | case ports.CaptureSessionLayer: | |
| 783 | + | if !v.Attached { | |
| 784 | + | order = append(order, "detach") | |
| 785 | + | } | |
| 786 | + | } | |
| 787 | + | } | |
| 788 | + | if order[0] != "unmap" || order[1] != "detach" { | |
| 789 | + | t.Fatalf("order %v", order) | |
| 790 | + | } | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | // An attached layer asking for keyboard interactivity, before or after the | |
| 794 | + | // attach, is never reported with it to core. | |
| 795 | + | func TestPrivateCaptureLayerKeyboardNone(t *testing.T) { | |
| 796 | + | h := newPrivateHarness(t) | |
| 797 | + | owner := h.client(t) | |
| 798 | + | _, msgs := owner.begin(t, 0, 0, 0, 0, 0, 0) | |
| 799 | + | begin := nextEvent[ports.CaptureSessionBegin](t, h.events) | |
| 800 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 1}) | |
| 801 | + | token := waitMsg(t, owner.c, msgs).token | |
| 802 | + | hd := h.hud(t, ports.LayerOverlay, 2) // on demand before the attach | |
| 803 | + | att := hd.attach(t, token) | |
| 804 | + | layer := nextEvent[ports.CaptureSessionLayer](t, h.events) | |
| 805 | + | h.send(ports.CaptureSessionState{ID: begin.ID, Output: "HEADLESS-1", Active: true, Revision: 2, Layers: []ports.WindowID{layer.Layer}}) | |
| 806 | + | expectAttachment(t, hd.c, att, capturesession.NeferwlCaptureLayerV1EventAttached) | |
| 807 | + | requestProtocol(t, hd.c, hd.layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetKeyboardInteractivity, uint32(2)) // and after | |
| 808 | + | mapLayer(t, hd) | |
| 809 | + | l := nextEvent[ports.LayerChanged](t, h.events) | |
| 810 | + | if len(l.Layers) != 1 || l.Layers[0].Keyboard != 0 { | |
| 811 | + | t.Fatalf("layer reported with keyboard: %+v", l.Layers) | |
| 812 | + | } | |
| 813 | + | } | |
A internal/adapters/wayland/capturesession/capturesession.go +296 -0
| @@ -0,0 +1,296 @@ | |||
| 1 | + | // Code generated by wlgen; DO NOT EDIT. | |
| 2 | + | package capturesession | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | wayland "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 7 | + | "github.com/bnema/purego-libwayland/server" | |
| 8 | + | "runtime" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var NeferwlCaptureManagerV1Interface *server.Interface | |
| 12 | + | var NeferwlCaptureLayerV1Interface *server.Interface | |
| 13 | + | var NeferwlCaptureSessionV1Interface *server.Interface | |
| 14 | + | ||
| 15 | + | func init() { | |
| 16 | + | NeferwlCaptureManagerV1Interface = &server.Interface{Name: "neferwl_capture_manager_v1", Version: 1} | |
| 17 | + | NeferwlCaptureLayerV1Interface = &server.Interface{Name: "neferwl_capture_layer_v1", Version: 1} | |
| 18 | + | NeferwlCaptureSessionV1Interface = &server.Interface{Name: "neferwl_capture_session_v1", Version: 1} | |
| 19 | + | NeferwlCaptureManagerV1Interface.Requests = []server.Message{ | |
| 20 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 21 | + | {Name: "begin_session", Signature: "noiiiiuuu", Types: []*server.Interface{NeferwlCaptureSessionV1Interface, wayland.OutputInterface, nil, nil, nil, nil, nil, nil, nil}}, | |
| 22 | + | {Name: "attach_surface", Signature: "nso", Types: []*server.Interface{NeferwlCaptureLayerV1Interface, nil, wayland.SurfaceInterface}}, | |
| 23 | + | } | |
| 24 | + | NeferwlCaptureManagerV1Interface.Events = []server.Message{ | |
| 25 | + | {Name: "workspace", Signature: "uussiiiiu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil}}, | |
| 26 | + | {Name: "workspace_removed", Signature: "uu", Types: []*server.Interface{nil, nil}}, | |
| 27 | + | } | |
| 28 | + | NeferwlCaptureLayerV1Interface.Requests = []server.Message{ | |
| 29 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 30 | + | } | |
| 31 | + | NeferwlCaptureLayerV1Interface.Events = []server.Message{ | |
| 32 | + | {Name: "attached", Signature: "", Types: []*server.Interface{}}, | |
| 33 | + | {Name: "failed", Signature: "u", Types: []*server.Interface{nil}}, | |
| 34 | + | {Name: "detached", Signature: "u", Types: []*server.Interface{nil}}, | |
| 35 | + | } | |
| 36 | + | NeferwlCaptureSessionV1Interface.Requests = []server.Message{ | |
| 37 | + | {Name: "destroy", Signature: "", Types: []*server.Interface{}}, | |
| 38 | + | {Name: "ping", Signature: "", Types: []*server.Interface{}}, | |
| 39 | + | } | |
| 40 | + | NeferwlCaptureSessionV1Interface.Events = []server.Message{ | |
| 41 | + | {Name: "state", Signature: "suiiiiuuuu", Types: []*server.Interface{nil, nil, nil, nil, nil, nil, nil, nil, nil, nil}}, | |
| 42 | + | {Name: "stopped", Signature: "u", Types: []*server.Interface{nil}}, | |
| 43 | + | } | |
| 44 | + | if err := server.NewInterfaces(NeferwlCaptureManagerV1Interface, NeferwlCaptureLayerV1Interface, NeferwlCaptureSessionV1Interface); err != nil { | |
| 45 | + | panic(err) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // NeferwlCaptureManagerV1 private capture manager. | |
| 50 | + | type NeferwlCaptureManagerV1 struct{ *server.Resource } | |
| 51 | + | ||
| 52 | + | func WrapNeferwlCaptureManagerV1(r *server.Resource) *NeferwlCaptureManagerV1 { | |
| 53 | + | if r == nil { | |
| 54 | + | return nil | |
| 55 | + | } | |
| 56 | + | return &NeferwlCaptureManagerV1{r} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const NeferwlCaptureManagerV1RequestDestroy uint32 = 0 | |
| 60 | + | const NeferwlCaptureManagerV1RequestBeginSession uint32 = 1 | |
| 61 | + | const NeferwlCaptureManagerV1RequestAttachSurface uint32 = 2 | |
| 62 | + | const NeferwlCaptureManagerV1EventWorkspace uint32 = 0 | |
| 63 | + | const NeferwlCaptureManagerV1EventWorkspaceRemoved uint32 = 1 | |
| 64 | + | ||
| 65 | + | // NeferwlCaptureManagerV1Error . | |
| 66 | + | type NeferwlCaptureManagerV1Error uint32 | |
| 67 | + | ||
| 68 | + | const ( | |
| 69 | + | NeferwlCaptureManagerV1ErrorInvalidOutput NeferwlCaptureManagerV1Error = 0 | |
| 70 | + | NeferwlCaptureManagerV1ErrorInvalidRegion NeferwlCaptureManagerV1Error = 1 | |
| 71 | + | NeferwlCaptureManagerV1ErrorSurfaceRole NeferwlCaptureManagerV1Error = 2 | |
| 72 | + | NeferwlCaptureManagerV1ErrorInvalidLayer NeferwlCaptureManagerV1Error = 3 | |
| 73 | + | NeferwlCaptureManagerV1ErrorExclusiveKeyboard NeferwlCaptureManagerV1Error = 4 | |
| 74 | + | NeferwlCaptureManagerV1ErrorInvalidSurface NeferwlCaptureManagerV1Error = 5 | |
| 75 | + | ) | |
| 76 | + | ||
| 77 | + | // NeferwlCaptureManagerV1Handler handles neferwl_capture_manager_v1 requests. | |
| 78 | + | type NeferwlCaptureManagerV1Handler interface { | |
| 79 | + | Destroy(self *NeferwlCaptureManagerV1) | |
| 80 | + | BeginSession(self *NeferwlCaptureManagerV1, id uint32, output *wayland.Output, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, record uint32) | |
| 81 | + | AttachSurface(self *NeferwlCaptureManagerV1, id uint32, token string, surface *wayland.Surface) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func NewNeferwlCaptureManagerV1(c server.Client, version int32, id uint32, h NeferwlCaptureManagerV1Handler) (*NeferwlCaptureManagerV1, error) { | |
| 85 | + | if h == nil { | |
| 86 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_manager_v1") | |
| 87 | + | } | |
| 88 | + | var self *NeferwlCaptureManagerV1 | |
| 89 | + | r, err := c.CreateResource(NeferwlCaptureManagerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 90 | + | switch opcode { | |
| 91 | + | case 0: | |
| 92 | + | h.Destroy(self) | |
| 93 | + | self.Destroy() | |
| 94 | + | case 1: | |
| 95 | + | h.BeginSession(self, args[0].NewID(), wayland.WrapOutput(args[1].Resource()), args[2].Int(), args[3].Int(), args[4].Int(), args[5].Int(), args[6].Uint(), args[7].Uint(), args[8].Uint()) | |
| 96 | + | case 2: | |
| 97 | + | h.AttachSurface(self, args[0].NewID(), args[1].String(), wayland.WrapSurface(args[2].Resource())) | |
| 98 | + | } | |
| 99 | + | }) | |
| 100 | + | if err != nil { | |
| 101 | + | return nil, err | |
| 102 | + | } | |
| 103 | + | self = WrapNeferwlCaptureManagerV1(r) | |
| 104 | + | r.Data = self | |
| 105 | + | return self, nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // SendWorkspace a workspace and its geometry. | |
| 109 | + | func (self *NeferwlCaptureManagerV1) SendWorkspace(workspaceHi uint32, workspaceLo uint32, outputName string, name string, x int32, y int32, width int32, height int32, active uint32) { | |
| 110 | + | var p runtime.Pinner | |
| 111 | + | defer p.Unpin() | |
| 112 | + | self.PostEvent(NeferwlCaptureManagerV1EventWorkspace, server.Uint(workspaceHi), server.Uint(workspaceLo), server.String(&p, outputName, false), server.String(&p, name, false), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(active)) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // SendWorkspaceRemoved a workspace is gone. | |
| 116 | + | func (self *NeferwlCaptureManagerV1) SendWorkspaceRemoved(workspaceHi uint32, workspaceLo uint32) { | |
| 117 | + | self.PostEvent(NeferwlCaptureManagerV1EventWorkspaceRemoved, server.Uint(workspaceHi), server.Uint(workspaceLo)) | |
| 118 | + | } | |
| 119 | + | func NewNeferwlCaptureManagerV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 120 | + | return d.CreateGlobal(NeferwlCaptureManagerV1Interface, version, bind) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // NeferwlCaptureLayerV1 a layer surface attached to a capture session. | |
| 124 | + | type NeferwlCaptureLayerV1 struct{ *server.Resource } | |
| 125 | + | ||
| 126 | + | func WrapNeferwlCaptureLayerV1(r *server.Resource) *NeferwlCaptureLayerV1 { | |
| 127 | + | if r == nil { | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | return &NeferwlCaptureLayerV1{r} | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | const NeferwlCaptureLayerV1RequestDestroy uint32 = 0 | |
| 134 | + | const NeferwlCaptureLayerV1EventAttached uint32 = 0 | |
| 135 | + | const NeferwlCaptureLayerV1EventFailed uint32 = 1 | |
| 136 | + | const NeferwlCaptureLayerV1EventDetached uint32 = 2 | |
| 137 | + | ||
| 138 | + | // NeferwlCaptureLayerV1Failure . | |
| 139 | + | type NeferwlCaptureLayerV1Failure uint32 | |
| 140 | + | ||
| 141 | + | const ( | |
| 142 | + | NeferwlCaptureLayerV1FailureUnknownToken NeferwlCaptureLayerV1Failure = 0 | |
| 143 | + | NeferwlCaptureLayerV1FailureUnauthorized NeferwlCaptureLayerV1Failure = 1 | |
| 144 | + | NeferwlCaptureLayerV1FailureTooManyLayers NeferwlCaptureLayerV1Failure = 2 | |
| 145 | + | NeferwlCaptureLayerV1FailureAlreadyAttached NeferwlCaptureLayerV1Failure = 3 | |
| 146 | + | NeferwlCaptureLayerV1FailureSessionEnded NeferwlCaptureLayerV1Failure = 4 | |
| 147 | + | NeferwlCaptureLayerV1FailureLayerDestroyed NeferwlCaptureLayerV1Failure = 5 | |
| 148 | + | ) | |
| 149 | + | ||
| 150 | + | // NeferwlCaptureLayerV1DetachReason . | |
| 151 | + | type NeferwlCaptureLayerV1DetachReason uint32 | |
| 152 | + | ||
| 153 | + | const ( | |
| 154 | + | NeferwlCaptureLayerV1DetachReasonSessionEnded NeferwlCaptureLayerV1DetachReason = 0 | |
| 155 | + | NeferwlCaptureLayerV1DetachReasonLayerDestroyed NeferwlCaptureLayerV1DetachReason = 1 | |
| 156 | + | ) | |
| 157 | + | ||
| 158 | + | // NeferwlCaptureLayerV1Handler handles neferwl_capture_layer_v1 requests. | |
| 159 | + | type NeferwlCaptureLayerV1Handler interface { | |
| 160 | + | Destroy(self *NeferwlCaptureLayerV1) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func NewNeferwlCaptureLayerV1(c server.Client, version int32, id uint32, h NeferwlCaptureLayerV1Handler) (*NeferwlCaptureLayerV1, error) { | |
| 164 | + | if h == nil { | |
| 165 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_layer_v1") | |
| 166 | + | } | |
| 167 | + | var self *NeferwlCaptureLayerV1 | |
| 168 | + | r, err := c.CreateResource(NeferwlCaptureLayerV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 169 | + | switch opcode { | |
| 170 | + | case 0: | |
| 171 | + | h.Destroy(self) | |
| 172 | + | self.Destroy() | |
| 173 | + | } | |
| 174 | + | }) | |
| 175 | + | if err != nil { | |
| 176 | + | return nil, err | |
| 177 | + | } | |
| 178 | + | self = WrapNeferwlCaptureLayerV1(r) | |
| 179 | + | r.Data = self | |
| 180 | + | return self, nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // SendAttached the layer is part of the session. | |
| 184 | + | func (self *NeferwlCaptureLayerV1) SendAttached() { | |
| 185 | + | self.PostEvent(NeferwlCaptureLayerV1EventAttached) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // SendFailed the attachment was refused. | |
| 189 | + | func (self *NeferwlCaptureLayerV1) SendFailed(reason uint32) { | |
| 190 | + | self.PostEvent(NeferwlCaptureLayerV1EventFailed, server.Uint(reason)) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // SendDetached the layer left the session. | |
| 194 | + | func (self *NeferwlCaptureLayerV1) SendDetached(reason uint32) { | |
| 195 | + | self.PostEvent(NeferwlCaptureLayerV1EventDetached, server.Uint(reason)) | |
| 196 | + | } | |
| 197 | + | func NewNeferwlCaptureLayerV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 198 | + | return d.CreateGlobal(NeferwlCaptureLayerV1Interface, version, bind) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // NeferwlCaptureSessionV1 one private capture session. | |
| 202 | + | type NeferwlCaptureSessionV1 struct{ *server.Resource } | |
| 203 | + | ||
| 204 | + | func WrapNeferwlCaptureSessionV1(r *server.Resource) *NeferwlCaptureSessionV1 { | |
| 205 | + | if r == nil { | |
| 206 | + | return nil | |
| 207 | + | } | |
| 208 | + | return &NeferwlCaptureSessionV1{r} | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | const NeferwlCaptureSessionV1RequestDestroy uint32 = 0 | |
| 212 | + | const NeferwlCaptureSessionV1RequestPing uint32 = 1 | |
| 213 | + | const NeferwlCaptureSessionV1EventState uint32 = 0 | |
| 214 | + | const NeferwlCaptureSessionV1EventStopped uint32 = 1 | |
| 215 | + | ||
| 216 | + | // NeferwlCaptureSessionV1StopReason . | |
| 217 | + | type NeferwlCaptureSessionV1StopReason uint32 | |
| 218 | + | ||
| 219 | + | const ( | |
| 220 | + | NeferwlCaptureSessionV1StopReasonRequested NeferwlCaptureSessionV1StopReason = 0 | |
| 221 | + | NeferwlCaptureSessionV1StopReasonOutputGone NeferwlCaptureSessionV1StopReason = 1 | |
| 222 | + | NeferwlCaptureSessionV1StopReasonOutputOff NeferwlCaptureSessionV1StopReason = 2 | |
| 223 | + | NeferwlCaptureSessionV1StopReasonPingTimeout NeferwlCaptureSessionV1StopReason = 3 | |
| 224 | + | NeferwlCaptureSessionV1StopReasonWorkspaceGone NeferwlCaptureSessionV1StopReason = 5 | |
| 225 | + | NeferwlCaptureSessionV1StopReasonBusy NeferwlCaptureSessionV1StopReason = 6 | |
| 226 | + | NeferwlCaptureSessionV1StopReasonInvalidRegion NeferwlCaptureSessionV1StopReason = 7 | |
| 227 | + | NeferwlCaptureSessionV1StopReasonUnauthorized NeferwlCaptureSessionV1StopReason = 8 | |
| 228 | + | NeferwlCaptureSessionV1StopReasonTooManyExcluded NeferwlCaptureSessionV1StopReason = 9 | |
| 229 | + | ) | |
| 230 | + | ||
| 231 | + | // NeferwlCaptureSessionV1Handler handles neferwl_capture_session_v1 requests. | |
| 232 | + | type NeferwlCaptureSessionV1Handler interface { | |
| 233 | + | Destroy(self *NeferwlCaptureSessionV1) | |
| 234 | + | Ping(self *NeferwlCaptureSessionV1) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func NewNeferwlCaptureSessionV1(c server.Client, version int32, id uint32, h NeferwlCaptureSessionV1Handler) (*NeferwlCaptureSessionV1, error) { | |
| 238 | + | if h == nil { | |
| 239 | + | return nil, fmt.Errorf("purego-libwayland: nil handler for neferwl_capture_session_v1") | |
| 240 | + | } | |
| 241 | + | var self *NeferwlCaptureSessionV1 | |
| 242 | + | r, err := c.CreateResource(NeferwlCaptureSessionV1Interface, version, id, func(_ *server.Resource, opcode uint32, args []server.Arg) { | |
| 243 | + | switch opcode { | |
| 244 | + | case 0: | |
| 245 | + | h.Destroy(self) | |
| 246 | + | self.Destroy() | |
| 247 | + | case 1: | |
| 248 | + | h.Ping(self) | |
| 249 | + | } | |
| 250 | + | }) | |
| 251 | + | if err != nil { | |
| 252 | + | return nil, err | |
| 253 | + | } | |
| 254 | + | self = WrapNeferwlCaptureSessionV1(r) | |
| 255 | + | r.Data = self | |
| 256 | + | return self, nil | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // SendState what the compositor decided. | |
| 260 | + | func (self *NeferwlCaptureSessionV1) SendState(token string, active uint32, x int32, y int32, width int32, height int32, workspaceHi uint32, workspaceLo uint32, revisionHi uint32, revisionLo uint32) { | |
| 261 | + | var p runtime.Pinner | |
| 262 | + | defer p.Unpin() | |
| 263 | + | self.PostEvent(NeferwlCaptureSessionV1EventState, server.String(&p, token, false), server.Uint(active), server.Int(x), server.Int(y), server.Int(width), server.Int(height), server.Uint(workspaceHi), server.Uint(workspaceLo), server.Uint(revisionHi), server.Uint(revisionLo)) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // SendStopped the session ended. | |
| 267 | + | func (self *NeferwlCaptureSessionV1) SendStopped(reason uint32) { | |
| 268 | + | self.PostEvent(NeferwlCaptureSessionV1EventStopped, server.Uint(reason)) | |
| 269 | + | } | |
| 270 | + | func NewNeferwlCaptureSessionV1Global(d *server.Display, version int32, bind server.BindFunc) error { | |
| 271 | + | return d.CreateGlobal(NeferwlCaptureSessionV1Interface, version, bind) | |
| 272 | + | } | |
| 273 | + | func resourceOf(v interface{ GetResource() *server.Resource }) *server.Resource { | |
| 274 | + | if v == nil { | |
| 275 | + | return nil | |
| 276 | + | } | |
| 277 | + | return v.GetResource() | |
| 278 | + | } | |
| 279 | + | func (self *NeferwlCaptureManagerV1) GetResource() *server.Resource { | |
| 280 | + | if self == nil { | |
| 281 | + | return nil | |
| 282 | + | } | |
| 283 | + | return self.Resource | |
| 284 | + | } | |
| 285 | + | func (self *NeferwlCaptureLayerV1) GetResource() *server.Resource { | |
| 286 | + | if self == nil { | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | return self.Resource | |
| 290 | + | } | |
| 291 | + | func (self *NeferwlCaptureSessionV1) GetResource() *server.Resource { | |
| 292 | + | if self == nil { | |
| 293 | + | return nil | |
| 294 | + | } | |
| 295 | + | return self.Resource | |
| 296 | + | } | |
A internal/adapters/wayland/capturesession/gen.sh +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | #!/bin/sh | |
| 2 | + | # Regenerates capturesession.go from neferwl-capture-v1.xml with the wlgen of | |
| 3 | + | # the purego-libwayland module in use (offline: go.work or the module cache). | |
| 4 | + | set -eu | |
| 5 | + | cd "$(dirname "$0")" | |
| 6 | + | mod=$(go list -m -f '{{.Dir}}' github.com/bnema/purego-libwayland) | |
| 7 | + | go run github.com/bnema/purego-libwayland/cmd/wlgen -package capturesession -out capturesession.go \ | |
| 8 | + | -import wayland=github.com/bnema/purego-libwayland/protocol/wayland -import-xml wayland="$mod/protocols/wayland.xml" \ | |
| 9 | + | neferwl-capture-v1.xml | |
A internal/adapters/wayland/capturesession/neferwl-capture-v1.xml +236 -0
| @@ -0,0 +1,236 @@ | |||
| 1 | + | <?xml version="1.0" encoding="UTF-8"?> | |
| 2 | + | <protocol name="neferwl_capture_v1"> | |
| 3 | + | <copyright> | |
| 4 | + | neferwl private protocol. Not for other compositors; no stability promise | |
| 5 | + | beyond the version of the interface. | |
| 6 | + | </copyright> | |
| 7 | + | ||
| 8 | + | <description summary="private capture sessions for the neferwl screen recorder"> | |
| 9 | + | Lets one client (the capture connection) open a session on a workspace and | |
| 10 | + | lets a second connection of the same user (the HUD) attach layer surfaces | |
| 11 | + | to it. While the session is active, the attached surfaces stay visible | |
| 12 | + | over a fullscreen window and are left out of the captures the capture | |
| 13 | + | connection takes (ext-image-copy-capture / wlr-screencopy on the same | |
| 14 | + | connection). Every other client's captures still show them. | |
| 15 | + | ||
| 16 | + | Only one session is live in the compositor. The session token is a secret: | |
| 17 | + | it is sent only to the session's owner and must never be logged. | |
| 18 | + | Only the compositor's own user may use this protocol. | |
| 19 | + | </description> | |
| 20 | + | ||
| 21 | + | <interface name="neferwl_capture_manager_v1" version="1"> | |
| 22 | + | <description summary="private capture manager"> | |
| 23 | + | Global. Binding it starts the workspace inventory: one workspace event | |
| 24 | + | per workspace, then workspace events again for every change and | |
| 25 | + | workspace_removed for each workspace that disappears. | |
| 26 | + | </description> | |
| 27 | + | ||
| 28 | + | <enum name="error"> | |
| 29 | + | <entry name="invalid_output" value="0" summary="the wl_output is unknown or gone"/> | |
| 30 | + | <entry name="invalid_region" value="1" summary="negative width or height"/> | |
| 31 | + | <entry name="surface_role" value="2" summary="the surface is not a wlr-layer-shell surface"/> | |
| 32 | + | <entry name="invalid_layer" value="3" summary="the layer is neither top nor overlay"/> | |
| 33 | + | <entry name="exclusive_keyboard" value="4" summary="the layer takes the keyboard exclusively"/> | |
| 34 | + | <entry name="invalid_surface" value="5" summary="the surface belongs to another client or is already mapped"/> | |
| 35 | + | </enum> | |
| 36 | + | ||
| 37 | + | <request name="destroy" type="destructor"> | |
| 38 | + | <description summary="destroy the manager"> | |
| 39 | + | Sessions created through it stay alive. | |
| 40 | + | </description> | |
| 41 | + | </request> | |
| 42 | + | ||
| 43 | + | <request name="begin_session"> | |
| 44 | + | <description summary="open a capture session"> | |
| 45 | + | Asks for a session. Workspace 0 (both halves) targets the output: | |
| 46 | + | whatever it shows, bars and margins included, and the overview too. | |
| 47 | + | A non-zero workspace ID (from a workspace event) targets that | |
| 48 | + | workspace's frame, without bars, whether or not it is on screen and | |
| 49 | + | whatever the overview shows. The session is stopped with | |
| 50 | + | workspace_gone when the workspace is removed. | |
| 51 | + | ||
| 52 | + | The region is in output-local logical pixels, clipped to the output | |
| 53 | + | (workspace 0) or to the workspace frame. A zero width or height means | |
| 54 | + | all of it. Record non-zero asks for the native recording border, drawn | |
| 55 | + | inside the target's edge and never in this connection's clean captures. | |
| 56 | + | ||
| 57 | + | A served session answers with state, which carries the token. A | |
| 58 | + | session that cannot be served gets stopped with the reason, without a | |
| 59 | + | state. Only one session is live: a second one is stopped with reason | |
| 60 | + | busy. | |
| 61 | + | </description> | |
| 62 | + | <arg name="id" type="new_id" interface="neferwl_capture_session_v1"/> | |
| 63 | + | <arg name="output" type="object" interface="wl_output"/> | |
| 64 | + | <arg name="x" type="int"/> | |
| 65 | + | <arg name="y" type="int"/> | |
| 66 | + | <arg name="width" type="int"/> | |
| 67 | + | <arg name="height" type="int"/> | |
| 68 | + | <arg name="workspace_hi" type="uint"/> | |
| 69 | + | <arg name="workspace_lo" type="uint"/> | |
| 70 | + | <arg name="record" type="uint"/> | |
| 71 | + | </request> | |
| 72 | + | ||
| 73 | + | <request name="attach_surface"> | |
| 74 | + | <description summary="attach a layer surface to a session by token"> | |
| 75 | + | Marks a layer surface of this client as part of the session that owns | |
| 76 | + | the token, and creates the attachment object that reports the result. | |
| 77 | + | The client is usually not the session's owner but has the same user | |
| 78 | + | ID; both are checked with the peer credentials. The surface must | |
| 79 | + | already have the wlr-layer-shell role, on the top or overlay layer, | |
| 80 | + | must not have mapped yet and must not have asked for exclusive keyboard | |
| 81 | + | interactivity. At most 4 layers per session. Once attached, a layer | |
| 82 | + | never gets keyboard interactivity, whatever it asks for, and if it later moves to another layer the compositor closes it | |
| 83 | + | and sends detached. | |
| 84 | + | ||
| 85 | + | The attachment answers with exactly one of attached or failed. The | |
| 86 | + | surface is excluded from clean captures from this request on, before | |
| 87 | + | it can map; attached only says that the compositor core confirmed it, | |
| 88 | + | and the client must not show the surface before it. A refusal is | |
| 89 | + | failed, not a protocol error, except the errors above. | |
| 90 | + | ||
| 91 | + | The attachment ends with detached when the session stops or the layer | |
| 92 | + | surface is destroyed. Destroying the attachment object does not | |
| 93 | + | release the surface: it stays excluded until its layer role is | |
| 94 | + | destroyed or the session stops. | |
| 95 | + | </description> | |
| 96 | + | <arg name="id" type="new_id" interface="neferwl_capture_layer_v1"/> | |
| 97 | + | <arg name="token" type="string"/> | |
| 98 | + | <arg name="surface" type="object" interface="wl_surface"/> | |
| 99 | + | </request> | |
| 100 | + | ||
| 101 | + | <event name="workspace"> | |
| 102 | + | <description summary="a workspace and its geometry"> | |
| 103 | + | A workspace of the inventory, new or changed. The rectangle is the | |
| 104 | + | workspace frame in output-local logical pixels. Active is non-zero for | |
| 105 | + | the workspace on screen. Name is the workspace name or its number. | |
| 106 | + | </description> | |
| 107 | + | <arg name="workspace_hi" type="uint"/> | |
| 108 | + | <arg name="workspace_lo" type="uint"/> | |
| 109 | + | <arg name="output_name" type="string"/> | |
| 110 | + | <arg name="name" type="string"/> | |
| 111 | + | <arg name="x" type="int"/> | |
| 112 | + | <arg name="y" type="int"/> | |
| 113 | + | <arg name="width" type="int"/> | |
| 114 | + | <arg name="height" type="int"/> | |
| 115 | + | <arg name="active" type="uint"/> | |
| 116 | + | </event> | |
| 117 | + | ||
| 118 | + | <event name="workspace_removed"> | |
| 119 | + | <description summary="a workspace is gone"/> | |
| 120 | + | <arg name="workspace_hi" type="uint"/> | |
| 121 | + | <arg name="workspace_lo" type="uint"/> | |
| 122 | + | </event> | |
| 123 | + | </interface> | |
| 124 | + | ||
| 125 | + | <interface name="neferwl_capture_layer_v1" version="1"> | |
| 126 | + | <description summary="a layer surface attached to a capture session"> | |
| 127 | + | Created by attach_surface. Exactly one of attached or failed comes | |
| 128 | + | first; detached comes at most once, after attached. | |
| 129 | + | </description> | |
| 130 | + | ||
| 131 | + | <enum name="failure"> | |
| 132 | + | <entry name="unknown_token" value="0" summary="no live session has this token"/> | |
| 133 | + | <entry name="unauthorized" value="1" summary="peer credentials differ from the compositor user's or are unreadable"/> | |
| 134 | + | <entry name="too_many_layers" value="2" summary="the session already has the maximum of 4 attached layers"/> | |
| 135 | + | <entry name="already_attached" value="3" summary="the surface is already attached"/> | |
| 136 | + | <entry name="session_ended" value="4" summary="the session stopped before core confirmed the layer"/> | |
| 137 | + | <entry name="layer_destroyed" value="5" summary="the layer role was destroyed before core confirmed it"/> | |
| 138 | + | </enum> | |
| 139 | + | ||
| 140 | + | <enum name="detach_reason"> | |
| 141 | + | <entry name="session_ended" value="0" summary="the session stopped; the compositor closed the layer surface"/> | |
| 142 | + | <entry name="layer_destroyed" value="1" summary="the layer role was destroyed"/> | |
| 143 | + | </enum> | |
| 144 | + | ||
| 145 | + | <request name="destroy" type="destructor"> | |
| 146 | + | <description summary="destroy the attachment object"> | |
| 147 | + | The surface stays excluded from clean captures until its layer role | |
| 148 | + | is destroyed or the session stops. | |
| 149 | + | </description> | |
| 150 | + | </request> | |
| 151 | + | ||
| 152 | + | <event name="attached"> | |
| 153 | + | <description summary="the layer is part of the session"> | |
| 154 | + | The compositor core confirmed the attachment: the layer is excluded | |
| 155 | + | from the session's clean captures, and was already before it could map. | |
| 156 | + | Safe to show. | |
| 157 | + | </description> | |
| 158 | + | </event> | |
| 159 | + | ||
| 160 | + | <event name="failed"> | |
| 161 | + | <description summary="the attachment was refused"/> | |
| 162 | + | <arg name="reason" type="uint" enum="failure"/> | |
| 163 | + | </event> | |
| 164 | + | ||
| 165 | + | <event name="detached"> | |
| 166 | + | <description summary="the layer left the session"> | |
| 167 | + | Sent once after attached. When the session stopped, the compositor | |
| 168 | + | also closed the layer surface (zwlr_layer_surface_v1.closed): the | |
| 169 | + | client destroys it. | |
| 170 | + | </description> | |
| 171 | + | <arg name="reason" type="uint" enum="detach_reason"/> | |
| 172 | + | </event> | |
| 173 | + | </interface> | |
| 174 | + | ||
| 175 | + | <interface name="neferwl_capture_session_v1" version="1"> | |
| 176 | + | <description summary="one private capture session"> | |
| 177 | + | Destroying the session stops it. A session whose owner sends no ping for | |
| 178 | + | 5 seconds is stopped by the compositor. | |
| 179 | + | </description> | |
| 180 | + | ||
| 181 | + | <enum name="stop_reason"> | |
| 182 | + | <entry name="requested" value="0" summary="the client stopped it"/> | |
| 183 | + | <entry name="output_gone" value="1"/> | |
| 184 | + | <entry name="output_off" value="2"/> | |
| 185 | + | <entry name="ping_timeout" value="3"/> | |
| 186 | + | <!-- 4 is reserved: it stays unused so the values of the other reasons do not move --> | |
| 187 | + | <entry name="workspace_gone" value="5"/> | |
| 188 | + | <entry name="busy" value="6" summary="another session is live"/> | |
| 189 | + | <entry name="invalid_region" value="7"/> | |
| 190 | + | <entry name="unauthorized" value="8"/> | |
| 191 | + | <entry name="too_many_excluded" value="9" summary="attached layers and their popups exceed 128 surfaces"/> | |
| 192 | + | </enum> | |
| 193 | + | ||
| 194 | + | <request name="destroy" type="destructor"> | |
| 195 | + | <description summary="stop and destroy the session"/> | |
| 196 | + | </request> | |
| 197 | + | ||
| 198 | + | <request name="ping"> | |
| 199 | + | <description summary="keep the session alive"> | |
| 200 | + | Must arrive at least every 5 seconds. | |
| 201 | + | </description> | |
| 202 | + | </request> | |
| 203 | + | ||
| 204 | + | <event name="state"> | |
| 205 | + | <description summary="what the compositor decided"> | |
| 206 | + | Sent once the session exists and on every change. The token is 64 | |
| 207 | + | lowercase hexadecimal characters (32 random bytes): a secret for the | |
| 208 | + | owner only, never logged, valid until stopped. The rectangle is the | |
| 209 | + | clipped target in output-local logical pixels and the workspace is | |
| 210 | + | the real one (0 for an output target). Active non-zero means captures of this connection are | |
| 211 | + | served clean. Active is 1 while the session lives: a session that is | |
| 212 | + | lost is stopped, never paused. The revision (64 bits, from 1) grows | |
| 213 | + | whenever the session's configuration changes: layers, popups, target. | |
| 214 | + | </description> | |
| 215 | + | <arg name="token" type="string"/> | |
| 216 | + | <arg name="active" type="uint"/> | |
| 217 | + | <arg name="x" type="int"/> | |
| 218 | + | <arg name="y" type="int"/> | |
| 219 | + | <arg name="width" type="int"/> | |
| 220 | + | <arg name="height" type="int"/> | |
| 221 | + | <arg name="workspace_hi" type="uint"/> | |
| 222 | + | <arg name="workspace_lo" type="uint"/> | |
| 223 | + | <arg name="revision_hi" type="uint"/> | |
| 224 | + | <arg name="revision_lo" type="uint"/> | |
| 225 | + | </event> | |
| 226 | + | ||
| 227 | + | <event name="stopped"> | |
| 228 | + | <description summary="the session ended"> | |
| 229 | + | Final. The token is void. The compositor closes the attached layer | |
| 230 | + | surfaces (zwlr_layer_surface_v1.closed) and ends their attachments | |
| 231 | + | with detached. Destroy the object. | |
| 232 | + | </description> | |
| 233 | + | <arg name="reason" type="uint" enum="stop_reason"/> | |
| 234 | + | </event> | |
| 235 | + | </interface> | |
| 236 | + | </protocol> | |
M internal/adapters/wayland/frames.go +15 -1
| @@ -118,18 +118,32 @@ func toplevelRoot(surf *surface) *surface { | |||
| 118 | 118 | return surf | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | + | // invisible reports that no display shows the toplevel tree: the physical | |
| 122 | + | // truth, used for scanout and presentation feedback. A tree rendered only | |
| 123 | + | // for a capture session is invisible. | |
| 121 | 124 | func (s *Server) invisible(surf *surface) bool { | |
| 122 | 125 | root := toplevelRoot(surf) | |
| 123 | 126 | return root.xdg != nil && root.xdg.window != nil && root.xdg.window.hasLast && !root.xdg.window.last.Visible | |
| 124 | 127 | } | |
| 125 | 128 | ||
| 129 | + | // suspended reports that nothing draws the toplevel tree, not even a capture | |
| 130 | + | // session: it is throttled and marked suspended. A captured tree keeps the | |
| 131 | + | // frame callbacks of its output so the capture keeps moving. | |
| 132 | + | func (s *Server) suspended(surf *surface) bool { | |
| 133 | + | root := toplevelRoot(surf) | |
| 134 | + | if root.xdg == nil || root.xdg.window == nil || !root.xdg.window.hasLast { | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | return !root.xdg.window.last.Visible && !root.xdg.window.last.Captured | |
| 138 | + | } | |
| 139 | + | ||
| 126 | 140 | // frameOutput is the pacing class for a surface; "" is the 60 Hz | |
| 127 | 141 | // outputless class, not the invisible toplevel class. | |
| 128 | 142 | func (s *Server) frameOutput(surf *surface) string { | |
| 129 | 143 | surf = toplevelRoot(surf) | |
| 130 | 144 | switch { | |
| 131 | 145 | case surf.xdg != nil && surf.xdg.window != nil: | |
| 132 | - | if s.invisible(surf) { | |
| 146 | + | if s.suspended(surf) { | |
| 133 | 147 | return suspendedFrameQueue | |
| 134 | 148 | } | |
| 135 | 149 | return surf.xdg.window.last.Output | |
M internal/adapters/wayland/frames_test.go +22 -0
| @@ -122,3 +122,25 @@ func TestInvisibleFramePacingAndMigration(t *testing.T) { | |||
| 122 | 122 | t.Fatalf("resume: fire %v queues %+v", fire, s.awaiting) | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 125 | + | ||
| 126 | + | // A window only a capture session draws is physically invisible (no scanout, | |
| 127 | + | // no presentation feedback) but is not throttled: its callbacks stay on its | |
| 128 | + | // output's queue and move when Captured changes. | |
| 129 | + | func TestCapturedWindowKeepsOutputFramePacing(t *testing.T) { | |
| 130 | + | out := &output{place: ports.OutputPlacement{Info: ports.OutputInfo{Name: "DP-2", RefreshMilli: 60000}}} | |
| 131 | + | s := &Server{outputs: []*output{out}, awaiting: map[string][]*wayland.Callback{}, frameDue: map[string]time.Time{}, frameReady: make(chan struct{}, 1)} | |
| 132 | + | w := &window{hasLast: true, last: ports.ConfigureWindow{Output: "DP-2", Visible: true}} | |
| 133 | + | root := &surface{xdg: &xdgSurface{window: w}} | |
| 134 | + | a := new(wayland.Callback) | |
| 135 | + | s.queueFrames(root, []*wayland.Callback{a}) | |
| 136 | + | w.last = ports.ConfigureWindow{Output: "DP-2", Captured: true} | |
| 137 | + | s.relocateCallbacks(root) | |
| 138 | + | if !s.invisible(root) || s.suspended(root) || s.frameOutput(root) != "DP-2" || len(s.awaiting["DP-2"]) != 1 || len(s.awaiting[suspendedFrameQueue]) != 0 { | |
| 139 | + | t.Fatalf("captured: invisible %v suspended %v queues %+v", s.invisible(root), s.suspended(root), s.awaiting) | |
| 140 | + | } | |
| 141 | + | w.last = ports.ConfigureWindow{Output: "DP-2"} | |
| 142 | + | s.relocateCallbacks(root) | |
| 143 | + | if !s.suspended(root) || len(s.awaiting[suspendedFrameQueue]) != 1 || len(s.awaiting["DP-2"]) != 0 { | |
| 144 | + | t.Fatalf("released: queues %+v", s.awaiting) | |
| 145 | + | } | |
| 146 | + | } | |
M internal/adapters/wayland/globals.go +1 -0
| @@ -30,6 +30,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 30 | 30 | func() error { return registerInhibit(d, s) }, | |
| 31 | 31 | func() error { return registerIdle(d, s) }, | |
| 32 | 32 | func() error { return registerCapture(d, s) }, | |
| 33 | + | func() error { return registerCaptureSession(d, s) }, | |
| 33 | 34 | func() error { return registerActivation(d, s) }, | |
| 34 | 35 | func() error { return registerVirtualKeyboard(d, s) }, | |
| 35 | 36 | func() error { return registerTextInput(d, s) }, | |
M internal/adapters/wayland/layer.go +29 -0
| @@ -31,6 +31,9 @@ type layerSurface struct { | |||
| 31 | 31 | // creation. The surface is closed when it is unplugged. | |
| 32 | 32 | output *output | |
| 33 | 33 | closed bool // output gone: commits are ignored until destroy | |
| 34 | + | // capture is the private capture session this surface is attached to. | |
| 35 | + | capture *privateSession | |
| 36 | + | attachment *layerAttachment | |
| 34 | 37 | } | |
| 35 | 38 | ||
| 36 | 39 | func registerLayer(d *server.Display, s *Server) error { | |
| @@ -71,9 +74,12 @@ func (h layerShell) GetLayerSurface(r *wlrlayershell.ZwlrLayerShellV1, id uint32 | |||
| 71 | 74 | h.server.layers[l.id] = l | |
| 72 | 75 | if gone { | |
| 73 | 76 | l.close() | |
| 77 | + | } else if out != nil { | |
| 78 | + | state.sendTreeScale() | |
| 74 | 79 | } | |
| 75 | 80 | resource.OnDestroy = func() { | |
| 76 | 81 | l.unmap() | |
| 82 | + | l.captureDetach() | |
| 77 | 83 | delete(h.server.layers, l.id) | |
| 78 | 84 | state.layer = nil | |
| 79 | 85 | state.role = nil | |
| @@ -104,6 +110,10 @@ func (l *layerSurface) SetKeyboardInteractivity(r *wlrlayershell.ZwlrLayerSurfac | |||
| 104 | 110 | r.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidKeyboardInteractivity), "invalid keyboard interactivity") | |
| 105 | 111 | return | |
| 106 | 112 | } | |
| 113 | + | if l.capture != nil { | |
| 114 | + | // A capture HUD never takes the keyboard, whatever it asks for. | |
| 115 | + | k = 0 | |
| 116 | + | } | |
| 107 | 117 | l.pending.keyboard = k | |
| 108 | 118 | } | |
| 109 | 119 | ||
| @@ -175,6 +185,9 @@ func (l *layerSurface) unmap() { | |||
| 175 | 185 | // close tells the client its output is gone; the surface stays unmapped | |
| 176 | 186 | // until the client destroys it. | |
| 177 | 187 | func (l *layerSurface) close() { | |
| 188 | + | if l.closed { | |
| 189 | + | return | |
| 190 | + | } | |
| 178 | 191 | l.unmap() | |
| 179 | 192 | l.output = nil | |
| 180 | 193 | l.closed = true | |
| @@ -190,6 +203,10 @@ func (s *Server) layerChanged() { | |||
| 190 | 203 | continue | |
| 191 | 204 | } | |
| 192 | 205 | v := ports.LayerSurface{ID: l.id, Layer: l.current.layer, Anchor: l.current.anchor, ExclusiveZone: l.current.zone, Margin: l.current.margin, Namespace: l.namespace, Keyboard: l.current.keyboard} | |
| 206 | + | if l.capture != nil { | |
| 207 | + | // A capture HUD never takes the keyboard. | |
| 208 | + | v.Keyboard = 0 | |
| 209 | + | } | |
| 193 | 210 | if l.output != nil { | |
| 194 | 211 | v.Output = l.output.name() | |
| 195 | 212 | } | |
| @@ -233,12 +250,24 @@ func (l *layerSurface) commit(buffer bool) { | |||
| 233 | 250 | func (l *layerSurface) commitState(p layerState, buffer bool) { | |
| 234 | 251 | if l.surface.destroyed { | |
| 235 | 252 | l.unmap() | |
| 253 | + | l.captureDetach() | |
| 236 | 254 | delete(l.shell.server.layers, l.id) | |
| 237 | 255 | return | |
| 238 | 256 | } | |
| 239 | 257 | if l.closed { | |
| 240 | 258 | return | |
| 241 | 259 | } | |
| 260 | + | if l.capture != nil { | |
| 261 | + | p.keyboard = 0 | |
| 262 | + | if p.layer != ports.LayerTop && p.layer != ports.LayerOverlay { | |
| 263 | + | // Only top and overlay layers may be attached: closing unmaps the | |
| 264 | + | // surface (its LayerChanged is queued first), then it is detached, | |
| 265 | + | // so core keeps it excluded until it is gone. | |
| 266 | + | l.close() | |
| 267 | + | l.captureDetach() | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | } | |
| 242 | 271 | if (p.width == 0 && p.anchor&(ports.AnchorLeft|ports.AnchorRight) != ports.AnchorLeft|ports.AnchorRight) || (p.height == 0 && p.anchor&(ports.AnchorTop|ports.AnchorBottom) != ports.AnchorTop|ports.AnchorBottom) { | |
| 243 | 272 | l.resource.PostError(uint32(wlrlayershell.ZwlrLayerSurfaceV1ErrorInvalidSize), "zero size requires opposing anchors") | |
| 244 | 273 | return | |
A internal/adapters/wayland/layer_scale_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/bnema/purego-libwayland/protocol/fractionalscale" | |
| 8 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 9 | + | "github.com/bnema/purego-libwayland/protocol/wlrlayershell" | |
| 10 | + | "github.com/bnema/wlturbo" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // layerScaleOrderProxy is a real protocol client recording fractional-scale | |
| 14 | + | // and layer events in their wire order across the two protocol objects. | |
| 15 | + | type layerScaleOrderProxy struct { | |
| 16 | + | wlturbo.BaseProxy | |
| 17 | + | layer bool | |
| 18 | + | events chan [3]uint32 | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func (p *layerScaleOrderProxy) Dispatch(e *wlturbo.Event) { | |
| 22 | + | if !p.layer { | |
| 23 | + | p.events <- [3]uint32{0, e.Uint32()} | |
| 24 | + | return | |
| 25 | + | } | |
| 26 | + | switch e.Opcode { | |
| 27 | + | case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventConfigure): | |
| 28 | + | _ = e.Uint32() // serial | |
| 29 | + | p.events <- [3]uint32{1, e.Uint32(), e.Uint32()} | |
| 30 | + | case uint16(wlrlayershell.ZwlrLayerSurfaceV1EventClosed): | |
| 31 | + | p.events <- [3]uint32{2} | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestLayerTargetScaleBeforeConfigure(t *testing.T) { | |
| 36 | + | for _, target := range []string{"fractional", "focused", "inferred", "removed"} { | |
| 37 | + | t.Run(target, func(t *testing.T) { | |
| 38 | + | s, _, commands, dir := lifecycleServer(t) | |
| 39 | + | c := protocolClient(t, s, dir) | |
| 40 | + | fractional := ports.OutputPlacement{Info: ports.OutputInfo{Name: "HEADLESS-2", Width: 900, Height: 600}, X: 1920, Width: 750, Height: 500, Scale: 1.2} | |
| 41 | + | commands <- ports.SetOutputs{Outputs: ports.Layout{testOutputs[0], fractional}, Focused: "HEADLESS-1"} | |
| 42 | + | waitOutputs(t, c, 2) | |
| 43 | + | var first, last uint32 | |
| 44 | + | for n, g := range c.Registry().GetGlobals() { | |
| 45 | + | if g.Interface == "wl_output" { | |
| 46 | + | if first == 0 || n < first { | |
| 47 | + | first = n | |
| 48 | + | } | |
| 49 | + | last = max(last, n) | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | var out uint32 | |
| 53 | + | if target != "inferred" { | |
| 54 | + | name := last | |
| 55 | + | if target == "focused" { | |
| 56 | + | name = first | |
| 57 | + | } | |
| 58 | + | var err error | |
| 59 | + | out, err = c.Registry().BindID(name, "wl_output", 4) | |
| 60 | + | if err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | registerProtocol(t, c, out) | |
| 64 | + | } | |
| 65 | + | if target == "removed" { | |
| 66 | + | commands <- ports.SetOutputs{Outputs: testOutputs, Focused: "HEADLESS-1"} | |
| 67 | + | waitOutputs(t, c, 1) | |
| 68 | + | } | |
| 69 | + | comp := bindVersion(t, c, "wl_compositor", 6) | |
| 70 | + | shell := bindProtocol(t, c, "zwlr_layer_shell_v1") | |
| 71 | + | fm := bindProtocol(t, c, "wp_fractional_scale_manager_v1") | |
| 72 | + | surf := c.AllocateID() | |
| 73 | + | integers := make(chan int32, 4) | |
| 74 | + | sp := &surfaceScaleProxy{scales: integers} | |
| 75 | + | sp.SetID(surf) | |
| 76 | + | c.Context().Register(sp) | |
| 77 | + | requestProtocol(t, c, comp, wayland.CompositorRequestCreateSurface, surf) | |
| 78 | + | frac := c.AllocateID() | |
| 79 | + | events := make(chan [3]uint32, 8) | |
| 80 | + | fp := &layerScaleOrderProxy{events: events} | |
| 81 | + | fp.SetID(frac) | |
| 82 | + | c.Context().Register(fp) | |
| 83 | + | requestProtocol(t, c, fm, fractionalscale.WpFractionalScaleManagerV1RequestGetFractionalScale, frac, surf) | |
| 84 | + | if err := c.Roundtrip(); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 87 | + | if len(events) != 1 || <-events != [3]uint32{0, 120} { | |
| 88 | + | t.Fatal("surface must initially use focused output scale 120") | |
| 89 | + | } | |
| 90 | + | if len(integers) != 1 || <-integers != 1 { | |
| 91 | + | t.Fatal("initial integer scale must be 1") | |
| 92 | + | } | |
| 93 | + | layer := c.AllocateID() | |
| 94 | + | lp := &layerScaleOrderProxy{layer: true, events: events} | |
| 95 | + | lp.SetID(layer) | |
| 96 | + | c.Context().Register(lp) | |
| 97 | + | requestProtocol(t, c, shell, wlrlayershell.ZwlrLayerShellV1RequestGetLayerSurface, layer, surf, out, uint32(ports.LayerOverlay), "scale-test") | |
| 98 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetAnchor, uint32(15)) | |
| 99 | + | requestProtocol(t, c, layer, wlrlayershell.ZwlrLayerSurfaceV1RequestSetSize, uint32(0), uint32(0)) | |
| 100 | + | requestProtocol(t, c, surf, wayland.SurfaceRequestCommit) | |
| 101 | + | if err := c.Roundtrip(); err != nil { | |
| 102 | + | t.Fatal(err) | |
| 103 | + | } | |
| 104 | + | want := [][3]uint32{{1, 1920, 1080}} | |
| 105 | + | switch target { | |
| 106 | + | case "fractional": | |
| 107 | + | want = [][3]uint32{{0, 144}, {1, 750, 500}} | |
| 108 | + | if len(integers) != 1 || <-integers != 2 { | |
| 109 | + | t.Fatal("target integer scale must update to 2") | |
| 110 | + | } | |
| 111 | + | case "removed": | |
| 112 | + | want = [][3]uint32{{2}} | |
| 113 | + | } | |
| 114 | + | if len(events) != len(want) { | |
| 115 | + | t.Fatalf("events: %d, want %v", len(events), want) | |
| 116 | + | } | |
| 117 | + | for _, expected := range want { | |
| 118 | + | if got := <-events; got != expected { | |
| 119 | + | t.Fatalf("event %v, want %v", got, expected) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | if len(integers) != 0 { | |
| 123 | + | t.Fatal("unexpected integer scale notification") | |
| 124 | + | } | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
M internal/adapters/wayland/outputs.go +1 -0
| @@ -186,6 +186,7 @@ func (s *Server) setOutputs(c ports.SetOutputs) { | |||
| 186 | 186 | session.res.SendStopped() | |
| 187 | 187 | } | |
| 188 | 188 | } | |
| 189 | + | s.stopCaptureOnOutput(o.name()) | |
| 189 | 190 | s.log.Info().Str("output", o.name()).Msg("output removed") | |
| 190 | 191 | for _, l := range s.layers { | |
| 191 | 192 | if l.output == o { | |
M internal/adapters/wayland/presentation.go +3 -1
| @@ -94,7 +94,9 @@ func (s *surface) commitFeedback(pending []*presentationtime.WpPresentationFeedb | |||
| 94 | 94 | return | |
| 95 | 95 | } | |
| 96 | 96 | output := srv.frameOutput(s) | |
| 97 | - | if win == 0 || output == "" || output == suspendedFrameQueue { | |
| 97 | + | // A tree only a capture session draws gets no presentation feedback: no | |
| 98 | + | // display presents it. | |
| 99 | + | if win == 0 || output == "" || output == suspendedFrameQueue || srv.invisible(s) { | |
| 98 | 100 | for _, fb := range pending { | |
| 99 | 101 | discard(fb) | |
| 100 | 102 | } | |
M internal/adapters/wayland/release.go +11 -4
| @@ -15,7 +15,8 @@ import ( | |||
| 15 | 15 | // that window and neither shows nor queues the buffer. Outputs keep only | |
| 16 | 16 | // the latest content of a window, so they never draw an older buffer | |
| 17 | 17 | // again. heldTimeout guards against an output that stopped reporting | |
| 18 | - | // (switched away, stalled): a buffer it does not scan out is released then. | |
| 18 | + | // (switched away, stalled): a buffer it does not scan out or read on a | |
| 19 | + | // separate child device is released then. | |
| 19 | 20 | ||
| 20 | 21 | // heldTimeout releases held buffers of an output that stopped reporting. | |
| 21 | 22 | const heldTimeout = 100 * time.Millisecond | |
| @@ -90,7 +91,8 @@ func (s *Server) releaseBufferSync(surf *surface, sync syncHold) { | |||
| 90 | 91 | // releaseHeld records the output reports and releases the buffers no | |
| 91 | 92 | // output reads. Every output reads every window's content, so a buffer | |
| 92 | 93 | // waits until no live output shows or queues it and all of them have seen | |
| 93 | - | // a later content of its window (or heldTimeout passed). It reports | |
| 94 | + | // a later content of its window (or heldTimeout passed, without child reads). | |
| 95 | + | // It reports | |
| 94 | 96 | // whether buffers are still held. | |
| 95 | 97 | func (s *Server) releaseHeld(now time.Time, reports []ports.OutputPresented) bool { | |
| 96 | 98 | for _, r := range reports { | |
| @@ -138,14 +140,19 @@ func (s *Server) bufferReferenced(b *wayland.Buffer) bool { | |||
| 138 | 140 | } | |
| 139 | 141 | ||
| 140 | 142 | // keepHeld reports whether an output may still read a held buffer: one | |
| 141 | - | // scans it out or queued it, or one has not got a later content of its | |
| 142 | - | // window yet and heldTimeout has not passed. | |
| 143 | + | // scans it out or queued it, a child renderer still reads its content, or | |
| 144 | + | // one has not got a later content of its window yet and heldTimeout has | |
| 145 | + | // not passed. Child reads are exempt from the timeout until the render | |
| 146 | + | // finishes or its device shuts down. | |
| 143 | 147 | func keepHeld(h heldBuffer, reports []ports.OutputPresented, now time.Time) bool { | |
| 144 | 148 | seen := true | |
| 145 | 149 | for _, r := range reports { | |
| 146 | 150 | if h.id != 0 && (r.Shown == h.id || r.Queued == h.id) { | |
| 147 | 151 | return true | |
| 148 | 152 | } | |
| 153 | + | if seq, ok := r.ChildReads[h.window]; ok && seq <= h.after { | |
| 154 | + | return true | |
| 155 | + | } | |
| 149 | 156 | if r.Seen[h.window] <= h.after { | |
| 150 | 157 | seen = false | |
| 151 | 158 | } | |
M internal/adapters/wayland/release_test.go +82 -0
| @@ -1,12 +1,20 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "os" | |
| 4 | 8 | "testing" | |
| 5 | 9 | "time" | |
| 6 | 10 | ||
| 11 | + | "github.com/bnema/neferwl/internal/adapters/capture" | |
| 12 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 7 | 13 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 14 | "github.com/bnema/purego-libwayland/protocol/fifo" | |
| 9 | 15 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 16 | + | "github.com/stretchr/testify/mock" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 10 | 18 | ) | |
| 11 | 19 | ||
| 12 | 20 | // A cached child buffer is not released until its role is destroyed; | |
| @@ -84,6 +92,80 @@ func TestKeepHeld(t *testing.T) { | |||
| 84 | 92 | } | |
| 85 | 93 | } | |
| 86 | 94 | ||
| 95 | + | // Child reads on a separate device outlive the stale display report timeout. | |
| 96 | + | // The same predicate protects wl_buffer.release and explicit release points. | |
| 97 | + | func TestKeepHeldChildReadsBeyondTimeout(t *testing.T) { | |
| 98 | + | now := time.Unix(100, 0) | |
| 99 | + | report := ports.OutputPresented{ | |
| 100 | + | Seen: map[ports.WindowID]uint64{1: 20, 2: 20}, | |
| 101 | + | ChildReads: map[ports.WindowID]uint64{1: 5}, | |
| 102 | + | } | |
| 103 | + | for _, id := range []uint64{0, 7} { // SHM / explicit points only, and dmabuf | |
| 104 | + | h := heldBuffer{window: 1, id: id, after: 5, at: now} | |
| 105 | + | if !keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) { | |
| 106 | + | t.Fatal("unfinished child read released after 100ms") | |
| 107 | + | } | |
| 108 | + | h.window = 2 | |
| 109 | + | if keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) { | |
| 110 | + | t.Fatal("unrelated display window held by child") | |
| 111 | + | } | |
| 112 | + | h.window, h.after = 1, 4 | |
| 113 | + | if keepHeld(h, []ports.OutputPresented{report}, now.Add(time.Second)) { | |
| 114 | + | t.Fatal("buffer older than child's content held") | |
| 115 | + | } | |
| 116 | + | h.after = 5 | |
| 117 | + | finished := report | |
| 118 | + | finished.ChildReads = nil // render completed, or device closed | |
| 119 | + | if keepHeld(h, []ports.OutputPresented{finished}, now.Add(time.Second)) { | |
| 120 | + | t.Fatal("finished child read still holds buffer") | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | allocs := testing.AllocsPerRun(100, func() { | |
| 124 | + | keepHeld(heldBuffer{window: 1, after: 5, at: now}, []ports.OutputPresented{report}, now.Add(time.Second)) | |
| 125 | + | }) | |
| 126 | + | if allocs != 0 { | |
| 127 | + | t.Fatalf("child release guard: %.1f allocs, want 0", allocs) | |
| 128 | + | } | |
| 129 | + | t.Logf("child release guard: %.1f allocs", allocs) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // An actual unfinished child fence protects releases after the stale timeout, | |
| 133 | + | // even when the display's Seen is unchanged (so no cap was necessary). | |
| 134 | + | func TestKeepHeldUntilChildFenceCompletes(t *testing.T) { | |
| 135 | + | read, write, err := os.Pipe() | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | defer write.Close() | |
| 138 | + | child := portsmocks.NewMockRenderer(t) | |
| 139 | + | child.EXPECT().Render(mock.Anything, mock.Anything).Return(read, nil).Once() | |
| 140 | + | // A failed readback does not finish the render's client-buffer reads. | |
| 141 | + | child.EXPECT().BeginCapture().Return(nil, errors.New("readback unavailable")).Once() | |
| 142 | + | child.EXPECT().Close().Return().Once() | |
| 143 | + | p := capture.NewPipeline(context.Background(), make(chan ports.CaptureDone, 1)) | |
| 144 | + | defer p.Close(nil) | |
| 145 | + | p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil }) | |
| 146 | + | s := ports.Scene{ | |
| 147 | + | Capture: &ports.SceneCapture{Session: 7, TargetRect: ports.Rect{W: 2, H: 2}}, | |
| 148 | + | CaptureScene: &ports.Scene{OutputWidth: 2, OutputHeight: 2, Scale: 1, Windows: []ports.SceneWindow{{ID: 1}}}, | |
| 149 | + | } | |
| 150 | + | p.SubmitHidden(s, map[ports.WindowID]ports.SurfaceContent{1: {Seq: 5}}, []ports.CaptureRequest{{ | |
| 151 | + | ID: 1, Clean: true, Session: 7, Region: image.Rect(0, 0, 2, 2), | |
| 152 | + | }}) | |
| 153 | + | now := time.Unix(100, 0) | |
| 154 | + | h := heldBuffer{window: 1, after: 5, at: now} | |
| 155 | + | seen := map[ports.WindowID]uint64{1: 5} | |
| 156 | + | got, reads, pending := p.CapHiddenSeen(seen) | |
| 157 | + | require.True(t, pending) | |
| 158 | + | require.Equal(t, seen, got) | |
| 159 | + | reports := []ports.OutputPresented{{Seen: got, ChildReads: reads}} | |
| 160 | + | require.True(t, keepHeld(h, reports, now.Add(time.Second)), "unsignalled fence outlives 100ms timeout") | |
| 161 | + | _, err = write.Write([]byte{1}) | |
| 162 | + | require.NoError(t, err) | |
| 163 | + | got, reads, pending = p.CapHiddenSeen(seen) | |
| 164 | + | require.False(t, pending) | |
| 165 | + | reports[0] = ports.OutputPresented{Seen: got, ChildReads: reads} | |
| 166 | + | require.False(t, keepHeld(h, reports, now.Add(time.Second)), "signalled fence lifts child hold") | |
| 167 | + | } | |
| 168 | + | ||
| 87 | 169 | // A buffer replaced just after its window becomes invisible is still held: | |
| 88 | 170 | // a scanout plane may read it until the flip that hides the window. | |
| 89 | 171 | func TestInvisibleWindowBufferHeldUntilReport(t *testing.T) { | |
M internal/adapters/wayland/screencopy.go +6 -1
| @@ -165,6 +165,11 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 165 | 165 | s.log.Debug().Str("output", o.name()).Int("inflight", len(s.captureInflight)).Msg("capture refused: too many captures in flight") | |
| 166 | 166 | return 0, false | |
| 167 | 167 | } | |
| 168 | + | clean, session, revision, allowed := s.captureTag(life, o) | |
| 169 | + | if !allowed { | |
| 170 | + | s.log.Debug().Str("output", o.name()).Msg("capture refused: private session not active") | |
| 171 | + | return 0, false | |
| 172 | + | } | |
| 168 | 173 | buf := s.buffers[b.Resource].(*buffer) | |
| 169 | 174 | fd, err := unix.Dup(int(buf.pool.file.Fd())) | |
| 170 | 175 | if err != nil { | |
| @@ -185,7 +190,7 @@ func (s *Server) requestCapture(o *output, rect image.Rectangle, cursor bool, b | |||
| 185 | 190 | reply(done) | |
| 186 | 191 | } | |
| 187 | 192 | } | |
| 188 | - | req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format} | |
| 193 | + | req := ports.CaptureRequest{ID: id, Output: o.name(), Region: rect, Cursor: cursor, Dst: ports.SHMBuffer{File: os.NewFile(uintptr(fd), "capture"), Offset: buf.offset}, Width: buf.width, Height: buf.height, Stride: buf.stride, Format: buf.format, Clean: clean, Session: session, CaptureRevision: revision} | |
| 189 | 194 | select { | |
| 190 | 195 | case s.channels.Captures <- req: | |
| 191 | 196 | s.log.Debug().Uint64("id", id).Str("output", o.name()).Stringer("region", rect).Uint32("format", format).Bool("cursor", cursor).Msg("capture requested") | |
M internal/adapters/wayland/server.go +14 -8
| @@ -81,13 +81,15 @@ type Server struct { | |||
| 81 | 81 | captureInflight map[uint64]struct{} | |
| 82 | 82 | captureSources map[*server.Resource]*output | |
| 83 | 83 | captureSessions map[*captureSession]struct{} | |
| 84 | - | display *server.Display | |
| 85 | - | env procEnv | |
| 86 | - | slotsPending bool // core waits for a slot window | |
| 87 | - | name string | |
| 88 | - | cleanup func() | |
| 89 | - | log zerowrap.Logger | |
| 90 | - | channels Channels | |
| 84 | + | // private is the private capture session state (capture_session.go). | |
| 85 | + | private privateCapture | |
| 86 | + | display *server.Display | |
| 87 | + | env procEnv | |
| 88 | + | slotsPending bool // core waits for a slot window | |
| 89 | + | name string | |
| 90 | + | cleanup func() | |
| 91 | + | log zerowrap.Logger | |
| 92 | + | channels Channels | |
| 91 | 93 | // awaiting holds frame callbacks by output name, due at its next | |
| 92 | 94 | // frame (frameDue, or its page flip). | |
| 93 | 95 | awaiting map[string][]*wayland.Callback | |
| @@ -625,7 +627,9 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 625 | 627 | return | |
| 626 | 628 | } | |
| 627 | 629 | s.log.Info().Uint64("id", uint64(c.ID)).Int("w", c.Width).Int("h", c.Height).Bool("fullscreen", c.Fullscreen).Bool("activated", c.Activated).Msg("configure") | |
| 628 | - | visibilityChanged := w.hasLast && w.last.Visible != c.Visible | |
| 630 | + | // Callbacks move when the tree stops or starts being throttled: on | |
| 631 | + | // Visible or on Captured. | |
| 632 | + | visibilityChanged := w.hasLast && (w.last.Visible != c.Visible || w.last.Captured != c.Captured) | |
| 629 | 633 | scanoutChanged := !w.hasLast || w.last.Fullscreen != c.Fullscreen || w.last.Output != c.Output || w.last.Visible != c.Visible | |
| 630 | 634 | w.last, w.hasLast = c, true | |
| 631 | 635 | if visibilityChanged { | |
| @@ -651,6 +655,8 @@ func (s *Server) apply(cmd ports.ClientCommand) { | |||
| 651 | 655 | off[name] = true | |
| 652 | 656 | } | |
| 653 | 657 | s.setOutputsOff(off) | |
| 658 | + | case ports.CaptureSessionState: | |
| 659 | + | s.captureState(c) | |
| 654 | 660 | case ports.UserActivity: | |
| 655 | 661 | s.userActivity() | |
| 656 | 662 | case ports.SlotsPending: | |
M internal/adapters/wayland/workspaces.go +1 -0
| @@ -131,6 +131,7 @@ func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | |||
| 131 | 131 | for _, m := range s.workspaceManagers { | |
| 132 | 132 | m.update(snapshot) | |
| 133 | 133 | } | |
| 134 | + | s.updateCaptureWorkspaces(snapshot) | |
| 134 | 135 | } | |
| 135 | 136 | func workspaceState(w ports.WorkspaceInfo) uint32 { | |
| 136 | 137 | var state uint32 | |
M internal/adapters/wayland/xdg.go +1 -1
| @@ -167,7 +167,7 @@ func toplevelStates(c ports.ConfigureWindow, version int32) []byte { | |||
| 167 | 167 | if c.Activated { | |
| 168 | 168 | add(xdgshell.ToplevelStateActivated) | |
| 169 | 169 | } | |
| 170 | - | if !c.Visible && version >= 6 { | |
| 170 | + | if !c.Visible && !c.Captured && version >= 6 { | |
| 171 | 171 | add(xdgshell.ToplevelStateSuspended) | |
| 172 | 172 | } | |
| 173 | 173 | if c.Fullscreen || c.Floating { | |
M internal/adapters/wayland/xdg_states_test.go +1 -0
| @@ -23,6 +23,7 @@ func TestToplevelStates(t *testing.T) { | |||
| 23 | 23 | {"fullscreen", ports.ConfigureWindow{Fullscreen: true, Visible: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen}}, | |
| 24 | 24 | {"hidden tiled v6", ports.ConfigureWindow{}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateSuspended, xdgshell.ToplevelStateMaximized}, tiled...)}, | |
| 25 | 25 | {"hidden fullscreen v6", ports.ConfigureWindow{Fullscreen: true}, 6, []xdgshell.ToplevelState{xdgshell.ToplevelStateFullscreen, xdgshell.ToplevelStateSuspended}}, | |
| 26 | + | {"captured hidden tiled v6", ports.ConfigureWindow{Captured: true}, 6, append([]xdgshell.ToplevelState{xdgshell.ToplevelStateMaximized}, tiled...)}, | |
| 26 | 27 | {"hidden floating v5", ports.ConfigureWindow{Floating: true}, 5, nil}, | |
| 27 | 28 | } | |
| 28 | 29 | for _, tt := range tests { | |
M internal/app/drm.go +1 -0
| @@ -330,6 +330,7 @@ func (b *drmBackend) runOutputs(ctx context.Context, want func(ports.Config) drm | |||
| 330 | 330 | case <-ctx.Done(): | |
| 331 | 331 | } | |
| 332 | 332 | }() | |
| 333 | + | o.NewCaptureRenderer = newRenderer | |
| 333 | 334 | progress.started(name, source) | |
| 334 | 335 | if cur := o.Cursor(); cur != nil { | |
| 335 | 336 | curs.set(name, cur) | |
M internal/app/headless.go +1 -1
| @@ -31,7 +31,7 @@ func runHeadless(ctx context.Context, sizes [][2]int, shots string, hdr bool, ap | |||
| 31 | 31 | } | |
| 32 | 32 | cur := &headless.Cursor{} | |
| 33 | 33 | curs.set(name, cur) | |
| 34 | - | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 34 | + | opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: hdr, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured} | |
| 35 | 35 | set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest) error { | |
| 36 | 36 | return headless.Run(octx, opts, sc, cc, cu, cap) | |
| 37 | 37 | }) | |
A internal/core/capture_session.go +301 -0
| @@ -0,0 +1,301 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "reflect" | |
| 6 | + | "slices" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // captureSession is the one live private capture session (ports/capture_session.go). | |
| 13 | + | // Core owns its state; wayland only relays requests and decisions. | |
| 14 | + | type captureSession struct { | |
| 15 | + | begin ports.CaptureSessionBegin | |
| 16 | + | layers []WindowID // attached layer surfaces, at most ports.MaxCaptureSessionLayers | |
| 17 | + | // retained are detached layer surfaces still listed by a screen: they | |
| 18 | + | // stay excluded until a LayerChanged stops listing them, so a HUD that | |
| 19 | + | // is destroyed is never captured during its last frame. | |
| 20 | + | retained []WindowID | |
| 21 | + | expired bool // no ping within ports.CaptureSessionTimeout | |
| 22 | + | rev uint64 // revision fence, bumped when the decision changes | |
| 23 | + | last captureView | |
| 24 | + | hasLast bool | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // captureView is what one evaluation of the session decided. | |
| 28 | + | type captureView struct { | |
| 29 | + | state ports.CaptureSessionState | |
| 30 | + | output string | |
| 31 | + | scene *ports.SceneCapture // nil unless Active | |
| 32 | + | // hidden is set when the targeted workspace is not drawn as itself on | |
| 33 | + | // screen (another one is, or the overview previews it): it is rendered | |
| 34 | + | // for capture only (Scene.CaptureScene) and the physical scene carries no | |
| 35 | + | // border for it. | |
| 36 | + | hidden bool | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // captureBegin registers a session, or refuses it as busy: the live session | |
| 40 | + | // is not disturbed. | |
| 41 | + | func (c *Core) captureBegin(ctx context.Context, v ports.CaptureSessionBegin) error { | |
| 42 | + | if c.capture != nil { | |
| 43 | + | return c.command(ctx, ports.CaptureSessionState{ID: v.ID, Output: v.Output, Workspace: v.Workspace, Reason: ports.CaptureReasonBusy}) | |
| 44 | + | } | |
| 45 | + | c.capture = &captureSession{begin: v} | |
| 46 | + | c.armCapture() | |
| 47 | + | return nil | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // captureLayer attaches or detaches a layer surface of the live session. | |
| 51 | + | func (c *Core) captureLayer(v ports.CaptureSessionLayer) { | |
| 52 | + | s := c.capture | |
| 53 | + | if s == nil || s.begin.ID != v.ID { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | i := slices.Index(s.layers, v.Layer) | |
| 57 | + | switch { | |
| 58 | + | case v.Attached && i < 0 && len(s.layers) < ports.MaxCaptureSessionLayers: | |
| 59 | + | s.layers = append(s.layers, v.Layer) | |
| 60 | + | c.clampCaptureKeyboard() | |
| 61 | + | case !v.Attached && i >= 0: | |
| 62 | + | s.layers = slices.Delete(s.layers, i, i+1) | |
| 63 | + | if c.layerListed(v.Layer) { | |
| 64 | + | s.retained = append(s.retained, v.Layer) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // layerListed reports whether a screen still lists the layer surface. | |
| 70 | + | func (c *Core) layerListed(id WindowID) bool { | |
| 71 | + | for _, sc := range c.screens { | |
| 72 | + | for _, l := range sc.layers { | |
| 73 | + | if l.ID == id { | |
| 74 | + | return true | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // pruneRetained forgets the detached layers no screen lists any more. | |
| 82 | + | func (c *Core) pruneRetained() { | |
| 83 | + | if c.capture != nil { | |
| 84 | + | c.capture.retained = slices.DeleteFunc(c.capture.retained, func(id WindowID) bool { return !c.layerListed(id) }) | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // clampCaptureKeyboard gives every attached layer surface no keyboard | |
| 89 | + | // interactivity at all, whatever it asks for (exclusive or on demand): a | |
| 90 | + | // capture HUD never takes or steals the keyboard. Run whenever the layer | |
| 91 | + | // list or the attached set changes. | |
| 92 | + | func (c *Core) clampCaptureKeyboard() { | |
| 93 | + | if c.capture == nil { | |
| 94 | + | return | |
| 95 | + | } | |
| 96 | + | for _, sc := range c.screens { | |
| 97 | + | for i := range sc.layers { | |
| 98 | + | if sc.layers[i].Keyboard != 0 && (slices.Contains(c.capture.layers, sc.layers[i].ID) || slices.Contains(c.capture.retained, sc.layers[i].ID)) { | |
| 99 | + | sc.layers[i].Keyboard = 0 | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // captureEnd forgets the session on an explicit stop or its owner's disconnect. | |
| 106 | + | func (c *Core) captureEnd(id uint64) { | |
| 107 | + | if c.capture != nil && c.capture.begin.ID == id { | |
| 108 | + | c.dropCapture() | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // capturePing restarts the keep-alive timer. | |
| 113 | + | func (c *Core) capturePing(id uint64) { | |
| 114 | + | if c.capture != nil && c.capture.begin.ID == id { | |
| 115 | + | c.armCapture() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (c *Core) dropCapture() { | |
| 120 | + | c.capture = nil | |
| 121 | + | c.stopCapture() | |
| 122 | + | c.configures.cw.reset() | |
| 123 | + | for _, sc := range c.screens { | |
| 124 | + | sc.capture = nil | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // armCapture (re)starts the keep-alive timer; it exists only while a session does. | |
| 129 | + | func (c *Core) armCapture() { | |
| 130 | + | c.stopCapture() | |
| 131 | + | if c.ch.Clock != nil { | |
| 132 | + | t := c.ch.Clock.NewTimer(ports.CaptureSessionTimeout) | |
| 133 | + | c.captureC, c.captureStop = t.C(), t.Stop | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | t := time.NewTimer(ports.CaptureSessionTimeout) | |
| 137 | + | c.captureC, c.captureStop = t.C, t.Stop | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (c *Core) stopCapture() { | |
| 141 | + | if c.captureStop != nil { | |
| 142 | + | c.captureStop() | |
| 143 | + | } | |
| 144 | + | c.captureC, c.captureStop = nil, nil | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // captureExpired marks the session for the next publish to end it. | |
| 148 | + | func (c *Core) captureExpired() { | |
| 149 | + | c.captureC, c.captureStop = nil, nil | |
| 150 | + | if c.capture != nil { | |
| 151 | + | c.capture.expired = true | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // captureEvaluate decides the state of the session against the current | |
| 156 | + | // screens, sends it when it changed and returns the view; nil without a | |
| 157 | + | // session. A terminal state is sent once and the session is forgotten. | |
| 158 | + | func (c *Core) captureEvaluate(ctx context.Context) (*captureView, error) { | |
| 159 | + | s := c.capture | |
| 160 | + | if s == nil { | |
| 161 | + | return nil, nil | |
| 162 | + | } | |
| 163 | + | c.pruneRetained() | |
| 164 | + | view := c.captureDecide(s) | |
| 165 | + | if view.state.Reason.Terminal() { | |
| 166 | + | c.dropCapture() | |
| 167 | + | return nil, c.command(ctx, view.state) | |
| 168 | + | } | |
| 169 | + | // Screens keep the attached layers visible over a fullscreen window | |
| 170 | + | // only while the session serves captures. | |
| 171 | + | for _, sc := range c.screens { | |
| 172 | + | sc.capture = nil | |
| 173 | + | } | |
| 174 | + | if view.state.Active { | |
| 175 | + | if i := c.screenIndex(view.output); i >= 0 { | |
| 176 | + | keep := make(map[WindowID]bool, len(s.layers)) | |
| 177 | + | for _, id := range s.layers { | |
| 178 | + | keep[id] = true | |
| 179 | + | } | |
| 180 | + | c.screens[i].capture = keep | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | // The revision grows whenever what a clean capture must do changes; the | |
| 184 | + | // scene of this publish carries it, as does the state sent now. | |
| 185 | + | view.state.Layers = slices.Clone(s.layers) | |
| 186 | + | changed := !s.hasLast || !sameCapture(s.last, view) | |
| 187 | + | if changed { | |
| 188 | + | s.rev++ | |
| 189 | + | } | |
| 190 | + | view.state.Revision = s.rev | |
| 191 | + | if view.scene != nil { | |
| 192 | + | view.scene.Revision = s.rev | |
| 193 | + | } | |
| 194 | + | s.last, s.hasLast = view, true | |
| 195 | + | if changed { | |
| 196 | + | if err := c.command(ctx, view.state); err != nil { | |
| 197 | + | return nil, err | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | return &view, nil | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // sameCapture compares two decisions, revision aside. | |
| 204 | + | func sameCapture(a, b captureView) bool { | |
| 205 | + | a.state.Revision, b.state.Revision = 0, 0 | |
| 206 | + | if a.scene != nil { | |
| 207 | + | s := *a.scene | |
| 208 | + | s.Revision = 0 | |
| 209 | + | a.scene = &s | |
| 210 | + | } | |
| 211 | + | if b.scene != nil { | |
| 212 | + | s := *b.scene | |
| 213 | + | s.Revision = 0 | |
| 214 | + | b.scene = &s | |
| 215 | + | } | |
| 216 | + | return reflect.DeepEqual(a, b) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // captureDecide is the pure part of captureEvaluate. | |
| 220 | + | func (c *Core) captureDecide(s *captureSession) captureView { | |
| 221 | + | b := s.begin | |
| 222 | + | st := ports.CaptureSessionState{ID: b.ID, Output: b.Output, Workspace: b.Workspace} | |
| 223 | + | v := captureView{state: st, output: b.Output} | |
| 224 | + | stop := func(r ports.CaptureReason) captureView { | |
| 225 | + | v.state.Reason = r | |
| 226 | + | return v | |
| 227 | + | } | |
| 228 | + | if s.expired { | |
| 229 | + | return stop(ports.CaptureReasonPingTimeout) | |
| 230 | + | } | |
| 231 | + | i := c.screenIndex(b.Output) | |
| 232 | + | if i < 0 || b.Output == "" { | |
| 233 | + | return stop(ports.CaptureReasonOutputGone) | |
| 234 | + | } | |
| 235 | + | sc := c.screens[i] | |
| 236 | + | if sc.off { | |
| 237 | + | return stop(ports.CaptureReasonOutputOff) | |
| 238 | + | } | |
| 239 | + | m := sc.mon | |
| 240 | + | // target is the area captures cover, before the session's own region. | |
| 241 | + | target := m.Output() | |
| 242 | + | var origin Rect // the viewport origin of a hidden workspace's CaptureScene | |
| 243 | + | if b.Workspace != 0 { | |
| 244 | + | owner, w := c.workspaceByID(b.Workspace) | |
| 245 | + | if w == nil || owner != sc { | |
| 246 | + | return stop(ports.CaptureReasonWorkspaceGone) | |
| 247 | + | } | |
| 248 | + | target, origin = w.Output, Rect{X: w.Output.X, Y: w.Output.Y} | |
| 249 | + | v.hidden = m.Current() != w || m.ov.open | |
| 250 | + | } | |
| 251 | + | r := b.Region | |
| 252 | + | if r.W > 0 && r.H > 0 { | |
| 253 | + | target = intersect(target, r) | |
| 254 | + | } | |
| 255 | + | if target.W <= 0 || target.H <= 0 { | |
| 256 | + | return stop(ports.CaptureReasonInvalidRegion) | |
| 257 | + | } | |
| 258 | + | v.state.Rect = target | |
| 259 | + | excluded := c.captureExcluded(s) | |
| 260 | + | if len(excluded) > ports.MaxCaptureExcluded { | |
| 261 | + | return stop(ports.CaptureReasonTooManyExcluded) | |
| 262 | + | } | |
| 263 | + | v.state.Active = true | |
| 264 | + | scene := &ports.SceneCapture{Session: b.ID, TargetRect: target, Excluded: excluded, Workspace: b.Workspace} | |
| 265 | + | if v.hidden { | |
| 266 | + | // Rendered from Scene.CaptureScene, whose origin is the workspace | |
| 267 | + | // viewport's: TargetRect follows it. | |
| 268 | + | scene.TargetRect.X -= origin.X | |
| 269 | + | scene.TargetRect.Y -= origin.Y | |
| 270 | + | } else if b.Record { | |
| 271 | + | scene.BorderWidth, scene.BorderColor = ports.CaptureBorderWidth, ports.CaptureBorderColor | |
| 272 | + | } | |
| 273 | + | v.scene = scene | |
| 274 | + | return v | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // captureExcluded lists the attached layers and every popup hanging from | |
| 278 | + | // one of them, sorted. Layers not mapped yet are listed too: they are | |
| 279 | + | // excluded from their first frame. | |
| 280 | + | func (c *Core) captureExcluded(s *captureSession) []WindowID { | |
| 281 | + | out := append(slices.Clone(s.layers), s.retained...) | |
| 282 | + | for id := range c.popups { | |
| 283 | + | if root := c.popupRoot(id); slices.Contains(s.layers, root) || slices.Contains(s.retained, root) { | |
| 284 | + | out = append(out, id) | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | slices.Sort(out) | |
| 288 | + | return out | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // workspaceByID finds a workspace, numbered or hidden, on any screen. | |
| 292 | + | func (c *Core) workspaceByID(id uint64) (*screen, *Workspace) { | |
| 293 | + | for _, sc := range c.screens { | |
| 294 | + | for _, w := range sc.mon.all() { | |
| 295 | + | if w.ID == id { | |
| 296 | + | return sc, w | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | return nil, nil | |
| 301 | + | } | |
A internal/core/capture_session_test.go +420 -0
| @@ -0,0 +1,420 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 9 | + | "github.com/bnema/neferwl/internal/core" | |
| 10 | + | portsmocks "github.com/bnema/neferwl/internal/mocks/ports" | |
| 11 | + | "github.com/bnema/neferwl/internal/ports" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type captureRig struct { | |
| 15 | + | client chan ports.ClientEvent | |
| 16 | + | output chan ports.OutputEvent | |
| 17 | + | commands chan ports.ClientCommand | |
| 18 | + | scenes chan []ports.Scene | |
| 19 | + | workspaces chan ports.Workspaces | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // captureCore runs a core with one 100x80 output OUT-1; a nil clock gets a | |
| 23 | + | // keep-alive timer that never fires. | |
| 24 | + | func captureCore(t *testing.T, clock ports.Clock) *captureRig { | |
| 25 | + | t.Helper() | |
| 26 | + | cfg := config.Defaults() | |
| 27 | + | cfg.Border.Width = 0 | |
| 28 | + | r := &captureRig{ | |
| 29 | + | client: make(chan ports.ClientEvent, 16), | |
| 30 | + | output: make(chan ports.OutputEvent, 8), | |
| 31 | + | commands: make(chan ports.ClientCommand, 64), | |
| 32 | + | scenes: make(chan []ports.Scene, 1), | |
| 33 | + | workspaces: make(chan ports.Workspaces, 1), | |
| 34 | + | } | |
| 35 | + | if clock == nil { | |
| 36 | + | // The keep-alive timer never fires. | |
| 37 | + | timer := portsmocks.NewMockTimer(t) | |
| 38 | + | timer.EXPECT().Stop().Return(true).Maybe() | |
| 39 | + | timer.EXPECT().C().Return((<-chan time.Time)(nil)).Maybe() | |
| 40 | + | mc := portsmocks.NewMockClock(t) | |
| 41 | + | mc.EXPECT().Now().RunAndReturn(time.Now).Maybe() | |
| 42 | + | mc.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Maybe() | |
| 43 | + | clock = mc | |
| 44 | + | } | |
| 45 | + | c, err := core.New(cfg, core.Channels{Client: r.client, Output: r.output, Commands: r.commands, Scenes: r.scenes, Workspaces: r.workspaces, Clock: clock}) | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 50 | + | t.Cleanup(cancel) | |
| 51 | + | go c.Run(ctx) | |
| 52 | + | r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-1", Width: 100, Height: 80}} | |
| 53 | + | scene(t, r.scenes) | |
| 54 | + | return r | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // state returns the next CaptureSessionState, skipping other commands. | |
| 58 | + | func (r *captureRig) state(t *testing.T) ports.CaptureSessionState { | |
| 59 | + | t.Helper() | |
| 60 | + | for { | |
| 61 | + | if v, ok := receive(t, r.commands).(ports.CaptureSessionState); ok { | |
| 62 | + | return v | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // sceneWith returns the next scene whose capture presence equals want. | |
| 68 | + | func (r *captureRig) sceneWith(t *testing.T, want bool) ports.Scene { | |
| 69 | + | t.Helper() | |
| 70 | + | for { | |
| 71 | + | if s := scene(t, r.scenes); (s.Capture != nil) == want { | |
| 72 | + | return s | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (r *captureRig) layer(id ports.WindowID, layer ports.Layer, keyboard uint32) ports.LayerSurface { | |
| 78 | + | return ports.LayerSurface{ID: id, Layer: layer, Anchor: ports.AnchorTop | ports.AnchorLeft, Width: 10, Height: 10, Output: "OUT-1", Keyboard: keyboard} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestCaptureSessionOutputTarget(t *testing.T) { | |
| 82 | + | r := captureCore(t, nil) | |
| 83 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Region: ports.Rect{X: -10, Y: 10, W: 50, H: 200}, Record: true} | |
| 84 | + | st := r.state(t) | |
| 85 | + | if !st.Active || st.Workspace != 0 || st.Rect != (ports.Rect{X: 0, Y: 10, W: 40, H: 70}) { | |
| 86 | + | t.Fatalf("state %+v", st) | |
| 87 | + | } | |
| 88 | + | s := r.sceneWith(t, true) | |
| 89 | + | want := &ports.SceneCapture{Session: 1, TargetRect: st.Rect, Excluded: []ports.WindowID{}, BorderWidth: ports.CaptureBorderWidth, BorderColor: ports.CaptureBorderColor} | |
| 90 | + | if s.Capture.Session != 1 || s.Capture.TargetRect != want.TargetRect || len(s.Capture.Excluded) != 0 || s.Capture.BorderWidth != 2 || s.Capture.BorderColor != "#ff3b30" || s.Capture.Workspace != 0 { | |
| 91 | + | t.Fatalf("capture %+v", s.Capture) | |
| 92 | + | } | |
| 93 | + | // Without Record there is no border, and a zero region is the whole output. | |
| 94 | + | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 95 | + | r.sceneWith(t, false) | |
| 96 | + | r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"} | |
| 97 | + | if st := r.state(t); st.Rect != (ports.Rect{W: 100, H: 80}) { | |
| 98 | + | t.Fatalf("state %+v", st) | |
| 99 | + | } | |
| 100 | + | if s := r.sceneWith(t, true); s.Capture.BorderWidth != 0 || s.Capture.BorderColor != "" { | |
| 101 | + | t.Fatalf("border without record: %+v", s.Capture) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestCaptureSessionWorkspaceTarget(t *testing.T) { | |
| 106 | + | r := captureCore(t, nil) | |
| 107 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 108 | + | scene(t, r.scenes) | |
| 109 | + | ws := receive(t, r.workspaces).Outputs[0].Workspaces | |
| 110 | + | if ws[0].Frame != (ports.Rect{W: 100, H: 80}) { | |
| 111 | + | t.Fatalf("frame %+v", ws[0]) | |
| 112 | + | } | |
| 113 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID} | |
| 114 | + | if st := r.state(t); !st.Active || st.Workspace != ws[0].ID || st.Rect != ws[0].Frame { | |
| 115 | + | t.Fatalf("state %+v", st) | |
| 116 | + | } | |
| 117 | + | if s := r.sceneWith(t, true); s.Capture.Workspace != ws[0].ID { | |
| 118 | + | t.Fatalf("capture %+v", s.Capture) | |
| 119 | + | } | |
| 120 | + | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 121 | + | r.sceneWith(t, false) | |
| 122 | + | // A workspace that does not exist ends the session. | |
| 123 | + | r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Workspace: 9999} | |
| 124 | + | if st := r.state(t); st.Active || st.Reason != ports.CaptureReasonWorkspaceGone || !st.Reason.Terminal() { | |
| 125 | + | t.Fatalf("state %+v", st) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestCaptureSessionTerminalReasons(t *testing.T) { | |
| 130 | + | r := captureCore(t, nil) | |
| 131 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "NOPE"} | |
| 132 | + | if st := r.state(t); st.Reason != ports.CaptureReasonOutputGone || st.Active { | |
| 133 | + | t.Fatalf("state %+v", st) | |
| 134 | + | } | |
| 135 | + | r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1", Region: ports.Rect{X: 500, Y: 500, W: 5, H: 5}} | |
| 136 | + | if st := r.state(t); st.Reason != ports.CaptureReasonInvalidRegion { | |
| 137 | + | t.Fatalf("state %+v", st) | |
| 138 | + | } | |
| 139 | + | // An unplugged output ends the session. | |
| 140 | + | r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"} | |
| 141 | + | if st := r.state(t); !st.Active { | |
| 142 | + | t.Fatalf("state %+v", st) | |
| 143 | + | } | |
| 144 | + | r.output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "OUT-2", Width: 100, Height: 80}} | |
| 145 | + | r.output <- ports.OutputRemoved{Name: "OUT-1"} | |
| 146 | + | if st := r.state(t); st.ID != 3 || st.Reason != ports.CaptureReasonOutputGone { | |
| 147 | + | t.Fatalf("state %+v", st) | |
| 148 | + | } | |
| 149 | + | r.sceneWith(t, false) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestCaptureSessionBusy(t *testing.T) { | |
| 153 | + | r := captureCore(t, nil) | |
| 154 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 155 | + | if st := r.state(t); st.ID != 1 || !st.Active { | |
| 156 | + | t.Fatalf("state %+v", st) | |
| 157 | + | } | |
| 158 | + | r.client <- ports.CaptureSessionBegin{ID: 2, Output: "OUT-1"} | |
| 159 | + | if st := r.state(t); st.ID != 2 || st.Reason != ports.CaptureReasonBusy || st.Active { | |
| 160 | + | t.Fatalf("state %+v", st) | |
| 161 | + | } | |
| 162 | + | // The first session is undisturbed and ends only on its own request. | |
| 163 | + | r.client <- ports.CaptureSessionEnd{ID: 2} | |
| 164 | + | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 165 | + | r.sceneWith(t, false) | |
| 166 | + | r.client <- ports.CaptureSessionBegin{ID: 3, Output: "OUT-1"} | |
| 167 | + | if st := r.state(t); st.ID != 3 || !st.Active { | |
| 168 | + | t.Fatalf("state %+v", st) | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Attached layers stay over a fullscreen window while the session is active, | |
| 173 | + | // are excluded with their popups, and other layers keep hiding. | |
| 174 | + | func TestCaptureSessionLayersOverFullscreen(t *testing.T) { | |
| 175 | + | r := captureCore(t, nil) | |
| 176 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 177 | + | scene(t, r.scenes) | |
| 178 | + | r.client <- ports.WindowFullscreenRequest{ID: 1, Fullscreen: true, External: true} | |
| 179 | + | scene(t, r.scenes) | |
| 180 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerTop, 0)}} | |
| 181 | + | if s := scene(t, r.scenes); len(s.Layers) != 0 { | |
| 182 | + | t.Fatalf("fullscreen should hide layers: %+v", s.Layers) | |
| 183 | + | } | |
| 184 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 185 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 186 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} // duplicate | |
| 187 | + | r.client <- ports.PopupRequest{ID: 20, Parent: 10, Positioner: ports.Positioner{Width: 5, Height: 5, AnchorRect: ports.Rect{W: 1, H: 1}}} | |
| 188 | + | r.client <- ports.PopupMapped{ID: 20} | |
| 189 | + | var s ports.Scene | |
| 190 | + | for { | |
| 191 | + | s = r.sceneWith(t, true) | |
| 192 | + | if len(s.Capture.Excluded) == 2 { | |
| 193 | + | break | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | if s.Capture.Excluded[0] != 10 || s.Capture.Excluded[1] != 20 { | |
| 197 | + | t.Fatalf("excluded %v", s.Capture.Excluded) | |
| 198 | + | } | |
| 199 | + | if len(s.Layers) != 1 || s.Layers[0].ID != 10 { | |
| 200 | + | t.Fatalf("only the attached layer shows over fullscreen: %+v", s.Layers) | |
| 201 | + | } | |
| 202 | + | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 203 | + | if s = r.sceneWith(t, false); len(s.Layers) != 0 { | |
| 204 | + | t.Fatalf("layers after the session: %+v", s.Layers) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestCaptureSessionLayerBounds(t *testing.T) { | |
| 209 | + | r := captureCore(t, nil) | |
| 210 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 211 | + | for id := ports.WindowID(1); id <= ports.MaxCaptureSessionLayers+3; id++ { | |
| 212 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: id, Attached: true} | |
| 213 | + | } | |
| 214 | + | r.client <- ports.CaptureSessionLayer{ID: 99, Layer: 50, Attached: true} // another session | |
| 215 | + | var s ports.Scene | |
| 216 | + | for { | |
| 217 | + | s = r.sceneWith(t, true) | |
| 218 | + | if len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers { | |
| 219 | + | break | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | for _, id := range s.Capture.Excluded { | |
| 223 | + | if id > ports.MaxCaptureSessionLayers { | |
| 224 | + | t.Fatalf("excluded %v", s.Capture.Excluded) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | // Detaching removes one. | |
| 228 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 1} | |
| 229 | + | for { | |
| 230 | + | if s = r.sceneWith(t, true); len(s.Capture.Excluded) == ports.MaxCaptureSessionLayers-1 { | |
| 231 | + | break | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // An attached layer asking for exclusive keyboard never gets it; on-demand stays. | |
| 237 | + | func TestCaptureSessionKeyboardClamp(t *testing.T) { | |
| 238 | + | r := captureCore(t, nil) | |
| 239 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0), r.layer(11, ports.LayerOverlay, 0)}} | |
| 240 | + | scene(t, r.scenes) | |
| 241 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 242 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 243 | + | r.sceneWith(t, true) | |
| 244 | + | // The HUD commits exclusive afterwards; an unrelated layer may have it. | |
| 245 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 1), r.layer(11, ports.LayerOverlay, 1)}} | |
| 246 | + | for len(r.commands) > 0 { | |
| 247 | + | <-r.commands | |
| 248 | + | } | |
| 249 | + | scene(t, r.scenes) | |
| 250 | + | for { | |
| 251 | + | f, ok := receive(t, r.commands).(ports.FocusWindow) | |
| 252 | + | if ok { | |
| 253 | + | if f.ID != 11 { | |
| 254 | + | t.Fatalf("keyboard went to %d, want the unattached layer 11", f.ID) | |
| 255 | + | } | |
| 256 | + | return | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func TestCaptureSessionPingTimeout(t *testing.T) { | |
| 262 | + | fire := make(chan time.Time) | |
| 263 | + | timer := portsmocks.NewMockTimer(t) | |
| 264 | + | timer.EXPECT().Stop().Return(true).Maybe() | |
| 265 | + | timer.EXPECT().C().Return(fire) | |
| 266 | + | clock := portsmocks.NewMockClock(t) | |
| 267 | + | clock.EXPECT().Now().RunAndReturn(time.Now).Maybe() | |
| 268 | + | clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(timer).Once() | |
| 269 | + | r := captureCore(t, clock) | |
| 270 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 271 | + | if st := r.state(t); !st.Active { | |
| 272 | + | t.Fatalf("state %+v", st) | |
| 273 | + | } | |
| 274 | + | fire <- time.Now() | |
| 275 | + | if st := r.state(t); st.ID != 1 || st.Reason != ports.CaptureReasonPingTimeout || st.Active { | |
| 276 | + | t.Fatalf("state %+v", st) | |
| 277 | + | } | |
| 278 | + | r.sceneWith(t, false) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // A ping restarts the timer; the session then times out from the last one. | |
| 282 | + | func TestCaptureSessionPingRestartsTimer(t *testing.T) { | |
| 283 | + | first, second := make(chan time.Time), make(chan time.Time) | |
| 284 | + | t1, t2 := portsmocks.NewMockTimer(t), portsmocks.NewMockTimer(t) | |
| 285 | + | t1.EXPECT().Stop().Return(true).Once() | |
| 286 | + | t1.EXPECT().C().Return(first) | |
| 287 | + | t2.EXPECT().Stop().Return(true).Maybe() | |
| 288 | + | t2.EXPECT().C().Return(second) | |
| 289 | + | clock := portsmocks.NewMockClock(t) | |
| 290 | + | clock.EXPECT().Now().RunAndReturn(time.Now).Maybe() | |
| 291 | + | clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t1).Once() | |
| 292 | + | clock.EXPECT().NewTimer(ports.CaptureSessionTimeout).Return(t2).Once() | |
| 293 | + | r := captureCore(t, clock) | |
| 294 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 295 | + | r.state(t) | |
| 296 | + | r.client <- ports.CaptureSessionPing{ID: 1} | |
| 297 | + | r.client <- ports.CaptureSessionPing{ID: 7} // unknown: no effect | |
| 298 | + | r.client <- ports.WindowMapped{ID: 1} // a round trip through the owner loop | |
| 299 | + | scene(t, r.scenes) | |
| 300 | + | second <- time.Now() | |
| 301 | + | if st := r.state(t); st.Reason != ports.CaptureReasonPingTimeout { | |
| 302 | + | t.Fatalf("state %+v", st) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // Every change a clean capture depends on bumps the revision, in the state | |
| 307 | + | // and in the scene that carries it; an unchanged republish does not. | |
| 308 | + | func TestCaptureSessionRevisionFence(t *testing.T) { | |
| 309 | + | r := captureCore(t, nil) | |
| 310 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 311 | + | st := r.state(t) | |
| 312 | + | if st.Revision != 1 || len(st.Layers) != 0 { | |
| 313 | + | t.Fatalf("state %+v", st) | |
| 314 | + | } | |
| 315 | + | if s := r.sceneWith(t, true); s.Capture.Revision != 1 { | |
| 316 | + | t.Fatalf("scene revision %d", s.Capture.Revision) | |
| 317 | + | } | |
| 318 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 319 | + | st = r.state(t) | |
| 320 | + | if st.Revision != 2 || len(st.Layers) != 1 || st.Layers[0] != 10 { | |
| 321 | + | t.Fatalf("state after attach %+v", st) | |
| 322 | + | } | |
| 323 | + | var s ports.Scene | |
| 324 | + | for { | |
| 325 | + | if s = r.sceneWith(t, true); s.Capture.Revision == 2 { | |
| 326 | + | break | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 { | |
| 330 | + | t.Fatalf("scene %+v", s.Capture) | |
| 331 | + | } | |
| 332 | + | // An unrelated publish keeps the revision and sends no state. | |
| 333 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 334 | + | if s = r.sceneWith(t, true); s.Capture.Revision != 2 { | |
| 335 | + | t.Fatalf("revision moved without a change: %d", s.Capture.Revision) | |
| 336 | + | } | |
| 337 | + | for len(r.commands) > 0 { | |
| 338 | + | if v, ok := (<-r.commands).(ports.CaptureSessionState); ok { | |
| 339 | + | t.Fatalf("state without a change: %+v", v) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10} | |
| 343 | + | if st = r.state(t); st.Revision != 3 || len(st.Layers) != 0 { | |
| 344 | + | t.Fatalf("state after detach %+v", st) | |
| 345 | + | } | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | // A detached layer stays excluded until a LayerChanged stops listing it, so | |
| 349 | + | // the last frame of a destroyed HUD never leaks into a capture. | |
| 350 | + | func TestCaptureSessionDetachRetainsExclusion(t *testing.T) { | |
| 351 | + | r := captureCore(t, nil) | |
| 352 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}} | |
| 353 | + | scene(t, r.scenes) | |
| 354 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 355 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 356 | + | for { | |
| 357 | + | if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 { | |
| 358 | + | break | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | // Detach while the layer is still listed: still excluded. | |
| 362 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10} | |
| 363 | + | s := r.sceneWith(t, true) | |
| 364 | + | if len(s.Capture.Excluded) != 1 || s.Capture.Excluded[0] != 10 { | |
| 365 | + | t.Fatalf("detached layer no longer excluded while mapped: %+v", s.Capture) | |
| 366 | + | } | |
| 367 | + | // Once the layer unmaps, the exclusion goes. | |
| 368 | + | r.client <- ports.LayerChanged{Layers: nil} | |
| 369 | + | for { | |
| 370 | + | if s = r.sceneWith(t, true); len(s.Capture.Excluded) == 0 { | |
| 371 | + | break | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // An unmap queued before the detach (the wayland order) never leaves a | |
| 377 | + | // window where the layer is listed but not excluded. | |
| 378 | + | func TestCaptureSessionUnmapThenDetach(t *testing.T) { | |
| 379 | + | r := captureCore(t, nil) | |
| 380 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 0)}} | |
| 381 | + | scene(t, r.scenes) | |
| 382 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 383 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 384 | + | for { | |
| 385 | + | if s := r.sceneWith(t, true); len(s.Capture.Excluded) == 1 { | |
| 386 | + | break | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | r.client <- ports.LayerChanged{Layers: nil} | |
| 390 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10} | |
| 391 | + | for { | |
| 392 | + | s := r.sceneWith(t, true) | |
| 393 | + | if len(s.Layers) != 0 && len(s.Capture.Excluded) == 0 { | |
| 394 | + | t.Fatalf("layer shown but not excluded: %+v %+v", s.Layers, s.Capture) | |
| 395 | + | } | |
| 396 | + | if len(s.Layers) == 0 { | |
| 397 | + | return | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // An attached layer never gets keyboard interactivity, exclusive or on demand. | |
| 403 | + | func TestCaptureSessionKeyboardNone(t *testing.T) { | |
| 404 | + | r := captureCore(t, nil) | |
| 405 | + | r.client <- ports.LayerChanged{Layers: []ports.LayerSurface{r.layer(10, ports.LayerOverlay, 2), r.layer(11, ports.LayerOverlay, 2)}} | |
| 406 | + | scene(t, r.scenes) | |
| 407 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1"} | |
| 408 | + | r.client <- ports.CaptureSessionLayer{ID: 1, Layer: 10, Attached: true} | |
| 409 | + | r.sceneWith(t, true) | |
| 410 | + | for len(r.commands) > 0 { | |
| 411 | + | <-r.commands | |
| 412 | + | } | |
| 413 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 414 | + | scene(t, r.scenes) | |
| 415 | + | for len(r.commands) > 0 { | |
| 416 | + | if f, ok := (<-r.commands).(ports.FocusWindow); ok && f.ID == 10 { | |
| 417 | + | t.Fatalf("keyboard went to the attached layer") | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | } | |
A internal/core/capture_workspace.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // captureWorkspace is the workspace a capture session renders off screen | |
| 10 | + | // while it is not the one on screen. Its windows keep a real layout in the | |
| 11 | + | // workspace viewport: they are sized (configure Captured) and drawn into | |
| 12 | + | // Scene.CaptureScene, but they are hidden on the physical layout, so they | |
| 13 | + | // take no pointer input and no physical presentation feedback. | |
| 14 | + | // | |
| 15 | + | // Only the core goroutine touches it. The index map and its storage are | |
| 16 | + | // reused by every publish (cleared, never reallocated once warm). | |
| 17 | + | type captureWorkspace struct { | |
| 18 | + | sc *screen | |
| 19 | + | ws *Workspace // nil: no workspace is captured off screen | |
| 20 | + | frame Rect // ws.Output: the viewport, in monitor coordinates | |
| 21 | + | placements []Placement | |
| 22 | + | index map[WindowID]int | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // active reports whether a hidden workspace is captured. | |
| 26 | + | func (cw *captureWorkspace) active() bool { return cw != nil && cw.ws != nil } | |
| 27 | + | ||
| 28 | + | // reset forgets the captured workspace, keeping the storage. | |
| 29 | + | func (cw *captureWorkspace) reset() { | |
| 30 | + | cw.sc, cw.ws, cw.frame = nil, nil, Rect{} | |
| 31 | + | cw.placements = nil | |
| 32 | + | clear(cw.index) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // load lays out ws in its own viewport. ws.Layout allocates the placements | |
| 36 | + | // (the baseline of the existing layout); the index reuses its map. | |
| 37 | + | func (cw *captureWorkspace) load(sc *screen, ws *Workspace) { | |
| 38 | + | cw.sc, cw.ws, cw.frame = sc, ws, ws.Output | |
| 39 | + | cw.placements = ws.Layout() | |
| 40 | + | if cw.index == nil { | |
| 41 | + | cw.index = make(map[WindowID]int, len(cw.placements)) | |
| 42 | + | } | |
| 43 | + | clear(cw.index) | |
| 44 | + | for i := range cw.placements { | |
| 45 | + | cw.index[cw.placements[i].ID] = i | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // placement is the real placement of a window of the captured workspace. | |
| 50 | + | func (cw *captureWorkspace) placement(id WindowID) *Placement { | |
| 51 | + | if !cw.active() { | |
| 52 | + | return nil | |
| 53 | + | } | |
| 54 | + | if i, ok := cw.index[id]; ok { | |
| 55 | + | return &cw.placements[i] | |
| 56 | + | } | |
| 57 | + | return nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // captureTrack follows the session view of this publish: it lays out the | |
| 61 | + | // captured workspace when it is not drawn as itself (another workspace is | |
| 62 | + | // on screen, or the overview shows it as a preview) and forgets it | |
| 63 | + | // otherwise. It runs | |
| 64 | + | // right after captureEvaluate, before the popups and configures that use it. | |
| 65 | + | func (c *Core) captureTrack(view *captureView) { | |
| 66 | + | // Tracking follows the non-terminal view, not its Active flag: while the | |
| 67 | + | // captured workspace slides in its own viewport its windows stay captured | |
| 68 | + | // (not suspended) and its popups stay. A terminal or absent session has no | |
| 69 | + | // view. | |
| 70 | + | if view == nil || !view.hidden || view.state.Workspace == 0 { | |
| 71 | + | c.configures.cw.reset() | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | sc, ws := c.workspaceByID(view.state.Workspace) | |
| 75 | + | if ws == nil || sc == nil || sc.name() != view.output || (sc.mon.Current() == ws && !sc.mon.ov.open) { | |
| 76 | + | c.configures.cw.reset() | |
| 77 | + | return | |
| 78 | + | } | |
| 79 | + | c.configures.cw.load(sc, ws) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // captureConfigure picks how a physical placement p on sc is configured: | |
| 83 | + | // for a window of the captured workspace it returns the real placement and | |
| 84 | + | // the target sized for it, else nil and t unchanged. Pass both to | |
| 85 | + | // configures.nextWithCapture. | |
| 86 | + | func (c *Core) captureConfigure(sc *screen, p Placement, t configureTarget) (*Placement, configureTarget) { | |
| 87 | + | cw := &c.configures.cw | |
| 88 | + | if !cw.active() || cw.sc != sc { | |
| 89 | + | return nil, t | |
| 90 | + | } | |
| 91 | + | cp := cw.placement(p.ID) | |
| 92 | + | if cp == nil { | |
| 93 | + | return nil, t | |
| 94 | + | } | |
| 95 | + | t.captureArea = cw.frame | |
| 96 | + | if !p.Hidden { | |
| 97 | + | // An overview preview keeps its real size and physical state. | |
| 98 | + | return cp, t | |
| 99 | + | } | |
| 100 | + | t.area = cw.frame | |
| 101 | + | t.focused = false | |
| 102 | + | t.client, t.imposed = Rect{}, false | |
| 103 | + | if !cp.Hidden { | |
| 104 | + | t.client, t.imposed = c.clientRect(*cp), cw.ws.imposedFloat(cp.ID) | |
| 105 | + | } | |
| 106 | + | return cp, t | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // captureRect is where a window of the captured workspace, or a popup | |
| 110 | + | // hanging from one, is: its client area in monitor coordinates. Unlike | |
| 111 | + | // windowRect it knows hidden workspaces; it never serves input. A stale | |
| 112 | + | // captured workspace (its screen or workspace gone) has no geometry. | |
| 113 | + | func (c *Core) captureRect(id WindowID) (*screen, Rect, bool) { | |
| 114 | + | cw := &c.configures.cw | |
| 115 | + | if !cw.active() || !slices.Contains(c.screens, cw.sc) || !cw.sc.mon.has(cw.ws) { | |
| 116 | + | return nil, Rect{}, false | |
| 117 | + | } | |
| 118 | + | if p := c.popups[id]; p != nil { | |
| 119 | + | sc, pr, ok := c.captureRect(p.parent) | |
| 120 | + | if !ok { | |
| 121 | + | return nil, Rect{}, false | |
| 122 | + | } | |
| 123 | + | if cp := cw.placement(c.popupRoot(id)); cp != nil && cp.Peek { | |
| 124 | + | return nil, Rect{}, false | |
| 125 | + | } | |
| 126 | + | return sc, Rect{X: pr.X + p.rect.X, Y: pr.Y + p.rect.Y, W: p.rect.W, H: p.rect.H}, true | |
| 127 | + | } | |
| 128 | + | cp := cw.placement(id) | |
| 129 | + | if cp == nil || cp.Hidden || !cp.Rect.Overlaps(cw.frame) { | |
| 130 | + | return nil, Rect{}, false | |
| 131 | + | } | |
| 132 | + | return cw.sc, c.clientRect(*cp), true | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // popupRect is windowRect that also knows the captured workspace; physical | |
| 136 | + | // tells which one answered (a single physical lookup). | |
| 137 | + | func (c *Core) popupRect(id WindowID) (sc *screen, r Rect, physical, ok bool) { | |
| 138 | + | if sc, r, ok := c.windowRect(id); ok { | |
| 139 | + | return sc, r, true, true | |
| 140 | + | } | |
| 141 | + | sc, r, ok = c.captureRect(id) | |
| 142 | + | return sc, r, false, ok | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // captureScene is the captured workspace drawn for capture only: a scene the | |
| 146 | + | // size of its viewport, whose windows and lines are rebased on the viewport | |
| 147 | + | // origin. It has no layers and no capture of its own. nil without a | |
| 148 | + | // captured workspace. | |
| 149 | + | func (c *Core) captureScene(seq uint64) *ports.Scene { | |
| 150 | + | cw := &c.configures.cw | |
| 151 | + | if !cw.active() { | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | f := cw.frame | |
| 155 | + | rebase := func(r Rect) Rect { r.X, r.Y = r.X-f.X, r.Y-f.Y; return r } | |
| 156 | + | s := &ports.Scene{ | |
| 157 | + | Output: cw.sc.name(), Seq: seq, OutputWidth: f.W, OutputHeight: f.H, WorkspaceClip: Rect{W: f.W, H: f.H}, | |
| 158 | + | Scale: cw.sc.scale, Background: c.cfg.Background.Color, | |
| 159 | + | Border: ports.Border{Width: c.cfg.Border.Width, Active: c.cfg.Border.Active, Inactive: c.cfg.Border.Inactive}, | |
| 160 | + | Windows: make([]ports.SceneWindow, 0, len(cw.placements)), | |
| 161 | + | Dim: floatDim(cw.placements, f, c.cfg.Floating.Dim), | |
| 162 | + | } | |
| 163 | + | // A hidden workspace lights nothing: its focus is not shown. | |
| 164 | + | for _, p := range cw.placements { | |
| 165 | + | w := ports.SceneWindow{ID: p.ID, Focused: false, Fullscreen: p.Fullscreen, Hidden: p.Hidden, Floating: p.Floating, Below: p.Below, Inset: p.Inset, Preview: p.Preview} | |
| 166 | + | if !p.Hidden { | |
| 167 | + | w.Rect = rebase(p.Rect) | |
| 168 | + | } | |
| 169 | + | if p.Peek { | |
| 170 | + | w.Dim = c.cfg.Stash.Dim | |
| 171 | + | } | |
| 172 | + | s.Windows = append(s.Windows, w) | |
| 173 | + | } | |
| 174 | + | s.Separators = separators(cw.placements, c.cfg.Border.Width, cw.ws.gap(), f, false) | |
| 175 | + | for i := range s.Separators { | |
| 176 | + | s.Separators[i].Rect = rebase(s.Separators[i].Rect) | |
| 177 | + | } | |
| 178 | + | for _, id := range c.popupOrder { | |
| 179 | + | p := c.popups[id] | |
| 180 | + | if p == nil || !p.mapped { | |
| 181 | + | continue | |
| 182 | + | } | |
| 183 | + | if _, r, ok := c.captureRect(id); ok { | |
| 184 | + | s.Windows = append(s.Windows, ports.SceneWindow{ID: id, Rect: rebase(r), Popup: true}) | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | return s | |
| 188 | + | } | |
A internal/core/capture_workspace_e2e_test.go +86 -0
| @@ -0,0 +1,86 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // configureOf returns the next ConfigureWindow of id, skipping other commands. | |
| 10 | + | func (r *captureRig) configureOf(t *testing.T, id ports.WindowID) ports.ConfigureWindow { | |
| 11 | + | t.Helper() | |
| 12 | + | for { | |
| 13 | + | if v, ok := receive(t, r.commands).(ports.ConfigureWindow); ok && v.ID == id { | |
| 14 | + | return v | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // A session keeps capturing a workspace that leaves the screen: the state | |
| 20 | + | // stays active, the scene carries a sibling CaptureScene, and the window is | |
| 21 | + | // configured Captured (sized, physically invisible) until the session stops. | |
| 22 | + | func TestCaptureSessionSurvivesWorkspaceSwitch(t *testing.T) { | |
| 23 | + | r := captureCore(t, nil) | |
| 24 | + | r.client <- ports.WindowMapped{ID: 1} | |
| 25 | + | scene(t, r.scenes) | |
| 26 | + | // Mapping the window adds a spare workspace: wait for the inventory that lists it. | |
| 27 | + | var ws []ports.WorkspaceInfo | |
| 28 | + | for len(ws) < 2 { | |
| 29 | + | ws = receive(t, r.workspaces).Outputs[0].Workspaces | |
| 30 | + | } | |
| 31 | + | r.client <- ports.CaptureSessionBegin{ID: 1, Output: "OUT-1", Workspace: ws[0].ID} | |
| 32 | + | if st := r.state(t); !st.Active { | |
| 33 | + | t.Fatalf("state %+v", st) | |
| 34 | + | } | |
| 35 | + | on := r.sceneWith(t, true) | |
| 36 | + | if on.CaptureScene != nil { | |
| 37 | + | t.Fatalf("a workspace on screen needs no capture scene: %+v", on.CaptureScene) | |
| 38 | + | } | |
| 39 | + | // The window is shown and not Captured until its workspace leaves. | |
| 40 | + | for len(r.commands) > 0 { | |
| 41 | + | if v, ok := (<-r.commands).(ports.ConfigureWindow); ok && v.ID == 1 && (!v.Visible || v.Captured) { | |
| 42 | + | t.Fatalf("shown window %+v", v) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | r.client <- ports.WorkspaceActivate{IDs: []uint64{ws[1].ID}} | |
| 47 | + | var off ports.Scene | |
| 48 | + | for { | |
| 49 | + | off = scene(t, r.scenes) | |
| 50 | + | if off.CaptureScene != nil { | |
| 51 | + | break | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | if off.Capture.Workspace != ws[0].ID { | |
| 55 | + | t.Fatalf("capture %+v", off.Capture) | |
| 56 | + | } | |
| 57 | + | c := off.CaptureScene | |
| 58 | + | if c.Capture != nil || c.CaptureScene != nil || len(c.Layers) != 0 || c.Seq != off.Seq || c.OutputWidth != 100 || c.OutputHeight != 80 { | |
| 59 | + | t.Fatalf("capture scene %+v (root seq %d)", c, off.Seq) | |
| 60 | + | } | |
| 61 | + | if len(c.Windows) != 1 || c.Windows[0].ID != 1 || c.Windows[0].Hidden || c.Windows[0].Rect.W <= 0 { | |
| 62 | + | t.Fatalf("capture windows %+v", c.Windows) | |
| 63 | + | } | |
| 64 | + | for _, w := range off.Windows { | |
| 65 | + | if w.ID == 1 && !w.Hidden { | |
| 66 | + | t.Fatalf("captured window drawn on screen: %+v", w) | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | hid := r.configureOf(t, 1) | |
| 70 | + | if hid.Visible || hid.Activated || !hid.Captured || hid.Width <= 0 { | |
| 71 | + | t.Fatalf("captured hidden window %+v", hid) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Stopping the session hides the window for real again. | |
| 75 | + | r.client <- ports.CaptureSessionEnd{ID: 1} | |
| 76 | + | var after ports.Scene | |
| 77 | + | for { | |
| 78 | + | if after = scene(t, r.scenes); after.Capture == nil { | |
| 79 | + | break | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | end := r.configureOf(t, 1) | |
| 83 | + | if end.Captured || end.Visible || end.Width != hid.Width { | |
| 84 | + | t.Fatalf("released window %+v", end) | |
| 85 | + | } | |
| 86 | + | } | |
A internal/core/capture_workspace_test.go +274 -0
| @@ -0,0 +1,274 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // hiddenCapture is a 300x200 output whose first workspace (windows 1 and 2, | |
| 12 | + | // a 100x100 viewport) is off screen, captured. | |
| 13 | + | func hiddenCapture(t *testing.T) (*Core, *Workspace) { | |
| 14 | + | t.Helper() | |
| 15 | + | c, ws, _ := hiddenCaptureCommands(t) | |
| 16 | + | return c, ws | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // hiddenCaptureCommands is hiddenCapture with the channel core sends its commands on. | |
| 20 | + | func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCommand) { | |
| 21 | + | t.Helper() | |
| 22 | + | commands := make(chan ports.ClientCommand, 64) | |
| 23 | + | var cfg ports.Config | |
| 24 | + | cfg.Keyboard.CmdKey = "super" | |
| 25 | + | cfg.Layout.MaxColumns = 2 | |
| 26 | + | cfg.Border.Width = 2 | |
| 27 | + | cfg.Background.Color = "#102030" | |
| 28 | + | c, err := New(cfg, Channels{ | |
| 29 | + | Scenes: make(chan []ports.Scene, 1), Commands: commands, | |
| 30 | + | }) | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200}) | |
| 35 | + | m := c.screens[0].mon | |
| 36 | + | ws := m.Current() | |
| 37 | + | ws.SetSize(100, 100) | |
| 38 | + | m.AddWindow(1) | |
| 39 | + | m.AddWindow(2) | |
| 40 | + | m.Focus(1) | |
| 41 | + | if m.Current() == ws { | |
| 42 | + | t.Fatal("workspace still on screen") | |
| 43 | + | } | |
| 44 | + | c.configures.cw.load(c.screens[0], ws) | |
| 45 | + | return c, ws, commands | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) { | |
| 49 | + | c, _ := hiddenCapture(t) | |
| 50 | + | sc := c.screens[0] | |
| 51 | + | phys := Placement{ID: 1, Hidden: true} | |
| 52 | + | t0 := configureTarget{output: "A", area: sc.mon.Frame()} | |
| 53 | + | cp, tt := c.captureConfigure(sc, phys, t0) | |
| 54 | + | if cp == nil || cp.Hidden { | |
| 55 | + | t.Fatalf("no real placement: %+v", cp) | |
| 56 | + | } | |
| 57 | + | v, send := c.configures.nextWithCapture(phys, tt, cp) | |
| 58 | + | want := c.clientRect(*cp) | |
| 59 | + | if !send || v.Width != want.W || v.Height != want.H || want.W == 0 { | |
| 60 | + | t.Fatalf("configure %+v send %v want size %+v", v, send, want) | |
| 61 | + | } | |
| 62 | + | if v.Visible || v.Activated || !v.Captured || v.Output != "A" { | |
| 63 | + | t.Fatalf("captured hidden window must be sized, unfocused, physically invisible: %+v", v) | |
| 64 | + | } | |
| 65 | + | c.configures.mark(v) | |
| 66 | + | if _, send := c.configures.nextWithCapture(phys, tt, cp); send { | |
| 67 | + | t.Fatal("unchanged captured configure sent again") | |
| 68 | + | } | |
| 69 | + | // The session stops: the window is only hidden again, keeps its size and drops Captured. | |
| 70 | + | c.configures.cw.reset() | |
| 71 | + | cp, tt = c.captureConfigure(sc, phys, t0) | |
| 72 | + | if cp != nil { | |
| 73 | + | t.Fatal("placement kept after the session") | |
| 74 | + | } | |
| 75 | + | v2, send := c.configures.nextWithCapture(phys, tt, cp) | |
| 76 | + | if !send || v2.Captured || v2.Visible || v2.Width != v.Width || v2.Height != v.Height { | |
| 77 | + | t.Fatalf("released window %+v send %v after %+v", v2, send, v) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) { | |
| 82 | + | c, _ := hiddenCapture(t) | |
| 83 | + | sc := c.screens[0] | |
| 84 | + | phys := Placement{ID: 99, Hidden: true} | |
| 85 | + | t0 := configureTarget{output: "A", area: sc.mon.Frame()} | |
| 86 | + | if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 { | |
| 87 | + | t.Fatalf("window of another workspace captured: %+v", cp) | |
| 88 | + | } | |
| 89 | + | v, _ := c.configures.nextWithCapture(phys, t0, nil) | |
| 90 | + | if v.Captured || v.Visible || v.Width != 0 { | |
| 91 | + | t.Fatalf("plain hidden configure %+v", v) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestCaptureSceneRebasesOnViewport(t *testing.T) { | |
| 96 | + | c, ws := hiddenCapture(t) | |
| 97 | + | f := ws.Output | |
| 98 | + | if f != (Rect{X: 100, Y: 50, W: 100, H: 100}) { | |
| 99 | + | t.Fatalf("viewport %+v", f) | |
| 100 | + | } | |
| 101 | + | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 102 | + | c.popupOrder = append(c.popupOrder, 10) | |
| 103 | + | s := c.captureScene(7) | |
| 104 | + | if s == nil || s.OutputWidth != 100 || s.OutputHeight != 100 || s.WorkspaceClip != (Rect{W: 100, H: 100}) || s.Seq != 7 || s.Output != "A" { | |
| 105 | + | t.Fatalf("scene %+v", s) | |
| 106 | + | } | |
| 107 | + | if s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || s.Background != "#102030" || s.Border.Width != 2 { | |
| 108 | + | t.Fatalf("capture scene must be flat, layerless and use the normal config: %+v", s) | |
| 109 | + | } | |
| 110 | + | byID := map[WindowID]ports.SceneWindow{} | |
| 111 | + | for _, w := range s.Windows { | |
| 112 | + | byID[w.ID] = w | |
| 113 | + | } | |
| 114 | + | for _, p := range ws.Layout() { | |
| 115 | + | w := byID[p.ID] | |
| 116 | + | if want := (Rect{X: p.Rect.X - f.X, Y: p.Rect.Y - f.Y, W: p.Rect.W, H: p.Rect.H}); w.Rect != want || w.Focused { | |
| 117 | + | t.Fatalf("window %d rect %+v want %+v focused %v", p.ID, w.Rect, want, w.Focused) | |
| 118 | + | } | |
| 119 | + | if w.Rect.X < 0 || w.Rect.Y < 0 || w.Rect.X+w.Rect.W > 100 || w.Rect.Y+w.Rect.H > 100 { | |
| 120 | + | t.Fatalf("window %d outside its viewport: %+v", p.ID, w.Rect) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | client := c.clientRect(ws.Layout()[0]) | |
| 124 | + | pop := byID[10] | |
| 125 | + | if !pop.Popup || pop.Rect != (Rect{X: client.X - f.X + 3, Y: client.Y - f.Y + 4, W: 10, H: 8}) { | |
| 126 | + | t.Fatalf("popup %+v client %+v", pop, client) | |
| 127 | + | } | |
| 128 | + | for _, sep := range s.Separators { | |
| 129 | + | if sep.Rect.X < 0 || sep.Rect.Y < 0 || sep.Rect.X+sep.Rect.W > 100 || sep.Rect.Y+sep.Rect.H > 100 { | |
| 130 | + | t.Fatalf("separator outside the viewport: %+v", sep) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | c.configures.cw.reset() | |
| 134 | + | if c.captureScene(8) != nil { | |
| 135 | + | t.Fatal("scene without a captured workspace") | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestCapturedPopupsSurviveButHiddenWindowsTakeNoInput(t *testing.T) { | |
| 140 | + | c, _ := hiddenCapture(t) | |
| 141 | + | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 142 | + | c.popupOrder = append(c.popupOrder, 10) | |
| 143 | + | if _, _, ok := c.windowRect(10); ok { | |
| 144 | + | t.Fatal("captured popup is physically on screen") | |
| 145 | + | } | |
| 146 | + | if _, _, ok := c.windowRect(1); ok || c.visible(1) { | |
| 147 | + | t.Fatal("captured window is physically on screen") | |
| 148 | + | } | |
| 149 | + | if id, _, _ := c.hit(150, 100); id != 0 { | |
| 150 | + | t.Fatalf("pointer hit hidden window %d", id) | |
| 151 | + | } | |
| 152 | + | if sc := c.screens[0]; len(c.scenePopups(sc)) != 0 { | |
| 153 | + | t.Fatal("captured popup drawn on the physical scene") | |
| 154 | + | } | |
| 155 | + | if err := c.closeHiddenPopups(context.Background()); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | if c.popups[10] == nil { | |
| 159 | + | t.Fatal("popup of a captured window dismissed") | |
| 160 | + | } | |
| 161 | + | c.configures.cw.reset() | |
| 162 | + | if err := c.closeHiddenPopups(context.Background()); err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | if c.popups[10] != nil { | |
| 166 | + | t.Fatal("popup of a hidden window kept after the session") | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestCaptureWorkspaceReusesStorage(t *testing.T) { | |
| 171 | + | c, ws := hiddenCapture(t) | |
| 172 | + | sc := c.screens[0] | |
| 173 | + | cw := &c.configures.cw | |
| 174 | + | layoutAllocs := testing.AllocsPerRun(50, func() { _ = ws.Layout() }) | |
| 175 | + | loadAllocs := testing.AllocsPerRun(50, func() { cw.load(sc, ws) }) | |
| 176 | + | if loadAllocs > layoutAllocs { | |
| 177 | + | t.Fatalf("load allocates %v, more than the layout it wraps (%v)", loadAllocs, layoutAllocs) | |
| 178 | + | } | |
| 179 | + | if got := testing.AllocsPerRun(50, func() { | |
| 180 | + | cw.reset() | |
| 181 | + | cw.load(sc, ws) | |
| 182 | + | }); got > layoutAllocs { | |
| 183 | + | t.Fatalf("reset+load allocates %v, layout %v", got, layoutAllocs) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // While the captured workspace itself slides, the live (Active) session keeps | |
| 188 | + | // tracking it in its own viewport: its windows stay captured and its popups | |
| 189 | + | // stay, and both are forgotten when the session ends. | |
| 190 | + | func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) { | |
| 191 | + | c, ws, _ := hiddenCaptureCommands(t) | |
| 192 | + | c.configures.cw.reset() | |
| 193 | + | c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}} | |
| 194 | + | c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true} | |
| 195 | + | c.popupOrder = append(c.popupOrder, 10) | |
| 196 | + | ws.motion = newMotion(viewSpring(10, 0), time.Time{}) | |
| 197 | + | ctx := context.Background() | |
| 198 | + | if err := c.publish(ctx); err != nil { | |
| 199 | + | t.Fatal(err) | |
| 200 | + | } | |
| 201 | + | if v := c.capture.last; !v.hidden || !v.state.Active || v.state.Reason.Terminal() { | |
| 202 | + | t.Fatalf("session view %+v", v) | |
| 203 | + | } | |
| 204 | + | if !c.configures.cw.active() || c.popups[10] == nil { | |
| 205 | + | t.Fatal("sliding session lost its captured workspace or popup") | |
| 206 | + | } | |
| 207 | + | if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 { | |
| 208 | + | t.Fatalf("window during the slide %+v (sent %v)", v, ok) | |
| 209 | + | } | |
| 210 | + | ws.motion = nil | |
| 211 | + | if err := c.publish(ctx); err != nil { | |
| 212 | + | t.Fatal(err) | |
| 213 | + | } | |
| 214 | + | if !c.configures.cw.active() { | |
| 215 | + | t.Fatalf("captured workspace lost after the slide: %+v", c.capture.last.state) | |
| 216 | + | } | |
| 217 | + | // The session ends: the captured workspace is forgotten and its popup goes. | |
| 218 | + | c.dropCapture() | |
| 219 | + | if err := c.publish(ctx); err != nil { | |
| 220 | + | t.Fatal(err) | |
| 221 | + | } | |
| 222 | + | if c.configures.cw.active() || c.popups[10] != nil { | |
| 223 | + | t.Fatal("captured workspace or popup kept after the session") | |
| 224 | + | } | |
| 225 | + | if v, ok := c.configures.sent[1]; !ok || v.Captured || v.Visible || v.Width <= 0 { | |
| 226 | + | t.Fatalf("window after the session %+v (sent %v)", v, ok) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // In the overview the captured workspace's windows keep their real size and | |
| 231 | + | // their physical state; nothing is sized as a thumbnail. | |
| 232 | + | func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) { | |
| 233 | + | c, ws, _ := hiddenCaptureCommands(t) | |
| 234 | + | c.configures.cw.reset() | |
| 235 | + | m := c.screens[0].mon | |
| 236 | + | m.Focus(0) | |
| 237 | + | if m.Current() != ws { | |
| 238 | + | t.Fatal("workspace not current") | |
| 239 | + | } | |
| 240 | + | ctx := context.Background() | |
| 241 | + | if err := c.publish(ctx); err != nil { | |
| 242 | + | t.Fatal(err) | |
| 243 | + | } | |
| 244 | + | before := map[WindowID]ports.ConfigureWindow{} | |
| 245 | + | for _, p := range ws.Layout() { | |
| 246 | + | v, ok := c.configures.sent[p.ID] | |
| 247 | + | if !ok || v.Width <= 0 || !v.Visible || v.Captured { | |
| 248 | + | t.Fatalf("window %d before the overview %+v (sent %v)", p.ID, v, ok) | |
| 249 | + | } | |
| 250 | + | before[p.ID] = v | |
| 251 | + | } | |
| 252 | + | m.ToggleOverview() | |
| 253 | + | c.capture = &captureSession{begin: ports.CaptureSessionBegin{ID: 1, Output: "A", Workspace: ws.ID}} | |
| 254 | + | if err := c.publish(ctx); err != nil { | |
| 255 | + | t.Fatal(err) | |
| 256 | + | } | |
| 257 | + | if !c.configures.cw.active() { | |
| 258 | + | t.Fatal("overview did not track the captured workspace") | |
| 259 | + | } | |
| 260 | + | for _, p := range ws.Layout() { | |
| 261 | + | v, ok := c.configures.sent[p.ID] | |
| 262 | + | if !ok { | |
| 263 | + | t.Fatalf("window %d has no configure in the overview", p.ID) | |
| 264 | + | } | |
| 265 | + | // A preview keeps the client's real size and its physical state; it | |
| 266 | + | // is on screen, so it is not Captured. | |
| 267 | + | if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height || v.Width != c.clientRect(p).W || v.Height != c.clientRect(p).H { | |
| 268 | + | t.Fatalf("window %d configured %dx%d in the overview, real %dx%d", p.ID, v.Width, v.Height, c.clientRect(p).W, c.clientRect(p).H) | |
| 269 | + | } | |
| 270 | + | if !v.Visible || v.Captured || v.Activated != before[p.ID].Activated { | |
| 271 | + | t.Fatalf("window %d shown as a preview but %+v, was %+v", p.ID, v, before[p.ID]) | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | } | |
M internal/core/configures.go +45 -3
| @@ -11,6 +11,9 @@ type configureTarget struct { | |||
| 11 | 11 | // floating window (native floats pick their own size). | |
| 12 | 12 | client Rect | |
| 13 | 13 | imposed bool | |
| 14 | + | // captureArea is the viewport of the workspace a capture session renders | |
| 15 | + | // off screen, when this window belongs to it (captureConfigure). | |
| 16 | + | captureArea Rect | |
| 14 | 17 | // realTiled sizes a visible tiled preview from its real client rect. | |
| 15 | 18 | realTiled bool | |
| 16 | 19 | } | |
| @@ -20,6 +23,8 @@ type configureTarget struct { | |||
| 20 | 23 | type configures struct { | |
| 21 | 24 | sent map[WindowID]ports.ConfigureWindow | |
| 22 | 25 | seen map[WindowID]bool | |
| 26 | + | // cw is the workspace a capture session renders off screen, if any. | |
| 27 | + | cw captureWorkspace | |
| 23 | 28 | } | |
| 24 | 29 | ||
| 25 | 30 | func newConfigures() configures { | |
| @@ -29,8 +34,45 @@ func newConfigures() configures { | |||
| 29 | 34 | // next returns the configure for p at t and whether it must be sent. The | |
| 30 | 35 | // caller records it with mark once sent. | |
| 31 | 36 | func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow, bool) { | |
| 37 | + | s.seen[p.ID] = true | |
| 38 | + | old, ok := s.sent[p.ID] | |
| 39 | + | v := build(p, t, old, ok) | |
| 40 | + | return v, !ok || v != old | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // nextWithCapture is next for a window of a workspace a capture session | |
| 44 | + | // renders off screen. p is its placement on the physical layout; cp is its | |
| 45 | + | // real placement on its own workspace, laid out in the viewport | |
| 46 | + | // t.captureArea (nil: not captured, exactly next). | |
| 47 | + | // | |
| 48 | + | // A hidden p is sized from cp like a shown window and stays physically | |
| 49 | + | // invisible and deactivated (Visible false). Any other p (an overview | |
| 50 | + | // preview) is configured as usual, keeping its real size; only when the | |
| 51 | + | // display does not show it does it get Captured, so a capture that draws it | |
| 52 | + | // does not see it suspended. Captured is set when cp is drawn in the | |
| 53 | + | // viewport. | |
| 54 | + | func (s *configures) nextWithCapture(p Placement, t configureTarget, cp *Placement) (ports.ConfigureWindow, bool) { | |
| 55 | + | if cp == nil { | |
| 56 | + | return s.next(p, t) | |
| 57 | + | } | |
| 32 | 58 | s.seen[p.ID] = true | |
| 33 | 59 | old, ok := s.sent[p.ID] | |
| 60 | + | var v ports.ConfigureWindow | |
| 61 | + | if p.Hidden { | |
| 62 | + | t.focused = false | |
| 63 | + | v = build(*cp, t, old, ok) | |
| 64 | + | v.Activated, v.Visible = false, false | |
| 65 | + | } else { | |
| 66 | + | v = build(p, t, old, ok) | |
| 67 | + | } | |
| 68 | + | v.Captured = !v.Visible && !cp.Hidden && cp.Rect.Overlaps(t.captureArea) | |
| 69 | + | return v, !ok || v != old | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // build is the configure for p at t given the last one sent, if any. It | |
| 73 | + | // never sets Captured: only nextWithCapture does, so a window that leaves a | |
| 74 | + | // capture session drops the flag with its next configure. | |
| 75 | + | func build(p Placement, t configureTarget, old ports.ConfigureWindow, ok bool) ports.ConfigureWindow { | |
| 34 | 76 | var v ports.ConfigureWindow | |
| 35 | 77 | switch { | |
| 36 | 78 | case p.Hidden: | |
| @@ -41,7 +83,7 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||
| 41 | 83 | if !ok { | |
| 42 | 84 | v = ports.ConfigureWindow{ID: p.ID, Floating: p.Floating} | |
| 43 | 85 | } | |
| 44 | - | v.Activated, v.Visible, v.Output = false, false, t.output | |
| 86 | + | v.Activated, v.Visible, v.Output, v.Captured = false, false, t.output, false | |
| 45 | 87 | case p.Preview > 0: | |
| 46 | 88 | // A preview keeps its client's size: only its focus changes. | |
| 47 | 89 | v = old | |
| @@ -60,14 +102,14 @@ func (s *configures) next(p Placement, t configureTarget) (ports.ConfigureWindow | |||
| 60 | 102 | if t.realTiled { | |
| 61 | 103 | v.Width, v.Height = t.client.W, t.client.H | |
| 62 | 104 | } | |
| 63 | - | v.Activated, v.Visible, v.Output = t.focused, onScreen(p, t.area), t.output | |
| 105 | + | v.Activated, v.Visible, v.Output, v.Captured = t.focused, onScreen(p, t.area), t.output, false | |
| 64 | 106 | default: | |
| 65 | 107 | v = ports.ConfigureWindow{ID: p.ID, Width: t.client.W, Height: t.client.H, Fullscreen: p.Fullscreen, Activated: t.focused, Floating: p.Floating && !p.Fullscreen, Output: t.output, Visible: onScreen(p, t.area)} | |
| 66 | 108 | if v.Floating && !t.imposed { | |
| 67 | 109 | v.Width, v.Height = 0, 0 | |
| 68 | 110 | } | |
| 69 | 111 | } | |
| 70 | - | return v, !ok || v != old | |
| 112 | + | return v | |
| 71 | 113 | } | |
| 72 | 114 | ||
| 73 | 115 | // mark records v as sent. | |
M internal/core/core.go +38 -1
| @@ -111,6 +111,11 @@ type Core struct { | |||
| 111 | 111 | // last position sent in the pointer's window. | |
| 112 | 112 | motionMsec uint32 | |
| 113 | 113 | pointerAt [2]float64 | |
| 114 | + | // capture is the live private capture session (capture_session.go); | |
| 115 | + | // captureC fires when its owner stopped pinging. | |
| 116 | + | capture *captureSession | |
| 117 | + | captureC <-chan time.Time | |
| 118 | + | captureStop func() bool | |
| 114 | 119 | } | |
| 115 | 120 | ||
| 116 | 121 | func keyName(s string) string { | |
| @@ -416,12 +421,21 @@ func (c *Core) setLayers(all []ports.LayerSurface) { | |||
| 416 | 421 | for id := range previous { | |
| 417 | 422 | c.windows.layerGone(id) | |
| 418 | 423 | } | |
| 424 | + | c.pruneRetained() | |
| 425 | + | c.clampCaptureKeyboard() | |
| 419 | 426 | for _, sc := range c.screens { | |
| 420 | 427 | sc.arrange() | |
| 421 | 428 | } | |
| 422 | 429 | } | |
| 423 | 430 | ||
| 424 | 431 | func (c *Core) publish(ctx context.Context) error { | |
| 432 | + | capture, err := c.captureEvaluate(ctx) | |
| 433 | + | if err != nil { | |
| 434 | + | return err | |
| 435 | + | } | |
| 436 | + | // The workspace a session renders off screen keeps its real layout for | |
| 437 | + | // the configures, popups and CaptureScene of this publish. | |
| 438 | + | c.captureTrack(capture) | |
| 425 | 439 | if err := c.closeHiddenPopups(ctx); err != nil { | |
| 426 | 440 | return err | |
| 427 | 441 | } | |
| @@ -493,7 +507,8 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 493 | 507 | t.client = c.clientRect(rp) | |
| 494 | 508 | } | |
| 495 | 509 | } | |
| 496 | - | if v, send := c.configures.next(p, t); send { | |
| 510 | + | cp, t := c.captureConfigure(sc, p, t) | |
| 511 | + | if v, send := c.configures.nextWithCapture(p, t, cp); send { | |
| 497 | 512 | if err := c.command(ctx, v); err != nil { | |
| 498 | 513 | return err | |
| 499 | 514 | } | |
| @@ -501,6 +516,10 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 501 | 516 | } | |
| 502 | 517 | } | |
| 503 | 518 | scene.Windows = append(scene.Windows, c.scenePopups(sc)...) | |
| 519 | + | if capture != nil && capture.scene != nil && sc.name() == capture.output { | |
| 520 | + | scene.Capture = capture.scene | |
| 521 | + | scene.CaptureScene = c.captureScene(scene.Seq) | |
| 522 | + | } | |
| 504 | 523 | scenes = append(scenes, scene) | |
| 505 | 524 | } | |
| 506 | 525 | c.configures.prune() | |
| @@ -799,6 +818,7 @@ func (c *Core) hit(x, y float64) (WindowID, float64, float64) { | |||
| 799 | 818 | ||
| 800 | 819 | func (c *Core) Run(ctx context.Context) error { | |
| 801 | 820 | defer c.stopFrame() | |
| 821 | + | defer c.stopCapture() | |
| 802 | 822 | // Startup commands run once per session; a config reload does not | |
| 803 | 823 | // run them again. | |
| 804 | 824 | for _, argv := range c.cfg.Startup { | |
| @@ -823,6 +843,17 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 823 | 843 | continue | |
| 824 | 844 | } | |
| 825 | 845 | switch v := ev.(type) { | |
| 846 | + | case ports.CaptureSessionBegin: | |
| 847 | + | if c.captureBegin(ctx, v) != nil { | |
| 848 | + | return nil | |
| 849 | + | } | |
| 850 | + | case ports.CaptureSessionLayer: | |
| 851 | + | c.captureLayer(v) | |
| 852 | + | case ports.CaptureSessionPing: | |
| 853 | + | c.capturePing(v.ID) | |
| 854 | + | continue | |
| 855 | + | case ports.CaptureSessionEnd: | |
| 856 | + | c.captureEnd(v.ID) | |
| 826 | 857 | case ports.LayerChanged: | |
| 827 | 858 | c.layerChanged = true | |
| 828 | 859 | c.setLayers(v.Layers) | |
| @@ -934,6 +965,12 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 934 | 965 | return nil | |
| 935 | 966 | } | |
| 936 | 967 | continue | |
| 968 | + | case <-c.captureC: | |
| 969 | + | c.captureExpired() | |
| 970 | + | if c.publish(ctx) != nil { | |
| 971 | + | return nil | |
| 972 | + | } | |
| 973 | + | continue | |
| 937 | 974 | case ev, ok := <-c.ch.Output: | |
| 938 | 975 | if !ok { | |
| 939 | 976 | c.ch.Output = nil | |
M internal/core/layers.go +1 -1
| @@ -99,7 +99,7 @@ func hasFullscreen(sc *screen) bool { | |||
| 99 | 99 | // keyboard exclusively (a locker, a launcher) still shows: it has the | |
| 100 | 100 | // keyboard and must be seen. | |
| 101 | 101 | func shown(sc *screen, l ports.SceneLayer) bool { | |
| 102 | - | return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID) | |
| 102 | + | return l.Layer == ports.LayerBackground || !hasFullscreen(sc) || sc.exclusiveKeyboard(l.ID) || sc.capture[l.ID] | |
| 103 | 103 | } | |
| 104 | 104 | ||
| 105 | 105 | // exclusiveKeyboard reports whether layer surface id takes the keyboard | |
M internal/core/popup.go +6 -2
| @@ -173,7 +173,7 @@ func (c *Core) peeking(id WindowID) bool { | |||
| 173 | 173 | // placePopup places a new or repositioned popup and configures it; a popup | |
| 174 | 174 | // whose parent is not on screen is dismissed. | |
| 175 | 175 | func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |
| 176 | - | sc, pr, ok := c.windowRect(v.Parent) | |
| 176 | + | sc, pr, physical, ok := c.popupRect(v.Parent) | |
| 177 | 177 | if !ok { | |
| 178 | 178 | if c.popups[v.ID] != nil { | |
| 179 | 179 | return c.closePopup(ctx, v.ID) | |
| @@ -184,6 +184,9 @@ func (c *Core) placePopup(ctx context.Context, v ports.PopupRequest) error { | |||
| 184 | 184 | o := sc.mon.Output() | |
| 185 | 185 | if !c.onLayer(c.popupRoot(v.Parent)) { | |
| 186 | 186 | o = sc.mon.Frame() | |
| 187 | + | if !physical { | |
| 188 | + | o = c.configures.cw.frame // the captured workspace's viewport | |
| 189 | + | } | |
| 187 | 190 | } | |
| 188 | 191 | bounds := Rect{X: o.X - pr.X, Y: o.Y - pr.Y, W: o.W, H: o.H} | |
| 189 | 192 | p := c.popups[v.ID] | |
| @@ -249,7 +252,8 @@ func (c *Core) closeHiddenPopups(ctx context.Context) error { | |||
| 249 | 252 | if c.popups[id] == nil { | |
| 250 | 253 | continue | |
| 251 | 254 | } | |
| 252 | - | if _, _, ok := c.windowRect(id); !ok { | |
| 255 | + | // A popup of a window a capture session renders off screen stays. | |
| 256 | + | if _, _, _, ok := c.popupRect(id); !ok { | |
| 253 | 257 | if err := c.closePopup(ctx, id); err != nil { | |
| 254 | 258 | return err | |
| 255 | 259 | } | |
M internal/core/screens.go +7 -0
| @@ -18,6 +18,9 @@ type screen struct { | |||
| 18 | 18 | off bool // turned off by a client (output power management) | |
| 19 | 19 | layers []ports.LayerSurface | |
| 20 | 20 | placed []ports.SceneLayer | |
| 21 | + | // capture holds the layer surfaces of the active capture session on | |
| 22 | + | // this output: shown over a fullscreen window (capture_session.go). | |
| 23 | + | capture map[WindowID]bool | |
| 21 | 24 | } | |
| 22 | 25 | ||
| 23 | 26 | func (s *screen) name() string { return s.info.Name } | |
| @@ -213,6 +216,10 @@ func (c *Core) removeScreen(name string) { | |||
| 213 | 216 | if i < 0 { | |
| 214 | 217 | return | |
| 215 | 218 | } | |
| 219 | + | // A capture session's off-screen workspace of this output goes with it. | |
| 220 | + | if c.configures.cw.sc == c.screens[i] { | |
| 221 | + | c.configures.cw.reset() | |
| 222 | + | } | |
| 216 | 223 | if len(c.screens) == 1 { | |
| 217 | 224 | c.screens[0].info = ports.OutputInfo{} | |
| 218 | 225 | c.screens[0].layers = nil | |
M internal/core/workspaces.go +1 -1
| @@ -26,7 +26,7 @@ func (c *Core) publishWorkspaces() { | |||
| 26 | 26 | if name == "" { | |
| 27 | 27 | name = strconv.Itoa(i + 1) | |
| 28 | 28 | } | |
| 29 | - | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name}) | |
| 29 | + | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: m.Current() != w && (m.isHidden(w) || w.empty()), Configured: w.Name, Frame: w.Output}) | |
| 30 | 30 | } | |
| 31 | 31 | snapshot.Outputs = append(snapshot.Outputs, out) | |
| 32 | 32 | } | |
A internal/ports/capture_session.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // Private capture sessions (neferwl_capture_manager_v1, see | |
| 6 | + | // internal/adapters/wayland/capturesession/neferwl-capture-v1.xml). | |
| 7 | + | // | |
| 8 | + | // A session is opened by one client connection (the capture connection) for | |
| 9 | + | // one workspace of one output. While core reports it Active, the layer | |
| 10 | + | // surfaces another connection attached to it (the HUD, authorised by token | |
| 11 | + | // and peer UID) stay visible over a fullscreen window and are left out of | |
| 12 | + | // clean captures; screen captures requested by the session's own connection | |
| 13 | + | // are tagged Clean. Every other capture keeps the HUD and the native border. | |
| 14 | + | // | |
| 15 | + | // Only one session is live in the compositor. Core owns the session state; | |
| 16 | + | // wayland only relays requests and tells clients what core decided. | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | // MaxCaptureSessionLayers bounds the layer surfaces attached to a session. | |
| 20 | + | MaxCaptureSessionLayers = 4 | |
| 21 | + | // MaxCaptureExcluded bounds SceneCapture.Excluded: attached layers and | |
| 22 | + | // their popup descendants. | |
| 23 | + | MaxCaptureExcluded = 128 | |
| 24 | + | // CaptureSessionTimeout is how long core lets a session live without a | |
| 25 | + | // CaptureSessionPing (the explicit keep-alive) before it stops it. | |
| 26 | + | CaptureSessionTimeout = 5 * time.Second | |
| 27 | + | // CaptureBorderWidth and CaptureBorderColor are the native compositor | |
| 28 | + | // border of a recording session (CaptureSessionBegin.Record), in logical | |
| 29 | + | // pixels and #rrggbb. | |
| 30 | + | CaptureBorderWidth = 2 | |
| 31 | + | CaptureBorderColor = "#ff3b30" | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // CaptureReason says why a session is not active. The zero value is none. | |
| 35 | + | type CaptureReason string | |
| 36 | + | ||
| 37 | + | const ( | |
| 38 | + | CaptureReasonNone CaptureReason = "" | |
| 39 | + | // Every reason ends the session: core forgets it and wayland stops it. | |
| 40 | + | CaptureReasonOutputGone CaptureReason = "output-gone" | |
| 41 | + | CaptureReasonOutputOff CaptureReason = "output-off" | |
| 42 | + | CaptureReasonPingTimeout CaptureReason = "ping-timeout" | |
| 43 | + | CaptureReasonWorkspaceGone CaptureReason = "workspace-gone" | |
| 44 | + | CaptureReasonBusy CaptureReason = "busy" // another session is live | |
| 45 | + | CaptureReasonInvalidRegion CaptureReason = "invalid-region" | |
| 46 | + | CaptureReasonUnauthorized CaptureReason = "unauthorized" // wayland only: peer credentials unreadable or not the compositor's user | |
| 47 | + | // TooManyExcluded: the attached layers and their popups exceed | |
| 48 | + | // MaxCaptureExcluded; a capture could not leave them all out (fail closed). | |
| 49 | + | CaptureReasonTooManyExcluded CaptureReason = "too-many-excluded" | |
| 50 | + | ) | |
| 51 | + | ||
| 52 | + | // Terminal reports whether a state with this reason ends the session. | |
| 53 | + | func (r CaptureReason) Terminal() bool { return r != CaptureReasonNone } | |
| 54 | + | ||
| 55 | + | // CaptureSessionBegin carries wayland → core a new session. There are two | |
| 56 | + | // target kinds: | |
| 57 | + | // - Workspace 0 is an output target: whatever the output shows, bars and | |
| 58 | + | // margins included, overview too. A zero Region means the whole output; | |
| 59 | + | // a non-zero one is clipped to the output. | |
| 60 | + | // - Workspace is a ports.WorkspaceInfo.ID: that workspace's frame | |
| 61 | + | // (its own viewport, no bars). A zero Region means the whole frame; a | |
| 62 | + | // non-zero one is clipped to the frame. The workspace may be on screen or | |
| 63 | + | // not, and the overview does not matter: one that is not drawn as itself | |
| 64 | + | // is rendered for capture only (Scene.CaptureScene). It ends with | |
| 65 | + | // CaptureReasonWorkspaceGone when it is removed or moves to another | |
| 66 | + | // output; sliding never pauses it. | |
| 67 | + | // | |
| 68 | + | // Region is output-local logical pixels. Record asks for the native border. | |
| 69 | + | // ID is chosen by wayland and unique for the process lifetime. | |
| 70 | + | type CaptureSessionBegin struct { | |
| 71 | + | ID uint64 | |
| 72 | + | Output string | |
| 73 | + | Workspace uint64 | |
| 74 | + | Region Rect | |
| 75 | + | Record bool | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (CaptureSessionBegin) clientEvent() {} | |
| 79 | + | ||
| 80 | + | // CaptureSessionLayer carries wayland → core a layer surface the session's | |
| 81 | + | // authorised HUD attached (or detached, when it was destroyed). Layer is the | |
| 82 | + | // LayerSurface.ID, known before the surface maps. Wayland has already checked | |
| 83 | + | // the layer is top or overlay and that it never takes the keyboard | |
| 84 | + | // exclusively; at most MaxCaptureSessionLayers are attached. | |
| 85 | + | type CaptureSessionLayer struct { | |
| 86 | + | ID uint64 | |
| 87 | + | Layer WindowID | |
| 88 | + | Attached bool | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (CaptureSessionLayer) clientEvent() {} | |
| 92 | + | ||
| 93 | + | // CaptureSessionPing carries wayland → core the session owner's keep-alive. | |
| 94 | + | type CaptureSessionPing struct{ ID uint64 } | |
| 95 | + | ||
| 96 | + | func (CaptureSessionPing) clientEvent() {} | |
| 97 | + | ||
| 98 | + | // CaptureSessionEnd carries wayland → core an explicit stop or the owner's | |
| 99 | + | // disconnect. Unknown IDs are ignored. | |
| 100 | + | type CaptureSessionEnd struct{ ID uint64 } | |
| 101 | + | ||
| 102 | + | func (CaptureSessionEnd) clientEvent() {} | |
| 103 | + | ||
| 104 | + | // CaptureSessionState carries core → wayland what core decided (wayland relays it as neferwl_capture_session_v1.state), on every | |
| 105 | + | // change of it. Rect is the clipped target in output-local logical pixels and | |
| 106 | + | // Workspace the real workspace ID. Active means captures are being served | |
| 107 | + | // (and the scene carries SceneCapture); a non-empty Reason with Active false | |
| 108 | + | // explains it, and every Reason ends the session. A live session is always | |
| 109 | + | // Active: a slide or swipe does not pause it (a workspace target's geometry is | |
| 110 | + | // its own viewport, unaffected), and a session that is lost is stopped, never | |
| 111 | + | // paused. | |
| 112 | + | // | |
| 113 | + | // Revision is the session's revision fence, from 1. Core bumps it whenever | |
| 114 | + | // what a clean capture must do changes: the start, an attached or detached | |
| 115 | + | // layer, an excluded popup or the target. The scene carrying that | |
| 116 | + | // change has the same SceneCapture.Revision. Layers lists the attached layer | |
| 117 | + | // surfaces core knows (at most MaxCaptureSessionLayers): wayland confirms an | |
| 118 | + | // attach to the client only once a state lists the layer. | |
| 119 | + | // | |
| 120 | + | // Wayland serves a clean capture only for a session Active in the last state | |
| 121 | + | // it saw, stamps CaptureRequest.CaptureRevision with that state's Revision, | |
| 122 | + | // and a renderer refuses the request unless its scene's Capture.Revision is | |
| 123 | + | // at least that: a scene published before the change is never used, so no | |
| 124 | + | // stale exclusion list can leak a HUD. | |
| 125 | + | type CaptureSessionState struct { | |
| 126 | + | ID uint64 | |
| 127 | + | Output string | |
| 128 | + | Rect Rect | |
| 129 | + | Workspace uint64 | |
| 130 | + | Active bool | |
| 131 | + | Reason CaptureReason | |
| 132 | + | Revision uint64 | |
| 133 | + | Layers []WindowID | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (CaptureSessionState) clientCommand() {} | |
| 137 | + | ||
| 138 | + | // SceneCapture carries core → renderer the active session on one output's | |
| 139 | + | // scene (Scene.Capture; nil when none). TargetRect is output-local logical: | |
| 140 | + | // the clipped region of the output, or of the workspace frame. Excluded lists | |
| 141 | + | // the attached layer surfaces and their popup descendants (at most | |
| 142 | + | // MaxCaptureExcluded), which clean captures leave out. BorderWidth and | |
| 143 | + | // BorderColor are the native border of a recording session, zero and empty | |
| 144 | + | // otherwise; it is drawn inside TargetRect, on its edge, in every capture | |
| 145 | + | // that is not Clean, and only when the target is drawn on screen: a | |
| 146 | + | // workspace rendered for capture only (Scene.CaptureScene) has none. | |
| 147 | + | // Workspace is the real workspace ID, 0 for an output target. For such a | |
| 148 | + | // hidden workspace TargetRect is relative to the origin of CaptureScene (the | |
| 149 | + | // workspace viewport), the scene that capture renders; otherwise it is | |
| 150 | + | // output-local. | |
| 151 | + | type SceneCapture struct { | |
| 152 | + | Session uint64 | |
| 153 | + | TargetRect Rect | |
| 154 | + | Excluded []WindowID | |
| 155 | + | BorderWidth int | |
| 156 | + | BorderColor string | |
| 157 | + | Workspace uint64 | |
| 158 | + | // Revision is the CaptureSessionState.Revision this scene carries; a | |
| 159 | + | // clean CaptureRequest needs Revision >= its CaptureRevision. | |
| 160 | + | Revision uint64 | |
| 161 | + | } | |
M internal/ports/messages.go +28 -0
| @@ -15,6 +15,16 @@ type CaptureRequest struct { | |||
| 15 | 15 | Dst SHMBuffer | |
| 16 | 16 | Width, Height, Stride int | |
| 17 | 17 | Format uint32 | |
| 18 | + | // Clean asks for a capture without the private session's HUD layers | |
| 19 | + | // and native border (SceneCapture). Wayland sets it, with Session, only | |
| 20 | + | // on requests of the connection that owns the active session; every | |
| 21 | + | // other capture is false/0 and sees the scene as shown. | |
| 22 | + | Clean bool | |
| 23 | + | Session uint64 | |
| 24 | + | // CaptureRevision is the session Revision last confirmed by core | |
| 25 | + | // (CaptureSessionState); a renderer fails a Clean request whose scene | |
| 26 | + | // has Capture nil or Capture.Revision below it. 0 when not Clean. | |
| 27 | + | CaptureRevision uint64 | |
| 18 | 28 | } | |
| 19 | 29 | ||
| 20 | 30 | // CaptureDone is attempted once after the output closes the destination. | |
| @@ -128,6 +138,10 @@ type WorkspaceInfo struct { | |||
| 128 | 138 | Name string | |
| 129 | 139 | Index int // zero-based vertical coordinate | |
| 130 | 140 | Active, Hidden bool | |
| 141 | + | // Frame is the workspace viewport in output-local logical pixels | |
| 142 | + | // (Monitor.Frame geometry: the whole output unless the workspace has a | |
| 143 | + | // size override). It is where a capture session of it lies. | |
| 144 | + | Frame Rect | |
| 131 | 145 | } | |
| 132 | 146 | ||
| 133 | 147 | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| @@ -486,6 +500,10 @@ type OutputPresented struct { | |||
| 486 | 500 | Flip *FlipInfo | |
| 487 | 501 | Shown, Queued uint64 | |
| 488 | 502 | Seen map[WindowID]uint64 | |
| 503 | + | // ChildReads is the oldest content Seq per window still read by a child | |
| 504 | + | // renderer on a separate device. Unlike Seen, these holds never time out: | |
| 505 | + | // they end only with a completed render or device shutdown. | |
| 506 | + | ChildReads map[WindowID]uint64 | |
| 489 | 507 | } | |
| 490 | 508 | ||
| 491 | 509 | // OutputFormats carries output → wayland the dmabuf formats an output can | |
| @@ -583,6 +601,9 @@ type ConfigureWindow struct { | |||
| 583 | 601 | // hidden workspace, scrolled off, behind a maximized column or a | |
| 584 | 602 | // hidden float. An invisible window is suspended and throttled. | |
| 585 | 603 | Visible bool | |
| 604 | + | // Captured marks a window drawn only for a capture session (its | |
| 605 | + | // workspace is not on screen): Visible stays the physical truth. | |
| 606 | + | Captured bool | |
| 586 | 607 | } | |
| 587 | 608 | ||
| 588 | 609 | func (ConfigureWindow) clientCommand() {} | |
| @@ -793,6 +814,13 @@ type Scene struct { | |||
| 793 | 814 | // Window popups are drawn after the windows, layer popups | |
| 794 | 815 | // (SceneWindow.OverLayers) last, over every layer. | |
| 795 | 816 | Layers []SceneLayer | |
| 817 | + | // Capture is the active private capture session on this output, nil | |
| 818 | + | // when none (capture_session.go). | |
| 819 | + | Capture *SceneCapture | |
| 820 | + | // CaptureScene is the workspace of the active session drawn for | |
| 821 | + | // capture only (a workspace that is not on screen). Only the root scene | |
| 822 | + | // carries it; CaptureScene.Capture and .CaptureScene are nil. | |
| 823 | + | CaptureScene *Scene | |
| 796 | 824 | } | |
| 797 | 825 | ||
| 798 | 826 | // Shows reports whether the scene draws the surface of id: only its | |