65.9K
CodeProject 正在变化。 阅读更多。
Home

带键盘钩子的 Shell 扩展

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.83/5 (5投票s)

2008年8月31日

CPOL

9分钟阅读

viewsIcon

54031

downloadIcon

900

带键盘钩子的 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好得多。我来解释一下为什么(从评论上传):

  1. 在SHFileOperation中,要使用多个项目,您需要使用\0分隔的字符串。这在几方面都很糟糕,最糟糕的是,当您清零内存时,您必须手动找到连接的起始位置(如szMyItems[wcslen(szMyItems) + 1])。在我使用SHFileOperation的第一个版本中,我确实这样做了。这让我不寒而栗。
  2. IFileOperation是事务性的。也就是说,您可以在一次PerformOperations()调用中放入3个复制操作、4个移动操作、1个删除操作,当您在Windows中点击撤销时,它将撤销所有8个操作。这是我最喜欢的功能。
  3. IFileOperation更冗长,但也更清晰。SHFileOperation中的相同代码包含两个字符串,这些字符串可能格式错误,并且完全不包含有关它们包含的信息的数量/类型。IShellItem更具表现力:如果您有多个,则使用集合。集合有一个不同的方法。您可以命名每个项目。
  4. 就功能而言,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或失败,因此您可以在那里检查。它自己显示错误消息,所以您不必显示它们。事实上,只有当您打算对其进行处理时,才需要获取返回值,这就是我忽略它的原因。
  5. IFileOperation在Vista中为Elevation提供了一个标志。
  6. IFileOperation提供了一种将同步钩接到进度的​​方法,因此您可以以事件驱动的方式知道复制了多少文件,还剩多少文件等。

此外,挂钩Windows Explorer中的键盘事件比挂钩鼠标右键单击要困难得多。KeyboardProc应该是静态的原因是有道理的,因为实际上只有一个进程,但这非常有限。

总而言之,我认为这是一个有趣的小项目,希望您能从中获得一些东西。我可能省略了代码中的细节。附件的源代码是完整的。我的下一个目标是能够部署它,但我迄今为止还不能。

最后,感谢所有我从中获取信息的人!

更新

在使用该扩展一天后,我发现挂钩在一段时间的空闲后会卸载。它不应该,我正在研究原因。结果是,在扩展卸载后关闭explorer窗口会导致您的explorer进程崩溃。我找到修复方法后会尽快发布。

找到了问题:有相当多的静态变量/引用,并且多个Explorer窗口相互干扰。我已更改代码和文章以避免此问题。

© . All rights reserved.