mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-06 03:07:04 +02:00
[KBM] Migrate Engine and Editor into separate processes (#10774)
* Move KBM engine into separate process (#10672) * [KBM] Migrate KBM UI out of the runner (#10709) * Clean up keyboard hook handles (#10817) * [C++ common] Unhandled exception handler (#10821) * [KBM] Use icon in the KeyboardManagerEditor (#10845) * [KBM] Move resources from the Common project to the Editor. (#10844) * KBM Editor tests (#10858) * Rename engine executable (#10868) * clean up (#10870) * [KBM] Changed Editor and libraries output folders (#10871) * [KBM] New logs structure (#10872) * Add unhandled exception handling to the editor (#10874) * [KBM] Trace for edit keyboard window * Logging for XamlBridge message loop * [KBM] Added Editor and Engine to the installer (#10876) * Fix spelling * Interprocess communication logs, remove unnecessary windows message logs * [KBM] Separated telemetry for the engine and editor. (#10889) * [KBM] Editor test project (#10891) * Versions for the engine and the editor (#10897) * Add the editor's and the engine's executables to signing process (#10900) * [KBM editor] Run only one instance, exit when parent process exits (#10890) * [KBM] Force kill editor process to avoid XAML crash (#10907) * [KBM] Force kill editor process to avoid XAML crash * Fix event releasing Co-authored-by: mykhailopylyp <17161067+mykhailopylyp@users.noreply.github.com> * Make the editor dpi aware (#10908) * [KBM] KeyboardManagerCommon refactoring (#10909) * Do not start the process if it is already started (#10910) * logs * Update src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditKeyboardWindow.cpp * Update src/modules/keyboardmanager/KeyboardManagerEditorLibrary/EditKeyboardWindow.cpp * [KBM] Rename InitUnhandledExceptionHandler to make it explicit that is for x64 only. We will fix it properly when adding support for ARM64 and add a header with the proper conditional building. * [KBM] rename file/class/variables using camel case * [KBM] Rename "event_locker" -> "EventLocker" * [KBM] rename process_waiter Add a TODO comment * [KBM] rename methods Add TODO comment * [KBM] use uppercase for function names * [KBM] use uppercase for methos, lowercase for properties * [KBM] rename method, make methods private, formatting * [KBM] rename private variables * [KBM] use uppercase for function names * [KBM] Added support to run the editor stand-alone when built in debug mode * Update src/modules/keyboardmanager/KeyboardManagerEditor/KeyboardManagerEditor.cpp * Check success of event creation, comment (#10947) * [KBM] code formatting (#10951) * [KBM] code formatting * Update src/modules/keyboardmanager/KeyboardManagerEditorLibrary/BufferValidationHelpers.cpp * [KBM] tracing * [KBM] Remappings not showing fix. (#10954) * removed mutex * retry loop for reading * retry on reading config once * log error Co-authored-by: Enrico Giordani <enricogior@users.noreply.github.com> Co-authored-by: Enrico Giordani <enricogior@users.noreply.github.com> Co-authored-by: Seraphima Zykova <zykovas91@gmail.com> Co-authored-by: Enrico Giordani <enricogior@users.noreply.github.com> Co-authored-by: Enrico Giordani <enrico.giordani@gmail.com>
This commit is contained in:
@@ -1,21 +1,13 @@
|
||||
#include "pch.h"
|
||||
#include <interface/powertoy_module_interface.h>
|
||||
#include <common/SettingsAPI/settings_objects.h>
|
||||
#include <common/interop/shared_constants.h>
|
||||
#include <common/utils/resources.h>
|
||||
#include "Generated Files/resource.h"
|
||||
#include <keyboardmanager/ui/EditKeyboardWindow.h>
|
||||
#include <keyboardmanager/ui/EditShortcutsWindow.h>
|
||||
#include <keyboardmanager/common/KeyboardManagerState.h>
|
||||
#include <keyboardmanager/common/Shortcut.h>
|
||||
#include <keyboardmanager/common/RemapShortcut.h>
|
||||
#include <keyboardmanager/common/KeyboardManagerConstants.h>
|
||||
#include <common/debug_control.h>
|
||||
#include <common/utils/winapi_error.h>
|
||||
#include <common/logger/logger_settings.h>
|
||||
#include <keyboardmanager/common/trace.h>
|
||||
#include <keyboardmanager/common/Helpers.h>
|
||||
#include "KeyboardEventHandlers.h"
|
||||
#include "Input.h"
|
||||
#include <keyboardmanager/dll/trace.h>
|
||||
#include <shellapi.h>
|
||||
#include <common/utils/logger_helper.h>
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
|
||||
{
|
||||
@@ -47,189 +39,21 @@ private:
|
||||
//contains the non localized key of the powertoy
|
||||
std::wstring app_key = KeyboardManagerConstants::ModuleName;
|
||||
|
||||
// Low level hook handles
|
||||
static HHOOK hook_handle;
|
||||
|
||||
// Required for Unhook in old versions of Windows
|
||||
static HHOOK hook_handle_copy;
|
||||
|
||||
// Static pointer to the current keyboardmanager object required for accessing the HandleKeyboardHookEvent function in the hook procedure (Only global or static variables can be accessed in a hook procedure CALLBACK)
|
||||
static KeyboardManager* keyboardmanager_object_ptr;
|
||||
|
||||
// Variable which stores all the state information to be shared between the UI and back-end
|
||||
KeyboardManagerState keyboardManagerState;
|
||||
|
||||
// Object of class which implements InputInterface. Required for calling library functions while enabling testing
|
||||
Input inputHandler;
|
||||
|
||||
HANDLE m_hProcess = nullptr;
|
||||
public:
|
||||
// Constructor
|
||||
KeyboardManager()
|
||||
{
|
||||
std::filesystem::path logFilePath(PTSettingsHelper::get_module_save_folder_location(app_key));
|
||||
logFilePath.append(LogSettings::keyboardManagerLogPath);
|
||||
Logger::init(LogSettings::keyboardManagerLoggerName, logFilePath.wstring(), PTSettingsHelper::get_log_settings_file_location());
|
||||
|
||||
// Load the initial configuration.
|
||||
load_config();
|
||||
|
||||
// Set the static pointer to the newest object of the class
|
||||
keyboardmanager_object_ptr = this;
|
||||
LoggerHelpers::init_logger(KeyboardManagerConstants::ModuleName, L"ModuleInterface", LogSettings::keyboardManagerLoggerName);
|
||||
|
||||
std::filesystem::path oldLogPath(PTSettingsHelper::get_module_save_folder_location(app_key));
|
||||
oldLogPath.append("Logs");
|
||||
LoggerHelpers::delete_old_log_folder(oldLogPath);
|
||||
};
|
||||
|
||||
// Load config from the saved settings.
|
||||
void load_config()
|
||||
{
|
||||
try
|
||||
{
|
||||
PowerToysSettings::PowerToyValues settings =
|
||||
PowerToysSettings::PowerToyValues::load_from_settings_file(get_key());
|
||||
auto current_config = settings.get_string_value(KeyboardManagerConstants::ActiveConfigurationSettingName);
|
||||
|
||||
if (current_config)
|
||||
{
|
||||
keyboardManagerState.SetCurrentConfigName(*current_config);
|
||||
// Read the config file and load the remaps.
|
||||
auto configFile = json::from_file(PTSettingsHelper::get_module_save_folder_location(KeyboardManagerConstants::ModuleName) + L"\\" + *current_config + L".json");
|
||||
if (configFile)
|
||||
{
|
||||
auto jsonData = *configFile;
|
||||
|
||||
// Load single key remaps
|
||||
try
|
||||
{
|
||||
auto remapKeysData = jsonData.GetNamedObject(KeyboardManagerConstants::RemapKeysSettingName);
|
||||
keyboardManagerState.ClearSingleKeyRemaps();
|
||||
|
||||
if (remapKeysData)
|
||||
{
|
||||
auto inProcessRemapKeys = remapKeysData.GetNamedArray(KeyboardManagerConstants::InProcessRemapKeysSettingName);
|
||||
for (const auto& it : inProcessRemapKeys)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto originalKey = it.GetObjectW().GetNamedString(KeyboardManagerConstants::OriginalKeysSettingName);
|
||||
auto newRemapKey = it.GetObjectW().GetNamedString(KeyboardManagerConstants::NewRemapKeysSettingName);
|
||||
|
||||
// If remapped to a shortcut
|
||||
if (std::wstring(newRemapKey).find(L";") != std::string::npos)
|
||||
{
|
||||
keyboardManagerState.AddSingleKeyRemap(std::stoul(originalKey.c_str()), Shortcut(newRemapKey.c_str()));
|
||||
}
|
||||
|
||||
// If remapped to a key
|
||||
else
|
||||
{
|
||||
keyboardManagerState.AddSingleKeyRemap(std::stoul(originalKey.c_str()), std::stoul(newRemapKey.c_str()));
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper Key Data JSON. Try the next remap.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper JSON format for single key remaps. Skip to next remap type
|
||||
}
|
||||
|
||||
// Load shortcut remaps
|
||||
try
|
||||
{
|
||||
auto remapShortcutsData = jsonData.GetNamedObject(KeyboardManagerConstants::RemapShortcutsSettingName);
|
||||
keyboardManagerState.ClearOSLevelShortcuts();
|
||||
keyboardManagerState.ClearAppSpecificShortcuts();
|
||||
if (remapShortcutsData)
|
||||
{
|
||||
// Load os level shortcut remaps
|
||||
try
|
||||
{
|
||||
auto globalRemapShortcuts = remapShortcutsData.GetNamedArray(KeyboardManagerConstants::GlobalRemapShortcutsSettingName);
|
||||
for (const auto& it : globalRemapShortcuts)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto originalKeys = it.GetObjectW().GetNamedString(KeyboardManagerConstants::OriginalKeysSettingName);
|
||||
auto newRemapKeys = it.GetObjectW().GetNamedString(KeyboardManagerConstants::NewRemapKeysSettingName);
|
||||
|
||||
// If remapped to a shortcut
|
||||
if (std::wstring(newRemapKeys).find(L";") != std::string::npos)
|
||||
{
|
||||
keyboardManagerState.AddOSLevelShortcut(Shortcut(originalKeys.c_str()), Shortcut(newRemapKeys.c_str()));
|
||||
}
|
||||
|
||||
// If remapped to a key
|
||||
else
|
||||
{
|
||||
keyboardManagerState.AddOSLevelShortcut(Shortcut(originalKeys.c_str()), std::stoul(newRemapKeys.c_str()));
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper Key Data JSON. Try the next shortcut.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper JSON format for os level shortcut remaps. Skip to next remap type
|
||||
}
|
||||
|
||||
// Load app specific shortcut remaps
|
||||
try
|
||||
{
|
||||
auto appSpecificRemapShortcuts = remapShortcutsData.GetNamedArray(KeyboardManagerConstants::AppSpecificRemapShortcutsSettingName);
|
||||
for (const auto& it : appSpecificRemapShortcuts)
|
||||
{
|
||||
try
|
||||
{
|
||||
auto originalKeys = it.GetObjectW().GetNamedString(KeyboardManagerConstants::OriginalKeysSettingName);
|
||||
auto newRemapKeys = it.GetObjectW().GetNamedString(KeyboardManagerConstants::NewRemapKeysSettingName);
|
||||
auto targetApp = it.GetObjectW().GetNamedString(KeyboardManagerConstants::TargetAppSettingName);
|
||||
|
||||
// If remapped to a shortcut
|
||||
if (std::wstring(newRemapKeys).find(L";") != std::string::npos)
|
||||
{
|
||||
keyboardManagerState.AddAppSpecificShortcut(targetApp.c_str(), Shortcut(originalKeys.c_str()), Shortcut(newRemapKeys.c_str()));
|
||||
}
|
||||
|
||||
// If remapped to a key
|
||||
else
|
||||
{
|
||||
keyboardManagerState.AddAppSpecificShortcut(targetApp.c_str(), Shortcut(originalKeys.c_str()), std::stoul(newRemapKeys.c_str()));
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper Key Data JSON. Try the next shortcut.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper JSON format for os level shortcut remaps. Skip to next remap type
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Improper JSON format for shortcut remaps. Skip to next remap type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
// Unable to load inital config.
|
||||
}
|
||||
}
|
||||
|
||||
// Destroy the powertoy and free memory
|
||||
virtual void destroy() override
|
||||
{
|
||||
stop_lowlevel_keyboard_hook();
|
||||
delete this;
|
||||
}
|
||||
|
||||
@@ -259,36 +83,8 @@ public:
|
||||
}
|
||||
|
||||
// Signal from the Settings editor to call a custom action.
|
||||
// This can be used to spawn more complex editors.
|
||||
virtual void call_custom_action(const wchar_t* action) override
|
||||
{
|
||||
static UINT custom_action_num_calls = 0;
|
||||
try
|
||||
{
|
||||
// Parse the action values, including name.
|
||||
PowerToysSettings::CustomActionObject action_object =
|
||||
PowerToysSettings::CustomActionObject::from_json_string(action);
|
||||
HINSTANCE hInstance = reinterpret_cast<HINSTANCE>(&__ImageBase);
|
||||
|
||||
if (action_object.get_name() == L"RemapKeyboard")
|
||||
{
|
||||
if (!CheckEditKeyboardWindowActive() && !CheckEditShortcutsWindowActive())
|
||||
{
|
||||
std::thread(createEditKeyboardWindow, hInstance, std::ref(keyboardManagerState)).detach();
|
||||
}
|
||||
}
|
||||
else if (action_object.get_name() == L"EditShortcut")
|
||||
{
|
||||
if (!CheckEditKeyboardWindowActive() && !CheckEditShortcutsWindowActive())
|
||||
{
|
||||
std::thread(createEditShortcutsWindow, hInstance, std::ref(keyboardManagerState)).detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (std::exception&)
|
||||
{
|
||||
// Improper JSON.
|
||||
}
|
||||
}
|
||||
|
||||
// Called by the runner to pass the updated settings values as a serialized JSON.
|
||||
@@ -316,8 +112,30 @@ public:
|
||||
m_enabled = true;
|
||||
// Log telemetry
|
||||
Trace::EnableKeyboardManager(true);
|
||||
// Start keyboard hook
|
||||
start_lowlevel_keyboard_hook();
|
||||
|
||||
unsigned long powertoys_pid = GetCurrentProcessId();
|
||||
std::wstring executable_args = L"";
|
||||
executable_args.append(std::to_wstring(powertoys_pid));
|
||||
|
||||
SHELLEXECUTEINFOW sei{ sizeof(sei) };
|
||||
sei.fMask = { SEE_MASK_NOCLOSEPROCESS | SEE_MASK_FLAG_NO_UI };
|
||||
sei.lpFile = L"modules\\KeyboardManager\\KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe";
|
||||
sei.nShow = SW_SHOWNORMAL;
|
||||
sei.lpParameters = executable_args.data();
|
||||
if (ShellExecuteExW(&sei) == false)
|
||||
{
|
||||
Logger::error(L"Failed to start keyboard manager engine");
|
||||
auto message = get_last_error_message(GetLastError());
|
||||
if (message.has_value())
|
||||
{
|
||||
Logger::error(message.value());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
m_hProcess = sei.hProcess;
|
||||
SetPriorityClass(m_hProcess, REALTIME_PRIORITY_CLASS);
|
||||
}
|
||||
}
|
||||
|
||||
// Disable the powertoy
|
||||
@@ -326,11 +144,12 @@ public:
|
||||
m_enabled = false;
|
||||
// Log telemetry
|
||||
Trace::EnableKeyboardManager(false);
|
||||
// Close active windows
|
||||
CloseActiveEditKeyboardWindow();
|
||||
CloseActiveEditShortcutsWindow();
|
||||
// Stop keyboard hook
|
||||
stop_lowlevel_keyboard_hook();
|
||||
|
||||
if (m_hProcess)
|
||||
{
|
||||
TerminateProcess(m_hProcess, 0);
|
||||
m_hProcess = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns if the powertoys is enabled
|
||||
@@ -338,142 +157,8 @@ public:
|
||||
{
|
||||
return m_enabled;
|
||||
}
|
||||
|
||||
// Hook procedure definition
|
||||
static LRESULT CALLBACK hook_proc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
LowlevelKeyboardEvent event;
|
||||
if (nCode == HC_ACTION)
|
||||
{
|
||||
event.lParam = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
|
||||
event.wParam = wParam;
|
||||
if (keyboardmanager_object_ptr->HandleKeyboardHookEvent(&event) == 1)
|
||||
{
|
||||
// Reset Num Lock whenever a NumLock key down event is suppressed since Num Lock key state change occurs before it is intercepted by low level hooks
|
||||
if (event.lParam->vkCode == VK_NUMLOCK && (event.wParam == WM_KEYDOWN || event.wParam == WM_SYSKEYDOWN) && event.lParam->dwExtraInfo != KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
|
||||
{
|
||||
KeyboardEventHandlers::SetNumLockToPreviousState(keyboardmanager_object_ptr->inputHandler);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return CallNextHookEx(hook_handle_copy, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
void start_lowlevel_keyboard_hook()
|
||||
{
|
||||
#if defined(DISABLE_LOWLEVEL_HOOKS_WHEN_DEBUGGED)
|
||||
if (IsDebuggerPresent())
|
||||
{
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
if (!hook_handle)
|
||||
{
|
||||
hook_handle = SetWindowsHookEx(WH_KEYBOARD_LL, hook_proc, GetModuleHandle(NULL), NULL);
|
||||
hook_handle_copy = hook_handle;
|
||||
if (!hook_handle)
|
||||
{
|
||||
DWORD errorCode = GetLastError();
|
||||
show_last_error_message(L"SetWindowsHookEx", errorCode, L"PowerToys - Keyboard Manager");
|
||||
auto errorMessage = get_last_error_message(errorCode);
|
||||
Trace::Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"start_lowlevel_keyboard_hook.SetWindowsHookEx");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to terminate the low level hook
|
||||
void stop_lowlevel_keyboard_hook()
|
||||
{
|
||||
if (hook_handle)
|
||||
{
|
||||
UnhookWindowsHookEx(hook_handle);
|
||||
hook_handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Function called by the hook procedure to handle the events. This is the starting point function for remapping
|
||||
intptr_t HandleKeyboardHookEvent(LowlevelKeyboardEvent* data) noexcept
|
||||
{
|
||||
// If remappings are disabled (due to the remap tables getting updated) skip the rest of the hook
|
||||
if (!keyboardManagerState.AreRemappingsEnabled())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If key has suppress flag, then suppress it
|
||||
if (data->lParam->dwExtraInfo == KeyboardManagerConstants::KEYBOARDMANAGER_SUPPRESS_FLAG)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If the Detect Key Window is currently activated, then suppress the keyboard event
|
||||
KeyboardManagerHelper::KeyboardHookDecision singleKeyRemapUIDetected = keyboardManagerState.DetectSingleRemapKeyUIBackend(data);
|
||||
if (singleKeyRemapUIDetected == KeyboardManagerHelper::KeyboardHookDecision::Suppress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else if (singleKeyRemapUIDetected == KeyboardManagerHelper::KeyboardHookDecision::SkipHook)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// If the Detect Shortcut Window from Remap Keys is currently activated, then suppress the keyboard event
|
||||
KeyboardManagerHelper::KeyboardHookDecision remapKeyShortcutUIDetected = keyboardManagerState.DetectShortcutUIBackend(data, true);
|
||||
if (remapKeyShortcutUIDetected == KeyboardManagerHelper::KeyboardHookDecision::Suppress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else if (remapKeyShortcutUIDetected == KeyboardManagerHelper::KeyboardHookDecision::SkipHook)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Remap a key
|
||||
intptr_t SingleKeyRemapResult = KeyboardEventHandlers::HandleSingleKeyRemapEvent(inputHandler, data, keyboardManagerState);
|
||||
|
||||
// Single key remaps have priority. If a key is remapped, only the remapped version should be visible to the shortcuts and hence the event should be suppressed here.
|
||||
if (SingleKeyRemapResult == 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// If the Detect Shortcut Window is currently activated, then suppress the keyboard event
|
||||
KeyboardManagerHelper::KeyboardHookDecision shortcutUIDetected = keyboardManagerState.DetectShortcutUIBackend(data, false);
|
||||
if (shortcutUIDetected == KeyboardManagerHelper::KeyboardHookDecision::Suppress)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else if (shortcutUIDetected == KeyboardManagerHelper::KeyboardHookDecision::SkipHook)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* This feature has not been enabled (code from proof of concept stage)
|
||||
*
|
||||
//// Remap a key to behave like a modifier instead of a toggle
|
||||
//intptr_t SingleKeyToggleToModResult = KeyboardEventHandlers::HandleSingleKeyToggleToModEvent(inputHandler, data, keyboardManagerState);
|
||||
*/
|
||||
|
||||
// Handle an app-specific shortcut remapping
|
||||
intptr_t AppSpecificShortcutRemapResult = KeyboardEventHandlers::HandleAppSpecificShortcutRemapEvent(inputHandler, data, keyboardManagerState);
|
||||
|
||||
// If an app-specific shortcut is remapped then the os-level shortcut remapping should be suppressed.
|
||||
if (AppSpecificShortcutRemapResult == 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Handle an os-level shortcut remapping
|
||||
return KeyboardEventHandlers::HandleOSLevelShortcutRemapEvent(inputHandler, data, keyboardManagerState);
|
||||
}
|
||||
};
|
||||
|
||||
HHOOK KeyboardManager::hook_handle = nullptr;
|
||||
HHOOK KeyboardManager::hook_handle_copy = nullptr;
|
||||
KeyboardManager* KeyboardManager::keyboardmanager_object_ptr = nullptr;
|
||||
|
||||
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
|
||||
{
|
||||
return new KeyboardManager();
|
||||
|
||||
Reference in New Issue
Block a user