今日的 Windows 外壳集成 ── 上下文菜单、文件关联,以及 Windows 11 改了什么

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

有人来咨询:「换成 Windows 11 电脑后,你们多年前帮我们做的应用上下文菜单消失了」。再仔细听,其实没有消失。对文件右键单击,选菜单最下方的 「显示更多选项」,熟悉的菜单就跟以前一样出现。也就是说,内部应用的菜单项被再藏深了一层点击。现场传来「多点一下」以及「找不到项的询问变多了」。

这既不是故障也不是设置错误,而是 Windows 11 的设计变更。文件资源管理器的上下文菜单变成新旧两层结构,要把项放到新菜单上的条件,也跟以前完全不同。

另一方面,底下的文件关联与外壳扩展机制,至今仍是 COM 与注册表的旧世界。扩展名键指向 ProgID,ProgID 的 verb 保存命令行,较复杂的扩展则以加载到文件资源管理器的进程内 COM 服务器(DLL)运行——这套结构二十多年没变。若不同时掌握没变的基础、以及 Windows 11 拆成两层的菜单,就无法区分「菜单不出现」、「被藏起来」或「出现两次」。

本文面向中小企业的 IT 人员,以及维护业务应用的 Windows 开发者,把文件关联的三层结构、传统外壳扩展的注意事项、如何对准 Windows 11 新上下文菜单,以及安装程序的注册、清理与故障排除,收成同一张图。

1. 先讲结论

  • 上下文菜单与文件关联的基础,是「扩展名键 → ProgID → verb」这三层注册表结构。 扩展名键是指向 ProgID 的指针,ProgID 才是实体,其下的 shell\<verb>\command 保存命令行。1
  • HKEY_CLASSES_ROOT(HKCR)不是独立 hive,而是 HKLM\Software\Classes 与 HKCU\Software\Classes 的合并视图。 全体用户注册写到 HKLM,单用户注册写到 HKCU,把 HKCR 当只读。2
  • 默认应用(双击打开的那个)被设计成由用户选择,程序无法抢走。 操作系统保护用户的选择;安装程序能做的是把自己注册成候选。3
  • 传统外壳扩展是加载到文件资源管理器的进程内 COM DLL。 扩展崩溃或延迟会波及整个文件资源管理器(以及使用外壳的其他应用);64 位环境需要 64 位 DLL;托管代码实现不受支持。45
  • Windows 11 把上下文菜单拆成两层。 能出现在新菜单上的,只有以 IExplorerCommand 加上包标识注册的命令;传统 IContextMenu 扩展被移到「显示更多选项」(Shift+F10)的旧菜单。67
  • 把自定义命令放到新菜单的官方路径,是把实现 IExplorerCommand 的本机 DLL 注册到 MSIX 清单(desktop4:FileExplorerContextMenus)。 无法改成 MSIX 的应用,可以用 sparse package(外部位置的 MSIX)只取得标识。78
  • 若只想「用这个应用打开」,关联加上静态 verb 仍然够用。 不需要外壳扩展 DLL,Microsoft 自己也明白写着「选择符合需求的最简单方法(静态 verb)」。9
  • 注册或更改后,用 SHChangeNotify(SHCNE_ASSOCCHANGED) 通知;卸载时删除 ProgID,但不要删扩展名键的默认值——这是官方指引。 外壳集成也包含清理的设计。110

一句话:关联与 verb 的世界没变;只有菜单怎么显示,在 Windows 11 被拆成两层。 以下从基础往上走。

2. 文件关联怎么运作 ── 扩展名键 → ProgID → verb 的三层结构

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

双击某个扩展名的文件时会发生什么,由三层注册表键决定。1

HKEY_CLASSES_ROOT
   .kmrpt                                  ← (1) 扩展名键
      (Default) = KomuraSoft.Report.1      ←     只指名 ProgID 的指针
      OpenWithProgids
         KomuraSoft.Report.1               ←     「打开方式」的候选
   KomuraSoft.Report.1                     ← (2) ProgID(关联的实体)
      (Default) = Komura Report document
      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) ProgIDKomuraSoft.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 获胜。2

HKCR 是合并视图HKCR 是把 HKLM 与 HKCU 的 Classes 叠在一起;两边有同一个键时 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 当确认用的只读。与 WOW64 注册表重定向的关系也值得厘清。HKLM\Software\Classes 正下方的扩展名键与 ProgID 这类关联数据,自 Windows 7 起在 32 位与 64 位注册表视图之间是共享的,所以 32 位安装程序写入不会逃到 Wow6432Node。另一方面,Classes\CLSID 这类 COM 注册子键会被重定向,注册外壳扩展(进程内 COM)时,32 位/64 位的写入分法就很重要。细节见「注册表的32位/64位重定向与虚拟化陷阱 ── Wow6432Node与「写入的值找不到了」问题」。

2.3. 应用端的注册 ── App Paths、Applications、RegisteredApplications

与文件端(扩展名与 ProgID)配对的,还有三种应用端注册。11

  • App PathsHKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths):让 ShellExecuteEx 只靠可执行文件名就能启动的注册。Microsoft 建议这样做,因为不必污染 PATH 环境变量。
  • ApplicationsHKCR\Applications\<app.exe>):定义在「打开方式」交出任意文件时的默认打开方式,以及应用的显示名称(FriendlyAppName)。
  • RegisteredApplications + Capabilities:声明应用能处理的扩展名与 MIME 类型,并让它出现在 Windows 默认应用设置页的候选列表。

「我们的应用没出现在默认应用列表」这类咨询,多数是注册了 ProgID、却漏了这份 Capabilities 注册。

应用端的三种注册应用端注册有 App Paths、Applications、RegisteredApplications 三种,分别负责只靠文件名启动、打开方式的默认开法,以及出现在默认应用设置页应用端注册App PathsApplicationsRegisteredApplications只靠文件名启动打开方式的默认出现在默认应用页需要 Capabilities 声明

图 3: 应用端注册有三种,要成为默认应用候选需要 Capabilities 注册。

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

把 ProgID 写成扩展名键的默认值,并不会因此成为默认应用。用户在「打开方式」等处明确选择的结果,保存在 HKCU\...\Explorer\FileExts\<extension>\UserChoice,关联解析会优先采用那一边。

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

像 SetUserFTA 这种「模仿哈希再改写」的工具之所以被使用,正是这层保护的另一面。内部应用的安装程序该放的不是抢走默认值,而是 (a) 正确注册 ProgID 与 verb、(b) 把自己加到 OpenWithProgIds、(c) 必要时引导到设置页 这三件事。

默认应用解析与 UserChoice 保护用户明确选择的结果保存在 UserChoice 并在关联解析中优先;UCPD.sys 阻止应用改写,因此安装程序能做的是注册成候选并引导到设置页优先UCPD.sys 阻止UserChoice(用户的选择)关联解析扩展名键默认值从应用改写安装程序的工作注册 ProgID 与 verb加到 OpenWithProgIds引导到设置页

图 4: 关联解析优先采用用户的选择(UserChoice),操作系统保护它不被应用改写。

3. 「打开」以外的 verb ── print、edit、runas、自定义 verb

verb 不只是 open。操作系统认得意义的标准 verb,除了 open 还有 editprintplaypreview,标准 verb 会自动取得跟随操作系统区域设置的显示名称。双击使用的默认 verb,依次为:shell 键的默认值 → 注册表中的第一个 verb → openopenwith12

默认 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) = Verify report (&V)   ← 菜单显示名称
         command
            (Default) = "C:\Program Files\KomuraSoft\Report.exe" /verify "%1"

三件知道了就有帮助的小事。

  • 注册名为 runas 的 verb,就定义了相当于「以管理员身份运行」的提升启动,ShellExecute 系列 API 指定 runas 时也会用到。
  • 在 verb 键放一个名为 Extended 的空值,就会变成 只有 Shift+右键才显示的扩展 verb。适合把很少用、又危险的操作藏起来。12
  • 较旧应用的部分关联仍用 DDEddeexec 键)把文档送进既有进程,但以 DDE 启动 verb 已是 Deprecated 的旧机制。没有理由新写。12

还有一件常发生的事故是 命令行的引号。命令字符串的元素若可能含空格,就必须用引号包起来。这当然适用于 C:\Program Files\... 这类 EXE 路径,而且 %1(所选文件的路径)应一律写成 "%1"。无法保证用户的文件路径不含空格。没加引号的 My Program.exe 会被解读成「启动 My,参数是 Program.exe」。13

命令行的引号事故没加引号的命令会在空格处被切开,误当成启动 My 并带参数 Program.exe,因此可能含空格的 EXE 路径、以及代表所选文件路径的 %1,都应一律用引号包起来在空格处切开没加引号的命令被误当成启动别的 EXE有引号的命令按预期启动用引号包住 EXE 路径%1 也一律用引号包住

图 6: 没加引号的命令会在空格处被错误切开,因此 EXE 路径与 %1 一律用引号包住。

到目前为止只靠注册表的机制(静态 verb)不必写任何 DLL,也不会让文件资源管理器不稳定。Microsoft 自己一再说「在写外壳扩展之前,先考虑符合需求的最简单静态 verb 是否就够了」。9

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

4.1. 外壳扩展的种类

静态 verb 做不到的需求——「依所选内容动态更改菜单」、「替换图标或属性表」——要用外壳扩展处理程序。代表性种类如下。4

处理程序 主要接口 能做的事
上下文菜单处理程序 IContextMenu + IShellExtInit 动态添加与控制菜单项
图标处理程序/图标叠加 IExtractIcon / IShellIconOverlayIdentifier 每个文件的图标与叠加
属性表处理程序 IShellPropSheetExt 在属性表加选项卡
缩略图/信息提示 IThumbnailProvider / IQueryInfo 缩略图视图与悬停说明
拖放/复制钩子处理程序 IDropTarget / ICopyHook 在放置或复制/移动时介入

这些都以 COM 类实现,并以 CLSID 注册到注册表。COM 本身的概念见「COM / ActiveX / OCX 是什么 - 区别与关系整理」。

4.2. 身为进程内 COM 服务器意味着什么

传统外壳扩展的本质是 加载到文件资源管理器(或任何打开通用文件对话框的应用)的进程内 COM 服务器(DLL)。每一项注意事项都由此而来。4

  • 扩展崩溃,文件资源管理器会一起倒下。 若它挂住,右键会冻结好几秒。损害也不限于文件资源管理器,会波及所有显示过文件打开对话框的应用。
  • 菜单构建发生在 UI 线程,因此 显示菜单时不得做网络访问或文件 I/O 这类慢工作
  • 线程模型原则上注册为 Apartment
进程内扩展的连带损害结构外壳扩展 DLL 不只加载到文件资源管理器,也加载到打开文件对话框的任何应用进程,因此扩展的崩溃或挂住会波及整个宿主进程进程内加载进程内加载外壳扩展 DLL文件资源管理器打开对话框的任何应用崩溃或挂住会扩散显示时不要做慢工作

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

调查「打开特定文件夹时文件资源管理器冻结」或「右键要五秒」这类咨询时,原因往往不是内部应用,而是第三方外壳扩展。隔离方法在第 8 章。

4.3. 对齐位数 ── 64 位环境需要 64 位 DLL

进程内 DLL 必须与加载它的进程位数一致。64 位 Windows 的文件资源管理器是 64 位进程,因此 只建成 32 位的外壳扩展 DLL 永远不会被加载,菜单上也完全不会出现。而且没有错误,所以是「注册了却不出现」的常客。32 位应用本体搭配 64 位外壳扩展 DLL 是正当配置,但要注意 COM 注册按位数分开(Wow6432Node)。从 verb 的 command 启动的是独立进程 EXE,不受此限制(维持 32 位 EXE 没问题)。

外壳扩展 DLL 的位数对齐64 位文件资源管理器能加载的外壳扩展 DLL 只有 64 位;仅 32 位的 DLL 不会出现在菜单上也没有错误;从 verb command 启动的 EXE 是独立进程,不受此限制可加载无法加载独立进程64 位文件资源管理器64 位外壳扩展 DLL仅 32 位的 DLL没有错误、菜单上不出现从 verb 启动的 EXE维持 32 位没问题

图 8: 加载到 64 位文件资源管理器的只有 64 位 DLL;从 verb 启动的 EXE 不受此限制。

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

常有人问「可以用 C# 写外壳扩展吗」,但 Microsoft 已明确表示,用托管代码(.NET)编写进程内外壳扩展既不建议也超出支持范围5

原因是扩展会加载到任意进程。CLR 版本冲突(尤其在 .NET Framework 4 以下)、CLR 在等待锁时重入消息循环、垃圾回收的非确定性对象生存期与 COM 引用计数契约冲突,都是让宿主应用不稳定的结构性理由。部分项目在 .NET Framework 4 之后与现代 .NET 已缓解,但官方立场没变。

实务准则很单纯。进程内扩展用本机 C++ 写。若要用托管代码,就做成从 verb 的 command 启动的普通 EXE,或在独立进程运行的进程外扩展(预览处理程序等)。5

判断托管代码是否允许在文件资源管理器内运行的进程内扩展原则上用本机 C++ 编写;若要用托管代码,就做成从 verb command 启动的普通 EXE,或在独立进程运行的进程外扩展在进程内运行?用本机 C++ 写托管代码没问题CLR/重入风险使宿主变得不稳定由 verb 启动的 EXE进程外预览

图 9: 进程内扩展原则上是本机 C++;托管代码限于在独立进程运行的配置。

5. Windows 11 的新上下文菜单 ── 拆成两层的菜单

5.1. 发生了什么

Windows 11 重画了文件资源管理器的上下文菜单。剪切、复制等变成顶部一排图标;「打开」与「打开方式」集中在上方;应用加入的命令则集中在外壳标准命令下方。同一个应用加入多个命令时,会收进以应用命名的浮出(子菜单)。6

关键在这里。以传统 IContextMenu 为基础的外壳扩展并没有被删除;它们被移到用「显示更多选项」(Shift+F10)打开、原样加载 Windows 10 菜单的旧菜单那一侧。6 开头那则「菜单被藏起来」咨询的真面目,就是这次拆分。

Windows 11 拆成两层的上下文菜单右键先打开的是新菜单;能出现在那里的命令只有以 IExplorerCommand 与包标识注册的项;传统 IContextMenu 扩展被移到用显示更多选项打开的旧菜单显示更多选项 Shift+F10对文件右键单击新菜单(Windows 11)IExplorerCommand + 标识的命令旧菜单(Windows 10 菜单)传统 IContextMenu 扩展多个命令收进浮出

图 10: 能出现在新菜单上的只有 IExplorerCommand + 标识的命令;传统扩展被移到旧菜单那一侧。

5.2. 进入新菜单的官方路径 ── IExplorerCommand + 清单注册

要把自定义命令放到新菜单,只有一种做法。准备实现 IExplorerCommand 接口的本机 DLL,并在 MSIX 包清单中声明 COM 服务器与上下文菜单扩展。7

<!-- 包清单(摘录) -->
<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>

ItemTypeType 可以指定特定扩展名,或 *(所有文件)、Directory(文件夹)、Directory\Background(文件夹背景)。DLL 要对齐文件资源管理器的体系结构(64 位/ARM64)。7

IExplorerCommand 本身是 Windows 7 时代就有的接口;你实现标题(GetTitle)、图标(GetIcon)、启用/禁用/隐藏状态(GetState)与执行(Invoke)。方法从 UI 线程调用,因此 禁止访问网络资源,菜单构建方法也必须很快返回。繁重工作放在 Invoke 之后。147

新菜单注册的清单结构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 起可用,包需要目标计算机信任的证书签名。8

用 sparse package 取得标识的流程现有安装程序放好应用本体后,以外部位置注册只有清单的 sparse package,应用就取得包标识,并能做新菜单的清单注册现有安装程序放置应用本体sparse package只有清单,没有本体以外部位置注册取得包标识新菜单注册成为可能需要受信任的签名

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

最大优点是不必更换安装程序;对已有 MSI/EXE 安装程序资产的应用,这是务实的答案。与全面改成 MSIX 的比较,也可见「Windows 应用发布方式怎么选 - MSI / MSIX / ClickOnce / xcopy / 自定义 updater 判断指南」。

5.4. 关联 verb 如何出现在新菜单

容易误解的一点:第 2、3 章的关联(ProgID 与 verb)在新菜单上仍然有效。 双击的默认 verb、「打开」、以及「打开方式」候选,都从关联解析并显示在新菜单上方。所以若只想「能用这个应用打开」,Windows 11 不需要额外工作。另一方面,关联不是通用菜单扩展,若要把任意自定义命令放到新菜单第一层,就需要 IExplorerCommand 加上标识——这就是角色分工。7

关联与新菜单的角色分工ProgID 与 verb 关联在新菜单上仍用来解析默认 verb、打开、打开方式,并显示在上方;要把任意自定义命令放到新菜单第一层,需要 IExplorerCommand 与标识关联(ProgID + verb)解析默认/打开新菜单上方Win11 不需额外工作自定义命令IExplorerCommand+标识新菜单第一层

图 13: 关联在新菜单上仍负责「打开」系列解析;只有自定义命令才需要 IExplorerCommand 加上标识。

6. 实务判断表 ── 三个选项要选哪一个

把到目前为止整理成实务上的三选一。

想达成的事 建议手段 在 Windows 11 的样子 所需工作与成本
(a) 双击或「打开」就启动内部应用 关联 + 静态 verb(只做注册表注册) 整合进新菜单的「打开」与「打开方式」 只需安装程序注册表注册。没有 DLL,也没有额外签名要求
(b) 把针对所选文件/文件夹的自定义命令放到新菜单 IExplorerCommand 实现 + MSIX 清单注册。未打包应用用 sparse package 取得标识 新菜单第一层(多个命令收进应用名称浮出) 本机 C++ DLL + 包标识 + 代码签名
(c) 继续使用既有的传统 IContextMenu 扩展 暂时维持现状(新开发不要选它) 仅旧菜单那一侧,在「显示更多选项」(Shift+F10)下面 维持 64 位构建与 COM 注册。规划之后迁移到 (b)

判断点有两个。第一,不要为 (a) 就能满足的需求引进 (b) 或 (c)。一旦写外壳扩展,就要为文件资源管理器的稳定性负责。第二,(c) 只是「没坏掉」;就用户体验而言仍差一步。日常作业中越常使用的命令,迁移到 (b) 的回报越大。

三个选项怎么选若只想双击或打开就启动,关联与静态 verb 就够;要把自定义命令放到新菜单就用 IExplorerCommand 与 MSIX 清单注册;无法改成 MSIX 就用 sparse package 赋予标识;既有传统 IContextMenu 扩展暂时留在旧菜单那一侧打开就够了?关联 + 静态 verb新菜单上的自定义命令?能改成 MSIX?IExplorerCommand+MSIXsparse-pkg 标识暂时维持传统仅旧菜单那一侧没有 DLL,风险小

图 14: 按需求在静态 verb、IExplorerCommand 加上标识、以及维持传统之间选择。

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

7.1. HKLM 还是 HKCU

对齐安装程序的形态。全体用户(放在 Program Files 底下、需要管理员权限)用 HKLM\Software\Classes;单用户安装(不提升)用 HKCU\Software\Classes。混用会产生「A 打得开、B 打不开」这类询问。涉及 CLSID 注册的外壳扩展,Reg-Free COM——让注册表注册本身变得不必要——对应用内 COM 使用是有效选项,但无法套用到文件资源管理器加载的外壳扩展,因此需要正面注册(「什么是 Reg-Free COM——无需注册使用 COM 的机制」)。

7.2. 更改后要通知 ── SHChangeNotify

注册、更改或删除关联后,用 SHChangeNotify 通知 SHCNE_ASSOCCHANGED 事件。省略这一步,文件资源管理器可能直到重启才察觉更改。110

// 更改关联后调用一次,例如从安装程序自定义操作
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr);

7.3. 注册与移除 sparse package

注册与移除 sparse package 是安装程序的工作。放好文件后再注册;删文件之前先移除。8

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

# 卸载时:删文件之前先移除包注册
Remove-AppxPackage <package full name>

要注意:Add-AppxPackage 是为 执行它的那位用户 注册。若从每台计算机 MSI 的自定义操作、以 LocalSystem 调用,并不会把标识授给实际安装的用户,因此要设置成以用户模拟执行。即便如此,模拟下的注册也 只属于执行该次安装的那位用户。多人共用的计算机上,其他用户与之后创建的用户没有包标识,命令就不会出现在新菜单。若要让每位用户都能用,提供例如首次启动时检查自己的包注册、缺了就注册的机制(每用户注册),并把从每位已注册用户移除纳入卸载计划。反映清单注册也可能需要重启文件资源管理器(或注销)。7

注册与移除 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;留下扩展名键默认值,因为未注册的 ProgID 会被忽略;清理结束时用 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 路径,然后重启文件资源管理器。7
  5. 漏了通知:若忘了 SHChangeNotify,看重启文件资源管理器是否生效就能判断。
菜单上不出现时的隔离顺序先确认你看的是哪一面菜单,再依序隔离 DLL 位数、注册表注册目标、包注册与签名,以及漏掉的 SHChangeNotify确认看的是旧或新菜单确认 DLL 位数确认 HKLM 与 HKCU 注册目标确认包注册与签名用重启判断是否漏通知

图 17: 「不出现」时,依你看的菜单、位数、注册目标、包注册、漏通知的顺序隔离。

8.2. 出现两次,或消不掉

典型原因是传统注册表注册与清单注册并存、卸载清理漏洞(第 7.4 节),或旧版 ProgID 残留。若只在旧菜单出现两次,想残留;若新旧两边都出现,想并存。

隔离双重显示只在旧菜单出现两次指向清理漏洞或旧 ProgID 等残留;新旧两边都出现两次指向传统注册表注册与清单注册并存仅旧菜单新旧两边在哪里出现两次?残留并存清理漏洞或留下的旧 ProgID传统注册表注册与新注册并存

图 18: 只在旧菜单出现两次指向残留;新旧两边都出现两次指向并存。

8.3. 文件资源管理器变重或崩溃

右键变慢、或特定文件夹崩溃时,先盘点已安装的外壳扩展。用 NirSoft 的 ShellExView 等工具列出非 Microsoft 扩展,暂时禁用可疑项,再用二分查找找出元凶 DLL。崩溃时,事件查看器的「Faulting module」也是线索。若内部扩展是原因,怀疑菜单构建路径上的同步 I/O 或网络访问(第 4.2 与 5.2 节)。

变重或崩溃时找出元凶 DLL在 ShellExView 列出非 Microsoft 外壳扩展,暂时禁用可疑项并用二分查找找出元凶 DLL;崩溃时事件查看器的 faulting module 也是线索盘点外壳扩展列出非 Microsoft 的暂时禁用并二分查找找出元凶 DLL崩溃时检查 faulting module

图 19: 暂时禁用非 Microsoft 扩展并做二分查找;崩溃时也用事件查看器。

8.4. 验证时 Windows Sandbox 很方便

外壳集成的验证,基础是确认「在干净环境安装 → 操作 → 卸载 → 残留为零」。这里方便的是 Windows Sandbox(Pro/Enterprise/Education):每次启动几秒内就出现全新的一次性 Windows,安装程序的注册与清理测试可以跑任意多次。关掉就全部消失,也适合调查残留注册表。15

9. 总结

  • 文件关联是「扩展名键 → ProgID → verb」的三层结构,HKCR 是 HKLM/HKCU Classes 的合并视图。写入目标要指明,%1 一律用引号包住。
  • 默认应用被设计成由用户选择,无法从程序更改。安装程序的工作是正确注册成候选。
  • 传统外壳扩展是加载到文件资源管理器的进程内 COM DLL。崩溃或延迟会波及全体;需要 64 位;托管代码不受支持;原则是用本机 C++ 实现。
  • Windows 11 把上下文菜单拆成两层。要把自定义命令放到新菜单,需要 IExplorerCommand 加上 MSIX 清单;传统 IContextMenu 被移到「显示更多选项」那一侧。
  • 无法改成 MSIX 的应用,用 sparse package(外部位置的 MSIX)取得标识是务实答案。
  • 若只想「用这个应用打开」,关联加上静态 verb 仍然够用。从最简单的手段开始,也是官方准则。
  • 注册、更改或删除后用 SHChangeNotify 通知;卸载时删除 ProgID,但留下扩展名键默认值。验证时 Windows Sandbox 很方便。

若换成 Windows 11 电脑后才发现「菜单被藏起来」,先用第 6 章判断表确认属于 (a)、(b) 还是 (c)。当场就该能估出工作规模。

相关文章

相关咨询领域

小村软件有限公司承接业务应用的文件关联、上下文菜单与外壳扩展的设计与实现;对准 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, HKEY_CLASSES_ROOT Key. 关于 HKEY_CLASSES_ROOT 是 HKLM\Software\Classes 与 HKCU\Software\Classes 的合并视图、用户端定义优先于计算机端,以及写入时的分派规则。  2

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

  4. Microsoft Learn, Working with Shell Extensions. 关于外壳扩展处理程序的种类、扩展是加载到文件资源管理器(以及承载外壳的进程)的进程内 COM DLL 因此崩溃或挂住会波及整个文件资源管理器、以 ThreadingModel=Apartment 注册,以及在写外壳扩展之前先考虑更简单的替代方案。  2 3

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

  6. Windows Developer Blog, Extending the Context Menu and Share Dialog in Windows 11. 关于 Windows 11 新上下文菜单的设计、通过 IExplorerCommand 加上应用标识扩展、把「打开」与「打开方式」放在上方、把多个命令收进应用名称浮出,以及传统 IContextMenu 扩展在「显示更多选项」(Shift+F10)下以 Windows 10 菜单加载。  2 3

  7. 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

  8. Microsoft Learn, Grant package identity by packaging with external location. 关于不更改现有安装程序、以注册外部位置包(sparse package)取得包标识、自 Windows 10 版本 2004 起可用,以及需要标识的 Windows 功能(上下文菜单注册、通知等)变得可用。  2 3

  9. Microsoft Learn, Choosing a Static or Dynamic Shortcut Menu Method. 关于选择符合需求的最简单静态 verb 方法、IContextMenu 最强但也最复杂并被归到不建议那一侧,以及 IExplorerCommand/IExplorerCommandState 才是建议方法。  2

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

返回博客列表