今日的 Windows 外壳集成 ── 上下文菜单、文件关联,以及 Windows 11 改了什么
· Go Komura · 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) ProgID(
KomuraSoft.Report.1)是关联的实体,保存显示名称、图标与 verb 列表。 - (3) verb 是「打开」或「打印」这类动作,
shell\open\command的默认值才是实际启动的命令行。
因为这样分开,才能把多个扩展名(例如 .kmrpt 与 .kmrpt-file)指到同一个 ProgID,或在升级应用时更换 ProgID。
flowchart TB
accTitle: 文件关联的三层结构
accDescr: 扩展名键是默认值指名 ProgID 的指针;ProgID 是保存显示名称、图标与 verb 列表的实体;verb 底下 command 的默认值才是实际启动的命令行
ext["扩展名键 .kmrpt"] -->|默认值指名 ProgID| pid["ProgID KomuraSoft.Report.1"]
pid --> vb["verb(shell 底下的 open 等)"]
vb --> cmd["command 默认值"]
cmd --> exe["启动 Report.exe"]
pid -.-> attr["也保存显示名称与 DefaultIcon"]
图 1: 扩展名键是指针,ProgID 是实体,verb 的 command 才是实际启动的命令行。
2.2. HKCR 是「合并视图」── 写到哪里,意义就不同
上面的例子画在 HKEY_CLASSES_ROOT(HKCR)底下,但 HKCR 不是物理存放位置,而是 HKLM\Software\Classes 与 HKCU\Software\Classes 的合并视图。两边有同一个键时,HKCU 获胜。2
flowchart TB
accTitle: HKCR 是合并视图
accDescr: HKCR 是把 HKLM 与 HKCU 的 Classes 叠在一起;两边有同一个键时 HKCU 获胜;注册要明确写到 HKLM 或 HKCU,把 HKCR 当只读
hklm["HKLM\\Software\\Classes(全体用户)"] --> hkcr["HKCR(合并视图)"]
hkcu["HKCU\\Software\\Classes(单用户)"] --> hkcr
hkcu -.-> win["同一键存在时 HKCU 获胜"]
hkcr -.-> ro["当只读(用来确认)"]
图 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 Paths(
HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths):让ShellExecuteEx只靠可执行文件名就能启动的注册。Microsoft 建议这样做,因为不必污染 PATH 环境变量。 - Applications(
HKCR\Applications\<app.exe>):定义在「打开方式」交出任意文件时的默认打开方式,以及应用的显示名称(FriendlyAppName)。 - RegisteredApplications + Capabilities:声明应用能处理的扩展名与 MIME 类型,并让它出现在 Windows 默认应用设置页的候选列表。
「我们的应用没出现在默认应用列表」这类咨询,多数是注册了 ProgID、却漏了这份 Capabilities 注册。
flowchart TB
accTitle: 应用端的三种注册
accDescr: 应用端注册有 App Paths、Applications、RegisteredApplications 三种,分别负责只靠文件名启动、打开方式的默认开法,以及出现在默认应用设置页
app["应用端注册"] --> ap["App Paths"]
app --> apps["Applications"]
app --> ra["RegisteredApplications"]
ap --> r1["只靠文件名启动"]
apps --> r2["打开方式的默认"]
ra --> r3["出现在默认应用页"]
r3 -.-> cap["需要 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) 必要时引导到设置页 这三件事。
flowchart TB
accTitle: 默认应用解析与 UserChoice 保护
accDescr: 用户明确选择的结果保存在 UserChoice 并在关联解析中优先;UCPD.sys 阻止应用改写,因此安装程序能做的是注册成候选并引导到设置页
uc["UserChoice(用户的选择)"] -->|优先| res["关联解析"]
ext["扩展名键默认值"] --> res
wr["从应用改写"] -.->|UCPD.sys 阻止| uc
res ~~~ inst["安装程序的工作"]
inst --> a1["注册 ProgID 与 verb"]
inst --> a2["加到 OpenWithProgIds"]
inst --> a3["引导到设置页"]
图 4: 关联解析优先采用用户的选择(UserChoice),操作系统保护它不被应用改写。
3. 「打开」以外的 verb ── print、edit、runas、自定义 verb
verb 不只是 open。操作系统认得意义的标准 verb,除了 open 还有 edit、print、play、preview,标准 verb 会自动取得跟随操作系统区域设置的显示名称。双击使用的默认 verb,依次为:shell 键的默认值 → 注册表中的第一个 verb → open → openwith。12
flowchart TB
accTitle: 默认 verb 的决定顺序
accDescr: 双击使用的默认 verb,是依 shell 键默认值、注册表中第一个 verb、open、openwith 的顺序找到的第一个
s1["shell 键默认值"] -->|若无| s2["注册表中第一个 verb"]
s2 -->|若无| s3["open"]
s3 -->|若无| s4["openwith"]
图 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 - 较旧应用的部分关联仍用 DDE(
ddeexec键)把文档送进既有进程,但以 DDE 启动 verb 已是 Deprecated 的旧机制。没有理由新写。12
还有一件常发生的事故是 命令行的引号。命令字符串的元素若可能含空格,就必须用引号包起来。这当然适用于 C:\Program Files\... 这类 EXE 路径,而且 %1(所选文件的路径)应一律写成 "%1"。无法保证用户的文件路径不含空格。没加引号的 My Program.exe 会被解读成「启动 My,参数是 Program.exe」。13
flowchart TB
accTitle: 命令行的引号事故
accDescr: 没加引号的命令会在空格处被切开,误当成启动 My 并带参数 Program.exe,因此可能含空格的 EXE 路径、以及代表所选文件路径的 %1,都应一律用引号包起来
c1["没加引号的命令"] -->|在空格处切开| bad["被误当成启动别的 EXE"]
c2["有引号的命令"] --> good["按预期启动"]
c2 -.-> q1["用引号包住 EXE 路径"]
q1 -.-> q2["%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。
flowchart TB
accTitle: 进程内扩展的连带损害结构
accDescr: 外壳扩展 DLL 不只加载到文件资源管理器,也加载到打开文件对话框的任何应用进程,因此扩展的崩溃或挂住会波及整个宿主进程
dll["外壳扩展 DLL"] -->|进程内加载| exp["文件资源管理器"]
dll -->|进程内加载| any["打开对话框的任何应用"]
exp --> dmg["崩溃或挂住会扩散"]
any --> dmg
dmg -.-> rule["显示时不要做慢工作"]
图 7: 扩展 DLL 在宿主进程内运行,因此崩溃或挂住会波及整个宿主。
调查「打开特定文件夹时文件资源管理器冻结」或「右键要五秒」这类咨询时,原因往往不是内部应用,而是第三方外壳扩展。隔离方法在第 8 章。
4.3. 对齐位数 ── 64 位环境需要 64 位 DLL
进程内 DLL 必须与加载它的进程位数一致。64 位 Windows 的文件资源管理器是 64 位进程,因此 只建成 32 位的外壳扩展 DLL 永远不会被加载,菜单上也完全不会出现。而且没有错误,所以是「注册了却不出现」的常客。32 位应用本体搭配 64 位外壳扩展 DLL 是正当配置,但要注意 COM 注册按位数分开(Wow6432Node)。从 verb 的 command 启动的是独立进程 EXE,不受此限制(维持 32 位 EXE 没问题)。
flowchart TB
accTitle: 外壳扩展 DLL 的位数对齐
accDescr: 64 位文件资源管理器能加载的外壳扩展 DLL 只有 64 位;仅 32 位的 DLL 不会出现在菜单上也没有错误;从 verb command 启动的 EXE 是独立进程,不受此限制
exp["64 位文件资源管理器"] -->|可加载| d64["64 位外壳扩展 DLL"]
exp -.->|无法加载| d32["仅 32 位的 DLL"]
d32 -.-> sym["没有错误、菜单上不出现"]
exe["从 verb 启动的 EXE"] -->|独立进程| ok32["维持 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
flowchart TB
accTitle: 判断托管代码是否允许
accDescr: 在文件资源管理器内运行的进程内扩展原则上用本机 C++ 编写;若要用托管代码,就做成从 verb command 启动的普通 EXE,或在独立进程运行的进程外扩展
q1{"在进程内运行?"} -->|是| cpp["用本机 C++ 写"]
q1 -->|否| mg["托管代码没问题"]
cpp -.-> why["CLR/重入风险使宿主变得不稳定"]
mg --> e1["由 verb 启动的 EXE"]
mg --> e2["进程外预览"]
图 9: 进程内扩展原则上是本机 C++;托管代码限于在独立进程运行的配置。
5. Windows 11 的新上下文菜单 ── 拆成两层的菜单
5.1. 发生了什么
Windows 11 重画了文件资源管理器的上下文菜单。剪切、复制等变成顶部一排图标;「打开」与「打开方式」集中在上方;应用加入的命令则集中在外壳标准命令下方。同一个应用加入多个命令时,会收进以应用命名的浮出(子菜单)。6
关键在这里。以传统 IContextMenu 为基础的外壳扩展并没有被删除;它们被移到用「显示更多选项」(Shift+F10)打开、原样加载 Windows 10 菜单的旧菜单那一侧。6 开头那则「菜单被藏起来」咨询的真面目,就是这次拆分。
flowchart TB
accTitle: Windows 11 拆成两层的上下文菜单
accDescr: 右键先打开的是新菜单;能出现在那里的命令只有以 IExplorerCommand 与包标识注册的项;传统 IContextMenu 扩展被移到用显示更多选项打开的旧菜单
rc["对文件右键单击"] --> newm["新菜单(Windows 11)"]
newm --> newi["IExplorerCommand + 标识的命令"]
newm -->|显示更多选项 Shift+F10| oldm["旧菜单(Windows 10 菜单)"]
oldm --> oldi["传统 IContextMenu 扩展"]
newi -.-> fly["多个命令收进浮出"]
图 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>
ItemType 的 Type 可以指定特定扩展名,或 *(所有文件)、Directory(文件夹)、Directory\Background(文件夹背景)。DLL 要对齐文件资源管理器的体系结构(64 位/ARM64)。7
IExplorerCommand 本身是 Windows 7 时代就有的接口;你实现标题(GetTitle)、图标(GetIcon)、启用/禁用/隐藏状态(GetState)与执行(Invoke)。方法从 UI 线程调用,因此 禁止访问网络资源,菜单构建方法也必须很快返回。繁重工作放在 Invoke 之后。147
flowchart TB
accTitle: 新菜单注册的清单结构
accDescr: MSIX 清单的 COM 服务器声明把 CLSID 映射到 DLL,上下文菜单扩展声明用 ItemType 与 Verb 把目标与实现绑在一起,于是自定义命令出现在新菜单上
man["MSIX 清单"] --> com["COM 服务器声明"]
man --> ctx["菜单扩展声明"]
com -->|把 CLSID 映射到 DLL| impl["IExplorerCommand 实现 DLL"]
ctx -->|用 ItemType 与 Verb 指定| impl
impl --> shown["命令出现在新菜单"]
ctx -.-> tgt["目标是扩展名、所有文件等"]
图 11: 清单中的两份声明把实现 DLL 与目标绑在一起,命令就出现在新菜单上。
5.3. 未打包应用的选择 ── 用 sparse package 只取得标识
「我们的应用只能用 MSI 分发,不可能改成 MSIX」时的逃生门,是 sparse package(外部位置的 MSIX)。你签署一份只有清单、不含应用本体的小型 MSIX,并在现有安装程序结尾注册。应用于是取得 包标识,上面的清单注册(= 出现在新菜单)才成为可能。从 Windows 10 版本 2004 起可用,包需要目标计算机信任的证书签名。8
flowchart TB
accTitle: 用 sparse package 取得标识的流程
accDescr: 现有安装程序放好应用本体后,以外部位置注册只有清单的 sparse package,应用就取得包标识,并能做新菜单的清单注册
inst["现有安装程序"] --> files["放置应用本体"]
sp["sparse package"] -.-> only["只有清单,没有本体"]
files --> reg["以外部位置注册"]
sp --> reg
reg --> id["取得包标识"]
id --> ok["新菜单注册成为可能"]
sp -.-> sign["需要受信任的签名"]
图 12: 以外部位置注册不含本体的 sparse package,应用就取得包标识。
最大优点是不必更换安装程序;对已有 MSI/EXE 安装程序资产的应用,这是务实的答案。与全面改成 MSIX 的比较,也可见「Windows 应用发布方式怎么选 - MSI / MSIX / ClickOnce / xcopy / 自定义 updater 判断指南」。
5.4. 关联 verb 如何出现在新菜单
容易误解的一点:第 2、3 章的关联(ProgID 与 verb)在新菜单上仍然有效。 双击的默认 verb、「打开」、以及「打开方式」候选,都从关联解析并显示在新菜单上方。所以若只想「能用这个应用打开」,Windows 11 不需要额外工作。另一方面,关联不是通用菜单扩展,若要把任意自定义命令放到新菜单第一层,就需要 IExplorerCommand 加上标识——这就是角色分工。7
flowchart TB
accTitle: 关联与新菜单的角色分工
accDescr: ProgID 与 verb 关联在新菜单上仍用来解析默认 verb、打开、打开方式,并显示在上方;要把任意自定义命令放到新菜单第一层,需要 IExplorerCommand 与标识
assoc["关联(ProgID + verb)"] --> sol["解析默认/打开"]
sol --> top["新菜单上方"]
assoc -.-> keep["Win11 不需额外工作"]
cmd["自定义命令"] --> need["IExplorerCommand+标识"]
need --> first["新菜单第一层"]
图 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) 的回报越大。
flowchart TB
accTitle: 三个选项怎么选
accDescr: 若只想双击或打开就启动,关联与静态 verb 就够;要把自定义命令放到新菜单就用 IExplorerCommand 与 MSIX 清单注册;无法改成 MSIX 就用 sparse package 赋予标识;既有传统 IContextMenu 扩展暂时留在旧菜单那一侧
q1{"打开就够了?"} -->|是| pa["关联 + 静态 verb"]
q1 -->|否| q2{"新菜单上的自定义命令?"}
q2 -->|是| q3{"能改成 MSIX?"}
q3 -->|是| pb1["IExplorerCommand+MSIX"]
q3 -->|否| pb2["sparse-pkg 标识"]
q2 -->|否| pc["暂时维持传统"]
pc -.-> old["仅旧菜单那一侧"]
pa -.-> dllfree["没有 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
flowchart TB
accTitle: 注册与移除 sparse package 的顺序
accDescr: 安装时在放好文件后注册 sparse package;卸载时在删文件之前先移除注册;注意注册只对执行它的用户有效
i1["安装"] --> i2["放置文件"]
i2 --> i3["注册 sparse package"]
u1["卸载"] --> u2["移除包注册"]
u2 --> u3["删除文件"]
i3 -.-> pu["注册只对运行中的用户有效"]
图 15: 放好文件后再注册,删文件之前先移除,并注意注册是按运行中的用户。
7.4. 卸载时的清理 ── 删什么、留什么
卸载时的清理有清楚的官方指引线。1
- 删除:整个内部 ProgID 键、Capabilities/RegisteredApplications 注册、外壳扩展的 CLSID 注册、sparse package(Remove-AppxPackage)。
- 留下:扩展名键(
.kmrpt)的默认值。即使它仍指向内部 ProgID,官方建议也不要删。 安装后很难判断是否已有别的应用拿走默认值,而 Windows 会直接忽略未注册的默认值 ProgID,留下它没有实质伤害。 - 清理结束时也调用 SHChangeNotify(SHCNE_ASSOCCHANGED)。
多数「卸载了菜单上还有残留」问题,都是这份清理设计的漏洞。
flowchart TB
accTitle: 卸载时的清理设计
accDescr: 卸载时删除内部 ProgID 键、CLSID 注册与 sparse package;留下扩展名键默认值,因为未注册的 ProgID 会被忽略;清理结束时用 SHChangeNotify 通知更改
un["卸载"] --> del["删除"]
un --> keep["留下"]
del --> d1["ProgID 与 CLSID 注册"]
del --> d2["sparse package"]
keep --> k1["扩展名键默认值"]
k1 -.-> why["未注册的 ProgID 会被忽略"]
d1 --> fin["结束时用 SHChangeNotify 通知"]
k1 --> fin
图 16: 删除内部注册,留下扩展名键默认值,并在清理结束时通知更改。
8. 故障排除 ── 不见、重复、变重
8.1. 菜单上不出现
依此顺序隔离。
- 你看的是哪一面菜单:传统风格的注册只出现在 Shift+F10 的旧菜单那一侧。先两边都查。
- 位数:仅 32 位的外壳扩展 DLL 不会加载到 64 位文件资源管理器(第 4.3 节)。
- 注册目标:HKLM/HKCU、Wow6432Node 搞混。用
reg query确认实际键。 - 包注册:针对新菜单,用
Get-AppxPackage确认存在、签名证书是否受信任、以及-ExternalLocation路径,然后重启文件资源管理器。7 - 漏了通知:若忘了 SHChangeNotify,看重启文件资源管理器是否生效就能判断。
flowchart TB
accTitle: 菜单上不出现时的隔离顺序
accDescr: 先确认你看的是哪一面菜单,再依序隔离 DLL 位数、注册表注册目标、包注册与签名,以及漏掉的 SHChangeNotify
c1["确认看的是旧或新菜单"] --> c2["确认 DLL 位数"]
c2 --> c3["确认 HKLM 与 HKCU 注册目标"]
c3 --> c4["确认包注册与签名"]
c4 --> c5["用重启判断是否漏通知"]
图 17: 「不出现」时,依你看的菜单、位数、注册目标、包注册、漏通知的顺序隔离。
8.2. 出现两次,或消不掉
典型原因是传统注册表注册与清单注册并存、卸载清理漏洞(第 7.4 节),或旧版 ProgID 残留。若只在旧菜单出现两次,想残留;若新旧两边都出现,想并存。
flowchart TB
accTitle: 隔离双重显示
accDescr: 只在旧菜单出现两次指向清理漏洞或旧 ProgID 等残留;新旧两边都出现两次指向传统注册表注册与清单注册并存
q{"在哪里出现两次?"} -->|仅旧菜单| zan["残留"]
q -->|新旧两边| hei["并存"]
zan -.-> z1["清理漏洞或留下的旧 ProgID"]
hei -.-> h1["传统注册表注册与新注册并存"]
图 18: 只在旧菜单出现两次指向残留;新旧两边都出现两次指向并存。
8.3. 文件资源管理器变重或崩溃
右键变慢、或特定文件夹崩溃时,先盘点已安装的外壳扩展。用 NirSoft 的 ShellExView 等工具列出非 Microsoft 扩展,暂时禁用可疑项,再用二分查找找出元凶 DLL。崩溃时,事件查看器的「Faulting module」也是线索。若内部扩展是原因,怀疑菜单构建路径上的同步 I/O 或网络访问(第 4.2 与 5.2 节)。
flowchart TB
accTitle: 变重或崩溃时找出元凶 DLL
accDescr: 在 ShellExView 列出非 Microsoft 外壳扩展,暂时禁用可疑项并用二分查找找出元凶 DLL;崩溃时事件查看器的 faulting module 也是线索
s1["盘点外壳扩展"] --> s2["列出非 Microsoft 的"]
s2 --> s3["暂时禁用并二分查找"]
s3 --> s4["找出元凶 DLL"]
crash["崩溃时"] -.-> ev["检查 faulting module"]
ev -.-> s4
图 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)。当场就该能估出工作规模。
相关文章
- COM / ActiveX / OCX 是什么 - 区别与关系整理
- 什么是 Reg-Free COM——无需注册使用 COM 的机制
- 注册表的32位/64位重定向与虚拟化陷阱 ── Wow6432Node与「写入的值找不到了」问题
- Windows 应用发布方式怎么选 - MSI / MSIX / ClickOnce / xcopy / 自定义 updater 判断指南
- DLL・COM 接口的向后兼容性 ── 判断哪些改动会破坏调用方的对照表
- Windows 应用兼容如何运作 ── 用兼容模式、shims 与 Compatibility Administrator 延续旧应用
相关咨询领域
小村软件有限公司承接业务应用的文件关联、上下文菜单与外壳扩展的设计与实现;对准 Windows 11 新上下文菜单(迁移到 IExplorerCommand、引入 sparse package);检视现有安装程序的注册与清理;以及文件资源管理器变重或崩溃的原因调查。从决定「藏在显示更多选项下面的菜单该怎么办」开始也可以。
参考链接
-
Microsoft Learn, File Types. 关于扩展名键指向 ProgID 的结构、OpenWithProgIds、HKLM/HKCU\Software\Classes 的注册分法、关联更改后调用 SHChangeNotify(SHCNE_ASSOCCHANGED),以及卸载时删除 ProgID 但留下扩展名键默认值。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, HKEY_CLASSES_ROOT Key. 关于 HKEY_CLASSES_ROOT 是 HKLM\Software\Classes 与 HKCU\Software\Classes 的合并视图、用户端定义优先于计算机端,以及写入时的分派规则。 ↩ ↩2
-
Microsoft Learn, Windows app defaults platform. 关于更改默认应用被设计成只能通过系统设置 UI、用户设置数据已混淆并由筛选器驱动程序(UCPD.sys)写保护、以注册表为基础的更改不受支持,以及在受管理环境使用组策略/MDM 策略。 ↩ ↩2
-
Microsoft Learn, Working with Shell Extensions. 关于外壳扩展处理程序的种类、扩展是加载到文件资源管理器(以及承载外壳的进程)的进程内 COM DLL 因此崩溃或挂住会波及整个文件资源管理器、以 ThreadingModel=Apartment 注册,以及在写外壳扩展之前先考虑更简单的替代方案。 ↩ ↩2 ↩3
-
Microsoft Learn, Guidance for Implementing In-Process Extensions. 关于 Microsoft 不建议也不支持用托管代码实现进程内外壳扩展、理由包括 CLR 版本冲突、重入与非确定性对象生存期,以及进程外扩展(预览处理程序,或从 shell\verb\command 启动)可以使用托管代码。 ↩ ↩2 ↩3
-
Windows Developer Blog, Extending the Context Menu and Share Dialog in Windows 11. 关于 Windows 11 新上下文菜单的设计、通过 IExplorerCommand 加上应用标识扩展、把「打开」与「打开方式」放在上方、把多个命令收进应用名称浮出,以及传统 IContextMenu 扩展在「显示更多选项」(Shift+F10)下以 Windows 10 菜单加载。 ↩ ↩2 ↩3
-
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
-
Microsoft Learn, Grant package identity by packaging with external location. 关于不更改现有安装程序、以注册外部位置包(sparse package)取得包标识、自 Windows 10 版本 2004 起可用,以及需要标识的 Windows 功能(上下文菜单注册、通知等)变得可用。 ↩ ↩2 ↩3
-
Microsoft Learn, Choosing a Static or Dynamic Shortcut Menu Method. 关于选择符合需求的最简单静态 verb 方法、IContextMenu 最强但也最复杂并被归到不建议那一侧,以及 IExplorerCommand/IExplorerCommandState 才是建议方法。 ↩ ↩2
-
Microsoft Learn, SHChangeNotify function. 关于如何发出通知系统文件关联更改的 SHCNE_ASSOCCHANGED 事件,以及用它让外壳察觉更改。 ↩ ↩2
-
Microsoft Learn, Application Registration. 关于建议通过 App Paths 子键注册可执行文件、Applications 子键的角色、通过 SystemFileAssociations 注册 verb,以及默认应用更改时 ProgID 与相关信息的优先级。 ↩
-
Microsoft Learn, Verbs and File Associations. 关于 verb 也是 ShellExecuteEx 使用的动作、命令字符串中可能含空格的元素需要用引号包住且 “%1” 一律加引号,以及在 HKCR\Applications 下注册默认过程。 ↩
-
Microsoft Learn, IExplorerCommand interface. 关于 GetTitle、GetIcon、GetState、Invoke、EnumSubCommands 等方法组成、方法从 UI 线程调用因此不得与网络资源通信,以及自 Windows Vista 起可用。 ↩
-
Microsoft Learn, Windows Sandbox. 关于能在几秒内启动一次性、隔离的 Windows 环境、关闭后丢弃所有更改、适合软件测试与安装程序验证,以及在 Pro/Enterprise/Education 可用。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
剪贴板与拖放如何工作 ── 在业务应用中正确处理 OLE 数据传输
粘贴 Excel 表格时格式散架;关闭源应用后就再也贴不上——两者都来自剪贴板把同一内容同时放成多种格式。本文说明标准格式、延迟渲染、OLE 拖放,以及管辖剪贴板历史和云同步的策略。
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
Win32 线程池 API ── 用 CreateThreadpoolWork 实现不自建线程的并发
原生代码里是否到处调用 CreateThread?本文根据一手资料讲解 Vista 重新设计的 Win32 线程池 API——work、timer、wait、io 四种对象、清理组,以及回调中禁止做的事。
命名管道实务 ── 从设计到安全的 Windows 标准 IPC
以实务视角讲解 Windows 标准进程间通信——命名管道。根据一手资料整理字节模式与消息模式的选择、同时处理多客户端的服务器设计、ACL 与模拟的安全性,以及 .NET 的命名管道流。
从睡眠恢复后损坏的应用 ── Windows 电源事件机制与扛得住恢复的业务应用
打开笔记本后业务应用的连接全断了——原因是设计从未考虑睡眠。本文根据一手资料整理 WM_POWERBROADCAST 通知流程、Modern Standby 行为、断开/重连设计、睡眠抑制与调查命令。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
ActiveX 迁移
整理保留、包装或替换 COM / ActiveX / OCX 资产的阶段性判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
Windows 应用程序开发
支持包含常驻处理、设备联动、运行日志与可维护结构的 Windows 桌面应用程序。
Windows 软件维护 & 现代化
支持既有 Windows 软件的阶段性升级、功能追加、64 位就绪以及可维护性的重构。
常见问题
汇总了咨询这一主题时常见的问题。
- 为什么我们应用在 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。