The desktop portal

Applications on MixOS ask the desktop for its appearance (dark or light mode, contrast and accent colour) through the XDG Settings portal, and for a screen to share through the XDG ScreenCast portal. portald is the MixOS implementation of both. It owns org.freedesktop.portal.Desktop on the session bus, answers the org.freedesktop.appearance namespace and, when started with --screencast, serves monitor sources from the compositor.

Values come from the settings authority, settingsd. The portal never writes settings. Before authority or cache data is available, it serves the documented defaults.

Where the values come from

settingsd  --(retained topic: settingsd.desktop.changed.<profile>)-->  portald
portald    --settings.appearance.get (one bounded read)------------->  settingsd

The shared settings Consumer processes the initial connection and later generations, subscribes, and reads the projection with settings.appearance.get. The reply includes its atomic snapshot evidence. The Consumer checks that evidence, and the projection must match its binding and identity. Authenticated retained deliveries trigger fresh reads, including after settingsd restarts.

A projection carries the schema version, the binding (instance and profile), an incarnation, a revision, the design revision, mode, contrast and an sRGB accent. A projection is refused when it is older than the held one within the same incarnation. A new incarnation is adopted. A repeat of the held identity changes nothing and emits no signal.

What applications see

Interface org.freedesktop.portal.Settings, version 2, at /org/freedesktop/portal/desktop on org.freedesktop.portal.Desktop.

KeyTypeValues
color-schemeu1 dark, 2 light
contrastu0 normal, 1 high
accent-color(ddd)sRGB, each component 0 to 1

Methods:

  • ReadOne(namespace, key) returns the value with one variant layer.
  • Read(namespace, key) returns the value wrapped in one extra layer. This is the deprecated form, kept for older clients.
  • ReadAll(globs) returns a{sa{sv}}, matched against namespace globs. An empty array or any empty filter returns all namespaces. At most 64 filters of 256 bytes each are accepted; larger requests return org.freedesktop.portal.Error.InvalidArgument. Matching uses bounded iteration.

An unserved key returns org.freedesktop.portal.Error.NotFound. The accent-color key is absent until a projection with an accent has been adopted. SettingChanged(namespace, key, value) is emitted only for keys whose value actually changed.

Example, from a shell with busctl:

busctl --user call org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop \
  org.freedesktop.portal.Settings ReadOne ss org.freedesktop.appearance color-scheme

Screen sharing

Interface org.freedesktop.portal.ScreenCast, version 5, on the same object. Video calls and browsers use it to share the screen: Zoom, Firefox and Chromium getDisplayMedia(), OBS.

compd   --zwlr_screencopy_manager_v1 (shm, damage copies)-->  portald
portald --PipeWire Video/Source node (BGRx, default ceiling 30 fps)--> the caller
PropertyValue
AvailableSourceTypes1: monitors only
AvailableCursorModes1 hidden, 2 embedded
version5

A caller runs CreateSession, SelectSources, Start, then OpenPipeWireRemote. Each of the first three returns a request path at once and answers later with a Response signal addressed to the caller, as the portal specification describes. Request and session paths are derived from the caller's unique name and its handle_token / session_handle_token, so a caller can subscribe before it calls. Start answers with one stream:

KeyMeaning
node idthe PipeWire node to connect to
position, sizethe output in compositor (logical) coordinates
source_type1, monitor
idthe output name, such as HDMI-A-1

Frames are captured at the output's mode size with copy_with_damage, so a static screen costs nothing. The newest frame is sent again after a second without a new one, and as soon as a consumer attaches. The node is a Video/Source named portald-screencast, which is what the session manager links capture streams to.

Sessions belong to the connection that created them. Another caller cannot select, start or close them (NotFound, AccessDenied). A session ends when its owner calls Session.Close, when the owner leaves the bus, or when capture stops on its own (then Closed is emitted). Start answers 2 when there is nothing to capture, for example no compositor or no output with a mode.

Nothing is captured until the request is granted, in this order:

  1. Policy. --screencast-consent allow grants everything. Use it only for headless and test sessions. The default is prompt.
  2. A standing grant that covers the caller's app (its process name, as in /proc/<pid>/comm, so at most 15 bytes). This is how automation shares the screen with nobody at the desk. A process name says which app is asking but does not prove it, since any local process can set its own: a named grant is a convenience for a trusted, unsandboxed session, not an access control. A caller whose name cannot be read is covered only by a grant for any app.
  3. The person. portald posts a notifyd question, "Share your screen?", with Allow and Deny. The request waits as a pending prompt until the button is pressed, an agent answers it over the Bus, or 60 seconds pass (up to 65 when the question could not be posted). A timeout, Deny or a closed question all answer the app with 1 (cancelled). If the app closes the request or its session, or leaves the bus, while it waits, the question is taken down and nothing is captured. At most 4 requests wait at once, one per caller; more are denied straight away. Without notifyd the request still waits, and can only be answered over the Bus.
VerbBodyDoes
portald.screencast.pending{}lists waiting requests (request, app, owner, output, notification), the grants, and the policy
portald.screencast.answer{request, allow}decides a waiting request, as if the person pressed the button; withdraws the question
portald.screencast.grant{app?, count?, ttl_ms?}covers the next count (default 1, at most 100) Starts by app (any app when absent) for ttl_ms (default 5 minutes, at most an hour); at most 16 grants
portald.screencast.revoke{id?}withdraws one grant, or all of them

These verbs answer callers on this node only. A request with broker_origin other than local is refused with rc 13 FORBIDDEN, because a grant hands over the whole screen. notifyd holds the same line for the buttons: notify.action, notify.dismiss, notify.close and notify.close_all are refused for callers off this node, so no mesh peer can press Allow either. Every grant, answer and decision is logged with the caller.

The trust model, plainly: consent keeps the screen from other nodes and from an app that shares by accident. It is not a wall between local processes: any process on this node that can reach the Bus (local means this node, not this session) can answer or grant, and any Wayland client of this desk can already capture the screen through compd, so an app that wants to approve its own share can. Sandboxed apps, where that would matter, are out of scope for now.

To share the screen from a script with nobody at the desk:

send portald portald.screencast.grant body='{"app":"obs","count":1,"ttl_ms":60000}'

Then start the share in the app. To drive the prompt instead, read portald.screencast.pending and answer the request it lists.

This is a first cut, scoped to the trusted, unsandboxed session:

  • One stream per session: the first output with a current mode. multiple is accepted and ignored.
  • No window sources and no restore tokens. persist_mode is accepted and ignored.
  • shm frames, copied twice per frame. DMA-BUF is later work.
  • The remote is unrestricted. OpenPipeWireRemote returns a plain connection to the session's PipeWire, not one limited to the cast node.

Startup and failure

  • Startup takes the bus name immediately with a valid cache for the requested binding, or defaults (dark, normal contrast, no accent). Authority reads run asynchronously and never delay name acquisition. A production process test requires a complete Read reply within 250 ms of launch, with a silent authority.
  • The name is requested without queueing. If another portal already owns it, this instance exits. It does not wait in line.
  • A failed read backs off from 250 ms, doubling to a 30 s ceiling, and resets on success. The last-good values keep being served while the authority is down.
  • The supervised transport keeps retrying through initial broker failure.
  • The cache is <instance>/<profile>/appearance.json under the state directory, capped at 64 KiB and written by atomic rename. Mismatched bindings are rejected; the old unpartitioned cache is ignored. It seeds the next cold start and is never sent back to settingsd.

Running it

The unit is portald@.service, owned by services/portald/units/. It is part of session@<instance>.target and wants noded@<instance> and settingsd@<instance>. It runs as mixos with the session environment file, so it keeps DBUS_SESSION_BUS_ADDRESS. The unit passes --screencast.

portald serve --instance <name> [--profile default] [--state-dir DIR] [--screencast]
    [--screencast-consent prompt|allow] [--screencast-max-framerate 30]

It needs DBUS_SESSION_BUS_ADDRESS. --state-dir defaults to $STATE_DIRECTORY, which systemd sets from StateDirectory=portald. With --screencast it also needs WAYLAND_DISPLAY and XDG_RUNTIME_DIR for the compositor, and PIPEWIRE_RUNTIME_DIR / PIPEWIRE_REMOTE (or XDG_RUNTIME_DIR) for PipeWire. The binary links the system libpipewire-0.3.

--screencast-max-framerate N sets the frame-rate ceiling offered to cast consumers, from 1 through 120 Hz; invalid values are refused with SCREENCAST_MAX_FRAMERATE. The default stays 30. A headless desk may pass 60 in a unit drop-in; its own PipeWire and compositor refresh must support it.

Status for agents

HELP returns the verb manifest. The bus verb portald.status is read-only. It reports the origin of the held values (defaults, cache or settingsd), whether the name is owned, the identity of the held projection, the last fault and the counters. Its settings_generation reports the Consumer's connection generation, or null while disconnected. It is registered in docs/docs/spec/bus/verbs.conf.mix.

Tests

The source is services/portald.

cargo test --locked -p portald

Unit tests cover value mapping, iterative matching and acceptance. tests/portal.rs runs a private dbus-daemon and checks version 2, Read and ReadOne nesting, empty and adversarial ReadAll filters, NotFound, changed-only signals, cache isolation and single ownership. It launches the production binary against a silent authority and requires a complete Read reply within 250 ms, including cached startup and profile switching.

tests/screencast.rs runs the ScreenCast contract on a private dbus-daemon with no compositor: properties, the request and session paths, the Responses, refused source types and cursor modes, ownership, Start answering 2 with nothing to capture, Close, and sessions retired when their owner leaves. Capture itself needs compd and PipeWire. On a desktop session, cargo run -p portald --example screencast_probe drives one session and holds it so a consumer can attach, for example gst-launch-1.0 pipewiresrc target-object=<serial> ! videoconvert ! autovideosink.

Native tests embed the real noded via test-broker and run the production settingsd loop. They cover initial fetch, live update without reconnect, settingsd restart at unchanged revision, and broker recovery after the initial attempt budget is exhausted. These tests run by default; the build cluster needs dbus-daemon and the pinned Rust dependencies.

Not yet

  • The FileChooser portal and its chooser application.
  • A first-class accent field. Accent currently comes from the design's resolved accent, held in settingsd outside the sealed snapshot, and will move into the settings contract when that is decided.
  • Sandboxed callers and D-Bus activation.
  • For ScreenCast: window sources, several streams per session, restore tokens, DMA-BUF frames and a permission-limited PipeWire remote.

The contract, with its status, is in docs/docs/spec/portal.

This page as Markdown · This page describes the current main branch of MixOS.