Fix tray DBus menu activation

This commit is contained in:
David Heinemeier Hansson
2026-05-25 16:57:53 +02:00
parent 74fb9b0e80
commit 3c8aba55e5
4 changed files with 511 additions and 5 deletions
@@ -0,0 +1,187 @@
#!/usr/bin/env python
import os
import sys
import time
import traceback
import dbus
import dbus.service
from dbus.mainloop.glib import DBusGMainLoop
from gi.repository import GLib
ITEM_PATH = "/StatusNotifierItem"
MENU_PATH = "/StatusNotifierItem/Menu"
def variant(value):
return dbus.Variant(value)
class StatusNotifierItem(dbus.service.Object):
def __init__(self, bus):
super().__init__(bus, ITEM_PATH)
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v")
def Get(self, interface, prop):
return variant(self.GetAll(interface)[prop])
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != "org.kde.StatusNotifierItem":
return {}
return dbus.Dictionary({
"Category": "ApplicationStatus",
"Id": "omarchy-test-tray",
"Title": "omarchy-test-tray",
"Status": "Active",
"WindowId": dbus.Int32(0),
"IconName": "dialog-information",
"IconThemePath": "",
"Menu": dbus.ObjectPath(MENU_PATH),
"ItemIsMenu": dbus.Boolean(False),
"ToolTip": dbus.Struct((
"",
dbus.Array([], signature="(iiay)"),
"omarchy-test-tray",
"",
), signature=None),
}, signature="sv")
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ssv")
def Set(self, interface, prop, value):
return
@dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii")
def ContextMenu(self, x, y):
return
@dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii")
def Activate(self, x, y):
return
@dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii")
def SecondaryActivate(self, x, y):
return
@dbus.service.method("org.kde.StatusNotifierItem", in_signature="is")
def Scroll(self, delta, orientation):
return
class DBusMenu(dbus.service.Object):
def __init__(self, bus, event_path):
super().__init__(bus, MENU_PATH)
self.event_path = event_path
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v")
def Get(self, interface, prop):
return variant(self.GetAll(interface)[prop])
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}")
def GetAll(self, interface):
if interface != "com.canonical.dbusmenu":
return {}
return dbus.Dictionary({
"Version": dbus.UInt32(3),
"TextDirection": "ltr",
"Status": "normal",
"IconThemePath": dbus.Array([], signature="s"),
}, signature="sv")
@dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ssv")
def Set(self, interface, prop, value):
return
@dbus.service.method("com.canonical.dbusmenu", in_signature="i", out_signature="b")
def AboutToShow(self, item_id):
return False
@dbus.service.method("com.canonical.dbusmenu", in_signature="ai", out_signature="aiai")
def AboutToShowGroup(self, item_ids):
return ([], [])
@dbus.service.method("com.canonical.dbusmenu", in_signature="iias", out_signature="u(ia{sv}av)")
def GetLayout(self, parent_id, recursion_depth, property_names):
print("GetLayout", int(parent_id), int(recursion_depth), list(property_names), flush=True)
child = (
dbus.Int32(2),
dbus.Dictionary({"label": "Sign in", "enabled": True}, signature="sv"),
dbus.Array([], signature="v"),
)
root = (
dbus.Int32(0),
dbus.Dictionary({"children-display": "submenu"}, signature="sv"),
dbus.Array([dbus.Struct(child, signature=None, variant_level=1)], signature="v"),
)
return (dbus.UInt32(1), dbus.Struct(root, signature=None))
@dbus.service.method("com.canonical.dbusmenu", in_signature="aias", out_signature="a(ia{sv})")
def GetGroupProperties(self, item_ids, property_names):
return []
@dbus.service.method("com.canonical.dbusmenu", in_signature="is", out_signature="v")
def GetProperty(self, item_id, name):
return variant("")
@dbus.service.method("com.canonical.dbusmenu", in_signature="isvu")
def Event(self, item_id, event_id, data, timestamp):
print("Event", int(item_id), str(event_id), flush=True)
if int(item_id) == 2 and str(event_id) == "clicked":
with open(self.event_path, "w", encoding="utf-8") as handle:
handle.write("clicked\n")
@dbus.service.method("com.canonical.dbusmenu", in_signature="a(isvu)", out_signature="ai")
def EventGroup(self, events):
for item_id, event_id, data, timestamp in events:
self.Event(item_id, event_id, data, timestamp)
return []
def register_with_watcher(bus):
watcher = dbus.Interface(
bus.get_object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher"),
"org.kde.StatusNotifierWatcher",
)
watcher.RegisterStatusNotifierItem(ITEM_PATH)
def main():
try:
return run()
except Exception:
traceback.print_exc()
return 1
def run():
event_path = os.environ["OMARCHY_TRAY_MENU_EVENT_RESULT"]
ready_path = os.environ["OMARCHY_TRAY_MENU_READY"]
DBusGMainLoop(set_as_default=True)
bus = dbus.SessionBus()
dbus.service.BusName("org.omarchy.TestStatusNotifier", bus)
StatusNotifierItem(bus)
DBusMenu(bus, event_path)
for _ in range(50):
try:
register_with_watcher(bus)
with open(ready_path, "w", encoding="utf-8") as handle:
handle.write("ready\n")
break
except dbus.DBusException:
time.sleep(0.1)
else:
print("timed out registering StatusNotifierItem", file=sys.stderr)
return 1
GLib.MainLoop().run()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,75 @@
import QtQuick
import Quickshell
import Quickshell.Services.SystemTray
ShellRoot {
id: root
property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT")
property var trayItem: null
property int attempts: 0
property string lastState: ""
function shellQuote(value) {
return "'" + String(value).replace(/'/g, "'\\''") + "'"
}
function writeResult(ok, message) {
var payload = JSON.stringify({ ok: ok, message: String(message || "") })
if (resultPath) {
Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)])
}
}
function findTrayItem() {
var values = SystemTray.items.values
var ids = []
for (var i = 0; i < values.length; i++) {
ids.push(String(values[i].id || ""))
if (String(values[i].id || "") === "omarchy-test-tray") return values[i]
}
lastState = "items=" + ids.join(",")
return null
}
function triggerSignIn() {
var rows = menuOpener.children.values
var labels = []
for (var i = 0; i < rows.length; i++) {
var row = rows[i]
labels.push(String(row.text || ""))
if (!row.isSeparator && String(row.text || "") === "Sign in") {
row.triggered()
writeResult(true, "triggered Sign in")
return true
}
}
lastState = "item=" + String(root.trayItem ? root.trayItem.id : "") + " rows=" + labels.join(",")
return false
}
QsMenuOpener {
id: menuOpener
menu: root.trayItem ? root.trayItem.menu : null
}
Timer {
interval: 100
running: true
repeat: true
onTriggered: {
root.attempts++
if (!root.trayItem) root.trayItem = root.findTrayItem()
if (root.trayItem && root.triggerSignIn()) {
stop()
return
}
if (root.attempts >= 50) {
root.writeResult(false, "timed out waiting for tray menu Sign in row; " + root.lastState)
stop()
}
}
}
}
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
MOCK_PID=""
cleanup() {
if [[ -n $MOCK_PID ]] && kill -0 "$MOCK_PID" 2>/dev/null; then
kill "$MOCK_PID" 2>/dev/null || true
wait "$MOCK_PID" 2>/dev/null || true
fi
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
[[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping tray menu activation test"
exit 0
fi
require_command jq
require_command python
python - <<'PY' || {
import dbus
import gi
PY
pass "python DBus bindings unavailable; skipping tray menu activation test"
exit 0
}
TMPDIR=$(mktemp -d)
config_dir="$TMPDIR/tray-menu-activation"
result="$TMPDIR/result.json"
event_result="$TMPDIR/event"
ready="$TMPDIR/ready"
qs_log="$TMPDIR/quickshell.log"
mock_log="$TMPDIR/mock-sni.log"
mkdir -p "$config_dir" "$TMPDIR/home"
cp "$SHELL_TEST_DIR/fixtures/tray-menu-activation/shell.qml" "$config_dir/shell.qml"
OMARCHY_PATH="$ROOT" \
OMARCHY_QML_TEST_RESULT="$result" \
HOME="$TMPDIR/home" \
quickshell -p "$config_dir" --no-color >"$qs_log" 2>&1 &
QS_PID=$!
OMARCHY_TRAY_MENU_EVENT_RESULT="$event_result" \
OMARCHY_TRAY_MENU_READY="$ready" \
python "$SHELL_TEST_DIR/fixtures/tray-menu-activation/mock-sni.py" >"$mock_log" 2>&1 &
MOCK_PID=$!
for _ in {1..80}; do
[[ -s $event_result ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
sed -n '1,160p' "$qs_log" >&2
fail "tray menu activation quickshell exited before triggering menu item"
fi
if ! kill -0 "$MOCK_PID" 2>/dev/null; then
sed -n '1,160p' "$mock_log" >&2
fail "mock StatusNotifierItem exited before receiving menu event"
fi
sleep 0.1
done
if [[ ! -s $event_result ]]; then
[[ -s $result ]] && jq . "$result" >&2
printf 'Quickshell log:\n' >&2
sed -n '1,160p' "$qs_log" >&2
printf 'Mock SNI log:\n' >&2
sed -n '1,160p' "$mock_log" >&2
fail "tray menu sends DBusMenu clicked event"
fi
pass "tray menu sends DBusMenu clicked event"