← Otto Developer Guidexdg-desktop-portal-otto

User guide: Screen Sharing, Privacy
Specs: Screenshare, File Picker, Portal Access Dialog

Apps that want something only the desktop can give them (a screen to share, a file to open, the dark-mode preference, a screenshot) ask xdg-desktop-portal. That frontend is a generic freedesktop service: it checks who is asking, keeps permission tokens, and forwards the actual work to a backend chosen per desktop. xdg-desktop-portal-otto is Otto’s backend. It answers five portal interfaces (ScreenCast, Settings, Access, Screenshot and FileChooser) by calling into the compositor and Otto’s helper apps. It runs as its own process, a user service the frontend starts on demand through D-Bus activation under the name org.freedesktop.impl.portal.desktop.otto, and the frontend picks it for sessions whose desktop name is otto. Every other portal interface goes to whichever backend the frontend’s configuration names.

The model

A broker, not a renderer. The backend owns no window and holds no Wayland connection. When a request needs the user, it hands the question to a process that draws: dialogs go to otto-islands, file choosers to otto-files. When a request needs pixels, the compositor or grim produces them. The backend’s job is translation: freedesktop shapes in, Otto’s own D-Bus interfaces out, and the answer packed back without losing anything.

Fail closed. If nobody can ask the user, the answer is no. A screencast with no picker on the bus is refused rather than sharing a default output, an Access dialog with no renderer is denied, and a file chooser that cannot reach the picker reports a failure instead of an empty success.

Hold as little state as possible. The only live state is the ScreenCast session table, and it is in memory. What has to survive (a share the user already approved) travels in the portal’s own restore_data round trip or sits in xdg-permission-store, both owned by others.

The portal backend between xdg-desktop-portal and Otto’s services

Start to finish: sharing a window

A browser asks to share a window. The portal ScreenCast flow is four calls, and each one passes through the backend.

  1. CreateSession. The app calls the frontend, which picks a request handle and a session handle and calls the backend’s CreateSession. The backend exports a Session object at the session handle, then asks the compositor for a capture session over org.otto.ScreenCast. It records the pair (portal session, compositor session) in its table. If the compositor refuses, the Session object is removed again so nothing dangles on the bus.

  2. SelectSources. The app says what it accepts (monitors, windows or both), a cursor mode and whether it wants the share remembered. The backend asks the compositor for its outputs and its windows, then tries to avoid asking the user:

    • If the app replayed restore_data from an earlier share, that names a source.
    • Otherwise any share remembered for this app id in the permission store names one.
    • If the named output or window still exists and matches the requested types, it is selected silently.

    Failing that, the backend builds one radio list of every output and every window and sends it to otto-islands as an Access-style dialog over org.otto.Dialog1, with a “Remember for this app” checkbox when the share can be remembered. Cancel answers 1. No renderer at all answers 2. The chosen source is only written into the session table; nothing is captured yet.

  3. Start. The backend tells the compositor to record that window (or output) in the session and to start it. The compositor creates a PipeWire stream and reports its node id once the stream is ready, which the backend polls for briefly. The backend reads the stream’s metadata (size, position, scale, format) and returns it to the frontend in the streams result. If the app asked for persistence, it also returns restore_data: the source’s identity under the vendor name otto. The frontend turns that into a token for the app.

  4. OpenPipeWireRemote. The backend asks the compositor for a PipeWire file descriptor for the session and passes it up. The frontend hands it to the app, which connects to the node.

From here on frames go from the compositor to PipeWire to the app. The backend is not on that path. When the app closes the session, the frontend calls Close on the Session object and the backend stops the compositor session. If the app withdraws mid-request instead (it quit, or the user closed the window that asked), the frontend calls Close on the request object and the pending method answers “cancelled” wherever it was.

Screen Sharing covers the compositor half: the org.otto.ScreenCast service, the PipeWire streams and the GPU blit.

The other interfaces are shorter versions of the same shape:

Boundaries

BetweenCrosses asWho owns the state
App and frontendorg.freedesktop.portal.*Frontend: app identity, restore tokens
Frontend and backendorg.freedesktop.impl.portal.*, plus Request and Session objects the backend exports at handles the frontend choosesBackend: portal session to compositor session, and the picked source
Backend and compositororg.otto.ScreenCast (session and stream objects), org.otto.SettingsCompositor: capture sessions, streams, every setting
Backend and otto-islandsorg.otto.Dialog1Islands: the dialog while it is up
Backend and otto-filesorg.otto.FilePicker1otto-files: the picker window and its navigation
Backend and the permission storeorg.freedesktop.impl.portal.PermissionStore, table screencastPermission store: remembered shares
Backend and grimA child process, then a PNG on diskThe user’s Pictures folder
Compositor and appA PipeWire fd, passed over D-Bus through backend and frontendPipeWire: the frames

The backend is one process on one async runtime with one bus connection. The compositor tears down every capture session owned by a bus name that leaves, so a backend that exits takes its sessions with it. There is nothing to reconcile on restart.

Decisions

Dialogs and the file chooser live in other processes. The backend stays a headless D-Bus service, and the component that opens a window is separate and can be restarted, replaced or bus-activated on its own. The dialog contract org.otto.Dialog1 is modelled on the freedesktop Access interface, so the same primitive serves the Access portal, the ScreenCast picker and the Screenshot confirmation. See the Access dialog spec.

The ScreenCast picker borrows another desktop’s dialog when islands is missing. Refusing outright would make window sharing impossible without otto-islands, so the picker tries the GTK, GNOME and KDE Access backends in turn. They are named explicitly rather than looked up through the portal configuration, because this backend implements Access itself and a lookup could route the request back to it and deadlock. Icons are lost on that path.

One radio list for monitors and windows. The spec lets an app request both types; the user should not have to care which bit was set. The picker resolves to a single source, so multiple is accepted and ignored.

Restore data carries the source, not a session. Only the backend has to understand the payload, so it stores the source’s identity (connector name or window id) and nothing else. Sizes and positions are looked up afresh, because the window may have moved. Chrome depends on this: it builds a preview in one session and re-creates the session for the real capture, which would open the picker twice otherwise.

Remembering for apps that never ask. Some apps never request persistence, so when the user leaves “Remember” ticked the backend writes a permission-store entry of the frontend’s own shape under an id of its own. Settings › Privacy lists both kinds and can forget them (Permissions). Apps with the empty app id are never remembered this way: every unsandboxed program shares that id, and remembering for it would let any of them share the screen unasked.

The version property is lowercase. The impl portal spec spells it that way. Under the name Version the frontend reads 0 and silently gates off everything added after interface version 1, including the cursor-mode binding and the restore round trip.

The bus name is taken, not waited for. The session bus outlives the graphical session, so a backend from an earlier login would keep the name forever. The backend exports every interface first, claims the name with replacement allowed in both directions, and exits when a newer instance takes it over.

Screenshots shell out to grim. grim already speaks the compositor’s wlr-screencopy support, so the portal needed no new capture path. The confirmation dialog is skipped only when the frontend says it checked the app’s screenshot permission and the request is not interactive.

Limits and direction

See also