diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 0f7cbe60e8..ca359669c1 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -39,6 +39,7 @@ ALLOWUNDO ALPHATYPE Altdown altform +alwaysontop amd Amicrosoft AModifier @@ -1169,6 +1170,7 @@ mlcfg mmdeviceapi mmi mmsys +mmsystem mockapi MODECHANGE moderncop @@ -1325,6 +1327,7 @@ NOTIFYICONDATAW NOTIMPL notmatch Noto +NOTOPMOST NOTRACK NOUPDATE NOZORDER @@ -1889,6 +1892,8 @@ SVGIO svgpreviewhandler SWC SWFO +Switchbetweenvirtualdesktops +SWITCHEND SWP swprintf SWRESTORE @@ -1909,6 +1914,7 @@ SYSKEYUP syslog SYSMENU systemd +SYSTEMASTERISK SYSTEMTIME Tadele tadele @@ -2188,6 +2194,7 @@ Winhook winkey WINL winmd +winmm WINMSAPP winnt winres @@ -2219,7 +2226,7 @@ WNDPROC wofstream wordpad workaround -Workflow +workflow workspaces wostream wostringstream diff --git a/.pipelines/pipeline.user.windows.yml b/.pipelines/pipeline.user.windows.yml index 683d859203..7adbc89430 100644 --- a/.pipelines/pipeline.user.windows.yml +++ b/.pipelines/pipeline.user.windows.yml @@ -90,6 +90,8 @@ build: - 'modules\ColorPicker\PowerToys.Interop.dll' - 'modules\ColorPicker\Telemetry.dll' - '**\*.resources.dll' + - 'modules\AlwaysOnTop\PowerToys.AlwaysOnTop.exe' + - 'modules\AlwaysOnTop\PowerToys.AlwaysOnTopModuleInterface.dll' - 'modules\Awake\PowerToys.AwakeModuleInterface.dll' - 'modules\Awake\PowerToys.ManagedCommon.dll' - 'modules\Awake\PowerToys.ManagedTelemetry.dll' diff --git a/PowerToys.sln b/PowerToys.sln index 209b6e4af4..8d103b64d3 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -383,6 +383,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GcodePreviewHandler", "src\ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "UnitTests-GcodePreviewHandler", "src\modules\previewpane\UnitTests-GcodePreviewHandler\UnitTests-GcodePreviewHandler.csproj", "{FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "AlwaysOnTop", "AlwaysOnTop", "{60CD2D4F-C3B9-4897-9821-FCA5098B41CE}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AlwaysOnTop", "src\modules\alwaysontop\AlwaysOnTop\AlwaysOnTop.vcxproj", "{1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "AlwaysOnTopModuleInterface", "src\modules\alwaysontop\AlwaysOnTopModuleInterface\AlwaysOnTopModuleInterface.vcxproj", "{48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Community.PowerToys.Run.Plugin.WebSearch", "src\modules\launcher\Plugins\Community.PowerToys.Run.Plugin.WebSearch\Community.PowerToys.Run.Plugin.WebSearch.csproj", "{9F94B303-5E21-4364-9362-64426F8DB932}" EndProject Global @@ -1030,6 +1036,18 @@ Global {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|x64.ActiveCfg = Release|x64 {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|x64.Build.0 = Release|x64 {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3}.Release|x86.ActiveCfg = Release|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|x64.ActiveCfg = Debug|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|x64.Build.0 = Debug|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Debug|x86.ActiveCfg = Debug|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|x64.ActiveCfg = Release|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|x64.Build.0 = Release|x64 + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2}.Release|x86.ActiveCfg = Release|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|x64.ActiveCfg = Debug|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|x64.Build.0 = Debug|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Debug|x86.ActiveCfg = Debug|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|x64.ActiveCfg = Release|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|x64.Build.0 = Release|x64 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9}.Release|x86.ActiveCfg = Release|x64 {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|x64.ActiveCfg = Debug|x64 {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|x64.Build.0 = Debug|x64 {9F94B303-5E21-4364-9362-64426F8DB932}.Debug|x86.ActiveCfg = Debug|x64 @@ -1160,6 +1178,9 @@ Global {133281D8-1BCE-4D07-B31E-796612A9609E} = {2F305555-C296-497E-AC20-5FA1B237996A} {805306FF-A562-4415-8DEF-E493BDC45918} = {2F305555-C296-497E-AC20-5FA1B237996A} {FCF3E52D-B80A-4FC3-98FD-6391354F0EE3} = {2F305555-C296-497E-AC20-5FA1B237996A} + {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} = {4574FDD0-F61D-4376-98BF-E5A1262C11EC} + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2} = {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9} = {60CD2D4F-C3B9-4897-9821-FCA5098B41CE} {9F94B303-5E21-4364-9362-64426F8DB932} = {4AFC9975-2456-4C70-94A4-84073C1CED93} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/doc/images/icons/Always On Top.png b/doc/images/icons/Always On Top.png new file mode 100644 index 0000000000..ea782ae9f3 Binary files /dev/null and b/doc/images/icons/Always On Top.png differ diff --git a/doc/images/overview/AlwaysOnTop_large.png b/doc/images/overview/AlwaysOnTop_large.png new file mode 100644 index 0000000000..88754c3697 Binary files /dev/null and b/doc/images/overview/AlwaysOnTop_large.png differ diff --git a/doc/images/overview/AlwaysOnTop_small.png b/doc/images/overview/AlwaysOnTop_small.png new file mode 100644 index 0000000000..3172e4804a Binary files /dev/null and b/doc/images/overview/AlwaysOnTop_small.png differ diff --git a/doc/images/overview/Original/AlwaysOnTop.png b/doc/images/overview/Original/AlwaysOnTop.png new file mode 100644 index 0000000000..7533cf2845 Binary files /dev/null and b/doc/images/overview/Original/AlwaysOnTop.png differ diff --git a/installer/PowerToysSetup/Product.wxs b/installer/PowerToysSetup/Product.wxs index 2bdbb9619a..fdfb02fdfd 100644 --- a/installer/PowerToysSetup/Product.wxs +++ b/installer/PowerToysSetup/Product.wxs @@ -11,6 +11,7 @@ + @@ -304,6 +305,10 @@ + + + + @@ -830,6 +835,14 @@ + + + + + + + + @@ -877,21 +890,21 @@ - + - + - + @@ -989,6 +1002,7 @@ + diff --git a/installer/PowerToysSetupCustomActions/CustomAction.cpp b/installer/PowerToysSetupCustomActions/CustomAction.cpp index b05a79a556..b858a35cb9 100644 --- a/installer/PowerToysSetupCustomActions/CustomAction.cpp +++ b/installer/PowerToysSetupCustomActions/CustomAction.cpp @@ -938,7 +938,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) } processes.resize(bytes / sizeof(processes[0])); - std::array processesToTerminate = { + std::array processesToTerminate = { L"PowerToys.PowerLauncher.exe", L"PowerToys.Settings.exe", L"PowerToys.Awake.exe", @@ -946,6 +946,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall) L"PowerToys.Settings.UI.exe", L"PowerToys.FancyZonesEditor.exe", L"PowerToys.ColorPickerUI.exe", + L"PowerToys.AlwaysOnTop.exe", L"PowerToys.exe" }; diff --git a/src/common/logger/logger_settings.h b/src/common/logger/logger_settings.h index b88af2bc77..e45e79c042 100644 --- a/src/common/logger/logger_settings.h +++ b/src/common/logger/logger_settings.h @@ -28,6 +28,8 @@ struct LogSettings inline const static std::string findMyMouseLoggerName = "find-my-mouse"; inline const static std::string mouseHighlighterLoggerName = "mouse-highlighter"; inline const static std::string powerRenameLoggerName = "powerrename"; + inline const static std::string alwaysOnTopLoggerName = "always-on-top"; + inline const static std::wstring alwaysOnTopLogPath = L"always-on-top-log.txt"; inline const static int retention = 30; std::wstring logLevel; LogSettings(); diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp new file mode 100644 index 0000000000..841d9009e0 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.cpp @@ -0,0 +1,415 @@ +#include "pch.h" +#include "AlwaysOnTop.h" + +#include +#include +#include +#include +#include + +#include + +namespace NonLocalizable +{ + const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow"; +} + +// TODO: move to common utils +bool find_app_name_in_path(const std::wstring& where, const std::vector& what) +{ + for (const auto& row : what) + { + const auto pos = where.rfind(row); + const auto last_slash = where.rfind('\\'); + //Check that row occurs in where, and its last occurrence contains in itself the first character after the last backslash. + if (pos != std::wstring::npos && pos <= last_slash + 1 && pos + row.length() > last_slash) + { + return true; + } + } + return false; +} + +bool isExcluded(HWND window) +{ + auto processPath = get_process_path(window); + CharUpperBuffW(processPath.data(), (DWORD)processPath.length()); + return find_app_name_in_path(processPath, AlwaysOnTopSettings::settings().excludedApps); +} + +AlwaysOnTop::AlwaysOnTop() : + SettingsObserver({SettingId::FrameEnabled, SettingId::Hotkey, SettingId::ExcludeApps}), + m_hinstance(reinterpret_cast(&__ImageBase)) +{ + s_instance = this; + DPIAware::EnableDPIAwarenessForThisProcess(); + + if (InitMainWindow()) + { + InitializeWinhookEventIds(); + + AlwaysOnTopSettings::instance().InitFileWatcher(); + AlwaysOnTopSettings::instance().LoadSettings(); + + RegisterHotkey(); + SubscribeToEvents(); + StartTrackingTopmostWindows(); + } + else + { + Logger::error("Failed to init AlwaysOnTop module"); + // TODO: show localized message + } +} + +AlwaysOnTop::~AlwaysOnTop() +{ + CleanUp(); +} + +bool AlwaysOnTop::InitMainWindow() +{ + WNDCLASSEXW wcex{}; + wcex.cbSize = sizeof(WNDCLASSEX); + wcex.lpfnWndProc = WndProc_Helper; + wcex.hInstance = m_hinstance; + wcex.lpszClassName = NonLocalizable::TOOL_WINDOW_CLASS_NAME; + RegisterClassExW(&wcex); + + m_window = CreateWindowExW(WS_EX_TOOLWINDOW, NonLocalizable::TOOL_WINDOW_CLASS_NAME, L"", WS_POPUP, 0, 0, 0, 0, nullptr, nullptr, m_hinstance, this); + if (!m_window) + { + Logger::error(L"Failed to create AlwaysOnTop window: {}", get_last_error_or_default(GetLastError())); + return false; + } + + return true; +} + +void AlwaysOnTop::SettingsUpdate(SettingId id) +{ + switch (id) + { + case SettingId::Hotkey: + { + RegisterHotkey(); + } + break; + case SettingId::FrameEnabled: + { + if (AlwaysOnTopSettings::settings().enableFrame) + { + for (auto& iter : m_topmostWindows) + { + if (!iter.second) + { + AssignBorderTracker(iter.first); + } + } + } + else + { + for (auto& iter : m_topmostWindows) + { + iter.second = nullptr; + } + } + } + break; + case SettingId::ExcludeApps: + { + std::vector toErase{}; + for (const auto& [window, tracker] : m_topmostWindows) + { + if (isExcluded(window)) + { + UnpinTopmostWindow(window); + toErase.push_back(window); + } + } + + for (const auto window: toErase) + { + m_topmostWindows.erase(window); + } + } + break; + default: + break; + } +} + +LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) noexcept +{ + if (message == WM_HOTKEY) + { + if (HWND fw{ GetForegroundWindow() }) + { + ProcessCommand(fw); + } + } + else if (message == WM_PRIV_SETTINGS_CHANGED) + { + AlwaysOnTopSettings::instance().LoadSettings(); + } + + return 0; +} + +void AlwaysOnTop::ProcessCommand(HWND window) +{ + bool gameMode = detect_game_mode(); + if (AlwaysOnTopSettings::settings().blockInGameMode && gameMode) + { + return; + } + + if (isExcluded(window)) + { + return; + } + + Sound::Type soundType = Sound::Type::Off; + bool topmost = IsTopmost(window); + if (topmost) + { + if (UnpinTopmostWindow(window)) + { + auto iter = m_topmostWindows.find(window); + if (iter != m_topmostWindows.end()) + { + m_topmostWindows.erase(iter); + } + } + } + else + { + if (PinTopmostWindow(window)) + { + soundType = Sound::Type::On; + if (AlwaysOnTopSettings::settings().enableFrame) + { + AssignBorderTracker(window); + } + else + { + m_topmostWindows[window] = nullptr; + } + } + } + + if (AlwaysOnTopSettings::settings().enableSound) + { + m_sound.Play(soundType); + } +} + +void AlwaysOnTop::StartTrackingTopmostWindows() +{ + using result_t = std::vector; + result_t result; + + auto enumWindows = [](HWND hwnd, LPARAM param) -> BOOL { + if (!IsWindowVisible(hwnd)) + { + return TRUE; + } + + if (isExcluded(hwnd)) + { + return TRUE; + } + + auto windowName = GetWindowTextLength(hwnd); + if (windowName > 0) + { + result_t& result = *reinterpret_cast(param); + result.push_back(hwnd); + } + + return TRUE; + }; + + EnumWindows(enumWindows, reinterpret_cast(&result)); + + for (HWND window : result) + { + if (IsTopmost(window)) + { + if (AlwaysOnTopSettings::settings().enableFrame) + { + AssignBorderTracker(window); + } + else + { + m_topmostWindows[window] = nullptr; + } + } + } +} + +bool AlwaysOnTop::AssignBorderTracker(HWND window) +{ + auto tracker = std::make_unique(window); + if (!tracker->Init(m_hinstance)) + { + // Failed to init tracker, reset topmost + UnpinTopmostWindow(window); + return false; + } + + if (m_virtualDesktopUtils.IsWindowOnCurrentDesktop(window)) + { + tracker->Show(); + } + + m_topmostWindows[window] = std::move(tracker); + return true; +} + +void AlwaysOnTop::RegisterHotkey() const +{ + UnregisterHotKey(m_window, static_cast(HotkeyId::Pin)); + RegisterHotKey(m_window, static_cast(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code()); +} + +void AlwaysOnTop::SubscribeToEvents() +{ + // subscribe to windows events + std::array events_to_subscribe = { + EVENT_OBJECT_LOCATIONCHANGE, + EVENT_SYSTEM_MOVESIZEEND, + EVENT_SYSTEM_SWITCHEND, + EVENT_OBJECT_DESTROY, + EVENT_OBJECT_NAMECHANGE + }; + + for (const auto event : events_to_subscribe) + { + auto hook = SetWinEventHook(event, event, nullptr, WinHookProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS); + if (hook) + { + m_staticWinEventHooks.emplace_back(hook); + } + else + { + Logger::error(L"Failed to set win event hook"); + } + } +} + +void AlwaysOnTop::UnpinAll() +{ + for (const auto& [topWindow, tracker] : m_topmostWindows) + { + if (!UnpinTopmostWindow(topWindow)) + { + Logger::error(L"Unpinning topmost window failed"); + } + } + + m_topmostWindows.clear(); +} + +void AlwaysOnTop::CleanUp() +{ + UnpinAll(); + if (m_window) + { + DestroyWindow(m_window); + m_window = nullptr; + } + + UnregisterClass(NonLocalizable::TOOL_WINDOW_CLASS_NAME, reinterpret_cast(&__ImageBase)); +} + +bool AlwaysOnTop::IsTopmost(HWND window) const noexcept +{ + int exStyle = GetWindowLong(window, GWL_EXSTYLE); + return (exStyle & WS_EX_TOPMOST) == WS_EX_TOPMOST; +} + +bool AlwaysOnTop::PinTopmostWindow(HWND window) const noexcept +{ + return SetWindowPos(window, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); +} + +bool AlwaysOnTop::UnpinTopmostWindow(HWND window) const noexcept +{ + return SetWindowPos(window, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE); +} + +bool AlwaysOnTop::IsTracked(HWND window) const noexcept +{ + auto iter = m_topmostWindows.find(window); + return (iter != m_topmostWindows.end()); +} + +void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept +{ + if (!AlwaysOnTopSettings::settings().enableFrame) + { + return; + } + + switch (data->event) + { + case EVENT_OBJECT_LOCATIONCHANGE: + case EVENT_SYSTEM_MOVESIZEEND: + { + auto iter = m_topmostWindows.find(data->hwnd); + if (iter != m_topmostWindows.end()) + { + const auto& tracker = iter->second; + tracker->UpdateBorderPosition(); + } + } + break; + case EVENT_OBJECT_DESTROY: + { + auto iter = m_topmostWindows.find(data->hwnd); + if (iter != m_topmostWindows.end()) + { + m_topmostWindows.erase(iter); + } + } + break; + case EVENT_SYSTEM_SWITCHEND: + { + auto iter = m_topmostWindows.find(data->hwnd); + if (iter != m_topmostWindows.end()) + { + const auto& tracker = iter->second; + tracker->Hide(); + } + } + break; + case EVENT_OBJECT_NAMECHANGE: + { + // The accessibility name of the desktop window changes whenever the user + // switches virtual desktops. + if (data->hwnd == GetDesktopWindow()) + { + VirtualDesktopSwitchedHandle(); + } + } + break; + default: + break; + } +} + +void AlwaysOnTop::VirtualDesktopSwitchedHandle() +{ + for (const auto& [window, tracker] : m_topmostWindows) + { + if (m_virtualDesktopUtils.IsWindowOnCurrentDesktop(window)) + { + tracker->Show(); + } + else + { + tracker->Hide(); + } + } +} \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h new file mode 100644 index 0000000000..cd320fde35 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.h @@ -0,0 +1,88 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +#include + +class AlwaysOnTop : public SettingsObserver +{ +public: + AlwaysOnTop(); + ~AlwaysOnTop(); + +protected: + static LRESULT CALLBACK WndProc_Helper(HWND window, UINT message, WPARAM wparam, LPARAM lparam) noexcept + { + auto thisRef = reinterpret_cast(GetWindowLongPtr(window, GWLP_USERDATA)); + + if (!thisRef && (message == WM_CREATE)) + { + const auto createStruct = reinterpret_cast(lparam); + thisRef = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(thisRef)); + } + + return thisRef ? thisRef->WndProc(window, message, wparam, lparam) : + DefWindowProc(window, message, wparam, lparam); + } + +private: + // IDs used to register hot keys (keyboard shortcuts). + enum class HotkeyId : int + { + Pin = 1, + }; + + static inline AlwaysOnTop* s_instance = nullptr; + std::vector m_staticWinEventHooks{}; + Sound m_sound; + VirtualDesktopUtils m_virtualDesktopUtils; + + HWND m_window{ nullptr }; + HINSTANCE m_hinstance; + std::map> m_topmostWindows{}; + + LRESULT WndProc(HWND, UINT, WPARAM, LPARAM) noexcept; + void HandleWinHookEvent(WinHookEvent* data) noexcept; + + bool InitMainWindow(); + void RegisterHotkey() const; + void SubscribeToEvents(); + + void ProcessCommand(HWND window); + void StartTrackingTopmostWindows(); + void UnpinAll(); + void CleanUp(); + + void VirtualDesktopSwitchedHandle(); + + bool IsTracked(HWND window) const noexcept; + bool IsTopmost(HWND window) const noexcept; + + bool PinTopmostWindow(HWND window) const noexcept; + bool UnpinTopmostWindow(HWND window) const noexcept; + bool AssignBorderTracker(HWND window); + + virtual void SettingsUpdate(SettingId type) override; + + static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook, + DWORD event, + HWND window, + LONG object, + LONG child, + DWORD eventThread, + DWORD eventTime) + { + WinHookEvent data{ event, window, object, child, eventThread, eventTime }; + if (s_instance) + { + s_instance->HandleWinHookEvent(&data); + } + } +}; diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj new file mode 100644 index 0000000000..e9b1e9b31b --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj @@ -0,0 +1,203 @@ + + + + + + + Debug + x64 + + + Release + x64 + + + + + + true + Use + + + + + + pch.h + Level3 + false + true + stdcpplatest + /await %(AdditionalOptions) + _UNICODE;UNICODE;%(PreprocessorDefinitions) + + + Windows + + + true + + + + + + _DEBUG;%(PreprocessorDefinitions) + Disabled + true + MultiThreadedDebug + + + true + + + + + NDEBUG;%(PreprocessorDefinitions) + MaxSpeed + false + MultiThreaded + true + true + + + true + true + true + + + + + 10.0.17134.0 + 16.0 + Win32Proj + {1DC3BE92-CE89-43FB-8110-9C043A2FE7A2} + AlwaysOnTop + + + + Application + v142 + $(SolutionDir)$(Platform)\$(Configuration)\obj\$(ProjectName)\ + Unicode + Spectre + + + + true + true + + + false + true + false + + + + + + + + + + + + + + + true + PowerToys.$(MSBuildProjectName) + $(SolutionDir)$(Platform)\$(Configuration)\modules\AlwaysOnTop\ + + + false + PowerToys.$(MSBuildProjectName) + $(SolutionDir)$(Platform)\$(Configuration)\modules\AlwaysOnTop\ + + + + Level3 + true + _DEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + ./../;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src\common;$(SolutionDir)src\;./;%(AdditionalIncludeDirectories) + + + Windows + true + winmm.lib;shcore.lib;shlwapi.lib;DbgHelp.lib;uxtheme.lib;dwmapi.lib;%(AdditionalDependencies) + + + + + Level3 + true + true + true + NDEBUG;_CONSOLE;%(PreprocessorDefinitions) + true + ./../;$(SolutionDir)src\common\Telemetry;$(SolutionDir)src\common;$(SolutionDir)src\;./;%(AdditionalIncludeDirectories) + + + Windows + true + true + true + winmm.lib;shcore.lib;shlwapi.lib;DbgHelp.lib;uxtheme.lib;dwmapi.lib;%(AdditionalDependencies) + + + + + + + + Create + Create + + + + + + + + + + + + + + + + + + + + + + + + + + + {caba8dfb-823b-4bf2-93ac-3f31984150d9} + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj.filters b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj.filters new file mode 100644 index 0000000000..8eeaaded4f --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj.filters @@ -0,0 +1,87 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.cpp b/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.cpp new file mode 100644 index 0000000000..3c369799e5 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.cpp @@ -0,0 +1,172 @@ +#include "pch.h" +#include "FrameDrawer.h" + +std::unique_ptr FrameDrawer::Create(HWND window) +{ + auto self = std::make_unique(window); + if (self->Init()) + { + return self; + } + + return nullptr; +} + +FrameDrawer::FrameDrawer(FrameDrawer&& other) : + m_window(other.m_window), + m_renderTarget(std::move(other.m_renderTarget)), + m_sceneRect(std::move(other.m_sceneRect)), + m_renderThread(std::move(m_renderThread)) +{ +} + +FrameDrawer::FrameDrawer(HWND window) : + m_window(window), m_renderTarget(nullptr) +{ +} + +FrameDrawer::~FrameDrawer() +{ + m_abortThread = true; + m_renderThread.join(); + + if (m_renderTarget) + { + m_renderTarget->Release(); + } +} + +bool FrameDrawer::Init() +{ + RECT clientRect; + + // Obtain the size of the drawing area. + if (!GetClientRect(m_window, &clientRect)) + { + return false; + } + + HRESULT hr; + + // Create a Direct2D render target + // We should always use the DPI value of 96 since we're running in DPI aware mode + auto renderTargetProperties = D2D1::RenderTargetProperties( + D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1::PixelFormat(DXGI_FORMAT_UNKNOWN, D2D1_ALPHA_MODE_PREMULTIPLIED), + 96.f, + 96.f); + + auto renderTargetSize = D2D1::SizeU(clientRect.right - clientRect.left, clientRect.bottom - clientRect.top); + auto hwndRenderTargetProperties = D2D1::HwndRenderTargetProperties(m_window, renderTargetSize); + + hr = GetD2DFactory()->CreateHwndRenderTarget(renderTargetProperties, hwndRenderTargetProperties, &m_renderTarget); + + if (!SUCCEEDED(hr)) + { + return false; + } + + m_renderThread = std::thread([this]() { RenderLoop(); }); + + return true; +} + +void FrameDrawer::Hide() +{ + ShowWindow(m_window, SW_HIDE); +} + +void FrameDrawer::Show() +{ + ShowWindow(m_window, SW_SHOWNA); +} + +void FrameDrawer::SetBorderRect(RECT windowRect, COLORREF color, float thickness) +{ + std::unique_lock lock(m_mutex); + + auto borderColor = ConvertColor(color); + + m_sceneRect = DrawableRect{ + .rect = ConvertRect(windowRect), + .borderColor = borderColor, + .thickness = thickness + }; +} + +ID2D1Factory* FrameDrawer::GetD2DFactory() +{ + static auto pD2DFactory = [] { + ID2D1Factory* res = nullptr; + D2D1CreateFactory(D2D1_FACTORY_TYPE_MULTI_THREADED, &res); + return res; + }(); + return pD2DFactory; +} + +IDWriteFactory* FrameDrawer::GetWriteFactory() +{ + static auto pDWriteFactory = [] { + IUnknown* res = nullptr; + DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), &res); + return reinterpret_cast(res); + }(); + return pDWriteFactory; +} + +D2D1_COLOR_F FrameDrawer::ConvertColor(COLORREF color) +{ + return D2D1::ColorF(GetRValue(color) / 255.f, + GetGValue(color) / 255.f, + GetBValue(color) / 255.f, + 1.f); +} + +D2D1_RECT_F FrameDrawer::ConvertRect(RECT rect) +{ + return D2D1::RectF((float)rect.left, (float)rect.top, (float)rect.right, (float)rect.bottom); +} + +FrameDrawer::RenderResult FrameDrawer::Render() +{ + std::unique_lock lock(m_mutex); + + if (!m_renderTarget) + { + return RenderResult::Failed; + } + + m_renderTarget->BeginDraw(); + + // Draw backdrop + m_renderTarget->Clear(D2D1::ColorF(0.f, 0.f, 0.f, 0.f)); + + ID2D1SolidColorBrush* borderBrush = nullptr; + m_renderTarget->CreateSolidColorBrush(m_sceneRect.borderColor, &borderBrush); + + if (borderBrush) + { + m_renderTarget->DrawRectangle(m_sceneRect.rect, borderBrush, m_sceneRect.thickness); + borderBrush->Release(); + } + + // The lock must be released here, as EndDraw() will wait for vertical sync + lock.unlock(); + + m_renderTarget->EndDraw(); + return RenderResult::Ok; +} + +void FrameDrawer::RenderLoop() +{ + while (!m_abortThread) + { + auto result = Render(); + if (result == RenderResult::Failed) + { + Logger::error("Render failed"); + Hide(); + m_abortThread = true; + } + } +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.h b/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.h new file mode 100644 index 0000000000..47e2678fb7 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/FrameDrawer.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +class FrameDrawer +{ +public: + static std::unique_ptr Create(HWND window); + + FrameDrawer(HWND window); + FrameDrawer(FrameDrawer&& other); + ~FrameDrawer(); + + bool Init(); + + void Show(); + void Hide(); + void SetBorderRect(RECT windowRect, COLORREF color, float thickness); + +private: + struct DrawableRect + { + D2D1_RECT_F rect; + D2D1_COLOR_F borderColor; + float thickness; + }; + + enum struct RenderResult + { + Ok, + Failed, + }; + + static ID2D1Factory* GetD2DFactory(); + static IDWriteFactory* GetWriteFactory(); + static D2D1_COLOR_F ConvertColor(COLORREF color); + static D2D1_RECT_F ConvertRect(RECT rect); + RenderResult Render(); + void RenderLoop(); + + HWND m_window = nullptr; + ID2D1HwndRenderTarget* m_renderTarget = nullptr; + + std::mutex m_mutex; + DrawableRect m_sceneRect; + + std::atomic m_abortThread = false; + std::thread m_renderThread; +}; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/ModuleConstants.h b/src/modules/alwaysontop/AlwaysOnTop/ModuleConstants.h new file mode 100644 index 0000000000..29cfbd747b --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/ModuleConstants.h @@ -0,0 +1,6 @@ +#pragma once + +namespace NonLocalizable +{ + const inline wchar_t ModuleKey[] = L"AlwaysOnTop"; +} \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/Settings.cpp b/src/modules/alwaysontop/AlwaysOnTop/Settings.cpp new file mode 100644 index 0000000000..ea5cc8ee84 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/Settings.cpp @@ -0,0 +1,188 @@ +#include "pch.h" +#include "Settings.h" + +#include +#include +#include + +#include +#include // trim + +namespace NonLocalizable +{ + const static wchar_t* SettingsFileName = L"settings.json"; + + const static wchar_t* HotkeyID = L"hotkey"; + const static wchar_t* SoundEnabledID = L"sound-enabled"; + const static wchar_t* FrameEnabledID = L"frame-enabled"; + const static wchar_t* FrameThicknessID = L"frame-thickness"; + const static wchar_t* FrameColorID = L"frame-color"; + const static wchar_t* BlockInGameModeID = L"do-not-activate-on-game-mode"; + const static wchar_t* ExcludedAppsID = L"excluded-apps"; +} + +// TODO: move to common utils +inline COLORREF HexToRGB(std::wstring_view hex, const COLORREF fallbackColor = RGB(255, 255, 255)) +{ + hex = left_trim(trim(hex), L"#"); + + try + { + const long long tmp = std::stoll(hex.data(), nullptr, 16); + const BYTE nR = static_cast((tmp & 0xFF0000) >> 16); + const BYTE nG = static_cast((tmp & 0xFF00) >> 8); + const BYTE nB = static_cast((tmp & 0xFF)); + return RGB(nR, nG, nB); + } + catch (const std::exception&) + { + return fallbackColor; + } +} + +AlwaysOnTopSettings::AlwaysOnTopSettings() +{ +} + +AlwaysOnTopSettings& AlwaysOnTopSettings::instance() +{ + static AlwaysOnTopSettings instance; + return instance; +} + +void AlwaysOnTopSettings::InitFileWatcher() +{ + const std::wstring& settingsFileName = GetSettingsFileName(); + m_settingsFileWatcher = std::make_unique(settingsFileName, [&]() { + PostMessageW(HWND_BROADCAST, WM_PRIV_SETTINGS_CHANGED, NULL, NULL); + }); +} + +std::wstring AlwaysOnTopSettings::GetSettingsFileName() +{ + std::wstring saveFolderPath = PTSettingsHelper::get_module_save_folder_location(NonLocalizable::ModuleKey); + return saveFolderPath + L"\\" + std::wstring(NonLocalizable::SettingsFileName); +} + +void AlwaysOnTopSettings::AddObserver(SettingsObserver& observer) +{ + m_observers.insert(&observer); +} + +void AlwaysOnTopSettings::RemoveObserver(SettingsObserver& observer) +{ + auto iter = m_observers.find(&observer); + if (iter != m_observers.end()) + { + m_observers.erase(iter); + } +} + +void AlwaysOnTopSettings::LoadSettings() +{ + try + { + PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::load_from_settings_file(NonLocalizable::ModuleKey); + + if (const auto jsonVal = values.get_json(NonLocalizable::HotkeyID)) + { + auto val = PowerToysSettings::HotkeyObject::from_json(*jsonVal); + if (m_settings.hotkey.get_modifiers() != val.get_modifiers() || m_settings.hotkey.get_key() != val.get_key() || m_settings.hotkey.get_code() != val.get_code()) + { + m_settings.hotkey = val; + NotifyObservers(SettingId::Hotkey); + } + } + + if (const auto jsonVal = values.get_bool_value(NonLocalizable::SoundEnabledID)) + { + auto val = *jsonVal; + if (m_settings.enableSound != val) + { + m_settings.enableSound = val; + NotifyObservers(SettingId::SoundEnabled); + } + } + + if (const auto jsonVal = values.get_int_value(NonLocalizable::FrameThicknessID)) + { + auto val = *jsonVal; + if (m_settings.frameThickness != val) + { + m_settings.frameThickness = static_cast(val); + NotifyObservers(SettingId::FrameThickness); + } + } + + if (const auto jsonVal = values.get_string_value(NonLocalizable::FrameColorID)) + { + auto val = HexToRGB(*jsonVal); + if (m_settings.frameColor != val) + { + m_settings.frameColor = val; + NotifyObservers(SettingId::FrameColor); + } + } + + if (const auto jsonVal = values.get_bool_value(NonLocalizable::FrameEnabledID)) + { + auto val = *jsonVal; + if (m_settings.enableFrame != val) + { + m_settings.enableFrame = val; + NotifyObservers(SettingId::FrameEnabled); + } + } + + if (const auto jsonVal = values.get_bool_value(NonLocalizable::BlockInGameModeID)) + { + auto val = *jsonVal; + if (m_settings.blockInGameMode != val) + { + m_settings.blockInGameMode = val; + NotifyObservers(SettingId::BlockInGameMode); + } + } + + if (auto jsonVal = values.get_string_value(NonLocalizable::ExcludedAppsID)) + { + std::wstring apps = std::move(*jsonVal); + std::vector excludedApps; + auto excludedUppercase = apps; + CharUpperBuffW(excludedUppercase.data(), (DWORD)excludedUppercase.length()); + std::wstring_view view(excludedUppercase); + view = left_trim(trim(view)); + + while (!view.empty()) + { + auto pos = (std::min)(view.find_first_of(L"\r\n"), view.length()); + excludedApps.emplace_back(view.substr(0, pos)); + view.remove_prefix(pos); + view = left_trim(trim(view)); + } + + if (m_settings.excludedApps != excludedApps) + { + m_settings.excludedApps = excludedApps; + NotifyObservers(SettingId::ExcludeApps); + } + } + } + catch (...) + { + // Log error message and continue with default settings. + Logger::error("Failed to read settings"); + // TODO: show localized message + } +} + +void AlwaysOnTopSettings::NotifyObservers(SettingId id) const +{ + for (auto observer : m_observers) + { + if (observer->WantsToBeNotified(id)) + { + observer->SettingsUpdate(id); + } + } +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/Settings.h b/src/modules/alwaysontop/AlwaysOnTop/Settings.h new file mode 100644 index 0000000000..d447b044cf --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/Settings.h @@ -0,0 +1,50 @@ +#pragma once + +#include + +#include +#include + +#include + +class SettingsObserver; + +// Needs to be kept in sync with src\settings-ui\Settings.UI.Library\AlwaysOnTopProperties.cs +struct Settings +{ + PowerToysSettings::HotkeyObject hotkey = PowerToysSettings::HotkeyObject::from_settings(true, true, false, false, 84); // win + ctrl + T + bool enableFrame = true; + bool enableSound = true; + bool blockInGameMode = true; + float frameThickness = 15.0f; + COLORREF frameColor = RGB(0, 173, 239); + std::vector excludedApps{}; +}; + +class AlwaysOnTopSettings +{ +public: + static AlwaysOnTopSettings& instance(); + static inline const Settings& settings() + { + return instance().m_settings; + } + + void InitFileWatcher(); + static std::wstring GetSettingsFileName(); + + void AddObserver(SettingsObserver& observer); + void RemoveObserver(SettingsObserver& observer); + + void LoadSettings(); + +private: + AlwaysOnTopSettings(); + ~AlwaysOnTopSettings() = default; + + Settings m_settings; + std::unique_ptr m_settingsFileWatcher; + std::unordered_set m_observers; + + void NotifyObservers(SettingId id) const; +}; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/SettingsConstants.h b/src/modules/alwaysontop/AlwaysOnTop/SettingsConstants.h new file mode 100644 index 0000000000..4c7f466ca2 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/SettingsConstants.h @@ -0,0 +1,12 @@ +#pragma once + +enum class SettingId +{ + Hotkey = 0, + SoundEnabled, + FrameEnabled, + FrameThickness, + FrameColor, + BlockInGameMode, + ExcludeApps +}; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/SettingsObserver.h b/src/modules/alwaysontop/AlwaysOnTop/SettingsObserver.h new file mode 100644 index 0000000000..cfe79d2806 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/SettingsObserver.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include + +class SettingsObserver +{ +public: + SettingsObserver(std::unordered_set observedSettings) : + m_observedSettings(std::move(observedSettings)) + { + AlwaysOnTopSettings::instance().AddObserver(*this); + } + + virtual ~SettingsObserver() + { + AlwaysOnTopSettings::instance().RemoveObserver(*this); + } + + virtual void SettingsUpdate(SettingId type) {} + + bool WantsToBeNotified(SettingId type) const noexcept + { + return m_observedSettings.contains(type); + } + +protected: + std::unordered_set m_observedSettings; +}; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/Sound.h b/src/modules/alwaysontop/AlwaysOnTop/Sound.h new file mode 100644 index 0000000000..9fc8d51488 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/Sound.h @@ -0,0 +1,44 @@ +#pragma once + +#include "pch.h" + +#include +#include // sound + +class Sound +{ +public: + enum class Type + { + On, + Off, + }; + + Sound() + : isPlaying(false) + {} + + void Play(Type type) + { + BOOL success = false; + switch (type) + { + case Type::On: + success = PlaySound(TEXT("Media\\Speech On.wav"), NULL, SND_FILENAME | SND_ASYNC); + break; + case Type::Off: + success = PlaySound(TEXT("Media\\Speech Off.wav"), NULL, SND_FILENAME | SND_ASYNC); + break; + default: + break; + } + + if (!success) + { + Logger::error(L"Sound playing error"); + } + } + +private: + std::atomic isPlaying; +}; \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.cpp b/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.cpp new file mode 100644 index 0000000000..fe5f3983ab --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.cpp @@ -0,0 +1,63 @@ +#include "pch.h" +#include "VirtualDesktopUtils.h" + +// Non-Localizable strings +namespace NonLocalizable +{ + const wchar_t RegKeyVirtualDesktops[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\VirtualDesktops"; +} + +HKEY OpenVirtualDesktopsRegKey() +{ + HKEY hKey{ nullptr }; + if (RegOpenKeyEx(HKEY_CURRENT_USER, NonLocalizable::RegKeyVirtualDesktops, 0, KEY_ALL_ACCESS, &hKey) == ERROR_SUCCESS) + { + return hKey; + } + return nullptr; +} + +HKEY GetVirtualDesktopsRegKey() +{ + static wil::unique_hkey virtualDesktopsKey{ OpenVirtualDesktopsRegKey() }; + return virtualDesktopsKey.get(); +} + +VirtualDesktopUtils::VirtualDesktopUtils() +{ + auto res = CoCreateInstance(CLSID_VirtualDesktopManager, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&m_vdManager)); + if (FAILED(res)) + { + Logger::error("Failed to create VirtualDesktopManager instance"); + } +} + +VirtualDesktopUtils::~VirtualDesktopUtils() +{ + if (m_vdManager) + { + m_vdManager->Release(); + } +} + +bool VirtualDesktopUtils::IsWindowOnCurrentDesktop(HWND window) const +{ + std::optional id = GetDesktopId(window); + return id.has_value(); +} + +std::optional VirtualDesktopUtils::GetDesktopId(HWND window) const +{ + GUID id; + BOOL isWindowOnCurrentDesktop = false; + if (m_vdManager && m_vdManager->IsWindowOnCurrentVirtualDesktop(window, &isWindowOnCurrentDesktop) == S_OK && isWindowOnCurrentDesktop) + { + // Filter windows such as Windows Start Menu, Task Switcher, etc. + if (m_vdManager->GetWindowDesktopId(window, &id) == S_OK && id != GUID_NULL) + { + return id; + } + } + + return std::nullopt; +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.h b/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.h new file mode 100644 index 0000000000..c445b48902 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/VirtualDesktopUtils.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +class VirtualDesktopUtils +{ +public: + VirtualDesktopUtils(); + ~VirtualDesktopUtils(); + + bool IsWindowOnCurrentDesktop(HWND window) const; + + std::optional GetDesktopId(HWND window) const; + +private: + IVirtualDesktopManager* m_vdManager; +}; diff --git a/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.cpp b/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.cpp new file mode 100644 index 0000000000..c9ca780f57 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.cpp @@ -0,0 +1,14 @@ +#include "pch.h" + +#include "WinHookEventIDs.h" + +UINT WM_PRIV_SETTINGS_CHANGED; + +std::once_flag init_flag; + +void InitializeWinhookEventIds() +{ + std::call_once(init_flag, [&] { + WM_PRIV_SETTINGS_CHANGED = RegisterWindowMessage(L"{11978F7B-221A-4E65-B8A8-693F7D6E4B25}"); + }); +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.h b/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.h new file mode 100644 index 0000000000..756ddbbbae --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/WinHookEventIDs.h @@ -0,0 +1,5 @@ +#pragma once + +extern UINT WM_PRIV_SETTINGS_CHANGED; // Scheduled when the a watched settings file is updated + +void InitializeWinhookEventIds(); \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp new file mode 100644 index 0000000000..f0e8b6ee2c --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.cpp @@ -0,0 +1,223 @@ +#include "pch.h" +#include "WindowBorder.h" + +#include + +#include +#include + +// Non-Localizable strings +namespace NonLocalizable +{ + const wchar_t ToolWindowClassName[] = L"AlwaysOnTop_Border"; +} + +std::optional GetFrameRect(HWND window) +{ + RECT rect; + if (!SUCCEEDED(DwmGetWindowAttribute(window, DWMWA_EXTENDED_FRAME_BOUNDS, &rect, sizeof(rect)))) + { + return std::nullopt; + } + + int border = static_cast(AlwaysOnTopSettings::settings().frameThickness / 2); + rect.top -= border; + rect.left -= border; + rect.right += border; + rect.bottom += border; + + return rect; +} + +WindowBorder::WindowBorder(HWND window) : + SettingsObserver({SettingId::FrameColor, SettingId::FrameThickness}), + m_window(nullptr), + m_trackingWindow(window), + m_frameDrawer(nullptr) +{ +} + +WindowBorder::WindowBorder(WindowBorder&& other) : + SettingsObserver({ SettingId::FrameColor, SettingId::FrameThickness }), + m_window(other.m_window), + m_trackingWindow(other.m_trackingWindow), + m_frameDrawer(std::move(other.m_frameDrawer)) +{ +} + +WindowBorder::~WindowBorder() +{ + if (m_frameDrawer) + { + m_frameDrawer->Hide(); + m_frameDrawer = nullptr; + } + + if (m_window) + { + SetWindowLongPtrW(m_window, GWLP_USERDATA, 0); + ShowWindow(m_window, SW_HIDE); + } +} + +bool WindowBorder::Init(HINSTANCE hinstance) +{ + if (!m_trackingWindow) + { + return false; + } + + auto windowRectOpt = GetFrameRect(m_trackingWindow); + if (!windowRectOpt.has_value()) + { + return false; + } + + RECT windowRect = windowRectOpt.value(); + + WNDCLASSEXW wcex{}; + wcex.cbSize = sizeof(WNDCLASSEX); + wcex.lpfnWndProc = s_WndProc; + wcex.hInstance = hinstance; + wcex.lpszClassName = NonLocalizable::ToolWindowClassName; + wcex.hCursor = LoadCursorW(nullptr, IDC_ARROW); + RegisterClassExW(&wcex); + + m_window = CreateWindowExW(WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW + , NonLocalizable::ToolWindowClassName + , L"" + , WS_POPUP + , windowRect.left + , windowRect.top + , windowRect.right - windowRect.left + , windowRect.bottom - windowRect.top + , nullptr + , nullptr + , hinstance + , this); + + if (!m_window) + { + return false; + } + + if (!SetLayeredWindowAttributes(m_window, RGB(0, 0, 0), 0, LWA_COLORKEY)) + { + return false; + } + + // set position of the border-window behind the tracking window + // helps to prevent border overlapping (happens after turning borders off and on) + SetWindowPos(m_trackingWindow + , m_window + , windowRect.left + , windowRect.top + , windowRect.right - windowRect.left - static_cast(AlwaysOnTopSettings::settings().frameThickness) + , windowRect.bottom - windowRect.top - static_cast(AlwaysOnTopSettings::settings().frameThickness) + , SWP_NOMOVE | SWP_NOSIZE); + + m_frameDrawer = FrameDrawer::Create(m_window); + return m_frameDrawer != nullptr; +} + +void WindowBorder::UpdateBorderPosition() const +{ + if (!m_trackingWindow) + { + return; + } + + auto rectOpt = GetFrameRect(m_trackingWindow); + if (!rectOpt.has_value()) + { + return; + } + + RECT rect = rectOpt.value(); + SetWindowPos(m_window, m_trackingWindow, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, SWP_NOREDRAW); +} + +void WindowBorder::UpdateBorderProperties() const +{ + if (!m_trackingWindow || !m_frameDrawer) + { + return; + } + + auto windowRectOpt = GetFrameRect(m_trackingWindow); + if (!windowRectOpt.has_value()) + { + return; + } + + RECT windowRect = windowRectOpt.value(); + RECT frameRect{ 0, 0, windowRect.right - windowRect.left, windowRect.bottom - windowRect.top }; + m_frameDrawer->SetBorderRect(frameRect, AlwaysOnTopSettings::settings().frameColor, AlwaysOnTopSettings::settings().frameThickness); +} + +void WindowBorder::Show() const +{ + UpdateBorderProperties(); + m_frameDrawer->Show(); +} + +void WindowBorder::Hide() const +{ + m_frameDrawer->Hide(); +} + +LRESULT WindowBorder::WndProc(UINT message, WPARAM wparam, LPARAM lparam) noexcept +{ + switch (message) + { + case WM_NCDESTROY: + { + ::DefWindowProc(m_window, message, wparam, lparam); + SetWindowLongPtr(m_window, GWLP_USERDATA, 0); + } + break; + + case WM_ERASEBKGND: + return TRUE; + + default: + { + return DefWindowProc(m_window, message, wparam, lparam); + } + } + return FALSE; +} + +void WindowBorder::SettingsUpdate(SettingId id) +{ + if (!AlwaysOnTopSettings::settings().enableFrame) + { + return; + } + + auto windowRectOpt = GetFrameRect(m_trackingWindow); + if (!windowRectOpt.has_value()) + { + return; + } + + switch (id) + { + case SettingId::FrameThickness: + { + UpdateBorderPosition(); + UpdateBorderProperties(); + } + break; + + case SettingId::FrameColor: + { + UpdateBorderProperties(); + } + break; + + default: + break; + } + +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.h b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.h new file mode 100644 index 0000000000..b1f6cf727a --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/WindowBorder.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +class FrameDrawer; + +class WindowBorder : public SettingsObserver +{ +public: + WindowBorder(HWND window); + WindowBorder(WindowBorder&& other); + ~WindowBorder(); + + bool Init(HINSTANCE hinstance); + + void Show() const; + void Hide() const; + + void UpdateBorderPosition() const; + void UpdateBorderProperties() const; + +protected: + static LRESULT CALLBACK s_WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) noexcept + { + auto thisRef = reinterpret_cast(GetWindowLongPtr(window, GWLP_USERDATA)); + if ((thisRef == nullptr) && (message == WM_CREATE)) + { + auto createStruct = reinterpret_cast(lparam); + thisRef = reinterpret_cast(createStruct->lpCreateParams); + SetWindowLongPtr(window, GWLP_USERDATA, reinterpret_cast(thisRef)); + } + + return (thisRef != nullptr) ? thisRef->WndProc(message, wparam, lparam) : + DefWindowProc(window, message, wparam, lparam); + } + +private: + HWND m_window; + HWND m_trackingWindow; + std::unique_ptr m_frameDrawer; + + LRESULT WndProc(UINT message, WPARAM wparam, LPARAM lparam) noexcept; + + virtual void SettingsUpdate(SettingId id) override; +}; diff --git a/src/modules/alwaysontop/AlwaysOnTop/main.cpp b/src/modules/alwaysontop/AlwaysOnTop/main.cpp new file mode 100644 index 0000000000..2b7fc14229 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/main.cpp @@ -0,0 +1,62 @@ +#include "pch.h" + +#include +#include +#include + +#include + +#include +#include + +// Non-localizable +const std::wstring moduleName = L"AlwaysOnTop"; +const std::wstring internalPath = L""; +const std::wstring instanceMutexName = L"Local\\PowerToys_AlwaysOnTop_InstanceMutex"; + +int WINAPI wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ PWSTR lpCmdLine, _In_ int nCmdShow) +{ + winrt::init_apartment(); + LoggerHelpers::init_logger(moduleName, internalPath, LogSettings::alwaysOnTopLoggerName); + InitUnhandledExceptionHandler_x64(); + + auto mutex = CreateMutex(nullptr, true, instanceMutexName.c_str()); + if (mutex == nullptr) + { + Logger::error(L"Failed to create mutex. {}", get_last_error_or_default(GetLastError())); + } + + if (GetLastError() == ERROR_ALREADY_EXISTS) + { + return 0; + } + + std::wstring pid = std::wstring(lpCmdLine); + if (!pid.empty()) + { + auto mainThreadId = GetCurrentThreadId(); + ProcessWaiter::OnProcessTerminate(pid, [mainThreadId](int err) { + if (err != ERROR_SUCCESS) + { + Logger::error(L"Failed to wait for parent process exit. {}", get_last_error_or_default(err)); + } + else + { + Logger::trace(L"PowerToys runner exited."); + } + + Logger::trace(L"Exiting AlwaysOnTop"); + PostThreadMessage(mainThreadId, WM_QUIT, 0, 0); + }); + } + + Trace::RegisterProvider(); + + AlwaysOnTop app; + + run_message_loop(); + + Trace::UnregisterProvider(); + + return 0; +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/packages.config b/src/modules/alwaysontop/AlwaysOnTop/packages.config new file mode 100644 index 0000000000..510c10c51f --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/pch.cpp b/src/modules/alwaysontop/AlwaysOnTop/pch.cpp new file mode 100644 index 0000000000..bcb5590be1 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/alwaysontop/AlwaysOnTop/pch.h b/src/modules/alwaysontop/AlwaysOnTop/pch.h new file mode 100644 index 0000000000..b3e44606f6 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/pch.h @@ -0,0 +1,7 @@ +#pragma once +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTop/trace.cpp b/src/modules/alwaysontop/AlwaysOnTop/trace.cpp new file mode 100644 index 0000000000..f4f21b2e84 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/trace.cpp @@ -0,0 +1,35 @@ +#include "pch.h" +#include "trace.h" + +// Telemetry strings should not be localized. +#define LoggingProviderKey "Microsoft.PowerToys" + +#define EventEnableAlwaysOnTopKey "AlwaysOnTop_EnableAlwaysOnTop" +#define EventEnabledKey "Enabled" + +TRACELOGGING_DEFINE_PROVIDER( + g_hProvider, + LoggingProviderKey, + // {38e8889b-9731-53f5-e901-e8a7c1753074} + (0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74), + TraceLoggingOptionProjectTelemetry()); + +void Trace::RegisterProvider() noexcept +{ + TraceLoggingRegister(g_hProvider); +} + +void Trace::UnregisterProvider() noexcept +{ + TraceLoggingUnregister(g_hProvider); +} + +void Trace::AlwaysOnTop::Enable(bool enabled) noexcept +{ + TraceLoggingWrite( + g_hProvider, + EventEnableAlwaysOnTopKey, + ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance), + TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE), + TraceLoggingBoolean(enabled, EventEnabledKey)); +} diff --git a/src/modules/alwaysontop/AlwaysOnTop/trace.h b/src/modules/alwaysontop/AlwaysOnTop/trace.h new file mode 100644 index 0000000000..4c18060a8a --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTop/trace.h @@ -0,0 +1,14 @@ +#pragma once + +class Trace +{ +public: + static void RegisterProvider() noexcept; + static void UnregisterProvider() noexcept; + + class AlwaysOnTop + { + public: + static void Enable(bool enabled) noexcept; + }; +}; diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj new file mode 100644 index 0000000000..9e9c8ca0d0 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj @@ -0,0 +1,81 @@ + + + + + 15.0 + {48A0A19E-A0BE-4256-ACF8-CC3B80291AF9} + Win32Proj + alwaysontop + AlwaysOnTopModuleInterface + + + + DynamicLibrary + + + + + + + + + + + + + + + + + $(SolutionDir)$(Platform)\$(Configuration)\modules\AlwaysOnTop\ + + + PowerToys.AlwaysOnTopModuleInterface + + + PowerToys.AlwaysOnTopModuleInterface + + + + _WINDOWS;_USRDLL;%(PreprocessorDefinitions) + ..\;..\..\..\common\inc;..\..\..\common\Telemetry;..\..\;..\..\..\;%(AdditionalIncludeDirectories) + + + $(OutDir)$(TargetName)$(TargetExt) + gdiplus.lib;dwmapi.lib;shlwapi.lib;uxtheme.lib;shcore.lib;%(AdditionalDependencies) + + + + + + + + + + + + Create + + + + + {d9b8fc84-322a-4f9f-bbb9-20915c47ddfd} + + + {6955446d-23f7-4023-9bb3-8657f904af99} + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj.filters b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj.filters new file mode 100644 index 0000000000..d52173be4f --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj.filters @@ -0,0 +1,42 @@ + + + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + + + {21926bf1-03b3-482d-8f60-8bc4fbfc6564} + + + {2f10207d-d8d1-4a42-8027-8ca597b3cb23} + + + {a4241930-ecae-44e2-be82-25eff2499fcd} + + + {8d479404-964b-4eb1-8fe8-554be3e68c9b} + + + + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp new file mode 100644 index 0000000000..28227f83fb --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/dllmain.cpp @@ -0,0 +1,159 @@ +#include "pch.h" + +#include + +#include +#include +#include + +#include +#include + +#include + +namespace NonLocalizable +{ + const wchar_t ModulePath[] = L"modules\\AlwaysOnTop\\PowerToys.AlwaysOnTop.exe"; +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Trace::RegisterProvider(); + break; + + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + break; + + case DLL_PROCESS_DETACH: + Trace::UnregisterProvider(); + break; + } + return TRUE; +} + +class AlwaysOnTopModuleInterface : public PowertoyModuleIface +{ +public: + // Return the localized display name of the powertoy + virtual PCWSTR get_name() override + { + return app_name.c_str(); + } + + // Return the non localized key of the powertoy, this will be cached by the runner + virtual const wchar_t* get_key() override + { + return app_key.c_str(); + } + + // Return JSON with the configuration options. + // These are the settings shown on the settings page along with their current values. + virtual bool get_config(_Out_ PWSTR buffer, _Out_ int* buffer_size) override + { + return false; + } + + // Passes JSON with the configuration settings for the powertoy. + // This is called when the user hits Save on the settings page. + virtual void set_config(PCWSTR config) override + { + } + + // Enable the powertoy + virtual void enable() + { + Logger::info("AlwaysOnTop enabling"); + + Enable(); + } + + // Disable the powertoy + virtual void disable() + { + Logger::info("AlwaysOnTop disabling"); + + Disable(true); + } + + // Returns if the powertoy is enabled + virtual bool is_enabled() override + { + return m_enabled; + } + + // Destroy the powertoy and free memory + virtual void destroy() override + { + Disable(false); + delete this; + } + + AlwaysOnTopModuleInterface() + { + app_name = L"AlwaysOnTop"; //TODO: localize + app_key = NonLocalizable::ModuleKey; + } + +private: + void Enable() + { + m_enabled = true; + + // Log telemetry + Trace::AlwaysOnTop::Enable(true); + + 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 = NonLocalizable::ModulePath; + sei.nShow = SW_SHOWNORMAL; + sei.lpParameters = executable_args.data(); + if (ShellExecuteExW(&sei) == false) + { + Logger::error(L"Failed to start AlwaysOnTop"); + auto message = get_last_error_message(GetLastError()); + if (message.has_value()) + { + Logger::error(message.value()); + } + } + else + { + m_hProcess = sei.hProcess; + } + } + + void Disable(bool const traceEvent) + { + m_enabled = false; + // Log telemetry + if (traceEvent) + { + Trace::AlwaysOnTop::Enable(false); + } + + if (m_hProcess) + { + TerminateProcess(m_hProcess, 0); + m_hProcess = nullptr; + } + } + + std::wstring app_name; + std::wstring app_key; //contains the non localized key of the powertoy + + bool m_enabled = false; + HANDLE m_hProcess = nullptr; +}; + +extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create() +{ + return new AlwaysOnTopModuleInterface(); +} diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config new file mode 100644 index 0000000000..d758b61ef1 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/packages.config @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.cpp b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.cpp new file mode 100644 index 0000000000..1d9f38c57d --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.h b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.h new file mode 100644 index 0000000000..3be2007bf0 --- /dev/null +++ b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/pch.h @@ -0,0 +1,17 @@ +#pragma once + +#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace winrt +{ + using namespace ::winrt; +} \ No newline at end of file diff --git a/src/modules/alwaysontop/AlwaysOnTopModuleInterface/targetver.h b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/targetver.h new file mode 100644 index 0000000000..567cd346ef Binary files /dev/null and b/src/modules/alwaysontop/AlwaysOnTopModuleInterface/targetver.h differ diff --git a/src/runner/main.cpp b/src/runner/main.cpp index bdba1a437c..08b6816d59 100644 --- a/src/runner/main.cpp +++ b/src/runner/main.cpp @@ -149,7 +149,8 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow L"modules/ColorPicker/PowerToys.ColorPicker.dll", L"modules/Awake/PowerToys.AwakeModuleInterface.dll", L"modules/MouseUtils/PowerToys.FindMyMouse.dll" , - L"modules/MouseUtils/PowerToys.MouseHighlighter.dll" + L"modules/MouseUtils/PowerToys.MouseHighlighter.dll", + L"modules/AlwaysOnTop/PowerToys.AlwaysOnTopModuleInterface.dll", }; const auto VCM_PATH = L"modules/VideoConference/PowerToys.VideoConferenceModule.dll"; diff --git a/src/settings-ui/PowerToys.Settings/Program.cs b/src/settings-ui/PowerToys.Settings/Program.cs index e10131d970..60d2e4bc39 100644 --- a/src/settings-ui/PowerToys.Settings/Program.cs +++ b/src/settings-ui/PowerToys.Settings/Program.cs @@ -62,6 +62,7 @@ namespace PowerToys.Settings switch (args[(int)Arguments.SettingsWindow]) { case "Overview": app.StartupPage = typeof(Microsoft.PowerToys.Settings.UI.Views.GeneralPage); break; + case "AlwaysOnTop": app.StartupPage = typeof(Microsoft.PowerToys.Settings.UI.Views.AlwaysOnTopPage); break; case "Awake": app.StartupPage = typeof(Microsoft.PowerToys.Settings.UI.Views.AwakePage); break; case "ColorPicker": app.StartupPage = typeof(Microsoft.PowerToys.Settings.UI.Views.ColorPickerPage); break; case "FancyZones": app.StartupPage = typeof(Microsoft.PowerToys.Settings.UI.Views.FancyZonesPage); break; diff --git a/src/settings-ui/Settings.UI.Library/AlwaysOnTopProperties.cs b/src/settings-ui/Settings.UI.Library/AlwaysOnTopProperties.cs new file mode 100644 index 0000000000..05304c4447 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AlwaysOnTopProperties.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + // Needs to be kept in sync with src\modules\alwaysontop\AlwaysOnTop\Settings.h + public class AlwaysOnTopProperties + { + public static readonly HotkeySettings DefaultHotkeyValue = new HotkeySettings(true, true, false, false, 0x54); + public const bool DefaultFrameEnabled = true; + public const int DefaultFrameThickness = 15; + public const string DefaultFrameColor = "#0099cc"; + public const bool DefaultSoundEnabled = true; + public const bool DefaultDoNotActivateOnGameMode = true; + + public AlwaysOnTopProperties() + { + Hotkey = new KeyboardKeysProperty(DefaultHotkeyValue); + FrameEnabled = new BoolProperty(DefaultFrameEnabled); + FrameThickness = new IntProperty(DefaultFrameThickness); + FrameColor = new StringProperty(DefaultFrameColor); + SoundEnabled = new BoolProperty(DefaultSoundEnabled); + DoNotActivateOnGameMode = new BoolProperty(DefaultDoNotActivateOnGameMode); + ExcludedApps = new StringProperty(); + } + + [JsonPropertyName("hotkey")] + public KeyboardKeysProperty Hotkey { get; set; } + + [JsonPropertyName("frame-enabled")] + public BoolProperty FrameEnabled { get; set; } + + [JsonPropertyName("frame-thickness")] + public IntProperty FrameThickness { get; set; } + + [JsonPropertyName("frame-color")] + public StringProperty FrameColor { get; set; } + + [JsonPropertyName("sound-enabled")] + public BoolProperty SoundEnabled { get; set; } + + [JsonPropertyName("do-not-activate-on-game-mode")] + public BoolProperty DoNotActivateOnGameMode { get; set; } + + [JsonPropertyName("excluded-apps")] + public StringProperty ExcludedApps { get; set; } + + public string ToJsonString() + { + return JsonSerializer.Serialize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs new file mode 100644 index 0000000000..e2f92a2fa6 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AlwaysOnTopSettings.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library +{ + public class AlwaysOnTopSettings : BasePTModuleSettings, ISettingsConfig + { + public const string ModuleName = "AlwaysOnTop"; + public const string ModuleVersion = "0.0.1"; + + public AlwaysOnTopSettings() + { + Name = ModuleName; + Version = ModuleVersion; + Properties = new AlwaysOnTopProperties(); + } + + [JsonPropertyName("properties")] + public AlwaysOnTopProperties Properties { get; set; } + + public string GetModuleName() + { + return Name; + } + + public bool UpgradeSettingsConfiguration() + { + return false; + } + } +} diff --git a/src/settings-ui/Settings.UI.Library/EnabledModules.cs b/src/settings-ui/Settings.UI.Library/EnabledModules.cs index 6d652df912..09f0015277 100644 --- a/src/settings-ui/Settings.UI.Library/EnabledModules.cs +++ b/src/settings-ui/Settings.UI.Library/EnabledModules.cs @@ -207,6 +207,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library } } + private bool alwaysOnTop = true; + + [JsonPropertyName("AlwaysOnTop")] + public bool AlwaysOnTop + { + get => alwaysOnTop; + set + { + if (alwaysOnTop != value) + { + LogTelemetryEvent(value); + alwaysOnTop = value; + } + } + } + public string ToJsonString() { return JsonSerializer.Serialize(this); diff --git a/src/settings-ui/Settings.UI.Library/ViewModels/AlwaysOnTopViewModel.cs b/src/settings-ui/Settings.UI.Library/ViewModels/AlwaysOnTopViewModel.cs new file mode 100644 index 0000000000..e2868a302a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/ViewModels/AlwaysOnTopViewModel.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Runtime.CompilerServices; +using Microsoft.PowerToys.Settings.UI.Library.Helpers; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; + +namespace Microsoft.PowerToys.Settings.UI.Library.ViewModels +{ + public class AlwaysOnTopViewModel : Observable + { + private ISettingsUtils SettingsUtils { get; set; } + + private GeneralSettings GeneralSettingsConfig { get; set; } + + private AlwaysOnTopSettings Settings { get; set; } + + private Func SendConfigMSG { get; } + + public AlwaysOnTopViewModel(ISettingsUtils settingsUtils, ISettingsRepository settingsRepository, ISettingsRepository moduleSettingsRepository, Func ipcMSGCallBackFunc) + { + if (settingsUtils == null) + { + throw new ArgumentNullException(nameof(settingsUtils)); + } + + SettingsUtils = settingsUtils; + + // To obtain the general settings configurations of PowerToys Settings. + if (settingsRepository == null) + { + throw new ArgumentNullException(nameof(settingsRepository)); + } + + GeneralSettingsConfig = settingsRepository.SettingsConfig; + + // To obtain the settings configurations of AlwaysOnTop. + if (moduleSettingsRepository == null) + { + throw new ArgumentNullException(nameof(moduleSettingsRepository)); + } + + Settings = moduleSettingsRepository.SettingsConfig; + + _isEnabled = GeneralSettingsConfig.Enabled.AlwaysOnTop; + _hotkey = Settings.Properties.Hotkey.Value; + _frameEnabled = Settings.Properties.FrameEnabled.Value; + _frameThickness = Settings.Properties.FrameThickness.Value; + _frameColor = Settings.Properties.FrameColor.Value; + _soundEnabled = Settings.Properties.SoundEnabled.Value; + _doNotActivateOnGameMode = Settings.Properties.DoNotActivateOnGameMode.Value; + _excludedApps = Settings.Properties.ExcludedApps.Value; + + // set the callback functions value to hangle outgoing IPC message. + SendConfigMSG = ipcMSGCallBackFunc; + } + + public bool IsEnabled + { + get => _isEnabled; + + set + { + if (value != _isEnabled) + { + _isEnabled = value; + + // Set the status in the general settings configuration + GeneralSettingsConfig.Enabled.AlwaysOnTop = value; + OutGoingGeneralSettings snd = new OutGoingGeneralSettings(GeneralSettingsConfig); + + SendConfigMSG(snd.ToString()); + OnPropertyChanged(nameof(IsEnabled)); + } + } + } + + public HotkeySettings Hotkey + { + get => _hotkey; + + set + { + if (value != _hotkey) + { + if (value == null || value.IsEmpty()) + { + _hotkey = AlwaysOnTopProperties.DefaultHotkeyValue; + } + else + { + _hotkey = value; + } + + Settings.Properties.Hotkey.Value = _hotkey; + NotifyPropertyChanged(); + } + } + } + + public bool FrameEnabled + { + get => _frameEnabled; + + set + { + if (value != _frameEnabled) + { + _frameEnabled = value; + Settings.Properties.FrameEnabled.Value = value; + NotifyPropertyChanged(); + } + } + } + + public int FrameThickness + { + get => _frameThickness; + + set + { + if (value != _frameThickness) + { + _frameThickness = value; + Settings.Properties.FrameThickness.Value = value; + NotifyPropertyChanged(); + } + } + } + + public string FrameColor + { + get => _frameColor; + + set + { + if (value != _frameColor) + { + _frameColor = value; + Settings.Properties.FrameColor.Value = value; + NotifyPropertyChanged(); + } + } + } + + public bool SoundEnabled + { + get => _soundEnabled; + + set + { + if (value != _soundEnabled) + { + _soundEnabled = value; + Settings.Properties.SoundEnabled.Value = value; + NotifyPropertyChanged(); + } + } + } + + public bool DoNotActivateOnGameMode + { + get => _doNotActivateOnGameMode; + + set + { + if (value != _doNotActivateOnGameMode) + { + _doNotActivateOnGameMode = value; + Settings.Properties.DoNotActivateOnGameMode.Value = value; + NotifyPropertyChanged(); + } + } + } + + public string ExcludedApps + { + get => _excludedApps; + + set + { + if (value != _excludedApps) + { + _excludedApps = value; + Settings.Properties.ExcludedApps.Value = value; + NotifyPropertyChanged(); + } + } + } + + public void NotifyPropertyChanged([CallerMemberName] string propertyName = null) + { + OnPropertyChanged(propertyName); + SettingsUtils.SaveSettings(Settings.ToJsonString(), AlwaysOnTopSettings.ModuleName); + } + + private bool _isEnabled; + private HotkeySettings _hotkey; + private bool _frameEnabled; + private int _frameThickness; + private string _frameColor; + private bool _soundEnabled; + private bool _doNotActivateOnGameMode; + private string _excludedApps; + } +} diff --git a/src/settings-ui/Settings.UI/Assets/FluentIcons/FluentIconsAlwaysOnTop.png b/src/settings-ui/Settings.UI/Assets/FluentIcons/FluentIconsAlwaysOnTop.png new file mode 100644 index 0000000000..bb97aee508 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/FluentIcons/FluentIconsAlwaysOnTop.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Modules/AlwaysOnTop.png b/src/settings-ui/Settings.UI/Assets/Modules/AlwaysOnTop.png new file mode 100644 index 0000000000..b2fbcdfa81 Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Modules/AlwaysOnTop.png differ diff --git a/src/settings-ui/Settings.UI/Assets/Modules/OOBE/AlwaysOnTop.png b/src/settings-ui/Settings.UI/Assets/Modules/OOBE/AlwaysOnTop.png new file mode 100644 index 0000000000..a1532116ba Binary files /dev/null and b/src/settings-ui/Settings.UI/Assets/Modules/OOBE/AlwaysOnTop.png differ diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModulesEnum.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModulesEnum.cs index 6464b97e41..d58e5a19ab 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModulesEnum.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModulesEnum.cs @@ -7,6 +7,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Enums public enum PowerToysModulesEnum { Overview = 0, + AlwaysOnTop, Awake, ColorPicker, FancyZones, diff --git a/src/settings-ui/Settings.UI/OOBE/Views/OobeAlwaysOnTop.xaml b/src/settings-ui/Settings.UI/OOBE/Views/OobeAlwaysOnTop.xaml new file mode 100644 index 0000000000..f5ce24542e --- /dev/null +++ b/src/settings-ui/Settings.UI/OOBE/Views/OobeAlwaysOnTop.xaml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + +