← Otto Developer Guide`org.otto.Settings` — the settings D-Bus contract

The D-Bus interface the compositor serves for reading and writing settings, and the contract a settings client can build against.

Status: implemented on both sides. The compositor serves it (src/settings/, src/settings_service.rs) and otto-settings consumes it (components/otto-settings/src/settings_client.rs). This document is the interface they build against. Behavioural requirements live in specs/settings-app.md; this is the wire.

What the compositor serves today: 61 settings, of which 55 are live and six are restart. The nine dock.* ones (size, position, autohide, magnification, genie_scale, genie_span, colorize_icons, colorize_color, colorize_intensity) reconfigure the dock in place. The eleven input.* touchpad/pointer ones (tap_enabled, tap_drag_enabled, tap_drag_lock_enabled, touchpad_click_method, touchpad_dwt_enabled, touchpad_natural_scroll_enabled, touchpad_left_handed, touchpad_middle_emulation_enabled, scroll_speed, pointer_accel_speed, pointer_accel_profile) reconfigure the connected libinput devices (or, for scroll_speed, take effect on the next scroll event, since Otto reads the live config per event rather than caching it). Fifteen are appearance: theme_scheme, accent_color, rounded_corners, frosting, window_controls_side, show_maximize_button, cursor_theme, cursor_size, icon_theme, and the six wallpaper settings background_color, background_image, background_mode, background_position_x, background_position_y and background_zoom. For those the compositor’s own chrome and its window decorations are rebuilt, the cursor and icon caches dropped, the wallpaper frames re-rendered, and the change relayed to other processes through the Settings portal. The seven that need a restart are screen_scale, font_family, gtk_theme, locales, login.greeter_command, login.greeter_args and rendering.renderer. topbar.show_app_menu, topbar.show_clock and topbar.clock_format are live with nothing for the compositor to do: otto-bar reads them over this interface and follows Changed itself. desktop.widget is live too: the compositor runs ewwii for it and swaps the window it shows, or stops it for none (desktop-widget.md). No setting is unsupported. Keyed collections (dock bookmarks, shortcuts, display profiles) have no single identifier and are not in the schema.

Bus name org.otto.Settings, object path /org/otto/Settings, interface org.otto.Settings. The compositor owns the name.

The portal getters (GetColorScheme, GetIconTheme, GetAccentColor) stay exactly as they are: xdg-desktop-portal-otto depends on them and must not be disturbed.

Values

Every setting value crosses the bus as a variant. The variant’s contained type is fixed per setting and given in the schema:

Schema typeD-Bus typeNotes
boolb
inti
doubled
stringsfree text (paths, command lines)
enumsone of choices
colorsa palette name from choices, or a #RGB/#RRGGBB literal
string-listasordered, e.g. locales

Sending a variant whose type does not match the schema is an error; the compositor never coerces.

Identifiers

A stable dotted path matching the configuration structure: dock.size, input.pointer_accel_speed, power_management.on_lid_close. Top-level keys have no prefix: accent_color, cursor_size.

Identifiers are a permanent contract. Once shipped, an identifier is never renamed or repurposed. An app built against an older compositor must keep working, and the app is the thing that hardcodes these strings.

Methods

Describe()                 → aa{sv}    schema, one dict per setting
GetAll()                   → a{sv}     current effective value of every setting
Get(id: s)                 → v         one value
GetOverridden()            → as        ids currently set in the writable file
Set(id: s, value: v)       → s         status, see below
Reset(id: s)               → s         status

GetColorScheme()           → u         0 none, 1 dark, 2 light
GetIconTheme()             → s         icon theme name, empty to auto-detect
GetAccentColor()           → (ddd)     accent as sRGB in 0.0..=1.0

GetSoundTheme()            → s         sound theme name, empty to auto-detect
GetLocales()               → as        preferred locales, most preferred first
GetFontSettings()          → a{ss}     UI font and text rendering, by GNOME key

ListOutputs()              → aa{sv}    every output, physical and virtual
SetOutputProfile(connector: s, width: u, height: u,
                 refresh_hz: d, x: i, y: i,
                 primary: b)           → s   status, always pending-restart
GetOutputSubpixels()       → a{ss}     subpixel layout each display profile sets
SetOutputSubpixel(connector: s,
                  subpixel: s)         → s   status, always pending-restart
AddVirtualOutput(name: s, width: u, height: u,
                 refresh_hz: d, interactive: b,
                 persist: b)           → u   PipeWire node id
RemoveVirtualOutput(name: s)           → ()

ListShortcuts()            → a(ss)     every shortcut in force, (trigger, action)

ConfigPath()               → s         the file a change is written to

Portal getters

These three answer in the shapes org.freedesktop.appearance defines, so the portal backend can pass them through untouched. GetAccentColor resolves the stored accent name against the current palette before converting. The name is what Get("accent_color") returns, and it is the value Set takes.

Two the portal asks for by other names

GetSoundTheme goes out on org.gnome.desktop.sound as theme-name, with the same contract as GetIconTheme: empty means no preference, so the application decides for itself.

GetLocales has no portal key at all. Otto’s own components take their language from here rather than from LANG, so changing Preferred languages moves the whole desktop and not just the compositor. Sandboxed third-party applications still read the environment, which this does not touch.

GetFontSettings is keyed by the names the portal serves under org.gnome.desktop.interface: font-name (font_family at body size, in Pango points), font-antialiasing, font-hinting and font-rgba-order. It is not a setting: the rendering is fontconfig’s and the subpixel order the outputs’, so the compositor works it out on its own thread, the same values it sends Xwayland as XSETTINGS. See Text rendering.

Outputs

Outputs are deliberately not settings. The schema is a table fixed at compile time, and outputs come and go with the hardware, so there is no honest identifier for “the second display”. They get their own three methods instead.

ListOutputs returns one dictionary per output: name, connector, width, height, refresh (millihertz), x, y, scale, and virtual. A client drawing a display arrangement reads it from here rather than inventing one.

SetOutputProfile writes displays.named.<connector>, the same profile the compositor resolves when it brings that output up, so a resolution, refresh rate, position or primary choice survives a restart. It is keyed by connector because that is the handle the config has; see the Open section for why that identity is not the last word. It applies at the next start, never now, and says so: a modeset made from under a running session cannot be undone if the display does not come back, and that is worse than a restart. A zero width or height leaves the resolution unset and a zero rate leaves the refresh unset, so moving a display need not name a mode for it. Primary is a choice among displays, so a client turning it on for one must write it off for the others.

SetOutputSubpixel writes displays.named.<connector>.subpixel: rgb, bgr, vrgb, vbgr or none, and an empty string removes the key, putting the display back on automatic (grayscale when fontconfig’s rgba is none; else the connector’s report, then fontconfig’s rgba, then grayscale). Anything else is refused with OutOfRange. It applies when the output is next created, at the next start or replug, because the layout is fixed at output creation and clients learn it only then; the running configuration is updated too, so a replug this session picks it up. GetOutputSubpixels answers connector to layout for every named profile that sets one, so an absent connector is on automatic.

AddVirtualOutput creates a PipeWire-backed output on the running compositor and answers with the node id to capture from. It takes effect immediately: a virtual screen you must restart to get is useless for the thing it is mostly wanted for. persist also writes a [[virtual_outputs]] entry to the writable config so it returns next session; the entry is written only after the output actually came up, for the same reason Set persists only after a successful apply. RemoveVirtualOutput tears one down and drops its config entry, and refuses physical outputs: unmapping one would black out a real screen.

The configuration file

ConfigPath answers with the file a changed setting is written to. Otto’s configuration is layered and the writable layer is whichever sits on top: a local otto_config.toml next to the running binary outranks ~/.config/otto/config.toml. A client cannot work that out for itself. It is worth showing: everything a settings app changes lands there, and anything it does not offer is edited by hand.

Describe returns one dictionary per setting. Keys:

KeyTypeMeaning
idsthe identifier
typesfrom the table above
sectionsconfig section, e.g. dock; empty for top-level
labelsshort human-readable name
descriptionsone sentence; may be empty
defaultvthe built-in default
applyslive, restart, or unsupported
minvoptional, numeric types only
maxvoptional, numeric types only
stepvoptional, numeric types only; granularity to snap to
choicesasoptional, required for enum; for color, the palette names to offer as swatches
choice_labelsasoptional; human names for choices, same order
unavailable_choicesasoptional; entries of choices this build cannot take
applies_hereboptional; false when the setting has no effect under the running backend
confirmsoptional; password when Set and Reset first ask the user through polkit

Unknown keys must be ignored by clients, so the schema can grow without breaking them.

A setting with confirm = "password" (the locker, greeter, auto-lock, locking on suspend, the lid and power button) is changed only after polkit authorizes org.otto.settings.lock: Otto’s auth panel names the change and the user types their password. The call waits for the answer, which can take as long as the user takes, so call it without a short timeout. A refusal, or polkit not running, is org.freedesktop.DBus.Error.AccessDenied. Setting such a value to what it already is asks nothing.

step exists because a slider maps pixels to values: without it a drag lands on whatever float the pixel happened to hit, and a pointer speed the user meant to leave alone reads -0.01. Where a setting has a range it has a step, and the step always divides the range so the advertised maximum stays reachable. Clients should also snap to default when the drag comes within half a step of it, so a value can land exactly back on the inherited one.

choice_labels exists because the strings in choices are configuration tokens, and those are part of the permanent contract. clickfinger has to stay clickfinger on the wire and in the file, however badly it reads in a menu. When present it has exactly one entry per choice, and a client shows it in place of the token while continuing to Set the token. When absent, the tokens are already fit to show.

unavailable_choices lists choices the compositor knows but this session cannot honour, such as vulkan for rendering.renderer in a build without the vulkan feature, or in a session that asked for Vulkan and fell back to OpenGL because it could not start on the primary GPU. They stay in choices so a client can explain their absence; Set refuses them with Unsupported, and a menu should leave them out.

applies_here is present only for a setting tied to particular backends. rendering.renderer reaches the tty (udev) backend alone, so under --winit or --x11 it is served with applies_here: false. The value can still be set, for the next login session; a client should present it as not affecting the session it runs in. A missing key means the setting applies.

apply is what the setting does when set: live takes effect immediately; restart is persisted but needs a compositor restart, with the running session left exactly as it was, so nothing that happens to re-read the value can half-apply it; unsupported cannot be changed on this system or in this build (a display setting under a windowed backend, say) and Set will reject it. It must be truthful: a Set that silently does nothing is worse than one that refuses.

Get and GetAll answer for a restart setting with the value the next start will use: after a Set they return the persisted value, not the one the running session is still on.

Set performs validate → apply → persist → announce, in that order. It returns a status string:

Any failure is a D-Bus error, not a status, and nothing is persisted.

Reset removes the identifier from the writable config file so its value falls back to whatever the lower configuration layers provide. This is not the same as setting it to its default: the writable file is the highest-priority layer, so a default written into it permanently shadows every lower layer. Resetting an identifier that is not in the writable file succeeds and changes nothing. Returns the same status strings as Set.

GetOverridden returns the identifiers currently present in the writable file, which is what the app needs to show a per-setting revert affordance. Effective values always come from GetAll.

Signal

Changed(values: a{sv})

Emitted after any effective value changes, from any source: this API, an in-compositor interaction such as dragging the dock handle, or an external edit of a configuration file. Carries only the identifiers that changed.

A client that called Set also receives the signal. It must not assume it is the only writer, and must not suppress its own echo.

The signal is coalesced: a continuous interaction such as dragging a slider applies live throughout but emits and persists when the interaction settles.

Errors

Error nameWhen
org.otto.Settings.Error.UnknownSettingno such identifier
org.otto.Settings.Error.InvalidTypevariant type does not match the schema
org.otto.Settings.Error.OutOfRangeoutside min/max, or not in choices
org.otto.Settings.Error.Unsupportedapply is unsupported on this system, or the value is in unavailable_choices
org.otto.Settings.Error.ApplyFailedvalid, but the compositor could not apply it; message says why

ApplyFailed means nothing was persisted and the running state is unchanged.

What a client can rely on

Worked example

Describe() → [
  { id: "dock.size", type: "double", section: "dock",
    label: "Size", description: "Dock size multiplier",
    default: <1.0>, min: <0.5>, max: <2.0>, step: <0.05>, apply: "live" },
  { id: "dock.position", type: "enum", section: "dock",
    label: "Position on screen", default: <"bottom">,
    choices: ["bottom", "left", "right"],
    choice_labels: ["Bottom", "Left", "Right"], apply: "live" },
  { id: "accent_color", type: "color", section: "",
    label: "Accent colour", default: <"blue">,
    choices: ["blue", "purple", "pink", ...],
    choice_labels: ["Blue", "Purple", "Pink", ...], apply: "live" },
  { id: "input.touchpad_click_method", type: "enum", section: "input",
    label: "Click method", default: <"clickfinger">,
    choices: ["clickfinger", "buttonareas"],
    choice_labels: ["Click with fingers", "Click in corners"], apply: "live" },
  ...
]

Set("dock.size", <1.25>) → "applied"
  … Changed({ "dock.size": <1.25> })

Set("login.greeter_command", <"otto-greeter">) → "pending-restart"

Set("dock.size", <9.0>) → error OutOfRange
Set("dock.size", <"big">) → error InvalidType

Reset("dock.size") → "applied"
  … Changed({ "dock.size": <1.0> })      // whatever the lower layers give

Open