今天的 Windows 外壳集成——右键菜单、文件关联与 Windows 11 的变化

· 更新日期: · · Windows, 外壳扩展, 上下文菜单, 文件关联, COM, Windows 11, 文件资源管理器, MSIX, Windows 开发

更新记录(仅首版,2026年08月20日 发布)
首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22176280)

以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。

Go Komura(2026)。《今天的 Windows 外壳集成——右键菜单、文件关联与 Windows 11 的变化》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-shell-integration-context-menu-file-association/

DOI(已登记存档)
10.5281/zenodo.22176280
DOI(上次登记版本)
10.5281/zenodo.22176281

“换到 Windows 11 之后,找不到自家应用的右键菜单了。”实际确认后会发现项并没有消失,打开菜单末尾的“显示更多选项”就能看到——这类咨询背后不是故障,而是 Windows 11 对菜单做的设计变更。在现场,它会变成“多点了一次”“找不到菜单项”这类问询。

不过,“让文件用这个应用打开”和“把自定义命令放进新的右键菜单”是两件不同的事。 不先把两者分开就动手写外壳扩展 DLL,本来只要注册关联就能满足的需求也会被做复杂。

本文是面向中小企业信息系统负责人和维护业务应用的 Windows 开发者的入门文章。从关联的基础,讲到新旧菜单的差异、实现方式的选择、安装程序中的注册与删除,再到故障的排查,把它们串成一条线来说明。

1. 先给出结论

关联和 verb 的底层没有变,Windows 11 变的是菜单的呈现方式,分成了新旧两套。 先确定“想实现什么”,再只挑出为此必需的机制。

只要“打开”的话,先从关联和静态 verb 考虑

文件关联的底层,是注册表里“扩展名键→ProgID→verb”这样的三层结构。扩展名键指向 ProgID,ProgID 下的 shell\<verb>\command 保存要启动的命令行。如果只是想实现“用这个应用打开”,现在依然靠这套注册和静态 verb 就够了,不需要外壳扩展 DLL。Microsoft 也建议先从能满足需求的最简单的静态 verb 开始考虑。12

注册位置方面,面向全体用户就写 HKLM,按用户就写 HKCU。HKCR 是把两者的 Classes 叠加起来的合并视图,所以写入时要明确指定 HKLM 或 HKCU,把 HKCR 当作查看用。3

另外,注册为候选和被选为默认应用是两回事。 双击时打开的默认应用由用户选择,操作系统会保护这个选择。安装程序不应擅自抢走默认值,应用这边的职责到“正确注册为候选”为止。4

要把自定义命令放进新菜单,就需要 IExplorerCommand 和包标识

在 Windows 11 中,传统的 IContextMenu 扩展被移到用“显示更多选项”(Shift+F10)打开的旧菜单一侧。要把自定义命令放到新菜单上,需要实现 IExplorerCommand 并具备包标识。56

官方路径是:把实现了 IExplorerCommand 的本机 DLL 通过 MSIX 清单(desktop4:FileExplorerContextMenus)注册。如果现有应用无法整体改成 MSIX,可以用 sparse package(带外部位置的 MSIX)只赋予标识。67

把 DLL 的安全性和卸载时的善后清理一并考虑进来

传统外壳扩展是会被加载进文件资源管理器等进程内部的 COM DLL。扩展崩溃或变慢会波及整个宿主。64 位宿主需要 64 位 DLL,而用托管代码实现进程内扩展不受支持。89

注册、修改、删除关联之后,要用 SHChangeNotify(SHCNE_ASSOCCHANGED) 发出通知。卸载时删除自家的 ProgID 等,而扩展名键的默认值保留不删,这是官方指引。外壳集成不只是把菜单显示出来,还包括让变更生效和事后清理。110

按目的选择读法

想了解的内容、正在发生的症状 阅读位置
该选哪种实现方式 第 6 章的决策表。先区分是只做关联,还是要往新菜单加自定义命令
想支持双击和“打开方式” 第 2 章的关联和第 3 章的静态 verb
已经注册却没成为默认应用 2.4 节的 UserChoice。把候选注册和用户的选择分开看
Windows 11 上菜单被藏进了更深一层 第 5 章的新旧菜单。处理办法见 5.2 节,无法改成 MSIX 时见 5.3 节
安装程序里该注册和注销什么 第 7 章。尤其是 7.3 节的按用户注册和 7.4 节的善后清理
菜单不出现、出现两次、文件资源管理器卡顿 第 8 章的排查。DLL 的限制见第 4 章

想从机制学起可以从第 2 章依次往下读,想给现有应用定处理方针可以从第 6 章开始读。

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 16 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 文件关联的机制——扩展名键→ProgID→verb 的三层结构

本章按文件侧的注册 → 写入位置 → 应用侧的注册 → 用户的选择这个顺序梳理。扩展名指向 ProgID、verb 保存命令行、复杂的扩展以进程内 COM DLL 运行——这套底层二十多年没有变过。

2.1. 用一个例子读懂三层结构

先用一个例子看关联的基本形态。扩展名键指向 ProgID,ProgID 里的 verb 保存要启动的命令。1 用户已经选定默认应用时的优先关系,在 2.4 节说明。

HKEY_CLASSES_ROOT
   .kmrpt                                  ← (1) 扩展名键
      (Default) = KomuraSoft.Report.1      ←     只是指向 ProgID 的指针
      OpenWithProgids
         KomuraSoft.Report.1               ←     “打开方式”的候选
   KomuraSoft.Report.1                     ← (2) ProgID(关联的实体)
      (Default) = 小村报表文档
      DefaultIcon
         (Default) = "C:\Program Files\KomuraSoft\Report.exe",0
      shell                                ← (3) verb(动词)一览
         open
            command
               (Default) = "C:\Program Files\KomuraSoft\Report.exe" "%1"
  • (1) 扩展名键(.kmrpt)只是用默认值指向 ProgID 的名字。在这里直接写命令是错的。
  • (2) ProgID(KomuraSoft.Report.1)才是关联的实体,持有显示名称、图标和 verb 列表。
  • (3) verb 是“打开”“打印”这类动词,shell\open\command 的默认值就是实际启动的命令行。

有了这种分离,就可以把多个扩展名(例如 .kmrpt 和 .kmrpt-file)指向同一个 ProgID,也可以在应用升级时替换 ProgID。

文件关联的三层结构扩展名键只是用默认值指向 ProgID 的指针,ProgID 才是持有显示名称、图标和 verb 列表的实体,verb 下 command 的默认值就是实际启动的命令行默认值指向 ProgID扩展名键 .kmrptProgID KomuraSoft.Report.1verb(shell 下的 open 等)command 的默认值启动 Report.exe同时持有显示名称和 DefaultIcon

图1:扩展名键是指针,ProgID 是实体,verb 的 command 才是实际启动的命令行。

2.2. HKCR 是“合并视图”——写在哪里,含义就不同

上面的例子用 HKEY_CLASSES_ROOT(HKCR)来展示,但HKCR 不是物理存储位置,而是把 HKLM\Software\Classes 和 HKCU\Software\Classes 叠加起来的合并视图。同一个键两边都有时,HKCU 一侧胜出。3

HKCR 是合并视图HKLM 和 HKCU 各自的 Classes 叠加起来就是 HKCR,同一个键两边都有时 HKCU 一侧优先,注册写入要明确指定 HKLM 或 HKCU 而把 HKCR 当作读取用HKLM\Software\Classes(全体用户)HKCR(合并视图)HKCU\Software\Classes(按用户)同一个键存在时 HKCU 胜出当作读取(查看)用

图2:HKCR 是 HKLM 和 HKCU 的 Classes 叠加后的呈现,写入位置必须明确指定其中之一。

写入位置 含义 需要的权限
HKLM\Software\Classes 全体用户共用的注册 管理员权限
HKCU\Software\Classes 仅该用户的注册 不需要
直接写入 HKCR 依据已有键所在的位置被分派到某一侧 视情况而定

在实际工作中,注册一定要明确写到 HKLM 或 HKCU 其中之一,把 HKCR 只当作读取(查看)用,这样最稳妥。

不要把关联数据和 COM 注册的位数混为一谈

在 WOW64 的注册表重定向中,关联数据和 COM 注册的处理并不相同。扩展名键、ProgID 这类位于 HKLM\Software\Classes 下的关联数据,从 Windows 7 起在 32 位/64 位注册表视图之间是共享的,即使由 32 位安装程序写入,也不会跑到 Wow6432Node 一侧去。

另一方面,Classes\CLSID 等 COM 注册相关的部分子键属于重定向对象,在后面要讲的外壳扩展(进程内 COM)注册中,分别按 32 位/64 位写入就有了意义。详细内容在“注册表的 32 位/64 位重定向与虚拟化陷阱”中讨论。

2.3. 应用侧的注册——App Paths、Applications、RegisteredApplications

与文件侧(扩展名和 ProgID)配对的应用侧注册也有三种。11

  • App Paths(HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths):让 ShellExecuteEx 只凭可执行文件名就能启动的注册。因为不用污染 PATH 环境变量,Microsoft 推荐用这种方式。
  • Applications(HKCR\Applications\<应用.exe>):定义通过“打开方式”被传入任意文件时的默认打开方式,以及应用的显示名称(FriendlyAppName)。
  • RegisteredApplications + Capabilities:声明应用能处理的扩展名和 MIME 类型,让它作为候选出现在 Windows 默认应用设置页面中的注册。

“我们的应用不出现在默认应用列表里”这类咨询,大多是注册了 ProgID 却省略了这项 Capabilities 注册。

应用侧的三种注册应用侧的注册有 App Paths、Applications 和 RegisteredApplications 三种,分别承担只凭可执行文件名启动、通过打开方式打开时的默认方式,以及出现在默认应用设置页面上的职责应用侧的注册App PathsApplicationsRegisteredApplications只凭文件名启动打开方式的默认项出现在默认应用页面前提是用 Capabilities 声明

图3:应用侧的注册有三种,要出现在默认应用页面的候选中就需要 Capabilities 注册。

2.4. 默认应用属于用户——UserChoice 的保护

即便在扩展名键的默认值里写了 ProgID,也不一定就能成为默认应用。用户通过“打开方式”等明确选择的结果会保存在 HKCU\...\Explorer\FileExts\<扩展名>\UserChoice,解析关联时以它为优先。

不要设计成直接改写 UserChoice

Windows 不支持由程序更改默认应用。 默认应用的设置被设计为由用户通过系统设置 UI 完成,UserChoice 的数据经过混淆,筛选器驱动程序(UCPD.sys)会阻止应用写入。在受管环境中,组策略/MDM 策略才是官方手段。4

过去之所以出现 SetUserFTA 这类“模仿哈希再改写”的工具,正是这层保护的反面。

安装程序要做的是“准备好被选中”

要嵌进自家应用安装程序的,是下面三件事。

  1. 正确注册 ProgID 和 verb。
  2. 把自己加入 OpenWithProgIds。
  3. 必要时把用户引导到默认应用设置页面。

不是抢走默认值,而是把用户可以选择的状态准备好。

默认应用的解析与 UserChoice 的保护用户明确选择的结果保存在 UserChoice 中并在解析关联时优先,来自应用的改写会被 UCPD.sys 拦截,因此安装程序能做的只到注册为候选和引导用户到设置页面为止优先UCPD.sys 拦截UserChoice(用户的选择)关联的解析扩展名键的默认值来自应用的改写安装程序的职责注册 ProgID 和 verb加入 OpenWithProgIds引导到设置页面

图4:解析关联时以用户的选择(UserChoice)为优先,来自应用的改写由操作系统拦截保护。

3. “打开”之外的 verb——print、edit、runas 与自定义 verb

3.1. 标准 verb 与自定义 verb

verb 不止 open 一个。操作系统知道其含义的标准 verb,除 open 外还有 edit、print、play、preview 等,标准 verb 会自动获得与操作系统区域设置相符的显示名称。双击时使用的默认 verb,按 shell 键的默认值→注册表中的第一个 verb→open→openwith 的顺序确定。12

默认 verb 的确定顺序双击时使用的默认 verb 按 shell 键的默认值、注册表中的第一个 verb、open、openwith 的顺序取最先找到的那个没有则没有则没有则shell 键的默认值注册表中的第一个 verbopenopenwith

图5:双击时的默认 verb,按这个顺序取最先找到的那个。

想添加自定义动词时,就注册自定义 verb。

KomuraSoft.Report.1
   shell
      open
         command
            (Default) = "C:\Program Files\KomuraSoft\Report.exe" "%1"
      print
         command
            (Default) = "C:\Program Files\KomuraSoft\Report.exe" /print "%1"
      verify                          ← 自定义 verb
         (Default) = 验证报表(&V)     ← 菜单上的显示名称
         command
            (Default) = "C:\Program Files\KomuraSoft\Report.exe" /verify "%1"

3.2. 区分提权、按 Shift 时的显示,以及老旧的 DDE

verb 还有下面这些用法。

  • 注册名为 runas 的 verb,可以定义相当于“以管理员身份运行”的提权启动,从 ShellExecute 系列 API 指定 runas 启动时也会用到它。
  • 在 verb 键上放一个名为 Extended 的空值,它就变成只有按住 Shift 键右键单击时才显示的扩展 verb。适合用来隐藏很少用到的危险操作。12
  • 老应用的关联里,有时还留着用 DDE(ddeexec 键)把文档送进已有进程的做法,但通过 DDE 启动 verb 已经是被弃用(Deprecated)的遗留产物。没有理由在新代码里再写它。12

3.3. 用引号把 EXE 路径和所选文件的路径括起来

最容易出问题的是命令行里的引号。只要命令字符串的组成部分可能含有空格,就必须用引号括起来。C:\Program Files\... 这样的 EXE 路径自不必说,%1(所选文件的路径)也应该始终写成 "%1"。因为无法保证用户的文件路径里不含空格。没有引号的 My Program.exe 会被解释成“以 Program.exe 为参数启动 My”。13

命令行引号引发的问题没有引号的命令会在空格处被切开,被误解释成以 Program.exe 为参数启动 My,因此可能含空格的 EXE 路径和表示所选文件路径的 %1 都要始终用引号括起来按空格切分没有引号的 command被误解释为启动别的 EXE带引号的 command按预期启动用引号括住 EXE 路径%1 也始终用引号括住

图6:没有引号的命令会在空格处被错误切分,因此 EXE 路径和 %1 都要始终用引号括起来。

3.4. 写 DLL 之前,先确认静态 verb 是否够用

到这里为止只用注册表的机制(静态 verb),一个 DLL 都不用写就能实现,也没有让文件资源管理器变得不稳定的风险。Microsoft 自己也反复强调“在写外壳扩展之前,先考虑能否用满足需求的最简单的静态 verb 解决”。2

4. 传统外壳扩展——运行在文件资源管理器内部的 DLL

本章要抓住的一点是:传统的扩展 DLL 运行在文件资源管理器等进程的内部。 理解了它运行在哪里,稳定性、位数和实现语言上的限制就都串起来了。

4.1. 外壳扩展的种类

遇到静态 verb 满足不了的需求,例如“根据所选内容动态改变菜单”“替换图标或属性页面”,就要用外壳扩展处理程序。有代表性的种类如下。8

处理程序 主要接口 能做什么
上下文菜单处理程序 IContextMenu + IShellExtInit 动态添加和控制菜单项
图标处理程序 / 图标覆盖 IExtractIcon / IShellIconOverlayIdentifier 按文件设置图标、叠加显示
属性页处理程序 IShellPropSheetExt 向属性页面添加选项卡
缩略图 / 信息提示 IThumbnailProvider / IQueryInfo 缩略显示、悬停时的说明
拖放 / 复制挂钩处理程序 IDropTarget / ICopyHook 在放下时、复制移动时介入

这些都作为 COM 类来实现,并把 CLSID 注册到注册表。COM 本身的思路请参阅“COM / ActiveX / OCX 是什么”。

4.2. “进程内 COM 服务器”意味着什么

传统外壳扩展的本质,是被加载进文件资源管理器(以及任何打开了通用文件对话框的应用)进程内部的进程内 COM 服务器(DLL)。所有注意事项都由此推导出来。8

  • 扩展一旦崩溃,文件资源管理器就会被连带拖垮。一旦挂起,右键单击就会卡住几秒。而且受影响的不止文件资源管理器,还包括所有显示过“打开文件”对话框的应用。
  • 菜单的构建在 UI 线程上进行,因此不能在显示菜单时执行网络访问、文件 I/O 这类耗时处理。
  • 线程模型原则上要注册为 Apartment。
进程内扩展的连带影响结构外壳扩展 DLL 不只会被加载进文件资源管理器,也会被加载进任何打开了文件对话框的应用进程,因此扩展的崩溃和挂起会波及整个宿主进程加载进进程内加载进进程内外壳扩展 DLL文件资源管理器打开对话框的任意应用崩溃和挂起会波及显示时不做耗时处理

图7:扩展 DLL 运行在宿主进程内部,因此崩溃和挂起会波及整个宿主。

调查“打开某个文件夹时文件资源管理器就卡住”“右键单击要等 5 秒”这类咨询时,发现原因不在自家应用而在第三方外壳扩展的情况并不少见。排查方法在第 8 章讨论。

4.3. 位数要一致——64 位环境必须用 64 位 DLL

进程内 DLL 必须与加载它的进程位数一致。64 位 Windows 的文件资源管理器是 64 位进程,所以只编译成 32 位的外壳扩展 DLL 根本不会被加载,菜单里也完全不会出现。因为连错误都不报,它是“注册了却不出现”的典型原因。

并不是说应用本体也必须改成 64 位

32 位应用本体加 64 位外壳扩展 DLL 是合法的组合,但要注意 COM 注册会按位数分开(Wow6432Node)。另外,从 verb 的 command 启动的是另一个进程的 EXE,所以不受这个限制(保持 32 位 EXE 也没问题)。

外壳扩展 DLL 的位数一致64 位文件资源管理器只能加载 64 位的外壳扩展 DLL,只有 32 位的 DLL 不报错也不会出现在菜单里,而从 verb 的 command 启动的 EXE 属于另一个进程因此不受限制能加载无法加载另一个进程64 位文件资源管理器64 位外壳扩展 DLL只有 32 位的 DLL不报错但菜单里不出现由 verb 启动的 EXE保持 32 位也没问题

图8:64 位文件资源管理器只会加载 64 位 DLL,而由 verb 启动的 EXE 不受这个限制。

4.4. 为什么不能用托管代码来写

经常有人问“能不能用 C# 写外壳扩展”,但Microsoft 明确表示,不推荐用托管代码(.NET)编写进程内的外壳扩展,并且不提供支持。9

原因在于扩展会被加载进任意进程这一性质。让宿主应用变得不稳定的因素主要有以下三个。

  • CLR 版本冲突。 在 .NET Framework 4 之前尤其容易出问题。
  • 重入。 存在等待锁时 CLR 重入消息循环的问题。
  • 对象生存期。 垃圾回收带来的生存期非确定性,与 COM 的引用计数约定相冲突。

其中有些问题在 .NET Framework 4 之后和现代 .NET 上有所缓解,但官方立场没有改变。

实际工作中的指引很简单。进程内扩展用本机 C++ 写。想用托管代码,就做成由 verb 的 command 启动的普通 EXE,或者做成在另一个进程中运行的进程外扩展(预览处理程序等)。9

能否使用托管代码的判断运行在文件资源管理器进程内部的进程内扩展原则上用本机 C++ 编写,想用托管代码时就做成由 verb 的 command 启动的普通 EXE 或在另一个进程中运行的进程外扩展是否运行在进程内部的扩展?用本机 C++ 编写可以用托管代码CLR 冲突和重入会让宿主不稳定由 verb 启动的普通 EXE预览等另一个进程的扩展

图9:进程内扩展原则上用本机 C++,托管代码只限于在另一个进程中运行的形态。

5. Windows 11 的新上下文菜单——菜单的双层化

这里按移到旧菜单的原因 → 向新菜单注册 → 保留现有安装程序的办法的顺序说明。只做“用这个应用打开”的关联,与添加自定义命令之间的差别,在 5.4 节确认。

5.1. 发生了什么

Windows 11 重做了文件资源管理器的右键菜单。剪切、复制等变成了顶部的图标行,“打开”和“打开方式”被集中放到上部,应用追加的命令则被归到外壳标准命令的下方。同一个应用追加多个命令时,它们会被收进带应用名的浮出菜单(子菜单)里。5

而最关键的一点是:基于传统 IContextMenu 的外壳扩展并没有被删除,而是被移到了用“显示更多选项”(Shift+F10)打开的旧菜单一侧,那里原样加载了 Windows 10 的菜单。5 开头那条咨询里“菜单被藏起来了”,真身就是这种双层化。

Windows 11 中双层化的右键菜单右键单击首先打开的是新菜单,能出现在那里的只有以 IExplorerCommand 加包标识注册的命令,传统 IContextMenu 扩展被移到用显示更多选项打开的旧菜单一侧显示更多选项 Shift+F10右键单击文件新菜单(Windows 11)IExplorerCommand+标识的命令旧菜单(Windows 10 的菜单)传统 IContextMenu 扩展多个命令收进浮出菜单

图10:新菜单上只有 IExplorerCommand+标识的命令,传统扩展被移到旧菜单一侧。

5.2. 放上新菜单的官方路径——IExplorerCommand+清单注册

把自定义命令放进新菜单的官方路径,是实现了 IExplorerCommand 的本机 DLL,加上在 MSIX 清单中注册。6

清单里要写两种声明

下面的例子,前半段声明COM 服务器(CLSID 与实现 DLL),后半段声明上下文菜单扩展(目标与命令)。把两者连起来的是同一个 CLSID。

<!-- 包清单(节选) -->
<com:Extension Category="windows.comServer">
  <com:ComServer>
    <com:SurrogateServer DisplayName="Komura commands">
      <com:Class Id="01234567-89AB-CDEF-0123-456789ABCDEF"
                 Path="KomuraCommand.dll" ThreadingModel="STA" />
    </com:SurrogateServer>
  </com:ComServer>
</com:Extension>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
  <desktop4:FileExplorerContextMenus>
    <desktop5:ItemType Type=".kmrpt">
      <desktop5:Verb Id="VerifyReport"
                     Clsid="01234567-89AB-CDEF-0123-456789ABCDEF" />
    </desktop5:ItemType>
  </desktop4:FileExplorerContextMenus>
</desktop4:Extension>

ItemType 的 Type 除了具体扩展名,还可以指定 *(所有文件)、Directory(文件夹)、Directory\Background(文件夹背景)。DLL 要与文件资源管理器的体系结构(64 位/ARM64)保持一致。6

菜单构建方法要快速返回

IExplorerCommand 本身是从 Windows 7 时代就有的接口,需要实现标题(GetTitle)、图标(GetIcon)、启用/禁用/隐藏的状态(GetState)和执行(Invoke)。这些方法由 UI 线程调用,因此禁止访问网络资源,菜单构建类的方法必须快速返回。耗时处理放到 Invoke 之后再做。146

新菜单注册的清单结构MSIX 清单中的 COM 服务器声明把 CLSID 和 DLL 对应起来,上下文菜单扩展的声明用 ItemType 和 Verb 把目标与实现连起来,自定义命令由此显示在新菜单上把 CLSID 与 DLL 对应起来用 ItemType 和 Verb 指定MSIX 清单COM 服务器声明菜单扩展的声明IExplorerCommand 实现 DLL新菜单上显示命令目标是扩展名或全部文件等

图11:清单中的两个声明把实现 DLL 和目标连起来,命令就出现在新菜单上。

5.3. 非打包应用的选择——用 sparse package 只取得标识

当情况是“我们的应用只能用 MSI 分发,改成 MSIX 不现实”时,变通办法就是 sparse package(带外部位置的 MSIX)。制作并签名一个不含应用本体、只有清单的小型 MSIX,在现有安装程序的最后一步注册它。这样应用就取得了包标识,上面那种清单注册(即显示在新菜单上)也就可行了。

它从 Windows 10 版本 2004 起可用,并且包必须用目标计算机信任的证书签名。7 注册与注销的顺序,以及“注册是按用户生效”这一点的注意事项,在 7.3 节讨论。

用 sparse package 取得标识的流程用现有安装程序放好应用本体之后,把只含清单的 sparse package 以带外部位置的方式注册,应用就取得包标识,向新菜单做清单注册也就可行了现有安装程序放置应用本体sparse package只有清单没有本体以带外部位置的方式注册取得包标识可以注册到新菜单需要受信任的签名

图12:把不含本体的 sparse package 以带外部位置的方式注册后,应用就取得包标识。

最大的好处是不用替换安装程序,对已有 MSI/EXE 安装程序资产的应用来说是务实的解法。与全面迁移到 MSIX 的比较,也可参阅“Windows 应用发布方式怎么选”。

5.4. 关联的 verb 在新菜单上是什么样子

这一点容易被误解:第 2、3 章讲的关联(ProgID 和 verb)在新菜单上依然有效。双击的默认 verb、“打开”和“打开方式”的候选都由关联解析而来,并显示在新菜单的上部。也就是说,如果只想“让文件能用这个应用打开”,在 Windows 11 上不需要任何额外处理。

另一方面,关联并不是通用的菜单扩展机制,所以想把任意自定义命令放到新菜单的第一层,就需要 IExplorerCommand+标识——两者就是这样分工的。6

关联与新菜单的分工ProgID 和 verb 的关联在新菜单上仍然用于解析双击的默认 verb 以及打开和打开方式并显示在上部,而要把任意自定义命令放到新菜单的第一层则需要 IExplorerCommand 和标识关联(ProgID 和 verb)解析默认 verb 与打开显示在新菜单上部Windows 11 上也无需额外处理任意的自定义命令IExplorerCommand+标识显示在新菜单第一层

图13:关联在新菜单上仍然负责“打开”一类的解析,只有自定义命令需要 IExplorerCommand+标识。

6. 实际工作中的决策表——三个选项该取哪一个

先确认 (a) 是否够用,如果需要把自定义命令显示到新菜单上就选 (b)。 暂时维持现有传统扩展的是 (c)。

想实现的目标 推荐的手段 在 Windows 11 上的呈现 需要的工作与成本
(a) 想通过双击或“打开”启动自家应用 关联+静态 verb(只做注册表注册) 整合显示在新菜单的“打开”“打开方式”中 只需安装程序做注册表注册。不需要 DLL,也没有额外的签名要求
(b) 想把针对所选文件/文件夹的自定义命令放进新菜单 实现 IExplorerCommand+MSIX 清单注册。非打包应用用 sparse package 赋予标识 新菜单的第一层(多个命令收进带应用名的浮出菜单) 本机 C++ DLL+包标识+代码签名
(c) 继续使用现有的传统 IContextMenu 扩展 暂时原样维持(新开发不要选它) 只出现在“显示更多选项”(Shift+F10)的旧菜单一侧 维持 64 位构建和 COM 注册。将来规划迁移到 (b)

判断 1:选择能满足需求的最简单方法

首要的是:(a) 就能解决的需求,不要搬出 (b) 或 (c)。外壳扩展从写下的那一刻起,就要为文件资源管理器的稳定性负责。

判断 2:把维持旧菜单和改善易用性分开考虑

(c) 只是“没坏”,在用户体验上会一直处于低一档的位置。日常操作中使用频率越高的命令,迁移到 (b) 的投入产出比就越大。

三个选项的选法只想通过双击或打开来启动时用关联和静态 verb 就够了,要把自定义命令放进新菜单就用 IExplorerCommand 和 MSIX 清单注册,无法改成 MSIX 时用 sparse package 赋予标识,现有的传统 IContextMenu 扩展则在旧菜单一侧暂时维持是否是是否否只要能打开就行?关联+静态 verb要往新菜单放自定义命令?能改成 MSIX 吗?IExplorerCommand+MSIX用 sparse package 取得标识暂时维持传统方式只显示在旧菜单一侧不需要 DLL,风险小

图14:根据需求,从静态 verb、IExplorerCommand+标识、维持传统方式这三者中选择。

7. 部署与注册的实务——安装程序、sparse package 与清理

实现方式定下来之后,把注册位置、变更通知、包的注册与注销、卸载时的善后清理嵌进安装程序。

7.1. 写 HKLM 还是 HKCU

注册位置要与安装程序的形态一致。面向全体用户(放到 Program Files、需要管理员权限)就写 HKLM\Software\Classes,按用户安装(不提权)就写 HKCU\Software\Classes。混着用就会催生“甲能打开、乙打不开”这类问询。

对于伴随 CLSID 注册的外壳扩展,虽然在应用内部使用 COM 时,可以用 Reg-Free COM 这种免去注册表注册本身的选项,但它无法用于文件资源管理器要加载的外壳扩展,所以还是得走正规的注册(“什么是 Reg-Free COM”)。

7.2. 改完要发出通知——SHChangeNotify

注册、修改或删除关联之后,要用 SHChangeNotify 发出 SHCNE_ASSOCCHANGED 事件通知。省掉这一步,变更有时要等到重启后文件资源管理器才会识别到。110

// 在安装程序的自定义操作等处,于更改关联之后调用一次
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr);

7.3. sparse package 的注册与注销

sparse package 的注册与注销是安装程序的职责。注册放在文件放置之后,注销放在文件删除之前。7

# 安装时:放好文件后,把安装目录注册为外部位置
Add-AppxPackage -Path "C:\Program Files\KomuraSoft\KomuraReport.identity.msix" `
                -ExternalLocation "C:\Program Files\KomuraSoft"

# 卸载时:在删除文件之前注销包注册
Remove-AppxPackage <包的完整名称>

区分“注册给执行用户”和“面向全体用户部署”

Add-AppxPackage 是针对执行它的用户注册的。如果从 per-machine 的 MSI 自定义操作里以 LocalSystem 执行,执行安装的用户并不会获得标识。因此要做成以用户模拟(impersonate)方式执行的结构。

不过,即使用模拟方式注册,得到注册的也只有执行这次安装的那个用户。 一台机器由多个用户使用时,其他用户以及之后才创建的用户没有包标识,新菜单里不会出现命令。

若要让全体用户都能使用,就要准备按用户注册的机制,例如应用首次启动时检查自己的包注册状态,未注册则进行注册。卸载时也要把“从已注册的各个用户处注销”纳入计划。

注册之后菜单上没有生效时

清单注册要生效,有时需要重启文件资源管理器(或者注销登录)。6

sparse package 注册与注销的顺序安装时在放置文件之后注册 sparse package,卸载时在删除文件之前注销注册,但要注意注册只对执行它的用户有效安装放置文件注册 sparse package卸载注销包注册删除文件注册只对执行用户有效

图15:注册放在文件放置之后,注销放在文件删除之前,并注意注册是按执行用户生效的。

7.4. 卸载时的清理——删什么、留什么

关于卸载时的清理,官方指南给出了明确的方针。1

  • 要删的:自家的整套 ProgID 键、Capabilities/RegisteredApplications 注册、外壳扩展的 CLSID 注册、sparse package(Remove-AppxPackage)。
  • 要留的:扩展名键(.kmrpt)的默认值。即使它仍指向自家 ProgID 也不要删除,这是官方的推荐做法。因为很难判断安装之后是否有别的应用取走了默认值,而 Windows 在默认值里的 ProgID 未注册时只会直接忽略,留着并没有实际危害。
  • 清理的最后同样要调用 SHChangeNotify(SHCNE_ASSOCCHANGED)。

“已经卸载了菜单里却还有残留”的问题,多数出在这套清理设计的疏漏上。

卸载时清理的设计卸载时要删除自家的 ProgID 键、CLSID 注册和 sparse package,扩展名键的默认值因为未注册时会被忽略所以保留,清理的最后用 SHChangeNotify 通知变更卸载要删的要留的ProgID 和 CLSID 注册sparse package扩展名键的默认值未注册的 ProgID 会被忽略最后用 SHChangeNotify 通知

图16:删除自家的注册,保留扩展名键的默认值,并在清理的最后通知变更。

8. 故障排查——不出现、出两次、卡顿

故障按“不出现”“出现两次、消不掉”“卡顿、崩溃”分开来查。最后说明如何在干净的环境里验证注册和善后清理。

8.1. 菜单里不出现

首先确认自己看的是新菜单还是旧菜单。 在此基础上,按下面的顺序排查。

  1. 看的是哪个菜单:旧方式的注册只会出现在 Shift+F10 的旧菜单一侧。先把两边都看一遍。
  2. 位数:只有 32 位的外壳扩展 DLL 不会被 64 位文件资源管理器加载(4.3 节)。
  3. 注册位置:HKLM/HKCU、Wow6432Node 搞混了。用 reg query 确认实际的键。
  4. 包注册:面向新菜单时,用 Get-AppxPackage 确认是否已注册、签名证书是否受信任、-ExternalLocation 的路径是否正确,然后重启文件资源管理器。6
  5. 漏发通知:如果是忘了 SHChangeNotify,可以通过重启文件资源管理器后是否生效来判别。
菜单不出现时的排查顺序先确认自己看的是哪个菜单,再依次排查 DLL 的位数、注册表的注册位置、包注册与签名,以及是否漏发了 SHChangeNotify 通知确认是新菜单还是旧菜单确认 DLL 的位数确认 HKLM 和 HKCU 的注册位置确认包注册与签名用重启判别是否漏发通知

图17:“不出现”时,按所看的菜单、位数、注册位置、包注册、漏发通知的顺序排查。

8.2. 出现两次、消不掉

先根据在哪一侧菜单里重复来判断大致方向。

  • 只在旧菜单里出现两次: 怀疑卸载时的清理疏漏(7.4 节),或者旧版本 ProgID 的残留。
  • 新旧菜单里都出现: 怀疑旧方式的注册表注册与 MSIX 清单注册同时存在。

这两条都只是对典型原因的区分,最终要查看实际的注册内容再下判断。

重复显示的排查只在旧菜单里出现两次就大致归为残留类原因,新旧菜单里都出现就大致归为旧方式注册表注册与清单注册并存类原因只有旧菜单新旧都有在哪一侧出现两次残留类并存类清理疏漏或旧 ProgID 残留旧注册表注册与新注册并存

图18:只在旧菜单里重复就归为残留类,新旧菜单都出现就归为并存类。

8.3. 文件资源管理器卡顿、崩溃

右键单击慢、在特定文件夹崩溃时,先盘点已安装的外壳扩展。

  1. 列出扩展。 用 NirSoft 的 ShellExView 这类工具,确认非 Microsoft 出品的扩展。
  2. 临时禁用以缩小范围。 对可疑项做二分查找,定位出问题的 DLL。如果是崩溃,事件查看器里的“出错模块名称”也是线索。
  3. 如果是自家扩展,就检查菜单构建路径。 怀疑同步 I/O 和网络访问(4.2 节、5.2 节)。
卡顿崩溃时定位出问题的 DLL用 ShellExView 列出非 Microsoft 出品的外壳扩展,一边临时禁用可疑项一边做二分查找来定位出问题的 DLL,崩溃时还可以把事件查看器里的出错模块作为线索盘点外壳扩展列出非 Microsoft 出品的临时禁用并二分查找定位出问题的 DLL崩溃的情况确认出错模块

图19:一边临时禁用非 Microsoft 扩展一边做二分查找,崩溃时并用事件查看器。

8.4. 验证时 Windows 沙盒很好用

外壳集成的验证,基本是确认“在干净的环境里安装→运行→卸载→零残留”。这里好用的是 Windows 沙盒(Pro/Enterprise/Education),每次启动都能在几秒内拉起一个全新的一次性 Windows,安装程序的注册与清理测试可以反复跑。关掉就全部消失,因此也适合排查注册表残留。15

9. 小结

有人因为换到 Windows 11 而说“菜单被藏起来了”时,请先用第 6 章的决策表确认想实现的到底是什么。思考顺序分为下面三步。

1. 判断关联是否够用

如果只是“用这个应用打开”,现在依然靠关联和静态 verb 就够。底层是扩展名键→ProgID→verb,HKCR 是把 HKLM/HKCU 的 Classes 叠加起来、供查看用的视图。要明确写入位置,%1 必须用引号括起来。

不过,选择默认应用的是用户。安装程序不要去抢默认值,而要把自己正确注册为候选。

2. 决定把自定义命令放到哪一侧菜单

传统 IContextMenu 扩展运行在用“显示更多选项”打开的旧菜单一侧。要把自定义命令放到新菜单上,需要IExplorerCommand 以及在 MSIX 清单中注册。无法整体改成 MSIX 的应用,用 sparse package 取得包标识是务实的解法。

传统扩展的 DLL 运行在文件资源管理器等进程的内部。崩溃和变慢会波及整个宿主,64 位宿主需要 64 位 DLL。用托管代码编写进程内扩展不受支持,原则上要用本机 C++ 实现。

3. 把注册、变更通知、注销当作一组来验证

注册、修改、删除关联之后要用 SHChangeNotify 发出通知。卸载时删除自家的 ProgID 等,保留扩展名键的默认值。sparse package 是按用户注册的,因此在多用户环境中,还要把面向各个用户的注册与注销纳入计划。

用 Windows 沙盒反复执行在干净的环境里安装→运行→卸载→确认残留这一整套流程。

从最简单的手段开始选,只在必要时才推进到向新菜单添加自定义命令。按这个顺序,就能理清工作的规模,以及需要维护的注册和实现的范围。

相关文章

相关咨询领域

小村软件有限公司承接业务应用的文件关联、右键菜单与外壳扩展的设计和实现,Windows 11 新上下文菜单的适配(改用 IExplorerCommand、引入 sparse package),现有安装程序注册与清理的梳理,以及文件资源管理器卡顿、崩溃问题的原因调查。从“被藏进显示更多选项里的菜单该怎么办”这一层的方针确定开始谈也可以。

参考链接

  1. Microsoft Learn, File Types。关于扩展名键指向 ProgID 的结构、OpenWithProgIds、HKLM 与 HKCU\Software\Classes 注册位置的区分使用、更改关联之后应调用 SHChangeNotify(SHCNE_ASSOCCHANGED),以及卸载时应删除 ProgID 但保留扩展名键默认值的说明。 ↩ ↩2 ↩3 ↩4 ↩5

  2. Microsoft Learn, Choosing a Static or Dynamic Shortcut Menu Method。关于应选择能满足需求的最简单的静态 verb 方式、IContextMenu 最强大但也最复杂并被归入不推荐的一侧,以及 IExplorerCommand/IExplorerCommandState 才是推荐方式。 ↩ ↩2

  3. Microsoft Learn, HKEY_CLASSES_ROOT Key。关于 HKEY_CLASSES_ROOT 是 HKLM\Software\Classes 与 HKCU\Software\Classes 合并而成的视图、用户一侧的定义优先于计算机一侧,以及写入时的分派规则。 ↩ ↩2

  4. Microsoft Learn, Windows app defaults platform。关于默认应用的更改被设计为只能通过系统设置 UI 完成、用户设置数据经过混淆并由筛选器驱动程序(UCPD.sys)做写入保护、基于注册表的更改不受支持,以及受管环境中应使用组策略/MDM 策略。 ↩ ↩2

  5. Windows Developer Blog, Extending the Context Menu and Share Dialog in Windows 11。关于 Windows 11 新上下文菜单的设计、用 IExplorerCommand 加应用标识做扩展、“打开”与“打开方式”被放到上部、多个命令被收进带应用名的浮出菜单,以及传统 IContextMenu 扩展作为“显示更多选项”(Shift+F10)的 Windows 10 菜单被加载。 ↩ ↩2 ↩3

  6. Microsoft Learn, Add a File Explorer context menu command to a packaged desktop app。关于注册到 Windows 11 新上下文菜单是通过实现 IExplorerCommand 加上 windows.comServer 与 desktop4:FileExplorerContextMenus 的清单声明完成、ItemType 可指定 *、Directory、Directory\Background、DLL 的体系结构要一致、菜单构建方法要保持快速、用 sparse package 支持非打包应用、注册生效有时需要重启文件资源管理器,以及文件关联并不是通用的菜单扩展机制。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  7. Microsoft Learn, Grant package identity by packaging with external location。关于不改动现有安装程序即可注册带外部位置的包(sparse package)来取得包标识、从 Windows 10 版本 2004 起可用,以及由此能使用必须具备标识的 Windows 功能(上下文菜单注册、通知等)。 ↩ ↩2 ↩3

  8. Microsoft Learn, Working with Shell Extensions。关于外壳扩展处理程序的种类、扩展是被加载进文件资源管理器(以及承载外壳的进程)的进程内 COM DLL 因而崩溃或挂起会波及整个 Explorer、以 ThreadingModel=Apartment 注册,以及应先考虑比外壳扩展更简单的替代手段。 ↩ ↩2 ↩3

  9. Microsoft Learn, Guidance for Implementing In-Process Extensions。关于 Microsoft 不推荐也不支持用托管代码实现进程内外壳扩展、CLR 版本冲突与重入与对象生存期非确定性等原因,以及进程外扩展(预览处理程序或从 shell\verb\command 启动)允许使用托管代码。 ↩ ↩2 ↩3

  10. Microsoft Learn, SHChangeNotify function。关于发出 SHCNE_ASSOCCHANGED 事件以把文件关联的更改通知给系统的方法,以及让外壳识别到更改的用法。 ↩ ↩2

  11. Microsoft Learn, Application Registration。关于推荐用 App Paths 子键注册可执行文件、Applications 子键的职责、通过 SystemFileAssociations 注册 verb,以及更改默认应用时 ProgID 与相关信息的优先顺序。 ↩

  12. Microsoft Learn, Creating Shortcut Menu Handlers。关于静态 verb 的注册方法、默认 verb 的确定顺序(默认值→第一个 verb→Open→Open With)、标准 verb 的显示名称由操作系统提供、用 Extended 实现扩展 verb、与 DDE 命令的关联已被弃用(Deprecated),以及 64 位环境下 WOW64 重定向的注意事项。 ↩ ↩2 ↩3

  13. Microsoft Learn, Verbs and File Associations。关于 verb 也用于 ShellExecuteEx、命令字符串中可能含空格的组成部分必须用引号括起来、”%1” 应始终带引号书写,以及在 HKCR\Applications 下注册默认处理过程。 ↩

  14. Microsoft Learn, IExplorerCommand interface。关于 GetTitle、GetIcon、GetState、Invoke、EnumSubCommands 等方法构成、方法在 UI 线程上被调用因而不得与网络资源通信,以及从 Windows Vista 起可用。 ↩

  15. Microsoft Learn, Windows Sandbox。关于可在几秒内启动一次性的隔离 Windows 环境、关闭后所有更改都被丢弃、适合软件测试与安装程序验证,以及在 Pro/Enterprise/Education 上可用。 ↩

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

为什么自家应用的右键菜单项在 Windows 11 上只出现在“显示更多选项”里面?
因为 Windows 11 把文件资源管理器的右键菜单拆成了新旧两层。能把项放到新菜单上的,只有实现了 IExplorerCommand 接口并在 MSIX 包清单中注册(即具备包标识)的命令,基于传统 IContextMenu 的外壳扩展被移到了用“显示更多选项”(Shift+F10)打开的旧菜单一侧。扩展本身并没有坏掉,所以暂时仍能照常工作;但如果想让它出现在新菜单上,就需要迁移到 IExplorerCommand,并通过改用 MSIX 或 sparse package 来获得标识。
可以由安装程序把自家应用设成文件的默认应用(双击时打开的那个应用)吗?
不能。默认应用的选择被设计为由用户完成,Windows 不支持从系统设置 UI 以外的地方更改默认应用。保存各用户选择的 UserChoice 信息经过混淆,并且由筛选器驱动程序(UCPD.sys)阻止应用写入。安装程序能做的,只到注册 ProgID 和 verb、把自己加入 OpenWithProgIds 以出现在“打开方式”的候选中,以及把用户引导到默认应用设置页面为止。正确的实现不是抢走默认值,而是把被选中的准备做好。
可以用 C# 这类托管代码编写外壳扩展吗?
Microsoft 已明确表示,用托管代码编写进程内加载的外壳扩展(上下文菜单处理程序、图标处理程序等)既不推荐也不提供支持。因为扩展会被加载进文件资源管理器,以及任何打开了通用文件对话框的应用进程中,CLR 版本冲突、重入以及对象生存期的非确定性都会让宿主应用变得不稳定。实现上原则是用本机 C++。另一方面,如果是由 verb 的 command 启动的普通 EXE,或在另一个进程中运行的进程外扩展(例如预览处理程序),用托管代码就没有问题。
什么是 sparse package(带外部位置的 MSIX)?
它是一个不含应用本体文件、只带清单(标识信息)的小型 MSIX 包。对于用现有安装程序(MSI、Inno Setup 等)正常安装的应用,只要用 Add-AppxPackage 的 -ExternalLocation 指向安装目录来注册,该应用就能取得包标识,从而使用注册到 Windows 11 新上下文菜单、Toast 通知等必须具备标识的功能。它从 Windows 10 版本 2004 起可用,并且包需要用目标计算机信任的代码签名。当你不想把分发方式全面迁移到 MSIX 又想支持新菜单时,它是务实的选择。
右键菜单项出现两次、或者怎么也消不掉时该怎么办?
首先作为排查的第一步,确认它出现在新菜单还是旧菜单(显示更多选项)里。重复显示的典型原因,是旧方式的注册表注册与 MSIX 清单注册同时存在,或者卸载时没有清理掉 ProgID、扩展的 CLSID 注册而留下了残留。更改关联之后,还要怀疑是否漏发了 SHChangeNotify(SHCNE_ASSOCCHANGED);刚注册完包时,则要怀疑是否漏了重启文件资源管理器。如果仍未解决,可以用 ShellExView 临时禁用非 Microsoft 出品的扩展并做二分查找,就能定位出问题的 DLL。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表