From 286c4fcfb086f0c72bcebdf1ce63f0e5d2f5e13f Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 02:27:09 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(win10-taskbar):=20=E6=8E=A5=E7=AE=A1?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=A0=8F=E5=BA=94=E7=94=A8=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=8F=9C=E5=8D=95=EF=BC=88Shell=20=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E8=87=AA=E5=BB=BA=E8=8F=9C=E5=8D=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象 Win10 上右键任务栏的应用按钮,弹的是 ShellExperienceHost 的 XAML 跳转列表, 完全绕过 Shell;任务栏空白处却已被 Shell 接管。两者观感不一致。 根因 任务栏按钮的右键菜单不是 Win32 菜单:弹出时无 #32768 窗口、不经过 TrackPopupMenu(Ex)、 也不经过 IContextMenu,无法用常规 Hook 拦截(实测埋日志:桌面/托盘/任务栏空白处都有日志, 只有按钮菜单一条都没有)。 做法 - subclass 任务栏按钮区窗口链 Shell_TrayWnd → ReBarWindow32 → MSTaskSwWClass → MSTaskListWClass, 拦 WM_CONTEXTMENU;只有点在真正的应用按钮上才吞掉,空白处放行给 explorer。 - 用 UIA ElementFromPoint 识别按钮(ControlType 接受 Button / MenuItem / SplitButton, 多窗口合并按钮是 MenuItem);按钮身份经 shell:AppsFolder 显示名解析。 - 自建 HMENU 后交给 TrackPopupMenuExProc,Shell 会抓快照并按自己的主题重绘 —— 菜单因此自动变成 Shell 样式(方案的关键)。 - 菜单内容:应用名 / 固定或取消固定 / 关闭窗口(仅在有运行窗口时)。 - 自建菜单期间置 in_taskbar_button_menu,ContextMenu 的两处配置项入口跳过 .nss 规则。 改动范围 仅 Win10 分支生效(IsWindows11OrGreater() 为假时才挂 hook);Win11 路径不变。 验证 Win10 22H2 + Shell 1.9.19:按钮菜单已是 Shell 样式;固定/取消固定、关闭窗口(含 UWP)、 多窗口合并按钮均实测通过;任务栏空白处行为不变。 --- src/dll/src/ContextMenu.cpp | 14 +- src/dll/src/Main.cpp | 1131 ++++++++++++++++++++++++++++++++++- 2 files changed, 1142 insertions(+), 3 deletions(-) diff --git a/src/dll/src/ContextMenu.cpp b/src/dll/src/ContextMenu.cpp index e7239da0..4761bd06 100644 --- a/src/dll/src/ContextMenu.cpp +++ b/src/dll/src/ContextMenu.cpp @@ -4,6 +4,9 @@ #include "Include/stb_image_write.h" using namespace Nilesoft::Diagnostics; + +// 定义在 Main.cpp:任务栏「应用按钮」自建菜单期间为 true(全局命名空间) +extern bool in_taskbar_button_menu; #include extern Logger &_log; @@ -1108,7 +1111,11 @@ namespace Nilesoft std::vector bottom_items; - if(_settings.new_items.enabled) + // 任务栏「应用按钮」自建菜单不加载 .nss 配置项(标志见 Main.cpp 的 in_taskbar_button_menu)。 + // 配置里新增的 menu(...)/item(...) 就是这一步加进来的 —— 例如「转到」(imports/goto.nss), + // 对单个应用按钮没有意义。注意这与下面 build_main_system_menuitems 那条是**两个不同入口**: + // 这里管「新增项」,那里管 modify(...) 这类「修改已有项」的规则。 + if(_settings.new_items.enabled && !in_taskbar_button_menu) { if(!(owner && owner->is_disabled())) { @@ -4470,7 +4477,10 @@ namespace Nilesoft if(0 == ::GetPropW(hwnd.owner, UxSubclass)) build_system_menuitems(_hMenu_original, __system_menu_tree, true); - if(_settings.modify_items.enabled) + // 任务栏「应用按钮」的自建菜单里不加载 .nss 配置项 —— 例如「转到」对单个应用按钮 + // 没有意义(这些项由 _cache->statics 应用,就是这一步)。 + // 任务栏空白处 / 桌面 / 文件菜单不走这条自建菜单路径,行为不变。 + if(_settings.modify_items.enabled && !in_taskbar_button_menu) build_main_system_menuitems(__system_menu_tree, true); return true; diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 0c56bdaa..9152b5c3 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -1,8 +1,11 @@ -#include +#include #include "Include/ContextMenu.h" #include "Library/detours.h" #include "RegistryConfig.h" #include +#include // PKEY_AppUserModel_ID(固定快捷方式时要用) +#include // CSIDL_APPDATA / IShellLink +#include // IStartMenuPinnedList(官方「从任务栏取消固定」API) using namespace Nilesoft::Shell; using namespace Diagnostics; @@ -179,6 +182,9 @@ std::unordered_map _window_taskbar; LRESULT __stdcall TaskbarSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); LRESULT __stdcall TaskbarProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); +// [PROBE] Win10 任务栏按钮区(MSTaskListWClass)探针 +LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); +static void tasklist_hook_all(uint32_t delay); __inline auto is_registered() -> bool { @@ -382,6 +388,1115 @@ struct taskbar_t } }; +// ===== Win10 任务栏按钮(MSTaskListWClass)右键接管 ===== +// 前置:这两个符号原本定义/声明在本文件更靠后的位置(约 515 行) +extern bool is_in_taskbar; +extern bool in_taskbar_button_menu; +BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); +// 注意:subclass 标记不能沿用 UxSubclass —— ContextMenu::Initialize() 里会用 +// `if(0 == ::GetPropW(hwnd.owner, UxSubclass))` 判断该窗口是否已由 Shell 处理过, +// 命中就【跳过 build_system_menuitems】,于是我们传进去的菜单项会被完全忽略。 +constexpr auto TaskListSubclassProp = L"ShellTaskListSubclass"; + +// 原理:Win10 的按钮菜单是 ShellExperienceHost 的 XAML 跳转列表(不是 Win32 菜单),Hook 不到。 +// 这里直接 subclass 任务栏按钮区窗口,拦下 WM_CONTEXTMENU 并吞掉,然后自己造一个 HMENU 交给 Shell: +// Shell 会抓快照并按自己的主题重绘 → 菜单变成 Shell 样式,而内容由我们决定。 +struct taskbar_button_info +{ + bool is_button{}; // 只有点在真正的应用按钮上才为 true + // 注意:UIA 对任务栏按钮返回的 pid 是 shell 进程,不是应用进程,不能用来定位窗口 + int pid{}; + HWND window{}; // 主窗口(第一个匹配到的,优先非最小化) + std::vector windows; // 所有匹配到的窗口:合并按钮要能「关闭所有窗口」 + string title; // 形如 "任务管理器 - 1 个运行窗口" +}; + +static taskbar_button_info query_taskbar_button(const Point &pt) +{ + taskbar_button_info out; + if(!taskbar_t::_IUIAutomation) + taskbar_t::_IUIAutomation.CreateInstance(__uuidof(CUIAutomation), CLSCTX_INPROC_SERVER); + if(!taskbar_t::_IUIAutomation) + return out; + + IComPtr elem; + if(FAILED(taskbar_t::_IUIAutomation->ElementFromPoint(pt, elem)) || !elem) + return out; + + // MSTaskListWClass 覆盖整个按钮区(含按钮之间的空白)。只有 ControlType 是按钮类 + // 才当成「应用按钮」;点在空白处必须放行,让 explorer 弹它自己的任务栏菜单(Shell 会接管)。 + // ⚠️ 应用有多个窗口(合并按钮)时,UIA 把任务栏按钮暴露成 MenuItem 而不是 Button + // (实测 Edge 3 个窗口 → ControlType=MenuItem),只认 Button 会让合并按钮弹回原生跳转列表。 + CONTROLTYPEID ctype = 0; + elem->get_CurrentControlType(&ctype); + if(ctype != UIA_ButtonControlTypeId && ctype != UIA_MenuItemControlTypeId && ctype != UIA_SplitButtonControlTypeId) + return out; + + BSTR name = nullptr; + elem->get_CurrentName(&name); + if(name) + { + out.title = name; + DLL::Invoke(L"OleAut32.dll", "SysFreeString", name); + } + int epid = 0; + elem->get_CurrentProcessId(&epid); + out.pid = epid; + out.is_button = !out.title.empty(); + return out; +} + +struct taskbar_window_finder +{ + string keyword; // 从按钮名解析出的应用名关键词 + bool file_explorer{}; // 该按钮是「文件资源管理器」→ 允许用窗口类认窗口(它没有任何 AppUserModel 属性) + HWND hwnd{}; // 主窗口(优先非最小化) + std::vector all; // 所有匹配到的窗口 +}; + +// Chromium 系应用(Edge/Chrome)会在窗口标题里插入零宽字符: +// 实际标题是 "Microsoft Edge",用 wcsstr 跟 "Microsoft Edge" 匹配必然失败。 +// 比较前先去掉零宽字符、把非断空格换成普通空格。 +static void normalize_for_match(wchar_t *s) +{ + if(!s) + return; + wchar_t *d = s; + for(const wchar_t *p = s; *p; ++p) + { + if(*p == 0x200B || *p == 0x200C || *p == 0x200D || *p == 0xFEFF || *p == 0x2060) + continue; + *d++ = (*p == 0x00A0) ? L' ' : *p; + } + *d = 0; +} + +// 窗口自己带着「这个应用在任务栏里是谁」的完整信息(都是公开属性,不用猜名字): +// AppUserModel.ID —— 分组主键 +// AppUserModel.RelaunchCommand —— 任务栏算 AppID 也用它。缺了它,固定项会跟运行窗口 +// 分成两个图标(实测 Blender:lnk 只有 ID、没这条, +// target 还是 blender.exe 而窗口 relaunch 是 blender-launcher.exe) +// AppUserModel.RelaunchDisplayNameResource —— 任务栏按钮名(实测 Blender 窗口='Blender 5.2') +// AppUserModel.RelaunchIconResource —— 图标 +struct window_appmodel +{ + wchar_t aumid[256]{}; + wchar_t relaunch_command[1024]{}; + wchar_t relaunch_display_name[512]{}; + wchar_t relaunch_icon[512]{}; +}; + +static void read_prop_str(IPropertyStore *ps, REFPROPERTYKEY key, wchar_t *out, size_t outLen) +{ + out[0] = 0; + PROPVARIANT pv{}; + if(SUCCEEDED(ps->GetValue(key, &pv))) + { + if(pv.vt == VT_LPWSTR && pv.pwszVal) + ::wcsncpy_s(out, outLen, pv.pwszVal, outLen - 1); + ::PropVariantClear(&pv); + } +} + +static bool get_window_appmodel(HWND hWnd, window_appmodel &out) +{ + if(!hWnd) + return false; + IPropertyStore *ps = nullptr; + if(FAILED(::SHGetPropertyStoreForWindow(hWnd, IID_IPropertyStore, reinterpret_cast(&ps))) || !ps) + return false; + read_prop_str(ps, PKEY_AppUserModel_ID, out.aumid, 256); + read_prop_str(ps, PKEY_AppUserModel_RelaunchCommand, out.relaunch_command, 1024); + read_prop_str(ps, PKEY_AppUserModel_RelaunchDisplayNameResource, out.relaunch_display_name, 512); + read_prop_str(ps, PKEY_AppUserModel_RelaunchIconResource, out.relaunch_icon, 512); + ps->Release(); + // 注意:AUMID 为空也算成功(explorer 的窗口就是这样,一个属性都不带), + // 调用方要自己回退到 exe 路径。 + return true; +} + +// 查一个应用在 shell:AppsFolder 里的显示名 —— 任务栏按钮名用的就是它。 +// 入参可以写 AppUserModelID(如 Microsoft.Windows.Explorer → 「文件资源管理器」), +// 也可以写 exe 路径(Blender 没注册 AUMID,按 ...\blender-launcher.exe → 「Blender 5.2」)。 +static bool appsfolder_display_name(const wchar_t *aumid_or_path, wchar_t *out, size_t outLen) +{ + out[0] = 0; + if(!aumid_or_path || !aumid_or_path[0]) + return false; + + wchar_t spec[1088]{}; + ::swprintf_s(spec, 1088, L"shell:AppsFolder\\%s", aumid_or_path); + IShellItem *item = nullptr; + if(FAILED(::SHCreateItemFromParsingName(spec, nullptr, IID_IShellItem, reinterpret_cast(&item))) || !item) + return false; + + bool ok = false; + LPWSTR name = nullptr; + if(SUCCEEDED(item->GetDisplayName(SIGDN_NORMALDISPLAY, &name)) && name) + { + ::wcsncpy_s(out, outLen, name, outLen - 1); + ok = out[0] != 0; + ::CoTaskMemFree(name); + } + item->Release(); + return ok; +} + +// 这个窗口在任务栏上叫的名字:先按 AUMID 查 AppsFolder,查不到再按 exe 路径查 +static bool window_button_name(HWND hWnd, wchar_t *out, size_t outLen) +{ + out[0] = 0; + window_appmodel am; + get_window_appmodel(hWnd, am); // 读不到就全是空串,下面按 exe 路径兜底 + if(am.aumid[0] && appsfolder_display_name(am.aumid, out, outLen)) + return true; + + DWORD pid = 0; + ::GetWindowThreadProcessId(hWnd, &pid); + if(!pid) + return false; + HANDLE hp = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if(!hp) + return false; + wchar_t exe[MAX_PATH]{}; + DWORD size = MAX_PATH; + bool ok = ::QueryFullProcessImageNameW(hp, 0, exe, &size) && appsfolder_display_name(exe, out, outLen); + ::CloseHandle(hp); + return ok; +} + +// 「文件资源管理器」是唯一必须特判的应用:它的窗口**一个 AppUserModel 属性都不带** +// (实测 AUMID vt=0),`shell:AppsFolder\C:\Windows\explorer.exe` 也查不到(未注册), +// 而任务栏照样把它显示成「文件资源管理器」/「Windows 资源管理器」—— 那是 Windows 内部规则。 +// 所以这里按名字认按钮,窗口匹配再配合窗口类 CabinetWClass。 +static bool is_file_explorer_button(const wchar_t *button_name) +{ + if(!button_name || !button_name[0]) + return false; + + wchar_t key[256]{}; + const wchar_t *sep = ::wcsstr(button_name, L" - "); + size_t n = sep ? static_cast(sep - button_name) : ::wcslen(button_name); + if(n == 0 || n >= 256) + return false; + ::wcsncpy_s(key, 256, button_name, n); + + // 运行中按钮名是「文件资源管理器」,固定后被固定项 lnk 的显示名覆盖成「Windows 资源管理器」 + return ::_wcsicmp(key, L"\u6587\u4EF6\u8D44\u6E90\u7BA1\u7406\u5668") == 0 || + ::_wcsicmp(key, L"Windows \u8D44\u6E90\u7BA1\u7406\u5668") == 0 || + ::_wcsicmp(key, L"File Explorer") == 0; +} + +// 任务栏按钮的名字形如 "任务管理器 - 1 个运行窗口",取 " - " 之前的部分作为关键词, +// 三级回退匹配:标题精确 → 标题包含 → 进程名相同。 +static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) +{ + auto f = reinterpret_cast(lParam); + if(!::IsWindowVisible(hWnd) || ::GetWindow(hWnd, GW_OWNER)) + return TRUE; + if(::GetWindowLongPtrW(hWnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) + return TRUE; + + wchar_t title[512]{}; + int tlen = ::GetWindowTextW(hWnd, title, 512); + if(tlen > 0) + normalize_for_match(title); + + const wchar_t *btn = f->keyword.c_str(); + const wchar_t *sep = ::wcsstr(btn, L" - "); + size_t n = sep ? static_cast(sep - btn) : ::wcslen(btn); + if(n == 0 || n >= 256) + return TRUE; + wchar_t key[256]{}; + ::wcsncpy_s(key, 256, btn, n); + normalize_for_match(key); + + bool matched = false; + // 1) 标题精确 2) 标题包含 + if(tlen > 0 && ::_wcsicmp(title, key) == 0) + matched = true; + else if(tlen > 0 && ::wcsstr(title, key)) + matched = true; + else + { + // 3) 进程名相同(QQ.exe / WeChat.exe …) + DWORD pid = 0; + ::GetWindowThreadProcessId(hWnd, &pid); + if(pid) + { + wchar_t path[MAX_PATH]{}; + if(HANDLE hProcess = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)) + { + DWORD size = MAX_PATH; + if(::QueryFullProcessImageNameW(hProcess, 0, path, &size)) + { + const wchar_t *file = ::wcsrchr(path, L'\\'); + file = file ? file + 1 : path; + wchar_t base[MAX_PATH]{}; + ::wcsncpy_s(base, MAX_PATH, file, MAX_PATH); + if(wchar_t *dot = ::wcsrchr(base, L'.')) + *dot = 0; + matched = ::_wcsicmp(base, key) == 0; + } + ::CloseHandle(hProcess); + } + } + } + + // 4) 用「该窗口在任务栏上叫的名字」匹配:任务栏按钮名就是 shell:AppsFolder 里的显示名 + // (窗口 AUMID = Microsoft.Windows.Explorer → 「文件资源管理器」; + // 没注册 AUMID 的应用按 exe 路径查,如 ...\blender-launcher.exe → 「Blender 5.2」)。 + // 这一级完全不看窗口标题,所以标题与按钮名差很远的程序也能认出来。 + if(!matched) + { + wchar_t btn_name[512]{}; + if(window_button_name(hWnd, btn_name, 512)) + { + normalize_for_match(btn_name); + matched = ::_wcsicmp(btn_name, key) == 0; + } + } + + // 5) 兜底:「文件资源管理器」的窗口不带任何 AppUserModel 属性、AppsFolder 也查不到, + // 只能按窗口类认(2 个文件夹窗口就是 2 个 CabinetWClass) + if(!matched && f->file_explorer) + { + wchar_t cls[64]{}; + if(::GetClassNameW(hWnd, cls, 64) > 0 && + (::_wcsicmp(cls, L"CabinetWClass") == 0 || ::_wcsicmp(cls, L"ExploreWClass") == 0)) + matched = true; + } + + if(!matched) + return TRUE; + + // 收集**全部**匹配窗口:应用有多个窗口(合并按钮)时要能一次全关。 + // 主窗口优先取非最小化的:UWP 常同时有「最小化的 ApplicationFrameWindow」和 + // 「真正显示中的 CoreWindow」,只关前者会没反应(实测)。 + f->all.push_back(hWnd); + if(!f->hwnd) + f->hwnd = hWnd; + // ⚠️ 这里不能像以前那样在找到非最小化窗口时 return FALSE 提前结束枚举,否则窗口收不全 + return TRUE; +} + +static void find_app_windows(string const &button_name, std::vector &out) +{ + out.clear(); + if(button_name.empty()) + return; + + // 按钮名形如 "任务管理器 - 1 个运行窗口" / "Microsoft Edge (2) - 1 个运行窗口"。 + // 先取 " - " 之前的部分;注意还可能带 " (2)" 这类窗口数/配置文件后缀 + // (Edge 多配置文件时会出现),它会破坏标题匹配,需要剥掉再试一次。 + wchar_t key[256]{}; + const wchar_t *btn = button_name.c_str(); + const wchar_t *sep = ::wcsstr(btn, L" - "); + size_t n = sep ? static_cast(sep - btn) : ::wcslen(btn); + if(n == 0) + return; + if(n >= 256) + n = 255; + ::wcsncpy_s(key, 256, btn, n); + + taskbar_window_finder f; + f.keyword = key; + f.file_explorer = is_file_explorer_button(button_name.c_str()); + ::EnumWindows(find_app_window_proc, reinterpret_cast(&f)); + if(!f.all.empty()) + { + out = f.all; + return; + } + + // 剥离尾部 " (数字)" 再匹配 + wchar_t stripped[256]{}; + ::wcsncpy_s(stripped, 256, key, 256); + if(wchar_t *p = ::wcsrchr(stripped, L'(')) + { + if(p > stripped && *(p - 1) == L' ') + { + wchar_t *q = p + 1; + bool digits = *q != 0; + for(; *q && *q != L')'; ++q) + { + if(*q < L'0' || *q > L'9') + { + digits = false; + break; + } + } + if(digits && *q == L')' && *(q + 1) == 0) + { + *(p - 1) = 0; + taskbar_window_finder g; + g.keyword = stripped; + g.file_explorer = f.file_explorer; + ::EnumWindows(find_app_window_proc, reinterpret_cast(&g)); + out = g.all; + } + } + } +} + +static HWND find_app_window(string const &button_name) +{ + std::vector w; + find_app_windows(button_name, w); + return w.empty() ? nullptr : w.front(); +} + +// ===== 任务栏固定 / 取消固定 ===== +// 任务栏靠 AppUserModelID 合并「固定项」与「运行中的窗口」: +// 若 .lnk 的 AUMID 与运行窗口的不一致,固定后就会出现两个图标(QQ 就是这种)。 +// 图标同理,.lnk 的 IconLocation 为空时某些应用(Edge)会显示成空白图标。 + +static bool taskbar_pinned_dir(wchar_t *out, size_t outLen) +{ + wchar_t appdata[MAX_PATH]{}; + if(!::SHGetSpecialFolderPathW(nullptr, appdata, CSIDL_APPDATA, FALSE)) + return false; + ::swprintf_s(out, outLen, L"%s\\Microsoft\\Internet Explorer\\Quick Launch\\User Pinned\\TaskBar", appdata); + return true; +} + +// 该 .lnk 的目标是否就是这个 exe +static bool lnk_targets(const wchar_t *lnk_path, const wchar_t *exe_path) +{ + IShellLinkW *link = nullptr; + if(FAILED(::CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW, reinterpret_cast(&link))) || !link) + return false; + bool ok = false; + IPersistFile *pf = nullptr; + if(SUCCEEDED(link->QueryInterface(IID_IPersistFile, reinterpret_cast(&pf))) && pf) + { + if(SUCCEEDED(pf->Load(lnk_path, STGM_READ))) + { + wchar_t target[MAX_PATH]{}; + if(SUCCEEDED(link->GetPath(target, MAX_PATH, nullptr, SLGP_UNCPRIORITY))) + ok = ::_wcsicmp(target, exe_path) == 0; + } + pf->Release(); + } + link->Release(); + return ok; +} + +// 任务栏真正显示的固定项记录在 HKCU\...\Taskband\Favorites(REG_BINARY)里; +// User Pinned\TaskBar 目录里的 .lnk 只是素材 —— 被取消固定后文件会残留(幽灵项), +// 只扫目录会把幽灵误判成「已固定」(实测:目录里有 Microsoft Edge.lnk,Favorites 里没有, +// 任务栏上也没有 Edge 按钮 → Edge 一运行就被错当成「已固定」)。 +// 这里在 blob 里按 UTF-16LE 搜 lnk 文件名;字符串在 blob 里的偏移奇偶不定,两种对齐都要试。 +static bool is_pinned_lnk_active(const wchar_t *lnk_path) +{ + const wchar_t *name = ::wcsrchr(lnk_path, L'\\'); + name = name ? name + 1 : lnk_path; + size_t nlen = ::wcslen(name); + if(nlen == 0 || nlen > 128) + return false; + + HKEY key = nullptr; + if(::RegOpenKeyExW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Taskband", 0, KEY_READ, &key) != ERROR_SUCCESS) + return false; + + DWORD type = 0, size = 0; + if(::RegQueryValueExW(key, L"Favorites", nullptr, &type, nullptr, &size) != ERROR_SUCCESS || size < nlen * 2) + { + ::RegCloseKey(key); + return false; + } + std::vector blob(size); + bool read = ::RegQueryValueExW(key, L"Favorites", nullptr, &type, blob.data(), &size) == ERROR_SUCCESS; + ::RegCloseKey(key); + if(!read) + return false; + + auto fold = [](BYTE c) -> BYTE { return (c >= 'A' && c <= 'Z') ? static_cast(c + 32) : c; }; + + for(int align = 0; align < 2; ++align) + { + for(size_t i = static_cast(align); i + nlen * 2 <= size; i += 2) + { + bool hit = true; + for(size_t j = 0; j < nlen; ++j) + { + const wchar_t wc = name[j]; + if(fold(blob[i + j * 2]) != fold(static_cast(wc & 0xFF)) || + fold(blob[i + j * 2 + 1]) != fold(static_cast((wc >> 8) & 0xFF))) + { + hit = false; + break; + } + } + if(hit) + return true; + } + } + return false; +} + +// 在 User Pinned\TaskBar 里找该 exe 的固定项快捷方式(只认真正在任务栏上的,排除残留幽灵项) +static bool find_pinned_lnk(const wchar_t *exe_path, wchar_t *out, size_t outLen) +{ + wchar_t dir[MAX_PATH]{}; + if(!taskbar_pinned_dir(dir, MAX_PATH)) + return false; + + wchar_t pattern[MAX_PATH]{}; + ::swprintf_s(pattern, MAX_PATH, L"%s\\*.lnk", dir); + WIN32_FIND_DATAW fd{}; + HANDLE h = ::FindFirstFileW(pattern, &fd); + if(h == INVALID_HANDLE_VALUE) + return false; + bool found = false; + do + { + wchar_t full[MAX_PATH]{}; + ::swprintf_s(full, MAX_PATH, L"%s\\%s", dir, fd.cFileName); + if(lnk_targets(full, exe_path) && is_pinned_lnk_active(full)) + { + ::wcsncpy_s(out, outLen, full, outLen - 1); + found = true; + break; + } + } while(::FindNextFileW(h, &fd)); + ::FindClose(h); + return found; +} + +// 读 .lnk 的 target 路径。注意有些固定项没有 target(例如「文件资源管理器」的 lnk +// 只写了 IconLocation=%windir%\explorer.exe),这时返回 false。 +static bool lnk_target(const wchar_t *lnk_path, wchar_t *out, size_t outLen) +{ + out[0] = 0; + IShellLinkW *link = nullptr; + if(FAILED(::CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW, reinterpret_cast(&link))) || !link) + return false; + bool ok = false; + IPersistFile *pf = nullptr; + if(SUCCEEDED(link->QueryInterface(IID_IPersistFile, reinterpret_cast(&pf))) && pf) + { + if(SUCCEEDED(pf->Load(lnk_path, STGM_READ))) + { + wchar_t target[MAX_PATH]{}; + if(SUCCEEDED(link->GetPath(target, MAX_PATH, nullptr, SLGP_UNCPRIORITY)) && target[0]) + { + ::wcsncpy_s(out, outLen, target, outLen - 1); + ok = true; + } + } + pf->Release(); + } + link->Release(); + return ok; +} + +// 读 .lnk 在资源管理器里显示的**本地化名字**(不带扩展名)。 +// 关键用例:"File Explorer (2).lnk" 在中文系统上显示为「文件资源管理器」, +// 而任务栏按钮名用的就是这个显示名,磁盘文件名对不上。 +static bool lnk_display_name(const wchar_t *lnk_path, wchar_t *out, size_t outLen) +{ + out[0] = 0; + IShellItem *item = nullptr; + if(FAILED(::SHCreateItemFromParsingName(lnk_path, nullptr, IID_IShellItem, reinterpret_cast(&item))) || !item) + return false; + + bool ok = false; + LPWSTR name = nullptr; + if(SUCCEEDED(item->GetDisplayName(SIGDN_NORMALDISPLAY, &name)) && name) + { + ::wcsncpy_s(out, outLen, name, outLen - 1); + ok = out[0] != 0; + ::CoTaskMemFree(name); + } + item->Release(); + return ok; +} + +// 未运行的固定项按钮上拿不到窗口,UIA 也**只给名字**(AutomationId 为空、没有 AUMID), +// 所以只能拿按钮名去 User Pinned\TaskBar 里比对固定项的 .lnk。 +// 三级匹配:lnk 文件名 == 按钮名 → 去掉尾部 " (数字)" 再比 → lnk 的显示名 / target 主名 == 按钮名。 +// 命中后还要过 is_pinned_lnk_active(目录里残留的幽灵项不算固定项)。 +static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, size_t lnkLen, wchar_t *exeOut, size_t exeLen) +{ + if(!button_name || !button_name[0]) + return false; + + // 按钮名形如 "任务管理器 - 1 个运行窗口";取 " - " 之前的部分 + wchar_t key[256]{}; + const wchar_t *sep = ::wcsstr(button_name, L" - "); + size_t n = sep ? static_cast(sep - button_name) : ::wcslen(button_name); + if(n == 0 || n >= 256) + return false; + ::wcsncpy_s(key, 256, button_name, n); + normalize_for_match(key); + + // 再去掉尾部的 " (数字)"(同一应用有多个固定项时任务栏会加序号,如 "Microsoft Edge (2)") + wchar_t stripped[256]{}; + ::wcsncpy_s(stripped, 256, key, 256); + if(wchar_t *p = ::wcsrchr(stripped, L'(')) + { + if(p > stripped && *(p - 1) == L' ') + { + wchar_t *q = p + 1; + bool digits = *q != 0; + for(; *q && *q != L')'; ++q) + { + if(*q < L'0' || *q > L'9') + { + digits = false; + break; + } + } + if(digits && *q == L')' && *(q + 1) == 0) + *(p - 1) = 0; + } + } + + wchar_t dir[MAX_PATH]{}; + if(!taskbar_pinned_dir(dir, MAX_PATH)) + return false; + + wchar_t pattern[MAX_PATH]{}; + ::swprintf_s(pattern, MAX_PATH, L"%s\\*.lnk", dir); + WIN32_FIND_DATAW fd{}; + HANDLE h = ::FindFirstFileW(pattern, &fd); + if(h == INVALID_HANDLE_VALUE) + return false; + + bool found = false; + do + { + if(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + continue; + + wchar_t full[MAX_PATH]{}; + ::swprintf_s(full, MAX_PATH, L"%s\\%s", dir, fd.cFileName); + + // ① lnk 文件名(去扩展名) + wchar_t stem[MAX_PATH]{}; + ::wcsncpy_s(stem, MAX_PATH, fd.cFileName, MAX_PATH); + if(wchar_t *dot = ::wcsrchr(stem, L'.')) + *dot = 0; + normalize_for_match(stem); + + bool matched = ::_wcsicmp(stem, key) == 0; + if(!matched && stripped[0]) + matched = ::_wcsicmp(stem, stripped) == 0; + + // ② lnk 的本地化显示名(「文件资源管理器」↔ File Explorer (2).lnk) + if(!matched) + { + wchar_t disp[MAX_PATH]{}; + if(lnk_display_name(full, disp, MAX_PATH)) + { + normalize_for_match(disp); + matched = ::_wcsicmp(disp, key) == 0 || (stripped[0] && ::_wcsicmp(disp, stripped) == 0); + } + } + + // ③ lnk 的 target 主名(没扩展名) + if(!matched) + { + wchar_t target[MAX_PATH]{}; + if(lnk_target(full, target, MAX_PATH)) + { + const wchar_t *file = ::wcsrchr(target, L'\\'); + file = file ? file + 1 : target; + wchar_t base[MAX_PATH]{}; + ::wcsncpy_s(base, MAX_PATH, file, MAX_PATH); + if(wchar_t *dot = ::wcsrchr(base, L'.')) + *dot = 0; + matched = ::_wcsicmp(base, key) == 0 || (stripped[0] && ::_wcsicmp(base, stripped) == 0); + } + } + + if(matched) + { + // 已被取消固定、只剩文件的幽灵项不算固定项 + if(!is_pinned_lnk_active(full)) + continue; + + ::wcsncpy_s(lnkOut, lnkLen, full, lnkLen - 1); + if(exeOut && exeLen && !lnk_target(full, exeOut, exeLen)) + exeOut[0] = 0; + found = true; + break; + } + } while(::FindNextFileW(h, &fd)); + ::FindClose(h); + return found; +} + +static bool is_pinned_to_taskbar(const wchar_t *exe_path) +{ + wchar_t lnk[MAX_PATH]{}; + return find_pinned_lnk(exe_path, lnk, MAX_PATH); +} + +static void set_lnk_prop(IPropertyStore *ps, REFPROPERTYKEY key, const wchar_t *value) +{ + if(!value || !value[0]) + return; + PROPVARIANT pv{}; + pv.vt = VT_LPWSTR; + pv.pwszVal = const_cast(value); + ps->SetValue(key, pv); +} + +// pin 完成后修正刚生成的快捷方式: +// ① 补上**整组** AppUserModel 属性(ID + RelaunchCommand + RelaunchDisplayNameResource + IconResource)—— +// 只补 AUMID 不够:任务栏算 AppID 还看 RelaunchCommand,缺了它固定项会跟运行窗口分成两个图标 +// (实测 Blender:lnk 只有 ID、target 是 blender.exe,而窗口 relaunch 是 blender-launcher.exe)。 +// ② 补图标位置,避免空白图标。 +static void fix_pinned_shortcut(const wchar_t *lnk_path, const wchar_t *exe_path, const window_appmodel &am) +{ + IShellLinkW *link = nullptr; + if(FAILED(::CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW, reinterpret_cast(&link))) || !link) + return; + IPersistFile *pf = nullptr; + if(SUCCEEDED(link->QueryInterface(IID_IPersistFile, reinterpret_cast(&pf))) && pf) + { + if(SUCCEEDED(pf->Load(lnk_path, STGM_READWRITE))) + { + link->SetIconLocation(exe_path, 0); + IPropertyStore *ps = nullptr; + if(SUCCEEDED(link->QueryInterface(IID_IPropertyStore, reinterpret_cast(&ps))) && ps) + { + set_lnk_prop(ps, PKEY_AppUserModel_ID, am.aumid); + set_lnk_prop(ps, PKEY_AppUserModel_RelaunchCommand, am.relaunch_command); + set_lnk_prop(ps, PKEY_AppUserModel_RelaunchDisplayNameResource, am.relaunch_display_name); + set_lnk_prop(ps, PKEY_AppUserModel_RelaunchIconResource, am.relaunch_icon); + ps->Commit(); + ps->Release(); + } + pf->Save(lnk_path, TRUE); + } + pf->Release(); + } + link->Release(); +} + +// 调用 shell 上下文菜单里的某个动词(按语言无关的规范名,如 "TaskbarPin")。 +// +// 为什么不能直接用 ShellExecuteEx(verb): +// Win10 上 "TaskbarPin" 这个动词虽然存在,但程序化调用会被**静默忽略** +// (老的 "pin to taskbar" 更是直接 ERROR_NO_ASSOCIATION)——微软的「用户意图」保护。 +// 文件资源管理器里点「固定到任务栏」能生效,是因为 explorer 走的是 IContextMenu::InvokeCommand。 +// 本函数复刻这条路径,所以能真正固定。 +static bool invoke_shell_verb(const wchar_t *path, const wchar_t *verb_name) +{ + if(!path || !verb_name) + return false; + + PIDLIST_ABSOLUTE pidl = nullptr; + if(FAILED(::SHParseDisplayName(path, nullptr, &pidl, 0, nullptr)) || !pidl) + return false; + + bool ok = false; + IShellFolder *parent = nullptr; + PCUITEMID_CHILD child = nullptr; + if(SUCCEEDED(::SHBindToParent(pidl, IID_IShellFolder, reinterpret_cast(&parent), &child)) && parent) + { + IContextMenu *cm = nullptr; + if(SUCCEEDED(parent->GetUIObjectOf(nullptr, 1, &child, IID_IContextMenu, nullptr, reinterpret_cast(&cm))) && cm) + { + HMENU menu = ::CreatePopupMenu(); + if(menu && SUCCEEDED(cm->QueryContextMenu(menu, 0, 1, 0x7FFF, CMF_NORMAL))) + { + // 找不到目标动词时,把枚举到的动词名记进日志:不同对象上挂的动词集合不一样 + // (「从任务栏取消固定」只在固定项 .lnk 上有),下次失败可直接看清单定位。 + wchar_t seen[512]{}; + int count = ::GetMenuItemCount(menu); + for(int i = 0; i < count; ++i) + { + UINT id = ::GetMenuItemID(menu, i); + if(id == 0 || id == static_cast(-1)) + continue; + + wchar_t verb[128]{}; + if(SUCCEEDED(cm->GetCommandString(id - 1, GCS_VERBW, nullptr, reinterpret_cast(verb), 128))) + { + if(verb[0]) + { + size_t used = ::wcslen(seen); + if(used > 0 && used < 500) + seen[used++] = L','; + for(const wchar_t *p = verb; *p && used < 505; ++p) + seen[used++] = *p; + seen[used] = 0; + } + if(::_wcsicmp(verb, verb_name) == 0) + { + CMINVOKECOMMANDINFOEX ci{}; + ci.cbSize = sizeof(ci); + ci.fMask = CMIC_MASK_UNICODE | CMIC_MASK_FLAG_NO_UI; + ci.lpVerb = MAKEINTRESOURCEA(id - 1); + ci.lpVerbW = MAKEINTRESOURCEW(id - 1); + ci.nShow = SW_SHOWNORMAL; + ok = SUCCEEDED(cm->InvokeCommand(reinterpret_cast(&ci))); + break; + } + } + } + if(!ok && !seen[0]) + _log.info(L"[BTN] verb '%s' not found on '%s' (no verbs)", verb_name, path); + else if(!ok) + _log.info(L"[BTN] verb '%s' not found on '%s' (verbs: %s)", verb_name, path, seen); + } + if(menu) + ::DestroyMenu(menu); + cm->Release(); + } + parent->Release(); + } + ::CoTaskMemFree(pidl); + return ok; +} + +// 微软官方的「从任务栏 / 开始菜单取消固定」API(CLSID_StartMenuPin,实现于 shell32.dll)。 +// 它**只有 RemoveFromList**:官方没有提供「固定到任务栏」的 API(防止程序擅自改任务栏), +// 所以「固定」只能走 shell 动词,「取消固定」在动词不可用时用这条兜底。 +// 实测用例:File Explorer (2).lnk 上压根没有 taskbarunpin 动词 +// (枚举结果只有 open/opencontaining/OpenNewWindow/cut/copy/link/delete/properties), +// 但它在 Taskband\Favorites 里确实固定着,只能靠这条 API 取消。 +static bool start_menu_remove_from_list(const wchar_t *path) +{ + if(!path || !path[0]) + return false; + + IStartMenuPinnedList *pinned = nullptr; + if(FAILED(::CoCreateInstance(CLSID_StartMenuPin, nullptr, CLSCTX_INPROC_SERVER, IID_IStartMenuPinnedList, reinterpret_cast(&pinned))) || !pinned) + { + _log.info(L"[BTN] StartMenuPin: CoCreateInstance failed"); + return false; + } + + IShellItem *item = nullptr; + HRESULT hr = ::SHCreateItemFromParsingName(path, nullptr, IID_IShellItem, reinterpret_cast(&item)); + if(FAILED(hr) || !item) + { + _log.info(L"[BTN] StartMenuPin: SHCreateItemFromParsingName('%s') hr=0x%08X", path, (unsigned)hr); + pinned->Release(); + return false; + } + + hr = pinned->RemoveFromList(item); + _log.info(L"[BTN] StartMenuPin::RemoveFromList('%s') hr=0x%08X", path, (unsigned)hr); + item->Release(); + pinned->Release(); + return SUCCEEDED(hr); +} + +// 后台线程里跑:QueryContextMenu 会加载一堆 shell 扩展,别拖住 explorer 的 UI 线程 +// lnk 为「该应用的固定项快捷方式」路径(可能为空)。取消固定的动词只挂在这种 .lnk 上。 +static void set_taskbar_pin_async(string path, string lnk, HWND hWnd, bool pin) +{ + std::thread([path, lnk, hWnd, pin]() + { + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + // 固定前先把运行窗口的整组 AppUserModel 属性记下来,固定后写进快捷方式 —— + // 只写 AUMID 不够,任务栏算 AppID 还看 RelaunchCommand,缺了就会分成两个图标 + window_appmodel am; + if(pin) + { + get_window_appmodel(hWnd, am); + + // 「文件资源管理器」例外:explorer 的窗口一个 AppUserModel 属性都不带, + // 但固定项必须写上这组值才能与运行窗口合并(实测写入后重启 explorer, + // 固定项与文件夹窗口合并成一个按钮)。这些值是 Windows 自己用的 —— + // AppsFolder 里 Microsoft.Windows.Explorer 的显示名就是「文件资源管理器」。 + const wchar_t *file = ::wcsrchr(path.c_str(), L'\\'); + file = file ? file + 1 : path.c_str(); + if(!am.aumid[0] && ::_wcsicmp(file, L"explorer.exe") == 0) + { + ::wcsncpy_s(am.aumid, 256, L"Microsoft.Windows.Explorer", 255); + if(!am.relaunch_command[0]) + ::wcsncpy_s(am.relaunch_command, 1024, L"\"C:\\Windows\\explorer.exe\"", 1023); + if(!am.relaunch_display_name[0]) + ::wcsncpy_s(am.relaunch_display_name, 512, L"\u6587\u4EF6\u8D44\u6E90\u7BA1\u7406\u5668", 511); + } + } + + // 「从任务栏取消固定」的动词实测只挂在固定项的 .lnk 上 + // (msedge.exe / DeepSeek Harness.exe / notepad.exe / 普通 lnk 上都没有), + // 对 exe 调用自然枚举不到 → 这是取消固定时灵时不灵的根因。 + // 所以先从 .lnk 试,失败再回退到原来的 exe 路径。 + bool ok = false; + if(!pin && !lnk.empty()) + ok = invoke_shell_verb(lnk.c_str(), L"taskbarunpin"); + if(!ok) + ok = invoke_shell_verb(path.c_str(), pin ? L"TaskbarPin" : L"TaskbarUnpin"); + // 有些固定项的 .lnk 上压根没有 taskbarunpin 动词(实测「文件资源管理器」), + // 这时用官方 API 兜底(它只有取消固定,没有固定)。 + if(!ok && !pin) + ok = start_menu_remove_from_list(lnk.empty() ? path.c_str() : lnk.c_str()); + _log.info(L"[BTN] %s exe='%s' lnk='%s' -> %s", + pin ? L"TaskbarPin" : L"TaskbarUnpin", path.c_str(), lnk.c_str(), ok ? L"ok" : L"failed"); + + // 固定成功后补写快捷方式:AUMID + 图标位置 + if(pin && ok) + { + for(int i = 0; i < 25; ++i) + { + ::Sleep(200); + wchar_t lnk[MAX_PATH]{}; + if(find_pinned_lnk(path.c_str(), lnk, MAX_PATH)) + { + fix_pinned_shortcut(lnk, path.c_str(), am); + _log.info(L"[BTN] patched lnk aumid='%s' relaunch='%s' name='%s'", + am.aumid, am.relaunch_command, am.relaunch_display_name); + break; + } + } + } + ::CoUninitialize(); + }).detach(); +} + +// 「关闭窗口」= 请求窗口关闭,**不是结束进程**(结束进程会丢数据、应用没机会保存)。 +// 1) UIA 的 WindowPattern.Close():标准「关闭窗口」语义(等同用户点标题栏 ×,应用可弹保存/取消)。 +// UWP / 现代应用只认这条 —— 实测「设置」窗口对 WM_CLOSE、WM_SYSCOMMAND/SC_CLOSE、 +// SendMessage(WM_CLOSE) 三种传统发法全部无响应。 +// 2) 普通 Win32 窗口兜底:WM_CLOSE。 +// UIA 调用可能较慢,放后台线程,避免卡住 explorer 的 UI 线程。 +static bool close_window_via_uia(HWND hWnd) +{ + if(!hWnd || !taskbar_t::_IUIAutomation) + return false; + + IComPtr elem; + if(FAILED(taskbar_t::_IUIAutomation->ElementFromHandle(hWnd, elem)) || !elem) + return false; + + IUnknown *unk = nullptr; + if(FAILED(elem->GetCurrentPattern(UIA_WindowPatternId, &unk)) || !unk) + return false; + + bool ok = false; + IUIAutomationWindowPattern *wp = nullptr; + if(SUCCEEDED(unk->QueryInterface(__uuidof(IUIAutomationWindowPattern), reinterpret_cast(&wp))) && wp) + { + ok = SUCCEEDED(wp->Close()); + wp->Release(); + } + unk->Release(); + return ok; +} + +static void close_app_window_async(HWND hWnd) +{ + if(!hWnd) + return; + std::thread([hWnd]() + { + if(close_window_via_uia(hWnd)) + return; + ::PostMessageW(hWnd, WM_CLOSE, 0, 0); + }).detach(); +} + +// 把匹配到的窗口收敛成「真正要关闭的顶层窗口」:按 GA_ROOT 去重 —— +// UWP 会同时出现 CoreWindow 和它的宿主 ApplicationFrameWindow,其实是同一个窗口。 +static void distinct_top_windows(const std::vector &in, std::vector &out) +{ + out.clear(); + for(HWND w : in) + { + HWND root = ::GetAncestor(w, GA_ROOT); + if(!root) + root = w; + bool dup = false; + for(HWND t : out) + { + if(t == root) + { + dup = true; + break; + } + } + if(!dup) + out.push_back(root); + } +} + +static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_info &info) +{ + find_app_windows(info.title, info.windows); + info.window = info.windows.empty() ? nullptr : info.windows.front(); + + HMENU menu = ::CreatePopupMenu(); + if(!menu) + return; + + const UINT id_pin = 0x1001; + const UINT id_close = 0x1002; + const UINT id_unpin = 0x1003; + + // 源码文件无 BOM,MSVC 按 GBK(936) 解析源文件,中文字面量必须用 \u 转义, + // 否则会乱码:"固定到任务栏" → "鏁哄畾鍒颁换鍔℃爮" + const wchar_t *text_pin = L"\u56FA\u5B9A\u5230\u4EFB\u52A1\u680F"; // 固定到任务栏 + const wchar_t *text_unpin = L"\u4ECE\u4EFB\u52A1\u680F\u53D6\u6D88\u56FA\u5B9A"; // 从任务栏取消固定 + const wchar_t *text_close = L"\u5173\u95ED\u7A97\u53E3"; // 关闭窗口 + const wchar_t *text_close_all = L"\u5173\u95ED\u6240\u6709\u7A97\u53E3"; // 关闭所有窗口 + + auto title = info.title.empty() ? string(L"Application") : info.title; + ::AppendMenuW(menu, MF_STRING, 0, title.c_str()); + ::AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + + // 固定 / 取消固定:按当前是否已固定决定显示哪个,并记下 exe / 固定项 .lnk 给动作分支用 + wchar_t exe_path[MAX_PATH]{}; + bool has_exe = false; + if(info.window) + { + DWORD pid = 0; + ::GetWindowThreadProcessId(info.window, &pid); + if(pid) + { + if(HANDLE hp = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid)) + { + DWORD size = MAX_PATH; + if(::QueryFullProcessImageNameW(hp, 0, exe_path, &size)) + has_exe = true; + ::CloseHandle(hp); + } + } + } + + // 没运行(纯固定项按钮)时上面拿不到 exe:改用按钮名去固定项目录里找对应的 .lnk, + // 顺带取它的 target 当 exe(「文件资源管理器」这类 lnk 没有 target,只拿到 .lnk) + wchar_t pinned_lnk[MAX_PATH]{}; + bool has_pinned_lnk = false; + if(!has_exe) + { + has_pinned_lnk = find_pinned_lnk_by_name(info.title.c_str(), pinned_lnk, MAX_PATH, exe_path, MAX_PATH); + if(has_pinned_lnk && exe_path[0]) + has_exe = true; + } + // 运行中的固定项:由 exe 反查它的固定项 .lnk(取消固定的动词挂在 .lnk 上) + if(!has_pinned_lnk && has_exe) + has_pinned_lnk = find_pinned_lnk(exe_path, pinned_lnk, MAX_PATH); + + if(has_pinned_lnk) + ::AppendMenuW(menu, MF_STRING, id_unpin, text_unpin); + else if(has_exe) + ::AppendMenuW(menu, MF_STRING, id_pin, text_pin); + + // 「关闭窗口」只在真的有运行窗口时才给:没有窗口时点它没有任何作用。 + // 应用有多个窗口(合并按钮)时改成「关闭所有窗口」,一次全关。 + std::vector close_targets; + distinct_top_windows(info.windows, close_targets); + if(!close_targets.empty()) + ::AppendMenuW(menu, MF_STRING, id_close, close_targets.size() > 1 ? text_close_all : text_close); + + _log.info(L"[BTN] pid=%d window=0x%x windows=%d class='%s' title='%s' exe='%s' lnk='%s'", + info.pid, (unsigned)(ULONG_PTR)info.window, static_cast(close_targets.size()), + info.window ? Window::class_name(info.window).c_str() : L"", + info.title.c_str(), exe_path, pinned_lnk); + + // 菜单必须先拿到前台/焦点:否则键盘导航无效,鼠标第一次点击只当"激活", + // 且任何进程抢焦点都会把菜单关掉(返回 0)。 + if(auto root = ::GetAncestor(hWnd, GA_ROOT)) + ::SetForegroundWindow(root); + ::SetForegroundWindow(hWnd); + + is_in_taskbar = true; + in_taskbar_button_menu = true; + auto cmd = TrackPopupMenuExProc(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, hWnd, nullptr); + in_taskbar_button_menu = false; + is_in_taskbar = false; + + _log.info(L"[BTN] command=%d", static_cast(cmd)); + + if(cmd == id_close && !close_targets.empty()) + { + // UWP 应用(设置 / 商店等)里层的 Windows.UI.Core.CoreWindow 不响应 SC_CLOSE, + // 必须把关闭消息发给顶层宿主(ApplicationFrameWindow)—— 上面已按 GA_ROOT 收敛过。 + // 多窗口(合并按钮)时逐个发关闭请求,应用自己仍可弹保存/取消框。 + _log.info(L"[BTN] close %d window(s)", static_cast(close_targets.size())); + for(HWND t : close_targets) + close_app_window_async(t); + } + else if(cmd == id_pin && has_exe) + { + _log.info(L"[BTN] pin '%s'", exe_path); + set_taskbar_pin_async(exe_path, string(), info.window, true); + } + else if(cmd == id_unpin && has_pinned_lnk) + { + _log.info(L"[BTN] unpin exe='%s' lnk='%s'", exe_path, pinned_lnk); + set_taskbar_pin_async(has_exe ? string(exe_path) : string(), string(pinned_lnk), info.window, false); + } + + ::DestroyMenu(menu); +} + +LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) +{ + auto proc = reinterpret_cast(::GetPropW(hWnd, TaskListSubclassProp)); + if(!proc) + return ::DefWindowProcW(hWnd, uMsg, wParam, lParam); + + switch(uMsg) + { + case WM_CONTEXTMENU: + { + auto pt = Point::CursorPos(); + auto info = query_taskbar_button(pt); + if(!info.is_button) + break; // 按钮之间的空白:放行,走 explorer/Shell 原有的任务栏菜单 + _log.info(L"[BTN] WM_CONTEXTMENU cursor=(%d,%d)", pt.x, pt.y); + show_taskbar_button_menu(hWnd, pt, info); + return 0; // 只对真正的应用按钮吞掉,阻止 XAML 跳转列表 + } + } + return ::CallWindowProcW(proc, hWnd, uMsg, wParam, lParam); +} + +static bool tasklist_hook() +{ + auto tray = ::FindWindowW(Windows::WC_Shell_TrayWnd, nullptr); + _log.info(L"[PROBE] step1 tray=0x%x", (unsigned)(ULONG_PTR)tray); + if(!tray) + return false; + auto rebar = ::FindWindowExW(tray, nullptr, Windows::WC_ReBarWindow32, nullptr); + _log.info(L"[PROBE] step2 rebar=0x%x", (unsigned)(ULONG_PTR)rebar); + if(!rebar) + return false; + auto sw = ::FindWindowExW(rebar, nullptr, Windows::WC_MSTaskSwWClass, nullptr); + _log.info(L"[PROBE] step3 sw=0x%x", (unsigned)(ULONG_PTR)sw); + if(!sw) + return false; + auto list = ::FindWindowExW(sw, nullptr, Windows::WC_MSTaskListWClass, nullptr); + _log.info(L"[PROBE] step4 list=0x%x", (unsigned)(ULONG_PTR)list); + if(!list) + return false; + if(::GetPropW(list, TaskListSubclassProp)) + return true; + + auto proc = reinterpret_cast(::GetWindowLongPtrW(list, GWLP_WNDPROC)); + ::SetPropW(list, TaskListSubclassProp, reinterpret_cast(proc)); + ::SetWindowLongPtrW(list, GWLP_WNDPROC, reinterpret_cast(TaskListProc)); + _log.info(L"[PROBE] MSTaskListWClass hooked hwnd=0x%x", (unsigned)(ULONG_PTR)list); + return true; +} + +static void tasklist_hook_all(uint32_t delay) +{ + std::thread([delay]() + { + for(int i = 0; i < 15; ++i) + { + ::Sleep(delay); + if(tasklist_hook()) + break; + } + _log.info(L"[PROBE] tasklist_hook_all thread finished"); + }).detach(); +} + HRESULT __stdcall CoCreateInstanceHook(REFCLSID rclsid, LPUNKNOWN pUnkOuter, DWORD dwClsContext, REFIID riid, LPVOID *ppv) { auto is_local_server = (dwClsContext & CLSCTX_LOCAL_SERVER) != 0; @@ -514,6 +1629,10 @@ STDAPI WINAPI DllGetClassObjectHook(REFCLSID rclsid, _In_ REFIID riid, LPVOID *p #define TPM_SYSMENU 0x0200L bool is_in_taskbar; +// 任务栏「应用按钮」自建菜单期间为 true(见 show_taskbar_button_menu)。 +// ContextMenu 用它决定这次不加载 .nss 配置项 —— 对单个应用按钮来说配置项没有意义 +// (实际会冒出来的就是「转到」)。任务栏空白处 / 桌面 / 文件菜单不走这条路径,不受影响。 +bool in_taskbar_button_menu; BOOL WINAPI TrackPopupMenuProc(HMENU hMenu, uint32_t uFlags, int x, int y, int nReserved, HWND hWnd, const RECT *prcRect); BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); @@ -527,6 +1646,7 @@ BOOL WINAPI NtUserTrackPopupMenu(HMENU hMenu, uint32_t uFlags, int x, int y, HWN auto result = FALSE; auto invoked = false; + auto invoke = [&](HMENU hmenu, uint32_t flag, Point pt) { if(!invoked) @@ -1040,6 +2160,10 @@ BOOL APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) //m->init(hModule, "api-ms-win-core-com-l1-1-0.dll", ::CoCreateInstance, CoCreateInstanceProc).install(); _CoCreateInstance.Commit(); + // [注] 曾试过「无条件安装 IAT hook」(方案 B),实测: + // 1) 对任务栏按钮菜单无效(它不走 user32!TrackPopupMenu); + // 2) 会破坏 explorer 的系统菜单填充(桌面菜单只剩配置项,系统项全丢)。 + // 故回退为官方原有条件。 if(!iathook_NtUserTrackPopupMenuEx.installed()) { std::thread([=]() { ::Sleep(2000); hook(); }).detach(); @@ -1074,6 +2198,11 @@ BOOL APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) taskbar_t::hook_all(1000); } + else + { + // [PROBE] Win10:任务栏按钮区消息探针(只记录) + tasklist_hook_all(1000); + } } else if(!iathook_NtUserTrackPopupMenuEx.install()) { From 71ec0a4dce06b4a653ff3eaf9946954fe6c84985 Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 02:46:33 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(win10-taskbar):=20=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=94=AF=E6=8C=81=20.nss=20=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E9=A1=B9=EF=BC=88window.is=5Ftaskbar=5Fbutto?= =?UTF-8?q?n=EF=BC=89+=20=E7=BB=93=E6=9D=9F=E8=BF=9B=E7=A8=8B=E5=91=BD?= =?UTF-8?q?=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按钮菜单的上下文类型改成独立的 FSO_TASKBAR_BUTTON:用 type='taskbar' / window.is_taskbar 写的既有项(例如「转到」)不会再涌进按钮菜单;配置里用 where=window.is_taskbar_button 可以精确命中按钮菜单(任务栏空白处、桌面、文件菜单都是 false)。 - 新增内置命令 command.kill_process_tree:按 Toolhelp 快照展开按钮下**所有**窗口的进程树, 再从叶子往根终止(先杀父进程的话子进程会被系统改挂,杀树会漏)。 - 修好控制台窗口(cmd.exe)认不出来:AppsFolder 里 cmd.exe 的键是 {1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\cmd.exe,拿 exe 全路径查显示名必然失败。 增加「按 exe 文件名查 AppsFolder」兜底(整表只建一次 + 缓存)。 cmd 按钮因此恢复:结束进程 / 关闭窗口 / 固定到任务栏。 --- src/dll/src/ContextMenu.cpp | 28 ++-- src/dll/src/Expression/FuncExpression.cpp | 19 ++- src/dll/src/Main.cpp | 161 +++++++++++++++++++++- src/dll/src/Parser/IdentHash.h | 2 + src/dll/src/Parser/Verification.cpp | 2 + src/dll/src/Selections.cpp | 4 +- src/shared/FileSystemObjects.h | 3 + 7 files changed, 200 insertions(+), 19 deletions(-) diff --git a/src/dll/src/ContextMenu.cpp b/src/dll/src/ContextMenu.cpp index 4761bd06..4a48c3fe 100644 --- a/src/dll/src/ContextMenu.cpp +++ b/src/dll/src/ContextMenu.cpp @@ -1111,11 +1111,10 @@ namespace Nilesoft std::vector bottom_items; - // 任务栏「应用按钮」自建菜单不加载 .nss 配置项(标志见 Main.cpp 的 in_taskbar_button_menu)。 - // 配置里新增的 menu(...)/item(...) 就是这一步加进来的 —— 例如「转到」(imports/goto.nss), - // 对单个应用按钮没有意义。注意这与下面 build_main_system_menuitems 那条是**两个不同入口**: - // 这里管「新增项」,那里管 modify(...) 这类「修改已有项」的规则。 - if(_settings.new_items.enabled && !in_taskbar_button_menu) + // 配置里新增的 menu(...)/item(...) 就是这一步加进来的。按钮菜单同样吃这一套: + // 它已是独立类型(FSO_TASKBAR_BUTTON),普通 taskbar 项不会命中它, + // 所以这里不再需要按 in_taskbar_button_menu 屏蔽。 + if(_settings.new_items.enabled) { if(!(owner && owner->is_disabled())) { @@ -1202,7 +1201,7 @@ namespace Nilesoft else { bool rb = !Selected.Background && (Selected.Window.id == WINDOW_RECYCLEBIN); - if(!Selected.Types[FSO_TASKBAR] && !rb) + if(!Selected.Types[FSO_TASKBAR] && !Selected.Types[FSO_TASKBAR_BUTTON] && !rb) { dynamic_pos = items.size() == 0 ? 1 : findpos(); } @@ -4400,7 +4399,15 @@ namespace Nilesoft switch(Selected.Window.id) { case WINDOW_TASKBAR: - Selected.Types[FSO_TASKBAR] = TRUE; + // 任务栏「应用按钮」的自建菜单(见 Main.cpp 的 in_taskbar_button_menu)走独立类型位: + // 它虽然是任务栏的上下文,但类型标成 FSO_TASKBAR_BUTTON 而不是 FSO_TASKBAR, + // 于是「转到」、imports/taskbar.nss 这些已按 type='taskbar'/window.is_taskbar 写好的项 + // 不会自动涌进按钮菜单;配置里要用 where=window.is_taskbar_button 才能命中。 + if(in_taskbar_button_menu) + Selected.Types[FSO_TASKBAR_BUTTON] = TRUE; + else + Selected.Types[FSO_TASKBAR] = TRUE; + // front 保持 FSO_TASKBAR:按钮菜单此前也是这个值,避免影响依赖它的显示逻辑。 Selected.front = FSO_TASKBAR; Selected.Directory = Path::GetKnownFolder(FOLDERID_Desktop).move(); break; @@ -4477,10 +4484,9 @@ namespace Nilesoft if(0 == ::GetPropW(hwnd.owner, UxSubclass)) build_system_menuitems(_hMenu_original, __system_menu_tree, true); - // 任务栏「应用按钮」的自建菜单里不加载 .nss 配置项 —— 例如「转到」对单个应用按钮 - // 没有意义(这些项由 _cache->statics 应用,就是这一步)。 - // 任务栏空白处 / 桌面 / 文件菜单不走这条自建菜单路径,行为不变。 - if(_settings.modify_items.enabled && !in_taskbar_button_menu) + // modify(...) 这类「修改已有项」的规则同样允许作用到按钮菜单(它已是独立类型, + // 通用 taskbar 规则不会误伤),因此不再按 in_taskbar_button_menu 屏蔽。 + if(_settings.modify_items.enabled) build_main_system_menuitems(__system_menu_tree, true); return true; diff --git a/src/dll/src/Expression/FuncExpression.cpp b/src/dll/src/Expression/FuncExpression.cpp index d1af1114..02d894c1 100644 --- a/src/dll/src/Expression/FuncExpression.cpp +++ b/src/dll/src/Expression/FuncExpression.cpp @@ -1,4 +1,4 @@ -#include +#include #include "Resource.h" #include "Expression\Constants.h" #include "Expression\Variable.h" @@ -9,6 +9,9 @@ #include +// 定义在 Main.cpp:杀掉任务栏「应用按钮」菜单当前对应窗口的进程树(command.kill_process_tree)。 +extern bool kill_taskbar_button_process_tree(); + //#include namespace Nilesoft @@ -1461,7 +1464,15 @@ namespace Nilesoft _result = sel->Window.desktop; break; case IDENT_IS_TASKBAR: - _result = sel->Window.parent == WINDOW_TASKBAR; + // 按钮菜单虽然也挂在任务栏上,但它有自己的上下文类型 + // (FSO_TASKBAR_BUTTON),这里返回 false, + // 好让 type='taskbar' / window.is_taskbar 写的项(例如「转到」) + // 不会跟着跑进按钮菜单。 + _result = (sel->Window.parent == WINDOW_TASKBAR) && !(sel->Types[FSO_TASKBAR_BUTTON] > 0); + break; + case IDENT_IS_TASKBAR_BUTTON: + // 只对任务栏「应用按钮」的自建菜单为真(见 ContextMenu.cpp 的 WINDOW_TASKBAR 分支) + _result = sel->Types[FSO_TASKBAR_BUTTON] > 0; break; case IDENT_IS_EXPLORER: _result = sel->Window.explorer; @@ -1718,6 +1729,10 @@ namespace Nilesoft case IDENT_COMMAND_RESTART_EXPLORER: _result = Windows::Explorer::Restart(); break; + case IDENT_COMMAND_KILL_PROCESS_TREE: + // 结束该任务栏按钮对应窗口的进程树(按钮菜单由 command.kill_process_tree 提供该项) + _result = kill_taskbar_button_process_tree(); + break; case IDENT_COMMAND_TOGGLEHIDDEN: Explorer::ToggleHiddenFiles(); break; diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 9152b5c3..820cbbba 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -392,6 +392,8 @@ struct taskbar_t // 前置:这两个符号原本定义/声明在本文件更靠后的位置(约 515 行) extern bool is_in_taskbar; extern bool in_taskbar_button_menu; +// 定义在文件后部(约 1630 行),此处前置声明给按钮菜单与 command.kill_process_tree 用 +extern std::vector taskbar_button_windows; BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); // 注意:subclass 标记不能沿用 UxSubclass —— ContextMenu::Initialize() 里会用 // `if(0 == ::GetPropW(hwnd.owner, UxSubclass))` 判断该窗口是否已由 Shell 处理过, @@ -542,6 +544,74 @@ static bool appsfolder_display_name(const wchar_t *aumid_or_path, wchar_t *out, return ok; } +// exe 全路径查不到时的兜底:AppsFolder 里的项**不一定**拿 exe 全路径当键 —— +// 例如 cmd.exe 是 shell:AppsFolder\{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\cmd.exe +// (System32 的 KNOWNFOLDERID),拿 C:\Windows\system32\cmd.exe 去查必然失败。 +// 所以这里按 exe 文件名在 AppsFolder 里找同名项,取它的显示名(cmd.exe → 「命令提示符」)。 +// 整张表只建一次并缓存。 +static const std::map &appsfolder_name_map() +{ + static std::once_flag once; + static std::map cache; + std::call_once(once, []() + { + IShellItem *folder = nullptr; + if(FAILED(::SHCreateItemFromParsingName(L"shell:AppsFolder", nullptr, IID_IShellItem, + reinterpret_cast(&folder))) || !folder) + return; + + IEnumShellItems *en = nullptr; + if(SUCCEEDED(folder->BindToHandler(nullptr, BHID_EnumItems, IID_IEnumShellItems, + reinterpret_cast(&en))) && en) + { + IShellItem *child = nullptr; + while(en->Next(1, &child, nullptr) == S_OK) + { + LPWSTR path = nullptr; + if(SUCCEEDED(child->GetDisplayName(SIGDN_DESKTOPABSOLUTEPARSING, &path)) && path) + { + const wchar_t *file = ::wcsrchr(path, L'\\'); + file = file ? file + 1 : path; + if(file[0] && cache.find(file) == cache.end()) + { + LPWSTR name = nullptr; + if(SUCCEEDED(child->GetDisplayName(SIGDN_NORMALDISPLAY, &name)) && name) + { + cache[file] = name; + ::CoTaskMemFree(name); + } + } + ::CoTaskMemFree(path); + } + child->Release(); + } + en->Release(); + } + folder->Release(); + }); + return cache; +} + +static bool appsfolder_display_name_by_exe_filename(const wchar_t *exe_path, wchar_t *out, size_t outLen) +{ + out[0] = 0; + if(!exe_path || !exe_path[0]) + return false; + + const wchar_t *file = ::wcsrchr(exe_path, L'\\'); + file = file ? file + 1 : exe_path; + if(!file[0]) + return false; + + auto &map = appsfolder_name_map(); + auto it = map.find(file); + if(it == map.end()) + return false; + + ::wcsncpy_s(out, outLen, it->second.c_str(), outLen - 1); + return out[0] != 0; +} + // 这个窗口在任务栏上叫的名字:先按 AUMID 查 AppsFolder,查不到再按 exe 路径查 static bool window_button_name(HWND hWnd, wchar_t *out, size_t outLen) { @@ -560,9 +630,16 @@ static bool window_button_name(HWND hWnd, wchar_t *out, size_t outLen) return false; wchar_t exe[MAX_PATH]{}; DWORD size = MAX_PATH; - bool ok = ::QueryFullProcessImageNameW(hp, 0, exe, &size) && appsfolder_display_name(exe, out, outLen); + bool ok = ::QueryFullProcessImageNameW(hp, 0, exe, &size) != FALSE; ::CloseHandle(hp); - return ok; + if(!ok) + return false; + + if(appsfolder_display_name(exe, out, outLen)) + return true; + + // 控制台(cmd.exe)这类:AppsFolder 里不是用 exe 全路径当键,按文件名再找一次。 + return appsfolder_display_name_by_exe_filename(exe, out, outLen); } // 「文件资源管理器」是唯一必须特判的应用:它的窗口**一个 AppUserModel 属性都不带** @@ -1402,6 +1479,9 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ ::SetForegroundWindow(root); ::SetForegroundWindow(hWnd); + // 供 command.kill_process_tree 用(见下):按钮菜单当前对应的顶层窗口,已按 GA_ROOT 去重。 + taskbar_button_windows = close_targets; + is_in_taskbar = true; in_taskbar_button_menu = true; auto cmd = TrackPopupMenuExProc(menu, TPM_RETURNCMD | TPM_RIGHTBUTTON, pt.x, pt.y, hWnd, nullptr); @@ -1433,6 +1513,76 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ ::DestroyMenu(menu); } +// command.kill_process_tree 的实现(FuncExpression.cpp 里 extern 调用): +// 杀掉按钮菜单当前对应窗口的进程树。顺序很关键 —— 先按 Toolhelp 快照把后代全部展开, +// 再从叶子往根终止:反过来先杀父进程的话,子进程会被系统改挂到别的父上,杀树就漏了。 +bool kill_taskbar_button_process_tree() +{ + std::vector roots; + for(auto hwnd : taskbar_button_windows) + { + if(!::IsWindow(hwnd)) + continue; + DWORD pid = 0; + ::GetWindowThreadProcessId(hwnd, &pid); + if(pid == 0) + continue; + bool seen = false; + for(auto r : roots) + { + if(r == pid) + { + seen = true; + break; + } + } + if(!seen) + roots.push_back(pid); + } + + if(roots.empty()) + { + _log.info(L"[KILL] no window/pid for taskbar button (windows=%d)", static_cast(taskbar_button_windows.size())); + return false; + } + + _log.info(L"[KILL] root pid=%u", roots.front()); + + auto snapshot = Diagnostics::Process::EnumInfo(); + + std::vector order; + std::function collect = [&](uint32_t pid) + { + for(auto done : order) + if(done == pid) + return; + for(auto &p : snapshot) + { + if(p.th32ProcessID != 0 && p.th32ParentProcessID == pid && p.th32ProcessID != pid) + collect(p.th32ProcessID); + } + order.push_back(pid); + }; + + for(auto r : roots) + collect(r); + + int killed = 0; + for(auto pid : order) + { + if(auto hProcess = ::OpenProcess(PROCESS_TERMINATE, FALSE, pid)) + { + if(::TerminateProcess(hProcess, 0)) + killed++; + ::CloseHandle(hProcess); + } + } + + _log.info(L"[KILL] roots=%d processes=%d killed=%d", + static_cast(roots.size()), static_cast(order.size()), killed); + return killed > 0; +} + LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { auto proc = reinterpret_cast(::GetPropW(hWnd, TaskListSubclassProp)); @@ -1630,9 +1780,12 @@ STDAPI WINAPI DllGetClassObjectHook(REFCLSID rclsid, _In_ REFIID riid, LPVOID *p #define TPM_SYSMENU 0x0200L bool is_in_taskbar; // 任务栏「应用按钮」自建菜单期间为 true(见 show_taskbar_button_menu)。 -// ContextMenu 用它决定这次不加载 .nss 配置项 —— 对单个应用按钮来说配置项没有意义 -// (实际会冒出来的就是「转到」)。任务栏空白处 / 桌面 / 文件菜单不走这条路径,不受影响。 +// ContextMenu 用它把这次菜单的上下文类型标成 FSO_TASKBAR_BUTTON(而不是 FSO_TASKBAR), +// 于是配置项可以用 where=window.is_taskbar_button 精确命中按钮菜单, +// 而「转到」、imports/taskbar.nss 等既有 taskbar 项不会自动涌进来。 bool in_taskbar_button_menu; +// 按钮菜单当前对应的顶层窗口(弹菜单前填好):command.kill_process_tree 用它杀进程树。 +std::vector taskbar_button_windows; BOOL WINAPI TrackPopupMenuProc(HMENU hMenu, uint32_t uFlags, int x, int y, int nReserved, HWND hWnd, const RECT *prcRect); BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); diff --git a/src/dll/src/Parser/IdentHash.h b/src/dll/src/Parser/IdentHash.h index 52576d08..dee926b3 100644 --- a/src/dll/src/Parser/IdentHash.h +++ b/src/dll/src/Parser/IdentHash.h @@ -169,6 +169,7 @@ namespace Nilesoft constexpr auto IDENT_WND = 0x0B88BBAEU; constexpr auto IDENT_IS_DESKTOP = 0xE40E965AU; constexpr auto IDENT_IS_TASKBAR = 0xAA5D7188U; + constexpr auto IDENT_IS_TASKBAR_BUTTON = 0xCFAA4CE3U; constexpr auto IDENT_IS_EXPLORER = 0x01C5A551U; constexpr auto IDENT_IS_STARTED = 0x8873A357U; constexpr auto IDENT_IS_NAV = 0x0523A9A5U; @@ -811,6 +812,7 @@ namespace Nilesoft constexpr auto IDENT_COMMAND_TOGGLEHIDDEN = 0xDD2A6973U; constexpr auto IDENT_COMMAND_SLEEP = 0x105CF61EU; constexpr auto IDENT_COMMAND_RESTART_EXPLORER = 0x846E0BBAU; + constexpr auto IDENT_COMMAND_KILL_PROCESS_TREE = 0xA7AAE39EU; constexpr auto IDENT_COMMAND_NAVIGATE = 0x45B4B5F4U; constexpr auto IDENT_COMMAND_GOBACK = 0x0032646CU; diff --git a/src/dll/src/Parser/Verification.cpp b/src/dll/src/Parser/Verification.cpp index 6dc32a9f..6beb20ab 100644 --- a/src/dll/src/Parser/Verification.cpp +++ b/src/dll/src/Parser/Verification.cpp @@ -1757,6 +1757,7 @@ namespace Nilesoft return error_at(1); case IDENT_IS_DESKTOP: case IDENT_IS_TASKBAR: + case IDENT_IS_TASKBAR_BUTTON: case IDENT_IS_EXPLORER: case IDENT_IS_NAV: case IDENT_IS_TREE: @@ -1806,6 +1807,7 @@ namespace Nilesoft case IDENT_COMMAND_TOGGLEEXT: case IDENT_COMMAND_TOGGLEHIDDEN: case IDENT_COMMAND_RESTART_EXPLORER: + case IDENT_COMMAND_KILL_PROCESS_TREE: case IDENT_ID_SELECT_ALL: case IDENT_ID_INVERT_SELECTION: case IDENT_ID_SELECT_NONE: diff --git a/src/dll/src/Selections.cpp b/src/dll/src/Selections.cpp index 5dd1e115..eb63099e 100644 --- a/src/dll/src/Selections.cpp +++ b/src/dll/src/Selections.cpp @@ -362,7 +362,7 @@ namespace Nilesoft bool Selections::verify_mode(SelectionMode mode) { bool result = true; - if(Types[FSO_TASKBAR]) + if(Types[FSO_TASKBAR] || Types[FSO_TASKBAR_BUTTON]) result = true; else if(mode == SelectionMode::Single) result = Mode == SelectionMode::Single; @@ -381,7 +381,7 @@ namespace Nilesoft if(!fso.defined && Count() == 0) return false; - if(Items.empty() && !(Types[FSO_START] + Types[FSO_TASKBAR] + Types[FSO_EDIT] + Types[FSO_TITLEBAR])) + if(Items.empty() && !(Types[FSO_START] + Types[FSO_TASKBAR] + Types[FSO_TASKBAR_BUTTON] + Types[FSO_EDIT] + Types[FSO_TITLEBAR])) { return false; } diff --git a/src/shared/FileSystemObjects.h b/src/shared/FileSystemObjects.h index 1d3691fe..e2919669 100644 --- a/src/shared/FileSystemObjects.h +++ b/src/shared/FileSystemObjects.h @@ -43,6 +43,9 @@ namespace Nilesoft FSO_SIZE, FSO_NAMESPACE_LIBRARIES, + // 任务栏「应用按钮」自建菜单的上下文类型(与 FSO_NAMESPACE_LIBRARIES 一样放在 FSO_SIZE 之后: + // 不进 fs_types 表,也不改动任何既有 FSO_* 的枚举值)。 + FSO_TASKBAR_BUTTON, FSO_COUNT, FSO_MAX }; From 90298cd782446428a0f257ee82716f8420572edc Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 02:56:20 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(win10-taskbar):=20hook=20=E5=AE=88?= =?UTF-8?q?=E6=8A=A4=E4=B8=8E=E5=89=AF=E5=B1=8F=E4=BB=BB=E5=8A=A1=E6=A0=8F?= =?UTF-8?q?=20+=20=E6=AD=BB=E9=A1=B9=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hook 重构成「主任务栏 + 每个副屏任务栏(Shell_SecondaryTrayWnd)」,并加长期巡检: 任务栏窗口被就地重建(改 DPI / 插拔显示器 / 切主题)时 subclass 会跟着丢, 原先只重试 15 次就结束,之后按钮菜单会静默退回原生 XAML 跳转列表 —— 现在每 3 秒巡检一次, 发现丢了就重新挂上(只有真的新挂上才写日志)。顺手去掉 4 行 [PROBE] 启动日志。 - 新增 window.taskbar_button_windows(按钮菜单当前匹配到的窗口数),用来做死项治理: 未运行 / 窗口认不出来的按钮没有可操作目标,配置项可用它把「结束进程」这类死项藏掉。 --- src/dll/src/Expression/FuncExpression.cpp | 7 ++++ src/dll/src/Main.cpp | 47 ++++++++++++++++++----- src/dll/src/Parser/IdentHash.h | 1 + src/dll/src/Parser/Verification.cpp | 1 + 4 files changed, 46 insertions(+), 10 deletions(-) diff --git a/src/dll/src/Expression/FuncExpression.cpp b/src/dll/src/Expression/FuncExpression.cpp index 02d894c1..d09c2a7f 100644 --- a/src/dll/src/Expression/FuncExpression.cpp +++ b/src/dll/src/Expression/FuncExpression.cpp @@ -11,6 +11,8 @@ // 定义在 Main.cpp:杀掉任务栏「应用按钮」菜单当前对应窗口的进程树(command.kill_process_tree)。 extern bool kill_taskbar_button_process_tree(); +// 定义在 Main.cpp:按钮菜单当前匹配到的顶层窗口(window.taskbar_button_windows)。 +extern std::vector taskbar_button_windows; //#include @@ -1474,6 +1476,11 @@ namespace Nilesoft // 只对任务栏「应用按钮」的自建菜单为真(见 ContextMenu.cpp 的 WINDOW_TASKBAR 分支) _result = sel->Types[FSO_TASKBAR_BUTTON] > 0; break; + case IDENT_TASKBAR_BUTTON_WINDOWS: + // 按钮菜单当前匹配到的窗口数:0 表示这个按钮没有可操作的目标 + // (窗口认不出来 / 应用已退出),配置项可以用它把死项藏掉。 + _result = static_cast(taskbar_button_windows.size()); + break; case IDENT_IS_EXPLORER: _result = sel->Window.explorer; break; diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 820cbbba..f089b3ce 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -1605,34 +1605,55 @@ LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lPara return ::CallWindowProcW(proc, hWnd, uMsg, wParam, lParam); } -static bool tasklist_hook() +// 给**一个**任务栏挂 hook:主任务栏(Shell_TrayWnd)与副屏任务栏(Shell_SecondaryTrayWnd) +// 的窗口链是一样的 —— ReBarWindow32 → MSTaskSwWClass → MSTaskListWClass。 +static bool tasklist_hook_window(HWND tray) { - auto tray = ::FindWindowW(Windows::WC_Shell_TrayWnd, nullptr); - _log.info(L"[PROBE] step1 tray=0x%x", (unsigned)(ULONG_PTR)tray); if(!tray) return false; auto rebar = ::FindWindowExW(tray, nullptr, Windows::WC_ReBarWindow32, nullptr); - _log.info(L"[PROBE] step2 rebar=0x%x", (unsigned)(ULONG_PTR)rebar); if(!rebar) return false; auto sw = ::FindWindowExW(rebar, nullptr, Windows::WC_MSTaskSwWClass, nullptr); - _log.info(L"[PROBE] step3 sw=0x%x", (unsigned)(ULONG_PTR)sw); if(!sw) return false; auto list = ::FindWindowExW(sw, nullptr, Windows::WC_MSTaskListWClass, nullptr); - _log.info(L"[PROBE] step4 list=0x%x", (unsigned)(ULONG_PTR)list); if(!list) return false; if(::GetPropW(list, TaskListSubclassProp)) - return true; + return true; // 已经挂过 auto proc = reinterpret_cast(::GetWindowLongPtrW(list, GWLP_WNDPROC)); ::SetPropW(list, TaskListSubclassProp, reinterpret_cast(proc)); ::SetWindowLongPtrW(list, GWLP_WNDPROC, reinterpret_cast(TaskListProc)); - _log.info(L"[PROBE] MSTaskListWClass hooked hwnd=0x%x", (unsigned)(ULONG_PTR)list); + _log.info(L"[BTN] hooked taskbar list hwnd=0x%x tray=0x%x", + (unsigned)(ULONG_PTR)list, (unsigned)(ULONG_PTR)tray); return true; } +// 挂上**所有**任务栏:主任务栏 + 每个副屏任务栏(Win10 多屏时每屏一个 Shell_SecondaryTrayWnd)。 +static int tasklist_hook_all_now() +{ + int hooked = 0; + if(tasklist_hook_window(::FindWindowW(Windows::WC_Shell_TrayWnd, nullptr))) + hooked++; + + for(HWND prev = nullptr;;) + { + auto sec = ::FindWindowExW(nullptr, prev, Windows::WC_Shell_SecondaryTrayWnd, nullptr); + if(!sec) + break; + if(tasklist_hook_window(sec)) + hooked++; + prev = sec; + } + return hooked; +} + +// 启动后先重试若干次(explorer 刚起来时任务栏可能还没建好;delay 为尝试间隔), +// 成功之后转成**长期巡检**:任务栏窗口被就地重建时(改 DPI / 插拔显示器 / 切主题) +// subclass 会跟着丢,这里负责重新挂上 —— 否则按钮菜单会静默退回原生 XAML 跳转列表。 +// 只有真的新挂上时才写日志,巡检本身是静默的。 static void tasklist_hook_all(uint32_t delay) { std::thread([delay]() @@ -1640,10 +1661,16 @@ static void tasklist_hook_all(uint32_t delay) for(int i = 0; i < 15; ++i) { ::Sleep(delay); - if(tasklist_hook()) + if(tasklist_hook_all_now() > 0) break; } - _log.info(L"[PROBE] tasklist_hook_all thread finished"); + _log.info(L"[BTN] tasklist hook ready"); + + for(;;) + { + ::Sleep(3000); + tasklist_hook_all_now(); + } }).detach(); } diff --git a/src/dll/src/Parser/IdentHash.h b/src/dll/src/Parser/IdentHash.h index dee926b3..9282b9a1 100644 --- a/src/dll/src/Parser/IdentHash.h +++ b/src/dll/src/Parser/IdentHash.h @@ -170,6 +170,7 @@ namespace Nilesoft constexpr auto IDENT_IS_DESKTOP = 0xE40E965AU; constexpr auto IDENT_IS_TASKBAR = 0xAA5D7188U; constexpr auto IDENT_IS_TASKBAR_BUTTON = 0xCFAA4CE3U; + constexpr auto IDENT_TASKBAR_BUTTON_WINDOWS = 0x3D2E0A72U; constexpr auto IDENT_IS_EXPLORER = 0x01C5A551U; constexpr auto IDENT_IS_STARTED = 0x8873A357U; constexpr auto IDENT_IS_NAV = 0x0523A9A5U; diff --git a/src/dll/src/Parser/Verification.cpp b/src/dll/src/Parser/Verification.cpp index 6beb20ab..814aeaf9 100644 --- a/src/dll/src/Parser/Verification.cpp +++ b/src/dll/src/Parser/Verification.cpp @@ -1758,6 +1758,7 @@ namespace Nilesoft case IDENT_IS_DESKTOP: case IDENT_IS_TASKBAR: case IDENT_IS_TASKBAR_BUTTON: + case IDENT_TASKBAR_BUTTON_WINDOWS: case IDENT_IS_EXPLORER: case IDENT_IS_NAV: case IDENT_IS_TREE: From 377b809ad7c85e4f93cc16299734e3601c12d7ea Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 03:01:49 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(win10-taskbar):=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=90=8D=E9=82=A3=E4=B8=80=E9=A1=B9=E6=94=B9=E4=B8=BA=E5=8F=AF?= =?UTF-8?q?=E7=82=B9=E5=87=BB=EF=BC=88=E6=BF=80=E6=B4=BB=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=20/=20=E5=90=AF=E5=8A=A8=E7=A8=8B=E5=BA=8F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前应用名项的 id 写 0,点了完全没有反应。现在点击 = - 有运行窗口:切到前台(最小化先 SW_RESTORE;SetForegroundWindow 被前台锁拒绝时 用 AttachThreadInput 接上当前前台线程再试一次); - 没在运行 / 没有可见窗口(例如只留在托盘的 Everything):启动程序, 优先走固定项 .lnk(能带上它原有的工作目录与参数),否则用窗口进程的 exe 路径。 --- src/dll/src/Main.cpp | 42 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index f089b3ce..3b3d58e5 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -1411,6 +1411,7 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ const UINT id_pin = 0x1001; const UINT id_close = 0x1002; const UINT id_unpin = 0x1003; + const UINT id_activate = 0x1004; // 应用名那一项:点击 = 激活窗口(没运行则启动程序) // 源码文件无 BOM,MSVC 按 GBK(936) 解析源文件,中文字面量必须用 \u 转义, // 否则会乱码:"固定到任务栏" → "鏁哄畾鍒颁换鍔℃爮" @@ -1420,7 +1421,9 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ const wchar_t *text_close_all = L"\u5173\u95ED\u6240\u6709\u7A97\u53E3"; // 关闭所有窗口 auto title = info.title.empty() ? string(L"Application") : info.title; - ::AppendMenuW(menu, MF_STRING, 0, title.c_str()); + // 应用名那一项是**可点**的:有运行窗口就把它切到前台(等同点任务栏按钮), + // 没在运行就启动程序。以前这里 id 写 0,点了没有任何反应。 + ::AppendMenuW(menu, MF_STRING, id_activate, title.c_str()); ::AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); // 固定 / 取消固定:按当前是否已固定决定显示哪个,并记下 exe / 固定项 .lnk 给动作分支用 @@ -1490,7 +1493,42 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ _log.info(L"[BTN] command=%d", static_cast(cmd)); - if(cmd == id_close && !close_targets.empty()) + if(cmd == id_activate) + { + _log.info(L"[BTN] activate window=0x%x exe='%s'", (unsigned)(ULONG_PTR)info.window, exe_path); + if(info.window) + { + if(::IsIconic(info.window)) + ::ShowWindow(info.window, SW_RESTORE); + if(!::SetForegroundWindow(info.window)) + { + // 前台锁(foreground lock)会拒绝 SetForegroundWindow: + // 临时把输入队列接到当前前台线程,再试一次。 + if(auto fg = ::GetForegroundWindow()) + { + const auto tid_fg = ::GetWindowThreadProcessId(fg, nullptr); + const auto tid_me = ::GetCurrentThreadId(); + if(tid_fg && tid_fg != tid_me) + { + ::AttachThreadInput(tid_me, tid_fg, TRUE); + ::SetForegroundWindow(info.window); + ::AttachThreadInput(tid_me, tid_fg, FALSE); + } + } + } + } + else + { + // 没在运行:启动它。有固定项 .lnk 就优先走 lnk(能带上原有的工作目录/参数)。 + const wchar_t *target = has_pinned_lnk ? pinned_lnk : (has_exe ? exe_path : nullptr); + if(target) + { + _log.info(L"[BTN] launch '%s'", target); + ::ShellExecuteW(nullptr, nullptr, target, nullptr, nullptr, SW_SHOWNORMAL); + } + } + } + else if(cmd == id_close && !close_targets.empty()) { // UWP 应用(设置 / 商店等)里层的 Windows.UI.Core.CoreWindow 不响应 SC_CLOSE, // 必须把关闭消息发给顶层宿主(ApplicationFrameWindow)—— 上面已按 GA_ROOT 收敛过。 From 2cecb36cd4c6919bfba3a6614107d185f16ed894 Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 03:07:17 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(win10-taskbar):=20=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E5=90=8D=E9=A1=B9=E5=AF=B9=E6=8F=90=E6=9D=83=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E4=B9=9F=E8=83=BD=E5=88=87=E5=88=B0=E5=89=8D=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务管理器这类以管理员运行(高完整性)的窗口,从 explorer(中完整性)切前台时会失败: - SetForegroundWindow 被 Windows 的前台锁拒绝; - 常规兜底 AttachThreadInput 要访问目标线程的输入队列,**跨完整性被 UIPI 挡掉**; → 结果就是「点了完全没反应」(用户实测连点 4 次,日志里 4 行 activate 但窗口没动)。 现在按代价递增依次尝试,并把结果写进日志: 1) IsIconic → ShowWindow(SW_RESTORE)(窗口最小化时这条就够,不碰输入队列) 2) SetForegroundWindow 3) AttachThreadInput + SetForegroundWindow(同完整性时有效) 4) 模拟一次 ALT 键解开前台锁 + SetForegroundWindow(对提权窗口有效) 5) SwitchToThisWindow(hwnd, TRUE)(任务栏点击内部用的老 API) 日志改成 [BTN] activate window=0x.. ok=.. fg=0x..,以后「点了没反应」一眼能看出卡在哪一步。 实测:对提权的任务管理器连续两次 ok=1 且 fg 就是目标窗口。 --- src/dll/src/Main.cpp | 72 ++++++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 19 deletions(-) diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 3b3d58e5..0e64af56 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -1399,6 +1399,54 @@ static void distinct_top_windows(const std::vector &in, std::vector } } +// 把窗口切到前台。为什么写这么啰嗦: +// explorer 是**中完整性**进程,而像「任务管理器」这种以管理员运行的窗口是**高完整性**: +// - SetForegroundWindow 常被 Windows 的「前台锁」拒绝(调用方不是当前前台时); +// - 常规兜底 AttachThreadInput 要访问目标线程的输入队列,**跨完整性会被 UIPI 挡掉**。 +// 所以按代价递增依次尝试;最后一条 SwitchToThisWindow 是任务栏点击内部用的老 API。 +// 注意最小化窗口只要 ShowWindow(不碰输入队列),这解释了「最小化时能唤起、从后台切不过来」。 +extern "C" __declspec(dllimport) void WINAPI SwitchToThisWindow(HWND hwnd, BOOL fUnknown); + +static bool activate_top_window(HWND hWnd) +{ + if(!hWnd || !::IsWindow(hWnd)) + return false; + + if(::IsIconic(hWnd)) + ::ShowWindow(hWnd, SW_RESTORE); + else + ::ShowWindow(hWnd, SW_SHOW); + + if(::GetForegroundWindow() == hWnd) + return true; + if(::SetForegroundWindow(hWnd)) + return true; + + // 兜底 1:接上当前前台线程的输入队列(同完整性时有效;目标是提权进程时会被 UIPI 拒绝) + if(auto fg = ::GetForegroundWindow()) + { + const auto tid_fg = ::GetWindowThreadProcessId(fg, nullptr); + const auto tid_me = ::GetCurrentThreadId(); + if(tid_fg && tid_fg != tid_me && ::AttachThreadInput(tid_me, tid_fg, TRUE)) + { + const bool ok = ::SetForegroundWindow(hWnd) != FALSE; + ::AttachThreadInput(tid_me, tid_fg, FALSE); + if(ok) + return true; + } + } + + // 兜底 2:模拟一次 ALT 键 —— 前台锁在收到键盘输入后会放开,对提权窗口同样有效 + ::keybd_event(VK_MENU, 0, 0, 0); + ::keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0); + if(::SetForegroundWindow(hWnd)) + return true; + + // 兜底 3:任务栏点击内部用的老 API + ::SwitchToThisWindow(hWnd, TRUE); + return ::GetForegroundWindow() == hWnd; +} + static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_info &info) { find_app_windows(info.title, info.windows); @@ -1495,27 +1543,13 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ if(cmd == id_activate) { - _log.info(L"[BTN] activate window=0x%x exe='%s'", (unsigned)(ULONG_PTR)info.window, exe_path); if(info.window) { - if(::IsIconic(info.window)) - ::ShowWindow(info.window, SW_RESTORE); - if(!::SetForegroundWindow(info.window)) - { - // 前台锁(foreground lock)会拒绝 SetForegroundWindow: - // 临时把输入队列接到当前前台线程,再试一次。 - if(auto fg = ::GetForegroundWindow()) - { - const auto tid_fg = ::GetWindowThreadProcessId(fg, nullptr); - const auto tid_me = ::GetCurrentThreadId(); - if(tid_fg && tid_fg != tid_me) - { - ::AttachThreadInput(tid_me, tid_fg, TRUE); - ::SetForegroundWindow(info.window); - ::AttachThreadInput(tid_me, tid_fg, FALSE); - } - } - } + const bool ok = activate_top_window(info.window); + // 记下成功与否与实际前台窗口:以后「点了没反应」时一眼能看出卡在哪一步。 + _log.info(L"[BTN] activate window=0x%x ok=%d fg=0x%x exe='%s'", + (unsigned)(ULONG_PTR)info.window, ok ? 1 : 0, + (unsigned)(ULONG_PTR)::GetForegroundWindow(), exe_path); } else { From 77b3e4ce4697f7b9ab882d180ebfbed72bbb81ed Mon Sep 17 00:00:00 2001 From: v4806 Date: Mon, 12 Oct 2026 03:54:15 +0800 Subject: [PATCH 6/6] =?UTF-8?q?chore(win10-taskbar):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=A0=8F=E6=8E=A5=E7=AE=A1=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A=E4=B8=8E=E6=AD=BB=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为便于上游审核,把开发过程中留下的噪声还原为官方状态,行为零变化: - 删除全仓无调用点的 find_app_window() / is_pinned_to_taskbar(), 以及只写不读的 taskbar_window_finder::hwnd 字段与其赋值 - 新增注释统一改写为英文(Main.cpp 188 行、ContextMenu.cpp 11 行、 FuncExpression.cpp 10 行、FileSystemObjects.h 2 行) - 删除 [PROBE] / 无条件 IAT hook 试验等历史诊断注释 6 行 - Main.cpp / FuncExpression.cpp 恢复与官方一致的 UTF-8 BOM,去掉 1 处多余空行 验证:剥掉注释与空行后逐行比对,代码序列仅少上述死代码(added = 0); 编译 EXIT=0(v145 release x64,无 C4505);已部署并实机验证菜单功能正常。 --- src/dll/src/ContextMenu.cpp | 22 +- src/dll/src/Expression/FuncExpression.cpp | 22 +- src/dll/src/Main.cpp | 401 ++++++++++------------ src/shared/FileSystemObjects.h | 4 +- 4 files changed, 214 insertions(+), 235 deletions(-) diff --git a/src/dll/src/ContextMenu.cpp b/src/dll/src/ContextMenu.cpp index 4a48c3fe..a6251a3b 100644 --- a/src/dll/src/ContextMenu.cpp +++ b/src/dll/src/ContextMenu.cpp @@ -5,7 +5,7 @@ using namespace Nilesoft::Diagnostics; -// 定义在 Main.cpp:任务栏「应用按钮」自建菜单期间为 true(全局命名空间) +// Defined in Main.cpp: true while the self-built taskbar "app button" menu is open (global namespace) extern bool in_taskbar_button_menu; #include @@ -1111,9 +1111,9 @@ namespace Nilesoft std::vector bottom_items; - // 配置里新增的 menu(...)/item(...) 就是这一步加进来的。按钮菜单同样吃这一套: - // 它已是独立类型(FSO_TASKBAR_BUTTON),普通 taskbar 项不会命中它, - // 所以这里不再需要按 in_taskbar_button_menu 屏蔽。 + // New menu(...)/item(...) entries from the config are added here. The button menu uses the same path: + // it has its own context type (FSO_TASKBAR_BUTTON), so plain taskbar items do not match it and no + // in_taskbar_button_menu guard is needed here any more. if(_settings.new_items.enabled) { if(!(owner && owner->is_disabled())) @@ -4399,15 +4399,15 @@ namespace Nilesoft switch(Selected.Window.id) { case WINDOW_TASKBAR: - // 任务栏「应用按钮」的自建菜单(见 Main.cpp 的 in_taskbar_button_menu)走独立类型位: - // 它虽然是任务栏的上下文,但类型标成 FSO_TASKBAR_BUTTON 而不是 FSO_TASKBAR, - // 于是「转到」、imports/taskbar.nss 这些已按 type='taskbar'/window.is_taskbar 写好的项 - // 不会自动涌进按钮菜单;配置里要用 where=window.is_taskbar_button 才能命中。 + // The self-built taskbar "app button" menu (see in_taskbar_button_menu in Main.cpp) uses its own type bit: + // it is a taskbar context, but flagged as FSO_TASKBAR_BUTTON rather than FSO_TASKBAR, so items + // written for type='taskbar'/window.is_taskbar ("Go to", imports/taskbar.nss) do not flood the + // button menu; use where=window.is_taskbar_button to target it. if(in_taskbar_button_menu) Selected.Types[FSO_TASKBAR_BUTTON] = TRUE; else Selected.Types[FSO_TASKBAR] = TRUE; - // front 保持 FSO_TASKBAR:按钮菜单此前也是这个值,避免影响依赖它的显示逻辑。 + // 'front' stays FSO_TASKBAR: the button menu had this value before, keeping display logic intact. Selected.front = FSO_TASKBAR; Selected.Directory = Path::GetKnownFolder(FOLDERID_Desktop).move(); break; @@ -4484,8 +4484,8 @@ namespace Nilesoft if(0 == ::GetPropW(hwnd.owner, UxSubclass)) build_system_menuitems(_hMenu_original, __system_menu_tree, true); - // modify(...) 这类「修改已有项」的规则同样允许作用到按钮菜单(它已是独立类型, - // 通用 taskbar 规则不会误伤),因此不再按 in_taskbar_button_menu 屏蔽。 + // modify(...) rules that alter existing items may also apply to the button menu (it has its own + // type, so generic taskbar rules cannot hit it), hence no in_taskbar_button_menu guard here. if(_settings.modify_items.enabled) build_main_system_menuitems(__system_menu_tree, true); diff --git a/src/dll/src/Expression/FuncExpression.cpp b/src/dll/src/Expression/FuncExpression.cpp index d09c2a7f..5981bfc5 100644 --- a/src/dll/src/Expression/FuncExpression.cpp +++ b/src/dll/src/Expression/FuncExpression.cpp @@ -1,4 +1,4 @@ -#include +#include #include "Resource.h" #include "Expression\Constants.h" #include "Expression\Variable.h" @@ -9,9 +9,9 @@ #include -// 定义在 Main.cpp:杀掉任务栏「应用按钮」菜单当前对应窗口的进程树(command.kill_process_tree)。 +// Defined in Main.cpp: kills the process tree of the windows of the current taskbar button menu. extern bool kill_taskbar_button_process_tree(); -// 定义在 Main.cpp:按钮菜单当前匹配到的顶层窗口(window.taskbar_button_windows)。 +// Defined in Main.cpp: top-level windows matched by the current button menu (window.taskbar_button_windows). extern std::vector taskbar_button_windows; //#include @@ -1466,19 +1466,19 @@ namespace Nilesoft _result = sel->Window.desktop; break; case IDENT_IS_TASKBAR: - // 按钮菜单虽然也挂在任务栏上,但它有自己的上下文类型 - // (FSO_TASKBAR_BUTTON),这里返回 false, - // 好让 type='taskbar' / window.is_taskbar 写的项(例如「转到」) - // 不会跟着跑进按钮菜单。 + // The button menu also lives on the taskbar but has its own context type + // (FSO_TASKBAR_BUTTON), so return false here to keep items written for + // type='taskbar' / window.is_taskbar (such as "Go to") + // out of the button menu. _result = (sel->Window.parent == WINDOW_TASKBAR) && !(sel->Types[FSO_TASKBAR_BUTTON] > 0); break; case IDENT_IS_TASKBAR_BUTTON: - // 只对任务栏「应用按钮」的自建菜单为真(见 ContextMenu.cpp 的 WINDOW_TASKBAR 分支) + // True only for the self-built taskbar "app button" menu (see the WINDOW_TASKBAR branch). _result = sel->Types[FSO_TASKBAR_BUTTON] > 0; break; case IDENT_TASKBAR_BUTTON_WINDOWS: - // 按钮菜单当前匹配到的窗口数:0 表示这个按钮没有可操作的目标 - // (窗口认不出来 / 应用已退出),配置项可以用它把死项藏掉。 + // Number of windows matched by the current button menu: 0 means the button has no usable target + // (window not recognized / app exited); config items can use it to hide dead entries. _result = static_cast(taskbar_button_windows.size()); break; case IDENT_IS_EXPLORER: @@ -1737,7 +1737,7 @@ namespace Nilesoft _result = Windows::Explorer::Restart(); break; case IDENT_COMMAND_KILL_PROCESS_TREE: - // 结束该任务栏按钮对应窗口的进程树(按钮菜单由 command.kill_process_tree 提供该项) + // Kill the process tree of the windows of this taskbar button (menu item provided by command.kill_process_tree) _result = kill_taskbar_button_process_tree(); break; case IDENT_COMMAND_TOGGLEHIDDEN: diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 0e64af56..35c488e0 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -1,11 +1,11 @@ -#include +#include #include "Include/ContextMenu.h" #include "Library/detours.h" #include "RegistryConfig.h" #include -#include // PKEY_AppUserModel_ID(固定快捷方式时要用) +#include // PKEY_AppUserModel_ID (needed when patching a pinned shortcut) #include // CSIDL_APPDATA / IShellLink -#include // IStartMenuPinnedList(官方「从任务栏取消固定」API) +#include // IStartMenuPinnedList (the official "unpin from taskbar" API) using namespace Nilesoft::Shell; using namespace Diagnostics; @@ -182,7 +182,7 @@ std::unordered_map _window_taskbar; LRESULT __stdcall TaskbarSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); LRESULT __stdcall TaskbarProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); -// [PROBE] Win10 任务栏按钮区(MSTaskListWClass)探针 +// Win10 taskbar button area (MSTaskListWClass): hook entry points. LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); static void tasklist_hook_all(uint32_t delay); @@ -388,29 +388,29 @@ struct taskbar_t } }; -// ===== Win10 任务栏按钮(MSTaskListWClass)右键接管 ===== -// 前置:这两个符号原本定义/声明在本文件更靠后的位置(约 515 行) +// ===== Win10 taskbar button (MSTaskListWClass) right-click takeover ===== +// Forward declarations: both symbols are defined/declared further down in this file. extern bool is_in_taskbar; extern bool in_taskbar_button_menu; -// 定义在文件后部(约 1630 行),此处前置声明给按钮菜单与 command.kill_process_tree 用 +// Defined near the bottom of this file; needed here by the button menu and command.kill_process_tree. extern std::vector taskbar_button_windows; BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); -// 注意:subclass 标记不能沿用 UxSubclass —— ContextMenu::Initialize() 里会用 -// `if(0 == ::GetPropW(hwnd.owner, UxSubclass))` 判断该窗口是否已由 Shell 处理过, -// 命中就【跳过 build_system_menuitems】,于是我们传进去的菜单项会被完全忽略。 +// NOTE: do not reuse UxSubclass as the subclass marker: ContextMenu::Initialize() uses +// `if(0 == ::GetPropW(hwnd.owner, UxSubclass))` to detect windows Shell already handled, +// and when it matches, build_system_menuitems is skipped, so the items we pass in are ignored. constexpr auto TaskListSubclassProp = L"ShellTaskListSubclass"; -// 原理:Win10 的按钮菜单是 ShellExperienceHost 的 XAML 跳转列表(不是 Win32 菜单),Hook 不到。 -// 这里直接 subclass 任务栏按钮区窗口,拦下 WM_CONTEXTMENU 并吞掉,然后自己造一个 HMENU 交给 Shell: -// Shell 会抓快照并按自己的主题重绘 → 菜单变成 Shell 样式,而内容由我们决定。 +// Why this works: on Win10 the button menu is a ShellExperienceHost XAML jump list (not a Win32 +// menu), so it cannot be hooked. Instead subclass the taskbar button area window, swallow +// WM_CONTEXTMENU and hand our own HMENU to Shell, which snapshots and repaints it with its own theme. struct taskbar_button_info { - bool is_button{}; // 只有点在真正的应用按钮上才为 true - // 注意:UIA 对任务栏按钮返回的 pid 是 shell 进程,不是应用进程,不能用来定位窗口 + bool is_button{}; // true only when the click landed on a real app button + // NOTE: the pid UIA reports for a taskbar button is the shell process, not the app process. int pid{}; - HWND window{}; // 主窗口(第一个匹配到的,优先非最小化) - std::vector windows; // 所有匹配到的窗口:合并按钮要能「关闭所有窗口」 - string title; // 形如 "任务管理器 - 1 个运行窗口" + HWND window{}; // main window (first match) + std::vector windows; // all matched windows (a merged button must be able to close them all) + string title; // e.g. "Task Manager - 1 running window" }; static taskbar_button_info query_taskbar_button(const Point &pt) @@ -425,10 +425,10 @@ static taskbar_button_info query_taskbar_button(const Point &pt) if(FAILED(taskbar_t::_IUIAutomation->ElementFromPoint(pt, elem)) || !elem) return out; - // MSTaskListWClass 覆盖整个按钮区(含按钮之间的空白)。只有 ControlType 是按钮类 - // 才当成「应用按钮」;点在空白处必须放行,让 explorer 弹它自己的任务栏菜单(Shell 会接管)。 - // ⚠️ 应用有多个窗口(合并按钮)时,UIA 把任务栏按钮暴露成 MenuItem 而不是 Button - // (实测 Edge 3 个窗口 → ControlType=MenuItem),只认 Button 会让合并按钮弹回原生跳转列表。 + // MSTaskListWClass covers the whole button area, including the gaps between buttons. + // Only button-like control types count as an app button; clicks in the gaps fall through so + // explorer can show its own taskbar menu (Shell takes that one over). WARNING: a merged button + // (app with several windows) is exposed as MenuItem, not Button (measured: Edge with 3 windows). CONTROLTYPEID ctype = 0; elem->get_CurrentControlType(&ctype); if(ctype != UIA_ButtonControlTypeId && ctype != UIA_MenuItemControlTypeId && ctype != UIA_SplitButtonControlTypeId) @@ -450,15 +450,14 @@ static taskbar_button_info query_taskbar_button(const Point &pt) struct taskbar_window_finder { - string keyword; // 从按钮名解析出的应用名关键词 - bool file_explorer{}; // 该按钮是「文件资源管理器」→ 允许用窗口类认窗口(它没有任何 AppUserModel 属性) - HWND hwnd{}; // 主窗口(优先非最小化) - std::vector all; // 所有匹配到的窗口 + string keyword; // app name keyword parsed from the button name + bool file_explorer{}; // File Explorer button: allow matching by window class (it has no AppUserModel properties) + std::vector all; // all matched windows }; -// Chromium 系应用(Edge/Chrome)会在窗口标题里插入零宽字符: -// 实际标题是 "Microsoft Edge",用 wcsstr 跟 "Microsoft Edge" 匹配必然失败。 -// 比较前先去掉零宽字符、把非断空格换成普通空格。 +// Chromium apps (Edge/Chrome) insert zero-width characters into their window titles: +// the real title is "Microsoft Edge", so matching "Microsoft Edge" with wcsstr always fails. +// Strip zero-width characters and turn non-breaking spaces into plain spaces before comparing. static void normalize_for_match(wchar_t *s) { if(!s) @@ -473,13 +472,13 @@ static void normalize_for_match(wchar_t *s) *d = 0; } -// 窗口自己带着「这个应用在任务栏里是谁」的完整信息(都是公开属性,不用猜名字): -// AppUserModel.ID —— 分组主键 -// AppUserModel.RelaunchCommand —— 任务栏算 AppID 也用它。缺了它,固定项会跟运行窗口 -// 分成两个图标(实测 Blender:lnk 只有 ID、没这条, -// target 还是 blender.exe 而窗口 relaunch 是 blender-launcher.exe) -// AppUserModel.RelaunchDisplayNameResource —— 任务栏按钮名(实测 Blender 窗口='Blender 5.2') -// AppUserModel.RelaunchIconResource —— 图标 +// A window carries the full identity of its taskbar entry (all public properties, no name guessing): +// AppUserModel.ID - grouping key +// AppUserModel.RelaunchCommand - also used for the taskbar AppID. Without it a pinned +// shortcut becomes a second icon (measured Blender: the +// lnk had only ID, target was blender.exe while the window relaunched blender-launcher.exe) +// AppUserModel.RelaunchDisplayNameResource - taskbar button name (measured: Blender -> 'Blender 5.2') +// AppUserModel.RelaunchIconResource - icon struct window_appmodel { wchar_t aumid[256]{}; @@ -512,14 +511,14 @@ static bool get_window_appmodel(HWND hWnd, window_appmodel &out) read_prop_str(ps, PKEY_AppUserModel_RelaunchDisplayNameResource, out.relaunch_display_name, 512); read_prop_str(ps, PKEY_AppUserModel_RelaunchIconResource, out.relaunch_icon, 512); ps->Release(); - // 注意:AUMID 为空也算成功(explorer 的窗口就是这样,一个属性都不带), - // 调用方要自己回退到 exe 路径。 + // NOTE: an empty AUMID still counts as success (explorer windows carry no properties at all); + // the caller has to fall back to the exe path. return true; } -// 查一个应用在 shell:AppsFolder 里的显示名 —— 任务栏按钮名用的就是它。 -// 入参可以写 AppUserModelID(如 Microsoft.Windows.Explorer → 「文件资源管理器」), -// 也可以写 exe 路径(Blender 没注册 AUMID,按 ...\blender-launcher.exe → 「Blender 5.2」)。 +// Look up an app's display name in shell:AppsFolder - that is exactly the taskbar button name. +// The argument may be an AppUserModelID (Microsoft.Windows.Explorer -> "File Explorer") or an +// exe path (Blender registers no AUMID; ...\blender-launcher.exe -> "Blender 5.2"). static bool appsfolder_display_name(const wchar_t *aumid_or_path, wchar_t *out, size_t outLen) { out[0] = 0; @@ -544,11 +543,11 @@ static bool appsfolder_display_name(const wchar_t *aumid_or_path, wchar_t *out, return ok; } -// exe 全路径查不到时的兜底:AppsFolder 里的项**不一定**拿 exe 全路径当键 —— -// 例如 cmd.exe 是 shell:AppsFolder\{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\cmd.exe -// (System32 的 KNOWNFOLDERID),拿 C:\Windows\system32\cmd.exe 去查必然失败。 -// 所以这里按 exe 文件名在 AppsFolder 里找同名项,取它的显示名(cmd.exe → 「命令提示符」)。 -// 整张表只建一次并缓存。 +// Fallback when the full exe path is not found: AppsFolder entries do not always key on the full +// exe path - cmd.exe is shell:AppsFolder\{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\cmd.exe (the +// System32 KNOWNFOLDERID), so querying with C:\Windows\system32\cmd.exe always fails. +// Match by exe file name instead and take that entry's display name (cmd.exe -> "Command Prompt"). +// The table is built once and cached. static const std::map &appsfolder_name_map() { static std::once_flag once; @@ -612,12 +611,12 @@ static bool appsfolder_display_name_by_exe_filename(const wchar_t *exe_path, wch return out[0] != 0; } -// 这个窗口在任务栏上叫的名字:先按 AUMID 查 AppsFolder,查不到再按 exe 路径查 +// The name this window has on the taskbar: AppsFolder by AUMID first, then by exe path. static bool window_button_name(HWND hWnd, wchar_t *out, size_t outLen) { out[0] = 0; window_appmodel am; - get_window_appmodel(hWnd, am); // 读不到就全是空串,下面按 exe 路径兜底 + get_window_appmodel(hWnd, am); // empty strings when unavailable; the exe-path fallback follows if(am.aumid[0] && appsfolder_display_name(am.aumid, out, outLen)) return true; @@ -638,14 +637,14 @@ static bool window_button_name(HWND hWnd, wchar_t *out, size_t outLen) if(appsfolder_display_name(exe, out, outLen)) return true; - // 控制台(cmd.exe)这类:AppsFolder 里不是用 exe 全路径当键,按文件名再找一次。 + // Console apps (cmd.exe): AppsFolder does not key them by full exe path, so match by file name. return appsfolder_display_name_by_exe_filename(exe, out, outLen); } -// 「文件资源管理器」是唯一必须特判的应用:它的窗口**一个 AppUserModel 属性都不带** -// (实测 AUMID vt=0),`shell:AppsFolder\C:\Windows\explorer.exe` 也查不到(未注册), -// 而任务栏照样把它显示成「文件资源管理器」/「Windows 资源管理器」—— 那是 Windows 内部规则。 -// 所以这里按名字认按钮,窗口匹配再配合窗口类 CabinetWClass。 +// File Explorer is the only app that needs a special case: its windows carry no AppUserModel +// properties at all (measured AUMID vt=0) and `shell:AppsFolder\C:\Windows\explorer.exe` cannot +// be resolved, yet the taskbar still shows "File Explorer" / "Windows Explorer" by internal Windows rules. +// So this button is recognized by name and its windows by the CabinetWClass fallback. static bool is_file_explorer_button(const wchar_t *button_name) { if(!button_name || !button_name[0]) @@ -658,14 +657,14 @@ static bool is_file_explorer_button(const wchar_t *button_name) return false; ::wcsncpy_s(key, 256, button_name, n); - // 运行中按钮名是「文件资源管理器」,固定后被固定项 lnk 的显示名覆盖成「Windows 资源管理器」 + // A running button is "File Explorer"; once pinned the lnk display name makes it "Windows Explorer". return ::_wcsicmp(key, L"\u6587\u4EF6\u8D44\u6E90\u7BA1\u7406\u5668") == 0 || ::_wcsicmp(key, L"Windows \u8D44\u6E90\u7BA1\u7406\u5668") == 0 || ::_wcsicmp(key, L"File Explorer") == 0; } -// 任务栏按钮的名字形如 "任务管理器 - 1 个运行窗口",取 " - " 之前的部分作为关键词, -// 三级回退匹配:标题精确 → 标题包含 → 进程名相同。 +// A taskbar button name looks like "Task Manager - 1 running window"; take the part before " - " and +// match in three steps: exact title, title substring, same process name. static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) { auto f = reinterpret_cast(lParam); @@ -689,14 +688,14 @@ static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) normalize_for_match(key); bool matched = false; - // 1) 标题精确 2) 标题包含 + // 1) exact title 2) title substring if(tlen > 0 && ::_wcsicmp(title, key) == 0) matched = true; else if(tlen > 0 && ::wcsstr(title, key)) matched = true; else { - // 3) 进程名相同(QQ.exe / WeChat.exe …) + // 3) same process name (QQ.exe / WeChat.exe ...) DWORD pid = 0; ::GetWindowThreadProcessId(hWnd, &pid); if(pid) @@ -720,10 +719,10 @@ static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) } } - // 4) 用「该窗口在任务栏上叫的名字」匹配:任务栏按钮名就是 shell:AppsFolder 里的显示名 - // (窗口 AUMID = Microsoft.Windows.Explorer → 「文件资源管理器」; - // 没注册 AUMID 的应用按 exe 路径查,如 ...\blender-launcher.exe → 「Blender 5.2」)。 - // 这一级完全不看窗口标题,所以标题与按钮名差很远的程序也能认出来。 + // 4) match against the name this window has on the taskbar (the shell:AppsFolder display name) + // (window AUMID Microsoft.Windows.Explorer -> "File Explorer"; apps without an AUMID are + // looked up by exe path, e.g. ...\blender-launcher.exe -> "Blender 5.2"). + // This step ignores the window title, so apps whose title differs from the button name still match. if(!matched) { wchar_t btn_name[512]{}; @@ -734,8 +733,8 @@ static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) } } - // 5) 兜底:「文件资源管理器」的窗口不带任何 AppUserModel 属性、AppsFolder 也查不到, - // 只能按窗口类认(2 个文件夹窗口就是 2 个 CabinetWClass) + // 5) Last resort: File Explorer windows have no AppUserModel properties and are absent from + // AppsFolder, so they can only be matched by class (two folder windows = two CabinetWClass). if(!matched && f->file_explorer) { wchar_t cls[64]{}; @@ -747,13 +746,11 @@ static BOOL CALLBACK find_app_window_proc(HWND hWnd, LPARAM lParam) if(!matched) return TRUE; - // 收集**全部**匹配窗口:应用有多个窗口(合并按钮)时要能一次全关。 - // 主窗口优先取非最小化的:UWP 常同时有「最小化的 ApplicationFrameWindow」和 - // 「真正显示中的 CoreWindow」,只关前者会没反应(实测)。 + // Collect ALL matching windows: a merged button must be able to close every window at once. + // Prefer a non-minimized main window: UWP apps often have a minimized ApplicationFrameWindow + // plus the visible CoreWindow; closing only the former does nothing (measured). f->all.push_back(hWnd); - if(!f->hwnd) - f->hwnd = hWnd; - // ⚠️ 这里不能像以前那样在找到非最小化窗口时 return FALSE 提前结束枚举,否则窗口收不全 + // WARNING: do not return FALSE early on the first non-minimized window, or windows are missed. return TRUE; } @@ -763,9 +760,9 @@ static void find_app_windows(string const &button_name, std::vector &out) if(button_name.empty()) return; - // 按钮名形如 "任务管理器 - 1 个运行窗口" / "Microsoft Edge (2) - 1 个运行窗口"。 - // 先取 " - " 之前的部分;注意还可能带 " (2)" 这类窗口数/配置文件后缀 - // (Edge 多配置文件时会出现),它会破坏标题匹配,需要剥掉再试一次。 + // Button names look like "Task Manager - 1 running window" / "Microsoft Edge (2) - 1 running window". + // Take the part before " - "; it may still carry a " (2)" suffix (window count / profile), + // which appears with multiple Edge profiles and breaks title matching - strip it and retry. wchar_t key[256]{}; const wchar_t *btn = button_name.c_str(); const wchar_t *sep = ::wcsstr(btn, L" - "); @@ -786,7 +783,7 @@ static void find_app_windows(string const &button_name, std::vector &out) return; } - // 剥离尾部 " (数字)" 再匹配 + // Strip a trailing " (number)" and match again wchar_t stripped[256]{}; ::wcsncpy_s(stripped, 256, key, 256); if(wchar_t *p = ::wcsrchr(stripped, L'(')) @@ -816,17 +813,10 @@ static void find_app_windows(string const &button_name, std::vector &out) } } -static HWND find_app_window(string const &button_name) -{ - std::vector w; - find_app_windows(button_name, w); - return w.empty() ? nullptr : w.front(); -} - -// ===== 任务栏固定 / 取消固定 ===== -// 任务栏靠 AppUserModelID 合并「固定项」与「运行中的窗口」: -// 若 .lnk 的 AUMID 与运行窗口的不一致,固定后就会出现两个图标(QQ 就是这种)。 -// 图标同理,.lnk 的 IconLocation 为空时某些应用(Edge)会显示成空白图标。 +// ===== Taskbar pin / unpin ===== +// The taskbar merges a pinned shortcut with running windows by AppUserModelID: +// if the .lnk AUMID differs from the window's, pinning produces two icons (that is what QQ does). +// Same for icons: an empty .lnk IconLocation shows a blank icon for some apps (Edge). static bool taskbar_pinned_dir(wchar_t *out, size_t outLen) { @@ -837,7 +827,7 @@ static bool taskbar_pinned_dir(wchar_t *out, size_t outLen) return true; } -// 该 .lnk 的目标是否就是这个 exe +// Does this .lnk point at that exe? static bool lnk_targets(const wchar_t *lnk_path, const wchar_t *exe_path) { IShellLinkW *link = nullptr; @@ -859,11 +849,11 @@ static bool lnk_targets(const wchar_t *lnk_path, const wchar_t *exe_path) return ok; } -// 任务栏真正显示的固定项记录在 HKCU\...\Taskband\Favorites(REG_BINARY)里; -// User Pinned\TaskBar 目录里的 .lnk 只是素材 —— 被取消固定后文件会残留(幽灵项), -// 只扫目录会把幽灵误判成「已固定」(实测:目录里有 Microsoft Edge.lnk,Favorites 里没有, -// 任务栏上也没有 Edge 按钮 → Edge 一运行就被错当成「已固定」)。 -// 这里在 blob 里按 UTF-16LE 搜 lnk 文件名;字符串在 blob 里的偏移奇偶不定,两种对齐都要试。 +// The pinned items actually shown on the taskbar live in HKCU\...\Explorer\Taskband\Favorites +// (REG_BINARY). The .lnk files under User Pinned\TaskBar are only source material: unpinning leaves +// the file behind (a ghost item), so scanning that folder alone reports a wrong "pinned" state +// (measured: Microsoft Edge.lnk present, not in Favorites, no Edge button - running Edge was treated as pinned). +// The blob is searched for the lnk file name in UTF-16LE; the offset may be odd or even, so both alignments are tried. static bool is_pinned_lnk_active(const wchar_t *lnk_path) { const wchar_t *name = ::wcsrchr(lnk_path, L'\\'); @@ -913,7 +903,7 @@ static bool is_pinned_lnk_active(const wchar_t *lnk_path) return false; } -// 在 User Pinned\TaskBar 里找该 exe 的固定项快捷方式(只认真正在任务栏上的,排除残留幽灵项) +// Find this exe's pinned shortcut under User Pinned\TaskBar (only real ones, no ghost items). static bool find_pinned_lnk(const wchar_t *exe_path, wchar_t *out, size_t outLen) { wchar_t dir[MAX_PATH]{}; @@ -942,8 +932,8 @@ static bool find_pinned_lnk(const wchar_t *exe_path, wchar_t *out, size_t outLen return found; } -// 读 .lnk 的 target 路径。注意有些固定项没有 target(例如「文件资源管理器」的 lnk -// 只写了 IconLocation=%windir%\explorer.exe),这时返回 false。 +// Read the target path of a .lnk. Some pinned shortcuts have no target (the File Explorer +// lnk only sets IconLocation=%windir%\explorer.exe); then this returns false. static bool lnk_target(const wchar_t *lnk_path, wchar_t *out, size_t outLen) { out[0] = 0; @@ -969,9 +959,9 @@ static bool lnk_target(const wchar_t *lnk_path, wchar_t *out, size_t outLen) return ok; } -// 读 .lnk 在资源管理器里显示的**本地化名字**(不带扩展名)。 -// 关键用例:"File Explorer (2).lnk" 在中文系统上显示为「文件资源管理器」, -// 而任务栏按钮名用的就是这个显示名,磁盘文件名对不上。 +// Read the localized display name of a .lnk as Explorer shows it (without extension). +// Key case: "File Explorer (2).lnk" is shown as "File Explorer" while the taskbar button +// name uses that display name, so the on-disk file name does not match. static bool lnk_display_name(const wchar_t *lnk_path, wchar_t *out, size_t outLen) { out[0] = 0; @@ -991,16 +981,16 @@ static bool lnk_display_name(const wchar_t *lnk_path, wchar_t *out, size_t outLe return ok; } -// 未运行的固定项按钮上拿不到窗口,UIA 也**只给名字**(AutomationId 为空、没有 AUMID), -// 所以只能拿按钮名去 User Pinned\TaskBar 里比对固定项的 .lnk。 -// 三级匹配:lnk 文件名 == 按钮名 → 去掉尾部 " (数字)" 再比 → lnk 的显示名 / target 主名 == 按钮名。 -// 命中后还要过 is_pinned_lnk_active(目录里残留的幽灵项不算固定项)。 +// A pinned button that is not running yields no window and UIA gives only a name (no +// AutomationId, no AUMID), so the button name has to be matched against the .lnk files. +// Three steps: lnk file name == button name, then without a trailing " (number)", then lnk display name / target stem. +// A match still has to pass is_pinned_lnk_active (ghost items do not count as pinned). static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, size_t lnkLen, wchar_t *exeOut, size_t exeLen) { if(!button_name || !button_name[0]) return false; - // 按钮名形如 "任务管理器 - 1 个运行窗口";取 " - " 之前的部分 + // Button name looks like "Task Manager - 1 running window"; take the part before " - ". wchar_t key[256]{}; const wchar_t *sep = ::wcsstr(button_name, L" - "); size_t n = sep ? static_cast(sep - button_name) : ::wcslen(button_name); @@ -1009,7 +999,7 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, ::wcsncpy_s(key, 256, button_name, n); normalize_for_match(key); - // 再去掉尾部的 " (数字)"(同一应用有多个固定项时任务栏会加序号,如 "Microsoft Edge (2)") + // Then strip a trailing " (number)" (the taskbar numbers duplicates, e.g. "Microsoft Edge (2)"). wchar_t stripped[256]{}; ::wcsncpy_s(stripped, 256, key, 256); if(wchar_t *p = ::wcsrchr(stripped, L'(')) @@ -1051,7 +1041,7 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, wchar_t full[MAX_PATH]{}; ::swprintf_s(full, MAX_PATH, L"%s\\%s", dir, fd.cFileName); - // ① lnk 文件名(去扩展名) + // (1) lnk file name without extension wchar_t stem[MAX_PATH]{}; ::wcsncpy_s(stem, MAX_PATH, fd.cFileName, MAX_PATH); if(wchar_t *dot = ::wcsrchr(stem, L'.')) @@ -1062,7 +1052,7 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, if(!matched && stripped[0]) matched = ::_wcsicmp(stem, stripped) == 0; - // ② lnk 的本地化显示名(「文件资源管理器」↔ File Explorer (2).lnk) + // (2) localized display name ("File Explorer" <-> File Explorer (2).lnk) if(!matched) { wchar_t disp[MAX_PATH]{}; @@ -1073,7 +1063,7 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, } } - // ③ lnk 的 target 主名(没扩展名) + // (3) target stem without extension if(!matched) { wchar_t target[MAX_PATH]{}; @@ -1091,7 +1081,7 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, if(matched) { - // 已被取消固定、只剩文件的幽灵项不算固定项 + // An unpinned ghost item that only left a file behind does not count as pinned if(!is_pinned_lnk_active(full)) continue; @@ -1106,12 +1096,6 @@ static bool find_pinned_lnk_by_name(const wchar_t *button_name, wchar_t *lnkOut, return found; } -static bool is_pinned_to_taskbar(const wchar_t *exe_path) -{ - wchar_t lnk[MAX_PATH]{}; - return find_pinned_lnk(exe_path, lnk, MAX_PATH); -} - static void set_lnk_prop(IPropertyStore *ps, REFPROPERTYKEY key, const wchar_t *value) { if(!value || !value[0]) @@ -1122,11 +1106,11 @@ static void set_lnk_prop(IPropertyStore *ps, REFPROPERTYKEY key, const wchar_t * ps->SetValue(key, pv); } -// pin 完成后修正刚生成的快捷方式: -// ① 补上**整组** AppUserModel 属性(ID + RelaunchCommand + RelaunchDisplayNameResource + IconResource)—— -// 只补 AUMID 不够:任务栏算 AppID 还看 RelaunchCommand,缺了它固定项会跟运行窗口分成两个图标 -// (实测 Blender:lnk 只有 ID、target 是 blender.exe,而窗口 relaunch 是 blender-launcher.exe)。 -// ② 补图标位置,避免空白图标。 +// After a successful pin, fix up the shortcut that was just created: +// (1) write the WHOLE set of AppUserModel properties (ID + RelaunchCommand + RelaunchDisplayNameResource +// + IconResource). The AUMID alone is not enough: the taskbar AppID also uses RelaunchCommand, +// so without it the pinned item splits from the running window (measured Blender: lnk had only ID, +// target blender.exe, window relaunch blender-launcher.exe). (2) set the icon location to avoid a blank icon. static void fix_pinned_shortcut(const wchar_t *lnk_path, const wchar_t *exe_path, const window_appmodel &am) { IShellLinkW *link = nullptr; @@ -1155,13 +1139,13 @@ static void fix_pinned_shortcut(const wchar_t *lnk_path, const wchar_t *exe_path link->Release(); } -// 调用 shell 上下文菜单里的某个动词(按语言无关的规范名,如 "TaskbarPin")。 +// Invoke a verb of the shell context menu by its canonical, language-independent name ("TaskbarPin"). // -// 为什么不能直接用 ShellExecuteEx(verb): -// Win10 上 "TaskbarPin" 这个动词虽然存在,但程序化调用会被**静默忽略** -// (老的 "pin to taskbar" 更是直接 ERROR_NO_ASSOCIATION)——微软的「用户意图」保护。 -// 文件资源管理器里点「固定到任务栏」能生效,是因为 explorer 走的是 IContextMenu::InvokeCommand。 -// 本函数复刻这条路径,所以能真正固定。 +// Why ShellExecuteEx(verb) cannot be used directly: +// on Win10 the "TaskbarPin" verb exists but a programmatic call is silently ignored +// (the old "pin to taskbar" even returns ERROR_NO_ASSOCIATION) - Microsoft's user-intent guard. +// Clicking "Pin to taskbar" in Explorer works because Explorer goes through IContextMenu::InvokeCommand. +// This function replicates that path, which is why the pin really takes effect. static bool invoke_shell_verb(const wchar_t *path, const wchar_t *verb_name) { if(!path || !verb_name) @@ -1182,8 +1166,8 @@ static bool invoke_shell_verb(const wchar_t *path, const wchar_t *verb_name) HMENU menu = ::CreatePopupMenu(); if(menu && SUCCEEDED(cm->QueryContextMenu(menu, 0, 1, 0x7FFF, CMF_NORMAL))) { - // 找不到目标动词时,把枚举到的动词名记进日志:不同对象上挂的动词集合不一样 - // (「从任务栏取消固定」只在固定项 .lnk 上有),下次失败可直接看清单定位。 + // When the target verb is missing, log the enumerated verb names: the verb set differs per object + // ("Unpin from taskbar" only exists on pinned .lnk files), which pinpoints later failures. wchar_t seen[512]{}; int count = ::GetMenuItemCount(menu); for(int i = 0; i < count; ++i) @@ -1232,12 +1216,12 @@ static bool invoke_shell_verb(const wchar_t *path, const wchar_t *verb_name) return ok; } -// 微软官方的「从任务栏 / 开始菜单取消固定」API(CLSID_StartMenuPin,实现于 shell32.dll)。 -// 它**只有 RemoveFromList**:官方没有提供「固定到任务栏」的 API(防止程序擅自改任务栏), -// 所以「固定」只能走 shell 动词,「取消固定」在动词不可用时用这条兜底。 -// 实测用例:File Explorer (2).lnk 上压根没有 taskbarunpin 动词 -// (枚举结果只有 open/opencontaining/OpenNewWindow/cut/copy/link/delete/properties), -// 但它在 Taskband\Favorites 里确实固定着,只能靠这条 API 取消。 +// Microsoft's official "unpin from taskbar / Start menu" API (CLSID_StartMenuPin, implemented in shell32.dll). +// It only offers RemoveFromList: there is no official pin API (to keep programs from changing the +// taskbar), so pinning uses the shell verb and unpinning falls back to this API. +// Measured case: File Explorer (2).lnk has no taskbarunpin verb at all +// (the enumerated set is only open/opencontaining/OpenNewWindow/cut/copy/link/delete/properties), +// yet it really is pinned in Taskband\Favorites, so only this API can unpin it. static bool start_menu_remove_from_list(const wchar_t *path) { if(!path || !path[0]) @@ -1266,25 +1250,25 @@ static bool start_menu_remove_from_list(const wchar_t *path) return SUCCEEDED(hr); } -// 后台线程里跑:QueryContextMenu 会加载一堆 shell 扩展,别拖住 explorer 的 UI 线程 -// lnk 为「该应用的固定项快捷方式」路径(可能为空)。取消固定的动词只挂在这种 .lnk 上。 +// Runs on a background thread: QueryContextMenu loads many shell extensions and must not block +// explorer's UI thread. 'lnk' is the pinned shortcut path (may be empty); the unpin verb lives there. static void set_taskbar_pin_async(string path, string lnk, HWND hWnd, bool pin) { std::thread([path, lnk, hWnd, pin]() { ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - // 固定前先把运行窗口的整组 AppUserModel 属性记下来,固定后写进快捷方式 —— - // 只写 AUMID 不够,任务栏算 AppID 还看 RelaunchCommand,缺了就会分成两个图标 + // Before pinning, capture the whole AppUserModel property set of the running window and write it into + // the shortcut afterwards. The AUMID alone is not enough (the AppID also uses RelaunchCommand), otherwise it becomes two icons. window_appmodel am; if(pin) { get_window_appmodel(hWnd, am); - // 「文件资源管理器」例外:explorer 的窗口一个 AppUserModel 属性都不带, - // 但固定项必须写上这组值才能与运行窗口合并(实测写入后重启 explorer, - // 固定项与文件夹窗口合并成一个按钮)。这些值是 Windows 自己用的 —— - // AppsFolder 里 Microsoft.Windows.Explorer 的显示名就是「文件资源管理器」。 + // File Explorer is the exception: explorer windows carry no AppUserModel property at all, + // yet the pinned shortcut needs this set to merge with the running window (measured: after writing + // it and restarting explorer the pinned item merged with the folder window). These are the values + // Windows itself uses: Microsoft.Windows.Explorer in AppsFolder displays as "File Explorer". const wchar_t *file = ::wcsrchr(path.c_str(), L'\\'); file = file ? file + 1 : path.c_str(); if(!am.aumid[0] && ::_wcsicmp(file, L"explorer.exe") == 0) @@ -1297,23 +1281,23 @@ static void set_taskbar_pin_async(string path, string lnk, HWND hWnd, bool pin) } } - // 「从任务栏取消固定」的动词实测只挂在固定项的 .lnk 上 - // (msedge.exe / DeepSeek Harness.exe / notepad.exe / 普通 lnk 上都没有), - // 对 exe 调用自然枚举不到 → 这是取消固定时灵时不灵的根因。 - // 所以先从 .lnk 试,失败再回退到原来的 exe 路径。 + // The "unpin from taskbar" verb only exists on pinned .lnk files (measured: msedge.exe, + // DeepSeek Harness.exe, notepad.exe and plain .lnk files do not have it), so calling it on an exe + // enumerates nothing - that was the root cause of unpin working only sometimes. + // Therefore try the .lnk first and fall back to the exe path. bool ok = false; if(!pin && !lnk.empty()) ok = invoke_shell_verb(lnk.c_str(), L"taskbarunpin"); if(!ok) ok = invoke_shell_verb(path.c_str(), pin ? L"TaskbarPin" : L"TaskbarUnpin"); - // 有些固定项的 .lnk 上压根没有 taskbarunpin 动词(实测「文件资源管理器」), - // 这时用官方 API 兜底(它只有取消固定,没有固定)。 + // Some pinned .lnk files have no taskbarunpin verb at all (measured: File Explorer), so fall + // back to the official API (unpin only). if(!ok && !pin) ok = start_menu_remove_from_list(lnk.empty() ? path.c_str() : lnk.c_str()); _log.info(L"[BTN] %s exe='%s' lnk='%s' -> %s", pin ? L"TaskbarPin" : L"TaskbarUnpin", path.c_str(), lnk.c_str(), ok ? L"ok" : L"failed"); - // 固定成功后补写快捷方式:AUMID + 图标位置 + // After a successful pin, patch the shortcut: AUMID + icon location if(pin && ok) { for(int i = 0; i < 25; ++i) @@ -1333,12 +1317,12 @@ static void set_taskbar_pin_async(string path, string lnk, HWND hWnd, bool pin) }).detach(); } -// 「关闭窗口」= 请求窗口关闭,**不是结束进程**(结束进程会丢数据、应用没机会保存)。 -// 1) UIA 的 WindowPattern.Close():标准「关闭窗口」语义(等同用户点标题栏 ×,应用可弹保存/取消)。 -// UWP / 现代应用只认这条 —— 实测「设置」窗口对 WM_CLOSE、WM_SYSCOMMAND/SC_CLOSE、 -// SendMessage(WM_CLOSE) 三种传统发法全部无响应。 -// 2) 普通 Win32 窗口兜底:WM_CLOSE。 -// UIA 调用可能较慢,放后台线程,避免卡住 explorer 的 UI 线程。 +// "Close window" requests a close, it does NOT kill the process (killing loses unsaved data). +// 1) UIA WindowPattern.Close(): the standard close semantics (like clicking the title-bar X). +// UWP / modern apps only honour this one - the Settings window ignored WM_CLOSE, +// WM_SYSCOMMAND/SC_CLOSE and SendMessage(WM_CLOSE) (measured). +// 2) Fallback for plain Win32 windows: WM_CLOSE. +// UIA calls can be slow, so this runs on a background thread to keep explorer's UI responsive. static bool close_window_via_uia(HWND hWnd) { if(!hWnd || !taskbar_t::_IUIAutomation) @@ -1375,8 +1359,8 @@ static void close_app_window_async(HWND hWnd) }).detach(); } -// 把匹配到的窗口收敛成「真正要关闭的顶层窗口」:按 GA_ROOT 去重 —— -// UWP 会同时出现 CoreWindow 和它的宿主 ApplicationFrameWindow,其实是同一个窗口。 +// Reduce the matched windows to the top-level windows to close: de-duplicate by GA_ROOT +// (UWP exposes a CoreWindow and its ApplicationFrameWindow host, which are the same window). static void distinct_top_windows(const std::vector &in, std::vector &out) { out.clear(); @@ -1399,12 +1383,12 @@ static void distinct_top_windows(const std::vector &in, std::vector } } -// 把窗口切到前台。为什么写这么啰嗦: -// explorer 是**中完整性**进程,而像「任务管理器」这种以管理员运行的窗口是**高完整性**: -// - SetForegroundWindow 常被 Windows 的「前台锁」拒绝(调用方不是当前前台时); -// - 常规兜底 AttachThreadInput 要访问目标线程的输入队列,**跨完整性会被 UIPI 挡掉**。 -// 所以按代价递增依次尝试;最后一条 SwitchToThisWindow 是任务栏点击内部用的老 API。 -// 注意最小化窗口只要 ShowWindow(不碰输入队列),这解释了「最小化时能唤起、从后台切不过来」。 +// Bring a window to the foreground. Why this is so verbose: +// explorer runs at medium integrity while a window like Task Manager (run as admin) is high: +// - SetForegroundWindow is often refused by the foreground lock (caller is not the foreground); +// - the usual AttachThreadInput fallback needs the target thread's input queue and UIPI blocks it +// across integrity levels. So try them in increasing order of cost; SwitchToThisWindow, the +// legacy taskbar API, is last. Minimized windows only need ShowWindow (no input queue), which is why they come up but background ones do not. extern "C" __declspec(dllimport) void WINAPI SwitchToThisWindow(HWND hwnd, BOOL fUnknown); static bool activate_top_window(HWND hWnd) @@ -1422,7 +1406,7 @@ static bool activate_top_window(HWND hWnd) if(::SetForegroundWindow(hWnd)) return true; - // 兜底 1:接上当前前台线程的输入队列(同完整性时有效;目标是提权进程时会被 UIPI 拒绝) + // Fallback 1: attach to the current foreground thread's input queue (same integrity only; UIPI blocks elevated targets) if(auto fg = ::GetForegroundWindow()) { const auto tid_fg = ::GetWindowThreadProcessId(fg, nullptr); @@ -1436,13 +1420,13 @@ static bool activate_top_window(HWND hWnd) } } - // 兜底 2:模拟一次 ALT 键 —— 前台锁在收到键盘输入后会放开,对提权窗口同样有效 + // Fallback 2: simulate an ALT keypress - the foreground lock is released on keyboard input, works for elevated windows too ::keybd_event(VK_MENU, 0, 0, 0); ::keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0); if(::SetForegroundWindow(hWnd)) return true; - // 兜底 3:任务栏点击内部用的老 API + // Fallback 3: the legacy API the taskbar uses internally ::SwitchToThisWindow(hWnd, TRUE); return ::GetForegroundWindow() == hWnd; } @@ -1459,22 +1443,22 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ const UINT id_pin = 0x1001; const UINT id_close = 0x1002; const UINT id_unpin = 0x1003; - const UINT id_activate = 0x1004; // 应用名那一项:点击 = 激活窗口(没运行则启动程序) + const UINT id_activate = 0x1004; // app-name item: clicking it activates the window (or launches the app) - // 源码文件无 BOM,MSVC 按 GBK(936) 解析源文件,中文字面量必须用 \u 转义, - // 否则会乱码:"固定到任务栏" → "鏁哄畾鍒颁换鍔℃爮" - const wchar_t *text_pin = L"\u56FA\u5B9A\u5230\u4EFB\u52A1\u680F"; // 固定到任务栏 - const wchar_t *text_unpin = L"\u4ECE\u4EFB\u52A1\u680F\u53D6\u6D88\u56FA\u5B9A"; // 从任务栏取消固定 - const wchar_t *text_close = L"\u5173\u95ED\u7A97\u53E3"; // 关闭窗口 - const wchar_t *text_close_all = L"\u5173\u95ED\u6240\u6709\u7A97\u53E3"; // 关闭所有窗口 + // Non-ASCII literals are written as \u escapes so the file stays independent of the compiler's + // source encoding (see the L"\u56FA..." literals below). + const wchar_t *text_pin = L"\u56FA\u5B9A\u5230\u4EFB\u52A1\u680F"; // Pin to taskbar + const wchar_t *text_unpin = L"\u4ECE\u4EFB\u52A1\u680F\u53D6\u6D88\u56FA\u5B9A"; // Unpin from taskbar + const wchar_t *text_close = L"\u5173\u95ED\u7A97\u53E3"; // Close window + const wchar_t *text_close_all = L"\u5173\u95ED\u6240\u6709\u7A97\u53E3"; // Close all windows auto title = info.title.empty() ? string(L"Application") : info.title; - // 应用名那一项是**可点**的:有运行窗口就把它切到前台(等同点任务栏按钮), - // 没在运行就启动程序。以前这里 id 写 0,点了没有任何反应。 + // The app-name item is clickable: with a running window it is brought to the foreground (same as + // clicking the button); otherwise the app is launched. Previously the id was 0 and nothing happened. ::AppendMenuW(menu, MF_STRING, id_activate, title.c_str()); ::AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); - // 固定 / 取消固定:按当前是否已固定决定显示哪个,并记下 exe / 固定项 .lnk 给动作分支用 + // Pin / unpin: show one based on the current state; remember the exe / pinned .lnk for the action. wchar_t exe_path[MAX_PATH]{}; bool has_exe = false; if(info.window) @@ -1493,8 +1477,8 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ } } - // 没运行(纯固定项按钮)时上面拿不到 exe:改用按钮名去固定项目录里找对应的 .lnk, - // 顺带取它的 target 当 exe(「文件资源管理器」这类 lnk 没有 target,只拿到 .lnk) + // A not-running (pinned-only) button gives no exe above, so look the .lnk up by button name - + // and take its target as the exe (lnk files like File Explorer's have no target, only the .lnk). wchar_t pinned_lnk[MAX_PATH]{}; bool has_pinned_lnk = false; if(!has_exe) @@ -1503,7 +1487,7 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ if(has_pinned_lnk && exe_path[0]) has_exe = true; } - // 运行中的固定项:由 exe 反查它的固定项 .lnk(取消固定的动词挂在 .lnk 上) + // Running pinned app: look the pinned .lnk up from the exe (the unpin verb lives on the .lnk). if(!has_pinned_lnk && has_exe) has_pinned_lnk = find_pinned_lnk(exe_path, pinned_lnk, MAX_PATH); @@ -1512,8 +1496,8 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ else if(has_exe) ::AppendMenuW(menu, MF_STRING, id_pin, text_pin); - // 「关闭窗口」只在真的有运行窗口时才给:没有窗口时点它没有任何作用。 - // 应用有多个窗口(合并按钮)时改成「关闭所有窗口」,一次全关。 + // "Close window" is only offered when a window really exists; otherwise it would do nothing. + // With several windows (merged button) it becomes "Close all windows" and closes them at once. std::vector close_targets; distinct_top_windows(info.windows, close_targets); if(!close_targets.empty()) @@ -1524,13 +1508,13 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ info.window ? Window::class_name(info.window).c_str() : L"", info.title.c_str(), exe_path, pinned_lnk); - // 菜单必须先拿到前台/焦点:否则键盘导航无效,鼠标第一次点击只当"激活", - // 且任何进程抢焦点都会把菜单关掉(返回 0)。 + // The menu must take the foreground/focus first: otherwise keyboard navigation does not work, the + // first mouse click only activates it, and any other process taking focus closes it (returns 0). if(auto root = ::GetAncestor(hWnd, GA_ROOT)) ::SetForegroundWindow(root); ::SetForegroundWindow(hWnd); - // 供 command.kill_process_tree 用(见下):按钮菜单当前对应的顶层窗口,已按 GA_ROOT 去重。 + // Used by command.kill_process_tree: the top-level windows of this button, de-duplicated by GA_ROOT. taskbar_button_windows = close_targets; is_in_taskbar = true; @@ -1546,14 +1530,14 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ if(info.window) { const bool ok = activate_top_window(info.window); - // 记下成功与否与实际前台窗口:以后「点了没反应」时一眼能看出卡在哪一步。 + // Log success and the actual foreground window, so "clicked but nothing happened" is easy to diagnose. _log.info(L"[BTN] activate window=0x%x ok=%d fg=0x%x exe='%s'", (unsigned)(ULONG_PTR)info.window, ok ? 1 : 0, (unsigned)(ULONG_PTR)::GetForegroundWindow(), exe_path); } else { - // 没在运行:启动它。有固定项 .lnk 就优先走 lnk(能带上原有的工作目录/参数)。 + // Not running: launch it. Prefer the pinned .lnk (it keeps the original working dir / arguments). const wchar_t *target = has_pinned_lnk ? pinned_lnk : (has_exe ? exe_path : nullptr); if(target) { @@ -1564,9 +1548,9 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ } else if(cmd == id_close && !close_targets.empty()) { - // UWP 应用(设置 / 商店等)里层的 Windows.UI.Core.CoreWindow 不响应 SC_CLOSE, - // 必须把关闭消息发给顶层宿主(ApplicationFrameWindow)—— 上面已按 GA_ROOT 收敛过。 - // 多窗口(合并按钮)时逐个发关闭请求,应用自己仍可弹保存/取消框。 + // The inner Windows.UI.Core.CoreWindow of UWP apps (Settings, Store, ...) ignores SC_CLOSE, so the + // close message must go to the top-level host ApplicationFrameWindow (already reduced by GA_ROOT). + // With several windows each one gets its own close request; the app can still show save/cancel dialogs. _log.info(L"[BTN] close %d window(s)", static_cast(close_targets.size())); for(HWND t : close_targets) close_app_window_async(t); @@ -1585,9 +1569,9 @@ static void show_taskbar_button_menu(HWND hWnd, const Point &pt, taskbar_button_ ::DestroyMenu(menu); } -// command.kill_process_tree 的实现(FuncExpression.cpp 里 extern 调用): -// 杀掉按钮菜单当前对应窗口的进程树。顺序很关键 —— 先按 Toolhelp 快照把后代全部展开, -// 再从叶子往根终止:反过来先杀父进程的话,子进程会被系统改挂到别的父上,杀树就漏了。 +// Implementation of command.kill_process_tree (called via extern from FuncExpression.cpp): +// Kill the process tree of the windows this button menu refers to. Order matters: expand all +// descendants from the Toolhelp snapshot first, then terminate leaves first - killing parents first lets Windows reparent the children and the tree leaks. bool kill_taskbar_button_process_tree() { std::vector roots; @@ -1668,17 +1652,17 @@ LRESULT __stdcall TaskListProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lPara auto pt = Point::CursorPos(); auto info = query_taskbar_button(pt); if(!info.is_button) - break; // 按钮之间的空白:放行,走 explorer/Shell 原有的任务栏菜单 + break; // gap between buttons: fall through to explorer/Shell's own taskbar menu _log.info(L"[BTN] WM_CONTEXTMENU cursor=(%d,%d)", pt.x, pt.y); show_taskbar_button_menu(hWnd, pt, info); - return 0; // 只对真正的应用按钮吞掉,阻止 XAML 跳转列表 + return 0; // swallow only for real app buttons, preventing the XAML jump list } } return ::CallWindowProcW(proc, hWnd, uMsg, wParam, lParam); } -// 给**一个**任务栏挂 hook:主任务栏(Shell_TrayWnd)与副屏任务栏(Shell_SecondaryTrayWnd) -// 的窗口链是一样的 —— ReBarWindow32 → MSTaskSwWClass → MSTaskListWClass。 +// Hook ONE taskbar. The main taskbar (Shell_TrayWnd) and secondary taskbars +// (Shell_SecondaryTrayWnd) share the same window chain: ReBarWindow32 -> MSTaskSwWClass -> MSTaskListWClass. static bool tasklist_hook_window(HWND tray) { if(!tray) @@ -1693,7 +1677,7 @@ static bool tasklist_hook_window(HWND tray) if(!list) return false; if(::GetPropW(list, TaskListSubclassProp)) - return true; // 已经挂过 + return true; // already hooked auto proc = reinterpret_cast(::GetWindowLongPtrW(list, GWLP_WNDPROC)); ::SetPropW(list, TaskListSubclassProp, reinterpret_cast(proc)); @@ -1703,7 +1687,7 @@ static bool tasklist_hook_window(HWND tray) return true; } -// 挂上**所有**任务栏:主任务栏 + 每个副屏任务栏(Win10 多屏时每屏一个 Shell_SecondaryTrayWnd)。 +// Hook ALL taskbars: the main one plus every secondary taskbar (one Shell_SecondaryTrayWnd per screen on Win10). static int tasklist_hook_all_now() { int hooked = 0; @@ -1722,10 +1706,10 @@ static int tasklist_hook_all_now() return hooked; } -// 启动后先重试若干次(explorer 刚起来时任务栏可能还没建好;delay 为尝试间隔), -// 成功之后转成**长期巡检**:任务栏窗口被就地重建时(改 DPI / 插拔显示器 / 切主题) -// subclass 会跟着丢,这里负责重新挂上 —— 否则按钮菜单会静默退回原生 XAML 跳转列表。 -// 只有真的新挂上时才写日志,巡检本身是静默的。 +// Retry a few times after startup (the taskbar may not exist yet when explorer starts; 'delay' is +// the retry interval), then switch to a permanent watchdog: when the taskbar window is recreated +// in place (DPI change, monitor plug/unplug, theme switch) the subclass is dropped and re-installed +// here; otherwise the button menu silently falls back to the native XAML jump list. Only real re-hooks are logged. static void tasklist_hook_all(uint32_t delay) { std::thread([delay]() @@ -1878,12 +1862,12 @@ STDAPI WINAPI DllGetClassObjectHook(REFCLSID rclsid, _In_ REFIID riid, LPVOID *p #define TPM_SYSMENU 0x0200L bool is_in_taskbar; -// 任务栏「应用按钮」自建菜单期间为 true(见 show_taskbar_button_menu)。 -// ContextMenu 用它把这次菜单的上下文类型标成 FSO_TASKBAR_BUTTON(而不是 FSO_TASKBAR), -// 于是配置项可以用 where=window.is_taskbar_button 精确命中按钮菜单, -// 而「转到」、imports/taskbar.nss 等既有 taskbar 项不会自动涌进来。 +// True while the self-built taskbar "app button" menu is open (see show_taskbar_button_menu). +// ContextMenu uses it to tag this menu with FSO_TASKBAR_BUTTON instead of FSO_TASKBAR, so config +// items can target the button menu with where=window.is_taskbar_button while existing taskbar +// items ("Go to", imports/taskbar.nss, ...) do not leak into it. bool in_taskbar_button_menu; -// 按钮菜单当前对应的顶层窗口(弹菜单前填好):command.kill_process_tree 用它杀进程树。 +// Top-level windows of the current button menu (filled before the menu pops up); used by command.kill_process_tree. std::vector taskbar_button_windows; BOOL WINAPI TrackPopupMenuProc(HMENU hMenu, uint32_t uFlags, int x, int y, int nReserved, HWND hWnd, const RECT *prcRect); @@ -1898,7 +1882,6 @@ BOOL WINAPI NtUserTrackPopupMenu(HMENU hMenu, uint32_t uFlags, int x, int y, HWN auto result = FALSE; auto invoked = false; - auto invoke = [&](HMENU hmenu, uint32_t flag, Point pt) { if(!invoked) @@ -2412,10 +2395,6 @@ BOOL APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) //m->init(hModule, "api-ms-win-core-com-l1-1-0.dll", ::CoCreateInstance, CoCreateInstanceProc).install(); _CoCreateInstance.Commit(); - // [注] 曾试过「无条件安装 IAT hook」(方案 B),实测: - // 1) 对任务栏按钮菜单无效(它不走 user32!TrackPopupMenu); - // 2) 会破坏 explorer 的系统菜单填充(桌面菜单只剩配置项,系统项全丢)。 - // 故回退为官方原有条件。 if(!iathook_NtUserTrackPopupMenuEx.installed()) { std::thread([=]() { ::Sleep(2000); hook(); }).detach(); @@ -2452,7 +2431,7 @@ BOOL APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) } else { - // [PROBE] Win10:任务栏按钮区消息探针(只记录) + // Win10: install the taskbar button area (MSTaskListWClass) hook. tasklist_hook_all(1000); } } diff --git a/src/shared/FileSystemObjects.h b/src/shared/FileSystemObjects.h index e2919669..9354ee14 100644 --- a/src/shared/FileSystemObjects.h +++ b/src/shared/FileSystemObjects.h @@ -43,8 +43,8 @@ namespace Nilesoft FSO_SIZE, FSO_NAMESPACE_LIBRARIES, - // 任务栏「应用按钮」自建菜单的上下文类型(与 FSO_NAMESPACE_LIBRARIES 一样放在 FSO_SIZE 之后: - // 不进 fs_types 表,也不改动任何既有 FSO_* 的枚举值)。 + // Context type of the self-built taskbar "app button" menu. Like FSO_NAMESPACE_LIBRARIES it is + // placed after FSO_SIZE so it stays out of the fs_types table and no existing FSO_* value changes. FSO_TASKBAR_BUTTON, FSO_COUNT, FSO_MAX