Desktop Bus capabilities

Status: API 1, implementation 0.3.8. Every verb is open to mesh callers by default. Explicit grants apply only under the opt-in lock described below. Cross-node calls use native ABP between noded instances, requiring noded 0.15.0 or newer at both ends. Automatic clipboard synchronisation is not implemented.

Clipboard helpers pin wl-copy and wl-paste to the compositor's human seat. On a foreign compositor, only wl-clipboard's exact missing-seat error permits one retry using its default seat. The service logs the selected path and outcome without logging clipboard text. Each wl-copy attempt routes stderr to a private temporary file in the session runtime directory, reads it only on failure, and removes it afterwards. This lets its background selection owner keep stderr open without delaying the reply or being killed by the helper timeout. wl-paste still captures stderr.

The implementation version is the -- version: header of src/desktop/scripts/desktop-session.mix, which is what mix desktop-session.mix --version prints and what desktop.capabilities reports as implementation_version (read through script_version()). Bump the header. lib/desktop.mix keeps the same number as a literal fallback for a mix older than 0.95.0, and tests/desktop-test.mix fails when the two disagree.

The reusable scripts live in src/desktop/scripts/. One supervised Mix citizen belongs to one desktop session. It uses the Wayland display, runtime directory and session D-Bus inherited at launch; requests cannot select another session or executable. Use separate service names for simultaneous desktops.

Start a provider

Create a private strict-data session configuration containing:

{session:"desktop-a", opener:"/usr/bin/xdg-open"}

Set DESKTOP_SESSION_CONFIG to its absolute path and COSMIX_NODE_CONFIG to the intended local broker configuration. Launch from the intended desktop environment, under its user:

mix --serve /path/to/cosmix/src/desktop/scripts/desktop-session.mix --name desktop-a

The session needs wl-copy, wl-paste and the configured opener. The service reports whether these prerequisites are configured; actual operations can still fail if the compositor or session bus is unavailable. Run it in a systemd unit bound to the desktop lifetime, with KillMode=control-group, so clipboard-owner children exit with that session. Set MIX_STATS=off.

Commands

Set COSMIX to the checkout root (or DESKTOP_REQUEST_WORKER to the installed desktop-request.mix). The CLI registers a temporary citizen and deregisters when finished. It prints metadata and outcome only, never clipboard text or the opened URL.

mix /path/to/cosmix/src/desktop/scripts/desktop-cli.mix capabilities desktop-a
mix /path/to/cosmix/src/desktop/scripts/desktop-cli.mix copy desktop-a desktop-b
mix /path/to/cosmix/src/desktop/scripts/desktop-cli.mix open desktop-b https://example.org/

Targets may be local services or service.node.bus addresses. Cross-node messages use the existing noded ABP transport, without an alternative relay. By default every verb is open to mesh callers (see below). The grant lists apply only when the provider runs with COSMIX_MESH_OPEN=0. Both lists are empty by default. In that mode:

  • mesh_clipboard_nodes:["alpha"] grants capabilities, read and write to node alpha's registered local citizens.
  • mesh_history_nodes:["alpha"] also grants desktop.clipboard.history and desktop.clipboard.entry. It must be a subset of mesh_clipboard_nodes.
  • Every other verb is local-only.

Add the lists to the trusted provider configuration and restart it. Cross-mesh @ addresses are refused.

Under the open default, the citizen checks only that noded stamped the call broker_origin=mesh. Whatever noded itself requires to deliver a mesh call still applies; see noded. The lock needs more, because it relies on the attested broker_peer and broker_service stamps. noded supplies those only when both nodes have protected WireGuard endpoints, verified signed membership and D2 identities, and the receiving noded enforces admission. A provider grant never substitutes for broker admission. Example:

mix /path/to/cosmix/src/desktop/scripts/desktop-cli.mix copy desktop-a desktop-b.beta.bus
mix /path/to/cosmix/src/desktop/scripts/desktop-cli.mix copy desktop-b.beta.bus desktop-a
VerbJSON requestSuccessful response
desktop.capabilities{}API/implementation version, session/instance, access posture, configured operations and limits
desktop.clipboard.read{instance}{instance,mime,text,bytes}
desktop.clipboard.write{instance,text}{instance,accepted:true,bytes}
desktop.open{instance,url}{instance,accepted:true}

Discover the current instance before read/write/open. Each process start gets a new UUID; broker reconnect preserves it. Unknown fields, old instances, non-HTTP(S) URLs, NUL text, invalid UTF-8 and oversized text are rejected. Text whitespace and trailing newlines are preserved. Empty text is valid; unavailable clipboard data returns an error rather than inventing empty text.

accepted means the fixed helper exited successfully. It does not prove a browser page loaded or that another application pasted the text. A helper timeout returns ACTION_OUTCOME_UNKNOWN; callers must not automatically retry. There is no idempotency/replay guarantee in API 1.

Errors use a nonzero application rc and {error:STABLE_CODE}: rc 10 invalid request/data; 11 unavailable/helper failure; 12 stale session; 13 caller rejected; 20 timeout with ambiguous outcome. Transport errors remain separate.

Trust and privacy boundary

Local calls require broker-stamped broker_origin=local and a canonical registered from. A call stamped broker_origin=mesh reaches every verb, including desktop.open, desktop.clipboard.menu and desktop.clipboard.rotate, with no per-peer grant (citizen 0.3.6 and later). noded sets broker_origin from the source address, so the WireGuard mesh is the trust boundary. The instance fence still applies to mesh callers: a stale instance is refused with rc 12. Refusal with rc 13 is then reserved for two cases. The first is a call with no recognised broker origin. The second is a local policy gate, not a well-formedness check: a local call without a canonical registered from is refused. The same anonymous send from a mesh node is admitted, so locally the citizen is stricter than over the mesh. Local callers use a registered one-shot citizen instead, as desktop-cli.mix does. Whether to keep this gate is an open decision.

desktop.capabilities reports the posture it enforces. Under the open default it returns access:"mesh-open" and mesh_open:true. Under the lock it returns access:"registered-local-with-explicit-mesh-clipboard-grants" and mesh_open:false. Before citizen 0.3.6, access always carried the second string, and mesh_open was absent. history_mesh.granted counts the configured history grants in either posture, although only the lock uses them.

Setting COSMIX_MESH_OPEN=0 in the provider's environment re-arms the strict opt-in lock. A mesh call then requires a verb covered by a grant list, an allowed broker_peer, a canonical broker_service, and from=bridge-<peer>. noded supplies these only for a direct registered source received on a proven, currently authorised bridge connection. Anonymous sources and multi-hop relays receive no such authority. Callers cannot supply their own identity stamps.

A node grant trusts that node's registered local citizens; it is not per-app consent or Unix UID isolation. Revocation is checked at broker enqueue; work already queued or executing cannot be recalled. Session admission retains the existing inventory policy for overlapping D2 credentials. See noded.

Broker taps can expose message bodies. Do not mistake suppressed helper logs for end-to-end clipboard confidentiality. Use an isolated/trusted broker for real clipboard data. Clipboard history IS written to disk: captured text is kept in history.path if configured, otherwise in $XDG_STATE_HOME/cosmix/clipboard/history-<session>.json, with ~/.local/state used when XDG_STATE_HOME is unset. Pause capture with desktop.clipboard.pause and remove entries with desktop.clipboard.clear. No payload is retained on topics or included in notifications by these scripts. Runtime-reserved verbs (including QUIT) and lifecycle properties are provided by Mix and do not pass through the desktop handler checks.

Application registry citizen (apps)

A second desktop citizen, src/desktop/scripts/apps.mix, is the data foundation for the desktop launcher and the taskbar's app icons. It scans the freedesktop desktop-entry directories ($XDG_DATA_HOME/applications, then each $XDG_DATA_DIRS entry's applications, subdirectories folded into the id with -), keeps [Desktop Entry]-group Type=Application entries that pass OnlyShowIn/NotShowIn (against the desktop name COSMIX) and TryExec; Hidden=true erases an entry (the spec treats it as deleted) while NoDisplay=true only hides it from apps.list — the entry stays in the index with no_display set, so apps.get and apps.launch still find it. Localised keys resolve per the spec's lang_COUNTRY@MODIFIER order from LC_ALL, then LC_MESSAGES, then LANG (encoding stripped). Icons resolve through the freedesktop icon-theme spec (exact size match, then closest by DirectorySizeDistance, Inherits depth-first, hicolor, then /usr/share/pixmaps; parsed index.theme files are cached in memory). Start it like the session provider:

mix --serve /path/to/cosmix/src/desktop/scripts/apps.mix

It is event-driven: the tree is scanned once at start and again only on apps.reload. There is no timer or poll loop; a future inotify wake is the intended rescan trigger. src/desktop/scripts/cosmix-desk-apps.service is an example user unit bound to graphical-session.target.

apps.launch sets DISPLAY for each child from comp's live XWayland, not from the citizen's own environment. A static DISPLAY in the citizen's unit is ignored. At every launch it reads comp's XWayland descriptor, $XDG_RUNTIME_DIR/cosmix-comp/$WAYLAND_DISPLAY.xwayland.env, and passes its DISPLAY=:N to the child on top of the inherited environment. Because it is read per launch, an XWayland restart on another display number is seen by the next launch.

With no usable descriptor, the child is launched with DISPLAY removed, never inherited. That covers XWayland not ready yet, restarting or disabled, and a nested comp without it. An X11 app then fails at once instead of drawing on another X server. The citizen writes one stderr line per such launch naming the reason, including an unreadable descriptor. The file is mode 0600, so the citizen must run as comp's uid. The unset goes through /usr/bin/env -u DISPLAY, because Mix's spawn env option cannot remove a variable. The program is resolved first, on the same PATH the child gets, and env is handed the absolute path. A path containing = goes through /bin/sh -c 'exec "$@"', because GNU env reads any such operand as an assignment. On this path the app's argv[0] is therefore its absolute path. A missing or non-executable program is still refused with rc 11 spawn_failed, never answered with the wrapper's pid.

A descriptor left behind by a comp killed with SIGKILL is not checked against its GENERATION or against a live XWayland. It is trusted until comp's next start republishes or removes it.

VerbJSON requestSuccessful response
apps.list{category?, query?}array of {id,name,generic_name,comment,icon,categories,exec,terminal,path} sorted by name
apps.get{id}the entry with all parsed fields (adds keywords, try_exec, no_display, workdir, only_show_in, not_show_in)
apps.icon{name, size?, scale?, theme?}{path,size,scale,theme,kind} — kind is svg, png or xpm; name may be an absolute path
apps.launch{id, uris?}{id,pid,argv} — spawned detached via argv, never a shell
apps.reload{}{count}

query is a case-insensitive substring over name, generic name, comment and keywords. uris feed the Exec field codes: %f/%F take file:// URIs decoded to local paths (an empty or localhost authority is local per RFC 8089; any other authority keeps the URI as-is), %u/%U take the URIs, %i becomes --icon ICON, %c the name, %k the desktop-file path; deprecated %d %D %n %N %v %m are dropped, unrecognised %X sequences stay literal, and Exec quoting is parsed into argv per the spec (%f/%F/%u/%U are only meaningful as a standalone argument). Terminal=true entries are prefixed with the terminal property's argv — apps.terminal may carry arguments (e.g. kitty -e) and is parsed with the same Exec rules. Unknown ids answer {error:"not_found"} with rc 14; malformed requests rc 10.

Properties publish through apps.props.watch (snapshot read; optionally {path} for one leaf) and apps.props.set ({path, value}): apps.count (read-only), apps.icon_theme (default: [Icons] Theme from ~/.config/kdeglobals, else breeze when installed, else hicolor) and apps.terminal (default foot). Mix 0.89.0 reserves apps.props.{get,list,describe} for the runtime-owned lifecycle tree, so the author properties ride the SPEC-12 fall-through verbs instead.

Workspace citizen (desktop)

src/desktop/scripts/desktop-workspace.mix registers the Bus service desktop and switches cosmix-comp's workspaces. inputd's default keymap fires two of its verbs with an empty body: RightCtrl+Left (code 105) sends desktop.workspace.prev and RightCtrl+Right (code 106) sends desktop.workspace.next. Both rows route by first segment, so they reach this citizen with no service field (see Inputd keymap rows). Start it under that name:

mix --serve /path/to/cosmix/src/desktop/scripts/desktop-workspace.mix --name desktop

Each verb makes exactly one call to comp, with a 3-second budget. COSMIX_DESKTOP_COMP_SERVICE names comp's Bus service and defaults to comp.

VerbJSON requestForwards to compReply
desktop.workspace.nextempty or {}comp.workspace.switch {"index":"next","wrap":true}{ok,from,to}
desktop.workspace.prevempty or {}comp.workspace.switch {"index":"prev","wrap":true}{ok,from,to}
desktop.workspace.set{"n":N}, N an integer 1–16comp.workspace.switch {"index":N,"wrap":true}{ok,from,to}
desktop.workspace.currentempty or {}comp.props.get {"path":"workspaces"}{ok,desktop,count}

comp does the wrap: next on the last workspace lands on the first, and prev on the first lands on the last. The citizen sends wrap explicitly, so it does not depend on comp's default. from and to are comp's own numbers. current returns count too, so a caller knows where next will wrap. comp still range-checks set, so an N above the live workspace count is comp's invalid_value.

{"ok":true,"from":4,"to":1}
{"ok":true,"desktop":3,"count":4}

Every failure has an rc of 10 or more, so the $rc >= 10 test reads it as a failure:

rcMeaningBodyWhat to do
10Malformed request, refused locally and never sent to comp{ok:false,error}Fix the request body
11comp unreachable: no reply, a broker refusal such as an unregistered service, or the 3-second timeout{ok:false,error}Check that comp is registered and running
12comp replied outside its contract{ok:false,error:"malformed comp reply",comp}Likely a comp version mismatch, so file it
comp's rccomp refused, for example locked under a session lock{ok:false,error,comp}Read comp.error_code

A malformed request is a body that is not a JSON object, any field on next, prev or current, or a set without exactly an integer n in range. On a comp refusal, comp is comp's whole reply. comp stamps error_code beside error on every refusal and adds detail fields where it has them. A set above the live count, for example, is rc 10 with this comp field:

{"error":"invalid_value","path":"index","expected":"unsigned integer","range":"1..=4","error_code":"invalid_value"}

error in the reply is comp's error, or its error_code when error is absent. On a comp whose workspaces subtree has no count, which is true of releases before workspace counts, current answers rc 12 with the subtree under comp. There is no retry and no second backend. Mesh callers reach every verb with no authorization gate, and only well-formedness is checked.

next is a Mix keyword, so quote the verb in a Mix send until the parser fix lands:

send desktop "desktop.workspace.next"
$b = json_encode({n: 2})
send desktop desktop.workspace.set body=$b

Install: the citizen requires lib/workspace.mix beside it, so install both files into /opt/cosmix/share/desktop/. Stop any running desktop citizen first, because two registrations of the same service name are undefined.

install -D -m 0644 src/desktop/scripts/desktop-workspace.mix /opt/cosmix/share/desktop/desktop-workspace.mix
install -D -m 0644 src/desktop/scripts/lib/workspace.mix /opt/cosmix/share/desktop/lib/workspace.mix

src/desktop/scripts/cosmix-desktop-workspace.service is an example system unit. Its one placeholder is User=CHANGE-ME: set it to the desktop user before systemctl enable. systemd derives HOME from it. The unit's comment describes the user-unit form for hosts that run a user session manager. Validation and reply shaping live in src/desktop/scripts/lib/workspace.mix.

Earlier deployments ran a private copy of this citizen with a KWin fallback over the session D-Bus. This script replaces that copy. The KWin path is not carried over, because cosmix components reach D-Bus only through cosmix-dbusd adapters.

Scene Editor chord (scenes)

inputd's default keymap binds Ctrl+Alt+P twice on KEY_P (code 25): once with Left Ctrl and Left Alt, once with Right Ctrl and Left Alt. Both rows send scenes.editor.open with body {"safe":true} to the scenes loader, which is registered as scenes. Right Alt is not bound, because it is AltGr on some layouts. The rows swallow the chord, so applications never receive it, and they fire on the press only, not on auto-repeat. A safe open while the shipped editor is visible hides it, so the same chord opens and closes the editor. The rows and how to add them to an existing keymap file are in Inputd keymap rows.

The same request from a terminal or an agent needs no key:

$b = json_encode({safe: true})
send scenes "scenes.editor.open" body=$b

Verification

tests/desktop-test.mix exercises production request validation and result handling without desktop effects. Run it with COSMIX_MESH_OPEN=0 in the environment:

env COSMIX_MESH_OPEN=0 mix src/desktop/scripts/tests/desktop-test.mix

Most of the file tests the locked posture, and without that setting it fails early, at the first grant check. The test opens the default posture itself for its full-mesh-access block and restores the setting afterwards. The locked checks for menu, rotate and capabilities run only when the setting is 0. tests/desktop-bus-test.mix uses a real, isolated noded and two production citizens with synthetic helper programs; it requires a user systemd manager and the installed Mix/noded binaries. It does not read or replace the user's clipboard or open a real browser. The noded suite covers reply connection ownership, spoofed identity stripping, proof/registration/membership gates and reload while delivery is waiting. Deployment acceptance still requires bidirectional transfer over the actual admitted nodes and their Wayland sessions; unit gates do not prove that result.

tests/apps-test.mix exercises the apps citizen's desktop-entry parser and icon resolver against a fixture tree it creates under a temp dir (no Bus, nothing launched). The pure functions live in src/desktop/scripts/lib/apps.mix so the test can require them directly. tests/apps-bus-test.mix serves the production apps citizen against an isolated noded (private TCP port plus private verified-lane unix socket, so it needs no user systemd manager and cannot reach the host Bus) and covers list/get/launch/reload/props over the real Bus with recorder-script fixture "applications" — no real application is ever launched.

tests/workspace-test.mix exercises the workspace citizen's request validation and reply shaping with no Bus and no compositor:

mix src/desktop/scripts/tests/workspace-test.mix