diff --git a/README.md b/README.md index 900d7d8..7404246 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ dependencies: #### Linux requirements - [`keybinder-3.0`](https://github.com/kupferlauncher/keybinder) +- [`xdg-desktop-portal`](https://flatpak.github.io/xdg-desktop-portal/) with + GlobalShortcuts support for Wayland sessions Run the following command @@ -75,6 +77,25 @@ Run the following command sudo apt-get install keybinder-3.0 ``` +On X11, Linux global shortcuts are registered through `keybinder-3.0`. On +Wayland, they are registered through the desktop portal, so the compositor may +show a permission dialog the first time shortcuts are bound. Portal support for +`capsLock` and `fn` modifiers depends on the compositor and may be unavailable. +Give each system-scoped `HotKey` a stable, semantic `identifier` (for example, +`toggle-mute`) and reuse it across application launches. The portal owns the +saved binding and the plugin queries it on startup; applications only need to +persist their own `HotKey` definitions when those definitions are user-created. +The key combination is only a preference when an identifier is first bound; +later changes must be made through the desktop's shortcut configuration UI. +Adding a new identifier may show the portal dialog again. Wayland portals do +not provide a portable API for removing one saved shortcut, so `unregister` +only stops handling it in the current application session. +Recent versions of `xdg-desktop-portal` also require host applications to have +an installed `.desktop` file whose basename matches the Linux `application-id` +(for example, `com.example.MyApp.desktop` for `com.example.MyApp`). Compositors +without a GlobalShortcuts portal backend, such as some Niri/wlroots setups, will +still reject Wayland global shortcut registration. + ### Usage ```dart @@ -105,7 +126,8 @@ await hotKeyManager.register( keyDownHandler: (hotKey) { print('onKeyDown+${hotKey.toJson()}'); }, - // Only works on macOS. + // Only works on macOS and on Linux Wayland sessions that support the + // GlobalShortcuts portal. keyUpHandler: (hotKey){ print('onKeyUp+${hotKey.toJson()}'); } , diff --git a/packages/hotkey_manager/example/pubspec.yaml b/packages/hotkey_manager/example/pubspec.yaml index f2dbc2b..48b1dd3 100644 --- a/packages/hotkey_manager/example/pubspec.yaml +++ b/packages/hotkey_manager/example/pubspec.yaml @@ -18,5 +18,15 @@ dev_dependencies: sdk: flutter mostly_reasonable_lints: ^0.1.1 +dependency_overrides: + hotkey_manager_linux: + path: ../../hotkey_manager_linux + hotkey_manager_macos: + path: ../../hotkey_manager_macos + hotkey_manager_platform_interface: + path: ../../hotkey_manager_platform_interface + hotkey_manager_windows: + path: ../../hotkey_manager_windows + flutter: uses-material-design: true diff --git a/packages/hotkey_manager_linux/README.md b/packages/hotkey_manager_linux/README.md index 0a32d2c..188652f 100644 --- a/packages/hotkey_manager_linux/README.md +++ b/packages/hotkey_manager_linux/README.md @@ -7,6 +7,36 @@ The Linux implementation of [hotkey_manager](https://pub.dev/packages/hotkey_manager). +## Backends + +On X11, global shortcuts are registered with +[`keybinder-3.0`](https://github.com/kupferlauncher/keybinder). + +On Wayland, global shortcuts are registered with the +[`org.freedesktop.portal.GlobalShortcuts`](https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.GlobalShortcuts.html) +desktop portal. The compositor may show a permission dialog the first time +shortcuts are bound. The portal backend emits both `onKeyDown` and `onKeyUp` +events when the compositor sends activation and deactivation signals. + +Applications should reuse a stable, semantic `HotKey.identifier` for each +system shortcut across launches. The backend queries `ListShortcuts` and reuses +portal-owned bindings when all requested identifiers already exist; it calls +`BindShortcuts` only when an identifier is missing. Adding a shortcut can +therefore show the portal dialog again. A requested key combination is only a +preference for a new binding; change an existing binding through the desktop's +shortcut configuration UI. The portal has no portable API for removing one +persisted shortcut, so unregistering only stops handling that shortcut in the +current application session. + +Recent versions of `xdg-desktop-portal` require host applications to have an +installed `.desktop` file whose basename matches the Linux `application-id` +(for example, `com.example.MyApp.desktop` for `com.example.MyApp`). Wayland +compositors without a GlobalShortcuts portal backend, such as some Niri/wlroots +setups, will still reject global shortcut registration. + +The Wayland portal does not have portable equivalents for the `capsLock` and +`fn` modifiers, so those modifiers may be ignored by the compositor. + ## License [MIT](./LICENSE) diff --git a/packages/hotkey_manager_linux/linux/CMakeLists.txt b/packages/hotkey_manager_linux/linux/CMakeLists.txt index 9edffd0..96c5825 100644 --- a/packages/hotkey_manager_linux/linux/CMakeLists.txt +++ b/packages/hotkey_manager_linux/linux/CMakeLists.txt @@ -13,7 +13,10 @@ set(PLUGIN_NAME "hotkey_manager_linux_plugin") # Any new source files that you add to the plugin should be added here. list(APPEND PLUGIN_SOURCES + "hotkey_manager_keybinder_backend.cc" + "hotkey_manager_linux_backend.cc" "hotkey_manager_linux_plugin.cc" + "hotkey_manager_portal_backend.cc" ) # Define the plugin library target. Its name must not be changed (see comment @@ -40,7 +43,6 @@ target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) -target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::KEYBINDER) pkg_check_modules(KEYBINDER IMPORTED_TARGET keybinder-3.0) if(KEYBINDER_FOUND) @@ -96,6 +98,7 @@ apply_standard_settings(${TEST_RUNNER}) target_include_directories(${TEST_RUNNER} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(${TEST_RUNNER} PRIVATE flutter) target_link_libraries(${TEST_RUNNER} PRIVATE PkgConfig::GTK) +target_link_libraries(${TEST_RUNNER} PRIVATE PkgConfig::KEYBINDER) target_link_libraries(${TEST_RUNNER} PRIVATE gtest_main gmock) # Enable automatic test discovery. diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.cc b/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.cc new file mode 100644 index 0000000..ef00a7f --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.cc @@ -0,0 +1,127 @@ +#include "hotkey_manager_keybinder_backend.h" + +#include +#include +#include + +#include +#include +#include + +namespace { + +HotkeyManagerKeybinderBackend* keybinder_backend_instance = nullptr; + +void HandleKeyDownCallback(const char* keystring, void* user_data) { + if (keybinder_backend_instance != nullptr) { + keybinder_backend_instance->HandleKeyDown(keystring); + } +} + +guint GetMods(const std::vector& modifiers) { + guint mods = 0; + for (const std::string& modifier : modifiers) { + guint mod = 0; + if (modifier == "alt") + mod = GDK_MOD1_MASK; + else if (modifier == "capsLock") + mod = GDK_LOCK_MASK; + else if (modifier == "control") + mod = GDK_CONTROL_MASK; + else if (modifier == "meta") + mod = GDK_META_MASK; + else if (modifier == "shift") + mod = GDK_SHIFT_MASK; + mods = mods | mod; + } + return mods; +} + +} // namespace + +HotkeyManagerKeybinderBackend::HotkeyManagerKeybinderBackend( + FlEventChannel* event_channel) + : event_channel_(event_channel) { + keybinder_backend_instance = this; + keybinder_init(); +} + +HotkeyManagerKeybinderBackend::~HotkeyManagerKeybinderBackend() { + UnbindAll(); + if (keybinder_backend_instance == this) { + keybinder_backend_instance = nullptr; + } +} + +FlMethodResponse* HotkeyManagerKeybinderBackend::Register(FlValue* args) { + HotkeyDefinition hotkey; + std::string error_message; + if (!ParseHotkeyDefinition(args, &hotkey, &error_message)) { + return ErrorResponse("bad-arguments", error_message); + } + + g_autofree gchar* keystring = gtk_accelerator_name( + hotkey.key_code, static_cast(GetMods(hotkey.modifiers))); + if (keystring == nullptr || keystring[0] == '\0') { + return ErrorResponse("invalid-hotkey", "Unable to create GTK accelerator."); + } + + auto existing = hotkey_id_map_.find(hotkey.identifier); + if (existing != hotkey_id_map_.end()) { + keybinder_unbind(existing->second.c_str(), HandleKeyDownCallback); + hotkey_id_map_.erase(existing); + } + + if (!keybinder_bind(keystring, HandleKeyDownCallback, nullptr)) { + return ErrorResponse("register-failed", "Unable to bind global hotkey."); + } + + hotkey_id_map_.insert({hotkey.identifier, keystring}); + return SuccessResponse(); +} + +FlMethodResponse* HotkeyManagerKeybinderBackend::Unregister(FlValue* args) { + FlValue* identifier_value = fl_value_lookup_string(args, "identifier"); + if (identifier_value == nullptr || + fl_value_get_type(identifier_value) != FL_VALUE_TYPE_STRING) { + return ErrorResponse("bad-arguments", "Expected a string identifier."); + } + + std::string identifier = fl_value_get_string(identifier_value); + auto existing = hotkey_id_map_.find(identifier); + if (existing != hotkey_id_map_.end()) { + keybinder_unbind(existing->second.c_str(), HandleKeyDownCallback); + hotkey_id_map_.erase(existing); + } + + return SuccessResponse(); +} + +FlMethodResponse* HotkeyManagerKeybinderBackend::UnregisterAll() { + UnbindAll(); + return SuccessResponse(); +} + +void HotkeyManagerKeybinderBackend::HandleKeyDown(const char* keystring) { + if (keystring == nullptr) { + return; + } + + std::string accelerator = keystring; + auto result = std::find_if( + hotkey_id_map_.begin(), hotkey_id_map_.end(), + [accelerator](const auto& hotkey) { return hotkey.second == accelerator; }); + + if (result == hotkey_id_map_.end()) { + return; + } + + SendHotkeyEvent(event_channel_, "onKeyDown", result->first); +} + +void HotkeyManagerKeybinderBackend::UnbindAll() { + for (const auto& hotkey : hotkey_id_map_) { + keybinder_unbind(hotkey.second.c_str(), HandleKeyDownCallback); + } + hotkey_id_map_.clear(); +} diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.h b/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.h new file mode 100644 index 0000000..4e53a41 --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_keybinder_backend.h @@ -0,0 +1,28 @@ +#ifndef FLUTTER_PLUGIN_HOTKEY_MANAGER_KEYBINDER_BACKEND_H_ +#define FLUTTER_PLUGIN_HOTKEY_MANAGER_KEYBINDER_BACKEND_H_ + +#include + +#include +#include + +#include "hotkey_manager_linux_backend.h" + +class HotkeyManagerKeybinderBackend : public HotkeyManagerLinuxBackend { + public: + explicit HotkeyManagerKeybinderBackend(FlEventChannel* event_channel); + ~HotkeyManagerKeybinderBackend() override; + + FlMethodResponse* Register(FlValue* args) override; + FlMethodResponse* Unregister(FlValue* args) override; + FlMethodResponse* UnregisterAll() override; + void HandleKeyDown(const char* keystring); + + private: + void UnbindAll(); + + FlEventChannel* event_channel_; + std::map hotkey_id_map_; +}; + +#endif // FLUTTER_PLUGIN_HOTKEY_MANAGER_KEYBINDER_BACKEND_H_ diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.cc b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.cc new file mode 100644 index 0000000..2728a4c --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.cc @@ -0,0 +1,74 @@ +#include "hotkey_manager_linux_backend.h" + +bool ParseHotkeyDefinition(FlValue* args, + HotkeyDefinition* hotkey, + std::string* error_message) { + if (args == nullptr || fl_value_get_type(args) != FL_VALUE_TYPE_MAP) { + *error_message = "Expected hotkey arguments."; + return false; + } + + FlValue* identifier_value = fl_value_lookup_string(args, "identifier"); + FlValue* key_code_value = fl_value_lookup_string(args, "keyCode"); + FlValue* modifiers_value = fl_value_lookup_string(args, "modifiers"); + + if (identifier_value == nullptr || + fl_value_get_type(identifier_value) != FL_VALUE_TYPE_STRING) { + *error_message = "Expected a string identifier."; + return false; + } + if (key_code_value == nullptr || + fl_value_get_type(key_code_value) != FL_VALUE_TYPE_INT) { + *error_message = "Expected an integer keyCode."; + return false; + } + if (modifiers_value == nullptr || + fl_value_get_type(modifiers_value) != FL_VALUE_TYPE_LIST) { + *error_message = "Expected a modifiers list."; + return false; + } + + hotkey->identifier = fl_value_get_string(identifier_value); + hotkey->key_code = static_cast(fl_value_get_int(key_code_value)); + hotkey->modifiers.clear(); + + for (gint i = 0; i < fl_value_get_length(modifiers_value); i++) { + FlValue* modifier_value = fl_value_get_list_value(modifiers_value, i); + if (modifier_value == nullptr || + fl_value_get_type(modifier_value) != FL_VALUE_TYPE_STRING) { + *error_message = "Expected string modifiers."; + return false; + } + hotkey->modifiers.push_back(fl_value_get_string(modifier_value)); + } + + return true; +} + +FlMethodResponse* SuccessResponse() { + return FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_bool(true))); +} + +FlMethodResponse* ErrorResponse(const char* code, const std::string& message) { + return FL_METHOD_RESPONSE( + fl_method_error_response_new(code, message.c_str(), nullptr)); +} + +void SendHotkeyEvent(FlEventChannel* event_channel, + const char* type, + const std::string& identifier) { + if (event_channel == nullptr || identifier.empty()) { + return; + } + + FlValue* event_data = fl_value_new_map(); + fl_value_set_string_take(event_data, "identifier", + fl_value_new_string(identifier.c_str())); + + g_autoptr(FlValue) event = fl_value_new_map(); + fl_value_set_string_take(event, "type", fl_value_new_string(type)); + fl_value_set_string_take(event, "data", event_data); + + fl_event_channel_send(event_channel, event, nullptr, nullptr); +} diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.h b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.h new file mode 100644 index 0000000..3235764 --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_backend.h @@ -0,0 +1,34 @@ +#ifndef FLUTTER_PLUGIN_HOTKEY_MANAGER_LINUX_BACKEND_H_ +#define FLUTTER_PLUGIN_HOTKEY_MANAGER_LINUX_BACKEND_H_ + +#include +#include + +#include +#include + +struct HotkeyDefinition { + std::string identifier; + guint key_code; + std::vector modifiers; +}; + +class HotkeyManagerLinuxBackend { + public: + virtual ~HotkeyManagerLinuxBackend() = default; + + virtual FlMethodResponse* Register(FlValue* args) = 0; + virtual FlMethodResponse* Unregister(FlValue* args) = 0; + virtual FlMethodResponse* UnregisterAll() = 0; +}; + +bool ParseHotkeyDefinition(FlValue* args, + HotkeyDefinition* hotkey, + std::string* error_message); +FlMethodResponse* SuccessResponse(); +FlMethodResponse* ErrorResponse(const char* code, const std::string& message); +void SendHotkeyEvent(FlEventChannel* event_channel, + const char* type, + const std::string& identifier); + +#endif // FLUTTER_PLUGIN_HOTKEY_MANAGER_LINUX_BACKEND_H_ diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_linux_plugin.cc b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_plugin.cc index 74fabe2..17ddb99 100644 --- a/packages/hotkey_manager_linux/linux/hotkey_manager_linux_plugin.cc +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_linux_plugin.cc @@ -1,139 +1,34 @@ #include "include/hotkey_manager_linux/hotkey_manager_linux_plugin.h" #include -#include #include #include #include -#include - -#include -#include -#include -#include +#ifdef GDK_WINDOWING_X11 +#include +#endif +#include "hotkey_manager_keybinder_backend.h" +#include "hotkey_manager_linux_backend.h" #include "hotkey_manager_linux_plugin_private.h" +#include "hotkey_manager_portal_backend.h" #define HOTKEY_MANAGER_LINUX_PLUGIN(obj) \ (G_TYPE_CHECK_INSTANCE_CAST((obj), hotkey_manager_linux_plugin_get_type(), \ HotkeyManagerLinuxPlugin)) -std::map hotkey_id_map; -FlEventChannel* event_channel; - struct _HotkeyManagerLinuxPlugin { GObject parent_instance; + FlEventChannel* event_channel; + HotkeyManagerLinuxBackend* backend; }; G_DEFINE_TYPE(HotkeyManagerLinuxPlugin, hotkey_manager_linux_plugin, g_object_get_type()) -void handle_key_down(const char* keystring, void* user_data) { - const char* identifier; - - std::string val = keystring; - auto result = std::find_if(hotkey_id_map.begin(), hotkey_id_map.end(), - [val](const auto& e) { return e.second == val; }); - - if (result != hotkey_id_map.end()) - identifier = result->first.c_str(); - - g_autoptr(FlValue) event_data = fl_value_new_map(); - fl_value_set_string_take(event_data, "identifier", - fl_value_new_string(identifier)); - - FlValue* event = fl_value_new_map(); - fl_value_set_string_take(event, "type", fl_value_new_string("onKeyDown")); - fl_value_set_string_take(event, "data", event_data); - - fl_event_channel_send(event_channel, event, nullptr, nullptr); -} - -guint get_mods(const std::vector& modifiers) { - guint mods = 0; - for (int i = 0; i < modifiers.size(); i++) { - guint mod = 0; - if (modifiers[i] == "alt") - mod = GDK_MOD1_MASK; - else if (modifiers[i] == "capsLock") - mod = GDK_LOCK_MASK; - else if (modifiers[i] == "control") - mod = GDK_CONTROL_MASK; - else if (modifiers[i] == "meta") - mod = GDK_META_MASK; - else if (modifiers[i] == "shift") - mod = GDK_SHIFT_MASK; - mods = mods | mod; - } - return mods; -} - -static FlMethodResponse* hkm_register(_HotkeyManagerLinuxPlugin* self, - FlValue* args) { - FlValue* modifiers_value = fl_value_lookup_string(args, "modifiers"); - - const char* identifier = - fl_value_get_string(fl_value_lookup_string(args, "identifier")); - const int key_code = - fl_value_get_int(fl_value_lookup_string(args, "keyCode")); - std::vector modifiers; - for (gint i = 0; i < fl_value_get_length(modifiers_value); i++) { - std::string keyModifier = - fl_value_get_string(fl_value_get_list_value(modifiers_value, i)); - modifiers.push_back(keyModifier); - } - - const char* keystring = - gtk_accelerator_name(key_code, (GdkModifierType)get_mods(modifiers)); - - hotkey_id_map.insert( - std::pair(identifier, keystring)); - - keybinder_init(); - keybinder_bind(keystring, handle_key_down, NULL); - - return FL_METHOD_RESPONSE( - fl_method_success_response_new(fl_value_new_bool(true))); -} - -static FlMethodResponse* hkm_unregister(_HotkeyManagerLinuxPlugin* self, - FlValue* args) { - const char* identifier = - fl_value_get_string(fl_value_lookup_string(args, "identifier")); - const char* keystring; - - std::string val = identifier; - auto result = std::find_if(hotkey_id_map.begin(), hotkey_id_map.end(), - [val](const auto& e) { return e.first == val; }); - - if (result != hotkey_id_map.end()) - keystring = result->second.c_str(); - - keybinder_unbind(keystring, handle_key_down); - hotkey_id_map.erase(identifier); - - return FL_METHOD_RESPONSE( - fl_method_success_response_new(fl_value_new_bool(true))); -} - -static FlMethodResponse* hkm_unregister_all(_HotkeyManagerLinuxPlugin* self, - FlValue* args) { - for (std::map::iterator it = hotkey_id_map.begin(); - it != hotkey_id_map.end(); ++it) { - std::string identifier = it->first; - const char* keystring = it->second.c_str(); - keybinder_unbind(keystring, handle_key_down); - } - - hotkey_id_map.clear(); - - return FL_METHOD_RESPONSE( - fl_method_success_response_new(fl_value_new_bool(true))); -} - // Called when a method call is received from Flutter. static void hotkey_manager_linux_plugin_handle_method_call( HotkeyManagerLinuxPlugin* self, @@ -144,11 +39,11 @@ static void hotkey_manager_linux_plugin_handle_method_call( FlValue* args = fl_method_call_get_args(method_call); if (strcmp(method, "register") == 0) { - response = hkm_register(self, args); + response = self->backend->Register(args); } else if (strcmp(method, "unregister") == 0) { - response = hkm_unregister(self, args); + response = self->backend->Unregister(args); } else if (strcmp(method, "unregisterAll") == 0) { - response = hkm_unregister_all(self, args); + response = self->backend->UnregisterAll(); } else { response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); } @@ -165,7 +60,10 @@ FlMethodResponse* get_platform_version() { } static void hotkey_manager_linux_plugin_dispose(GObject* object) { - g_clear_object(&event_channel); + HotkeyManagerLinuxPlugin* self = HOTKEY_MANAGER_LINUX_PLUGIN(object); + delete self->backend; + self->backend = nullptr; + g_clear_object(&self->event_channel); G_OBJECT_CLASS(hotkey_manager_linux_plugin_parent_class)->dispose(object); } @@ -176,6 +74,22 @@ static void hotkey_manager_linux_plugin_class_init( static void hotkey_manager_linux_plugin_init(HotkeyManagerLinuxPlugin* self) {} +static bool is_x11_display() { +#ifdef GDK_WINDOWING_X11 + GdkDisplay* display = gdk_display_get_default(); + return display != nullptr && GDK_IS_X11_DISPLAY(display); +#else + return false; +#endif +} + +static HotkeyManagerLinuxBackend* create_backend(FlEventChannel* event_channel) { + if (is_x11_display()) { + return new HotkeyManagerKeybinderBackend(event_channel); + } + return new HotkeyManagerPortalBackend(event_channel); +} + static void method_call_cb(FlMethodChannel* channel, FlMethodCall* method_call, gpointer user_data) { @@ -196,10 +110,11 @@ void hotkey_manager_linux_plugin_register_with_registrar( channel, method_call_cb, g_object_ref(plugin), g_object_unref); g_autoptr(FlStandardMethodCodec) event_codec = fl_standard_method_codec_new(); - event_channel = + plugin->event_channel = fl_event_channel_new(fl_plugin_registrar_get_messenger(registrar), "dev.leanflutter.plugins/hotkey_manager_event", FL_METHOD_CODEC(event_codec)); + plugin->backend = create_backend(plugin->event_channel); g_object_unref(plugin); } diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.cc b/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.cc new file mode 100644 index 0000000..af5a3f6 --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.cc @@ -0,0 +1,635 @@ +#include "hotkey_manager_portal_backend.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +constexpr char kPortalBusName[] = "org.freedesktop.portal.Desktop"; +constexpr char kPortalObjectPath[] = "/org/freedesktop/portal/desktop"; +constexpr char kGlobalShortcutsInterface[] = + "org.freedesktop.portal.GlobalShortcuts"; +constexpr char kHostRegistryInterface[] = "org.freedesktop.host.portal.Registry"; +constexpr char kRequestInterface[] = "org.freedesktop.portal.Request"; +constexpr char kSessionInterface[] = "org.freedesktop.portal.Session"; +constexpr guint kRebindDelayMs = 100; + +struct RequestContext { + HotkeyManagerPortalBackend* backend; + guint generation; + guint subscription_id; + PortalRequestKind kind; +}; + +gboolean RebindTimeoutCallback(gpointer user_data) { + return static_cast(user_data)->RebindNow(); +} + +void RequestResponseCallback(GDBusConnection* connection, + const gchar* sender_name, + const gchar* object_path, + const gchar* interface_name, + const gchar* signal_name, + GVariant* parameters, + gpointer user_data) { + guint32 response = 0; + GVariant* results = nullptr; + g_variant_get(parameters, "(u@a{sv})", &response, &results); + + RequestContext* context = static_cast(user_data); + switch (context->kind) { + case PortalRequestKind::create_session: + context->backend->HandleCreateSessionResponse(response, results, + context->generation); + break; + case PortalRequestKind::list_shortcuts: + context->backend->HandleListShortcutsResponse(response, results, + context->generation); + break; + case PortalRequestKind::bind_shortcuts: + context->backend->HandleBindShortcutsResponse(response, + context->generation); + break; + } + + context->backend->RemoveRequestSubscription(context->subscription_id); + g_dbus_connection_signal_unsubscribe(connection, context->subscription_id); + g_variant_unref(results); +} + +void ShortcutSignalCallback(GDBusConnection* connection, + const gchar* sender_name, + const gchar* object_path, + const gchar* interface_name, + const gchar* signal_name, + GVariant* parameters, + gpointer user_data) { + static_cast(user_data)->HandleShortcutSignal( + signal_name, parameters); +} + +std::string NextToken(const char* prefix) { + g_autofree gchar* uuid = g_uuid_string_random(); + std::string token = std::string(prefix) + "_"; + for (const gchar* character = uuid; *character != '\0'; character++) { + token.push_back(g_ascii_isalnum(*character) ? *character : '_'); + } + return token; +} + +void SubscribeRequestResponse(GDBusConnection* connection, + const std::string& request_handle, + HotkeyManagerPortalBackend* backend, + guint generation, + PortalRequestKind kind) { + RequestContext* context = + new RequestContext{backend, generation, 0, kind}; + context->subscription_id = g_dbus_connection_signal_subscribe( + connection, kPortalBusName, kRequestInterface, "Response", + request_handle.c_str(), nullptr, G_DBUS_SIGNAL_FLAGS_NONE, + RequestResponseCallback, context, + [](gpointer data) { delete static_cast(data); }); + + if (context->subscription_id == 0) { + delete context; + g_warning("Failed to subscribe to portal request response."); + return; + } + + backend->AddRequestSubscription(context->subscription_id); +} + +bool AddModifierToTrigger(const std::string& modifier, + std::vector* trigger_parts) { + if (modifier == "control") { + trigger_parts->push_back("CTRL"); + } else if (modifier == "alt") { + trigger_parts->push_back("ALT"); + } else if (modifier == "shift") { + trigger_parts->push_back("SHIFT"); + } else if (modifier == "meta") { + trigger_parts->push_back("LOGO"); + } else if (modifier == "capsLock" || modifier == "fn") { + g_warning("The Wayland GlobalShortcuts portal does not support the %s " + "modifier.", + modifier.c_str()); + return false; + } + + return true; +} + +std::string NormalizeKeyName(const gchar* key_name) { + if (key_name == nullptr) { + return ""; + } + + std::string normalized = key_name; + if (normalized.size() == 1) { + normalized[0] = static_cast( + std::tolower(static_cast(normalized[0]))); + } + return normalized; +} + +std::string JoinTriggerParts(const std::vector& trigger_parts) { + std::stringstream trigger; + for (size_t i = 0; i < trigger_parts.size(); i++) { + if (i > 0) { + trigger << "+"; + } + trigger << trigger_parts[i]; + } + return trigger.str(); +} + +} // namespace + +HotkeyManagerPortalBackend::HotkeyManagerPortalBackend( + FlEventChannel* event_channel) + : event_channel_(event_channel), + connection_(nullptr), + portal_(nullptr), + activated_subscription_id_(0), + deactivated_subscription_id_(0), + rebind_source_id_(0), + generation_(0) {} + +HotkeyManagerPortalBackend::~HotkeyManagerPortalBackend() { + if (rebind_source_id_ != 0) { + g_source_remove(rebind_source_id_); + rebind_source_id_ = 0; + } + UnsubscribeRequestSignals(); + UnsubscribeShortcutSignals(); + CloseSession(); + g_clear_object(&portal_); + g_clear_object(&connection_); +} + +FlMethodResponse* HotkeyManagerPortalBackend::Register(FlValue* args) { + HotkeyDefinition hotkey; + std::string error_message; + if (!ParseHotkeyDefinition(args, &hotkey, &error_message)) { + return ErrorResponse("bad-arguments", error_message); + } + if (!EnsurePortal(&error_message)) { + return ErrorResponse("portal-unavailable", error_message); + } + + hotkeys_[hotkey.identifier] = {hotkey.key_code, hotkey.modifiers}; + ScheduleRebind(); + return SuccessResponse(); +} + +FlMethodResponse* HotkeyManagerPortalBackend::Unregister(FlValue* args) { + FlValue* identifier_value = fl_value_lookup_string(args, "identifier"); + if (identifier_value == nullptr || + fl_value_get_type(identifier_value) != FL_VALUE_TYPE_STRING) { + return ErrorResponse("bad-arguments", "Expected a string identifier."); + } + + hotkeys_.erase(fl_value_get_string(identifier_value)); + ScheduleRebind(); + return SuccessResponse(); +} + +FlMethodResponse* HotkeyManagerPortalBackend::UnregisterAll() { + hotkeys_.clear(); + ScheduleRebind(); + return SuccessResponse(); +} + +gboolean HotkeyManagerPortalBackend::RebindNow() { + rebind_source_id_ = 0; + generation_++; + CloseSession(); + + if (hotkeys_.empty()) { + return G_SOURCE_REMOVE; + } + + std::string error_message; + if (!EnsurePortal(&error_message)) { + g_warning("Unable to use GlobalShortcuts portal: %s", + error_message.c_str()); + return G_SOURCE_REMOVE; + } + + CreateSession(generation_); + return G_SOURCE_REMOVE; +} + +void HotkeyManagerPortalBackend::HandleCreateSessionResponse(guint32 response, + GVariant* results, + guint generation) { + if (generation != generation_) { + return; + } + if (response != 0) { + g_warning("GlobalShortcuts CreateSession failed with response %u.", + response); + return; + } + + GVariant* session_handle_value = + g_variant_lookup_value(results, "session_handle", nullptr); + if (session_handle_value == nullptr) { + g_warning("GlobalShortcuts CreateSession response did not include a " + "session handle."); + return; + } + + session_handle_ = g_variant_get_string(session_handle_value, nullptr); + g_variant_unref(session_handle_value); + + SubscribeShortcutSignals(); + ListShortcuts(generation); +} + +void HotkeyManagerPortalBackend::HandleListShortcutsResponse( + guint32 response, + GVariant* results, + guint generation) { + if (generation != generation_) { + return; + } + if (response != 0) { + g_warning("GlobalShortcuts ListShortcuts failed with response %u.", + response); + BindShortcuts(generation); + return; + } + + std::vector registered_shortcut_ids; + GVariant* shortcuts = + g_variant_lookup_value(results, "shortcuts", + G_VARIANT_TYPE("a(sa{sv})")); + if (shortcuts != nullptr) { + GVariantIter iterator; + const gchar* shortcut_id = nullptr; + GVariant* properties = nullptr; + g_variant_iter_init(&iterator, shortcuts); + while (g_variant_iter_next(&iterator, "(&s@a{sv})", &shortcut_id, + &properties)) { + registered_shortcut_ids.emplace_back(shortcut_id); + g_variant_unref(properties); + } + g_variant_unref(shortcuts); + } + + if (HasMissingShortcuts(hotkeys_, registered_shortcut_ids)) { + BindShortcuts(generation); + } +} + +void HotkeyManagerPortalBackend::HandleBindShortcutsResponse(guint32 response, + guint generation) { + if (generation != generation_) { + return; + } + if (response != 0) { + g_warning("GlobalShortcuts BindShortcuts failed with response %u.", + response); + } +} + +void HotkeyManagerPortalBackend::HandleShortcutSignal( + const gchar* signal_name, + GVariant* parameters) { + const gchar* session_handle = nullptr; + const gchar* shortcut_id = nullptr; + guint64 timestamp = 0; + GVariant* options = nullptr; + g_variant_get(parameters, "(&o&st@a{sv})", &session_handle, &shortcut_id, + ×tamp, &options); + + bool matches_session = session_handle_ == session_handle; + bool is_registered = + shortcut_id != nullptr && hotkeys_.find(shortcut_id) != hotkeys_.end(); + if (matches_session && is_registered) { + if (g_strcmp0(signal_name, "Activated") == 0) { + SendHotkeyEvent(event_channel_, "onKeyDown", shortcut_id); + } else if (g_strcmp0(signal_name, "Deactivated") == 0) { + SendHotkeyEvent(event_channel_, "onKeyUp", shortcut_id); + } + } + + g_variant_unref(options); +} + +void HotkeyManagerPortalBackend::AddRequestSubscription(guint subscription_id) { + request_subscription_ids_.push_back(subscription_id); +} + +void HotkeyManagerPortalBackend::RemoveRequestSubscription( + guint subscription_id) { + request_subscription_ids_.erase( + std::remove(request_subscription_ids_.begin(), + request_subscription_ids_.end(), subscription_id), + request_subscription_ids_.end()); +} + +bool HotkeyManagerPortalBackend::EnsurePortal(std::string* error_message) { + if (portal_ != nullptr && connection_ != nullptr) { + return true; + } + + g_autoptr(GError) error = nullptr; + g_autofree gchar* bus_address = + g_dbus_address_get_for_bus_sync(G_BUS_TYPE_SESSION, nullptr, &error); + if (bus_address == nullptr) { + *error_message = + error != nullptr ? error->message : "No session bus address."; + return false; + } + + connection_ = g_dbus_connection_new_for_address_sync( + bus_address, + static_cast( + G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | + G_DBUS_CONNECTION_FLAGS_MESSAGE_BUS_CONNECTION), + nullptr, nullptr, &error); + if (connection_ == nullptr) { + *error_message = error != nullptr ? error->message : "No session bus."; + return false; + } + if (!RegisterHostApplication(error_message)) { + g_clear_object(&connection_); + return false; + } + + portal_ = g_dbus_proxy_new_sync( + connection_, G_DBUS_PROXY_FLAGS_NONE, nullptr, kPortalBusName, + kPortalObjectPath, kGlobalShortcutsInterface, nullptr, &error); + if (portal_ == nullptr) { + *error_message = + error != nullptr ? error->message : "GlobalShortcuts portal missing."; + g_clear_object(&connection_); + return false; + } + + g_autofree gchar* owner = g_dbus_proxy_get_name_owner(portal_); + if (owner == nullptr) { + *error_message = "GlobalShortcuts portal is not running."; + g_clear_object(&portal_); + g_clear_object(&connection_); + return false; + } + + g_autoptr(GVariant) version = + g_dbus_proxy_get_cached_property(portal_, "version"); + if (version == nullptr) { + g_warning("GlobalShortcuts portal did not expose a cached version " + "property."); + } + + return true; +} + +bool HotkeyManagerPortalBackend::RegisterHostApplication( + std::string* error_message) { + GApplication* application = g_application_get_default(); + const gchar* application_id = + application != nullptr ? g_application_get_application_id(application) + : nullptr; + if (application_id == nullptr || application_id[0] == '\0') { + *error_message = + "The GlobalShortcuts portal requires a GApplication application-id."; + return false; + } + + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + + g_autoptr(GError) error = nullptr; + g_dbus_connection_call_sync( + connection_, kPortalBusName, kPortalObjectPath, kHostRegistryInterface, + "Register", g_variant_new("(sa{sv})", application_id, &options), nullptr, + G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (error != nullptr) { + if (g_strrstr(error->message, "already associated") != nullptr) { + return true; + } + if (g_strrstr(error->message, "App info not found") != nullptr) { + std::stringstream message; + message << "The GlobalShortcuts portal requires an installed .desktop " + "file whose basename matches the application id '" + << application_id << "'. " << error->message; + *error_message = message.str(); + return false; + } + *error_message = error->message; + return false; + } + + return true; +} + +void HotkeyManagerPortalBackend::ScheduleRebind() { + if (rebind_source_id_ != 0) { + g_source_remove(rebind_source_id_); + } + rebind_source_id_ = g_timeout_add(kRebindDelayMs, RebindTimeoutCallback, this); +} + +void HotkeyManagerPortalBackend::CreateSession(guint generation) { + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + std::string handle_token = NextToken("hkm_create"); + std::string session_token = NextToken("hkm_session"); + 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_autoptr(GError) error = nullptr; + g_autoptr(GVariant) result = g_dbus_proxy_call_sync( + portal_, "CreateSession", g_variant_new("(a{sv})", &options), + G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (result == nullptr) { + g_warning("GlobalShortcuts CreateSession call failed: %s", + error != nullptr ? error->message : "unknown error"); + return; + } + + const gchar* request_handle = nullptr; + g_variant_get(result, "(&o)", &request_handle); + SubscribeRequestResponse(connection_, request_handle, this, generation, + PortalRequestKind::create_session); +} + +void HotkeyManagerPortalBackend::ListShortcuts(guint generation) { + if (session_handle_.empty()) { + return; + } + + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + std::string handle_token = NextToken("hkm_list"); + g_variant_builder_add(&options, "{sv}", "handle_token", + g_variant_new_string(handle_token.c_str())); + + g_autoptr(GError) error = nullptr; + g_autoptr(GVariant) result = g_dbus_proxy_call_sync( + portal_, "ListShortcuts", + g_variant_new("(oa{sv})", session_handle_.c_str(), &options), + G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (result == nullptr) { + g_warning("GlobalShortcuts ListShortcuts call failed: %s", + error != nullptr ? error->message : "unknown error"); + BindShortcuts(generation); + return; + } + + const gchar* request_handle = nullptr; + g_variant_get(result, "(&o)", &request_handle); + SubscribeRequestResponse(connection_, request_handle, this, generation, + PortalRequestKind::list_shortcuts); +} + +void HotkeyManagerPortalBackend::BindShortcuts(guint generation) { + if (session_handle_.empty()) { + return; + } + + GVariantBuilder shortcuts; + g_variant_builder_init(&shortcuts, G_VARIANT_TYPE("a(sa{sv})")); + for (const auto& item : hotkeys_) { + GVariantBuilder properties; + g_variant_builder_init(&properties, G_VARIANT_TYPE_VARDICT); + + std::string description = "Global shortcut"; + g_variant_builder_add(&properties, "{sv}", "description", + g_variant_new_string(description.c_str())); + + std::string preferred_trigger = TriggerForHotkey(item.second); + if (!preferred_trigger.empty()) { + g_variant_builder_add(&properties, "{sv}", "preferred_trigger", + g_variant_new_string(preferred_trigger.c_str())); + } + + g_variant_builder_add(&shortcuts, "(s@a{sv})", item.first.c_str(), + g_variant_builder_end(&properties)); + } + + GVariantBuilder options; + g_variant_builder_init(&options, G_VARIANT_TYPE_VARDICT); + std::string handle_token = NextToken("hkm_bind"); + g_variant_builder_add(&options, "{sv}", "handle_token", + g_variant_new_string(handle_token.c_str())); + + g_autoptr(GError) error = nullptr; + g_autoptr(GVariant) result = g_dbus_proxy_call_sync( + portal_, "BindShortcuts", + g_variant_new("(oa(sa{sv})sa{sv})", session_handle_.c_str(), &shortcuts, + "", &options), + G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error); + if (result == nullptr) { + g_warning("GlobalShortcuts BindShortcuts call failed: %s", + error != nullptr ? error->message : "unknown error"); + return; + } + + const gchar* request_handle = nullptr; + g_variant_get(result, "(&o)", &request_handle); + SubscribeRequestResponse(connection_, request_handle, this, generation, + PortalRequestKind::bind_shortcuts); +} + +void HotkeyManagerPortalBackend::CloseSession() { + UnsubscribeShortcutSignals(); + + if (connection_ != nullptr && !session_handle_.empty()) { + g_autoptr(GError) error = nullptr; + g_dbus_connection_call_sync( + connection_, kPortalBusName, session_handle_.c_str(), kSessionInterface, + "Close", g_variant_new("()"), nullptr, G_DBUS_CALL_FLAGS_NONE, -1, + nullptr, &error); + if (error != nullptr) { + g_warning("GlobalShortcuts session close failed: %s", error->message); + } + } + + session_handle_.clear(); +} + +void HotkeyManagerPortalBackend::UnsubscribeRequestSignals() { + if (connection_ == nullptr) { + request_subscription_ids_.clear(); + return; + } + + for (guint subscription_id : request_subscription_ids_) { + g_dbus_connection_signal_unsubscribe(connection_, subscription_id); + } + request_subscription_ids_.clear(); +} + +void HotkeyManagerPortalBackend::SubscribeShortcutSignals() { + if (connection_ == nullptr || session_handle_.empty()) { + return; + } + + UnsubscribeShortcutSignals(); + activated_subscription_id_ = g_dbus_connection_signal_subscribe( + connection_, kPortalBusName, kGlobalShortcutsInterface, "Activated", + kPortalObjectPath, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, + ShortcutSignalCallback, this, nullptr); + deactivated_subscription_id_ = g_dbus_connection_signal_subscribe( + connection_, kPortalBusName, kGlobalShortcutsInterface, "Deactivated", + kPortalObjectPath, nullptr, G_DBUS_SIGNAL_FLAGS_NONE, + ShortcutSignalCallback, this, nullptr); +} + +void HotkeyManagerPortalBackend::UnsubscribeShortcutSignals() { + if (connection_ == nullptr) { + return; + } + if (activated_subscription_id_ != 0) { + g_dbus_connection_signal_unsubscribe(connection_, + activated_subscription_id_); + activated_subscription_id_ = 0; + } + if (deactivated_subscription_id_ != 0) { + g_dbus_connection_signal_unsubscribe(connection_, + deactivated_subscription_id_); + deactivated_subscription_id_ = 0; + } +} + +std::string HotkeyManagerPortalBackend::TriggerForHotkey( + const PortalHotkey& hotkey) const { + std::vector trigger_parts; + for (const std::string& modifier : hotkey.modifiers) { + AddModifierToTrigger(modifier, &trigger_parts); + } + + std::string key_name = NormalizeKeyName(gdk_keyval_name(hotkey.key_code)); + if (key_name.empty()) { + g_warning("Unable to resolve key name for key code %u.", hotkey.key_code); + return ""; + } + trigger_parts.push_back(key_name); + + return JoinTriggerParts(trigger_parts); +} + +bool HasMissingShortcuts( + const std::map& desired_hotkeys, + const std::vector& registered_shortcut_ids) { + return std::any_of( + desired_hotkeys.begin(), desired_hotkeys.end(), + [®istered_shortcut_ids](const auto& desired_hotkey) { + return std::find(registered_shortcut_ids.begin(), + registered_shortcut_ids.end(), + desired_hotkey.first) == + registered_shortcut_ids.end(); + }); +} diff --git a/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.h b/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.h new file mode 100644 index 0000000..d58a4eb --- /dev/null +++ b/packages/hotkey_manager_linux/linux/hotkey_manager_portal_backend.h @@ -0,0 +1,73 @@ +#ifndef FLUTTER_PLUGIN_HOTKEY_MANAGER_PORTAL_BACKEND_H_ +#define FLUTTER_PLUGIN_HOTKEY_MANAGER_PORTAL_BACKEND_H_ + +#include +#include + +#include +#include +#include + +#include "hotkey_manager_linux_backend.h" + +struct PortalHotkey { + guint key_code; + std::vector modifiers; +}; + +enum class PortalRequestKind { + create_session, + list_shortcuts, + bind_shortcuts, +}; + +class HotkeyManagerPortalBackend : public HotkeyManagerLinuxBackend { + public: + explicit HotkeyManagerPortalBackend(FlEventChannel* event_channel); + ~HotkeyManagerPortalBackend() override; + + FlMethodResponse* Register(FlValue* args) override; + FlMethodResponse* Unregister(FlValue* args) override; + FlMethodResponse* UnregisterAll() override; + gboolean RebindNow(); + void HandleCreateSessionResponse(guint32 response, + GVariant* results, + guint generation); + void HandleListShortcutsResponse(guint32 response, + GVariant* results, + guint generation); + void HandleBindShortcutsResponse(guint32 response, guint generation); + void HandleShortcutSignal(const gchar* signal_name, GVariant* parameters); + void AddRequestSubscription(guint subscription_id); + void RemoveRequestSubscription(guint subscription_id); + + private: + bool EnsurePortal(std::string* error_message); + bool RegisterHostApplication(std::string* error_message); + void ScheduleRebind(); + void CreateSession(guint generation); + void ListShortcuts(guint generation); + void BindShortcuts(guint generation); + void CloseSession(); + void UnsubscribeRequestSignals(); + void SubscribeShortcutSignals(); + void UnsubscribeShortcutSignals(); + std::string TriggerForHotkey(const PortalHotkey& hotkey) const; + + FlEventChannel* event_channel_; + GDBusConnection* connection_; + GDBusProxy* portal_; + std::map hotkeys_; + std::vector request_subscription_ids_; + std::string session_handle_; + guint activated_subscription_id_; + guint deactivated_subscription_id_; + guint rebind_source_id_; + guint generation_; +}; + +bool HasMissingShortcuts( + const std::map& desired_hotkeys, + const std::vector& registered_shortcut_ids); + +#endif // FLUTTER_PLUGIN_HOTKEY_MANAGER_PORTAL_BACKEND_H_ diff --git a/packages/hotkey_manager_linux/linux/test/hotkey_manager_linux_plugin_test.cc b/packages/hotkey_manager_linux/linux/test/hotkey_manager_linux_plugin_test.cc index 4b5f3fd..cf11368 100644 --- a/packages/hotkey_manager_linux/linux/test/hotkey_manager_linux_plugin_test.cc +++ b/packages/hotkey_manager_linux/linux/test/hotkey_manager_linux_plugin_test.cc @@ -2,8 +2,13 @@ #include #include +#include +#include +#include + #include "include/hotkey_manager_linux/hotkey_manager_linux_plugin.h" #include "hotkey_manager_linux_plugin_private.h" +#include "hotkey_manager_portal_backend.h" // This demonstrates a simple unit test of the C portion of this plugin's // implementation. @@ -27,5 +32,24 @@ TEST(HotkeyManagerLinuxPlugin, GetPlatformVersion) { EXPECT_THAT(fl_value_get_string(result), testing::StartsWith("Linux ")); } +TEST(HotkeyManagerPortalBackend, FindsMissingShortcut) { + const std::map desired_hotkeys = { + {"open-window", {0, {}}}, + {"toggle-mute", {0, {}}}, + }; + + EXPECT_TRUE(HasMissingShortcuts(desired_hotkeys, {"open-window"})); +} + +TEST(HotkeyManagerPortalBackend, ReusesPreviouslyRegisteredShortcuts) { + const std::map desired_hotkeys = { + {"open-window", {0, {}}}, + {"toggle-mute", {0, {}}}, + }; + + EXPECT_FALSE(HasMissingShortcuts( + desired_hotkeys, {"toggle-mute", "unused-action", "open-window"})); +} + } // namespace test } // namespace hotkey_manager_linux diff --git a/packages/hotkey_manager_platform_interface/lib/src/hotkey.dart b/packages/hotkey_manager_platform_interface/lib/src/hotkey.dart index f3ee7f6..67b992e 100644 --- a/packages/hotkey_manager_platform_interface/lib/src/hotkey.dart +++ b/packages/hotkey_manager_platform_interface/lib/src/hotkey.dart @@ -65,6 +65,10 @@ class HotKey { return _$HotKeyFromJson(json); } + /// The stable identity of this shortcut's action. + /// + /// Supply the same value across launches for system shortcuts on Wayland so + /// the desktop portal can reuse the user's existing binding. final String identifier; final KeyboardKey key; final List? modifiers;