SpecialMenu 实现说明

July 15, 2026 · View on GitHub

1. 为什么 SpecialMenu 不是普通右键菜单

SpecialMenu 指当前代码中由 SpecialMenuService 管理、但不适合放进 ContextMenuRegistryCatalog 的菜单面。它们不完全遵循传统 shell / shellex\ContextMenuHandlers 模型:

SpecialMenu为什么特殊
ShellNew“新建”菜单来自扩展名下的 ShellNew 子键,同时还受 Explorer ShellNew order key 和排序值影响。
SendTo“发送到”菜单主要来自用户 profile 下的文件系统目录。
WinXWin+X 菜单来自用户 LocalAppData 下的分组目录、.lnk 文件和 hash。
OpenWith“打开方式”菜单来自 Classes Applications\<app>\shell\<verb>\commandNoOpenWith / NoUseStoreOpenWith 策略,不是普通文件类型 verb。
DragDrop右键拖放默认效果和 handler 配置不是普通菜单项。
CommandStoreExplorer command store 是另一套命令注册表模型。
GuidBlock通过 GUID blocked list 屏蔽对象,不是普通 verb。
InternetExplorerIE MenuExt 是旧式浏览器扩展入口。

因此不要用 ContextMenuRegistryCatalog 的普通开关、删除、恢复逻辑直接处理 SpecialMenu,除非代码已经明确把某个路径泛化。

2. SpecialMenuService 总览

SpecialMenuService 位于 ContextMenuMgr.Backend/Services/SpecialMenuService.cs。当前 SpecialMenuKind 定义在 ContextMenuMgr.Contracts/SpecialMenuContracts.cs

类型主要数据位置用户上下文要求
ShellNewSoftware\Classes\<.ext>\ShellNew 和 Explorer ShellNew order key需要用户 SID,排序和用户级 Classes 要写 HKEY_USERS\<SID>
SendTo%APPDATA%\Microsoft\Windows\SendTo需要用户 profile path / RoamingAppData。
WinX%LOCALAPPDATA%\Microsoft\Windows\WinX需要用户 LocalAppData。
OpenWith用户 / 机器 Classes Applications\<app>\shell\<verb>\command,以及 Explorer NoUseStoreOpenWith policy需要用户 SID;新增项写 HKEY_USERS\<SID>\Software\Classes\Applications,枚举同时读取 HKU 和 HKLM。
DragDrop相关 Classes 注册表路径当前实现可在服务侧处理,仍需谨慎区分用户级与机器级。
CommandStoreExplorer CommandStore 注册表路径不等同于普通右键菜单。
GuidBlockGUID blocked list用于屏蔽指定 GUID。
InternetExplorerIE MenuExt 注册表路径旧式功能,当前实现按执行进程的 HKCU 处理,属于 best-effort;后续若要面向前端用户,应补用户上下文路径。

前端主要由 SpecialMenuPageViewModel 组装 PipeRequest,后端由 NamedPipeBackendServer 分发到 SpecialMenuService

3. ShellNew

ShellNew 是 SpecialMenu 中最容易踩坑的部分。

WPS / Microsoft Office 共存保护不会在 ShellNew 或 OpenWith 普通页面追加 File Association / Icon 项。两套 Office 同时存在时,后端只通过待审核专用管道读取当前用户 HKEY_USERS\<SID>\Software\Classes 和 ShellNew order key,检测 WPS 注入的 .pdfwpsshellnew.pptxwpsaicreateshellnew 等 ShellNew command 项,以及 WPS 对文档关联和图标的用户级覆盖。检测结果是 special:wps-* 合成待审核项,不通过 ShellNew ACL lock 修复,也不会自动删除 WPS key。状态库为空(首次运行或重置)时,当前已存在的 WPS 合成项会作为已确认 baseline 保存,不进入待审核;建立 baseline 后首次出现的新合成项才进入待审核。

概念当前实现说明
ShellNew 子键位于扩展名 Classes 下,例如 Software\Classes\.txt\ShellNew
文件扩展名与新建项每个扩展名可能对应一个“新建”菜单项,显示名按 Windows / BluePointLilac 语义解析。
NullFile表示创建空文件。当前创建请求未提供 DataText 时倾向写入 NullFile
Data当前 ShellNewCreateRequest 支持 DataText,后端会写入二进制 Data
CommandShellNewUpdateRequest 支持更新 Command
创建请求字段新建时会应用前端表单中的扩展名、显示名、图标路径、命令、数据文本和 BeforeSeparator;命令优先于 Data,没有命令和数据时写 NullFile
FileName / DirectoryWindows ShellNew 支持这些创建方式;枚举会把 NullFileDataFileNameDirectoryCommand 视为有效 ShellNew 值。
Config\BeforeSeparator当前更新请求支持 BeforeSeparator
Explorer ShellNew order keySoftware\Microsoft\Windows\CurrentVersion\Explorer\Discardable\PostSetup\ShellNew,控制 Explorer 侧排序信息。
Classes 排序值排序会同时考虑扩展名、Explorer order key 和 Classes 相关数据。

创建 ShellNew 时,后端必须先基于前端用户上下文解析文件类型,而不能使用服务进程的 Registry.ClassesRoot 来验证 ProgId。解析顺序是用户 HKU\<SID>\Software\Classes\.ext、机器 HKLM\SOFTWARE\Classes\.ext、用户 FileExts\.ext\UserChoice\ProgId,再考虑 OpenWithProgids。ProgId 只在同一组用户 / 机器 Classes 根中存在时才视为有效;UserChoice 只读不写,不修改 hash,也不会为项目创建全局 fake ProgId。

ShellNew 显示名遵循 Windows / BluePointLilac 优先级:ShellNew\MenuText 仅在它是 @... 间接资源字符串且能解析为非空文本时优先;其次读取默认 ProgID 的 FriendlyTypeName;再读取默认 ProgID 的默认值;最后回退到扩展名。创建或编辑普通用户显示名时,ContextMenuMgr 不写纯文本 ShellNew\MenuText,而是写入前端用户覆盖层 HKU\<SID>\Software\Classes\<ProgID>\FriendlyTypeName。编辑显示名时会从实际可写的用户 ShellNew 覆盖键删除 MenuText,避免旧值继续压过 FriendlyTypeName

即使扩展名没有出现在用户或机器 Classes 中,创建仍会继续写用户级 HKU\<SID>\Software\Classes\.ext\ShellNew。ProgId 是可选元数据:解析到有效 ProgId 时用于 per-user FriendlyTypeName 和返回项 metadata;没有 ProgId 时仍创建扩展名级 ShellNew,但普通显示名不会保存为纯文本 MenuText,刷新后可能回退到扩展名。UserChoice 始终只读,不写入、不修改 hash。

编辑系统 / HKLM ShellNew 项时,后端优先把对应 Classes 相对路径复制到 HKU\<SID>\Software\Classes\... 作为用户覆盖层,再修改 IconPathCommandDataNullFileConfig\BeforeSeparator 等 ShellNew-local 属性,避免默认写入机器范围。显示名覆盖始终写到用户 ProgID 的 FriendlyTypeName

排序复杂的原因是 Explorer “新建”菜单不是简单按注册表子键自然顺序显示。SpecialMenuServiceMoveShellNewAsync 要求 ShellNew order lock 已启用;移动时会用简单 unlock 临时移除 WorldSid deny 规则,更新 Classes 排序值,再按原锁定状态重新加锁。

ShellNew ACL lock / unlock 只针对 ShellNew order key 相关保护。当前实现使用 v2 narrow lock:锁定时读取现有 DACL,移除重复的显式 WorldSid deny 规则和可读取的旧版 broad WriteKey deny 规则,再添加一个 WorldSid Deny SetValue | CreateSubKey | Delete 规则。v2 故意不使用 RegistryRights.WriteKey,因为 .NET 中 WriteKey 过宽,可能阻止后续读取 / 修改 ACL,导致本程序无法正常解锁。解锁时会移除旧版 WorldSid WriteKey deny 规则和 v2 narrow deny 规则。它只请求 ReadPermissions / ChangePermissions,不 deny ReadPermissionsChangePermissions、ownership 修改或 FullControl,保留现有继承和显式 allow 规则,不合成替换 DACL,不调用 take ownership,也不启用高危所有权 / 还原类权限修复 ShellNew ACL。这里即使服务是 LocalSystem,也不能把用户级路径写到 SYSTEM 的 HKCU;用户级 Classes 和 Explorer order key 必须定位到 HKEY_USERS\<sid>

如果旧版本 broad WriteKey lock 或外部工具留下的 broken ACL 无法通过 ReadPermissions | ChangePermissions 安全修改,本程序会返回明确错误:需要先用 BluePointLilac / ContextMenuManager 或系统工具解锁 / 修复,然后重试。主后端不再执行 take ownership、replacement DACL 或自动 ACL reset。

4. ShellNew ACL Lock 与 Registry Write Protection 的区别

项目ShellNew ACL LockRegistry Write Protection
保护范围主要针对 Explorer ShellNew order key / 新建菜单排序保护。更广泛的传统右键菜单注册表写入保护。
代码路径SpecialMenuService.SetShellNewOrderLockAsyncContextMenuRegistryCatalog 的注册表写保护相关逻辑。
用户提示主要围绕 ShellNew 排序锁定 / 解锁;无法安全修改 ACL 时提示外部修复。编辑菜单项时可能提示去设置页解锁。
失败形态legacy broad WriteKey 或 broken ACL 可能导致 ChangePermissions 修改失败;主程序不会 take ownership 或替换 DACL。可能阻止第三方安装器或本程序运行时写入受保护路径。

不要把两者混用。ShellNew ACL Lock 不是普通菜单禁用,也不是全局右键菜单保护开关。

5. SendTo

SendTo 菜单来自用户 profile 下的目录,当前代码通过 BackendUserContext.GetSendToPath() 定位到 RoamingAppData 的 Microsoft\Windows\SendTo

行为当前实现倾向
枚举读取 SendTo 目录中的文件和快捷方式。
启用 / 禁用通过 hidden 属性等文件系统状态表达。
删除 / 恢复使用 .deleted 机制进行软删除和恢复。
编辑更新 .lnk 的目标、参数、工作目录、图标等。

除了用户 SendTo 目录中的真实文件,快照还会追加 Explorer 动态生成的当前可用可移动磁盘目标,例如 USB 盘。此类项来自 DriveInfo.GetDrives()DriveType.RemovableIsReady 的驱动器,使用 sendto:drive:X 形式的稳定 id,并通过 metadata 标记为 Explorer-generated dynamic target。它们不对应 SendTo 目录内的文件,因此 CanEdit=falseCanDelete=false,前端不显示开关,后端也不会对它们执行创建、删除、编辑或 hidden 属性切换。

SendTo 是文件系统菜单,不是 registry-only 功能。没有正确 profile path 时,服务可能改到错误用户目录或找不到目标。

6. WinX

WinX 菜单来自用户 LocalAppData 下的 Microsoft\Windows\WinX 目录。它包含分组、排序、.lnk 文件和 hash。

行为当前实现倾向
分组以 group 目录组织。
排序通过文件名前缀和分组位置处理,移动操作只移动/重命名 .lnk 和对应的 desktop.ini 本地化名称,不重写 WinX hash。
.lnk条目是快捷方式,需要写目标、参数和工作目录。
hashWinXHasher.HashLnk 使用硬编码 PKEY 写入 Explorer 接受的 WinX 快捷方式 hash;新增快捷方式、恢复默认组、修改 target/arguments 后必须重新 hash。显示名、图标、RunAs 和移动排序不重新 hash。hasher 不允许使用空 target 计算 hash,并会记录 property-store/fallback/final target 与 arguments。
删除当前 WinX 删除是直接删除用户 WinX 目录下的 .lnk 或分组目录,不使用 .deleted 软删除;Undo/Purge 对 WinX 已禁用,直到有持久 manifest-based undo 模型。
恢复默认禁止恢复整个 WinX 根目录;只允许恢复单个 group,恢复前会把现有 group 移到 .backup,复制失败时回滚。.deleted.backup 不作为普通 WinX group 枚举。

WinX 修改需要用户上下文,因为不同用户有不同的 LocalAppData 和 WinX 配置。

WinX 快捷方式的创建、更新和读取由后端 WinXShortcutFile 使用原生 IShellLinkW / IPersistFile 完成。每次操作在短生命周期 STA 线程中显式初始化 COM,不调用 IShellLink.Resolve,也不使用 WScript.Shell。SendTo 当前仍保留原有 ShortcutFile 路径,以限制改动范围。

创建 WinX 条目时,backend.log 会记录 WinXCreateStartWinXShortcutWriteStart/EndWinXDesktopIniWriteStart/EndWinXHashStart/EndWinXCreateEnd。若创建失败,WinXCreateFailed 会记录失败 阶段;COM 异常同时记录 HRESULT。

7. OpenWith

OpenWith 管理的是所有文件“打开方式”子菜单中的应用注册。它参考 BluePointLilac / ContextMenuManager 的模型,但在本项目中必须走后端 pipe 和前端用户上下文。

WPS / Microsoft Office 共存保护不在 OpenWith 普通页面展示受保护文档扩展名 owner,也不在该页面提供默认应用切换。关联、图标、ShellNew 注入只作为 WPS 专用待审核项显示;UserChoice 只能作为证据读取,不能由本项目直接写入 Hash。

行为当前实现倾向
枚举读取 HKU\<SID>\Software\Classes\ApplicationsHKLM\SOFTWARE\Classes\Applications,只纳入带文件扩展名的应用 key,并选择 shell 下优先的非 NeverDefault verb。
显示名当应用 key 名等于命令目标文件名时优先解析 FriendlyAppName,否则读取目标文件 FileDescription,最后回退到文件名。
启用 / 禁用对应用 key 写入或删除 NoOpenWith
新增写入当前前端用户的 HKU\<SID>\Software\Classes\Applications\<app.exe>\shell\open\command,并写 FriendlyAppName
编辑更新 FriendlyAppName 和 command 默认值;为避免把一个应用 key 改成另一个程序,当前不允许编辑时改变目标 exe 路径。
删除 / 恢复对应用 key 做 .deleted 软删除和恢复,而不是只删除 command 子键。
应用商店入口快照包含 NoUseStoreOpenWith policy 项;开关会同步 HKLM 和当前用户 HKU policy 值。

OpenWith 需要 SID,但不需要 SessionId。服务不能用 LocalSystem 的 HKCU 读写用户 Applications 或用户 policy。机器级 Applications 项按真实 HKLM\SOFTWARE\Classes 路径显示和修改;新增自定义项默认只写用户覆盖层。

8. SpecialMenu 的用户上下文要求

SpecialMenu需要 SID需要 ProfilePath需要 LocalAppData/RoamingAppData原因
ShellNew通常需要需要用户 hive 和 Explorer order key用户级 Classes 和 ShellNew order key 必须写 HKEY_USERS\<SID>
SendTo通常需要需要 RoamingAppDataSendTo 是用户 profile 下目录。
WinX通常需要需要 LocalAppDataWinX 是每用户文件系统配置。
OpenWith通常不需要通常不需要用户级 Applications 和 NoUseStoreOpenWith policy 必须写 HKEY_USERS\<SID>,不能写服务 HKCU
DragDrop视具体操作而定通常不需要通常不需要当前实现主要按注册表路径处理。
CommandStore视具体操作而定通常不需要通常不需要属于 Explorer 命令注册表模型。
GuidBlock视具体操作而定通常不需要通常不需要以 GUID blocked list 为主。
InternetExplorer视具体操作而定通常不需要通常不需要旧式 IE MenuExt 路径。

这里的“通常需要”表示当前实现会通过 BackendUserContext 获取 SID 或 profile 目录。不要用 LocalSystem 的环境变量推断前端用户路径。

9. 常见坑

正确处理
用普通 ContextMenuRegistryCatalog 处理 ShellNew 排序SpecialMenuService 的 ShellNew 专用逻辑。
用服务 HKCU 写 ShellNewHKEY_USERS\<sid>
把锁定失败简单判断为权限不够ACL deny、所有者、继承状态都可能影响 ReadPermissions / ChangePermissions;证据不足时不要推断根因。
期望主程序自动修复 legacy broad lock / broken ACLShellNew lock/unlock 不再 take ownership、不替换 DACL;旧版 WriteKey broad lock 无法安全修改时应使用 BluePointLilac / ContextMenuManager 或系统工具外部解锁 / 修复。
把 SendTo / WinX 当 registry-only 功能它们主要是用户文件系统目录。
用服务 HKCU 写 OpenWith Applications新增和用户 policy 写 HKEY_USERS\<sid>;机器级项才写 HKLM。
把 ShellNew ACL Lock 和 Registry Write Protection 混用两者保护范围、代码路径和用户提示都不同。