From bb1b4c1ae32c235e9edfb1e5b4dc830394134492 Mon Sep 17 00:00:00 2001 From: TuringLambdaAI Date: Sun, 4 Oct 2026 06:21:23 +0800 Subject: [PATCH] Linux tray contract: TrayIcon via StatusNotifierItem + dbusmenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux adapter deliberately omitted tray hosting because desktop support is compositor-dependent; that left downstream hosts without any system-tray surface (Windows has Shell_NotifyIconW), and close-to-tray apps like SyncPilot lose their only persistent entry point. Add an explicit opt-in rivet::system::TrayIcon: org.kde.StatusNotifierItem plus a com.canonical.dbusmenu menu over the session bus (GDBus, already a dependency). TrayIcon::available() and the new 'tray' capability report a reachable watcher, so tray presence remains an application decision; on a watcherless session the constructor fails clearly. Icon, tooltip and the flat menu (labelled items, separators, enabled state) update in place through NewIcon/NewToolTip/LayoutUpdated; left-click Activate reaches an optional activation handler; the item re-registers whenever the watcher reappears, so GNOME Shell restarts and extension reloads do not strand a running process without its icon. Dispatch (property reads, method calls, clicks) lands on the thread-default main context the icon was constructed on — for GTK hosts, the main-loop thread. Verified end to end against the live GNOME StatusNotifierWatcher: registration appears in RegisteredStatusNotifierItems, GetLayout returns the full tree, a delivered click Event fires the menu callback, set_icon flips the icon mid-run; an isolated dbus-run-session without a watcher fails clearly. The integration self-check exercises the tray when the capability is present and skips otherwise. --- CHANGELOG.md | 10 + docs/system-services.md | 5 +- platform/linux/Integration/main.cpp | 19 + platform/linux/system/system_services.cpp | 597 ++++++++++++++++++++++ platform/linux/system/system_services.hpp | 63 ++- 5 files changed, 687 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c97a8f6..e1cf315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- Add the Linux tray contract: `rivet::system::TrayIcon` hosts an + org.kde.StatusNotifierItem with a com.canonical.dbusmenu menu over the + session bus (GDBus), the pairing every desktop watcher serves (GNOME via + the AppIndicator extension, KDE natively). The opt-in stays an application + decision — `TrayIcon::available()` and the `tray` capability report a + reachable watcher; the icon, tooltip, and flat menu update in place, and + the watcher is re-registered automatically when GNOME Shell or the + extension reloads. Verified end to end against the real desktop watcher + (registration, property reads, menu layout, click dispatch) plus the + graceful no-watcher path in an isolated D-Bus session. - Add optional human-readable `publisher` project metadata and use it for the Windows MSI `Manufacturer`/Apps & Features Publisher value. Legacy projects fall back to `display-name` instead of exposing a reverse-DNS identifier. diff --git a/docs/system-services.md b/docs/system-services.md index 9aecd6d..bd3bfa2 100644 --- a/docs/system-services.md +++ b/docs/system-services.md @@ -44,13 +44,14 @@ macOS asks the user for notification and login-item consent as required by the O - an abstract-namespace Unix socket for the single-instance lease; secondaries forward their activation arguments to the primary over that socket; - `ActivationArguments()` from `/proc/self/cmdline` for URL-scheme and file-association payloads registered by installers; - `org.freedesktop.Notifications` over the session bus (GDBus), with stable notification ids for tagged replacements; +- `TrayIcon` — org.kde.StatusNotifierItem plus a com.canonical.dbusmenu menu, through the session bus (GDBus). This is an explicit opt-in, not an adapter default: desktop hosting is compositor-dependent (GNOME hosts it only through the AppIndicator extension, KDE natively), so the application checks `TrayIcon::available()` (or the `tray` capability) and decides. The icon, tooltip, and flat menu (labelled items, separators, enabled state) update in place; the watcher is re-registered whenever it reappears, so GNOME Shell restarts and extension reloads do not strand a running process without its icon. Methods and menu callbacks run on the thread-default main context the icon was constructed on — for GTK hosts, the main-loop thread; - XDG autostart entries under `$XDG_CONFIG_HOME/autostart`; - the Secret Service provider (GNOME Keyring, KWallet bridge) through libsecret for binary secrets; the capability is compiled in when libsecret is present and reported at runtime only when a provider is reachable; - `sigaction` fatal-signal hooks that append a preformatted note (signal number plus restart arguments) to a file under the XDG state directory. -`rivet::system::Capabilities()` reports what the running session actually provides; a missing session bus, Secret Service provider, or libsecret build fails clearly at call time instead of silently degrading. The tray contract is deliberately absent: StatusNotifierItem hosting is compositor-dependent (GNOME hosts it only through an extension), so tray presence must be an explicit application decision rather than an adapter default. +`rivet::system::Capabilities()` reports what the running session actually provides; a missing session bus, Secret Service provider, or libsecret build fails clearly at call time instead of silently degrading. -The Linux integration binary self-checks the adapter (`RivetIntegration --system`): lease acquisition and activation forwarding, autostart entries, and the crash hook always run; notification and secure-storage checks skip themselves when the session lacks those services. +The Linux integration binary self-checks the adapter (`RivetIntegration --system`): lease acquisition and activation forwarding, autostart entries, and the crash hook always run; notification, tray, and secure-storage checks skip themselves when the session lacks those services. ## Settings, logs, and crashes diff --git a/platform/linux/Integration/main.cpp b/platform/linux/Integration/main.cpp index 223b370..a5be1fa 100644 --- a/platform/linux/Integration/main.cpp +++ b/platform/linux/Integration/main.cpp @@ -145,6 +145,25 @@ int run_system_self_check(std::vector const& arguments) { progress("skipping notifications (no session bus or notification host)"); } + if (has_capability(capabilities, "tray")) { + progress("checking the StatusNotifierItem tray"); + require(rivet::system::TrayIcon::available(), + "the tray capability must imply an available StatusNotifierItem watcher"); + rivet::system::TrayIcon tray("org.rivet.integration.test", + "Rivet Linux integration", + "network-transmit-receive"); + std::vector items; + items.emplace_back("Open", [] {}); + items.emplace_back(rivet::system::TrayMenuItem::Type::separator); + items.emplace_back("Disabled", [] {}, false); + tray.set_menu(std::move(items)); + tray.set_tooltip("Rivet Linux integration", "tray self-check"); + // Registration went to the watcher; a desktop session answers the + // layout/property fetches from the main loop this thread must run. + } else { + progress("skipping tray (no session bus or StatusNotifierItem watcher)"); + } + if (has_capability(capabilities, "secure-storage")) { progress("checking Secret Service secure storage"); std::string const service = "org.rivet.integration.test"; diff --git a/platform/linux/system/system_services.cpp b/platform/linux/system/system_services.cpp index c266de1..61d2a75 100644 --- a/platform/linux/system/system_services.cpp +++ b/platform/linux/system/system_services.cpp @@ -8,6 +8,8 @@ #include #include +#include +#include #include #include #include @@ -294,6 +296,7 @@ bool secrets_service_available() { std::vector Capabilities() { std::vector capabilities{"single-instance"}; if (Notifications::available()) capabilities.push_back("notification"); + if (TrayIcon::available()) capabilities.push_back("tray"); capabilities.push_back("autostart"); if (secrets_service_available()) capabilities.push_back("secure-storage"); capabilities.push_back("crash-hook"); @@ -695,6 +698,600 @@ void Notifications::CloseTag(std::string const& tag) { Close(id); } +// ------------------------------------------------------------------ tray +// +// The tray surface speaks org.kde.StatusNotifierItem plus a +// com.canonical.dbusmenu menu, the pair every Linux desktop watcher hosts +// (GNOME through the AppIndicator extension, KDE natively). Everything below +// runs on the thread-default main context the TrayIcon was constructed on — +// that is where GDBus dispatches property reads, method calls and the +// watcher-restart signal. + +namespace { + +constexpr char const* kStatusNotifierWatcherName = "org.kde.StatusNotifierWatcher"; +constexpr char const* kStatusNotifierWatcherPath = "/StatusNotifierWatcher"; + +// The interface subsets the desktop watchers actually consume; extra methods +// of the full contracts are omitted rather than stubbed. +constexpr char const* kStatusNotifierItemXml = + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; + +constexpr char const* kDbusMenuXml = + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + ""; + +GDBusNodeInfo const* status_notifier_item_info() { + static GDBusNodeInfo* info = [] { + GDBusNodeInfo* parsed = + g_dbus_node_info_new_for_xml(kStatusNotifierItemXml, nullptr); + if (!parsed || !parsed->interfaces[0]) + throw std::runtime_error( + "the StatusNotifierItem interface description failed to parse"); + return parsed; + }(); + return info; +} + +GDBusNodeInfo const* dbus_menu_info() { + static GDBusNodeInfo* info = [] { + GDBusNodeInfo* parsed = g_dbus_node_info_new_for_xml(kDbusMenuXml, nullptr); + if (!parsed || !parsed->interfaces[0]) + throw std::runtime_error( + "the com.canonical.dbusmenu interface description failed to parse"); + return parsed; + }(); + return info; +} + +// Object-path element for an application id: D-Bus path elements are +// restricted to [A-Za-z0-9_], so replace everything else. Callers prefix a +// literal segment, so a leading digit in the id cannot survive. +std::string sanitize_path_element(std::string const& application_id) { + std::string element; + element.reserve(application_id.size()); + for (char character : application_id) { + element += + (std::isalnum(static_cast(character)) || character == '_') + ? character + : '_'; + } + return element; +} + +} // namespace + +TrayMenuItem::TrayMenuItem(std::string label, std::function activated, + bool enabled) + : type(Type::item), + label(std::move(label)), + enabled(enabled), + activated(std::move(activated)) {} + +struct TrayIcon::Impl { + std::string id; + std::string title; + std::string icon_name; + std::string tooltip_title; + std::string tooltip_body; + std::string object_path; + std::string menu_path; + GDBusConnection* bus = nullptr; + guint sni_registration = 0; + guint menu_registration = 0; + guint watcher_subscription = 0; + std::function activation_handler; + + std::mutex mutex; // guards items and revision + std::vector items; + std::uint32_t revision = 1; + + ~Impl() { + if (watcher_subscription) + g_dbus_connection_signal_unsubscribe(bus, watcher_subscription); + if (menu_registration) + g_dbus_connection_unregister_object(bus, menu_registration); + if (sni_registration) + g_dbus_connection_unregister_object(bus, sni_registration); + } + + void emit_sni_signal(char const* signal_name) { + g_dbus_connection_emit_signal(bus, nullptr, object_path.c_str(), + "org.kde.StatusNotifierItem", signal_name, + nullptr, nullptr); + } + + // (Re-)announce this item to the watcher. Runs again whenever the watcher + // reappears (GNOME Shell restarts, extension reloads), which otherwise + // leaves a surviving process without its icon. + void register_with_watcher() { + GError* error = nullptr; + GVariant* result = g_dbus_connection_call_sync( + bus, kStatusNotifierWatcherName, kStatusNotifierWatcherPath, + kStatusNotifierWatcherName, "RegisterStatusNotifierItem", + g_variant_new("(s)", object_path.c_str()), nullptr, + G_DBUS_CALL_FLAGS_NONE, 2000, nullptr, &error); + if (!result) { + std::string message = error_message("tray registration failed", error); + throw std::runtime_error(message); + } + g_variant_unref(result); + } + + // GDBus dispatches property reads, method calls and the watcher-restart + // signal on the thread-default main context the icon was constructed on; + // the callbacks are members so they can reach this private implementation. + static GVariant* sni_get_property(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* property_name, GError**, + gpointer user_data); + static void sni_method_call(GDBusConnection*, gchar const*, gchar const*, + gchar const*, gchar const* method_name, + GVariant*, GDBusMethodInvocation* invocation, + gpointer user_data); + static GVariant* menu_get_property(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* property_name, GError**, + gpointer user_data); + static void menu_method_call(GDBusConnection*, gchar const*, gchar const*, + gchar const*, gchar const* method_name, + GVariant* parameters, + GDBusMethodInvocation* invocation, + gpointer user_data); + static void watcher_owner_changed(GDBusConnection*, gchar const*, + gchar const*, gchar const*, gchar const*, + GVariant* parameters, gpointer user_data); + + static constexpr GDBusInterfaceVTable sni_vtable{ + &Impl::sni_method_call, &Impl::sni_get_property, nullptr, {}}; + static constexpr GDBusInterfaceVTable menu_vtable{ + &Impl::menu_method_call, &Impl::menu_get_property, nullptr, {}}; + + // Caller holds mutex. The (a{sv}) property dictionary of one menu entry. + GVariant* item_properties(gint item_id) { + GVariantBuilder properties; + g_variant_builder_init(&properties, G_VARIANT_TYPE("a{sv}")); + if (item_id == 0) { + g_variant_builder_add(&properties, "{sv}", "children-display", + g_variant_new_string("submenu")); + return g_variant_builder_end(&properties); + } + TrayMenuItem const& item = items.at(static_cast(item_id - 1)); + if (item.type == TrayMenuItem::Type::separator) { + g_variant_builder_add(&properties, "{sv}", "type", + g_variant_new_string("separator")); + } else { + g_variant_builder_add(&properties, "{sv}", "type", + g_variant_new_string("standard")); + g_variant_builder_add(&properties, "{sv}", "label", + g_variant_new_string(item.label.c_str())); + g_variant_builder_add(&properties, "{sv}", "enabled", + g_variant_new_boolean(item.enabled)); + } + g_variant_builder_add(&properties, "{sv}", "visible", + g_variant_new_boolean(true)); + return g_variant_builder_end(&properties); + } + + // Caller holds mutex. One (ia{sv}av) layout node: the entry's id and + // properties plus its children — only the root (id 0) has children, the + // menu is flat. + GVariant* layout_node(gint item_id) { + GVariantBuilder children; + g_variant_builder_init(&children, G_VARIANT_TYPE("av")); + if (item_id == 0) { + for (gint child = 1; child <= static_cast(items.size()); ++child) { + g_variant_builder_add_value( + &children, g_variant_new_variant(layout_node(child))); + } + } + // @-annotate the pre-built property dictionary: bare array/dict format + // positions expect a GVariantBuilder, not a GVariant. + return g_variant_new("(i@a{sv}av)", item_id, item_properties(item_id), + &children); + } + + void dispatch_event(gint item_id, gchar const* event_id) { + if (std::strcmp(event_id, "clicked") != 0) return; + TrayMenuItem item_copy; + { + std::lock_guard guard(mutex); + if (item_id < 1 || item_id > static_cast(items.size())) return; + item_copy = items[static_cast(item_id - 1)]; + } + if (item_copy.type != TrayMenuItem::Type::item || !item_copy.enabled) return; + if (item_copy.activated) { + try { + item_copy.activated(); + } catch (...) { + // A throwing menu callback must not take the process down inside a + // GDBus dispatch. + } + } + } +}; + +GVariant* TrayIcon::Impl::sni_get_property(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* property_name, + GError**, gpointer user_data) { + Impl* impl = static_cast(user_data); + std::string const name = property_name; + if (name == "Id") return g_variant_new_string(impl->id.c_str()); + if (name == "Category") return g_variant_new_string("ApplicationStatus"); + if (name == "Status") return g_variant_new_string("Active"); + if (name == "Title") return g_variant_new_string(impl->title.c_str()); + if (name == "IconName") return g_variant_new_string(impl->icon_name.c_str()); + if (name == "ToolTip") { + // KDbusToolTipStruct: (icon name, pixmap array, title, description). + GVariantBuilder pixmaps; + g_variant_builder_init(&pixmaps, G_VARIANT_TYPE("a(iiay)")); + return g_variant_new("(sa(iiay)ss)", impl->icon_name.c_str(), &pixmaps, + impl->tooltip_title.c_str(), + impl->tooltip_body.c_str()); + } + if (name == "Menu") return g_variant_new_object_path(impl->menu_path.c_str()); + return nullptr; +} + +void TrayIcon::Impl::sni_method_call(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* method_name, GVariant*, + GDBusMethodInvocation* invocation, + gpointer user_data) { + Impl* impl = static_cast(user_data); + if (std::strcmp(method_name, "Activate") == 0) { + if (impl->activation_handler) { + try { + impl->activation_handler(); + } catch (...) { + } + } + } + // SecondaryActivate and Scroll have no first-party behaviour yet; they are + // accepted so watchers never see an error for a standard method. + g_dbus_method_invocation_return_value(invocation, nullptr); +} + +GVariant* TrayIcon::Impl::menu_get_property(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* property_name, + GError**, gpointer) { + std::string const name = property_name; + if (name == "Version") return g_variant_new_uint32(3); + if (name == "TextDirection") return g_variant_new_string("ltr"); + if (name == "Status") return g_variant_new_string("normal"); + if (name == "IconThemePath") { + GVariantBuilder path_array; + g_variant_builder_init(&path_array, G_VARIANT_TYPE("as")); + return g_variant_builder_end(&path_array); + } + return nullptr; +} + +void TrayIcon::Impl::menu_method_call(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const* method_name, + GVariant* parameters, + GDBusMethodInvocation* invocation, + gpointer user_data) { + Impl* impl = static_cast(user_data); + + if (std::strcmp(method_name, "GetLayout") == 0) { + GVariant* parent_value = g_variant_get_child_value(parameters, 0); + gint const parent_id = g_variant_get_int32(parent_value); + g_variant_unref(parent_value); + std::lock_guard guard(impl->mutex); + if (parent_id < 0 || parent_id > static_cast(impl->items.size())) { + g_dbus_method_invocation_return_dbus_error( + invocation, "com.canonical.dbusmenu.Error.NoItem", "no such item"); + return; + } + g_dbus_method_invocation_return_value( + invocation, + g_variant_new("(u@(ia{sv}av))", impl->revision, + impl->layout_node(parent_id))); + return; + } + + if (std::strcmp(method_name, "GetGroupProperties") == 0) { + GVariant* ids_variant = g_variant_get_child_value(parameters, 0); + std::vector requested; + GVariantIter id_iterator; + g_variant_iter_init(&id_iterator, ids_variant); + gint id = 0; + while (g_variant_iter_loop(&id_iterator, "i", &id)) requested.push_back(id); + g_variant_unref(ids_variant); + + GVariantBuilder result; + g_variant_builder_init(&result, G_VARIANT_TYPE("a(ia{sv})")); + std::lock_guard guard(impl->mutex); + if (requested.empty()) { + for (gint item_id = 1; item_id <= static_cast(impl->items.size()); + ++item_id) { + g_variant_builder_add(&result, "(i@a{sv})", item_id, + impl->item_properties(item_id)); + } + } else { + for (gint item_id : requested) { + if (item_id < 1 || item_id > static_cast(impl->items.size())) + continue; + g_variant_builder_add(&result, "(i@a{sv})", item_id, + impl->item_properties(item_id)); + } + } + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(a(ia{sv}))", &result)); + return; + } + + if (std::strcmp(method_name, "GetProperty") == 0) { + GVariant* id_value = g_variant_get_child_value(parameters, 0); + GVariant* name_value = g_variant_get_child_value(parameters, 1); + gint const item_id = g_variant_get_int32(id_value); + gchar const* property = + g_variant_get_string(name_value, nullptr); + GVariant* value = nullptr; + { + std::lock_guard guard(impl->mutex); + if (item_id >= 1 && item_id <= static_cast(impl->items.size())) { + GVariant* all = impl->item_properties(item_id); + GVariant* found = g_variant_lookup_value(all, property, nullptr); + if (found) value = found; + g_variant_unref(all); + } + } + g_variant_unref(id_value); + g_variant_unref(name_value); + if (!value) value = g_variant_new_string(""); + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(v)", value)); + return; + } + + if (std::strcmp(method_name, "Event") == 0) { + GVariant* id_value = g_variant_get_child_value(parameters, 0); + GVariant* event_value = g_variant_get_child_value(parameters, 1); + impl->dispatch_event(g_variant_get_int32(id_value), + g_variant_get_string(event_value, nullptr)); + g_variant_unref(id_value); + g_variant_unref(event_value); + g_dbus_method_invocation_return_value(invocation, nullptr); + return; + } + + if (std::strcmp(method_name, "EventGroup") == 0) { + GVariant* events = g_variant_get_child_value(parameters, 0); + GVariantIter event_iterator; + g_variant_iter_init(&event_iterator, events); + gint item_id = 0; + gchar* event_id = nullptr; + while (g_variant_iter_loop(&event_iterator, "(isvu)", &item_id, &event_id, + nullptr, nullptr)) { + impl->dispatch_event(item_id, event_id); + } + g_variant_unref(events); + GVariantBuilder errors; + g_variant_builder_init(&errors, G_VARIANT_TYPE("ai")); + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(ai)", &errors)); + return; + } + + if (std::strcmp(method_name, "AboutToShow") == 0) { + // Layout changes are announced through LayoutUpdated, so there is never + // a pending update to reveal here. + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(b)", FALSE)); + return; + } + + if (std::strcmp(method_name, "AboutToShowGroup") == 0) { + GVariantBuilder updates; + g_variant_builder_init(&updates, G_VARIANT_TYPE("ai")); + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(ai)", &updates)); + return; + } + + g_dbus_method_invocation_return_dbus_error( + invocation, "org.freedesktop.DBus.Error.UnknownMethod", "unknown method"); +} + +void TrayIcon::Impl::watcher_owner_changed(GDBusConnection*, gchar const*, + gchar const*, gchar const*, + gchar const*, GVariant* parameters, + gpointer user_data) { + gchar* new_owner = nullptr; + g_variant_get(parameters, "(sss)", nullptr, nullptr, &new_owner); + gboolean const appeared = new_owner && *new_owner != '\0'; + g_free(new_owner); + if (!appeared) return; + Impl* impl = static_cast(user_data); + try { + impl->register_with_watcher(); + } catch (...) { + // The watcher may vanish again before answering; the next appearance + // retries. A missing icon is visible on the desktop, not fatal here. + } +} + +bool TrayIcon::available() { + static int available_state = -1; // -1 unknown, 0 no, 1 yes + if (available_state >= 0) return available_state == 1; + GDBusConnection* bus = session_bus(); + if (!bus) { + available_state = 0; + return false; + } + GError* error = nullptr; + GVariant* owner = g_dbus_connection_call_sync( + bus, "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "GetNameOwner", + g_variant_new("(s)", kStatusNotifierWatcherName), G_VARIANT_TYPE("(s)"), + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + if (!owner) { + if (error) g_error_free(error); + available_state = 0; + return false; + } + g_variant_unref(owner); + available_state = 1; + return true; +} + +TrayIcon::TrayIcon(std::string const& application_id, + std::string const& title, std::string const& icon_name) + : impl_(std::make_unique()) { + GDBusConnection* bus = session_bus(); + if (!bus) + throw std::runtime_error( + "tray is unavailable: no D-Bus session bus is reachable"); + if (!available()) + throw std::runtime_error( + "tray is unavailable: no StatusNotifierItem watcher is running"); + + impl_->bus = bus; + impl_->id = application_id; + impl_->title = title; + impl_->icon_name = icon_name; + + // Object paths must be unique per instance; a process may host several + // icons (one per window, for example). + static std::atomic instance_counter{0}; + std::string const element = sanitize_path_element(application_id); + impl_->object_path = "/org/rivet/tray/" + + (element.empty() ? std::string("app") : element) + "_" + + std::to_string(instance_counter.fetch_add(1)); + impl_->menu_path = impl_->object_path + "/menu"; + + GError* error = nullptr; + impl_->sni_registration = g_dbus_connection_register_object( + bus, impl_->object_path.c_str(), + status_notifier_item_info()->interfaces[0], &Impl::sni_vtable, + impl_.get(), nullptr, &error); + if (!impl_->sni_registration) { + std::string message = error_message("tray object registration failed", error); + throw std::runtime_error(message); + } + impl_->menu_registration = g_dbus_connection_register_object( + bus, impl_->menu_path.c_str(), dbus_menu_info()->interfaces[0], + &Impl::menu_vtable, impl_.get(), nullptr, &error); + if (!impl_->menu_registration) { + std::string message = error_message("tray menu registration failed", error); + throw std::runtime_error(message); + } + + // Watch for the watcher: GNOME Shell restarts and extension reloads + // replace the StatusNotifierItem watcher without touching this process. + impl_->watcher_subscription = g_dbus_connection_signal_subscribe( + bus, "org.freedesktop.DBus", "org.freedesktop.DBus", "NameOwnerChanged", + "/org/freedesktop/DBus", kStatusNotifierWatcherName, + G_DBUS_SIGNAL_FLAGS_NONE, &Impl::watcher_owner_changed, impl_.get(), + nullptr); + + impl_->register_with_watcher(); +} + +TrayIcon::~TrayIcon() = default; + +void TrayIcon::set_icon(std::string const& icon_name) { + impl_->icon_name = icon_name; + impl_->emit_sni_signal("NewIcon"); +} + +void TrayIcon::set_tooltip(std::string const& title, std::string const& body) { + impl_->tooltip_title = title; + impl_->tooltip_body = body; + impl_->emit_sni_signal("NewToolTip"); +} + +void TrayIcon::set_menu(std::vector items) { + std::uint32_t updated_revision = 0; + { + std::lock_guard guard(impl_->mutex); + impl_->items = std::move(items); + updated_revision = ++impl_->revision; + } + g_dbus_connection_emit_signal( + impl_->bus, nullptr, impl_->menu_path.c_str(), "com.canonical.dbusmenu", + "LayoutUpdated", g_variant_new("(ui)", updated_revision, 0), nullptr); +} + +void TrayIcon::set_activation_handler(std::function handler) { + impl_->activation_handler = std::move(handler); +} + + void InstallCrashHook(CrashCallback callback, std::string const& restart_arguments) { if (crash_log_fd >= 0) diff --git a/platform/linux/system/system_services.hpp b/platform/linux/system/system_services.hpp index 969f506..3e50d19 100644 --- a/platform/linux/system/system_services.hpp +++ b/platform/linux/system/system_services.hpp @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -18,11 +19,11 @@ namespace rivet::system { // Runtime capability names, matching the Racket system-adapter contract: -// single-instance, notification, autostart, secure-storage, crash-hook. -// Tray is deliberately absent: the StatusNotifierItem contract is -// compositor-dependent (GNOME hosts it only through an extension), so tray -// presence must be an explicit application decision rather than a silent -// adapter default. +// single-instance, notification, tray, autostart, secure-storage, crash-hook. +// The tray capability reflects a reachable session bus with a +// StatusNotifierItem watcher (a compositor service: GNOME hosts it through +// the AppIndicator extension, KDE natively), so tray presence stays an +// explicit application decision rather than a silent adapter default. std::vector Capabilities(); // Command-line activation payload (URL schemes and file associations @@ -92,6 +93,58 @@ class Notifications final { static void CloseTag(std::string const& tag); }; +// One StatusNotifierItem menu entry: a labelled, optionally disabled item +// with a click callback, or a separator. Callbacks run on the +// thread-default main context TrayIcon was constructed on — for GTK hosts, +// the main-loop thread. +struct TrayMenuItem final { + enum class Type { item, separator }; + + TrayMenuItem() = default; + TrayMenuItem(std::string label, std::function activated, + bool enabled = true); + explicit TrayMenuItem(Type type) : type(type) {} + + Type type = Type::item; + std::string label; + bool enabled = true; + std::function activated; +}; + +// org.kde.StatusNotifierItem with a com.canonical.dbusmenu menu, through the +// session bus (GDBus). This is the Linux counterpart of the Windows +// Shell_NotifyIcon adapter: an explicit opt-in — check available() (or the +// "tray" capability) and construct only when a StatusNotifierItem watcher is +// running, since desktop hosting is compositor-dependent. Methods must be +// called from the thread-default main context the icon was constructed on; +// GTK hosts satisfy this with their main loop. +class TrayIcon final { + public: + // True when the session bus has a StatusNotifierItem watcher to register + // with. False without a session bus — same contract as Notifications. + static bool available(); + + TrayIcon(std::string const& application_id, std::string const& title, + std::string const& icon_name); + ~TrayIcon(); + TrayIcon(TrayIcon const&) = delete; + TrayIcon& operator=(TrayIcon const&) = delete; + + // Themed icon name (installed icon theme entry, e.g. "syncpilot"). + void set_icon(std::string const& icon_name); + void set_tooltip(std::string const& title, std::string const& body); + // Replaces the whole menu and bumps the layout revision. Item callbacks + // are invoked on the main context when the watcher reports a click. + void set_menu(std::vector items); + // Left-click / Activate handling where the watcher supports it (KDE; the + // GNOME AppIndicator extension opens the menu instead). + void set_activation_handler(std::function handler); + + private: + struct Impl; + std::unique_ptr impl_; +}; + // POSIX fatal-signal hook. The handler is async-signal-safe: it appends a // preformatted note (signal number and restart arguments captured at install // time) to a file under the XDG state directory, invokes the callback, and