Skip to main content

agent_seat_linux/seat/
capture.rs

1//! Frame capture for the agent seat.
2//!
3//! The proxied app renders into buffers it attaches to its surfaces. For
4//! software-rendered apps (GDK_BACKEND/GSK_RENDERER forced to cairo) those are
5//! `wl_shm` buffers: a shared-memory pool plus offset/stride/format. We read
6//! the pixels straight out of the pool's file descriptor — no compositor
7//! round-trip, no portal, no consent, and independent of which window is in
8//! the foreground. Capture remains bound to the controlled app instead of the
9//! ambient desktop.
10
11use std::os::fd::AsRawFd;
12
13use super::proxy::Conn;
14
15/// wl_shm formats we can decode (little-endian, so ARGB8888 is B-G-R-A bytes).
16const WL_SHM_FORMAT_ARGB8888: u32 = 0;
17const WL_SHM_FORMAT_XRGB8888: u32 = 1;
18
19/// One decoded frame plus the surface it came from.
20#[derive(Debug)]
21pub struct CapturedFrame {
22    /// Decoded RGB application frame.
23    pub image: image::DynamicImage,
24    /// Buffer dimensions (also available via `image.width()/height()`).
25    #[allow(dead_code)]
26    pub width: u32,
27    /// Captured buffer height in pixels.
28    #[allow(dead_code)]
29    pub height: u32,
30}
31
32/// Geometry + pool of an shm buffer (everything needed to read it back).
33struct ShmFrameRef<'a> {
34    fd: &'a std::os::fd::OwnedFd,
35    pool_size: i32,
36    offset: i32,
37    width: i32,
38    height: i32,
39    stride: i32,
40    format: u32,
41}
42
43/// Pick the surface most likely to be the app's main window: the largest
44/// attached shm-backed buffer, using commit count only to break ties. Startup
45/// icons and splash helpers can commit many more times than the eventual main
46/// window, so commit count alone can permanently select a tiny
47/// non-interactive surface.
48///
49/// Returns the shm fields directly (dmabuf buffers are skipped — they need a
50/// GPU readback path).
51fn primary_shm_frame(conn: &Conn) -> Option<ShmFrameRef<'_>> {
52    let mut best: Option<(i64, u64, ShmFrameRef<'_>)> = None;
53    for state in conn.surfaces.values() {
54        let Some(attached) = state.attached.as_ref() else {
55            continue;
56        };
57        let frame = ShmFrameRef {
58            fd: &attached.fd,
59            pool_size: attached.pool_size,
60            offset: attached.offset,
61            width: attached.width,
62            height: attached.height,
63            stride: attached.stride,
64            format: attached.format,
65        };
66        let area = i64::from(attached.width.max(0)) * i64::from(attached.height.max(0));
67        match &best {
68            Some((best_area, best_commits, _))
69                if (*best_area, *best_commits) >= (area, state.commit_count) => {}
70            _ => best = Some((area, state.commit_count, frame)),
71        }
72    }
73    best.map(|(_, _, frame)| frame)
74}
75
76/// Read the app's current frame from its attached shm buffer.
77///
78/// `Conn` is passed under its lock guard; this performs no locking itself.
79pub(crate) fn capture_frame(conn: &Conn) -> Result<CapturedFrame, String> {
80    let frame = primary_shm_frame(conn).ok_or_else(|| {
81        "the app has no readable shm frame yet (it may use GPU buffers)".to_string()
82    })?;
83    let ShmFrameRef {
84        fd,
85        pool_size,
86        offset,
87        width,
88        height,
89        stride,
90        format,
91    } = frame;
92
93    if offset < 0 || width <= 0 || height <= 0 || stride <= 0 || pool_size <= 0 {
94        return Err(format!(
95            "invalid shm frame offset={offset}, geometry={width}x{height}, stride={stride}, pool={pool_size}"
96        ));
97    }
98
99    let need = (offset as i64) + (stride as i64) * (height as i64);
100    if need > pool_size as i64 {
101        return Err(format!(
102            "frame ({need} bytes) exceeds shm pool size ({} bytes)",
103            pool_size
104        ));
105    }
106
107    // Map the pool read-only and copy the frame out.
108    let fd = fd.as_raw_fd();
109    // SAFETY: fd is an owned, open shm-pool descriptor and pool_size was
110    // validated as positive. The returned mapping is checked before use.
111    let mapped = unsafe {
112        libc::mmap(
113            std::ptr::null_mut(),
114            pool_size as usize,
115            libc::PROT_READ,
116            libc::MAP_SHARED,
117            fd,
118            0,
119        )
120    };
121    if mapped == libc::MAP_FAILED {
122        return Err(format!(
123            "mmap of the shm pool failed: {}",
124            std::io::Error::last_os_error()
125        ));
126    }
127
128    let result = decode_frame(mapped as *const u8, offset, width, height, stride, format);
129
130    // SAFETY: mapped came from the successful mmap above and is unmapped once
131    // with the identical length after decode_frame has finished reading it.
132    unsafe {
133        libc::munmap(mapped, pool_size as usize);
134    }
135
136    let image = result?;
137    Ok(CapturedFrame {
138        width: width as u32,
139        height: height as u32,
140        image,
141    })
142}
143
144/// Decode B-G-R-(A/X) rows into an RGB image.
145fn decode_frame(
146    base: *const u8,
147    offset: i32,
148    width: i32,
149    height: i32,
150    stride: i32,
151    format: u32,
152) -> Result<image::DynamicImage, String> {
153    match format {
154        WL_SHM_FORMAT_ARGB8888 | WL_SHM_FORMAT_XRGB8888 => {}
155        other => {
156            return Err(format!(
157                "unsupported wl_shm format {other:#x} (only ARGB8888/XRGB8888 are decoded)"
158            ))
159        }
160    }
161
162    let w = width as usize;
163    let h = height as usize;
164    let stride = stride as usize;
165    let mut rgb = vec![0u8; w * h * 3];
166    for y in 0..h {
167        // SAFETY: the caller validated offset + stride * height against the
168        // mapped pool size. y is below height and the slice is capped to one
169        // validated row.
170        let row = unsafe {
171            std::slice::from_raw_parts(base.add(offset as usize + y * stride), stride.min(w * 4))
172        };
173        for x in 0..w {
174            let px = x * 4;
175            if px + 2 >= row.len() {
176                break;
177            }
178            // Little-endian ARGB/XRGB => bytes are B, G, R, (A/X).
179            let b = row[px];
180            let g = row[px + 1];
181            let r = row[px + 2];
182            let dst = (y * w + x) * 3;
183            rgb[dst] = r;
184            rgb[dst + 1] = g;
185            rgb[dst + 2] = b;
186        }
187    }
188    let img = image::RgbImage::from_raw(w as u32, h as u32, rgb)
189        .ok_or_else(|| "failed to assemble the captured frame".to_string())?;
190    Ok(image::DynamicImage::ImageRgb8(img))
191}