Introduction
I run a small web shop from my home office in Pompano Beach. The same machine also hosts a personal automation server that drives a few on‑screen demos, takes periodic screenshots for monitoring, and even clicks a few buttons in a legacy GUI when a webhook arrives. All of that runs under my regular user account – no sudo, no X11, pure Wayland.
GNOME’s compositor, Mutter, ships two D‑Bus interfaces that are perfect for this use case:
-
org.gnome.Mutter.RemoteDesktop– lets you inject pointer and keyboard events. -
org.gnome.Mutter.ScreenCast– lets you start a PipeWire screencast and read the frames.
The trick is that both are exposed over the session bus and require only the org.gnome.Mutter.RemoteDesktop and org.gnome.Mutter.ScreenCast permissions, which GNOME grants to any user that is logged into the desktop. Below is a step‑by‑step guide to wiring them together with Python.
Prerequisites
- GNOME 45 (or later) running on Wayland.
- Python 3.9+.
-
dbus-next– a modern async D‑Bus client. -
pypipewire– thin wrapper around PipeWire’s C API (optional, we can read raw fd). -
glib– for the main loop (gi.repository.GLib).
Install the Python bits with:
pip install dbus-next pypipewire
Getting a Session Bus Connection
The session bus is already available to any graphical user. Using dbus-next we can connect like this:
from dbus_next.aio import MessageBus
import asyncio
async def get_bus():
bus = await MessageBus().connect()
return bus
The MessageBus object will be reused for both interfaces.
Requesting a Screencast
The ScreenCast interface follows the same pattern as the older XDG desktop portal. First we create a session, then we start it.
SCREENCAST_IFACE = 'org.gnome.Mutter.ScreenCast'
SCREENCAST_PATH = '/org/gnome/Mutter/ScreenCast'
async def start_screencast(bus):
proxy = await bus.introspect(SCREENCAST_IFACE, SCREENCAST_PATH)
obj = bus.get_proxy_object(SCREENCAST_IFACE, SCREENCAST_PATH, proxy)
iface = obj.get_interface(SCREENCAST_IFACE)
# Create a new session – we ask for a single monitor, 30 fps, RGBA.
reply = await iface.call_create_session({'source_type': 1, # MONITOR
'monitor': 0,
'cursor_mode': 2}) # EMBEDDED
session_path = reply[0]
# Open a PipeWire stream for the session.
reply = await iface.call_open_pipe_wire_remote(session_path, {})
fd = reply[0].take() # Raw file descriptor for the PipeWire node.
return fd
The returned file descriptor can be handed to pypipewire to pull frames. A minimal frame reader looks like this:
import pypipewire as pw
async def read_frames(fd):
stream = pw.Stream(fd)
async for frame in stream:
# `frame` is a numpy‑compatible buffer; we just count frames here.
print('got frame', frame.width, frame.height)
In production I store the latest frame in a shared memory object so the automation logic can inspect pixels without blocking the capture loop.
Injecting Input via RemoteDesktop
The RemoteDesktop interface is a thin wrapper around the same code path that GNOME’s built‑in “remote control” uses.
RDESK_IFACE = 'org.gnome.Mutter.RemoteDesktop'
RDESK_PATH = '/org/gnome/Mutter/RemoteDesktop'
async def init_remote_desktop(bus):
proxy = await bus.introspect(RDESK_IFACE, RDESK_PATH)
obj = bus.get_proxy_object(RDESK_IFACE, RDESK_PATH, proxy)
iface = obj.get_interface(RDESK_IFACE)
# Enable the session – required before sending events.
await iface.call_enable(True)
return iface
Moving the Pointer
The pointer API expects normalized coordinates (0‑1). To click the centre of the primary monitor we do:
async def click_center(rdesk):
# Move to centre.
await rdesk.call_move_pointer(0.5, 0.5)
# Press left button (button 1).
await rdesk.call_button_press(1)
await rdesk.call_button_release(1)
Typing Text
Keyboard events are sent as XKB keycodes. The easiest way is to ask the system for a mapping via xkbcommon. For a short demo I ship a tiny lookup table for ASCII letters.
ASCII_TO_XKB = {
'a': 0x26, 'b': 0x38, 'c': 0x36, # … truncated for brevity
}
async def type_string(rdesk, text):
for ch in text:
keycode = ASCII_TO_XKB.get(ch.lower())
if not keycode:
continue
await rdesk.call_key_press(keycode)
await rdesk.call_key_release(keycode)
In a real script I generate the table programmatically with xkbcommon to avoid missing symbols.
Putting It All Together
The following main() function starts the screencast, launches a background task that watches for a red pixel in the top‑left corner, and clicks a button when the pixel appears.
import asyncio
from gi.repository import GLib
async def monitor_and_click(bus, rdesk):
fd = await start_screencast(bus)
# Simple frame loop – break when condition met.
async for frame in read_frames(fd):
# Assume frame is a numpy array (height, width, 4).
if (frame[0,0,:3] == [255,0,0]).all():
print('red pixel detected – clicking')
await click_center(rdesk)
break
async def main():
bus = await get_bus()
rdesk = await init_remote_desktop(bus)
await monitor_and_click(bus, rdesk)
# Clean up – disable remote desktop.
await rdesk.call_enable(False)
# Run inside GLib's main loop so D‑Bus signals are processed.
loop = GLib.MainLoop()
asyncio.get_event_loop().run_until_complete(main())
loop.run()
The script runs entirely as my normal user. No sudo or X11 tricks are needed, and because everything talks to the session bus, the compositor enforces the same security model you get when you use GNOME’s built‑in “Remote Desktop” feature.
Running as a Systemd User Service
I keep the automation alive with a systemd --user unit. Create ~/.config/systemd/user/gnome‑automation.service:
[Unit]
Description=GNOME Wayland automation script
After=graphical-session.target
[Service]
ExecStart=/usr/bin/python3 /home/kaleb/automation/gnome_automation.py
Restart=on-failure
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=default.target
Enable and start it:
systemctl --user daemon-reload
systemctl --user enable --now gnome-automation.service
The service will automatically reconnect to the session bus each time I log in, and it will stop cleanly when I log out.
Security Considerations
- Permission Scope – The D‑Bus interfaces are only exposed to the active session. A rogue process that runs under a different user cannot talk to them.
- User Confirmation – GNOME shows a one‑time dialog the first time an application requests RemoteDesktop or ScreenCast. I accepted it during development; the dialog is a useful guardrail for any future scripts.
- Network Exposure – All communication stays on the local D‑Bus. If you need remote triggers, expose a tiny HTTP endpoint on the automation server and let the script poll it – never forward the D‑Bus over the network.
Conclusion
Mutter’s D‑Bus APIs give you a clean, root‑less way to automate a Wayland desktop. By combining ScreenCast for visual feedback and RemoteDesktop for input injection, you can build headless‑style workflows on a regular GNOME session. The code snippets above are exactly what I run on my own machine to generate screenshots for a status page and to click a “Refresh” button when a webhook tells me new data is available.
I build sites and automations for small businesses at dreamfuturestech.com.
Top comments (0)