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.
| Key | Type | Values |
|---|---|---|
color-scheme | u | 1 dark, 2 light |
contrast | u | 0 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)returnsa{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 returnorg.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
| Property | Value |
|---|---|
AvailableSourceTypes | 1: monitors only |
AvailableCursorModes | 1 hidden, 2 embedded |
version | 5 |
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:
| Key | Meaning |
|---|---|
| node id | the PipeWire node to connect to |
position, size | the output in compositor (logical) coordinates |
source_type | 1, monitor |
id | the 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.
Consent
Nothing is captured until the request is granted, in this order:
- Policy.
--screencast-consent allowgrants everything. Use it only for headless and test sessions. The default isprompt. - 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. - 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.
| Verb | Body | Does |
|---|---|---|
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.
multipleis accepted and ignored. - No window sources and no restore tokens.
persist_modeis accepted and ignored. - shm frames, copied twice per frame. DMA-BUF is later work.
- The remote is unrestricted.
OpenPipeWireRemotereturns 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.jsonunder 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.