Keep controller bindings / support taking screenshots

whoop whoop
pull/130/head
Peter Repukat 3 years ago
parent feb25b9440
commit 07d29acbc0

@ -80,14 +80,14 @@
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
<LinkIncremental>true</LinkIncremental>
<ExternalIncludePath>..\deps\SFML\include;..\deps\WinReg;..\deps\spdlog\include;..\deps\ValveFileVDF;$(ExternalIncludePath)</ExternalIncludePath>
<ExternalIncludePath>..\deps\SFML\include;..\deps\WinReg;..\deps\spdlog\include;..\deps\ValveFileVDF;..\deps\subhook;$(ExternalIncludePath)</ExternalIncludePath>
<LibraryPath>..\deps\SFML\out\build\x64-Debug\lib;$(LibraryPath)</LibraryPath>
<CopyLocalProjectReference>false</CopyLocalProjectReference>
<CopyLocalDeploymentContent>true</CopyLocalDeploymentContent>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
<LinkIncremental>false</LinkIncremental>
<ExternalIncludePath>..\deps\SFML\include;..\deps\WinReg;..\deps\spdlog\include;..\deps\subhook;$(ExternalIncludePath)</ExternalIncludePath>
<ExternalIncludePath>..\deps\SFML\include;..\deps\WinReg;..\deps\spdlog\include;..\deps\ValveFileVDF;..\deps\subhook;$(ExternalIncludePath)</ExternalIncludePath>
<LibraryPath>..\deps\SFML\out\build\x64-Release\lib;$(LibraryPath)</LibraryPath>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
@ -122,7 +122,7 @@
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_CONSOLE;SPDLOG_WCHAR_TO_UTF8_SUPPORT;_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<PreprocessorDefinitions>_DEBUG;SPDLOG_WCHAR_TO_UTF8_SUPPORT;_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING;SUBHOOK_STATIC;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
@ -138,7 +138,7 @@
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_CONSOLE;SPDLOG_WCHAR_TO_UTF8_SUPPORT;_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<PreprocessorDefinitions>NDEBUG;SPDLOG_WCHAR_TO_UTF8_SUPPORT;_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING;SUBHOOK_STATIC;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<LanguageStandard>stdcpp20</LanguageStandard>
</ClCompile>
@ -150,12 +150,14 @@
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="..\deps\subhook\subhook.c" />
<ClCompile Include="main.cpp" />
<ClCompile Include="OverlayDetector.cpp" />
<ClCompile Include="SteamTarget.cpp" />
<ClCompile Include="TargetWindow.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\deps\subhook\subhook.h" />
<ClInclude Include="OverlayDetector.h" />
<ClInclude Include="SteamTarget.h" />
<ClInclude Include="steam_sf_keymap.h" />

@ -27,6 +27,9 @@
<ClCompile Include="OverlayDetector.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\deps\subhook\subhook.c">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="SteamTarget.h">
@ -41,6 +44,9 @@
<ClInclude Include="steam_sf_keymap.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="..\deps\subhook\subhook.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<None Include="..\deps\SFML\out\build\x64-Debug\lib\sfml-system-d-2.dll" />

@ -24,16 +24,33 @@ limitations under the License.
#include <spdlog/spdlog.h>
#include <vdf_parser.hpp>
#include <subhook.h>
#ifdef _WIN32
subhook::Hook getFgWinHook;
static HWND target_hwnd = nullptr;
HWND keepForegroundWindow()
{
return target_hwnd;
}
#endif
SteamTarget::SteamTarget(int argc, char *argv[])
: window_([this] { run_ = false; }),
: window_([this] { run_ = false; }, getScreenshotHotkey()),
detector_([this](bool overlay_open) { onOverlayChanged(overlay_open); }), target_window_handle_(window_.getSystemHandle())
{
#ifdef _WIN32
target_hwnd = target_window_handle_;
#endif
}
int SteamTarget::run()
{
run_ = true;
window_.setFpsLimit(90);
keepControllerConfig(true);
while (run_) {
detector_.update();
window_.update();
@ -57,11 +74,17 @@ void SteamTarget::focusWindow(WindowHandle hndl)
{
#ifdef _WIN32
//MH_DisableHook(&GetForegroundWindow); // TODO: when GetForegroundWindow hooked, unhook!
// store last focused window for later restore
if (hndl == target_window_handle_) {
spdlog::info("Bring own window to foreground");
}
else {
spdlog::info("Bring window \"{:#x}\" to foreground", reinterpret_cast<uint64_t>(hndl));
}
keepControllerConfig(false); // unhook GetForegroundWindow
last_foreground_window_ = GetForegroundWindow();
const DWORD fg_thread = GetWindowThreadProcessId(last_foreground_window_, nullptr);
//MH_EnableHook(&GetForegroundWindow); // TODO: when GetForegroundWindow hooked, re-hook!
keepControllerConfig(true); // re-hook GetForegroundWindow
// lot's of ways actually bringing our window to foreground...
const DWORD current_thread = GetCurrentThreadId();
@ -91,7 +114,9 @@ std::wstring SteamTarget::getSteamPath()
// TODO: check if keys/value exist
// steam should always be open and have written reg values...
winreg::RegKey key{HKEY_CURRENT_USER, L"SOFTWARE\\Valve\\Steam"};
return key.GetStringValue(L"SteamPath");
const auto res = key.GetStringValue(L"SteamPath");
spdlog::info(L"Detected Steam Path: {}", res);
return res;
#else
return L""; // TODO
#endif
@ -103,7 +128,9 @@ std::wstring SteamTarget::getSteamUserId()
// TODO: check if keys/value exist
// steam should always be open and have written reg values...
winreg::RegKey key{HKEY_CURRENT_USER, L"SOFTWARE\\Valve\\Steam\\ActiveProcess"};
return std::to_wstring(key.GetDwordValue(L"ActiveUser"));
const auto res = std::to_wstring(key.GetDwordValue(L"ActiveUser"));
spdlog::info(L"Detected Steam UserId: {}", res);
return res;
#else
return L""; // TODO
#endif
@ -111,10 +138,7 @@ std::wstring SteamTarget::getSteamUserId()
std::vector<std::string> SteamTarget::getOverlayHotkey()
{
const auto steam_path = getSteamPath();
const auto user_id = getSteamUserId();
const auto config_path = steam_path + std::wstring(user_data_path_) + user_id + std::wstring(config_file_name_);
const auto config_path = steam_path_ + std::wstring(user_data_path_) + steam_user_id_ + std::wstring(config_file_name_);
std::ifstream config_file(config_path);
// TODO: check if file exists
auto root = tyti::vdf::read(config_file);
@ -125,7 +149,42 @@ std::vector<std::string> SteamTarget::getOverlayHotkey()
// has anyone more than 4 keys to open overlay?!
std::smatch m;
if (!std::regex_match(hotkeys, m, std::regex(R"((\w*)\s*(\w*)\s*(\w*)\s*(\w*))"))) {
return {"Shift", "KEY_TAB"};
spdlog::warn("Couldn't detect overlay hotkey, using default: Shift+Tab");
return {"Shift", "KEY_TAB"}; // default
}
std::vector<std::string> res;
for (auto i = 1; i < m.size(); i++) {
const auto s = std::string(m[i]);
if (!s.empty()) {
res.push_back(s);
}
}
if (res.empty()) {
spdlog::warn("Couldn't detect overlay hotkey, using default: Shift+Tab");
return {"Shift", "KEY_TAB"}; // default
}
spdlog::info("Detected Overlay hotkey(s): {}", std::accumulate(
res.begin() + 1, res.end(), res[0],
[](auto acc, const auto curr) { return acc += "+" + curr; }));
return res;
}
std::vector<std::string> SteamTarget::getScreenshotHotkey()
{
const auto config_path = steam_path_ + std::wstring(user_data_path_) + steam_user_id_ + std::wstring(config_file_name_);
std::ifstream config_file(config_path);
// TODO: check if file exists
auto root = tyti::vdf::read(config_file);
auto children = root.childs["system"];
auto hotkeys = children->attribs.at("InGameOverlayScreenshotHotKey");
// has anyone more than 4 keys to screenshot?!
std::smatch m;
if (!std::regex_match(hotkeys, m, std::regex(R"((\w*)\s*(\w*)\s*(\w*)\s*(\w*))"))) {
spdlog::warn("Couldn't detect overlay hotkey, using default: F12");
return {"KEY_F12"}; //default
}
std::vector<std::string> res;
@ -135,39 +194,63 @@ std::vector<std::string> SteamTarget::getOverlayHotkey()
res.push_back(s);
}
}
spdlog::info("Detected Overlay hotkeys: {}", std::accumulate(
res.begin() + 1, res.end(), res[0],
[](auto acc, const auto curr) { return acc += "+" + curr; }));
if (res.empty()) {
spdlog::warn("Couldn't detect overlay hotkey, using default: F12");
return {"KEY_F12"}; //default
}
spdlog::info("Detected screenshot hotkey(s): {}", std::accumulate(
res.begin() + 1, res.end(), res[0],
[](auto acc, const auto curr) { return acc += "+" + curr; }));
return res;
}
void SteamTarget::keepControllerConfig(bool keep)
{
#ifdef _WIN32
if (keep && !getFgWinHook.IsInstalled()) {
spdlog::debug("Hooking GetForegroudnWindow (in own process)");
getFgWinHook.Install(&GetForegroundWindow, &keepForegroundWindow, subhook::HookFlags::HookFlag64BitOffset);
if (!getFgWinHook.IsInstalled()) {
spdlog::error("Couldn't install GetForegroundWindow hook!");
}
}
else if (!keep && getFgWinHook.IsInstalled()) {
spdlog::debug("Un-Hooking GetForegroudnWindow (in own process)");
getFgWinHook.Remove();
if (getFgWinHook.IsInstalled()) {
spdlog::error("Couldn't un-install GetForegroundWindow hook!");
}
}
#endif
}
void SteamTarget::overlayHotkeyWorkaround()
{
static bool pressed = false;
if (std::all_of(overlay_hotkey_.begin(), overlay_hotkey_.end(),
[](const auto &key) {
return sf::Keyboard::isKeyPressed(keymap::sfkey[key]);
})) {
if (std::ranges::all_of(overlay_hotkey_,
[](const auto &key) {
return sf::Keyboard::isKeyPressed(keymap::sfkey[key]);
})) {
spdlog::debug("Detected overlay hotkey(s)");
pressed = true;
std::for_each(
overlay_hotkey_.begin(), overlay_hotkey_.end(), [this](const auto &key) {
std::ranges::for_each(overlay_hotkey_, [this](const auto &key) {
#ifdef _WIN32
PostMessage(target_window_handle_, WM_KEYDOWN, keymap::winkey[key], 0);
PostMessage(target_window_handle_, WM_KEYDOWN, keymap::winkey[key], 0);
#else
#endif
});
});
spdlog::debug("Sending Overlay KeyDown events...");
} else if (pressed) {
}
else if (pressed) {
pressed = false;
std::for_each(
overlay_hotkey_.begin(), overlay_hotkey_.end(), [this](const auto &key) {
std::ranges::for_each(overlay_hotkey_, [this](const auto &key) {
#ifdef _WIN32
PostMessage(target_window_handle_, WM_KEYUP, keymap::winkey[key], 0);
PostMessage(target_window_handle_, WM_KEYUP, keymap::winkey[key], 0);
#else
#endif
});
});
spdlog::debug("Sending Overlay KeyUp events...");
}
}

@ -16,8 +16,8 @@ limitations under the License.
#pragma once
#include "OverlayDetector.h"
#include "TargetWindow.h"
#include "TargetWindow.h"
class SteamTarget {
public:
@ -29,8 +29,16 @@ class SteamTarget {
void focusWindow(WindowHandle hndl);
std::wstring getSteamPath();
std::wstring getSteamUserId();
std::wstring steam_path_ = getSteamPath();
std::wstring steam_user_id_ = getSteamUserId();
std::vector<std::string> getOverlayHotkey();
std::vector<std::string> getScreenshotHotkey();
// Keep controllerConfig even is window is switched.
// On Windoze hooking "GetForeGroundWindow" is enough;
void keepControllerConfig(bool keep);
/*
* Run once per frame
* detects steam configured overlay hotkey, and simulates key presses to window
@ -49,5 +57,6 @@ class SteamTarget {
static constexpr std::wstring_view user_data_path_ = L"/userdata/";
static constexpr std::wstring_view config_file_name_ = L"/config/localconfig.vdf";
static constexpr std::string_view hotkey_name_ = "InGameOverlayShortcutKey ";
static constexpr std::string_view overlay_hotkey_name_ = "InGameOverlayShortcutKey ";
static constexpr std::string_view screenshot_hotkey_name_ = "InGameOverlayScreenshotHotKey ";
};

@ -15,20 +15,24 @@ limitations under the License.
*/
#include "TargetWindow.h"
#include "steam_sf_keymap.h"
#include <iostream>
#include <utility>
#include <SFML/Window/Event.hpp>
#include <spdlog/spdlog.h>
#ifdef _WIN32
#include <SFML/Graphics.hpp>
#include <Windows.h>
#include <dwmapi.h>
#endif
static const bool DEV_MODE = false;
TargetWindow::TargetWindow(std::function<void()> on_close)
: on_close_(std::move(on_close))
TargetWindow::TargetWindow(std::function<void()> on_close, std::vector<std::string> screenshot_hotkey)
: on_close_(std::move(on_close)), screenshot_keys_(std::move(screenshot_hotkey))
{
if (DEV_MODE) {
window_.create(sf::VideoMode{1920, 1080}, "GlosSITarget", sf::Style::Default);
@ -82,6 +86,7 @@ void TargetWindow::update()
on_close_();
}
}
if (DEV_MODE) {
window_.clear(sf::Color(0, 0, 0, 128));
}
@ -89,6 +94,7 @@ void TargetWindow::update()
window_.clear(sf::Color::Transparent);
}
screenShotWorkaround();
window_.display();
}
@ -98,6 +104,79 @@ void TargetWindow::close()
on_close_();
}
void TargetWindow::screenShotWorkaround()
{
#ifdef _WIN32
if (std::ranges::all_of(screenshot_keys_,
[](const auto &key) {
return sf::Keyboard::isKeyPressed(keymap::sfkey[key]);
})) {
spdlog::debug("Detected screenshot hotkey(s); Taking screenshot");
// stolen from: https://en.sfml-dev.org/forums/index.php?topic=14323.15
// no time to do this shit.
HDC hScreenDC = GetDC(nullptr);
HDC hMemoryDC = CreateCompatibleDC(hScreenDC);
int width = GetDeviceCaps(hScreenDC, HORZRES);
int height = GetDeviceCaps(hScreenDC, VERTRES);
HBITMAP hBitmap = CreateCompatibleBitmap(hScreenDC, width, height);
auto hOldBitmap = static_cast<HBITMAP>(SelectObject(hMemoryDC, hBitmap));
BitBlt(hMemoryDC, 0, 0, width, height, hScreenDC, 0, 0, SRCCOPY);
hBitmap = static_cast<HBITMAP>(SelectObject(hMemoryDC, hOldBitmap));
BITMAP bm;
GetObject(hBitmap, sizeof(bm), &bm);
BITMAPINFO bmpInfo;
bmpInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmpInfo.bmiHeader.biWidth = bm.bmWidth;
bmpInfo.bmiHeader.biHeight = -bm.bmHeight;
bmpInfo.bmiHeader.biPlanes = 1;
bmpInfo.bmiHeader.biBitCount = 32;
bmpInfo.bmiHeader.biCompression = BI_RGB;
bmpInfo.bmiHeader.biSizeImage = 0;
bmpInfo.bmiHeader.biClrImportant = 0;
std::vector<COLORREF> pixel;
pixel.resize(bm.bmWidth * bm.bmHeight);
sf::Image captureImage;
captureImage.create(bm.bmWidth, bm.bmHeight, sf::Color::Black);
GetDIBits(hMemoryDC, hBitmap, 0, bm.bmHeight, pixel.data(), &bmpInfo, DIB_RGB_COLORS);
unsigned int j = 0;
for (unsigned int y = 0; y < bm.bmHeight; ++y) {
for (unsigned int x = 0; x < bm.bmWidth; ++x) {
const COLORREF this_color = pixel[j++];
captureImage.setPixel(x, y, sf::Color(GetBValue(this_color), GetGValue(this_color), GetRValue(this_color)));
}
}
ReleaseDC(NULL, hScreenDC);
DeleteObject(hBitmap);
DeleteDC(hMemoryDC);
DeleteDC(hScreenDC);
sf::Sprite sprite;
sf::Texture texture;
texture.loadFromImage(captureImage);
sprite.setTexture(texture);
spdlog::debug("Sending screenshot key events and rendering screen...");
std::ranges::for_each(screenshot_keys_, [this](const auto &key) {
PostMessage(window_.getSystemHandle(), WM_KEYDOWN, keymap::winkey[key], 0);
});
std::ranges::for_each(screenshot_keys_, [this](const auto &key) {
PostMessage(window_.getSystemHandle(), WM_KEYUP, keymap::winkey[key], 0);
});
//actually run event loop, so steam gets notified about keys.
sf::Event event{};
while (window_.pollEvent(event)) {
}
// steam takes screenshot on next frame, so render our screenshot and dipslay...
window_.clear(sf::Color::Black);
window_.draw(sprite);
window_.display();
// finally, draw another transparent frame.
window_.clear(sf::Color::Transparent);
}
#endif
}
WindowHandle TargetWindow::getSystemHandle() const
{
return window_.getSystemHandle();

@ -29,17 +29,32 @@ using WindowHandle = int; // ???
class TargetWindow {
public:
explicit TargetWindow(std::function<void()> on_close = []() {});
explicit TargetWindow(
std::function<void()> on_close = []() {}, std::vector<std::string> screenshot_hotkey = {"KEY_F12"});
void setFpsLimit(unsigned int fps_limit);
void setClickThrough(bool click_through);
void update();
void close();
/*
* Run once per frame
* - detects steam configured screenshot hotkey
* - takes actual screenshot
* - renders it to window
* - simulates screenshot keys
* - Wait a few millis...
* (- steam takes screenshot)
* - return to normal
*
*/
void screenShotWorkaround();
WindowHandle getSystemHandle() const;
private:
const std::function<void()> on_close_;
sf::RenderWindow window_;
std::vector<std::string> screenshot_keys_;
};

@ -24,19 +24,21 @@ limitations under the License.
#include <spdlog/sinks/stdout_color_sinks.h>
#include <spdlog/spdlog.h>
//int CALLBACK WinMain(
// _In_ HINSTANCE hInstance,
// _In_ HINSTANCE hPrevInstance,
// _In_ LPSTR lpCmdLine,
// _In_ int nCmdShow
//)
//{
// SteamTarget target(__argc, __argv);
// target.init();
// return SteamTarget::exec();
//}
#define CONSOLE
#ifdef _WIN32
#ifdef CONSOLE
int main(int argc, char *argv[])
#else
int CALLBACK WinMain(
_In_ HINSTANCE hInstance,
_In_ HINSTANCE hPrevInstance,
_In_ LPSTR lpCmdLine,
_In_ int nCmdShow
)
#endif
#else
int main(int argc, char *argv[])
#endif
{
const auto console_sink = std::make_shared<spdlog::sinks::stderr_color_sink_mt>();
console_sink->set_level(spdlog::level::trace);
@ -51,8 +53,11 @@ int main(int argc, char *argv[])
logger->set_level(spdlog::level::trace);
logger->flush_on(spdlog::level::info);
spdlog::set_default_logger(logger);
#ifdef _WIN32
SteamTarget target(__argc, __argv);
#else
SteamTarget target(argc, argv);
#endif
const auto exit = target.run();
spdlog::shutdown();
return exit;

@ -5,11 +5,13 @@
#define QQ(x) #x
#define QUOTE(x) QQ(x)
#define KEYCONVSF(KEY) \
{ QUOTE(KEY), sf::Keyboard::Key::KEY }
#define KEYCONVSF(KEY) \
{ \
QUOTE(KEY), sf::Keyboard::Key::KEY \
}
namespace keymap {
std::unordered_map<std::string, sf::Keyboard::Key> sfkey = {
static std::unordered_map<std::string, sf::Keyboard::Key> sfkey = {
{"Shift", sf::Keyboard::Key::LShift},
{"Alt", sf::Keyboard::Key::LAlt},
{"Ctrl", sf::Keyboard::Key::LControl},
@ -56,20 +58,31 @@ std::unordered_map<std::string, sf::Keyboard::Key> sfkey = {
KEYCONVSF(W),
KEYCONVSF(X),
KEYCONVSF(Y),
KEYCONVSF(Z)
};
KEYCONVSF(Z),
{"KEY_F1", sf::Keyboard::Key::F1},
{"KEY_F2", sf::Keyboard::Key::F2},
{"KEY_F3", sf::Keyboard::Key::F3},
{"KEY_F4", sf::Keyboard::Key::F4},
{"KEY_F5", sf::Keyboard::Key::F5},
{"KEY_F6", sf::Keyboard::Key::F6},
{"KEY_F7", sf::Keyboard::Key::F7},
{"KEY_F8", sf::Keyboard::Key::F8},
{"KEY_F9", sf::Keyboard::Key::F9},
{"KEY_F10", sf::Keyboard::Key::F10},
{"KEY_F11", sf::Keyboard::Key::F11},
{"KEY_F12", sf::Keyboard::Key::F12}};
#ifdef _WIN32
#define NOMINMAX
#include <Windows.h>
//yep.. there are smarter ways to tho this...
std::unordered_map<std::string, uint8_t> winkey = {
static std::unordered_map<std::string, uint8_t> winkey = {
{"Shift", VK_SHIFT},
{"Alt", VK_MENU},
{"Ctrl", VK_CONTROL},
{"Del", VK_DELETE},
{"Ins",VK_INSERT},
{"Home",VK_HOME},
{"Ins", VK_INSERT},
{"Home", VK_HOME},
{"Space", VK_SPACE},
{"Backspace", VK_BACK},
{"Enter", VK_RETURN},
@ -110,8 +123,18 @@ std::unordered_map<std::string, uint8_t> winkey = {
{"KEY_W", 0x57},
{"KEY_X", 0x58},
{"KEY_Y", 0x59},
{"KEY_Z", 0x5A}
};
{"KEY_Z", 0x5A},
{"KEY_F1", VK_F1},
{"KEY_F2", VK_F2},
{"KEY_F3", VK_F3},
{"KEY_F4", VK_F4},
{"KEY_F5", VK_F5},
{"KEY_F6", VK_F6},
{"KEY_F7", VK_F7},
{"KEY_F8", VK_F8},
{"KEY_F9", VK_F9},
{"KEY_F10", VK_F10},
{"KEY_F11", VK_F11},
{"KEY_F12", VK_F12}};
#endif
}
} // namespace keymap

Loading…
Cancel
Save