diff --git a/src/dll/src/ContextMenu.cpp b/src/dll/src/ContextMenu.cpp index e7239da0..a6251a3b 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; + +// Defined in Main.cpp: true while the self-built taskbar "app button" menu is open (global namespace) +extern bool in_taskbar_button_menu; #include extern Logger &_log; @@ -1108,6 +1111,9 @@ namespace Nilesoft std::vector bottom_items; + // 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())) @@ -1195,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(); } @@ -4393,7 +4399,15 @@ namespace Nilesoft switch(Selected.Window.id) { case WINDOW_TASKBAR: - Selected.Types[FSO_TASKBAR] = TRUE; + // 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' 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; @@ -4470,6 +4484,8 @@ namespace Nilesoft if(0 == ::GetPropW(hwnd.owner, UxSubclass)) build_system_menuitems(_hMenu_original, __system_menu_tree, true); + // 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 d1af1114..5981bfc5 100644 --- a/src/dll/src/Expression/FuncExpression.cpp +++ b/src/dll/src/Expression/FuncExpression.cpp @@ -9,6 +9,11 @@ #include +// Defined in Main.cpp: kills the process tree of the windows of the current taskbar button menu. +extern bool kill_taskbar_button_process_tree(); +// Defined in Main.cpp: top-level windows matched by the current button menu (window.taskbar_button_windows). +extern std::vector taskbar_button_windows; + //#include namespace Nilesoft @@ -1461,7 +1466,20 @@ namespace Nilesoft _result = sel->Window.desktop; break; case IDENT_IS_TASKBAR: - _result = sel->Window.parent == WINDOW_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: + // 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: + // 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: _result = sel->Window.explorer; @@ -1718,6 +1736,10 @@ namespace Nilesoft case IDENT_COMMAND_RESTART_EXPLORER: _result = Windows::Explorer::Restart(); break; + case IDENT_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: Explorer::ToggleHiddenFiles(); break; diff --git a/src/dll/src/Main.cpp b/src/dll/src/Main.cpp index 0c56bdaa..35c488e0 100644 --- a/src/dll/src/Main.cpp +++ b/src/dll/src/Main.cpp @@ -3,6 +3,9 @@ #include "Library/detours.h" #include "RegistryConfig.h" #include +#include // PKEY_AppUserModel_ID (needed when patching a pinned shortcut) +#include // CSIDL_APPDATA / IShellLink +#include // IStartMenuPinnedList (the official "unpin from taskbar" 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); +// 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); __inline auto is_registered() -> bool { @@ -382,6 +388,1348 @@ struct taskbar_t } }; +// ===== 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; +// 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); +// 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"; + +// 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 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{}; // 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) +{ + 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 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) + 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; // 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 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) + 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; +} + +// 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]{}; + 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(); + // 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; +} + +// 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; + 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; +} + +// 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; + 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; +} + +// 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); // empty strings when unavailable; the exe-path fallback follows + 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) != FALSE; + ::CloseHandle(hp); + if(!ok) + return false; + + if(appsfolder_display_name(exe, out, outLen)) + return true; + + // 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); +} + +// 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]) + 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); + + // 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; +} + +// 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); + 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) 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) same process name (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) 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]{}; + if(window_button_name(hWnd, btn_name, 512)) + { + normalize_for_match(btn_name); + matched = ::_wcsicmp(btn_name, key) == 0; + } + } + + // 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]{}; + if(::GetClassNameW(hWnd, cls, 64) > 0 && + (::_wcsicmp(cls, L"CabinetWClass") == 0 || ::_wcsicmp(cls, L"ExploreWClass") == 0)) + matched = true; + } + + if(!matched) + return TRUE; + + // 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); + // WARNING: do not return FALSE early on the first non-minimized window, or windows are missed. + return TRUE; +} + +static void find_app_windows(string const &button_name, std::vector &out) +{ + out.clear(); + if(button_name.empty()) + return; + + // 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" - "); + 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; + } + + // Strip a trailing " (number)" and match again + 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; + } + } + } +} + +// ===== 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) +{ + 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; +} + +// Does this .lnk point at that 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; +} + +// 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'\\'); + 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; +} + +// 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]{}; + 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; +} + +// 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; + 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; +} + +// 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; + 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; +} + +// 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; + + // 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); + if(n == 0 || n >= 256) + return false; + ::wcsncpy_s(key, 256, button_name, n); + normalize_for_match(key); + + // 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'(')) + { + 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); + + // (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'.')) + *dot = 0; + normalize_for_match(stem); + + bool matched = ::_wcsicmp(stem, key) == 0; + if(!matched && stripped[0]) + matched = ::_wcsicmp(stem, stripped) == 0; + + // (2) localized display name ("File Explorer" <-> 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); + } + } + + // (3) target stem without extension + 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) + { + // An unpinned ghost item that only left a file behind does not count as pinned + 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 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); +} + +// 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; + 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(); +} + +// Invoke a verb of the shell context menu by its canonical, language-independent name ("TaskbarPin"). +// +// 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) + 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))) + { + // 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) + { + 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; +} + +// 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]) + 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); +} + +// 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); + + // 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); + + // 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) + { + ::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); + } + } + + // 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"); + // 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"); + + // After a successful pin, patch the shortcut: AUMID + icon location + 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(); +} + +// "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) + 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(); +} + +// 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(); + 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); + } +} + +// 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) +{ + 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; + + // 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); + 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; + } + } + + // 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; + + // Fallback 3: the legacy API the taskbar uses internally + ::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); + 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; + const UINT id_activate = 0x1004; // app-name item: clicking it activates the window (or launches the app) + + // 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; + // 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); + + // 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) + { + 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); + } + } + } + + // 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) + { + 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; + } + // 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); + + if(has_pinned_lnk) + ::AppendMenuW(menu, MF_STRING, id_unpin, text_unpin); + 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()) + ::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); + + // 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); + + // 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; + 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_activate) + { + 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 + { + // 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) + { + _log.info(L"[BTN] launch '%s'", target); + ::ShellExecuteW(nullptr, nullptr, target, nullptr, nullptr, SW_SHOWNORMAL); + } + } + } + else if(cmd == id_close && !close_targets.empty()) + { + // 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); + } + 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); +} + +// 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; + 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)); + 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; // 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; // swallow only for real app buttons, preventing the XAML jump list + } + } + return ::CallWindowProcW(proc, hWnd, uMsg, wParam, lParam); +} + +// 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) + return false; + auto rebar = ::FindWindowExW(tray, nullptr, Windows::WC_ReBarWindow32, nullptr); + if(!rebar) + return false; + auto sw = ::FindWindowExW(rebar, nullptr, Windows::WC_MSTaskSwWClass, nullptr); + if(!sw) + return false; + auto list = ::FindWindowExW(sw, nullptr, Windows::WC_MSTaskListWClass, nullptr); + if(!list) + return false; + if(::GetPropW(list, TaskListSubclassProp)) + return true; // already hooked + + auto proc = reinterpret_cast(::GetWindowLongPtrW(list, GWLP_WNDPROC)); + ::SetPropW(list, TaskListSubclassProp, reinterpret_cast(proc)); + ::SetWindowLongPtrW(list, GWLP_WNDPROC, reinterpret_cast(TaskListProc)); + _log.info(L"[BTN] hooked taskbar list hwnd=0x%x tray=0x%x", + (unsigned)(ULONG_PTR)list, (unsigned)(ULONG_PTR)tray); + return true; +} + +// 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; + 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; +} + +// 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]() + { + for(int i = 0; i < 15; ++i) + { + ::Sleep(delay); + if(tasklist_hook_all_now() > 0) + break; + } + _log.info(L"[BTN] tasklist hook ready"); + + for(;;) + { + ::Sleep(3000); + tasklist_hook_all_now(); + } + }).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 +1862,13 @@ STDAPI WINAPI DllGetClassObjectHook(REFCLSID rclsid, _In_ REFIID riid, LPVOID *p #define TPM_SYSMENU 0x0200L bool is_in_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; +// 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); BOOL WINAPI TrackPopupMenuExProc(HMENU hMenu, uint32_t uFlags, int x, int y, HWND hWnd, LPTPMPARAMS lptpm); @@ -1074,6 +2429,11 @@ BOOL APIENTRY DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID) taskbar_t::hook_all(1000); } + else + { + // Win10: install the taskbar button area (MSTaskListWClass) hook. + tasklist_hook_all(1000); + } } else if(!iathook_NtUserTrackPopupMenuEx.install()) { diff --git a/src/dll/src/Parser/IdentHash.h b/src/dll/src/Parser/IdentHash.h index 52576d08..9282b9a1 100644 --- a/src/dll/src/Parser/IdentHash.h +++ b/src/dll/src/Parser/IdentHash.h @@ -169,6 +169,8 @@ 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_TASKBAR_BUTTON_WINDOWS = 0x3D2E0A72U; constexpr auto IDENT_IS_EXPLORER = 0x01C5A551U; constexpr auto IDENT_IS_STARTED = 0x8873A357U; constexpr auto IDENT_IS_NAV = 0x0523A9A5U; @@ -811,6 +813,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..814aeaf9 100644 --- a/src/dll/src/Parser/Verification.cpp +++ b/src/dll/src/Parser/Verification.cpp @@ -1757,6 +1757,8 @@ namespace Nilesoft return error_at(1); 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: @@ -1806,6 +1808,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..9354ee14 100644 --- a/src/shared/FileSystemObjects.h +++ b/src/shared/FileSystemObjects.h @@ -43,6 +43,9 @@ namespace Nilesoft FSO_SIZE, FSO_NAMESPACE_LIBRARIES, + // 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 };