From ae88f75f9f9ab112e0494704002404f122c6e190 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Mon, 12 Oct 2026 19:24:24 +0800 Subject: [PATCH 1/4] feat(linux): add portal global shortcuts --- .github/workflows/roundtrip.yml | 1 + CHANGELOG.md | 4 + README.md | 2 +- README.zh-CN.md | 2 +- docs/architecture.md | 4 +- docs/linux-display.md | 12 +- docs/system-services.md | 3 +- examples/taskboard/linux/CMakeLists.txt | 1 + platform/linux/Integration/CMakeLists.txt | 14 + .../Integration/global_shortcuts_test.cpp | 414 ++++++++++++++++ platform/linux/host/CMakeLists.txt | 1 + platform/linux/host/README.md | 16 +- platform/linux/system/global_shortcuts.cpp | 443 ++++++++++++++++++ platform/linux/system/system_services.cpp | 1 + platform/linux/system/system_services.hpp | 72 ++- 15 files changed, 975 insertions(+), 15 deletions(-) create mode 100644 platform/linux/Integration/global_shortcuts_test.cpp create mode 100644 platform/linux/system/global_shortcuts.cpp diff --git a/.github/workflows/roundtrip.yml b/.github/workflows/roundtrip.yml index af7fc1d..30982c4 100644 --- a/.github/workflows/roundtrip.yml +++ b/.github/workflows/roundtrip.yml @@ -52,6 +52,7 @@ jobs: -DRIVET_RACKET_LIBRARY="$RIVET_RACKET_LIBRARY" cmake --build "$native_build" --config Release "$native_build/RivetThemeTest" + dbus-run-session -- "$native_build/RivetGlobalShortcutsTest" cp "$native_build/RivetIntegration" "$stage/RivetIntegration" chmod +x "$stage/RivetIntegration" diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fdd93..10f3083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add Wayland-safe Linux global shortcuts through the XDG Desktop Portal, + including asynchronous consent/binding results, compositor activation tokens, + runtime capability discovery, and a hermetic mock-portal integration test. + Rivet deliberately provides no X11 `XGrabKey` fallback. - Bound and retry Ubuntu runner package-manager operations so a stalled mirror cannot consume an entire desktop validation job without a useful failure. - Removed the misleading `rivet-type` runtime descriptors from the pre-1.0 diff --git a/README.md b/README.md index 52afcde..6ac2dbe 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ See [architecture](docs/architecture.md), [API stability](docs/api-stability.md) | `new` / `doctor` / `build` / `dev` | ✅ | ✅ | ✅ | | `package` / `verify` | ✅ dependency audit | ✅ signing/rpath/plist audit | ✅ directory + `ldd` audit | | Production signing / installer | ✅ Authenticode + MSI | ✅ Developer ID + DMG | ✅ signed tarball + deb/rpm/AppImage | -| System services / secure storage | ✅ | ✅ | ✅ Linux adapter + StatusNotifierItem tray | +| System services / secure storage | ✅ | ✅ | ✅ Linux adapter + portal global shortcuts + StatusNotifierItem tray | | Real embedded-runtime CI | ✅ | ✅ | ✅ | Windows, macOS, and Linux are production release targets. Linux qualification diff --git a/README.zh-CN.md b/README.zh-CN.md index 4ad0f52..b499cd7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -119,7 +119,7 @@ Racket CS 运行在独立 runtime 线程。原生 UI 代码不会直接操作 Ra | `new` / `doctor` / `build` / `dev` | ✅ | ✅ | ✅ | | `package` / `verify` | ✅ DLL 依赖审计 | ✅ 签名/rpath/plist | ✅ 目录 + `ldd` 审计 | | 生产签名 / 安装包 | ✅ Authenticode + MSI | ✅ Developer ID + DMG | ✅ 签名 tarball + deb/rpm/AppImage | -| 系统服务 / 安全存储 | ✅ | ✅ | ✅ Linux 适配器 + StatusNotifierItem 托盘 | +| 系统服务 / 安全存储 | ✅ | ✅ | ✅ Linux 适配器 + Portal 全局快捷键 + StatusNotifierItem 托盘 | | 真实嵌入运行时 CI | ✅ | ✅ | ✅ | Windows、macOS 与 Linux 都是生产发布目标。Linux 资格门禁使用原生 diff --git a/docs/architecture.md b/docs/architecture.md index 9f1d23f..b6ba654 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -90,8 +90,8 @@ The Swift implementation is intentionally not the framework's specification. `do The GTK4 host follows the Windows C++ contract but uses a Unix `socketpair` for the in-process RVT1 transport. Racket CS is linked statically, while the UI remains an ordinary GTK4 application. Backend completions are returned to GLib's main loop before widgets are touched. Linux system integration is split by operating-system responsibility under -`platform/linux/system`: single-instance activation, notifications, tray, -autostart, secure storage, and crash/shutdown each have one implementation +`platform/linux/system`: single-instance activation, portal global shortcuts, +notifications, tray, autostart, secure storage, and crash/shutdown each have one implementation unit. `system_services.cpp` only assembles public capability discovery and activation arguments; `system_services_internal.*` is a private XDG, D-Bus, and low-level I/O support layer. Native applications continue to include only diff --git a/docs/linux-display.md b/docs/linux-display.md index d66e5b1..a3a617e 100644 --- a/docs/linux-display.md +++ b/docs/linux-display.md @@ -32,8 +32,14 @@ XDG portal or compositor protocol and must report capability absence honestly. Applications must not silently fall back to broad X11 privileges. StatusNotifierItem tray integration remains available where a desktop hosts -it. Global hotkeys, always-on-top overlays, and other compositor-specific -features are not implied by the GTK4 host baseline. +it. Global hotkeys use `org.freedesktop.portal.GlobalShortcuts`: the runtime +reports portal availability and version, and the compositor retains consent +and final-binding authority. There is deliberately no `XGrabKey` fallback. +On a compositor without the portal, an application can expose a command such +as `--toggle` through Rivet's existing single-instance activation route and +let the user assign that command in compositor settings. Always-on-top +overlays and other compositor-specific features are not implied by the GTK4 +host baseline. ## CI contract @@ -73,6 +79,8 @@ resource payload, graceful SIGTERM handling, and complete metadata removal. The Linux integration runner separately exercises single-instance forwarding, autostart persistence, crash/shutdown hooks, and capability-conditioned notifications, StatusNotifierItem tray, and Secret Service storage. +The GlobalShortcuts adapter is additionally exercised against a deterministic +mock portal on a private D-Bus session, including activation-token delivery. Fedora advances on its roughly thirteen-month upstream lifecycle; CI pins a specific maintained release instead of using `fedora:latest`. Ubuntu LTS and diff --git a/docs/system-services.md b/docs/system-services.md index 2ab5290..48acb4c 100644 --- a/docs/system-services.md +++ b/docs/system-services.md @@ -44,6 +44,7 @@ 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; +- `GlobalShortcuts` — the asynchronous `org.freedesktop.portal.GlobalShortcuts` session API. `support()` exposes the portal version, `bind(...)` reports bound, cancelled, unavailable, and failed outcomes without blocking GTK's main context, and activation callbacks preserve the compositor timestamp and activation token. The compositor owns the final shortcut and may replace or reject an application's preferred trigger. Rivet does not fall back to `XGrabKey`; applications on a session without this portal can expose their existing single-instance activation command (for example, an executable `--toggle` action) for the user to bind in compositor settings; - `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; @@ -59,7 +60,7 @@ break while the GTK main loop remains alive. Generated hosts and the reference Taskboard enforce this ordering and the deb lifecycle gate sends a real `SIGTERM` to the installed application. -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. +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. A separate hermetic test starts a private D-Bus session and mock GlobalShortcuts portal, then verifies discovery, session creation, binding results, and activation delivery without depending on a particular CI desktop. ## Settings, logs, and crashes diff --git a/examples/taskboard/linux/CMakeLists.txt b/examples/taskboard/linux/CMakeLists.txt index 879b4de..8fe17bb 100644 --- a/examples/taskboard/linux/CMakeLists.txt +++ b/examples/taskboard/linux/CMakeLists.txt @@ -51,6 +51,7 @@ add_executable(RivetHost "${RIVET_ROOT}/platform/linux/runtime/backend.cpp" "${RIVET_ROOT}/platform/linux/system/autostart.cpp" "${RIVET_ROOT}/platform/linux/system/crash_shutdown.cpp" + "${RIVET_ROOT}/platform/linux/system/global_shortcuts.cpp" "${RIVET_ROOT}/platform/linux/system/notifications.cpp" "${RIVET_ROOT}/platform/linux/system/secure_storage.cpp" "${RIVET_ROOT}/platform/linux/system/single_instance.cpp" diff --git a/platform/linux/Integration/CMakeLists.txt b/platform/linux/Integration/CMakeLists.txt index 6104a80..0b15b29 100644 --- a/platform/linux/Integration/CMakeLists.txt +++ b/platform/linux/Integration/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(RivetIntegration ../runtime/backend.cpp ../system/autostart.cpp ../system/crash_shutdown.cpp + ../system/global_shortcuts.cpp ../system/notifications.cpp ../system/secure_storage.cpp ../system/single_instance.cpp @@ -49,6 +50,19 @@ target_include_directories(RivetThemeTest PRIVATE ../theme) target_compile_options(RivetThemeTest PRIVATE -Wall -Wextra -Wpedantic) target_link_libraries(RivetThemeTest PRIVATE PkgConfig::GTK4) +add_executable(RivetGlobalShortcutsTest + global_shortcuts_test.cpp + ../system/global_shortcuts.cpp + ../system/system_services_internal.cpp +) +target_compile_features(RivetGlobalShortcutsTest PRIVATE cxx_std_17) +target_include_directories(RivetGlobalShortcutsTest PRIVATE ../system) +target_compile_options(RivetGlobalShortcutsTest PRIVATE -Wall -Wextra -Wpedantic) +target_link_libraries(RivetGlobalShortcutsTest PRIVATE + Threads::Threads + PkgConfig::GLIB +) + if(LIBSECRET_FOUND) target_compile_definitions(RivetIntegration PRIVATE RIVET_HAVE_LIBSECRET=1) endif() diff --git a/platform/linux/Integration/global_shortcuts_test.cpp b/platform/linux/Integration/global_shortcuts_test.cpp new file mode 100644 index 0000000..40b0248 --- /dev/null +++ b/platform/linux/Integration/global_shortcuts_test.cpp @@ -0,0 +1,414 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "system_services.hpp" + +namespace { + +constexpr char const* kPortalName = "org.freedesktop.portal.Desktop"; +constexpr char const* kPortalPath = "/org/freedesktop/portal/desktop"; +constexpr char const* kShortcutInterface = + "org.freedesktop.portal.GlobalShortcuts"; +constexpr char const* kRequestInterface = "org.freedesktop.portal.Request"; +constexpr char const* kSessionPath = + "/org/freedesktop/portal/desktop/session/rivet_test/session"; + +void require(bool condition, char const* message) { + if (!condition) throw std::runtime_error(message); +} + +std::string sender_path_element(char const* sender) { + std::string element(sender && sender[0] == ':' ? sender + 1 : sender); + for (char& character : element) { + bool const allowed = + (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '_'; + if (!allowed) character = '_'; + } + return element; +} + +std::string option_string(GVariant* options, char const* name) { + char const* value = nullptr; + if (!g_variant_lookup(options, name, "&s", &value) || !value) return {}; + return value; +} + +class MockGlobalShortcutsPortal final { + public: + MockGlobalShortcutsPortal() { + std::promise started; + auto ready = started.get_future(); + thread_ = std::thread( + [this, started = std::move(started)]() mutable { + serve(std::move(started)); + }); + std::string const error = ready.get(); + if (!error.empty()) { + thread_.join(); + throw std::runtime_error(error); + } + } + + ~MockGlobalShortcutsPortal() { + g_main_context_invoke( + context_, + [](gpointer data) -> gboolean { + g_main_loop_quit(static_cast(data)); + return G_SOURCE_REMOVE; + }, + loop_); + thread_.join(); + } + + MockGlobalShortcutsPortal(MockGlobalShortcutsPortal const&) = delete; + MockGlobalShortcutsPortal& operator=(MockGlobalShortcutsPortal const&) = + delete; + + private: + static void return_error(GDBusMethodInvocation* invocation, + char const* message) { + g_dbus_method_invocation_return_dbus_error( + invocation, "org.freedesktop.portal.Error.InvalidArgument", message); + } + + static void emit_response(GDBusConnection* connection, char const* sender, + std::string const& path, GVariant* results) { + GError* error = nullptr; + g_dbus_connection_emit_signal( + connection, sender, path.c_str(), kRequestInterface, "Response", + g_variant_new("(u@a{sv})", 0U, results), &error); + if (error) { + std::cerr << "mock portal response failed: " << error->message << "\n"; + g_error_free(error); + } + } + + static void method_called(GDBusConnection* connection, char const* sender, + char const*, char const* interface_name, + char const* method_name, GVariant* parameters, + GDBusMethodInvocation* invocation, gpointer) { + if (std::string(interface_name) == "org.freedesktop.portal.Session" && + std::string(method_name) == "Close") { + g_dbus_method_invocation_return_value(invocation, nullptr); + return; + } + if (std::string(interface_name) != kShortcutInterface) { + return_error(invocation, "unexpected interface"); + return; + } + + if (std::string(method_name) == "CreateSession") { + GVariant* options = nullptr; + g_variant_get(parameters, "(@a{sv})", &options); + std::string const token = option_string(options, "handle_token"); + std::string const session_token = + option_string(options, "session_handle_token"); + g_variant_unref(options); + if (token.empty() || session_token.empty()) { + return_error(invocation, "missing request or session token"); + return; + } + std::string const request_path = + "/org/freedesktop/portal/desktop/request/" + + sender_path_element(sender) + "/" + token; + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(o)", request_path.c_str())); + + GVariantBuilder results; + g_variant_builder_init(&results, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&results, "{sv}", "session_handle", + g_variant_new_string(kSessionPath)); + emit_response(connection, sender, request_path, + g_variant_builder_end(&results)); + return; + } + + if (std::string(method_name) == "BindShortcuts") { + char const* session = nullptr; + GVariant* shortcuts = nullptr; + char const* parent_window = nullptr; + GVariant* options = nullptr; + g_variant_get(parameters, "(&o@a(sa{sv})&s@a{sv})", &session, + &shortcuts, &parent_window, &options); + std::string const token = option_string(options, "handle_token"); + g_variant_unref(options); + if (!session || std::string(session) != kSessionPath || token.empty()) { + g_variant_unref(shortcuts); + return_error(invocation, "invalid session or request token"); + return; + } + + GVariantIter iterator; + g_variant_iter_init(&iterator, shortcuts); + char const* shortcut_id = nullptr; + GVariant* properties = nullptr; + bool const has_shortcut = g_variant_iter_next( + &iterator, "(&s@a{sv})", &shortcut_id, &properties); + std::string id = shortcut_id ? shortcut_id : ""; + std::string description = + has_shortcut ? option_string(properties, "description") : ""; + if (properties) g_variant_unref(properties); + g_variant_unref(shortcuts); + if (!has_shortcut || id.empty() || description.empty()) { + return_error(invocation, "expected one described shortcut"); + return; + } + + std::string const request_path = + "/org/freedesktop/portal/desktop/request/" + + sender_path_element(sender) + "/" + token; + g_dbus_method_invocation_return_value( + invocation, g_variant_new("(o)", request_path.c_str())); + + GVariantBuilder binding_properties; + g_variant_builder_init(&binding_properties, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&binding_properties, "{sv}", "description", + g_variant_new_string(description.c_str())); + g_variant_builder_add(&binding_properties, "{sv}", + "trigger_description", + g_variant_new_string("Alt+Space")); + GVariantBuilder bindings; + g_variant_builder_init(&bindings, G_VARIANT_TYPE("a(sa{sv})")); + g_variant_builder_add(&bindings, "(s@a{sv})", id.c_str(), + g_variant_builder_end(&binding_properties)); + GVariantBuilder results; + g_variant_builder_init(&results, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&results, "{sv}", "shortcuts", + g_variant_builder_end(&bindings)); + emit_response(connection, sender, request_path, + g_variant_builder_end(&results)); + + GVariantBuilder activation_options; + g_variant_builder_init(&activation_options, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&activation_options, "{sv}", "activation_token", + g_variant_new_string("rivet-test-token")); + GError* error = nullptr; + g_dbus_connection_emit_signal( + connection, sender, kPortalPath, kShortcutInterface, "Activated", + g_variant_new("(ost@a{sv})", kSessionPath, id.c_str(), + static_cast(42), + g_variant_builder_end(&activation_options)), + &error); + if (error) { + std::cerr << "mock activation failed: " << error->message << "\n"; + g_error_free(error); + } + return; + } + + return_error(invocation, "unexpected method"); + } + + static GVariant* property_read(GDBusConnection*, char const*, char const*, + char const* interface_name, + char const* property_name, GError**, + gpointer) { + if (std::string(interface_name) == kShortcutInterface && + std::string(property_name) == "version") { + return g_variant_new_uint32(2); + } + return nullptr; + } + + void serve(std::promise started) noexcept { + GDBusConnection* connection = nullptr; + GDBusNodeInfo* portal_info = nullptr; + GDBusNodeInfo* session_info = nullptr; + guint portal_registration = 0; + guint session_registration = 0; + try { + context_ = g_main_context_new(); + g_main_context_push_thread_default(context_); + loop_ = g_main_loop_new(context_, FALSE); + + char const* address = std::getenv("DBUS_SESSION_BUS_ADDRESS"); + require(address && *address, + "DBUS_SESSION_BUS_ADDRESS is missing; use dbus-run-session"); + GError* error = nullptr; + connection = g_dbus_connection_new_for_address_sync( + address, + static_cast( + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | + G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), + nullptr, nullptr, &error); + if (!connection) { + std::string message = "could not connect mock portal: "; + message += error ? error->message : "unknown D-Bus error"; + if (error) g_error_free(error); + throw std::runtime_error(message); + } + g_dbus_connection_set_exit_on_close(connection, FALSE); + + GVariant* name_reply = g_dbus_connection_call_sync( + connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", + "org.freedesktop.DBus", "RequestName", + g_variant_new("(su)", kPortalName, 0U), G_VARIANT_TYPE("(u)"), + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + if (!name_reply) { + std::string message = "could not own mock portal name: "; + message += error ? error->message : "unknown D-Bus error"; + if (error) g_error_free(error); + throw std::runtime_error(message); + } + guint32 name_result = 0; + g_variant_get(name_reply, "(u)", &name_result); + g_variant_unref(name_reply); + require(name_result == 1 || name_result == 4, + "mock portal name was already owned"); + + static char const portal_xml[] = R"XML( + + + + + + + + + + + + + + + + + + + + )XML"; + static char const session_xml[] = R"XML( + + + + + )XML"; + portal_info = g_dbus_node_info_new_for_xml(portal_xml, &error); + require(portal_info != nullptr, "mock portal XML is invalid"); + session_info = g_dbus_node_info_new_for_xml(session_xml, &error); + require(session_info != nullptr, "mock session XML is invalid"); + + GDBusInterfaceVTable const vtable = {&method_called, &property_read, + nullptr, {0}}; + portal_registration = g_dbus_connection_register_object( + connection, kPortalPath, portal_info->interfaces[0], &vtable, + nullptr, nullptr, &error); + require(portal_registration != 0, + "could not register mock shortcut portal"); + session_registration = g_dbus_connection_register_object( + connection, kSessionPath, session_info->interfaces[0], &vtable, + nullptr, nullptr, &error); + require(session_registration != 0, + "could not register mock shortcut session"); + + started.set_value({}); + g_main_loop_run(loop_); + } catch (std::exception const& exception) { + started.set_value(exception.what()); + } + + if (connection && session_registration) { + g_dbus_connection_unregister_object(connection, session_registration); + } + if (connection && portal_registration) { + g_dbus_connection_unregister_object(connection, portal_registration); + } + if (portal_info) g_dbus_node_info_unref(portal_info); + if (session_info) g_dbus_node_info_unref(session_info); + if (connection) g_object_unref(connection); + if (loop_) g_main_loop_unref(loop_); + g_main_context_pop_thread_default(context_); + if (context_) g_main_context_unref(context_); + } + + std::thread thread_; + GMainContext* context_ = nullptr; + GMainLoop* loop_ = nullptr; +}; + +gboolean timeout_loop(gpointer data) { + auto* state = static_cast*>(data); + *state->second = true; + g_main_loop_quit(state->first); + return G_SOURCE_REMOVE; +} + +} // namespace + +int main() { + try { + MockGlobalShortcutsPortal portal; + auto const support = rivet::system::GlobalShortcuts::support(); + require(support.available, "mock GlobalShortcuts portal was not detected"); + require(support.portal_version == 2, + "mock GlobalShortcuts portal version was not preserved"); + + bool bound = false; + bool activated = false; + bool timed_out = false; + std::string callback_error; + GMainLoop* loop = g_main_loop_new(nullptr, FALSE); + std::pair timeout_state{loop, &timed_out}; + guint const timeout = g_timeout_add_seconds(5, &timeout_loop, + &timeout_state); + { + rivet::system::GlobalShortcuts shortcuts; + shortcuts.set_activation_handler( + [&](std::string const& id, std::uint64_t timestamp, + std::string const& token) { + if (id != "toggle") { + callback_error = "activation returned the wrong id"; + } else if (timestamp != 42) { + callback_error = "activation returned the wrong timestamp"; + } else if (token != "rivet-test-token") { + callback_error = "activation token was not preserved"; + } + activated = true; + if (bound || !callback_error.empty()) g_main_loop_quit(loop); + }); + shortcuts.bind( + {{"toggle", "Show or hide the window", "ALT+space"}}, {}, + [&](rivet::system::GlobalShortcutBindResult result) { + if (result.status != + rivet::system::GlobalShortcutBindStatus::bound) { + callback_error = "portal binding did not succeed"; + } else if (result.bindings.size() != 1) { + callback_error = + "portal did not return exactly one binding"; + } else if (result.bindings[0].id != "toggle") { + callback_error = "portal binding returned the wrong id"; + } else if (result.bindings[0].trigger_description != "Alt+Space") { + callback_error = + "portal trigger description was not preserved"; + } + bound = true; + if (activated || !callback_error.empty()) g_main_loop_quit(loop); + }); + g_main_loop_run(loop); + } + if (!timed_out) g_source_remove(timeout); + g_main_loop_unref(loop); + require(!timed_out, "timed out waiting for portal callbacks"); + require(callback_error.empty(), callback_error.c_str()); + require(bound && activated, + "portal binding and activation did not both complete"); + std::cout << "Rivet GlobalShortcuts portal checks passed\n"; + return 0; + } catch (std::exception const& error) { + std::cerr << "Rivet GlobalShortcuts portal failure: " << error.what() + << "\n"; + return 1; + } +} diff --git a/platform/linux/host/CMakeLists.txt b/platform/linux/host/CMakeLists.txt index 879b4de..8fe17bb 100644 --- a/platform/linux/host/CMakeLists.txt +++ b/platform/linux/host/CMakeLists.txt @@ -51,6 +51,7 @@ add_executable(RivetHost "${RIVET_ROOT}/platform/linux/runtime/backend.cpp" "${RIVET_ROOT}/platform/linux/system/autostart.cpp" "${RIVET_ROOT}/platform/linux/system/crash_shutdown.cpp" + "${RIVET_ROOT}/platform/linux/system/global_shortcuts.cpp" "${RIVET_ROOT}/platform/linux/system/notifications.cpp" "${RIVET_ROOT}/platform/linux/system/secure_storage.cpp" "${RIVET_ROOT}/platform/linux/system/single_instance.cpp" diff --git a/platform/linux/host/README.md b/platform/linux/host/README.md index 7294e40..4b43f8a 100644 --- a/platform/linux/host/README.md +++ b/platform/linux/host/README.md @@ -25,6 +25,7 @@ platform/linux/ │ ├── system_services.cpp # capability discovery + activation arguments │ ├── single_instance.cpp # local activation lease and forwarding │ ├── notifications.cpp # freedesktop notifications +│ ├── global_shortcuts.cpp # XDG GlobalShortcuts portal │ ├── tray.cpp # StatusNotifierItem + dbusmenu │ ├── autostart.cpp # XDG autostart entries │ ├── secure_storage.cpp # Secret Service through libsecret @@ -74,10 +75,11 @@ and Rivet does not add X11-only application APIs. See `.tar.gz` with a detached Ed25519 signature, plus deb/rpm/AppImage formats and a signed portable-zip update feed. Real install/upgrade evidence across the supported distribution matrix remains follow-up work. -- The system adapter covers single-instance, notifications, StatusNotifierItem - tray integration, XDG autostart, Secret Service secure storage, crash hooks, - graceful shutdown, and runtime capability reporting. -- GTK is a toolkit, not a display protocol: global hotkeys and always-on-top - overlays remain compositor-dependent. Applications that need those features - must use capability checks and a Wayland protocol or portal appropriate to - their supported compositors. +- The system adapter covers single-instance, XDG portal global shortcuts, + notifications, StatusNotifierItem tray integration, XDG autostart, Secret + Service secure storage, crash hooks, graceful shutdown, and runtime + capability reporting. Global shortcuts remain compositor-controlled and + report unavailable instead of falling back to X11-only `XGrabKey`. +- GTK is a toolkit, not a display protocol: always-on-top overlays and other + compositor-specific features still require a capability check and an + appropriate Wayland protocol or portal. diff --git a/platform/linux/system/global_shortcuts.cpp b/platform/linux/system/global_shortcuts.cpp new file mode 100644 index 0000000..c3b4240 --- /dev/null +++ b/platform/linux/system/global_shortcuts.cpp @@ -0,0 +1,443 @@ +#include "system_services.hpp" +#include "system_services_internal.hpp" + +#include +#include +#include +#include +#include + +#include + +namespace rivet::system { +namespace { + +using detail::session_bus; + +constexpr char const* kPortalName = "org.freedesktop.portal.Desktop"; +constexpr char const* kPortalPath = "/org/freedesktop/portal/desktop"; +constexpr char const* kGlobalShortcutsInterface = + "org.freedesktop.portal.GlobalShortcuts"; +constexpr char const* kRequestInterface = "org.freedesktop.portal.Request"; +constexpr char const* kSessionInterface = "org.freedesktop.portal.Session"; + +std::string take_error_message(char const* prefix, GError* error) { + std::string message = prefix; + if (error) { + message += ": "; + message += error->message; + g_error_free(error); + } + return message; +} + +std::string sender_path_element(GDBusConnection* bus) { + char const* unique_name = g_dbus_connection_get_unique_name(bus); + if (!unique_name || !*unique_name) { + throw std::runtime_error("the D-Bus session connection has no unique name"); + } + std::string element(unique_name[0] == ':' ? unique_name + 1 : unique_name); + for (char& character : element) { + bool const allowed = + (character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '_'; + if (!allowed) character = '_'; + } + return element; +} + +std::string next_token() { + static std::atomic counter{1}; + return "rivet" + std::to_string(counter.fetch_add(1)); +} + +std::string request_path(GDBusConnection* bus, std::string const& token) { + return "/org/freedesktop/portal/desktop/request/" + + sender_path_element(bus) + "/" + token; +} + +bool contains_nul(std::string const& value) { + return value.find('\0') != std::string::npos; +} + +} // namespace + +struct GlobalShortcuts::Impl final + : std::enable_shared_from_this { + enum class CallStage { create_session, bind_shortcuts }; + + struct PendingCall final { + std::shared_ptr impl; + CallStage stage; + }; + + GDBusConnection* bus = nullptr; + GCancellable* cancellable = nullptr; + guint create_subscription = 0; + guint bind_subscription = 0; + guint activation_subscription = 0; + bool bind_started = false; + bool create_finished = false; + bool bind_finished = false; + bool closing = false; + std::string session_handle; + std::vector shortcuts; + std::string parent_window; + BindHandler completion; + ActivationHandler activation_handler; + + explicit Impl(GDBusConnection* connection) + : bus(connection), cancellable(g_cancellable_new()) {} + + ~Impl() { + if (cancellable) g_object_unref(cancellable); + } + + static PendingCall* pending(std::shared_ptr impl, CallStage stage) { + return new PendingCall{std::move(impl), stage}; + } + + void unsubscribe(guint& subscription) { + if (bus && subscription != 0) { + g_dbus_connection_signal_unsubscribe(bus, subscription); + subscription = 0; + } + } + + void close() { + if (closing) return; + closing = true; + g_cancellable_cancel(cancellable); + unsubscribe(create_subscription); + unsubscribe(bind_subscription); + unsubscribe(activation_subscription); + if (bus && !session_handle.empty()) { + g_dbus_connection_call(bus, kPortalName, session_handle.c_str(), + kSessionInterface, "Close", nullptr, nullptr, + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, nullptr, + nullptr); + session_handle.clear(); + } + completion = nullptr; + activation_handler = nullptr; + } + + void finish(GlobalShortcutBindResult result) { + if (closing || !completion) return; + BindHandler callback = std::move(completion); + if (result.status != GlobalShortcutBindStatus::bound) { + unsubscribe(activation_subscription); + if (bus && !session_handle.empty()) { + g_dbus_connection_call(bus, kPortalName, session_handle.c_str(), + kSessionInterface, "Close", nullptr, nullptr, + G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, nullptr, + nullptr); + session_handle.clear(); + } + } + callback(std::move(result)); + } + + void finish_response(std::uint32_t response, char const* operation) { + if (response == 1) { + finish({GlobalShortcutBindStatus::cancelled, {}, + std::string(operation) + " was cancelled by the user"}); + } else { + finish({GlobalShortcutBindStatus::failed, {}, + std::string(operation) + " failed in the desktop portal"}); + } + } + + void method_failed(CallStage stage, GError* error) { + std::string message = take_error_message( + stage == CallStage::create_session + ? "global shortcut session creation failed" + : "global shortcut binding failed", + error); + if (closing || (stage == CallStage::create_session && create_finished) || + (stage == CallStage::bind_shortcuts && bind_finished)) { + return; + } + if (stage == CallStage::create_session) { + unsubscribe(create_subscription); + } else { + unsubscribe(bind_subscription); + } + finish({GlobalShortcutBindStatus::failed, {}, std::move(message)}); + } + + static void call_returned(GObject* source, GAsyncResult* result, + gpointer data) { + std::unique_ptr call(static_cast(data)); + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_finish( + G_DBUS_CONNECTION(source), result, &error); + if (!reply) { + call->impl->method_failed(call->stage, error); + return; + } + g_variant_unref(reply); + } + + void start_create_session() { + std::string const handle_token = next_token(); + std::string const session_token = next_token(); + std::string const path = request_path(bus, handle_token); + create_subscription = g_dbus_connection_signal_subscribe( + bus, kPortalName, kRequestInterface, "Response", path.c_str(), nullptr, + G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE, &Impl::session_created, this, + nullptr); + + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&options, "{sv}", "handle_token", + g_variant_new_string(handle_token.c_str())); + g_variant_builder_add(&options, "{sv}", "session_handle_token", + g_variant_new_string(session_token.c_str())); + g_dbus_connection_call( + bus, kPortalName, kPortalPath, kGlobalShortcutsInterface, + "CreateSession", g_variant_new("(a{sv})", &options), + G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, -1, cancellable, + &Impl::call_returned, + pending(shared_from_this(), CallStage::create_session)); + } + + static void session_created(GDBusConnection*, gchar const*, gchar const*, + gchar const*, gchar const*, GVariant* parameters, + gpointer data) { + Impl* impl = static_cast(data); + if (impl->closing) return; + impl->create_finished = true; + impl->unsubscribe(impl->create_subscription); + + guint32 response = 2; + GVariant* results = nullptr; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + if (response != 0) { + g_variant_unref(results); + impl->finish_response(response, "global shortcut session creation"); + return; + } + gchar const* handle = nullptr; + bool const found = + g_variant_lookup(results, "session_handle", "&s", &handle) && handle; + if (found) impl->session_handle = handle; + g_variant_unref(results); + if (!found || impl->session_handle.empty()) { + impl->finish({GlobalShortcutBindStatus::failed, {}, + "global shortcut portal returned no session handle"}); + return; + } + + impl->activation_subscription = g_dbus_connection_signal_subscribe( + impl->bus, kPortalName, kGlobalShortcutsInterface, "Activated", + kPortalPath, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, + &Impl::shortcut_activated, impl, nullptr); + impl->start_bind_shortcuts(); + } + + void start_bind_shortcuts() { + std::string const handle_token = next_token(); + std::string const path = request_path(bus, handle_token); + bind_subscription = g_dbus_connection_signal_subscribe( + bus, kPortalName, kRequestInterface, "Response", path.c_str(), nullptr, + G_DBUS_SIGNAL_FLAGS_NO_MATCH_RULE, &Impl::shortcuts_bound, this, + nullptr); + + GVariantBuilder shortcut_values; + g_variant_builder_init(&shortcut_values, G_VARIANT_TYPE("a(sa{sv})")); + for (GlobalShortcutSpec const& shortcut : shortcuts) { + GVariantBuilder properties; + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&properties, "{sv}", "description", + g_variant_new_string(shortcut.description.c_str())); + if (!shortcut.preferred_trigger.empty()) { + g_variant_builder_add( + &properties, "{sv}", "preferred_trigger", + g_variant_new_string(shortcut.preferred_trigger.c_str())); + } + g_variant_builder_add(&shortcut_values, "(s@a{sv})", + shortcut.id.c_str(), + g_variant_builder_end(&properties)); + } + + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + g_variant_builder_add(&options, "{sv}", "handle_token", + g_variant_new_string(handle_token.c_str())); + g_dbus_connection_call( + bus, kPortalName, kPortalPath, kGlobalShortcutsInterface, + "BindShortcuts", + g_variant_new("(o@a(sa{sv})s@a{sv})", session_handle.c_str(), + g_variant_builder_end(&shortcut_values), + parent_window.c_str(), g_variant_builder_end(&options)), + G_VARIANT_TYPE("(o)"), G_DBUS_CALL_FLAGS_NONE, -1, cancellable, + &Impl::call_returned, + pending(shared_from_this(), CallStage::bind_shortcuts)); + } + + static void shortcuts_bound(GDBusConnection*, gchar const*, gchar const*, + gchar const*, gchar const*, GVariant* parameters, + gpointer data) { + Impl* impl = static_cast(data); + if (impl->closing) return; + impl->bind_finished = true; + impl->unsubscribe(impl->bind_subscription); + + guint32 response = 2; + GVariant* results = nullptr; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + if (response != 0) { + g_variant_unref(results); + impl->finish_response(response, "global shortcut binding"); + return; + } + + GVariant* values = g_variant_lookup_value( + results, "shortcuts", G_VARIANT_TYPE("a(sa{sv})")); + g_variant_unref(results); + if (!values) { + impl->finish({GlobalShortcutBindStatus::failed, {}, + "global shortcut portal returned no binding list"}); + return; + } + + std::vector bindings; + GVariantIter iterator; + g_variant_iter_init(&iterator, values); + gchar const* id = nullptr; + GVariant* properties = nullptr; + while (g_variant_iter_next(&iterator, "(&s@a{sv})", &id, &properties)) { + gchar const* description = ""; + gchar const* trigger = ""; + g_variant_lookup(properties, "description", "&s", &description); + g_variant_lookup(properties, "trigger_description", "&s", &trigger); + bindings.push_back({id ? id : "", description ? description : "", + trigger ? trigger : ""}); + g_variant_unref(properties); + } + g_variant_unref(values); + impl->finish({GlobalShortcutBindStatus::bound, std::move(bindings), {}}); + } + + static void shortcut_activated(GDBusConnection*, gchar const*, gchar const*, + gchar const*, gchar const*, + GVariant* parameters, gpointer data) { + Impl* impl = static_cast(data); + if (impl->closing || !impl->activation_handler) return; + gchar const* session = nullptr; + gchar const* shortcut_id = nullptr; + guint64 timestamp = 0; + GVariant* options = nullptr; + g_variant_get(parameters, "(&o&st@a{sv})", &session, &shortcut_id, + ×tamp, &options); + if (!session || impl->session_handle != session) { + g_variant_unref(options); + return; + } + gchar const* token = ""; + g_variant_lookup(options, "activation_token", "&s", &token); + try { + impl->activation_handler(shortcut_id ? shortcut_id : "", timestamp, + token ? token : ""); + } catch (...) { + // A host callback must not unwind through GLib's C signal dispatcher. + } + g_variant_unref(options); + } +}; + +GlobalShortcutSupport GlobalShortcuts::support() { + GDBusConnection* bus = session_bus(); + if (!bus) { + return {false, 0, "no D-Bus session bus is reachable"}; + } + + GError* error = nullptr; + GVariant* reply = g_dbus_connection_call_sync( + bus, kPortalName, kPortalPath, "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", kGlobalShortcutsInterface, "version"), + G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 1000, nullptr, &error); + if (!reply) { + return {false, 0, + take_error_message("GlobalShortcuts portal is unavailable", error)}; + } + GVariant* boxed = nullptr; + g_variant_get(reply, "(@v)", &boxed); + GVariant* value = g_variant_get_variant(boxed); + if (!g_variant_is_of_type(value, G_VARIANT_TYPE_UINT32)) { + g_variant_unref(value); + g_variant_unref(boxed); + g_variant_unref(reply); + return {false, 0, + "GlobalShortcuts portal returned a non-integer version"}; + } + std::uint32_t const version = g_variant_get_uint32(value); + g_variant_unref(value); + g_variant_unref(boxed); + g_variant_unref(reply); + if (version == 0) { + return {false, 0, "GlobalShortcuts portal reported version 0"}; + } + return {true, version, {}}; +} + +GlobalShortcuts::GlobalShortcuts() + : impl_(std::make_shared(session_bus())) {} + +GlobalShortcuts::~GlobalShortcuts() { + if (impl_) impl_->close(); +} + +void GlobalShortcuts::bind(std::vector shortcuts, + std::string parent_window, + BindHandler completion) { + if (!completion) { + throw std::invalid_argument("global shortcut completion must not be empty"); + } + if (impl_->bind_started) { + throw std::logic_error( + "a GlobalShortcuts object can bind shortcuts only once"); + } + if (shortcuts.empty()) { + throw std::invalid_argument("at least one global shortcut is required"); + } + std::unordered_set ids; + for (GlobalShortcutSpec const& shortcut : shortcuts) { + if (shortcut.id.empty() || contains_nul(shortcut.id)) { + throw std::invalid_argument( + "global shortcut ids must be non-empty D-Bus strings"); + } + if (!ids.insert(shortcut.id).second) { + throw std::invalid_argument("global shortcut ids must be unique"); + } + if (shortcut.description.empty() || contains_nul(shortcut.description) || + contains_nul(shortcut.preferred_trigger)) { + throw std::invalid_argument( + "global shortcut descriptions must be non-empty D-Bus strings"); + } + } + if (contains_nul(parent_window)) { + throw std::invalid_argument( + "global shortcut parent window must be a D-Bus string"); + } + + impl_->bind_started = true; + impl_->shortcuts = std::move(shortcuts); + impl_->parent_window = std::move(parent_window); + impl_->completion = std::move(completion); + + GlobalShortcutSupport const detected = support(); + if (!detected.available) { + impl_->finish({GlobalShortcutBindStatus::unavailable, {}, detected.reason}); + return; + } + impl_->start_create_session(); +} + +void GlobalShortcuts::set_activation_handler(ActivationHandler handler) { + impl_->activation_handler = std::move(handler); +} + +} // namespace rivet::system diff --git a/platform/linux/system/system_services.cpp b/platform/linux/system/system_services.cpp index c077022..852a5de 100644 --- a/platform/linux/system/system_services.cpp +++ b/platform/linux/system/system_services.cpp @@ -80,6 +80,7 @@ bool secrets_service_available() { std::vector Capabilities() { std::vector capabilities{"single-instance"}; + if (GlobalShortcuts::available()) capabilities.push_back("global-shortcuts"); if (Notifications::available()) capabilities.push_back("notification"); if (TrayIcon::available()) capabilities.push_back("tray"); capabilities.push_back("autostart"); diff --git a/platform/linux/system/system_services.hpp b/platform/linux/system/system_services.hpp index 66e5121..676b667 100644 --- a/platform/linux/system/system_services.hpp +++ b/platform/linux/system/system_services.hpp @@ -19,7 +19,8 @@ namespace rivet::system { // Runtime capability names, matching the Racket system-adapter contract: -// single-instance, notification, tray, autostart, secure-storage, crash-hook. +// single-instance, global-shortcuts, 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 @@ -93,6 +94,75 @@ class Notifications final { static void CloseTag(std::string const& tag); }; +// One shortcut offered to org.freedesktop.portal.GlobalShortcuts. The trigger +// follows the XDG Shortcuts specification (for example, "ALT+space"). The +// compositor owns the final binding and may let the user replace or reject it. +struct GlobalShortcutSpec final { + std::string id; + std::string description; + std::string preferred_trigger; +}; + +struct GlobalShortcutBinding final { + std::string id; + std::string description; + std::string trigger_description; +}; + +enum class GlobalShortcutBindStatus { + bound, + cancelled, + unavailable, + failed, +}; + +struct GlobalShortcutBindResult final { + GlobalShortcutBindStatus status = GlobalShortcutBindStatus::failed; + std::vector bindings; + std::string message; +}; + +struct GlobalShortcutSupport final { + bool available = false; + std::uint32_t portal_version = 0; + std::string reason; +}; + +// Wayland-safe global shortcuts through the XDG desktop portal. There is no +// XGrabKey fallback: when the portal is absent, support() and bind() report +// that honestly so the application can explain its compositor-command +// fallback. Construct, bind, and destroy this object on the same GLib main +// context as the GTK host. Callbacks run on that context. +class GlobalShortcuts final { + public: + using BindHandler = std::function; + using ActivationHandler = + std::function; + + static GlobalShortcutSupport support(); + static bool available() { return support().available; } + + GlobalShortcuts(); + ~GlobalShortcuts(); + GlobalShortcuts(GlobalShortcuts const&) = delete; + GlobalShortcuts& operator=(GlobalShortcuts const&) = delete; + + // Creates one portal session and offers all shortcuts in one compositor + // prompt. A portal session permits BindShortcuts only once, so bind() also + // permits one call per object. `parent_window` is an XDG portal window + // identifier (or empty for an unparented prompt). Completion runs exactly + // once unless the object is destroyed first. + void bind(std::vector shortcuts, + std::string parent_window, BindHandler completion); + void set_activation_handler(ActivationHandler handler); + + private: + struct Impl; + std::shared_ptr impl_; +}; + // 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, From 5a39c2654f86493dcd3dc34ff9ff4d12d2079380 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Mon, 12 Oct 2026 19:24:59 +0800 Subject: [PATCH 2/4] ci: check out scripts in AppImage gate --- .github/workflows/linux-installers.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/linux-installers.yml b/.github/workflows/linux-installers.yml index 2167c9c..59dbff5 100644 --- a/.github/workflows/linux-installers.yml +++ b/.github/workflows/linux-installers.yml @@ -120,6 +120,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 20 steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: linux-native-installers From ef29ee61b417974c5d6011a93e6763e7bbac9997 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Mon, 12 Oct 2026 19:26:01 +0800 Subject: [PATCH 3/4] test(linux): harden portal callback boundaries --- platform/linux/Integration/global_shortcuts_test.cpp | 4 +--- platform/linux/system/global_shortcuts.cpp | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/platform/linux/Integration/global_shortcuts_test.cpp b/platform/linux/Integration/global_shortcuts_test.cpp index 40b0248..8d201aa 100644 --- a/platform/linux/Integration/global_shortcuts_test.cpp +++ b/platform/linux/Integration/global_shortcuts_test.cpp @@ -1,9 +1,6 @@ -#include -#include #include #include #include -#include #include #include #include @@ -142,6 +139,7 @@ class MockGlobalShortcutsPortal final { GVariant* options = nullptr; g_variant_get(parameters, "(&o@a(sa{sv})&s@a{sv})", &session, &shortcuts, &parent_window, &options); + (void)parent_window; std::string const token = option_string(options, "handle_token"); g_variant_unref(options); if (!session || std::string(session) != kSessionPath || token.empty()) { diff --git a/platform/linux/system/global_shortcuts.cpp b/platform/linux/system/global_shortcuts.cpp index c3b4240..5460e5f 100644 --- a/platform/linux/system/global_shortcuts.cpp +++ b/platform/linux/system/global_shortcuts.cpp @@ -136,7 +136,11 @@ struct GlobalShortcuts::Impl final session_handle.clear(); } } - callback(std::move(result)); + try { + callback(std::move(result)); + } catch (...) { + // A host callback must not unwind through GLib's C signal dispatcher. + } } void finish_response(std::uint32_t response, char const* operation) { From c8a2f26f29ea6590eea3446da6bf894d549097f5 Mon Sep 17 00:00:00 2001 From: "ren.ji" Date: Mon, 12 Oct 2026 19:28:18 +0800 Subject: [PATCH 4/4] fix(linux): keep shortcut binding nonblocking --- platform/linux/system/global_shortcuts.cpp | 79 +++++++++++++++------- platform/linux/system/system_services.hpp | 11 +-- 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/platform/linux/system/global_shortcuts.cpp b/platform/linux/system/global_shortcuts.cpp index 5460e5f..a20304c 100644 --- a/platform/linux/system/global_shortcuts.cpp +++ b/platform/linux/system/global_shortcuts.cpp @@ -61,11 +61,30 @@ bool contains_nul(std::string const& value) { return value.find('\0') != std::string::npos; } +GlobalShortcutSupport parse_support_reply(GVariant* reply) { + GVariant* boxed = nullptr; + g_variant_get(reply, "(@v)", &boxed); + GVariant* value = g_variant_get_variant(boxed); + if (!g_variant_is_of_type(value, G_VARIANT_TYPE_UINT32)) { + g_variant_unref(value); + g_variant_unref(boxed); + return {false, 0, + "GlobalShortcuts portal returned a non-integer version"}; + } + std::uint32_t const version = g_variant_get_uint32(value); + g_variant_unref(value); + g_variant_unref(boxed); + if (version == 0) { + return {false, 0, "GlobalShortcuts portal reported version 0"}; + } + return {true, version, {}}; +} + } // namespace struct GlobalShortcuts::Impl final : std::enable_shared_from_this { - enum class CallStage { create_session, bind_shortcuts }; + enum class CallStage { detect_support, create_session, bind_shortcuts }; struct PendingCall final { std::shared_ptr impl; @@ -178,12 +197,44 @@ struct GlobalShortcuts::Impl final GVariant* reply = g_dbus_connection_call_finish( G_DBUS_CONNECTION(source), result, &error); if (!reply) { + if (call->stage == CallStage::detect_support) { + call->impl->finish( + {GlobalShortcutBindStatus::unavailable, {}, + take_error_message("GlobalShortcuts portal is unavailable", + error)}); + return; + } call->impl->method_failed(call->stage, error); return; } + if (call->stage == CallStage::detect_support) { + GlobalShortcutSupport const detected = parse_support_reply(reply); + g_variant_unref(reply); + if (!detected.available) { + call->impl->finish({GlobalShortcutBindStatus::unavailable, {}, + detected.reason}); + } else if (!call->impl->closing) { + call->impl->start_create_session(); + } + return; + } g_variant_unref(reply); } + void start_support_check() { + if (!bus) { + finish({GlobalShortcutBindStatus::unavailable, {}, + "no D-Bus session bus is reachable"}); + return; + } + g_dbus_connection_call( + bus, kPortalName, kPortalPath, "org.freedesktop.DBus.Properties", "Get", + g_variant_new("(ss)", kGlobalShortcutsInterface, "version"), + G_VARIANT_TYPE("(v)"), G_DBUS_CALL_FLAGS_NONE, 1000, cancellable, + &Impl::call_returned, + pending(shared_from_this(), CallStage::detect_support)); + } + void start_create_session() { std::string const handle_token = next_token(); std::string const session_token = next_token(); @@ -367,24 +418,9 @@ GlobalShortcutSupport GlobalShortcuts::support() { return {false, 0, take_error_message("GlobalShortcuts portal is unavailable", error)}; } - GVariant* boxed = nullptr; - g_variant_get(reply, "(@v)", &boxed); - GVariant* value = g_variant_get_variant(boxed); - if (!g_variant_is_of_type(value, G_VARIANT_TYPE_UINT32)) { - g_variant_unref(value); - g_variant_unref(boxed); - g_variant_unref(reply); - return {false, 0, - "GlobalShortcuts portal returned a non-integer version"}; - } - std::uint32_t const version = g_variant_get_uint32(value); - g_variant_unref(value); - g_variant_unref(boxed); + GlobalShortcutSupport const detected = parse_support_reply(reply); g_variant_unref(reply); - if (version == 0) { - return {false, 0, "GlobalShortcuts portal reported version 0"}; - } - return {true, version, {}}; + return detected; } GlobalShortcuts::GlobalShortcuts() @@ -432,12 +468,7 @@ void GlobalShortcuts::bind(std::vector shortcuts, impl_->parent_window = std::move(parent_window); impl_->completion = std::move(completion); - GlobalShortcutSupport const detected = support(); - if (!detected.available) { - impl_->finish({GlobalShortcutBindStatus::unavailable, {}, detected.reason}); - return; - } - impl_->start_create_session(); + impl_->start_support_check(); } void GlobalShortcuts::set_activation_handler(ActivationHandler handler) { diff --git a/platform/linux/system/system_services.hpp b/platform/linux/system/system_services.hpp index 676b667..0502648 100644 --- a/platform/linux/system/system_services.hpp +++ b/platform/linux/system/system_services.hpp @@ -149,11 +149,12 @@ class GlobalShortcuts final { GlobalShortcuts(GlobalShortcuts const&) = delete; GlobalShortcuts& operator=(GlobalShortcuts const&) = delete; - // Creates one portal session and offers all shortcuts in one compositor - // prompt. A portal session permits BindShortcuts only once, so bind() also - // permits one call per object. `parent_window` is an XDG portal window - // identifier (or empty for an unparented prompt). Completion runs exactly - // once unless the object is destroyed first. + // Non-blocking: discovers the portal, creates one session, and offers all + // shortcuts in one compositor prompt. A portal session permits + // BindShortcuts only once, so bind() also permits one call per object. + // `parent_window` is an XDG portal window identifier (or empty for an + // unparented prompt). Completion runs exactly once unless the object is + // destroyed first. void bind(std::vector shortcuts, std::string parent_window, BindHandler completion); void set_activation_handler(ActivationHandler handler);