← Otto Developer GuideScreenshot Portal — implementation plan
A plan for the D-Bus screenshot portal Otto does not have yet, and how it would sit on top of the screencopy machinery that already exists.
Status: partly built.
org.freedesktop.impl.portal.Screenshotnow exists (src/portal/screenshot.rs, interface version 2) andotto.portaldeclares it alongsideScreenCast,Settings,AccessandFileChooser. What it does today is shell out togrimfor the whole output set and hand back afile://URI, gatinginteractiverequests behind the Access dialog. The phases below — Otto-drawn region and window selection, the colour picker, capture without an external binary — are still the plan.This is not the same thing as taking a screenshot on Otto today. The
zwlr_screencopy_v1Wayland protocol is already in production (src/state/screencopy.rs) and is whatgrim,wf-recorder,wl-mirrorand OBS-via-wlrobs use. See screenshare.md. This document covers the D-Bus portal interface that GTK/Qt screenshot apps and sandboxed apps use instead.
What it would add
org.freedesktop.impl.portal.Screenshot, so third-party screenshot tools
(GNOME Screenshot, Spectacle, Flameshot) can capture through the standard
portal.
The key difference from ScreenCast: a screenshot is one file, once, not a
stream. No PipeWire, no session, no format negotiation. The portal returns a
file:// URI and the app takes it from there.
The chain
Screenshot app
→ org.freedesktop.portal.Screenshot (xdg-desktop-portal)
→ org.freedesktop.impl.portal.Screenshot (xdg-desktop-portal-otto)
→ org.otto.Screenshot (the compositor)
→ capture → PNG → temp file → file:// URI
Data flow
- App calls
org.freedesktop.portal.Screenshot.Screenshot(). - xdg-desktop-portal forwards to the otto backend.
- The backend sends a D-Bus request to the compositor.
- The compositor captures the current frame for the target output.
- Convert to CPU memory if needed (dmabuf → RGBA).
- Encode to PNG with the
imagecrate. - Write to
$XDG_RUNTIME_DIRor/tmp. - Return
file:///…/screenshot-XXXXXX.png. - The app displays, saves, or copies it.
Phase 1: basic screenshot
Reuse the existing capture path. The SHM branch of
zwlr_screencopy_v1 already does exactly steps 4–5: BlitCurrentFrame
(src/renderer/mod.rs) for the GPU side, skia_surface.read_pixels for the CPU
readback. A screenshot is a one-shot version of that, and should call the same
code rather than growing a parallel path.
Compositor side — a new src/screenshare/screenshot.rs plus one command:
pub enum CompositorCommand {
// … existing commands
Screenshot {
output_name: String,
response_tx: oneshot::Sender<Result<String, String>>, // the URI
},
}
The handler captures one frame, gets RGBA out of it, encodes PNG, writes a
temp file, and returns the URI. It runs on the main loop like every other
CompositorCommand — see the sync/async bridge in
screenshare.md.
Portal side — a new
components/xdg-desktop-portal-otto/src/portal/screenshot.rs:
impl Screenshot for PortalBackend {
async fn screenshot(
&self,
handle: ObjectPath<'_>,
app_id: &str,
parent_window: &str,
options: HashMap<String, Value<'_>>,
) -> Result<(u32, HashMap<String, Value>)> {
// forward over the existing org.otto.* connection
// return (response_code, {"uri": "file:///…"})
}
}
PNG encoding:
use image::{ImageBuffer, Rgba};
fn encode_png(rgba: Vec<u8>, width: u32, height: u32) -> Result<Vec<u8>> {
let img = ImageBuffer::<Rgba<u8>, _>::from_raw(width, height, rgba)
.ok_or("bad buffer size")?;
let mut out = Vec::new();
img.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)?;
Ok(out)
}
Do not forget to register the interface in the backend’s main.rs and add
it to otto.portal — an interface that is implemented but not declared is
never routed to.
Phase 2: colour picker (optional)
PickColor returns (response_code, {"color": (r, g, b)}). It needs pixel
readback at a point and a BGRA → RGB conversion; the same capture path applies.
D-Bus interface
<method name="Screenshot">
<arg type="o" name="handle" direction="in"/>
<arg type="s" name="app_id" direction="in"/>
<arg type="s" name="parent_window" direction="in"/>
<arg type="a{sv}" name="options" direction="in"/>
<arg type="u" name="response" direction="out"/>
<arg type="a{sv}" name="results" direction="out"/>
</method>
Options — modal (b) and interactive (b). Both can be ignored: the
requesting app provides its own selection and annotation UI.
Results — uri (s), a file:// URI to the PNG.
Dependencies
image = { version = "0.25", default-features = false, features = ["png"] }
tempfile = "3.0"
Otto already depends on image behind the udev and debug features; check
whether the existing dependency is enough before adding another.
Checklist
Phase 1
- Add
image(png) andtempfiledependencies where needed -
src/screenshare/screenshot.rs: one-shot capture reusingBlitCurrentFrame/read_pixels -
Screenshotvariant inCompositorCommandand its handler - PNG encoding and temp-file creation with a correct
file://URI -
components/xdg-desktop-portal-otto/src/portal/screenshot.rs - Register the interface in the backend’s
main.rs - Add
org.freedesktop.impl.portal.Screenshottootto.portal - Test with
gnome-screenshot,spectacle,flameshot gui
Phase 2
-
PickColorcommand and handler - Pixel readback and BGRA → RGB conversion
- Test with a portal-aware colour picker
Design notes
- No UI in the compositor. Third-party apps provide their own selection and annotation.
- Temporary files.
/tmpor$XDG_RUNTIME_DIR; the app is responsible for deleting them. - PNG only initially — most compatible and lossless.
- Full primary output initially.
Later
Specific output selection; window-specific screenshots by window id (the
window_to_dmabuf path from screenshare already does this); JPEG with a
quality parameter; app-provided save location; delay/timer; area capture from
coordinates.