Files
ghostty-rs/scripts/live-dbus/mock.py
T
ddidderr 39865d0ae1 test(gtk): live D-Bus/portal comparison against the original Ghostty
Adds scripts/live-dbus-test.sh (`just live-dbus`): each scenario runs
the installed Ghostty 1.3.1 and the Rust build on a private
dbus-run-session bus inside a private headless weston (the user's
session bus is never written to), with Python/Gio mocks for
org.freedesktop.Notifications, the GlobalShortcuts/Settings portal and
org.freedesktop.systemd1, plus a real xdg-desktop-portal run. Scenarios:
single-instance (+detect), +new-window with arguments, notifications,
global shortcuts, color-scheme settings, cgroup scope creation, URL
opening. tests/live_dbus.rs compares the Rust build against goldens
recorded from the original (skips when tools or a fresh production
build are missing).

Two Rust bugs found and fixed (no upstream deviation):
- GlobalShortcuts CreateSession/BindShortcuts payloads used the wrong
  D-Bus signatures ((v), double-boxed values), which a real portal
  rejects; signature tests added.
- CreateSession was sent twice at startup; the config is now bound only
  after the D-Bus connection is set, as in Zig.

All nine scenarios now behave identically to the original
(docs/rewrite/notes/live-dbus.md).

Test Plan:
- just live-dbus
- cargo test --test live_dbus (needs target/vp/production/ghostty)
2026-10-12 18:49:53 +02:00

252 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Mock D-Bus services for the live D-Bus comparison (scripts/live-dbus-test.sh).
Runs on the private session bus and claims:
org.freedesktop.Notifications Notify/CloseNotification/GetCapabilities/GetServerInformation
org.freedesktop.systemd1 Manager.StartTransientUnit
org.freedesktop.portal.Desktop GlobalShortcuts, Settings, OpenURI (+ Request objects)
test.ghostty.Mock control: Activate(s), SetColorScheme(u), Quit()
Every received call is appended to $MOCK_LOG as one JSON line:
{"iface": ..., "method": ..., "args": ...}
The portal pieces are mocks (the real xdg-desktop-portal needs a running
backend and a desktop session); they implement just what Ghostty calls.
"""
import json
import os
import signal
import sys
import gi
gi.require_version("Gio", "2.0")
from gi.repository import Gio, GLib # noqa: E402
LOG = os.environ.get("MOCK_LOG", "/dev/stderr")
COLOR_SCHEME = int(os.environ.get("MOCK_COLOR_SCHEME", "0")) # 0 none, 1 dark, 2 light
BIND_REPLY = os.environ.get("MOCK_BIND", "ok")
XML = """
<node>
<interface name='org.freedesktop.Notifications'>
<method name='Notify'>
<arg type='s' direction='in'/><arg type='u' direction='in'/><arg type='s' direction='in'/>
<arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='as' direction='in'/>
<arg type='a{sv}' direction='in'/><arg type='i' direction='in'/><arg type='u' direction='out'/>
</method>
<method name='CloseNotification'><arg type='u' direction='in'/></method>
<method name='GetCapabilities'><arg type='as' direction='out'/></method>
<method name='GetServerInformation'>
<arg type='s' direction='out'/><arg type='s' direction='out'/><arg type='s' direction='out'/><arg type='s' direction='out'/>
</method>
<signal name='ActionInvoked'><arg type='u'/><arg type='s'/></signal>
<signal name='NotificationClosed'><arg type='u'/><arg type='u'/></signal>
</interface>
<interface name='org.freedesktop.systemd1.Manager'>
<method name='StartTransientUnit'>
<arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='a(sv)' direction='in'/>
<arg type='a(sa(sv))' direction='in'/><arg type='o' direction='out'/>
</method>
</interface>
<interface name='org.freedesktop.portal.GlobalShortcuts'>
<method name='CreateSession'><arg type='a{sv}' direction='in'/><arg type='o' direction='out'/></method>
<method name='BindShortcuts'>
<arg type='o' direction='in'/><arg type='a(sa{sv})' direction='in'/><arg type='s' direction='in'/>
<arg type='a{sv}' direction='in'/><arg type='o' direction='out'/>
</method>
<signal name='Activated'><arg type='o'/><arg type='s'/><arg type='t'/><arg type='a{sv}'/></signal>
</interface>
<interface name='org.freedesktop.portal.Session'><method name='Close'/></interface>
<interface name='org.freedesktop.portal.Settings'>
<method name='ReadAll'><arg type='as' direction='in'/><arg type='a{sa{sv}}' direction='out'/></method>
<method name='Read'><arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='v' direction='out'/></method>
<signal name='SettingChanged'><arg type='s'/><arg type='s'/><arg type='v'/></signal>
<property name='version' type='u' access='read'/>
</interface>
<interface name='org.freedesktop.portal.OpenURI'>
<method name='OpenURI'>
<arg type='s' direction='in'/><arg type='s' direction='in'/><arg type='a{sv}' direction='in'/><arg type='o' direction='out'/>
</method>
<method name='OpenFile'>
<arg type='s' direction='in'/><arg type='h' direction='in'/><arg type='a{sv}' direction='in'/><arg type='o' direction='out'/>
</method>
<property name='version' type='u' access='read'/>
</interface>
<interface name='org.freedesktop.portal.Request'><method name='Close'/>
<signal name='Response'><arg type='u'/><arg type='a{sv}'/></signal></interface>
</node>
"""
CTRL_XML = """
<node><interface name='test.ghostty.Mock'>
<method name='Activate'><arg type='s' direction='in'/></method>
<method name='SetColorScheme'><arg type='u' direction='in'/></method>
<method name='Invoke'><arg type='u' direction='in'/><arg type='s' direction='in'/></method>
<method name='SetFail'><arg type='b' direction='in'/></method>
<method name='Quit'/>
</interface></node>
"""
nid = [0]
state = {"session": None, "scheme": COLOR_SCHEME}
loop = GLib.MainLoop()
conns = {}
def log(iface, method, args):
def conv(o):
if isinstance(o, (bytes, bytearray)):
return list(o)
return str(o)
with open(LOG, "a") as f:
f.write(json.dumps({"iface": iface, "method": method, "args": args}, default=conv, sort_keys=True) + "\n")
def unpack(v):
return v.unpack() if v is not None else None
def request_path(sender, options):
tok = options.get("handle_token", "tok")
return "/org/freedesktop/portal/desktop/request/" + sender[1:].replace(".", "_") + "/" + tok
def reply_request(conn, sender, options, results):
"""Emit Request.Response after the method reply was sent."""
path = request_path(sender, options)
def emit():
conn.emit_signal(
sender, path, "org.freedesktop.portal.Request", "Response",
GLib.Variant("(ua{sv})", (0, results)),
)
return False
GLib.timeout_add(100, emit)
return path
def settings_all():
return {"org.freedesktop.appearance": {"color-scheme": GLib.Variant("u", state["scheme"])}}
def on_call(conn, sender, path, iface, method, params, inv):
args = unpack(params)
if iface == "org.freedesktop.Notifications":
if method == "Notify":
log(iface, method, args)
nid[0] += 1
inv.return_value(GLib.Variant("(u)", (nid[0],)))
elif method == "GetCapabilities":
inv.return_value(GLib.Variant("(as)", (["actions", "body", "body-markup", "icon-static"],)))
elif method == "GetServerInformation":
inv.return_value(GLib.Variant("(ssss)", ("mock", "ghostty-test", "1.0", "1.2")))
else:
log(iface, method, args)
inv.return_value(None)
elif iface == "org.freedesktop.systemd1.Manager":
log(iface, method, args)
if state.get("fail"):
inv.return_dbus_error("org.freedesktop.DBus.Error.AccessDenied", "mock failure")
return
inv.return_value(GLib.Variant("(o)", ("/org/freedesktop/systemd1/job/1",)))
elif iface == "org.freedesktop.portal.GlobalShortcuts":
log(iface, method, args)
if method == "CreateSession":
opts = args[0]
sess = "/org/freedesktop/portal/desktop/session/" + sender[1:].replace(".", "_") + "/" + opts.get("session_handle_token", "s")
state["session"] = sess
state["sender"] = sender
sess_info = Gio.DBusNodeInfo.new_for_xml(XML).lookup_interface("org.freedesktop.portal.Session")
conn.register_object(sess, sess_info, on_call, None, None)
rp = reply_request(conn, sender, opts, {"session_handle": GLib.Variant("s", sess)})
inv.return_value(GLib.Variant("(o)", (rp,)))
else:
rp = reply_request(conn, sender, args[3], {"shortcuts": GLib.Variant("a(sa{sv})", [])})
inv.return_value(GLib.Variant("(o)", (rp,)))
elif iface == "org.freedesktop.portal.Session":
log(iface, method, {"path": path})
inv.return_value(None)
elif iface == "org.freedesktop.portal.Settings":
log(iface, method, args)
if method == "ReadAll":
inv.return_value(GLib.Variant("(a{sa{sv}})", (settings_all(),)))
else:
inv.return_value(GLib.Variant("(v)", (GLib.Variant("v", GLib.Variant("u", state["scheme"])),)))
elif iface == "org.freedesktop.portal.OpenURI":
log(iface, method, args)
rp = reply_request(conn, sender, args[-1], {})
inv.return_value(GLib.Variant("(o)", (rp,)))
elif iface == "org.freedesktop.portal.Request":
inv.return_value(None)
elif iface == "test.ghostty.Mock":
if method == "Activate":
conn.emit_signal(
None, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.GlobalShortcuts", "Activated",
GLib.Variant("(osta{sv})", (state["session"], args[0], 0, {})),
)
elif method == "SetColorScheme":
state["scheme"] = args[0]
conn.emit_signal(
None, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings", "SettingChanged",
GLib.Variant("(ssv)", ("org.freedesktop.appearance", "color-scheme", GLib.Variant("u", args[0]))),
)
elif method == "Invoke":
conn.emit_signal(
None, "/org/freedesktop/Notifications", "org.freedesktop.Notifications", "ActionInvoked",
GLib.Variant("(us)", (args[0], args[1])),
)
elif method == "SetFail":
state["fail"] = args[0]
elif method == "Quit":
loop.quit()
inv.return_value(None)
def on_prop(conn, sender, path, iface, name):
return GLib.Variant("u", 1 if iface == "org.freedesktop.portal.Settings" else 3)
def on_bus(conn, name):
node = Gio.DBusNodeInfo.new_for_xml(XML)
paths = {
"org.freedesktop.Notifications": ["/org/freedesktop/Notifications"],
"org.freedesktop.systemd1.Manager": ["/org/freedesktop/systemd1"],
"org.freedesktop.portal.GlobalShortcuts": ["/org/freedesktop/portal/desktop"],
"org.freedesktop.portal.Settings": ["/org/freedesktop/portal/desktop"],
"org.freedesktop.portal.OpenURI": ["/org/freedesktop/portal/desktop"],
"org.freedesktop.portal.Session": [],
}
for ifc in node.interfaces:
for p in paths.get(ifc.name, []):
conn.register_object(p, ifc, on_call, on_prop, None)
ctrl = Gio.DBusNodeInfo.new_for_xml(CTRL_XML)
conn.register_object("/test/ghostty/Mock", ctrl.interfaces[0], on_call, None, None)
conns["c"] = conn
def acquired(conn, name):
if name == "test.ghostty.Mock":
sys.stdout.write("READY\n")
sys.stdout.flush()
# Own all names on one connection; announce READY once the last is acquired.
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
on_bus(bus, None)
names = os.environ.get("MOCK_NAMES", "notifications,systemd,portal").split(",")
want = []
if "notifications" in names:
want.append("org.freedesktop.Notifications")
if "systemd" in names:
want.append("org.freedesktop.systemd1")
if "portal" in names:
want.append("org.freedesktop.portal.Desktop")
want.append("test.ghostty.Mock")
for n in want:
Gio.bus_own_name_on_connection(bus, n, Gio.BusNameOwnerFlags.NONE, acquired, None)
signal.signal(signal.SIGTERM, lambda *a: loop.quit())
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, signal.SIGINT, lambda *a: loop.quit())
loop.run()