diff --git a/CMakeLists.txt b/CMakeLists.txt index 400b8e102..15e8f1d11 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -537,6 +537,7 @@ protocolnew("protocols" "wlr-virtual-pointer-unstable-v1" true) protocolnew("protocols" "input-method-unstable-v2" true) protocolnew("protocols" "wlr-output-management-unstable-v1" true) protocolnew("protocols" "kde-server-decoration" true) +protocolnew("protocols" "vicinae-hotkey-v1" true) protocolnew("protocols" "wlr-data-control-unstable-v1" true) protocolnew("${HYPRLAND_PROTOCOLS}/protocols" "hyprland-focus-grab-v1" true) protocolnew("protocols" "wlr-layer-shell-unstable-v1" true) diff --git a/protocols/vicinae-hotkey-v1.xml b/protocols/vicinae-hotkey-v1.xml new file mode 100644 index 000000000..30f973942 --- /dev/null +++ b/protocols/vicinae-hotkey-v1.xml @@ -0,0 +1,305 @@ + + + + Copyright © 2026 Aurelien Brabant + + Permission is hereby granted, free of charge, to any person obtaining a + copy of this software and associated documentation files (the "Software"), + to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice (including the next + paragraph) shall be included in all copies or substantial portions of the + Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS IN THE SOFTWARE. + + + + This protocol lets a client choose, and freely reconfigure, its own + global hotkeys: it requests a specific key combination and the compositor + accepts it or rejects it with a reason. The compositor remains the eventual + arbiter (it may deny a request, revoke a binding at any time, or apply + whatever policy it likes), but within that the application manages its own + bindings, including changing them at runtime, with no out-of-band user + configuration and no persisted compositor state. + + A hotkey is a key combination that fires regardless of which surface holds + keyboard focus. This is unrelated to keyboard-shortcuts-inhibit, which lets a + focused client suppress the compositor's own shortcuts. + + Whether a combination is exclusive is compositor policy. A compositor MAY + treat combinations as exclusive and reject a clashing request with + "already_bound", or MAY grant the same combination to more than one binding + (each bound hotkey then receives the events), as some platforms do. Clients + must cope with either. + + It is intentionally a thin mechanism. It does NOT define which combinations + are acceptable: that is policy and belongs to the compositor, expressed + through the accept/deny channel. It does NOT persist hotkeys: a binding lives + only as long as its vicinae_hotkey_v1 object and the client connection; there is + no configure or storage step, a binding never outlives the client that created + it, and reconfiguring is simply destroying a hotkey and binding the new + combination. + + Warning! The protocol described in this file is currently in the testing + phase. Backward incompatible changes may be added together with the + corresponding interface version bump. Backward compatible changes are added + by bumping the interface version. + + Security considerations: + + A global hotkey lets an unfocused client observe that a specific key + combination was pressed. Compositors are the security boundary and SHOULD + apply policy when deciding whether to accept a request. In particular, + implementations SHOULD reject combinations that do not include at least one + non-latching modifier among Ctrl, Alt or Super, unless the trigger is a + function key, because unmodified (and Shift-only) keys carry ordinary text + entry and grabbing them turns this protocol into a keylogger. + + The protocol's one hard guarantee is containment: a compositor MUST NOT + deliver events for a combination the client did not successfully bind, and + never forwards the raw key stream, so a client learns nothing about input it + did not explicitly, and successfully, bind. Beyond that, a compositor MAY + apply any further policy it sees fit (for example prompting the user, + restricting which clients may bind, or limiting how many bindings a client may + hold) and MAY revoke a binding at any time via the "revoked" event, including + under user control. + + These are recommendations, not wire requirements: a combination the + compositor disallows is simply reported through the "denied" event with the + "not_permitted" reason, so applications can rely on a uniform rejection path + regardless of each compositor's policy. + + + + + This interface is the entry point of the protocol. It is used to request + global hotkeys. + + + + + A bitmask of the modifiers that must be held for the hotkey to fire. + + These are fixed, keymap-independent semantic bits naming the standard + modifiers, suitable for expressing a binding, unlike wl_keyboard.modifiers, + which carries opaque, keymap-derived masks for runtime state. A compositor + matches each bit against the corresponding modifier in its keyboard state + as the keymap defines it; for the xkb_v1 keymap format that is shift -> + XKB_MOD_NAME_SHIFT, ctrl -> XKB_MOD_NAME_CTRL, alt -> XKB_MOD_NAME_ALT + ("Mod1"), super -> XKB_MOD_NAME_LOGO ("Mod4"). + + Lock modifiers (Caps Lock, Num Lock) are never part of a binding and are + ignored when matching. + + + + + + + + + + Destroy the manager object. Hotkey objects created through this manager + are unaffected and remain valid; their bindings persist until they are + themselves destroyed or the client disconnects. + + + + + + Request that the given key combination be bound as a global hotkey. + + The keysym identifies the trigger key using the keysym numbering shared + with the keymap delivered over wl_keyboard (for the xkb_v1 keymap format, + an XKB_KEY_* value), taken in its unshifted form (XKB_KEY_b not XKB_KEY_B). A compositor SHOULD match + it against any of the user's layout groups, so a binding keeps firing after + the user switches layout group. This is one deliberately specified rule, + not a universal one: other platforms differ (macOS matches a physical + keycode, X11 re-grabs per layout, Windows tracks the active layout's key), + and no rule serves a layout that never produces the keysym; the single + documented rule is chosen for predictability across compositors. + + The request creates an vicinae_hotkey_v1 object immediately, but the binding + is not active yet. The compositor replies asynchronously with either the + "bound" event (the hotkey is now active) or the "denied" event (the + request was rejected, with a reason). + + seat is the wl_seat whose keyboard should trigger the hotkey, or null to + request it on all of the client's seats. Most clients pass null; seat is + provided for completeness on multi-seat systems. + + app_id identifies the requesting application, for use in the compositor's + policy and any user-facing audit UI. Where the application has a desktop + entry, app_id SHOULD be its desktop file ID as defined by the + freedesktop.org Desktop Entry specification, that is the .desktop file + name with the .desktop suffix removed (for example "org.example.Launcher"), + matching the convention used by xdg_toplevel.set_app_id so a compositor + can correlate the two. It is advisory and may be spoofed; a compositor + that needs a trustworthy identity SHOULD obtain it through the + security-context mechanism rather than relying on this string. + + description is a human-readable, localized description of what the hotkey + does (e.g. "Toggle the launcher"), for display in compositor UI or even in this protocol error messages. + + + + + + + + + + + + + Represents one requested global hotkey. Its binding is ephemeral: it is + released when this object is destroyed or when the client disconnects, and + it is never written to any persistent store. + + After bind, exactly one of "bound" or "denied" is sent. While the hotkey is + bound, "pressed" and "released" are sent as the combination is activated. + The compositor may send "revoked" at any time after "bound" to indicate the + binding is no longer active (for example because the user removed it, or a + higher-priority binding took the combination). + + + + + Carried by the "denied" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it (or try a different combination). + + + + + + + + + Carried by the "revoked" event. This is a reason code, not a fatal + protocol error: the object remains valid and the client should destroy + it. + + The distinction is actionable: on "superseded" the combination may + become available again, so a client may reasonably re-request it later; + on "removed" the withdrawal is a deliberate user or compositor decision, + so a client MUST NOT silently re-request the same combination. A client + that receives a value it does not recognise (a future addition) MUST + treat it conservatively as "removed" and not auto-rebind. + + + + + + + + + Release the binding and destroy the object. The combination becomes + available again immediately. It is valid to destroy the object before + receiving "bound" or "denied", which cancels the pending request. + + + + + + The requested combination was accepted and is now active. "pressed" and + "released" events may follow. + + + + + + The requested combination was not bound. No input events will be sent. + The client should destroy this object; it may then create a new request + with a different combination. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI (for + example a "change shortcut" settings screen). It may be empty, and a + compositor is never required to provide it. Clients MUST NOT parse it or + rely on its contents in any way; all programmatic behavior keys off + reason. A client with nowhere to show it simply ignores it. + + + + + + + + A binding that was previously "bound" is no longer active. No further + input events will be sent for it. The client should destroy this object; + it may request the combination again later. + + message is an optional, advisory, human-readable explanation in the + compositor's locale, intended for verbatim display in client UI. It may + be empty, and a compositor is never required to provide it. Clients MUST + NOT parse it or rely on its contents in any way; all programmatic + behavior keys off reason. A client with nowhere to show it simply + ignores it. + + + + + + + + The bound combination was activated. + + This event is sent once per activation. While the combination is held + down no further "pressed" events are sent, and a single "released" + follows when the trigger key is released: the compositor does not + auto-repeat the hotkey. + + serial is a compositor-issued input serial that counts as a recent user + interaction, so the client can act on the hotkey even though the + triggering key press is not otherwise delivered to it (the compositor + consumes it). In particular, a client may pass this serial to + xdg_activation_token_v1.set_serial in order to raise or focus a surface in + response to the hotkey; a compositor SHOULD honor activation carried by + this serial, since the hotkey is itself a deliberate user action. The + serial is drawn from the same space as other input event serials. + + time is the event timestamp, with the same millisecond clock as wl_pointer + and wl_keyboard input events; it is useful for ordering and for measuring + press-to-release duration. + + + + + + + + The trigger key of a previously pressed combination was released. This + enables press-and-hold uses (e.g. push-to-talk); clients that only act + on activation may ignore it. + + serial and time have the same meaning as in the "pressed" event. + + + + + + diff --git a/src/managers/KeybindManager.cpp b/src/managers/KeybindManager.cpp index 7fd034728..79f971545 100644 --- a/src/managers/KeybindManager.cpp +++ b/src/managers/KeybindManager.cpp @@ -5,6 +5,7 @@ #include "../devices/IKeyboard.hpp" #include "../managers/SeatManager.hpp" #include "../protocols/ShortcutsInhibit.hpp" +#include "../protocols/Hotkey.hpp" #include "../protocols/core/DataDevice.hpp" #include "../errorOverlay/Overlay.hpp" #include "KeybindManager.hpp" @@ -257,6 +258,32 @@ uint32_t CKeybindManager::keycodeToModifier(xkb_keycode_t keycode) { } } +SP CKeybindManager::findConflictingKeybind(xkb_keysym_t keysym, uint32_t modmask) { + if (keysym == XKB_KEY_NoSymbol) + return nullptr; + + for (const auto& k : m_keybinds) { + if (!k->enabled || k->mouse) + continue; + if (!k->submap.name.empty()) + continue; + if (k->modmask != modmask) + continue; + + xkb_keysym_t bindSym = XKB_KEY_NoSymbol; + if (!k->key.empty()) + bindSym = xkb_keysym_from_name(k->key.c_str(), XKB_KEYSYM_CASE_INSENSITIVE); + else if (k->keycode != 0 && m_xkbTranslationState) + // "code:NN" binds store the xkb keycode + bindSym = xkb_state_key_get_one_sym(m_xkbTranslationState, k->keycode); + + if (bindSym != XKB_KEY_NoSymbol && bindSym == keysym) + return k; + } + + return nullptr; +} + void CKeybindManager::updateXKBTranslationState() { if (m_xkbTranslationState) { xkb_state_unref(m_xkbTranslationState); @@ -350,6 +377,9 @@ bool CKeybindManager::onKeyEvent(std::any event, SP pKeyboard) { const auto MODS = g_pInputManager->getModsFromAllKBs(); + if (PROTO::hotkey && PROTO::hotkey->onKey(keysym, MODS, KEYCODE, e.state == WL_KEYBOARD_KEY_STATE_PRESSED, e.timeMs)) + return false; + Config::Actions::state()->m_timeLastMs = e.timeMs; Config::Actions::state()->m_lastCode = KEYCODE; Config::Actions::state()->m_lastMouseCode = 0; diff --git a/src/managers/KeybindManager.hpp b/src/managers/KeybindManager.hpp index 0973c02bd..921cdb258 100644 --- a/src/managers/KeybindManager.hpp +++ b/src/managers/KeybindManager.hpp @@ -124,6 +124,7 @@ class CKeybindManager { void removeKeybind(const std::string& displayKeys); uint32_t stringToModMask(std::string); uint32_t keycodeToModifier(xkb_keycode_t); + SP findConflictingKeybind(xkb_keysym_t keysym, uint32_t modmask); void clearKeybinds(); void shadowKeybinds(const xkb_keysym_t& doesntHave = 0, const uint32_t doesntHaveCode = 0); SSubmap getCurrentSubmap(); diff --git a/src/managers/ProtocolManager.cpp b/src/managers/ProtocolManager.cpp index e4265aa0c..2a0cf94fb 100644 --- a/src/managers/ProtocolManager.cpp +++ b/src/managers/ProtocolManager.cpp @@ -63,6 +63,7 @@ #include "../protocols/ContentType.hpp" #include "../protocols/XDGTag.hpp" #include "../protocols/XDGBell.hpp" +#include "../protocols/Hotkey.hpp" #include "../protocols/ExtWorkspace.hpp" #include "../protocols/ExtDataDevice.hpp" #include "../protocols/PointerWarp.hpp" @@ -193,6 +194,7 @@ CProtocolManager::CProtocolManager() { PROTO::contentType = makeUnique(&wp_content_type_manager_v1_interface, 1, "ContentType"); PROTO::xdgTag = makeUnique(&xdg_toplevel_tag_manager_v1_interface, 1, "XDGTag"); PROTO::xdgBell = makeUnique(&xdg_system_bell_v1_interface, 1, "XDGBell"); + PROTO::hotkey = makeUnique(&vicinae_hotkey_manager_v1_interface, 1, "Hotkey"); PROTO::extWorkspace = makeUnique(&ext_workspace_manager_v1_interface, 1, "ExtWorkspace"); PROTO::extDataDevice = makeUnique(&ext_data_control_manager_v1_interface, 1, "ExtDataDevice"); PROTO::pointerWarp = makeUnique(&wp_pointer_warp_v1_interface, 1, "PointerWarp"); @@ -303,6 +305,7 @@ CProtocolManager::~CProtocolManager() { PROTO::colorManagement.reset(); PROTO::xdgTag.reset(); PROTO::xdgBell.reset(); + PROTO::hotkey.reset(); PROTO::extWorkspace.reset(); PROTO::extDataDevice.reset(); PROTO::pointerWarp.reset(); diff --git a/src/protocols/Hotkey.cpp b/src/protocols/Hotkey.cpp new file mode 100644 index 000000000..d05004606 --- /dev/null +++ b/src/protocols/Hotkey.cpp @@ -0,0 +1,177 @@ +#include "Hotkey.hpp" +#include "../Compositor.hpp" +#include "../devices/IKeyboard.hpp" +#include "../managers/KeybindManager.hpp" +#include "../event/EventBus.hpp" + +#include +#include + +static constexpr uint32_t RELEVANT_MODS = HL_MODIFIER_SHIFT | HL_MODIFIER_CTRL | HL_MODIFIER_ALT | HL_MODIFIER_META; + +static uint32_t protoModsToHL(uint32_t mods) { + uint32_t out = 0; + if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_SHIFT) + out |= HL_MODIFIER_SHIFT; + if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_CTRL) + out |= HL_MODIFIER_CTRL; + if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_ALT) + out |= HL_MODIFIER_ALT; + if (mods & VICINAE_HOTKEY_MANAGER_V1_MODIFIERS_SUPER) + out |= HL_MODIFIER_META; + return out; +} + +static bool isFunctionKey(xkb_keysym_t sym) { + return sym >= XKB_KEY_F1 && sym <= XKB_KEY_F35; +} + +static bool isValidTrigger(xkb_keysym_t sym, uint32_t modmask) { + if (isFunctionKey(sym)) + return true; + return modmask & (HL_MODIFIER_CTRL | HL_MODIFIER_ALT | HL_MODIFIER_META); +} + +static std::string keybindLabel(const SP& k) { + if (!k->description.empty()) + return k->description; + if (!k->arg.empty()) + return k->handler + ", " + k->arg; + return k->handler; +} + +CVicinaeHotkeyManager::CVicinaeHotkeyManager(SP resource_) : m_resource(resource_) { + if UNLIKELY (!good()) + return; + + m_resource->setOnDestroy([this](CVicinaeHotkeyManagerV1*) { PROTO::hotkey->destroyManager(this); }); + m_resource->setDestroy([this](CVicinaeHotkeyManagerV1*) { PROTO::hotkey->destroyManager(this); }); + + m_resource->setBind([this](CVicinaeHotkeyManagerV1* mgr, uint32_t id, uint32_t keysym, vicinaeHotkeyManagerV1Modifiers mods, wl_resource* seat, const char* appid, + const char* description) { PROTO::hotkey->onBind(m_resource, id, (xkb_keysym_t)keysym, (uint32_t)mods, appid, description); }); +} + +bool CVicinaeHotkeyManager::good() { + return m_resource->resource(); +} + +CHotkeyProtocol::CHotkeyProtocol(const wl_interface* iface, const int& ver, const std::string& name) : IWaylandProtocol(iface, ver, name) { + m_reloadListener = Event::bus()->m_events.config.reloaded.listen([this] { revokeConflicting(); }); +} + +void CHotkeyProtocol::bindManager(wl_client* client, void* data, uint32_t ver, uint32_t id) { + const auto RESOURCE = m_managers.emplace_back(makeShared(makeShared(client, ver, id))); + + if UNLIKELY (!RESOURCE->good()) { + wl_client_post_no_memory(client); + m_managers.pop_back(); + return; + } +} + +void CHotkeyProtocol::destroyManager(CVicinaeHotkeyManager* mgr) { + // hotkeys outlive their manager + std::erase_if(m_managers, [&](const auto& other) { return other.get() == mgr; }); +} + +bool CHotkeyProtocol::comboTakenByHotkey(xkb_keysym_t keysym, uint32_t modmask) { + for (const auto& hk : m_hotkeys) { + if (hk->bound && hk->keysym == keysym && hk->modmask == modmask) + return true; + } + return false; +} + +void CHotkeyProtocol::onBind(SP mgr, uint32_t id, xkb_keysym_t keysym, uint32_t protoMods, const char* appid, const char* description) { + auto hk = makeShared(); + hk->resource = makeShared(mgr->client(), mgr->version(), id); + + if UNLIKELY (!hk->resource->resource()) { + mgr->noMemory(); + return; + } + + hk->keysym = keysym; + hk->modmask = protoModsToHL(protoMods); + hk->appid = appid ? appid : ""; + hk->description = description ? description : ""; + + SBoundHotkey* raw = hk.get(); + hk->resource->setDestroy([raw](CVicinaeHotkeyV1*) { std::erase_if(PROTO::hotkey->m_hotkeys, [&](const auto& other) { return other.get() == raw; }); }); + hk->resource->setOnDestroy([raw](CVicinaeHotkeyV1*) { std::erase_if(PROTO::hotkey->m_hotkeys, [&](const auto& other) { return other.get() == raw; }); }); + + m_hotkeys.emplace_back(hk); + + if (keysym == XKB_KEY_NoSymbol) { + hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_INVALID, "the keysym is not a valid trigger"); + return; + } + + if (!isValidTrigger(keysym, hk->modmask)) { + hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_NOT_PERMITTED, "a non-latching modifier (Ctrl, Alt or Super) is required unless the trigger is a function key"); + return; + } + + if (comboTakenByHotkey(keysym, hk->modmask)) { + hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_ALREADY_BOUND, "the combination is already bound by another hotkey"); + return; + } + + if (const auto CONFLICT = g_pKeybindManager->findConflictingKeybind(keysym, hk->modmask)) { + hk->resource->sendDenied(VICINAE_HOTKEY_V1_DENY_REASON_ALREADY_BOUND, + std::format("the combination is reserved by a compositor keybind ({})", keybindLabel(CONFLICT)).c_str()); + return; + } + + hk->bound = true; + hk->resource->sendBound(); +} + +bool CHotkeyProtocol::onKey(xkb_keysym_t keysym, uint32_t modmask, uint32_t keycode, bool pressed, uint32_t timeMs) { + const uint32_t mods = modmask & RELEVANT_MODS; + bool consumed = false; + + if (pressed) { + for (const auto& hk : m_hotkeys) { + if (!hk->bound || hk->held) + continue; + if (hk->keysym != keysym || hk->modmask != mods) + continue; + + hk->held = true; + hk->heldCode = keycode; + const uint32_t serial = wl_display_next_serial(g_pCompositor->m_wlDisplay); + hk->resource->sendPressed(serial, timeMs); + consumed = true; + } + } else { + for (const auto& hk : m_hotkeys) { + if (!hk->held || hk->heldCode != keycode) + continue; + + hk->held = false; + hk->heldCode = 0; + const uint32_t serial = wl_display_next_serial(g_pCompositor->m_wlDisplay); + hk->resource->sendReleased(serial, timeMs); + consumed = true; + } + } + + return consumed; +} + +void CHotkeyProtocol::revokeConflicting() { + for (const auto& hk : m_hotkeys) { + if (!hk->bound) + continue; + const auto CONFLICT = g_pKeybindManager->findConflictingKeybind(hk->keysym, hk->modmask); + if (!CONFLICT) + continue; + + hk->bound = false; + hk->held = false; + hk->heldCode = 0; + hk->resource->sendRevoked(VICINAE_HOTKEY_V1_REVOKE_REASON_SUPERSEDED, + std::format("the combination is now reserved by a compositor keybind ({})", keybindLabel(CONFLICT)).c_str()); + } +} diff --git a/src/protocols/Hotkey.hpp b/src/protocols/Hotkey.hpp new file mode 100644 index 000000000..c57b4f170 --- /dev/null +++ b/src/protocols/Hotkey.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "../defines.hpp" +#include "vicinae-hotkey-v1.hpp" +#include "./WaylandProtocol.hpp" +#include "../helpers/signal/Signal.hpp" + +#include +#include +#include + +// hotkeys are owned by the protocol, not this manager, so they outlive it +class CVicinaeHotkeyManager { + public: + CVicinaeHotkeyManager(SP resource); + + bool good(); + + private: + SP m_resource; + + friend class CHotkeyProtocol; +}; + +class CHotkeyProtocol : public IWaylandProtocol { + public: + CHotkeyProtocol(const wl_interface* iface, const int& ver, const std::string& name); + + void bindManager(wl_client* client, void* data, uint32_t ver, uint32_t id) override; + + // returns true if a bound hotkey consumed the key + bool onKey(xkb_keysym_t keysym, uint32_t modmask, uint32_t keycode, bool pressed, uint32_t timeMs); + + void revokeConflicting(); + + private: + struct SBoundHotkey { + SP resource; + xkb_keysym_t keysym = XKB_KEY_NoSymbol; + uint32_t modmask = 0; // HL_MODIFIER_* bits + std::string appid; + std::string description; + bool bound = false; + bool held = false; + uint32_t heldCode = 0; + }; + + void onBind(SP mgr, uint32_t id, xkb_keysym_t keysym, uint32_t protoMods, const char* appid, const char* description); + void destroyManager(CVicinaeHotkeyManager* mgr); + bool comboTakenByHotkey(xkb_keysym_t keysym, uint32_t modmask); + + std::vector> m_managers; + std::vector> m_hotkeys; + + CHyprSignalListener m_reloadListener; + + friend class CVicinaeHotkeyManager; +}; + +namespace PROTO { + inline UP hotkey; +};