# 第 ① 章 · 右键菜单扩展（IContextMenu）

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

- 作者：David（道雾轩）
- 专栏：Windows Shell 扩展指南（https://daiw.org/manual/windows-shell-ext.md）
- 最后更新：2026-07-11
- 原文：https://daiw.org/manual/windows-shell-ext/context-menu
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

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

配套代码：[`01-context-menu`](https://github.com/davidapp/shell-extension-demos/tree/main/01-context-menu)（CMake 工程，clone 即可构建）。

## 它由哪些部分组成

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

1. **一个 COM 对象**：有唯一 CLSID，实现 `IUnknown` 和一个类工厂；
2. **两个 Shell 接口**：
   - [`IShellExtInit`](https://learn.microsoft.com/zh-cn/windows/win32/api/shobjidl_core/nn-shobjidl_core-ishellextinit) —— 系统借它把「你右键了哪些文件」交给你；
   - [`IContextMenu`](https://learn.microsoft.com/zh-cn/windows/win32/api/shobjidl_core/nn-shobjidl_core-icontextmenu) —— 你借它往菜单里加项、并在用户点击时干活；
3. **注册表登记**：让系统知道「右键某类文件时该加载哪个 DLL」。

<Callout type="info">
  进程内扩展被**加载进资源管理器进程**里运行。所以第一条铁律：**在虚拟机里开发调试**。你的代码一崩，倒下的是整个桌面。
</Callout>

## COM/DLL 的四个出口

一个 COM 进程内服务器 DLL 必须导出四个标准函数（见 `dllmain.cpp` 与 `ContextMenuExt.def`）：

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

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

```cpp title="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` 传进来。我们从中取出第一个文件的路径：

```cpp title="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`](https://learn.microsoft.com/zh-cn/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew) 取路径，简单直观。

<Callout type="info">
  更现代的做法是 [`SHCreateShellItemArrayFromDataObject`](https://learn.microsoft.com/zh-cn/windows/win32/api/shobjidl_core/nf-shobjidl_core-shcreateshellitemarrayfromdataobject) 把 `IDataObject` 转成 `IShellItemArray`，能拿到完整的命名空间信息（不只是文件路径）。第 ② 章的 `IExplorerCommand` 天生就收 `IShellItemArray`，我们到那时再用。本例先用 `DragQueryFile` 把概念讲清楚。
</Callout>

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

## 加菜单、执行、给名字：IContextMenu

三个方法各司其职：

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

```cpp
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` 区分）：

```cpp
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, …)`](https://learn.microsoft.com/zh-cn/windows/win32/api/shlobj_core/nf-shlobj_core-shchangenotify) 通知系统，**不用重启**即可生效。

## 构建、安装、验证

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

<Callout type="warn">
  两个必踩的坑：**① 必须 x64**——现代资源管理器是 64 位进程，加载不了 32 位 DLL。**② 源码是 UTF-8**——中文 locale 下 MSVC 默认按 GB2312 读源码会报「语法错误」，CMake 里加 `/utf-8`（示例已加）。
</Callout>

在**管理员** PowerShell 里注册（写 `HKCR` 需要管理员）：

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

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

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

你会发现：在 **Win11** 上，这个命令**不在**默认弹出的那个新版右键菜单里，而要点「**显示更多选项**」（或按 <kbd>Shift</kbd>+<kbd>F10</kbd>）才在下面的旧菜单里。

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

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

## 参考

- [创建快捷（右键）菜单处理器](https://learn.microsoft.com/zh-cn/windows/win32/shell/context-menu-handlers)
- [IContextMenu](https://learn.microsoft.com/zh-cn/windows/win32/api/shobjidl_core/nn-shobjidl_core-icontextmenu) · [IShellExtInit](https://learn.microsoft.com/zh-cn/windows/win32/api/shobjidl_core/nn-shobjidl_core-ishellextinit)
- [注册 Shell 扩展处理器](https://learn.microsoft.com/zh-cn/windows/win32/shell/handlers#registering-shell-extension-handlers)
