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