diff --git a/dll/win32/shlwapi/shlwapi.spec b/dll/win32/shlwapi/shlwapi.spec index e8f0aedc2c0..d3666170aaf 100644 --- a/dll/win32/shlwapi/shlwapi.spec +++ b/dll/win32/shlwapi/shlwapi.spec @@ -538,7 +538,7 @@ 538 stdcall -noname IUnknown_QueryServiceForWebBrowserApp(ptr ptr ptr) 539 stub -noname IUnknown_ShowBrowserBar 540 stdcall -noname SHInvokeCommandOnContextMenu(ptr ptr ptr long str) -541 stub -noname SHInvokeCommandsOnContextMenu +541 stdcall -noname SHInvokeCommandsOnContextMenu(ptr ptr ptr long ptr long) 542 stdcall -noname GetUIVersion() 543 stdcall -noname CreateColorSpaceWrapW(ptr) gdi32.CreateColorSpaceW 544 stub -noname QuerySourceCreateFromKey diff --git a/dll/win32/shlwapi/utils.cpp b/dll/win32/shlwapi/utils.cpp index 632848376a0..8f7bb548785 100644 --- a/dll/win32/shlwapi/utils.cpp +++ b/dll/win32/shlwapi/utils.cpp @@ -215,6 +215,107 @@ SHInvokeCommandOnContextMenu( return SHInvokeCommandOnContextMenuEx(hWnd, pUnk, pCM, fCMIC, CMF_EXTENDEDVERBS, pszVerb, NULL); } +static inline BOOL +IsTextAsciiOnly(PCSTR psz) +{ + for (const signed char *pch = (const signed char *)psz; *pch; ++pch) + { + if (*pch < 0) + return FALSE; + } + return TRUE; +} + +/************************************************************************* + * SHInvokeCommandsOnContextMenu [SHLWAPI.541] + */ +EXTERN_C +HRESULT WINAPI +SHInvokeCommandsOnContextMenu( + _In_opt_ HWND hwnd, + _In_opt_ IUnknown *punkSite, + _In_ IContextMenu *pCM, + _In_ DWORD fMask, + _In_reads_opt_(cVerbs) PCSTR *pVerbs, + _In_ UINT cVerbs) +{ + HRESULT hr; + CMINVOKECOMMANDINFOEX ici; + WCHAR szVerbW[MAX_PATH]; + HMENU hMenu = NULL; + UINT iVerb, idDefault = (UINT)-1; + PCSTR pszVerbA = NULL; + + if (!pCM) + return E_INVALIDARG; + + hMenu = CreatePopupMenu(); + if (!hMenu) + return E_OUTOFMEMORY; + + if (punkSite) + IUnknown_SetSite(pCM, punkSite); + + hr = pCM->QueryContextMenu(hMenu, 0, 1, MAXSHORT, (cVerbs ? 0 : CMF_DEFAULTONLY)); + if (FAILED(hr)) + goto Cleanup; + + if (!cVerbs) + { + idDefault = GetMenuDefaultItem(hMenu, FALSE, 0); + if (idDefault != (UINT)-1) + pszVerbA = MAKEINTRESOURCEA(idDefault - 1); + } + + ZeroMemory(&ici, sizeof(ici)); + ici.cbSize = sizeof(ici); + ici.hwnd = hwnd; + ici.nShow = SW_SHOWNORMAL; + + iVerb = 0; + do + { + if (cVerbs) + pszVerbA = pVerbs[iVerb]; + + if (!pszVerbA && idDefault == (UINT)-1) + { + hr = E_FAIL; + break; + } + + ici.fMask = fMask; + ici.lpVerb = pszVerbA; + ici.lpVerbW = NULL; + + if (idDefault == (UINT)-1 && !IS_INTRESOURCE(pszVerbA) && IsTextAsciiOnly(pszVerbA)) + { + size_t ich; + for (ich = 0; pszVerbA[ich] && ich + 1 < _countof(szVerbW); ++ich) + { + szVerbW[ich] = (BYTE)pszVerbA[ich]; + } + szVerbW[ich] = UNICODE_NULL; + + ici.lpVerbW = szVerbW; + ici.fMask |= CMIC_MASK_UNICODE; + } + + hr = pCM->InvokeCommand((LPCMINVOKECOMMANDINFO)&ici); + + if (SUCCEEDED(hr) || hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) + break; + + ++iVerb; + } while (iVerb < cVerbs); + +Cleanup: + if (punkSite) + IUnknown_SetSite(pCM, NULL); + DestroyMenu(hMenu); + return hr; +} + /************************************************************************* * SHInvokeCommandWithFlagsAndSite [SHLWAPI.571] */ diff --git a/modules/rostests/apitests/shlwapi/CMakeLists.txt b/modules/rostests/apitests/shlwapi/CMakeLists.txt index 9de8e484547..a6c5aea4c1d 100644 --- a/modules/rostests/apitests/shlwapi/CMakeLists.txt +++ b/modules/rostests/apitests/shlwapi/CMakeLists.txt @@ -19,6 +19,7 @@ list(APPEND SOURCE PathUnExpandEnvStringsForUser.c SHAreIconsEqual.c SHGetRestriction.c + SHInvokeCommandsOnContextMenu.cpp SHLoadIndirectString.c SHLoadRegUIString.c SHPropertyBag.cpp diff --git a/modules/rostests/apitests/shlwapi/SHInvokeCommandsOnContextMenu.cpp b/modules/rostests/apitests/shlwapi/SHInvokeCommandsOnContextMenu.cpp new file mode 100644 index 00000000000..527503e9410 --- /dev/null +++ b/modules/rostests/apitests/shlwapi/SHInvokeCommandsOnContextMenu.cpp @@ -0,0 +1,318 @@ +/* + * PROJECT: ReactOS api tests + * LICENSE: GPL-2.0-or-later (https://spdx.org/licenses/GPL-2.0-or-later) + * PURPOSE: Tests for SHInvokeCommandsOnContextMenu + * COPYRIGHT: Copyright 2026 Katayama Hirofumi MZ + */ + +#include +#include +#include +#include +#include +#include + +typedef HRESULT (WINAPI *FN_SHInvokeCommandsOnContextMenu)(HWND, IUnknown *, IContextMenu *, + DWORD, PCSTR *, UINT); + +static FN_SHInvokeCommandsOnContextMenu g_fnSHInvokeCommandsOnContextMenu = NULL; + +struct MockContextMenu : public IContextMenu +{ + HRESULT queryResult = S_OK; + HRESULT invokeResult = S_OK; + UINT defaultItemId = (UINT)-1; + + INT queryCalled = 0; + INT invokeCalled = 0; + DWORD lastQueryFlags = 0; + + CHAR lastVerb[MAX_PATH] = {}; + WCHAR lastVerbW[MAX_PATH] = {}; + DWORD lastFMask = 0; + BOOL lastHadUnicode = FALSE; + + STDMETHODIMP_(ULONG) AddRef() override { return 1; } + STDMETHODIMP_(ULONG) Release() override { return 1; } + STDMETHODIMP QueryInterface(REFIID, void**) override + { + return E_NOINTERFACE; + } + + STDMETHODIMP + QueryContextMenu(HMENU hmenu, UINT, UINT idCmdFirst, UINT, UINT uFlags) override + { + ++queryCalled; + lastQueryFlags = uFlags; + + if (FAILED(queryResult)) + return queryResult; + + if (defaultItemId != (UINT)-1) + { + AppendMenuA(hmenu, MF_STRING, idCmdFirst + defaultItemId - 1, "Item"); + SetMenuDefaultItem(hmenu, idCmdFirst + defaultItemId - 1, FALSE); + } + return queryResult; + } + + STDMETHODIMP InvokeCommand(LPCMINVOKECOMMANDINFO pici) override + { + ++invokeCalled; + const CMINVOKECOMMANDINFOEX* iciex = reinterpret_cast(pici); + + lastFMask = iciex->fMask; + lastHadUnicode = !!(iciex->fMask & CMIC_MASK_UNICODE); + + if (HIWORD(iciex->lpVerb)) + lstrcpyA(lastVerb, iciex->lpVerb); + else + lastVerb[0] = ANSI_NULL; + + if (lastHadUnicode && iciex->lpVerbW && HIWORD(iciex->lpVerbW)) + lstrcpyW(lastVerbW, iciex->lpVerbW); + else + lastVerbW[0] = UNICODE_NULL; + + return invokeResult; + } + + STDMETHODIMP GetCommandString(UINT_PTR, UINT, UINT*, CHAR*, UINT) override + { + return E_NOTIMPL; + } +}; + +struct MockSite : public IUnknown +{ + STDMETHODIMP_(ULONG) AddRef() override { return 1; } + STDMETHODIMP_(ULONG) Release() override { return 1; } + STDMETHODIMP QueryInterface(REFIID, void**) override + { + return E_NOINTERFACE; + } +}; + +class SHInvokeCommandsOnContextMenuTest : public MockContextMenu +{ +protected: + MockSite site; + +public: + SHInvokeCommandsOnContextMenuTest() + { + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + } + ~SHInvokeCommandsOnContextMenuTest() + { + CoUninitialize(); + } +}; + +static void ZeroVerbs_NoDefaultItem_ReturnsEFail(void) +{ + SHInvokeCommandsOnContextMenuTest cm; + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, NULL, 0); + ok_hr(hr, E_FAIL); + ok_int(cm.queryCalled, 1); + ok_int(cm.invokeCalled, 0); +} + +static void ZeroVerbs_WithDefaultItem_InvokesOnce(void) +{ + SHInvokeCommandsOnContextMenuTest cm; + cm.defaultItemId = 1; + cm.invokeResult = S_OK; + + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, NULL, 0); + ok_hr(hr, S_OK); + ok_int(cm.queryCalled, 1); + ok_int(cm.invokeCalled, 1); +} + +static void ZeroVerbs_QueryReceivesCMF_DEFAULTONLY(void) +{ + SHInvokeCommandsOnContextMenuTest cm; + cm.defaultItemId = 1; + g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, NULL, 0); + ok_int(!!(cm.lastQueryFlags & CMF_DEFAULTONLY), TRUE); +} + +static void NonZeroVerbs_QueryDoesNotReceiveCMF_DEFAULTONLY(void) +{ + const char* verbs[] = { "open" }; + SHInvokeCommandsOnContextMenuTest cm; + g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 1); + ok_int(!!(cm.lastQueryFlags & CMF_DEFAULTONLY), FALSE); +} + +static void SingleVerb_Success(void) +{ + const char* verbs[] = { "open" }; + SHInvokeCommandsOnContextMenuTest cm; + cm.invokeResult = S_OK; + + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 1); + ok_hr(hr, S_OK); + ok_int(cm.invokeCalled, 1); + ok_str(cm.lastVerb, "open"); +} + +static void MultipleVerbs_StopsOnFirstSuccess(void) +{ + const char* verbs[] = { "open", "print" }; + SHInvokeCommandsOnContextMenuTest cm; + cm.invokeResult = S_OK; + + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 2); + ok_hr(hr, S_OK); + ok_int(cm.invokeCalled, 1); + ok_str(cm.lastVerb, "open"); +} + +static void Cancelled_BreaksLoop(void) +{ + const char* verbs[] = { "open", "print" }; + SHInvokeCommandsOnContextMenuTest cm; + cm.invokeResult = HRESULT_FROM_WIN32(ERROR_CANCELLED); + + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 2); + ok_hr(hr, HRESULT_FROM_WIN32(ERROR_CANCELLED)); + ok_int(cm.invokeCalled, 1); +} + +static void AsciiVerb_SetsUnicodeMaskAndVerbW(void) +{ + const char* verbs[] = { "open" }; + SHInvokeCommandsOnContextMenuTest cm; + cm.invokeResult = S_OK; + + g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 1); + ok_int(!!cm.lastHadUnicode, TRUE); + ok_wstr(cm.lastVerbW, L"open"); +} + +static void FMask_IsPassedToInvokeCommand(void) +{ + const DWORD kMask = SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + const char* verbs[] = { "open" }; + SHInvokeCommandsOnContextMenuTest cm; + cm.invokeResult = S_OK; + + g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, kMask, verbs, 1); + ok_int(!!(cm.lastFMask & kMask), TRUE); +} + +struct MockSiteTarget : public IContextMenu, public IObjectWithSite +{ + IUnknown* site = NULL; + BOOL siteSet = FALSE; + BOOL siteClear= FALSE; + INT queryCnt = 0; + + // IUnknown + STDMETHODIMP_(ULONG) AddRef() override { return 1; } + STDMETHODIMP_(ULONG) Release() override { return 1; } + STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override + { + if (riid == IID_IObjectWithSite) { *ppv = static_cast(this); return S_OK; } + return E_NOINTERFACE; + } + + // IObjectWithSite + STDMETHODIMP SetSite(IUnknown* pSite) override + { + if (pSite) siteSet = true; + else siteClear= true; + site = pSite; + return S_OK; + } + STDMETHODIMP GetSite(REFIID, void**) override { return E_NOTIMPL; } + + // IContextMenu + STDMETHODIMP QueryContextMenu(HMENU, UINT, UINT, UINT, UINT) override + { + ++queryCnt; + return S_OK; + } + STDMETHODIMP InvokeCommand(LPCMINVOKECOMMANDINFO) override { return E_FAIL; } + STDMETHODIMP GetCommandString(UINT_PTR, UINT, UINT*, CHAR*, UINT) override { return E_NOTIMPL; } +}; + +static void PunkSite_SetAndClearedAroundCall(void) +{ + MockSiteTarget target; + MockSite mySite; + + const char* verbs[] = { "open" }; + g_fnSHInvokeCommandsOnContextMenu(NULL, &mySite, &target, 0, verbs, 1); + + ok_int(target.siteSet, TRUE); + ok_int(target.siteClear, TRUE); +} + +static void QueryFails_InvokeNotCalled(void) +{ + SHInvokeCommandsOnContextMenuTest cm; + cm.queryResult = E_FAIL; + cm.invokeResult = S_OK; + const char* verbs[] = { "open" }; + + HRESULT hr = g_fnSHInvokeCommandsOnContextMenu(NULL, NULL, &cm, 0, verbs, 1); + ok_int(FAILED(hr), TRUE); + ok_int(cm.invokeCalled, 0); +} + +struct HwndCaptureMock : public MockContextMenu +{ + HWND capturedHwnd = NULL; + STDMETHODIMP InvokeCommand(LPCMINVOKECOMMANDINFO pici) override + { + capturedHwnd = pici->hwnd; + return S_OK; + } +}; + +static void Hwnd_IsPassedToInvokeCommand(void) +{ + HwndCaptureMock mock; + const char* verbs[] = { "open" }; + HWND fakeHwnd = reinterpret_cast(static_cast(0xDEADBEEF)); + + g_fnSHInvokeCommandsOnContextMenu(fakeHwnd, NULL, &mock, 0, verbs, 1); + ok_ptr(mock.capturedHwnd, fakeHwnd); +} + +START_TEST(SHInvokeCommandsOnContextMenu) +{ + HINSTANCE hSHLWAPI = LoadLibraryW(L"shlwapi"); + if (!hSHLWAPI) + { + skip("shlwapi not found\n"); + return; + } + + g_fnSHInvokeCommandsOnContextMenu = + (FN_SHInvokeCommandsOnContextMenu)GetProcAddress(hSHLWAPI, MAKEINTRESOURCEA(541)); + if (!g_fnSHInvokeCommandsOnContextMenu) + { + skip("SHInvokeCommandsOnContextMenu not found\n"); + FreeLibrary(hSHLWAPI); + return; + } + + ZeroVerbs_NoDefaultItem_ReturnsEFail(); + ZeroVerbs_WithDefaultItem_InvokesOnce(); + ZeroVerbs_QueryReceivesCMF_DEFAULTONLY(); + NonZeroVerbs_QueryDoesNotReceiveCMF_DEFAULTONLY(); + SingleVerb_Success(); + MultipleVerbs_StopsOnFirstSuccess(); + Cancelled_BreaksLoop(); + AsciiVerb_SetsUnicodeMaskAndVerbW(); + FMask_IsPassedToInvokeCommand(); + PunkSite_SetAndClearedAroundCall(); + QueryFails_InvokeNotCalled(); + Hwnd_IsPassedToInvokeCommand(); + + FreeLibrary(hSHLWAPI); +} diff --git a/modules/rostests/apitests/shlwapi/testlist.c b/modules/rostests/apitests/shlwapi/testlist.c index aafec585617..4f9738baff2 100644 --- a/modules/rostests/apitests/shlwapi/testlist.c +++ b/modules/rostests/apitests/shlwapi/testlist.c @@ -15,6 +15,7 @@ extern void func_PathUnExpandEnvStrings(void); extern void func_PathUnExpandEnvStringsForUser(void); extern void func_SHAreIconsEqual(void); extern void func_SHGetRestriction(void); +extern void func_SHInvokeCommandsOnContextMenu(void); extern void func_SHLoadIndirectString(void); extern void func_SHLoadRegUIString(void); extern void func_SHPropertyBag(void); @@ -38,6 +39,7 @@ const struct test winetest_testlist[] = { "PathUnExpandEnvStringsForUser", func_PathUnExpandEnvStringsForUser }, { "SHAreIconsEqual", func_SHAreIconsEqual }, { "SHGetRestriction", func_SHGetRestriction }, + { "SHInvokeCommandsOnContextMenu", func_SHInvokeCommandsOnContextMenu }, { "SHLoadIndirectString", func_SHLoadIndirectString }, { "SHLoadRegUIString", func_SHLoadRegUIString }, { "SHPropertyBag", func_SHPropertyBag }, diff --git a/sdk/include/reactos/shlwapi_undoc.h b/sdk/include/reactos/shlwapi_undoc.h index 15ce4726bf7..f0f2f383a7f 100644 --- a/sdk/include/reactos/shlwapi_undoc.h +++ b/sdk/include/reactos/shlwapi_undoc.h @@ -110,6 +110,15 @@ HRESULT WINAPI SHIsExpandableFolder(LPSHELLFOLDER lpFolder, LPCITEMIDLIST pidl); DWORD WINAPI SHFillRectClr(HDC hDC, LPCRECT pRect, COLORREF cRef); int WINAPI SHSearchMapInt(const int *lpKeys, const int *lpValues, int iLen, int iKey); +HRESULT WINAPI +SHInvokeCommandsOnContextMenu( + _In_opt_ HWND hwnd, + _In_opt_ IUnknown *punkSite, + _In_ IContextMenu *pCM, + _In_ DWORD fMask, + _In_reads_opt_(cVerbs) PCSTR *pVerbs, + _In_ UINT cVerbs); + HRESULT WINAPI MayQSForward( _In_ IUnknown *lpUnknown,