v2.7.0: Fix verb truncation, & stripping, atomic SendInput; enhance context menu matching - #4960
v2.7.0: Fix verb truncation, & stripping, atomic SendInput; enhance context menu matching#4960LiHua81 wants to merge 12 commits into
Conversation
…ontext menu matching
|
Thanks for the pull request! This repository uses a two-stage review: an AI review that you run yourself, followed by a human review. To get started, comment See the pull request review process for the full details. |
There was a problem hiding this comment.
Pull request overview
This PR updates the Click on Empty Explorer Windhawk mod to expand configurable click triggers (triple-click and modifier+click), make hotkey injection more reliable by batching SendInput, and add new actions that invoke real Explorer background context menu items (e.g., “Open in VS Code/Terminal/Cursor” or any matched entry).
Changes:
- Add triple-click + Ctrl/Alt/Shift+click triggers with corresponding settings and action dispatch.
- Implement context-menu based launching by enumerating and invoking a matched background context menu entry (with normalization that ignores spaces and
&). - Make hotkey injection “atomic” by sending press+release in a single
SendInputcall.
Suppressed comments (1)
mods/click-on-empty-explorer.wh.cpp:656
- Same issue as above in the diagnostic dump:
MIIM_TYPEis deprecated and not needed to retrievewID/hSubMenu. Dropping it avoids incidental string/type retrieval semantics and keeps the struct usage simpler.
MENUITEMINFOW mii = { sizeof(mii) };
mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU;
if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| MENUITEMINFOW mii = { sizeof(mii) }; | ||
| mii.fMask = MIIM_ID | MIIM_TYPE | MIIM_SUBMENU; | ||
| if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue; |
| @@ -261,8 +463,7 @@ | |||
| Release(vk2); | |||
| if (vk3) Release(vk3); | |||
| Release(vk1); | |||
| SendInput(count / 2, inputs, sizeof(INPUT)); | |||
| SendInput(count / 2, inputs + count / 2, sizeof(INPUT)); | |||
| SendInput(count, inputs, sizeof(INPUT)); // single atomic call | |||
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. The review covers the whole file, not only the diff — several of the items below are inherited from the 2.3.0 base (and from explorer-double-click-up.wh.cpp, which this is based on), but they are real and worth fixing while you're in here. 1. Subclasses installed after init are never recorded, so
if (wcscmp(className, L"SysListView32") == 0) {
WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, SysListViewSubclass, 0);
{ std::lock_guard<std::mutex> lk(g_wrappersMutex);
for (auto& w : g_Wrappers)
if (w.hShellTab == shellTab) { w.hListView = hWnd; break; } // no entry yet
}
}For a freshly opened Explorer window/tab that entry does not exist yet: Repro: enable the mod, open a new Explorer window, disable (or update) the mod, click in that window → the subclass proc is invoked in the unmapped mod image. Only windows that already existed at Fix: track subclassed windows in their own container at the moment you subclass, independent of static std::mutex g_subclassMutex;
static std::vector<std::pair<HWND, bool /*isListView*/>> g_subclassed;
// in CreateWindowExW_hook, after a successful SetWindowSubclassFromAnyThread:
{ std::lock_guard<std::mutex> lk(g_subclassMutex); g_subclassed.push_back({hWnd, true}); }Then in 2. Timer callbacks live in the mod image and can survive the unload.
Two things make it worse:
static VOID CALLBACK DblClickTimerProc(HWND hwnd, UINT, UINT_PTR idEvent, DWORD) {
CHECK_INIT_OR_RETURN_VOID(); // <-- timer is left running
KillTimer(hwnd, idEvent);
3. The auto browser = winrt::com_ptr<IShellBrowser>{
reinterpret_cast<IShellBrowser*>((void*)SendMessage(shellTab, WM_USER + 7, 0, 0)),
winrt::take_ownership_from_abi
};
Fix — drop winrt::com_ptr<IShellBrowser> browser;
browser.copy_from(reinterpret_cast<IShellBrowser*>(
(void*)SendMessage(shellTab, WM_USER + 7, 0, 0)));4. Per-window click state is kept in process-wide globals, but each Explorer window runs on its own thread.
This PR makes it materially worse: There is a functional side to it too: Fix: make this state per-window. 5.
Fix — drop dead/duplicate entries before inserting: std::lock_guard<std::mutex> lock(g_wrappersMutex);
std::erase_if(g_Wrappers, [&](const ExplorerWrapper& w) {
return w.hShellTab == shellTab || !IsWindow(w.hShellTab);
});
g_Wrappers.push_back(ExplorerWrapper(shellTab, pBrowser));Removing the entry on the shell tab's 6. Globals holding COM pointers run their destructors at process shutdown.
Fix — suppress the automatic destructor while keeping the explicit release in // com_ptr is nullable/handle-like, so the bare attribute is fine.
// Released with `g_pendingNavBrowser = nullptr;` in Wh_ModUninit.
[[clang::no_destroy]] static winrt::com_ptr<IShellBrowser> g_pendingNavBrowser;
// Container of thread-affine elements, so use the optional wrapper.
// Released with `g_Wrappers.reset();` in Wh_ModUninit (`.clear()` would keep the buffer).
[[clang::no_destroy]] static std::optional<std::vector<ExplorerWrapper>> g_Wrappers;with 7. The context-menu action runs synchronously inside the mouse-down handler.
Fix: get the work out of the mouse-down. Allocate a private message with 8. Cascading submenus are dropped before the recursion. In if (mii.wID == 0) continue;
if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue;
if (mii.hSubMenu != NULL) { ... recurse ... }A menu item that opens a submenu carries no meaningful command ID — inserted via The guards are only needed for leaf items, so just reorder: if (!GetMenuItemInfoW(hMenu, i, TRUE, &mii)) continue;
if (mii.hSubMenu != NULL) {
if (pcm2)
pcm2->HandleMenuMsg(WM_INITMENUPOPUP, (WPARAM)mii.hSubMenu, MAKELPARAM(i, 0));
if (EnumContextMenuMatch(mii.hSubMenu, pcm, pcm2, hwnd, matchText, idCmdFirst))
return true;
continue;
}
if (mii.wID == 0) continue; // separator
if (mii.wID < (UINT)idCmdFirst || mii.wID > 0x7FFF) continue; // leaf outside the range9. Hardcoded Chinese fallback, and three redundant brand-specific actions. if (InvokeFolderContextMenuVerb(path, hShellTab, L"Terminal")) return;
if (InvokeFolderContextMenuVerb(path, hShellTab, L"终端")) return;Windhawk mods default to English and express other languages through the localization syntax; one hardcoded non-English string means Chinese users are covered and German/Japanese/French/… users are not. Beyond that, Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. Thanks for working through the previous round — the over-release of 1. The subclass tracking fix covers the new-window path but dropped the already-open-window path —
if (lv) {
if (WindhawkUtils::SetWindowSubclassFromAnyThread(lv, SysListViewSubclass, 0)) {
Wh_Log(L"SysListView32 Subclassed %p", lv);
wrapper.hListView = lv; // <-- not added to g_subclassed
}
} else if (dui) {
if (WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, DUISubclass, 0)) {
Wh_Log(L"DirectUIHWND Subclassed %p", hWnd);
wrapper.hListView = hWnd; // <-- not added to g_subclassed
}
}Since Fix — record them at the point of subclassing, same as the hook does: if (lv) {
if (WindhawkUtils::SetWindowSubclassFromAnyThread(lv, SysListViewSubclass, 0)) {
wrapper.hListView = lv;
std::lock_guard<std::mutex> lk(g_subclassMutex);
g_subclassed.push_back({ lv, true });
}
} else if (dui) {
if (WindhawkUtils::SetWindowSubclassFromAnyThread(hWnd, DUISubclass, 0)) {
wrapper.hListView = hWnd;
std::lock_guard<std::mutex> lk(g_subclassMutex);
g_subclassed.push_back({ hWnd, false });
}
}2. Making the pending-click state
void Wh_ModUninit() {
InterlockedExchange(&g_initialized, 0);
CancelPendingMidClick(); // reads this thread's TLS -> 0/NULL, kills nothing
CancelPendingDblClick(); // same
if (g_pendingNavHwnd && IsWindow(g_pendingNavHwnd)) // always NULL here
KillTimer(g_pendingNavHwnd, 0x4D43);None of the three timers ( Fix — don't put a mod-image for (auto& e : toRemove) {
if (e.hWnd && IsWindow(e.hWnd)) {
KillTimer(e.hWnd, 0x4D44);
KillTimer(e.hWnd, 0x4D45);
WindhawkUtils::RemoveWindowSubclassFromAnyThread(
e.hWnd, e.isListView ? SysListViewSubclass : DUISubclass);
}
}file-explorer-details-autofit-columns.wh.cpp is a close model: Note the duplicate-tab timer Same root cause, smaller consequence: 3. Two separate problems in the same place. (a) std::lock_guard<std::mutex> lock(g_wrappersMutex);
for (ExplorerWrapper& w : *g_Wrappers) { // UB once Wh_ModUninit has done g_Wrappers.reset()
(b) The clean way out is to not cache the browser at all: Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. Three issues worth fixing before merge; the rest is optional. 1. The subclass procs swallow every In both if (uMsg == WM_TIMER) {
switch (wParam) {
case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); break;
case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); break;
case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); break;
}
return 0; // <-- also eats the window's own timers
}
Only consume the IDs you own: if (uMsg == WM_TIMER) {
switch (wParam) {
case 0x4D43: NavigateNewTabProc(hWnd, uMsg, wParam, dwRefData); return 0;
case 0x4D44: MidClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0;
case 0x4D45: DblClickTimerProc(hWnd, uMsg, wParam, dwRefData); return 0;
}
return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}Related: since you're setting timers on windows the mod doesn't own, hard-coded IDs ( 2. Injected key combos fire while the user is still physically holding the modifier.
Concrete case: Shift+Click → New Tab sends Before injecting, release whichever of static void ReleaseHeldModifiers(std::vector<INPUT>& pre, std::vector<INPUT>& post,
const std::vector<WORD>& combo) {
for (WORD vk : {VK_CONTROL, VK_MENU, VK_SHIFT, VK_LWIN}) {
if (!(GetKeyState(vk) & 0x8000)) continue;
if (std::find(combo.begin(), combo.end(), vk) != combo.end()) continue;
pre.push_back({INPUT_KEYBOARD, {.ki = {.wVk = vk, .dwFlags = KEYEVENTF_KEYUP}}});
post.push_back({INPUT_KEYBOARD, {.ki = {.wVk = vk}}});
}
}Alternatively, defer the injected-key actions until the modifier is released (e.g. act on 3. Only the first tab of an already-open Explorer window is hooked at enable time.
HWND shellTab = FindWindowEx(hWnd, NULL, L"ShellTabWindowClass", NULL);
if (shellTab != NULL)
EnumChildWindows(shellTab, InitEnumChildWindowsProc, (LPARAM)shellTab);On Windows 11 each tab is its own for (HWND shellTab = FindWindowEx(hWnd, NULL, L"ShellTabWindowClass", NULL);
shellTab;
shellTab = FindWindowEx(hWnd, shellTab, L"ShellTabWindowClass", NULL)) {
EnumChildWindows(shellTab, InitEnumChildWindowsProc, (LPARAM)shellTab);
}Optional improvements
Minor polish — none of this affects users in normal operation, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. Nice cleanup overall — switching the 1. Triple Click Action never fires unless Double Click Action is also set. The pending-double-click timer (
Since the third click is only recognized by } else if (uMsg == WM_LBUTTONDBLCLK) {
bool dblOn = (wcscmp(s.doubleClick.c_str(), L"none") != 0);
bool tripleOn = (wcscmp(s.tripleClick.c_str(), L"none") != 0);
if (!dblOn && !tripleOn)
return DefSubclassProc(hWnd, uMsg, wParam, lParam);
/* ...hit test... */
if (tripleOn) {
CancelPendingDblClick();
g_pendingDblClickHwnd = hWnd;
g_pendingDblClickAction = dblOn ? s.doubleClick : L"";
g_pendingDblClickCombo = s.doubleClickCombo;
g_pendingDblClickTimerId = SetTimer(hWnd, 0x4D45, GetDoubleClickTime(), nullptr);
} else { /* instant double-click */ }
}2. The new "release held modifiers, then restore them" logic in
Use the side-specific VKs so both sides are handled, and put release + combo + restore in a single static void SendKeyCombo(WORD vk1, WORD vk2, WORD vk3 = 0) {
static constexpr WORD kSideMods[] = {VK_LCONTROL, VK_RCONTROL, VK_LMENU,
VK_RMENU, VK_LSHIFT, VK_RSHIFT};
std::vector<INPUT> in;
auto Key = [&](WORD vk, DWORD flags) {
in.push_back(INPUT{INPUT_KEYBOARD, {.ki = {.wVk = vk, .dwFlags = flags}}});
};
std::vector<WORD> held;
for (WORD vk : kSideMods)
if (GetKeyState(vk) & 0x8000) held.push_back(vk);
for (WORD vk : held) Key(vk, KEYEVENTF_KEYUP);
Key(vk1, 0); Key(vk2, 0); if (vk3) Key(vk3, 0);
if (vk3) Key(vk3, KEYEVENTF_KEYUP);
Key(vk2, KEYEVENTF_KEYUP); Key(vk1, KEYEVENTF_KEYUP);
for (WORD vk : held) Key(vk, 0);
SendInput((UINT)in.size(), in.data(), sizeof(INPUT)); // one atomic batch
}3. All four context-menu actions silently do nothing in virtual folders. The path round-trip is also unnecessary work: the mod already holds the IShellView* psv = nullptr;
if (FAILED(hBrowser->QueryActiveShellView(&psv)) || !psv) return false;
IContextMenu* pcm = nullptr;
HRESULT hr = psv->GetItemObject(SVGIO_BACKGROUND, IID_IContextMenu, (void**)&pcm);
psv->Release();
if (FAILED(hr) || !pcm) return false;
// ...QueryContextMenu / EnumContextMenuMatch as today...At minimum, log when the folder path can't be resolved so the failure isn't silent. 4. The three hardcoded Since Optional improvements
Minor polish — none of this affects users, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
…, drop brand actions
|
/ai-review |
|
/ai-reveiw |
|
/ai-reveiw |
|
/ai-review |
Submission reviewNote: This review was done by Claude. Due to the amount of submissions, doing a fully manual review for each pull request is no longer feasible. Thank you for understanding. Remember: The AI reviewer can be wrong - it may misread code, flag correct code as broken, or suggest changes that make things worse. Treat its findings as suggestions to verify, not instructions to follow blindly. You're responsible for the code you submit, so if a finding doesn't hold up, say so instead of changing working code to satisfy it. Please address the following issues. The items in the collapsed sections are optional, so it's your call whether to address them. The new triple-click / modifier+click / context-menu features are a nice addition, but there are a few real problems in the dispatch code. 1.
if (ctrlOn && ctrlDown) {
if (!TryCustomHotkey(s.ctrlClick.c_str(), s.ctrlClickCombo)) // fires with Ctrl downSo 2. Missing braces / duplicated lines in the dispatch blocks Several if (tripleOn && g_pendingDblClickHwnd == hWnd && g_pendingDblClickTimerId != 0) {
CancelPendingDblClick();
if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo))
SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch);
SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch);
PostDoAction(hWnd, s.tripleClick.c_str());
return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}Same shape at lines 1256-1268, 1296-1298, 1316-1320, 1327-1329, 1412-1416, 1468-1475, 1502-1504, 1519-1522. Today the consequence is "only" that a if (!TryCustomHotkey(s.tripleClick.c_str(), s.tripleClickCombo)) {
SetCtxMenuMatch(s.tripleClick.c_str(), g_tripleClickCtxMatch);
PostDoAction(hWnd, s.tripleClick.c_str());
}3. Several per-trigger
The robust fix is to stop using a thread-local side channel and carry the match text with the action: resolve it once at dispatch time and pack both strings into the block 4. One
Make it per-thread. A raw thread_local IUIAutomation* g_threadUiAutomation = nullptr;(Pre-existing in 2.3.0, but the mod is much more multi-window-active now.) 5.
6. A failed match walks and re-populates the whole context menu twice
Collect the per-item dump strings during the single Optional improvements
Minor polish — none of this affects users in normal operation, so it's your call.
Functionality notes
Non-critical observations and ideas about the feature behavior itself.
|
Summary
v2.7.0 update for Click on Empty Explorer (#4500).
Bugfixes
GCS_VERB→GCS_VERBA— Unicode build was writing wide chars intoCHAR*buffer, truncating all verbs to 1 characterStrContainsNormnow strips&so "Git Bash" matches "Open Git Ba&sh here"Improvements
CopySettings()for unhandled messages→ match:)