带键盘钩子的 Shell 扩展






3.83/5 (5投票s)
带键盘钩子的 Shell 扩展
引言
不久前,我发现Windows有一个令我不满意的地方。一次按键就可以将文件移动到的唯一目录是“回收站”。于是我决定通过将F12映射到一个智能移动功能来修复这个问题,该功能会根据文件的扩展名将其移动到“文档”、“图片”、“音乐”或“视频”文件夹。在此过程中,我发现向Windows Explorer添加键盘钩子远非易事。这其中有充分的理由:除非你真的有很好的用例,否则你不应该这样做。我发布这篇文章是为了帮助那些试图找出如何在Windows Explorer中挂钩键盘事件的人。这个示例涵盖了我的Smart Move应用程序,它将F12映射到这个移动功能。我必须警告你,这个项目是Vista特定的。由于缺少依赖项,它在XP上将无法注册。我将在适当的地方提供如何修改它以在XP(及更早版本)上工作的指针。
免责声明
我并非以开发人员为生。我是一名C#开发人员,有大量的VB背景。因此,我可能无法提供最干净的代码/实现。我对字符串处理特别担心,因为C++中有太多字符串类型。我没有使用不安全函数,并且正确地释放了内存,所以我认为应该没问题……但是请注意,本文的重点是键盘钩子,其余部分是可选的。我使用了大量的外部信息来构建这个。我将提到一些网站,例如MSDN网站,以及我从朋友那里借来的一本Dino Esposito的书(虽然旧但仍然有用)。Shell Extensions
在Visual Studio中编写Shell Extension很容易。您必须打开一个新的ATL项目,然后添加一个ATL Simple Object类型的类。该类将按原样配置,除了IObjectWithSite复选框,您必须选中它。我将我的类命名为ShellExtension,创建了一个IShellExtension接口和一个CShellExtension类。
那么,我们先从钩子开始。
由于您添加了IObjectWithSite接口,您的对象将被“设置站点”。在这个示例中,这意味着如果存在,将调用您的SetSite方法。在ShellExtension.h文件中,我有两个公共方法来实现这一点。
ShellExtension.h文件中的公共部分如下所示:
public:
STDMETHOD(SubclassExplorer) (bool SubClass);
STDMETHOD(SetSite) (IUnknown *pUnkSite);
我还有几个私有方法和2个私有变量。我稍后会讲到它们。
private:
bool m_Subclassed;
static BOOL CALLBACK WndEnumProc(HWND, LPARAM);
static LRESULT CALLBACK KeyboardProc(int, WPARAM, LPARAM);
static LRESULT CALLBACK NewExplorerWndProc(HWND, UINT, WPARAM, LPARAM);
static VOID MoveSelectedFiles();
static BOOL FindIShellView(HWND, IShellView**);
static void AddFileToArray(LPCWSTR, LPWSTR, IFileOperation*);
m_Subclassed布尔值可以让我知道对象何时被挂接到事件。m_hwndExplorer是正在监听事件的Explorer的HWND。
SetSite方法如下所示:
STDMETHODIMP CShellExtension::SetSite(IUnknown *pUnkSite)
{
HRESULT hr = SubclassExplorer(true);
if (SUCCEEDED(hr))
m_Subclassed = true;
return S_OK;
}
调用SubclassExplorer,实际的钩子将在这里发生。该类的析构函数(也必须在ShellExplorer.h中声明)几乎相同,但它会指示SubclassExplorer进行取消挂钩。它如下所示:
CShellExtension::~CShellExtension()
{
if (m_Subclassed)
{
SubclassExplorer(false);
m_Subclassed = false;
}
}
注册
如果您希望Explorer打开它,您必须在HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Browser Helper Objects下注册代码。要将此注册添加到CShellExtension类的COM自注册中,请在ATLSmartMove.rgs(其中ATLSmartMove是您的项目名称)中添加以下内容:
HKLM {
NoRemove SOFTWARE {
NoRemove Microsoft {
NoRemove Windows {
NoRemove CurrentVersion {
NoRemove Explorer {
NoRemove 'Browser Helper Objects' {
ForceRemove '{6274E69B-9A6C-4818-97BA-123D645719C8}' = s 'SmartMove'
}
}
}
}
}
}
}
GUID是您库的ID(您可以在项目中的ATLSmartMove.idl中找到它)。
钩子
那么SubclassExplorer看起来是什么样的?我将逐行解释。
首先是头部:
STDMETHODIMP CShellExtension::SubclassExplorer(bool bSubclass){
我接收一个布尔值,告诉我是否挂钩(如果为true)或取消挂钩(如果为false)事件。
继续挂钩,我有两个if条件。在第一个中,如果我正在进行子类化(并且尚未进行子类化),我将设置实际的钩子。 if (bSubclass && !m_Subclassed)
{
g_hHook = SetWindowsHookEx(WH_KEYBOARD, KeyboardProc, NULL, GetCurrentThreadId());
}
我在NewExplorerWndProc方法上挂钩,并在KeyboardProc中接收键盘事件。取消挂钩很简单。
if (!bSubclass && m_Subclassed)
{
UnhookWindowsHookEx(g_hHook);
}
返回S_OK(因为一切都已完成),然后完成挂钩!
如果您只对钩子感兴趣,那么就是这样。如果您对F12代码感兴趣,请再和我待一会儿。
获取键盘事件
正如我告诉您的,键盘事件现在被分派到KeyboardProc。KeyboardProc的代码非常简单。如果事件是我的,我就会处理它。如果不是,我就会分派它。我还预先分派了一些条件。这段代码将在任何Windows Explorer窗口(几乎在Windows的任何地方)的每次按键时运行。我希望它运行得很快。此外,代码实际上会调用两次:一次用于按键,一次用于抬起。与lParam的奇怪标志比较是基于这个条件。NEWFOLDERKEY是一个映射到VK_F12的常量。KeyboardProc是一个静态方法(使用SetWindowsHookEx挂钩非静态方法没有简单的方法),所以我不能在这里或在调用的方法中使用实例变量。代码如下所示:
LRESULT CALLBACK CShellExtension::KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode < 0)
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
if ((lParam & 0x80000000) || (lParam & 0x40000000))
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
if (wParam == NEWFOLDERKEY)
{
MoveSelectedFiles();
}
return CallNextHookEx(g_hHook, nCode, wParam, lParam);
}
移动选定的文件
移动文件实际上很简单。难的是知道哪些文件被选中。下面是方法头部的样子:
void CShellExtension::MoveSelectedFiles()
{
虽然我挂钩到了Windows Explorer窗口,但我并不是作为一个常规的Shell Extension在进程中。例如,在上下文菜单中,当代码调用您时,您会获得有关已选内容的信息。在这里不是这样。幸运的是,即使从进程外部,您始终可以知道Windows Explorer窗口中选中了什么。您只需要它的HWND。
接下来的几行用于查找当前explorer实例的hwnd。由于我已设置站点,所以我运行在与explorer相同的线程上。因此,要找到hwnd,我将遍历线程中的每个窗口并寻找级别最高的那个。它看起来像这样:
EnumThreadWindows(GetCurrentThreadId(), WndEnumProc, reinterpret_cast<LPARAM>(&m_hwndExplorer));
if (!IsWindow(m_hwndExplorer))
{
return E_FAIL;
}
else
g_hwndExplorer = m_hwndExplorer;
该调用会将线程中的每个窗口发送到WndEnumProc(我们的私有方法之一),并在WndEnumProc返回FALSE时停止。WndEnumProc查找CabinetWClass
,这是Vista中级别最高的窗口。如果您想将此代码移植到旧操作系统,则应查找ExploreWClass
。WndEnumProc非常简单,如下所示:
BOOL CALLBACK CShellExtension::WndEnumProc(HWND hwnd, LPARAM lParam)
{
TCHAR szClassName[MAX_PATH] = {0};
GetClassName(hwnd, szClassName, MAX_PATH);
if (!lstrcmpi(szClassName, __TEXT("CabinetWClass")))
{
HWND* phWnd = reinterpret_cast<HWND*>(lParam);
*phWnd = hwnd;
return FALSE;
}
return TRUE;
}
我还需要获取所有通用文件夹(文档、图片、视频和音乐)的路径。我认为并非所有这些文件夹常量在Vista之前都可用,因此如果您移植到XP,请检查哪些可用。代码如下所示:
SHGetKnownFolderPath(FOLDERID_Documents, 0, NULL, ppszDocumentsPath);
SHGetKnownFolderPath(FOLDERID_Music, 0, NULL, ppszMusicPath);
SHGetKnownFolderPath(FOLDERID_Pictures, 0, NULL, ppszPicturesPath);
SHGetKnownFolderPath(FOLDERID_Videos, 0, NULL, ppszVideosPath);
接下来,我将向您展示如何从Explorer窗口的HWND获取IShellView。我从Raymond Chen的博客中获取了它。我理解它的作用,但我自己永远无法猜测出来。所以,就这样吧:BOOL CShellExtension::FindIShellView(HWND hwnd, IShellView** psv) { BOOL fFound = FALSE; IShellWindows *psw; if (SUCCEEDED(CoCreateInstance(CLSID_ShellWindows, NULL, CLSCTX_ALL, IID_IShellWindows, (void**)&psw))) { VARIANT v; V_VT(&v) = VT_I4; IDispatch *pdisp; for (V_I4(&v) = 0; !fFound && psw->Item(v, &pdisp) == S_OK; V_I4(&v)++) { IWebBrowserApp *pwba; if (SUCCEEDED(pdisp->QueryInterface(IID_IWebBrowserApp, (void**)&pwba))) { HWND hwndWBA; if (SUCCEEDED(pwba->get_HWND((LONG_PTR*)&hwndWBA)) && hwndWBA == hwnd) { IServiceProvider *psp; if (SUCCEEDED(pwba->QueryInterface(IID_IServiceProvider, (void**)&psp))) { IShellBrowser *psb; if (SUCCEEDED(psp->QueryService(SID_STopLevelBrowser, IID_IShellBrowser, (void**)&psb))) { if (SUCCEEDED(psb->QueryActiveShellView(psv))) { fFound = TRUE; } psb->Release(); } psp->Release(); } } pwba->Release(); } pdisp->Release(); } psw->Release(); } return fFound; }
很酷,是吧?那么我们继续。IShellView将为您提供所选文件的列表。
接下来的代码是我从这里获取的。它是MoveSelectedFiles的一部分,所以我们来看看:
IShellView* psv;
if (FindIShellView(g_hwndExplorer, &psv))
{
CComPtr<IDataObject> spDataObject;
if (SUCCEEDED(psv->GetItemObject(SVGIO_SELECTION,
IID_PPV_ARGS(&spDataObject))))
{
FORMATETC fmt = { CF_HDROP, NULL, DVASPECT_CONTENT,
-1, TYMED_HGLOBAL };
STGMEDIUM stg;
stg.tymed = TYMED_HGLOBAL;
if (SUCCEEDED(spDataObject->GetData(&fmt, &stg)))
{
HDROP hDrop = (HDROP) GlobalLock ( stg.hGlobal );
UINT uNumFiles = DragQueryFile ( hDrop, 0xFFFFFFFF, NULL, 0 );
HRESULT hr = S_OK;
IFileOperation *pfo;
hr = CoCreateInstance(CLSID_FileOperation,
NULL,
CLSCTX_ALL,
IID_PPV_ARGS(&pfo));
pfo->SetOperationFlags(FOFX_SHOWELEVATIONPROMPT);
在第一部分中,我从窗口的hwnd找到IShellView,并使用它来获取选定的文件(这就是上面SVGIO_SELECTION常量所代表的含义)。DragQueryFiles会告诉我文件的名称。我还创建了一个FileOperation对象(强制转换为IFileOperation接口)。这是一个Vista特定的类,它允许我将文件从一个地方移动到另一个地方。与之类似的早期版本是SHFileOperation函数,但IFileOperation要酷得多。您可以在这里获得更多信息。
接下来,我遍历文件,将它们添加到集合中:
for(UINT i = 0; i < uNumFiles; i++)
{
TCHAR szPath[MAX_PATH];
szPath[0] = 0;
DragQueryFile(hDrop, i, szPath, MAX_PATH);
if (szPath[0] != 0)
if (!(PathIsDirectory(szPath) || PathIsRoot(szPath)))
{
if (!PathMatchSpecEx(szPath, GRAPHICFILES, PMSF_MULTIPLE))
AddFileToArray(szPath, *ppszPicturesPath, pfo);
else if (!PathMatchSpecEx(szPath, VIDEOFILES, PMSF_MULTIPLE))
AddFileToArray(szPath, *ppszVideosPath, pfo);
else if (!PathMatchSpecEx(szPath, MUSICFILES, PMSF_MULTIPLE))
AddFileToArray(szPath, *ppszMusicPath, pfo);
else
AddFileToArray(szPath, *ppszDocumentsPath, pfo);
}
}
我忽略了目录和根路径。此外,我检查扩展名以确定将其放在哪里。PathMatchSpecEx告诉我szPath是否匹配通配符匹配(*.jpg、*.bmp等)。GRAPHICFILES、VIDEOFILES和MUSICFILES是我硬编码的特定文件类型的常量。
const TCHAR GRAPHICFILES[49] = TEXT("*.png;*.bmp;*.jpg;*.gif;*.jpeg;*.pcd;*.pcx;*.svg");
const TCHAR VIDEOFILES[87] = TEXT("*.avi;*.mpeg;*.mpg;*.flv;*.swf;*.fla;*.wmv;*.3gp;*.divx;*.rm;*.rmvb;*.srt;*.xvid;*.vid");
const TCHAR MUSICFILES[124] = TEXT("*.aac;*.aif;*.aiff;*.aud;*.m3u;*.mid;*.midi;*.mp1;*.mp2;*.mp3;*.mpa;*.mpga;;*.ogg;*.omf;*.omg;*.ra;*.r1m;*.wav;*.wave;*.wma");
AddFileToArray将移动操作添加到IFileOperation。它从源路径和目标路径创建两个IShellItem对象。它很简单,而且工作方式如下:
void CShellExtension::AddFileToArray(LPCWSTR szPath, LPWSTR pszDest, IFileOperation* pfo)
{
IShellItem *psiFrom = NULL;
int hr = SHCreateItemFromParsingName(szPath, NULL, IID_PPV_ARGS(&psiFrom));
if (SUCCEEDED(hr))
{
IShellItem *psiTo = NULL;
if (NULL != pszDest)
hr = SHCreateItemFromParsingName(pszDest, NULL, IID_PPV_ARGS(&psiTo));
if (SUCCEEDED(hr))
{
hr = pfo->MoveItem(psiFrom, psiTo, NULL, NULL);
if (NULL != psiTo)
psiTo->Release();
}
psiFrom->Release();
}
}
最后,我调用FileOperations类中的PerformOperations()方法,然后进行一些清理:
pfo->PerformOperations();
pfo->Release();
GlobalUnlock ( stg.hGlobal );
ReleaseStgMedium ( &stg );
}
}
psv->Release();
}
CoTaskMemFree(*ppszDocumentsPath);
CoTaskMemFree(*ppszMusicPath);
CoTaskMemFree(*ppszPicturesPath);
CoTaskMemFree(*ppszVideosPath);
}
就是这样!
关注点
所以,您看。Vista以一种非常壮观的方式打破了Shell Extension的兼容性,但它做得非常酷。IFileOperation比SHFileOperation好得多。我来解释一下为什么(从评论上传):
- 在SHFileOperation中,要使用多个项目,您需要使用\0分隔的字符串。这在几方面都很糟糕,最糟糕的是,当您清零内存时,您必须手动找到连接的起始位置(如szMyItems[wcslen(szMyItems) + 1])。在我使用SHFileOperation的第一个版本中,我确实这样做了。这让我不寒而栗。
- IFileOperation是事务性的。也就是说,您可以在一次PerformOperations()调用中放入3个复制操作、4个移动操作、1个删除操作,当您在Windows中点击撤销时,它将撤销所有8个操作。这是我最喜欢的功能。
- IFileOperation更冗长,但也更清晰。SHFileOperation中的相同代码包含两个字符串,这些字符串可能格式错误,并且完全不包含有关它们包含的信息的数量/类型。IShellItem更具表现力:如果您有多个,则使用集合。集合有一个不同的方法。您可以命名每个项目。
- 就功能而言,SHFileOperation在返回错误代码时并不一致(请参阅:http://shellrevealed.com/blogs/shellblog/archive/2006/09/11/Common-Questions-Concerning-the-SHFileOperation-API_3A00_-Part-1.aspx[^])。有几个错误实际上返回S_OK,而您必须在fAnyOperationsAborted中检查它是否真的起作用。IFileOperation在PerformOperations级别返回一个HR,带有S_OK或失败,因此您可以在那里检查。它自己显示错误消息,所以您不必显示它们。事实上,只有当您打算对其进行处理时,才需要获取返回值,这就是我忽略它的原因。
- IFileOperation在Vista中为Elevation提供了一个标志。
- IFileOperation提供了一种将同步钩接到进度的方法,因此您可以以事件驱动的方式知道复制了多少文件,还剩多少文件等。
此外,挂钩Windows Explorer中的键盘事件比挂钩鼠标右键单击要困难得多。KeyboardProc应该是静态的原因是有道理的,因为实际上只有一个进程,但这非常有限。
总而言之,我认为这是一个有趣的小项目,希望您能从中获得一些东西。我可能省略了代码中的细节。附件的源代码是完整的。我的下一个目标是能够部署它,但我迄今为止还不能。
最后,感谢所有我从中获取信息的人!
更新
在使用该扩展一天后,我发现挂钩在一段时间的空闲后会卸载。它不应该,我正在研究原因。结果是,在扩展卸载后关闭explorer窗口会导致您的explorer进程崩溃。我找到修复方法后会尽快发布。
找到了问题:有相当多的静态变量/引用,并且多个Explorer窗口相互干扰。我已更改代码和文章以避免此问题。