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)
This commit is contained in:
ddidderr committed 2026-10-12 18:49:53 +02:00
1 parent e44b9271d6
commit 30863e1f0b
25 files changed
+1278 -31

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Iteration build of the Rust binary used by live-dbus-test.sh / visual-parity.
cd "$(dirname "$0")/../.." || exit 1
CARGO_TARGET_DIR=target/vp CARGO_PROFILE_PRODUCTION_LTO=off CARGO_PROFILE_PRODUCTION_CODEGEN_UNITS=16 \
CARGO_PROFILE_PRODUCTION_INCREMENTAL=true exec cargo build --profile production "$@"
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Prints links on known rows, then reports the text area size (CSI 18 t).
esc=$(printf '\033'); bel=$(printf '\007')
stty raw -echo
printf '%s[H%s[2J' "$esc" "$esc"
printf 'https://example.org/plain\r\n'
printf '%s]8;;https://example.com/osc8%s\\osc8-link%s]8;;%s\\\r\n' "$esc" "$esc" "$esc" "$esc"
printf 'file:///etc/hostname\r\n'
printf '/etc/hostname\r\n'
printf '%s[18t' "$esc"
exec cat -v >>"$OUTF"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
# Terminal child for the notifications scenario.
esc=$(printf '\033'); bel=$(printf '\007')
sleep 2
printf '%s]9;osc9 body%s' "$esc" "$bel"; sleep 1.2
printf '%s]777;notify;Title777;Body777%s' "$esc" "$bel"; sleep 1.2
printf '%s]777;notify;;OnlyBody%s' "$esc" "$bel"; sleep 1.2
printf '%s]777;notify;Only title;%s' "$esc" "$bel"; sleep 1.2
printf '%s]9;<b>markup</b> & "quotes" \303\251\342\202\254%s' "$esc" "$bel"; sleep 1.2
printf '%s]9;osc9 body%s' "$esc" "$bel"; sleep 1.2
printf '%s]777;notify;semi;colon;body%s' "$esc" "$bel"; sleep 1.2
# command finished (OSC 133 prompt marks): C then D;3 after >1s
printf '%s]133;A%s%s]133;B%s%s]133;C%s' "$esc" "$bel" "$esc" "$bel" "$esc" "$bel"; sleep 2
printf '%s]133;D;3%s' "$esc" "$bel"; sleep 1.5
printf '%s]133;A%s%s]133;B%s%s]133;C%s' "$esc" "$bel" "$esc" "$bel" "$esc" "$bel"; sleep 2
printf '%s]133;D;0%s' "$esc" "$bel"
# wait for the harness to ask for one more notification
while [ ! -e "$FLAG2" ]; do sleep 0.3; done
printf '%s]9;after reload%s' "$esc" "$bel"
sleep 60
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Enables color-scheme reporting (mode 2031), asks for the current scheme
# (CSI ? 996 n) and keeps asking for the background colour (OSC 11).
esc=$(printf '\033'); bel=$(printf '\007')
stty raw -echo
printf '%s[?2031h%s[?996n' "$esc" "$esc"
( while :; do printf '%s]11;?%s' "$esc" "$bel"; sleep 0.7; done ) &
exec cat -v >>"$OUTF"
+15
View File
@@ -0,0 +1,15 @@
== facts ==
phase default:
phase always:
call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"}
phase always-limits:
call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["MemoryHigh", 123456789], ["TasksMax", 77], ["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"}
phase never:
phase always-failing:
call: {"args": ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []], "iface": "org.freedesktop.systemd1.Manager", "method": "StartTransientUnit"}
cgroup done
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.systemd1.Manager.StartTransientUnit ["app-ghostty-surface-transient-PID.scope", "fail", [["ManagedOOMMemoryPressure", "kill"], ["PIDs", [PID]]], []]
@@ -0,0 +1,16 @@
== facts ==
notifications so far: 7
alive after ActionInvoked: instances=1 windows=1
alive after more ActionInvoked: instances=1 windows=1
notifications after desktop-notifications=false: 7
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "osc9 body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 0, "", "Title777", "Body777", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "OnlyBody", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 0, "", "Only title", "", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 0, "", "Ghostty", "<b>markup</b> & \"quotes\" \u00e9\u20ac", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 1, "", "Ghostty", "osc9 body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
org.freedesktop.Notifications.Notify ["", 0, "", "semi", "colon;body", ["default", ""], {"desktop-entry": "com.mitchellh.ghostty", "image-path": "com.mitchellh.ghostty", "urgency": 1}, -1]
+7
View File
@@ -0,0 +1,7 @@
== facts ==
spawn: xdg-open argc=1 [$RUN/cfg/ghostty/config] cwd=$ROOT
portal OpenURI/OpenFile calls: 0
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
+14
View File
@@ -0,0 +1,14 @@
== facts ==
portal frontend: (<uint32 2>,)
tty: startup: 997 reports=1 last bg=rgb:1111/2222/3333
tty: after gsettings color-scheme=prefer-light: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd
tty: after gsettings color-scheme=prefer-dark: 997 reports=2,1 last bg=rgb:1111/2222/3333
tty: after gsettings color-scheme=default: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd
tty: after gsettings color-scheme=prefer-dark: 997 reports=2,1 last bg=rgb:1111/2222/3333
global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed
global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed
global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed
global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed
global-shortcuts request error: GDBus.Error:org.freedesktop.portal.Error.NotAllowed
alive: instances=1
== mock received ==
+12
View File
@@ -0,0 +1,12 @@
== facts ==
tty: startup: 997 reports=1 last bg=rgb:1111/2222/3333
tty: after portal color-scheme=2: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd
tty: after portal color-scheme=1: 997 reports=2,1 last bg=rgb:1111/2222/3333
tty: after portal color-scheme=0: 997 reports=1,2 last bg=rgb:ffff/eeee/dddd
tty: after portal color-scheme=2: 997 reports=- last bg=rgb:ffff/eeee/dddd
tty: after reload window-theme=dark: 997 reports=2 last bg=rgb:ffff/eeee/dddd
tty: after portal color-scheme=2 with window-theme=dark: 997 reports=- last bg=rgb:ffff/eeee/dddd
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
+16
View File
@@ -0,0 +1,16 @@
== facts ==
step start: windows=1
new_window shortcut id: CTRL+ALT+x
step Activated(new_window): windows=2
step Activated(unknown): windows=2
step reload without global: windows=2
step reload with global again: windows=2
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.GlobalShortcuts.CreateSession [{"handle_token": "ghostty_TOKEN", "session_handle_token": "ghostty_TOKEN"}]
org.freedesktop.portal.GlobalShortcuts.BindShortcuts ["/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN", [["CTRL+ALT+x", {"description": "new_window", "preferred_trigger": "CTRL+ALT+x"}], ["SHIFT+LOGO+b", {"description": "new_tab", "preferred_trigger": "SHIFT+LOGO+b"}], ["SHIFT+CTRL+F12", {"description": "reload_config", "preferred_trigger": "SHIFT+CTRL+F12"}], ["CTRL+ALT+q", {"description": "text:hi", "preferred_trigger": "CTRL+ALT+q"}]], "", {"handle_token": "ghostty_TOKEN"}]
org.freedesktop.portal.Session.Close {"path": "/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN"}
org.freedesktop.portal.GlobalShortcuts.CreateSession [{"handle_token": "ghostty_TOKEN", "session_handle_token": "ghostty_TOKEN"}]
org.freedesktop.portal.GlobalShortcuts.BindShortcuts ["/org/freedesktop/portal/desktop/session/SENDER/ghostty_TOKEN", [["CTRL+ALT+x", {"description": "new_window", "preferred_trigger": "CTRL+ALT+x"}]], "", {"handle_token": "ghostty_TOKEN"}]
@@ -0,0 +1,24 @@
== facts ==
plain launch owns name: 0
step plain: windows=1 instances=1
second plain launch rc=0
step second plain: windows=2 instances=1
step TERM_PROGRAM: windows=2 instances=2
step TERM_PROGRAM ended: windows=2 instances=1
step CLI arg: windows=2 instances=2
step CLI arg ended: windows=2 instances=1
ipc newwin rc=0
step +new-window: windows=3 instances=1
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
@@ -0,0 +1,49 @@
== facts ==
first instance owns name: 0
step first: windows=1 instances=1
ipc newwin rc=0
step +new-window: windows=2 instances=1
ipc newwin-e rc=0
step +new-window -e: windows=3 instances=1
ipc newwin-wd rc=0
step +new-window --working-directory: windows=4 instances=1
ipc newwin-e-wd rc=0
step +new-window -e..--working-directory: windows=5 instances=1
ipc newwin-class rc=1
ipc-err: D-Bus method call returned an error err=GDBus.Error:org.freedesktop.DBus.Error.ServiceUnknown: The name com.example.nobody was not provided by any .service files
step +new-window --class=nobody: windows=5 instances=1
second launch rc=0
step second launch: windows=6 instances=1
child: /usr/bin/bash cwd=$HOME
child: /usr/bin/bash cwd=$HOME
child: /usr/bin/bash cwd=$ROOT
child: /usr/bin/bash cwd=/usr
child: sleep 601 cwd=$ROOT
step -e launch: windows=6 instances=2
step -e launch ended: windows=6 instances=1
custom class owns name: 0
step custom: windows=1 instances=2
ipc newwin-custom rc=0
step +new-window --class=custom: custom windows=2 default windows=6
ipc newwin-custom-e rc=0
step +new-window --class=custom -e: custom windows=3 default windows=6
ipc newwin-badclass rc=1
ipc-err: D-Bus bus name is not valid: 1bad
badclass done
bus name: com.example.custom-app
bus name: com.mitchellh.ghostty
actions: ([open-config, present-surface, quit, new-window-command, new-window, reload-config],)
custom actions: ([open-config, present-surface, quit, new-window-command, new-window, reload-config],)
== mock received ==
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "color-scheme"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "contrast"]
org.freedesktop.portal.Settings.Read ["org.freedesktop.appearance", "accent-color"]
+251
View File
@@ -0,0 +1,251 @@
#!/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()
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Turn a scenario output dir into a comparable text: facts + filtered bus
traffic + mock-received calls, with volatile values (unique names, pids,
cookies, tokens, paths) normalized."""
import json
import os
import pwd
import re
import sys
d = sys.argv[1]
out = []
def rd(name):
p = os.path.join(d, name)
return open(p).read() if os.path.exists(p) else ""
def norm(s):
s = re.sub(r"app-ghostty-surface-transient-\d+", "app-ghostty-surface-transient-PID", s)
s = re.sub(r'"PIDs", (\[\d+\]|\{"data": \[\d+\])', lambda m: re.sub(r"\d+", "PID", m.group(0)), s)
s = re.sub(r"ghostty_[0-9a-f]{7}", "ghostty_TOKEN", s)
s = re.sub(r"gtk[0-9]{6,}", "gtkTOKEN", s)
s = re.sub(r"/request/[0-9_]+/", "/request/SENDER/", s)
s = re.sub(r"/session/[0-9_]+/", "/session/SENDER/", s)
s = s.replace(os.environ.get("LDB_RUN", "\0"), "$RUN")
s = s.replace(os.environ.get("LDB_ROOT", "\0"), "$ROOT")
s = s.replace(os.path.expanduser("~" + os.environ.get("USER", "")) if False else pwd.getpwuid(os.getuid()).pw_dir, "$HOME")
return s
# Scope creation races with the end of a scenario; only the cgroup scenario checks it.
CGROUP = os.path.basename(os.path.dirname(d.rstrip("/"))) == "cgroup"
out.append("== facts ==")
for l in rd("facts.txt").splitlines():
out.append(norm(l))
out.append("== mock received ==")
for l in rd("mock.jsonl").splitlines():
j = json.loads(l)
if j["method"] == "StartTransientUnit" and not CGROUP:
continue
out.append(norm(f"{j['iface']}.{j['method']} {json.dumps(j['args'], sort_keys=True)}"))
out.append("== bus traffic (filtered) ==")
names = {}
def label(n):
if n is None:
return "-"
if n.startswith(":"):
names.setdefault(n, f"conn{len(names)+1}")
return names[n]
return n
KEEP = re.compile(r"^(org\.gtk\.|org\.freedesktop\.(Notifications|systemd1|portal))")
for l in rd("bus.json").splitlines():
try:
m = json.loads(l)
except ValueError:
continue
iface = m.get("interface") or ""
if not KEEP.match(iface):
continue
if m.get("member") == "StartTransientUnit" and not CGROUP:
continue
payload = m.get("payload") or {}
data = json.dumps(payload.get("data"), sort_keys=True)
out.append(norm(f"{m['type']} {iface}.{m.get('member')} path={m.get('path')} -> {data}"))
print("\n".join(out))
+4
View File
@@ -0,0 +1,4 @@
wait 5000
windows-dump
wait 4000
windows-dump
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# One scenario against one binary, executed inside `dbus-run-session`
# (see ../live-dbus-test.sh). usage: scenario.sh <scenario> <bin> <outdir>
# Writes <outdir>/{bus.json,mock.jsonl,facts.txt,...}; normalize.py turns them
# into the comparable result.txt.
set -u
SCEN=$1; BIN=$2; D=$3
W=$LDB_RUN
LD=$LDB_ROOT/scripts/live-dbus
mkdir -p "$W/home" "$W/cfg/ghostty" "$W/data" "$W/cache"
export HOME=$W/home XDG_CONFIG_HOME=$W/cfg XDG_DATA_HOME=$W/data XDG_CACHE_HOME=$W/cache
export GSETTINGS_BACKEND=memory GDK_BACKEND=wayland LANG=C.UTF-8 SHELL=/bin/sh
export MOCK_LOG=$D/mock.jsonl
unset TERM_PROGRAM TERM_PROGRAM_VERSION GHOSTTY_BIN_DIR GHOSTTY_SHELL_FEATURES
export GHOSTTY_RESOURCES_DIR=${GHOSTTY_RESOURCES_DIR:-}
[ -z "$GHOSTTY_RESOURCES_DIR" ] && unset GHOSTTY_RESOURCES_DIR
FACTS=$D/facts.txt; : >"$FACTS"
fact() { echo "$*" >>"$FACTS"; }
PIDS=()
bg() { "$@" & PIDS+=($!); }
finish() {
for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done
for p in /proc/[0-9]*; do
grep -qa "LDB_TAG=$LDB_TAG" "$p/environ" 2>/dev/null || continue
case "$(cat "$p/comm" 2>/dev/null)" in dbus-*|bash|timeout) continue ;; esac
kill "${p#/proc/}" 2>/dev/null
done
sleep 0.5
}
trap finish EXIT
cfg() { printf '%s\n' "shell-integration = none" "confirm-close-surface = false" \
"quit-after-last-window-closed = false" "window-theme = auto" "$@" >"$W/cfg/ghostty/config"; }
start_bus_tools() { # [mock names]
busctl --user monitor --json=short >"$D/bus.json" 2>"$D/monitor.err" &
PIDS+=($!)
MOCK_NAMES=${1:-notifications,systemd,portal} python3 -W ignore "$LD/mock.py" >"$D/mock.out" 2>&1 &
PIDS+=($!)
for _ in $(seq 50); do grep -q READY "$D/mock.out" && break; sleep 0.1; done
sleep 0.5
}
# Wait until the app owns its bus name.
wait_name() { for _ in $(seq 300); do busctl --user list --acquired --no-pager 2>/dev/null | grep -q "^$1 " && return 0; sleep 0.1; done; return 1; }
mockctl() { gdbus call --session --dest test.ghostty.Mock --object-path /test/ghostty/Mock --method test.ghostty.Mock."$@" >/dev/null; }
# GUI window objects exported on the bus by GtkApplication.
windows() { # [bus name]
# (never activate the service: only query a name that is already owned)
busctl --user list --acquired --no-pager 2>/dev/null | grep -q "^${1:-com.mitchellh.ghostty} " || { echo 0; return; }
busctl --user tree "${1:-com.mitchellh.ghostty}" --list --no-pager 2>/dev/null | grep -c '/window/[0-9]*$'
}
# Number of GUI instances of the binary under test (not +actions).
instances() {
local n=0 p
for p in /proc/[0-9]*; do
[ "$(readlink "$p/exe" 2>/dev/null)" = "$(readlink -f "$BIN")" ] || continue
tr '\0' ' ' <"$p/cmdline" | grep -q ' +' && continue
n=$((n+1))
done
echo $n
}
# Command lines of terminal children (sh/sleep/...) of GUI instances, sorted.
children() {
local p c
for p in /proc/[0-9]*; do
[ "$(readlink "$p/exe" 2>/dev/null)" = "$(readlink -f "$BIN")" ] || continue
tr '\0' ' ' <"$p/cmdline" | grep -q ' +' && continue
for c in $(pgrep -P "${p#/proc/}"); do
echo "$(tr '\0' ' ' <"/proc/$c/cmdline") cwd=$(readlink /proc/$c/cwd)"
done
done | sort
}
# pids of processes with this comm that belong to this run
tagged() { local p; for p in /proc/[0-9]*; do
[ "$(cat "$p/comm" 2>/dev/null)" = "$1" ] && grep -qa "LDB_TAG=$LDB_TAG" "$p/environ" 2>/dev/null && echo "${p#/proc/}"
done; }
settle() { sleep "${1:-2}"; }
run_ipc() { # label args...
local label=$1; shift
timeout 15 "$BIN" "$@" >"$D/ipc-$label.log" 2>&1
fact "ipc $label rc=$?"
grep -E "D-Bus|not valid|error" "$D/ipc-$label.log" | grep -v "^warning\|^info" | sed 's/^/ ipc-err: /' >>"$FACTS"
}
. "$LD/scenarios.sh"
"scn_${SCEN//-/_}"
+275
View File
@@ -0,0 +1,275 @@
# Scenario bodies, sourced by scenario.sh.
scn_single_instance() {
cfg "gtk-single-instance = true"
start_bus_tools
# (A launch with -e is never single-instance: see the "-e launch" step.)
bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$LD/replay-windows.txt" "$BIN" >"$D/app1.log" 2>&1
wait_name com.mitchellh.ghostty; fact "first instance owns name: $?"
settle 4; fact "step first: windows=$(windows) instances=$(instances)"
run_ipc newwin +new-window; settle
fact "step +new-window: windows=$(windows) instances=$(instances)"
run_ipc newwin-e +new-window -e sleep 601; settle
fact "step +new-window -e: windows=$(windows) instances=$(instances)"
run_ipc newwin-wd +new-window --working-directory=/usr; settle
fact "step +new-window --working-directory: windows=$(windows) instances=$(instances)"
run_ipc newwin-e-wd +new-window -e sleep 602 --working-directory=/usr; settle
fact "step +new-window -e..--working-directory: windows=$(windows) instances=$(instances)"
run_ipc newwin-class +new-window --class=com.example.nobody; settle 1
fact "step +new-window --class=nobody: windows=$(windows) instances=$(instances)"
# A second GUI launch is forwarded to the first instance and exits.
timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second launch rc=$?"
settle; fact "step second launch: windows=$(windows) instances=$(instances)"
children | sed 's/^/child: /' >>"$FACTS"
# A launch with -e is not single-instance: it runs on its own.
bg timeout 8 "$BIN" -e sleep 603 >"$D/dash-e.log" 2>&1
settle 5; fact "step -e launch: windows=$(windows) instances=$(instances)"
settle 5; fact "step -e launch ended: windows=$(windows) instances=$(instances)"
# Custom class: a separate instance on its own bus name / object path.
bg "$BIN" --class=com.example.custom-app >"$D/app2.log" 2>&1
wait_name com.example.custom-app; fact "custom class owns name: $?"
settle 4; fact "step custom: windows=$(windows com.example.custom-app) instances=$(instances)"
run_ipc newwin-custom +new-window --class=com.example.custom-app; settle
fact "step +new-window --class=custom: custom windows=$(windows com.example.custom-app) default windows=$(windows)"
run_ipc newwin-custom-e +new-window --class=com.example.custom-app -e sleep 605; settle
fact "step +new-window --class=custom -e: custom windows=$(windows com.example.custom-app) default windows=$(windows)"
run_ipc newwin-badclass +new-window --class=1bad; fact "badclass done"
busctl --user list --acquired --no-pager | awk '$1 ~ /^(com\.mitchellh|com\.example)/ {print "bus name: "$1}' >>"$FACTS"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions.txt"; fact "actions: $(cat "$D/actions.txt")"
gdbus call --session --dest com.example.custom-app --object-path /com/example/custom_app \
--method org.gtk.Actions.List 2>&1 | tr -d "'" >"$D/actions2.txt"; fact "custom actions: $(cat "$D/actions2.txt")"
}
scn_single_instance_detect() {
cfg "gtk-single-instance = detect"
start_bus_tools
bg "$BIN" >"$D/app1.log" 2>&1
wait_name com.mitchellh.ghostty; fact "plain launch owns name: $?"
settle 4; fact "step plain: windows=$(windows) instances=$(instances)"
timeout 20 "$BIN" >"$D/second.log" 2>&1; fact "second plain launch rc=$?"
settle; fact "step second plain: windows=$(windows) instances=$(instances)"
# TERM_PROGRAM set: a dedicated instance (false) that does not use the bus name.
bg env TERM_PROGRAM=x timeout 12 "$BIN" >"$D/termprog.log" 2>&1
settle 6; fact "step TERM_PROGRAM: windows=$(windows) instances=$(instances)"
settle 7; fact "step TERM_PROGRAM ended: windows=$(windows) instances=$(instances)"
# Any CLI argument: dedicated instance.
bg timeout 12 "$BIN" --title=cli >"$D/cliarg.log" 2>&1
settle 6; fact "step CLI arg: windows=$(windows) instances=$(instances)"
settle 7; fact "step CLI arg ended: windows=$(windows) instances=$(instances)"
run_ipc newwin +new-window; settle
fact "step +new-window: windows=$(windows) instances=$(instances)"
}
scn_notifications() {
export FLAG2=$W/go2
rm -f "$FLAG2"
cfg "desktop-notifications = true" "notify-on-command-finished = always" "notify-on-command-finished-after = 1s" \
"command = sh $LD/child-notify.sh"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
settle 16
fact "notifications so far: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)"
# Activate the first notifications (default action): must not crash.
mockctl Invoke 1 default; settle 2
fact "alive after ActionInvoked: instances=$(instances) windows=$(windows)"
mockctl Invoke 2 default; mockctl Invoke 999 default; settle 1
fact "alive after more ActionInvoked: instances=$(instances) windows=$(windows)"
cfg "desktop-notifications = false" "command = sh $LD/child-notify.sh"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2
touch "$FLAG2"; settle 3
fact "notifications after desktop-notifications=false: $(grep -c "\"method\": \"Notify\"" "$MOCK_LOG" 2>/dev/null || true)"
}
scn_shortcuts() {
cfg "keybind = global:ctrl+alt+x=new_window" "keybind = global:super+shift+b=new_tab" \
"keybind = global:ctrl+shift+f12=reload_config" "keybind = global:alt+space>q=quit" \
"keybind = global:ctrl+alt+q=text:hi" "keybind = ctrl+alt+z=new_window"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
wait_name com.mitchellh.ghostty; settle 5
fact "step start: windows=$(windows)"
id=$(python3 - "$MOCK_LOG" <<'PY'
import json, sys
for l in open(sys.argv[1]):
j = json.loads(l)
if j["method"] == "BindShortcuts":
for sid, opts in j["args"][1]:
if opts.get("description", "").startswith("new_window"):
print(sid)
PY
)
fact "new_window shortcut id: $id"
mockctl Activate "$id"; settle 3
fact "step Activated(new_window): windows=$(windows)"
mockctl Activate nonexistent; settle 1
fact "step Activated(unknown): windows=$(windows)"
# Reload with the keybinds removed: the old session is closed.
cfg "keybind = ctrl+alt+z=new_window"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3
fact "step reload without global: windows=$(windows)"
cfg "keybind = global:ctrl+alt+x=new_window"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 3
fact "step reload with global again: windows=$(windows)"
}
scn_settings() {
mkdir -p "$W/themes"
printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light"
printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark"
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh"
MOCK_COLOR_SCHEME=1 start_bus_tools
export OUTF=$D/tty.txt
: >"$OUTF"
bg "$BIN" >"$D/app.log" 2>&1
for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done
[ -s "$OUTF" ] || fact "ERROR: terminal child produced no output"
settle 3
for scheme in 2 1 0 2; do
printf "\n--- after portal color-scheme=$scheme\n" >>"$OUTF"
mockctl SetColorScheme $scheme; settle 2
done
printf "\n--- after reload window-theme=dark\n" >>"$OUTF"
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = dark" "command = sh $LD/child-theme.sh"
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate reload-config '[]' '{}' >/dev/null; settle 2
printf "\n--- after portal color-scheme=2 with window-theme=dark\n" >>"$OUTF"
mockctl SetColorScheme 2; settle 2
python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS"
}
scn_cgroup() {
start_bus_tools
phase() { # name, config lines...
local name=$1; shift
: >"$MOCK_LOG"
cfg "$@"
bg "$BIN" -e sleep 30 >"$D/app-$name.log" 2>&1
local pid=$!
# wait for the terminal child (the scope is created right before it is exec'd)
for _ in $(seq 250); do pgrep -P $pid -x sleep >/dev/null && break; sleep 0.1; done
settle 3
fact "phase $name:"
grep StartTransientUnit "$MOCK_LOG" | sed 's/^/ call: /' >>"$FACTS"
kill $pid 2>/dev/null
for _ in $(seq 100); do kill -0 $pid 2>/dev/null || break; sleep 0.1; done
kill -9 $pid 2>/dev/null; kill $(tagged sleep) 2>/dev/null; settle 1
}
phase default "gtk-single-instance = false"
phase always "gtk-single-instance = false" "linux-cgroup = always"
phase always-limits "gtk-single-instance = false" "linux-cgroup = always" "linux-cgroup-memory-limit = 123456789" "linux-cgroup-processes-limit = 77"
phase never "gtk-single-instance = false" "linux-cgroup = never"
mockctl SetFail true
phase always-failing "gtk-single-instance = false" "linux-cgroup = always"
fact "cgroup done"
}
scn_openuri() {
# Ghostty 1.3.1 does not use the OpenURI portal (upstream TODO): links and
# the config file are opened by spawning `xdg-open`. A fake xdg-open on
# PATH records the invocations; the mock records any portal OpenURI call.
mkdir -p "$W/bin"
cat >"$W/bin/xdg-open" <<XDG
#!/bin/sh
printf 'xdg-open argc=%s' "\$#" >>"$D/xdg-open.log"
for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done
printf ' cwd=%s\n' "\$(pwd)" >>"$D/xdg-open.log"
XDG
chmod +x "$W/bin/xdg-open"
export PATH=$W/bin:$PATH GTK_USE_PORTAL=1
: >"$D/xdg-open.log"
cfg "gtk-single-instance = true"
start_bus_tools
bg "$BIN" >"$D/app.log" 2>&1
wait_name com.mitchellh.ghostty; settle 5
gdbus call --session --dest com.mitchellh.ghostty --object-path /com/mitchellh/ghostty \
--method org.gtk.Actions.Activate open-config '[]' '{}' >/dev/null; settle 3
sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS"
fact "portal OpenURI/OpenFile calls: $(grep -c 'portal.OpenURI' "$MOCK_LOG")"
}
scn_openurl_click() {
# The original has no input replay and weston's headless pointer is not
# reachable through Xwayland, so this scenario only runs for the Rust
# build; its result is compared against golden/openurl-click.txt, which is
# derived from the Zig source (internal_os/open.zig spawns `xdg-open <url>`).
[ "${LDB_LABEL:-}" = rust ] || return
mkdir -p "$W/bin"
cat >"$W/bin/xdg-open" <<XDG
#!/bin/sh
printf 'xdg-open argc=%s' "\$#" >>"$D/xdg-open.log"
for a in "\$@"; do printf ' [%s]' "\$a" >>"$D/xdg-open.log"; done
printf '\n' >>"$D/xdg-open.log"
XDG
chmod +x "$W/bin/xdg-open"
export PATH=$W/bin:$PATH GDK_BACKEND=x11 OUTF=$D/tty.txt
: >"$D/xdg-open.log"; : >"$OUTF"
cfg "gtk-single-instance = false" "gtk-titlebar = false" "window-padding-x = 0" "window-padding-y = 0" \
"font-size = 14" "window-decoration = none" "command = sh $LD/child-links.sh" "window-width = 60" "window-height = 12"
start_bus_tools
# Launch 1 measures the cell size.
bg "$BIN" >"$D/app-measure.log" 2>&1
local pid=$!
settle 7
rows=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f2)
cols=$(grep -o '\^\[\[8;[0-9]*;[0-9]*t' "$OUTF" | head -1 | cut -d';' -f3 | tr -d t)
wid=$(xdotool search --onlyvisible --class ghostty 2>/dev/null | head -1)
eval "$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null)"
kill $pid; settle 1
[ -n "${WIDTH:-}" ] && [ -n "${rows:-}" ] || { fact "no geometry"; return; }
cw=$((WIDTH / cols)); ch=$((HEIGHT / rows))
{
for row in 0 1 2 3; do
echo "mouse-move $((cw * 3)) $((row * ch + ch / 2)) ctrl"
echo "wait 600"
echo "click 1 ctrl"
echo "wait 1500"
done
echo "wait 500"
} >"$W/click.txt"
: >"$D/xdg-open.log"; : >"$OUTF"
bg env GHOSTTY_DEBUG_INPUT_SCRIPT="$W/click.txt" "$BIN" >"$D/app.log" 2>&1
settle 16
sed "s|$W|\$RUN|g" "$D/xdg-open.log" | sed 's/^/spawn: /' >>"$FACTS"
}
# The real xdg-desktop-portal frontend + the gtk backend (gsettings keyfile
# backend, so we can change color-scheme live) on the private bus.
scn_real_portal() {
mkdir -p "$W/cfg/xdg-desktop-portal" "$W/cfg/glib-2.0/settings" "$W/themes"
printf '[preferred]\ndefault=gtk\n' >"$W/cfg/xdg-desktop-portal/portals.conf"
portal_env="/usr/bin/env XDG_CURRENT_DESKTOP=GNOME GSETTINGS_BACKEND=keyfile XDG_CONFIG_HOME=$W/cfg HOME=$W/home XDG_DATA_HOME=$W/data XDG_CACHE_HOME=$W/cache WAYLAND_DISPLAY=$WAYLAND_DISPLAY"
svc() { printf '[D-BUS Service]\nName=%s\nExec=%s\n' "$1" "$2" >"$LDB_RUN/services/$1.service"; }
svc org.freedesktop.portal.Desktop "$portal_env /usr/lib/xdg-desktop-portal"
svc org.freedesktop.impl.portal.desktop.gtk "$portal_env /usr/lib/xdg-desktop-portal-gtk"
svc org.freedesktop.impl.portal.PermissionStore "$portal_env /usr/lib/xdg-permission-store"
printf 'background = ffeedd\nforeground = 111111\n' >"$W/themes/light"
printf 'background = 112233\nforeground = eeeeee\n' >"$W/themes/dark"
keyfile=$W/cfg/glib-2.0/settings/keyfile
setscheme() { printf '[org/gnome/desktop/interface]\ncolor-scheme='"'"'%s'"'"'\n' "$1" >"$keyfile"; }
setscheme prefer-dark
cfg "theme = light:$W/themes/light,dark:$W/themes/dark" "window-theme = auto" "command = sh $LD/child-theme.sh" \
"keybind = global:ctrl+alt+x=new_window"
start_bus_tools notifications,systemd
# Start the portal first so that its backends are up.
gdbus call --session --dest org.freedesktop.portal.Desktop --object-path /org/freedesktop/portal/desktop \
--method org.freedesktop.DBus.Properties.Get org.freedesktop.portal.Settings version >"$D/portal-version.txt" 2>&1
fact "portal frontend: $(tr -d '\n' <"$D/portal-version.txt" | cut -c1-80)"
settle 2
export OUTF=$D/tty.txt; : >"$OUTF"
bg env GSETTINGS_BACKEND=memory "$BIN" >"$D/app.log" 2>&1
for _ in $(seq 600); do [ -s "$OUTF" ] && break; sleep 0.1; done
[ -s "$OUTF" ] || fact "ERROR: terminal child produced no output"
settle 3
for scheme in prefer-light prefer-dark default prefer-dark; do
printf "\n--- after gsettings color-scheme=$scheme\n" >>"$OUTF"
setscheme $scheme; settle 3
done
python3 "$LD/summarize-tty.py" "$OUTF" | sed 's/^/tty: /' >>"$FACTS"
# Only the D-Bus error names (the log prefixes differ between the builds).
sed -n 's/.*request failed=\(GDBus.Error:[A-Za-z.]*\).*/\1/p' "$D/app.log" | sed 's/^/global-shortcuts request error: /' >>"$FACTS"
fact "alive: instances=$(instances)"
}
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env python3
"""Summarize the raw `cat -v` capture of child-theme.sh: per phase (split by
the `--- after` markers) the collapsed sequence of CSI ? 997 reports and the
last OSC 11 background answer."""
import re
import sys
text = open(sys.argv[1]).read()
parts = re.split(r"\n--- (after [^\n]*)\n", "\n" + text)
# parts: [startup, name1, body1, name2, body2, ...]; markers are written
# right before the action, so the body after a marker is its effect.
names = ["startup"] + parts[1::2]
chunks = [parts[0]] + parts[2::2]
for name, chunk in zip(names, chunks):
reports = re.findall(r"\^\[\[\?997;(\d)n", chunk)
collapsed = [r for i, r in enumerate(reports) if i == 0 or r != reports[i - 1]]
bgs = re.findall(r"\^\[\]11;(rgb:[0-9a-f/]+)\^G", chunk)
print(f"{name}: 997 reports={','.join(collapsed) or '-'} last bg={bgs[-1] if bgs else '-'}")