第 ① 章 · 右键菜单扩展(IContextMenu)

用一个最小的进程内 COM DLL 走通 Shell 扩展的地基——IShellExtInit 拿文件、IContextMenu 加菜单并执行、注册表挂载,以及 Win11 的「显示更多选项」。

作者 David更新于

第一个例子的目标不是炫技,而是把地基走通:一个进程内 COM DLL,右键任意文件时弹出「显示文件信息」,点一下用消息框显示该文件的路径和大小。后面每一章都建立在这套骨架上。

配套代码:01-context-menu(CMake 工程,clone 即可构建)。

它由哪些部分组成

一个能被资源管理器加载的右键菜单扩展,至少要凑齐三样东西:

  1. 一个 COM 对象:有唯一 CLSID,实现 IUnknown 和一个类工厂;
  2. 两个 Shell 接口:
    • IShellExtInit —— 系统借它把「你右键了哪些文件」交给你;
    • IContextMenu —— 你借它往菜单里加项、并在用户点击时干活;
  3. 注册表登记:让系统知道「右键某类文件时该加载哪个 DLL」。

进程内扩展被加载进资源管理器进程里运行。所以第一条铁律:在虚拟机里开发调试。你的代码一崩,倒下的是整个桌面。

COM/DLL 的四个出口

一个 COM 进程内服务器 DLL 必须导出四个标准函数(见 dllmain.cpp 与 ContextMenuExt.def):

导出函数作用
DllGetClassObjectCOM 向你要「类工厂」,好用它造对象
DllCanUnloadNow没有存活对象时返回 S_OK,允许系统卸载 DLL
DllRegisterServer / DllUnregisterServer自注册/反注册(regsvr32 调它们写/删注册表)

配合一个 DLL 级引用计数 g_cDllRef:每造一个对象就 +1,析构就 -1;只要它非零,DllCanUnloadNow 就返回 S_FALSE,系统便不会把正在被用的 DLL 卸载掉。

dllmain.cpp(节选)
STDAPI DllGetClassObject(REFCLSID rclsid, REFIID riid, void** ppv) {
    if (rclsid != CLSID_FileInfoContextMenu)
        return CLASS_E_CLASSNOTAVAILABLE;
    ClassFactory* pFactory = new (std::nothrow) ClassFactory();
    if (!pFactory) return E_OUTOFMEMORY;
    HRESULT hr = pFactory->QueryInterface(riid, ppv);
    pFactory->Release();
    return hr;
}

STDAPI DllCanUnloadNow() {
    return (g_cDllRef == 0) ? S_OK : S_FALSE;
}

类工厂本身很程式化:CreateInstance 里 new 出真正的处理器对象,QueryInterface 到请求的接口再把多余的引用释放掉。这段样板在每个扩展里几乎一样——第 ② 章我们就把它抽进一个公共头文件,本章先把它摊开让你看清全貌。

拿到被右键的文件:IShellExtInit

系统在弹出菜单之前调用 IShellExtInit::Initialize,把选中的对象通过一个 IDataObject 传进来。我们从中取出第一个文件的路径:

ContextMenuHandler.cpp(节选)
IFACEMETHODIMP ContextMenuHandler::Initialize(
    PCIDLIST_ABSOLUTE, IDataObject* pdtobj, HKEY)
{
    FORMATETC fe = { CF_HDROP, nullptr, DVASPECT_CONTENT, -1, TYMED_HGLOBAL };
    STGMEDIUM stg;
    if (FAILED(pdtobj->GetData(&fe, &stg))) return E_INVALIDARG;

    HDROP hDrop = static_cast<HDROP>(GlobalLock(stg.hGlobal));
    if (hDrop) {
        if (DragQueryFileW(hDrop, 0, m_szFile, ARRAYSIZE(m_szFile)) > 0)
            m_hasFile = true;
        GlobalUnlock(stg.hGlobal);
    }
    ReleaseStgMedium(&stg);
    return m_hasFile ? S_OK : E_INVALIDARG;
}

这里用经典的 CF_HDROP + DragQueryFile 取路径,简单直观。

更现代的做法是 SHCreateShellItemArrayFromDataObject 把 IDataObject 转成 IShellItemArray,能拿到完整的命名空间信息(不只是文件路径)。第 ② 章的 IExplorerCommand 天生就收 IShellItemArray,我们到那时再用。本例先用 DragQueryFile 把概念讲清楚。

示例为了简洁只取了第一个文件。真实扩展要用 DragQueryFile(hDrop, 0xFFFFFFFF, …) 拿到总数并遍历全部选中项。

加菜单、执行、给名字:IContextMenu

三个方法各司其职:

QueryContextMenu —— 往菜单里插项。返回值的低 16 位是你用掉的命令 ID 个数(这是最容易踩的坑:返回错了菜单会错乱):

IFACEMETHODIMP ContextMenuHandler::QueryContextMenu(
    HMENU hMenu, UINT indexMenu, UINT idCmdFirst, UINT, UINT uFlags)
{
    if (uFlags & CMF_DEFAULTONLY)              // 只要默认项时别加自定义命令
        return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, 0);

    InsertMenuW(hMenu, indexMenu, MF_BYPOSITION | MF_STRING,
                idCmdFirst + IDM_SHOWINFO, L"显示文件信息(&I)");

    return MAKE_HRESULT(SEVERITY_SUCCESS, FACILITY_NULL, IDM_SHOWINFO + 1);
}

菜单项的命令 ID 必须用 idCmdFirst + 偏移——因为同一个菜单上可能挂着好几个扩展,系统给每个扩展分配一段 ID 区间,你只能用自己的偏移,不能写死。

InvokeCommand —— 用户点了菜单项时执行。命令可能以「偏移量」或「字符串 verb」两种形式传入,两种都要认(用 IS_INTRESOURCE 区分):

IFACEMETHODIMP ContextMenuHandler::InvokeCommand(CMINVOKECOMMANDINFO* pici) {
    if (IS_INTRESOURCE(pici->lpVerb)) {
        if (LOWORD(pici->lpVerb) != IDM_SHOWINFO) return E_INVALIDARG;
    } else {
        if (StrCmpIA(pici->lpVerb, "showinfo") != 0) return E_INVALIDARG;
    }
    // …GetFileAttributesEx 取大小,MessageBox 显示路径+大小…
    return S_OK;
}

GetCommandString —— 给命令提供 verb 名(供脚本/ShellExecute 调用)和状态栏帮助文本,按 uType(GCS_VERBW / GCS_HELPTEXTW …)分别返回。

注册表:把 DLL 挂上去

DllRegisterServer 写三处(全在 HKEY_CLASSES_ROOT 下):

CLSID\{GUID}\                       (默认) = 友好名
CLSID\{GUID}\InProcServer32\        (默认) = DLL 完整路径
                                    ThreadingModel = Apartment
*\shellex\ContextMenuHandlers\FileInfoDemo\  (默认) = {GUID}

第三行是关键:把处理器挂在 *(所有文件) 的 ContextMenuHandlers 下。真实项目应挂在具体文件类型的 ProgID(如 txtfile)下,而不是 *——这里用 * 只是方便你右键任何文件都能立刻看到效果。

改完注册表,务必调用 SHChangeNotify(SHCNE_ASSOCCHANGED, …) 通知系统,不用重启即可生效。

构建、安装、验证

cd 01-context-menu
cmake -B build -A x64
cmake --build build --config Release

两个必踩的坑:① 必须 x64——现代资源管理器是 64 位进程,加载不了 32 位 DLL。② 源码是 UTF-8——中文 locale 下 MSVC 默认按 GB2312 读源码会报「语法错误」,CMake 里加 /utf-8(示例已加)。

在管理员 PowerShell 里注册(写 HKCR 需要管理员):

regsvr32 .\build\Release\ContextMenuExt.dll     # 注册
regsvr32 /u .\build\Release\ContextMenuExt.dll  # 卸载

然后右键任意文件——就能看到「显示文件信息」。

Windows 11:为什么在「显示更多选项」里

你会发现:在 Win11 上,这个命令不在默认弹出的那个新版右键菜单里,而要点「显示更多选项」(或按 Shift+F10)才在下面的旧菜单里。

这不是 bug。Win11 的新版菜单只收经过 MSIX / 稀疏包声明、并用 IExplorerCommand 实现的命令;所有用注册表挂载的经典 IContextMenu 处理器,一律被归到「显示更多选项」的兼容菜单里。

  • 想让命令用更现代、更省事的接口写 → 第 ② 章 IExplorerCommand;
  • 想让命令进入 Win11 主菜单 → 第 ③ 章 MSIX / 稀疏包打包。

参考

本页目录