Compare commits

...

1 Commits

Author SHA1 Message Date
Kai Tao
920d3a878c aat window context menu 2026-02-03 16:37:06 +08:00
5 changed files with 479 additions and 6 deletions

152
aat-context-menu.txt Normal file
View File

@@ -0,0 +1,152 @@
整体架构概览
┌────────────────────┐
│ PowerToys (AOT) │
│ 自身进程 │
│ │
│ 1. 监听前台窗口 │
│ 2. 修改 SystemMenu│
│ 3. 处理命令 │
│ │
└─────────┬──────────┘
│ USER32 / HWND
┌────────────────────┐
│ 目标应用窗口 │
│ (任意 Win32 app) │
│ │
│ System Menu │
│ ├ Restore │
│ ├ Move │
│ ├ Minimize │
│ ├ Maximize │
│ ├ ────────── │
│ ├ Always on top │ ← 我们加的
│ └ Close │
└────────────────────┘
技术分解(按执行顺序)
Step 1识别目标窗口Foreground Window
PowerToys 侧需要始终知道“当前操作对象”:
GetForegroundWindow()
可选SetWinEventHook(EVENT_SYSTEM_FOREGROUND, …)
过滤条件(非常重要):
排除:
Desktop / Shell / Taskbar
PowerToys 自己
无 WS_SYSMENU 的窗口
可选:
只对顶层窗口生效
只对 visible 窗口
Step 2获取并修改 System Menu官方 API
HMENU hMenu = GetSystemMenu(hwnd, FALSE);
插入位置
推荐插在 SC_CLOSE 前
或统一放在 separator 后
插入示例(伪代码)
AppendMenu(hMenu, MF_SEPARATOR, 0, nullptr);
AppendMenu(
hMenu,
MF_STRING | (isTopMost ? MF_CHECKED : MF_UNCHECKED),
IDM_ALWAYS_ON_TOP, // 自定义 ID>= 0x1000
L"Always on top"
);
⚠️ 关键规则:
不能使用 SC_* 范围的 ID
推荐 0x1000 ~ 0xEFFF
Step 3避免重复插入必做
因为 System Menu 是持久的:
同一个窗口只插一次
切换窗口时同步状态checked / unchecked
常见做法:
在 PowerToys 内部维护:
HWND → 已注入菜单标记
或在插入前:
GetMenuItemInfo 检查是否已有该 ID
Step 4用户点击菜单项系统行为
当用户点击:
Always on top
消息会送到目标窗口:
WM_SYSCOMMAND
wParam = IDM_ALWAYS_ON_TOP
⚠️ 注意:
目标窗口 不会处理这个命令
默认行为是忽略
Step 5PowerToys 在“外部进程”执行动作
这是关键设计点:
PowerToys 不需要接管目标窗口的 WindowProc
而是:
PowerToys 自己知道:
当前 foreground HWND
上一次点击的菜单项
直接对该 HWND 执行动作
例如:
SetWindowPos(
hwnd,
isTopMost ? HWND_NOTOPMOST : HWND_TOPMOST,
0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE
);
👉 所有动作都是合法的跨进程窗口管理 API
权限 / UIPI 行为(现实边界)
场景 行为
普通 → 普通窗口 ✅ 可用
普通 → 管理员窗口 ❌ 菜单不可改
管理员 → 普通窗口 ✅
管理员 → 管理员窗口 ✅
结论:
和 PowerToys 现有 AOT 行为 一致

View File

@@ -0,0 +1,39 @@
# Always On Top System Menu Injection & Handling
## Overview
Adds “Always on top” to a windows system menu and keeps the menu state in sync with the modules pin/unpin logic and hotkeys.
## Algorithm
1. **Event triggers**
- On startup.
- On `EVENT_SYSTEM_FOREGROUND` and `EVENT_SYSTEM_MENUPOPUPSTART`.
2. **EnsureSystemMenuForWindow(hwnd)**
- Filter: top-level, visible, has `WS_SYSMENU`, not `WS_CHILD`, not system/PowerToys/excluded.
- If item missing: insert separator (only if previous item isnt one) and add menu item ID `0x1000` before `SC_CLOSE`; call `DrawMenuBar`.
- Always update the checkmark via `CheckMenuItem` based on `IsTopmost`.
3. **Click handling** (`EVENT_OBJECT_INVOKED` / `EVENT_OBJECT_COMMAND`, child ID `0x1000`)
- Resolve actionable window: `GW_OWNER``GA_ROOTOWNER``GA_ROOT` → foreground fallback.
- If the resolved window lacks our item, attempt foreground-root fallback.
- Toggle with `ProcessCommandWithSource(hwnd, "systemmenu")`, then refresh the menu item state.
4. **Hotkeys / LLKH events**
- Use the same toggle path via `ProcessCommandWithSource(hwnd, "hotkey"/"llkh")`, ensuring menu checkmark stays aligned with hotkey toggles.
## Key IDs & resources
- Menu command ID: `0x1000` (outside `SC_*` range).
- Label: `System_Menu_Always_On_Top` in `Resources.resx` (generated to `resource.h`).
## Logging (for diagnostics)
- Source-tagged toggles: `[AOT] ProcessCommand source=<hotkey|llkh|systemmenu> hwnd=...`
- Target resolution chain: `GW_OWNER / GA_ROOTOWNER / GA_ROOT` plus foreground fallback.
- Injection: insertions, “already present”, and `GetMenuItemCount` failures (with `GetLastError`).
- Clicks: `System menu click captured (event=..., src=..., target=...)`.
## Edge cases handled
- Menu popup HWND without `WS_SYSMENU`: we climb to owner/root and optionally foreground to find the real system menu.
- Duplicate separators avoided by checking the previous item.
- Foreground elevated windows still blocked by existing UIPI limits; we log skips accordingly.
## How to test quickly
1. Start Always On Top, open a normal Win32 app, open its system menu, click “Always on top”; check that the window pins and the menu item shows a checkmark.
2. Use the hotkey to toggle the same window; ensure the menu checkmark follows.
3. Check `AppData\Local\Microsoft\PowerToys\AlwaysOnTop\Logs\...` for the trace lines above if something is off.

View File

@@ -1,6 +1,10 @@
#include "pch.h"
#include "AlwaysOnTop.h"
#include <filesystem>
#include <string>
#include <cstring>
#include <common/display/dpi_aware.h>
#include <common/utils/game_mode.h>
#include <common/utils/excluded_apps.h>
@@ -16,11 +20,21 @@
#include <trace.h>
#include <WinHookEventIDs.h>
#ifndef EVENT_OBJECT_COMMAND
#define EVENT_OBJECT_COMMAND 0x8010
#endif
// Raised when a window's system menu is about to be displayed.
#ifndef EVENT_SYSTEM_MENUPOPUPSTART
#define EVENT_SYSTEM_MENUPOPUPSTART 0x0006
#endif
namespace NonLocalizable
{
const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow";
const static wchar_t* WINDOW_IS_PINNED_PROP = L"AlwaysOnTop_Pinned";
const static UINT ALWAYS_ON_TOP_MENU_ITEM_ID = 0x1000;
}
bool isExcluded(HWND window)
@@ -53,6 +67,11 @@ AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
SubscribeToEvents();
StartTrackingTopmostWindows();
if (HWND foreground = GetForegroundWindow())
{
EnsureSystemMenuForWindow(foreground);
}
}
else
{
@@ -158,7 +177,7 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
{
if (hotkeyId == static_cast<int>(HotkeyId::Pin))
{
ProcessCommand(fw);
ProcessCommandWithSource(fw, L"hotkey");
}
else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity))
{
@@ -193,6 +212,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
Sound::Type soundType = Sound::Type::Off;
bool topmost = IsTopmost(window);
Logger::trace(L"[AOT] ProcessCommand toggle start hwnd={:#x} topmost={}", reinterpret_cast<uintptr_t>(window), topmost);
if (topmost)
{
if (UnpinTopmostWindow(window))
@@ -208,6 +228,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
m_windowOriginalLayeredState.erase(window);
Trace::AlwaysOnTop::UnpinWindow();
Logger::trace(L"[AOT] Unpinned hwnd={:#x}", reinterpret_cast<uintptr_t>(window));
}
}
else
@@ -218,6 +239,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
AssignBorder(window);
Trace::AlwaysOnTop::PinWindow();
Logger::trace(L"[AOT] Pinned hwnd={:#x}", reinterpret_cast<uintptr_t>(window));
}
}
@@ -225,6 +247,8 @@ void AlwaysOnTop::ProcessCommand(HWND window)
{
m_sound.Play(soundType);
}
EnsureSystemMenuForWindow(window);
}
void AlwaysOnTop::StartTrackingTopmostWindows()
@@ -357,7 +381,7 @@ void AlwaysOnTop::RegisterLLKH()
case WAIT_OBJECT_0: // Pin event
if (HWND fw{ GetForegroundWindow() })
{
ProcessCommand(fw);
ProcessCommandWithSource(fw, L"llkh");
}
break;
case WAIT_OBJECT_0 + 1: // Terminate event
@@ -392,7 +416,7 @@ void AlwaysOnTop::RegisterLLKH()
void AlwaysOnTop::SubscribeToEvents()
{
// subscribe to windows events
std::array<DWORD, 7> events_to_subscribe = {
std::array<DWORD, 10> events_to_subscribe = {
EVENT_OBJECT_LOCATIONCHANGE,
EVENT_SYSTEM_MINIMIZESTART,
EVENT_SYSTEM_MINIMIZEEND,
@@ -400,6 +424,9 @@ void AlwaysOnTop::SubscribeToEvents()
EVENT_SYSTEM_FOREGROUND,
EVENT_OBJECT_DESTROY,
EVENT_OBJECT_FOCUS,
EVENT_OBJECT_INVOKED,
EVENT_OBJECT_COMMAND,
EVENT_SYSTEM_MENUPOPUPSTART,
};
for (const auto event : events_to_subscribe)
@@ -492,7 +519,60 @@ bool AlwaysOnTop::IsTracked(HWND window) const noexcept
void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
{
if (!AlwaysOnTopSettings::settings().enableFrame || !data->hwnd)
if (!data || !data->hwnd)
{
return;
}
if (data->event == EVENT_SYSTEM_FOREGROUND || data->event == EVENT_SYSTEM_MENUPOPUPSTART)
{
HWND target = ResolveMenuTargetWindow(data->hwnd);
Logger::trace(L"[AOT:SystemMenu] Ensure on event {} (src={:#x}, target={:#x})", data->event, reinterpret_cast<uintptr_t>(data->hwnd), reinterpret_cast<uintptr_t>(target));
EnsureSystemMenuForWindow(target);
}
if ((data->event == EVENT_OBJECT_INVOKED || data->event == EVENT_OBJECT_COMMAND) &&
data->idChild == static_cast<LONG>(NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID))
{
HWND target = ResolveMenuTargetWindow(data->hwnd);
Logger::trace(L"System menu click captured (event={}, src={:#x}, target={:#x})", data->event, reinterpret_cast<uintptr_t>(data->hwnd), reinterpret_cast<uintptr_t>(target));
auto hasItem = [](HWND w) {
if (!w)
{
return false;
}
HMENU m = GetSystemMenu(w, FALSE);
return m && GetMenuState(m, NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID, MF_BYCOMMAND) != static_cast<UINT>(-1);
};
if (!hasItem(target))
{
HWND fg = GetForegroundWindow();
HWND fgRoot = fg ? GetAncestor(fg, GA_ROOT) : nullptr;
Logger::trace(L"[AOT:SystemMenu] Fallback to foreground (src={:#x}, fg={:#x}, fgRoot={:#x})",
reinterpret_cast<uintptr_t>(data->hwnd),
reinterpret_cast<uintptr_t>(fg),
reinterpret_cast<uintptr_t>(fgRoot));
if (hasItem(fgRoot))
{
target = fgRoot;
}
}
HMENU systemMenu = GetSystemMenu(target, FALSE);
if (systemMenu && GetMenuState(systemMenu, NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID, MF_BYCOMMAND) != static_cast<UINT>(-1))
{
ProcessCommandWithSource(target, L"systemmenu");
EnsureSystemMenuForWindow(target);
}
else
{
Logger::trace(L"Menu click ignored; menu item not present (target={:#x})", reinterpret_cast<uintptr_t>(target));
}
return;
}
if (!AlwaysOnTopSettings::settings().enableFrame)
{
return;
}
@@ -616,6 +696,200 @@ void AlwaysOnTop::RefreshBorders()
}
}
void AlwaysOnTop::ProcessCommandWithSource(HWND window, const wchar_t* sourceTag)
{
Logger::trace(L"[AOT] ProcessCommand source={} hwnd={:#x}", sourceTag ? sourceTag : L"unknown", reinterpret_cast<uintptr_t>(window));
ProcessCommand(window);
}
bool AlwaysOnTop::ShouldInjectSystemMenu(HWND window) const noexcept
{
if (!window || !IsWindow(window))
{
Logger::trace(L"[AOT:SystemMenu] Skip: invalid window handle");
return false;
}
// Only consider top-level, visible windows that expose a system menu.
LONG style = GetWindowLong(window, GWL_STYLE);
if ((style & WS_SYSMENU) == 0 || (style & WS_CHILD) == WS_CHILD)
{
Logger::trace(L"[AOT:SystemMenu] Skip: missing WS_SYSMENU or is child (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return false;
}
if (!IsWindowVisible(window))
{
Logger::trace(L"[AOT:SystemMenu] Skip: not visible (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return false;
}
if (GetAncestor(window, GA_ROOT) != window)
{
Logger::trace(L"[AOT:SystemMenu] Skip: not root window (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return false;
}
char className[256]{};
if (GetClassNameA(window, className, ARRAYSIZE(className)) && is_system_window(window, className))
{
const std::wstring classNameW{ std::wstring(className, className + std::strlen(className)) };
Logger::trace(L"[AOT:SystemMenu] Skip: system window class {}", classNameW);
return false;
}
if (isExcluded(window))
{
Logger::trace(L"[AOT:SystemMenu] Skip: user excluded (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return false;
}
DWORD processId = 0;
GetWindowThreadProcessId(window, &processId);
if (processId == GetCurrentProcessId())
{
Logger::trace(L"[AOT:SystemMenu] Skip: PowerToys process (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return false;
}
auto processPath = get_process_path(window);
if (!processPath.empty())
{
const std::filesystem::path path{ processPath };
const auto fileName = path.filename().wstring();
if (_wcsnicmp(fileName.c_str(), L"PowerToys", 9) == 0 ||
_wcsicmp(fileName.c_str(), L"PowerLauncher.exe") == 0)
{
Logger::trace(L"[AOT:SystemMenu] Skip: PowerToys executable {}", fileName.c_str());
return false;
}
}
return true;
}
void AlwaysOnTop::UpdateSystemMenuItemState(HWND window, HMENU systemMenu) const noexcept
{
if (!systemMenu)
{
Logger::trace(L"[AOT:SystemMenu] Update state skipped: null menu");
return;
}
const UINT state = IsTopmost(window) ? MF_CHECKED : MF_UNCHECKED;
CheckMenuItem(systemMenu, NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID, MF_BYCOMMAND | state);
}
HWND AlwaysOnTop::ResolveMenuTargetWindow(HWND window) const noexcept
{
if (!window)
{
return nullptr;
}
HWND candidate = window;
auto log_choice = [&](const wchar_t* stage, HWND hwnd) {
Logger::trace(L"[AOT:SystemMenu] Resolve target: {} -> {:#x}", stage, reinterpret_cast<uintptr_t>(hwnd));
};
LONG style = GetWindowLong(candidate, GWL_STYLE);
if ((style & WS_SYSMENU) == 0 || (style & WS_CHILD) == WS_CHILD)
{
HWND owner = GetWindow(candidate, GW_OWNER);
if (owner)
{
candidate = owner;
log_choice(L"GW_OWNER", candidate);
}
else
{
candidate = GetAncestor(window, GA_ROOTOWNER);
log_choice(L"GA_ROOTOWNER", candidate);
}
if (!candidate)
{
candidate = GetAncestor(window, GA_ROOT);
log_choice(L"GA_ROOT", candidate);
}
if (!candidate)
{
candidate = GetForegroundWindow();
log_choice(L"Foreground fallback", candidate);
}
}
return candidate;
}
void AlwaysOnTop::EnsureSystemMenuForWindow(HWND window)
{
Logger::trace(L"[AOT:SystemMenu] Ensure request (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
if (!ShouldInjectSystemMenu(window))
{
return;
}
HMENU systemMenu = GetSystemMenu(window, FALSE);
if (!systemMenu)
{
Logger::trace(L"[AOT:SystemMenu] GetSystemMenu failed (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
return;
}
// Insert menu item once per window.
if (GetMenuState(systemMenu, NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID, MF_BYCOMMAND) == static_cast<UINT>(-1))
{
Logger::trace(L"[AOT:SystemMenu] Inserting menu item (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
int itemCount = GetMenuItemCount(systemMenu);
if (itemCount == -1)
{
Logger::trace(L"[AOT:SystemMenu] GetMenuItemCount failed (hwnd={:#x}, lastError={})", reinterpret_cast<uintptr_t>(window), GetLastError());
return;
}
int insertPos = itemCount;
for (int i = 0; i < itemCount; ++i)
{
if (GetMenuItemID(systemMenu, i) == SC_CLOSE)
{
insertPos = i;
break;
}
}
// Add a separator only if the previous item is not already a separator
if (insertPos > 0)
{
MENUITEMINFOW prevInfo{};
prevInfo.cbSize = sizeof(MENUITEMINFOW);
prevInfo.fMask = MIIM_FTYPE;
if (GetMenuItemInfoW(systemMenu, insertPos - 1, TRUE, &prevInfo) && !(prevInfo.fType & MFT_SEPARATOR))
{
InsertMenuW(systemMenu, insertPos, MF_BYPOSITION | MF_SEPARATOR, 0, nullptr);
++insertPos;
}
}
const std::wstring menuLabel = GET_RESOURCE_STRING_FALLBACK(IDS_SYSTEM_MENU_ALWAYS_ON_TOP, L"Always on top");
InsertMenuW(systemMenu,
insertPos + 1,
MF_BYPOSITION | MF_STRING,
NonLocalizable::ALWAYS_ON_TOP_MENU_ITEM_ID,
menuLabel.c_str());
DrawMenuBar(window);
}
else
{
Logger::trace(L"[AOT:SystemMenu] Already present, updating state only (hwnd={:#x})", reinterpret_cast<uintptr_t>(window));
}
UpdateSystemMenuItemState(window, systemMenu);
}
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
{
if (!window || !IsWindow(window))
@@ -776,4 +1050,4 @@ void AlwaysOnTop::RestoreWindowAlpha(HWND window)
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}
}

View File

@@ -80,6 +80,7 @@ private:
void SubscribeToEvents();
void ProcessCommand(HWND window);
void ProcessCommandWithSource(HWND window, const wchar_t* sourceTag);
void StartTrackingTopmostWindows();
void UnpinAll();
void CleanUp();
@@ -92,6 +93,10 @@ private:
bool UnpinTopmostWindow(HWND window) const noexcept;
bool AssignBorder(HWND window);
void RefreshBorders();
void EnsureSystemMenuForWindow(HWND window);
bool ShouldInjectSystemMenu(HWND window) const noexcept;
void UpdateSystemMenuItemState(HWND window, HMENU systemMenu) const noexcept;
HWND ResolveMenuTargetWindow(HWND window) const noexcept;
// Transparency methods
HWND ResolveTransparencyTargetWindow(HWND window);

View File

@@ -131,4 +131,7 @@
<data name="System_Foreground_Elevated_Dialog_Dont_Show_Again" xml:space="preserve">
<value>Don't show again</value>
</data>
</root>
<data name="System_Menu_Always_On_Top" xml:space="preserve">
<value>Always on top</value>
</data>
</root>