“打开方式”的“打开方式”

“打开方式”对话框截图
“打开方式”对话框截图

——原标题《Windows 打开方式略解》。上古时期一些乱七八糟的软件喜欢瞎搞文件关联,现在情况好多了。这篇博客简略讲述 Windows shell 的文件关联机制(不涉及 URL 协议关联)。这篇文章并不是 Microsoft 官方文档,一切请以官方文档为准。如果你从我的 GitHub 的 PowerShellThingies 仓库中的 SurrogateUser 脚本库 转到这里,你可以了解为什么那个脚本注册 Windows 照片查看器的方式是符合文档的。

Windows 文件关联的概念

先要明白词的含义。

  • 文件关联:英文 file association,是将文件和文件类型关联的机制,为文件提供谓词、属性、信息提示、文件预览处理程序、缩略图处理程序等。
  • 关联数组:英文 association array。一个文件可进行的操作等内容可以有多个来源,文件的关联数组是一个有序列表,它决定该文件各个操作等信息的优先级。
  • 文件类型:是扩展名(extension)、文件类(class)、保底扩展名、泛类型(perceived type)、类别(kind)、MIME 类型的总称。例如 image.png 的扩展名是 .png,默认文件类是 pngfile,默认泛类型是 image,默认 MIME 类型是 image/png。这是我建立的一个术语,可能和大众理解不太一样,我认为这是用总结抽象文件类型的最好术语体系。
  • 扩展名:英文 extension,是文件名的一部分。若文件名从最后一个句点开始的后缀不含空格,则该后缀就是扩展名;若该后缀不存在(文件名不含句点)或者该后缀包含空格,则文件的扩展名是空。例如 .gitignore 的扩展名是 .gitignoreimage.png 的扩展名是 .png,而 Makefile 的扩展名是 。也有人把除掉句点之后的叫做扩展名,但这不是我认为的正统说法。此外,Win32 的文件名不能以连续的空格和句点结尾,虽然仍然可以在 NTFS 上使用 UNC 路径建立和访问名如 file. 的文件,并且 Windows shell 会认为该文件的扩展名是 .,但这应该是一个未被文档化的意外功能,此处不考虑(这也是为什么不应该把句点除掉再考虑扩展名)。
  • 文件类:英文 class,是一种用编程标识符——也叫 ProgID——命名的类型,可见于 HKCR: 下。最常见的注册新文件关联的方式就会用到它。
  • 保底扩展名:我造的术语,类似于文件类,可见于 HKCR:\SystemFileAssociations 中。
  • 泛类型:英文 perceived type,“泛类型”不是直译而是意译,这是一种粗略描述“这个文件是个啥”的方式。譬如 PNG、BMP、JPEG 图片都是图片,而 C++ 源代码、日志文件、XML 都是文本。Windows 自带好几种泛类型(图片、视频、文本等),见于 HKCR:\SystemFileAssociations。虽然可以建立新的泛类型,但我很少见到有程序这么做。
  • 类别:英文 kind,是通过名为 Kind 的文件属性进行的文件关联,见于 HKCR:\Kind.<kind>
  • MIME 类型:英文 MIME type。Windows 里是可以注册 MIME 类型的,见于 HKCR:\MIME\Database\Content Type 中。
  • HKCR:是指 HKEY_CLASSES_ROOT 根键,这并不是一个实体根键,而是由 HKLM:\SOFTWARE\ClassesHKCU:\Software\Classes 合成的视图(view)。这里存储了文件关联和 COM(组件对象模型)的信息。Windows 的文件关联和 COM 的联系可以非常紧密。
  • 谓词:英文 verb,是可以对文件进行的操作。例如 open(打开)、printto(打印)是非常常见的谓词,再比如 properties(属性)这个谓词可以用来打开文件的“属性”对话框。
  • 属性:英文 property。文件的属性是文件关联提供的信息,例如 MP3 文件具有“专辑名称”“曲目编号”等属性,但 Word 文档没有,图片具有“长度”“宽度”属性,但压缩文件没有。文件关联提供如何获取和编辑文件的属性的信息。
  • 信息提示:英文 info tip,当用户把光标停在一个文件的图标上,或者用键盘选定一个文件之后过一会儿出现的提示文字,通常包含简要的属性信息。
  • 文件预览处理程序:英文 preview handler,当用户打开了预览窗格(Preview Pane,可以通过 Alt+P 开关)并选定了文件时,一种提供文件预览的方式。Word、Excel、PowerPoint、Adobe Reader DC 都提供了文件预览,文本、图片也都自带预览。此外,Outlook 也是一个已知经常使用文件预览处理程序的应用。我有两篇英文博客讲述如何(作为消费者)使用文件预览处理程序:前篇后篇
  • 缩略图处理程序:英文 thumbnail handler,当用户打开了预览窗格时一种提供文件预览的方式,同时也提供文件的缩略图。因为 JPEG 图片关联了缩略图处理程序,所以用户在“大图标”模式下看到的 JPEG 图片是缩略图而不是默认的图标(可以通过建立空的 JPEG 图片文件查看默认图标,那时文件不是有效的 JPEG 图片,故缩略图不存在)。

Windows 文件关联解析示例

Windows 文件关联的设计确实有些复杂,不过这是同时具有高表达力、高效率和用户可控性的代价。

首先,shell 名字空间(粗略理解为 Explorer 能够访问的所有“文件夹”,也就是 Explorer 里面的能列举项目的窗口里面的东西)已经给每个文件都加上一些关联了。例如从普通的文件夹里打开文本文件的上下文菜单,通常不会出现“打开文件位置”,但是如果在“搜索结果”里打开一个文本文件的上下文菜单,则会出现。这里的“打开文件位置”就是“搜索结果视图”这个名字空间给予该文件的谓词。这种关联不在本篇的考察范围。

除了名字空间,Windows 会通过文件名关联文件,我们用刚刚默认配置安装好的 Windows 10 上的 image.png 文件作为例子。

首先,Windows 确定扩展名是 .png

接着 Windows 决定文件类是哪个,首先查询的是 HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.png\UserChoice,如果这里的 ProgID 是存在的 ProgID 且 hash 验证通过,则使用这个 ProgID。实际上,在默认配置下将会选择这个 ProgID,它是 AppX43hnxtbyyps62jhe9sqpdzxn1790zetc,这是 Photos app 建立的关联。实际上,绝大多数情况下这里的 ProgID 会被选择。如果 hash 验证不通过,则 Windows 会提示用户有程序损坏了 .png 的文件关联,Windows 已经重置它为默认。如果假设这一步没有成功,则会访问 HKCR:\.png,决定文件类;在默认配置的 Windows 10 上,这里应该是 pngfile

除了文件类,Windows 还需要决定该文件的泛类型、MIME 类型。.png 的泛类型默认是 image,会从 HKLM:\SOFTWARE\Classes\.pngPerceivedType 得到;MIME 类型是 image/png,同一个键的 Content Type 决定。

至此,Windows 会认为该文件的文件数组(完全展开形态,高优先级在前)是:

有否 注册表路径( 优先级降低)
文件类 HKCU:\Software\Classes\AppX43hnxtbyyps62jhe9sqpdzxn1790zetc
文件类 HKLM:\Software\Classes\AppX43hnxtbyyps62jhe9sqpdzxn1790zetc
扩展名 HKCU:\Software\Classes\.png
扩展名 HKLM:\SOFTWARE\Classes\.png
保底扩展名 HKCU:\Software\Classes\SystemFileAssociations\.png
保底扩展名 HKLM:\Software\Classes\SystemFileAssociations\.png
泛类型 HKCU:\Software\Classes\SystemFileAssociations\image
泛类型 HKLM:\Software\Classes\SystemFileAssociations\image
类别 HKCU:\Software\Classes\SystemFileAssociations\Kind.Picture
类别 HKLM:\Software\Classes\SystemFileAssociations\Kind.Picture
名字空间
文件
HKCU:\Software\Classes\*
名字空间
文件
HKLM:\Software\Classes\*
名字空间
文件和目录
HKCU:\Software\Classes\AllFilesystemObjects
名字空间
文件和目录
HKLM:\Software\Classes\AllFilesystemObjects

扩展名有些特殊,它通常不参与 shell 子键的选择(并且通常也没有这个子键)。现在来说说它们(在默认的 Windows 10 上)都提供了什么:

  • UserChoice 里面会提供目前用户选择的文件类,还存储了“打开方式”列表的缓存(它会复制其他地方信息,并且存储用户自己选择过的打开方式列表)。
  • 文件类提供了图标、三个谓词(打开、用 Photos 编辑、创建视频)。
  • 扩展名提供了搜索索引过滤器、打开方式列表和默认文件类(这很重要,通常建立文件关联的时候需要利用这一点)。
  • 保底扩展名提供了不同详细程度的属性摘要文字格式、两个谓词(用 3D 画图打开、设置为桌面背景)、一个上下文菜单处理程序(似乎没有用)。
  • 泛类型提供了默认图标、两个谓词(编辑、打印)、一个上下文菜单处理程序(“播放到”功能)、旧版缩略图提取器、新版缩略图提取器(也会充当预览处理程序)、打开方式列表(提供“画图”这个选项)。
  • 类别提供了一些默认视图信息。
  • 名字空间(文件)提供了一大堆文件通用的设置和默认设置,比如 OneDrive、共享、固定到“开始”、固定到任务栏等等上下文菜单都是这里设置的,还有很多属性处理程序。
  • 名字空间(文件和文件夹)提供了另外一堆东西,比如“发送到”“复制路径”等。

还有一些特殊情况,例如 Windows 认为一个新的可以处理 .png 文件的程序被安装过了,那么它会在用户下次尝试打开 .png 文件时将假装 .pngUndecided 文件类,它的默认谓词会启动“打开方式”对话框,提示用户重新选择自己的文件关联。Windows 8 的处理方式稍微不同——它会用先前的默认程序打开文件,并弹出一个 toast 通知告诉用户有新软件可以处理文件。

Windows 会用 HKCU:\Software\Microsoft\Windows\CurrentVersion\ApplicationAssociationToasts 记录每个文件类/扩展名组合是否已经向用户展示过了,如果某次打开文件的时候发现该扩展名的默认文件类(HKCR:\.<extension> 的默认值)没有和该扩展名被组合展示过,则查询所有该扩展名的打开方式并展示。这一点也会反映在 注册文件关联方法 里。

注册文件关联

这里以在 Windows 10 中注册 Windows 照片查看器的文件关联为例子,我按照这个流程写的 PowerShell 代码 可见于此

注册可执行文件

这一步不是必须的,但是如果是你自己的程序就需要,我的例子里面就没有,因为是用 Windows 自带的照片查看器。参考 这篇文档

注册程序功能

这一步的目的是为了让你的程序出现在 Settings | Apps | Default apps,包括六大类别选择的时候出现,以及在 Set defaults by app 里被列出为一个 app。

如果要注册到系统里,则内容应该位于 HKLM: 下;如果只是注册到用户,则内容应该位于 HKCU: 下。我的代码里以用户注册为例。

首先在 HKCU: 下合适的位置建立一个键,例如 HKCU:\Software\BringBackMsft\PhotoViewer,设置 ApplicationNameApplicationDescription 等值。在这个键下面建立 Capabilities 键,再在这下面建立 FileAssociations 子键,为每一个支持的扩展名建一个字符串类型的值,值的名称就是扩展名,并设置值的数据为该扩展名想要关联的 ProgID。注册 MIME 类型关联则是在 Capabilities 里建立 MimeAssociations 子键并设置合适的值,这里不再赘述。

设置完这些信息之后,在 HKCU:\Software\RegisteredApplications 里建立一个新的字符串值,值的名称是 app 的名称,值的数据是刚刚建立的键相对于根键的路径,在这里就是 Software\BringBackMsft\PhotoViewer

Microsoft 官方的例子见 这里

注册 ProgID

我们继续在当前用户内注册。接下来需要在 HKCU:\Software\Classes 里面建立 ProgID 子键。我的脚本会为每种文件都建立一个 ProgID(这样就可以设置不同的默认图标和文件类型名,比如 PNG 和 JPEG 的默认图标和类型名字就不一样),这里以其中一个为例子。建立 HKCU:\Software\Classes\BringBackMsft.PhotoViewer.bmp,设置默认值为类型名字(展示给用户的,如果是多语言则应该用 FriendlyTypeName 并引用对应的资源)。接着建立 DefaultIcon 子键并设置默认值为图标资源;建立 shell 子键,默认值是默认谓词的名字(这里是 preview),在这里面建立谓词的子键,并建立谓词命令的子键。

这里我直接复制的 TIFF 和 Windows Photo Viewer 的关联,可以看到这个谓词既支持命令行调用也支持 IDropTarget 调用。由于 IDropTarget 效率更好(COM 而不是命令行,符合 Windows 一贯内部操作尽量不序列化、在体系内永远保持结构化数据的思想,而且 IDropTarget 可以被复用),故实际上 ShellExecuteEx 永远都是使用 IDropTarget 的,我觉得保留 command 是为了兼容一些非要自己看注册表的程序。

注意 如果一个值里面包含环境变量,例如 %SystemRoot%,则必须设置值类型为“展开字符串”(REG_EXPAND_SZ)。

Microsoft 官方的例子见 这里

“设置自己为默认”

这一步在早期 Windows 上会设置新的文件类为一个扩展名的默认文件类,但在 Windows 10 上则是会让 shell 下次打开这个扩展名的文件的时候提示用户重新选择文件关联。

注意 不要尝试在生产环境中修改 …\FileExts 里面的内容来夺取默认关联——Windows 有 hash 校验——但是为了测试折腾这个是没问题的,实际上更简单的策略是建立一个专门的用户账户来测试,并循环删除这个测试账户的用户账户配置文件(使用 sysdm.cpl 操作)。也不要尝试在生产环境中修改 …\ApplicationAssociationToasts 来反复提示用户重新选择文件关联——这只会招来用户的厌烦——正确的做法是有节制地 使用“打开方式”对话框 引导用户。

做法很简单,在要注册的扩展名对应的键下面做如下操作:

  1. 把自己加入“打开方式”列表。新的程序都应该使用如 HKCU:\Software\Classes\.bmp\OpenWithProgids
  2. 把自己设置成早期 Windows 中的默认文件类,也就是改变 HKCU:\Software\Classes\.bmp 的默认值。

在 Windows 10 里,用户的文件关联保存在 …\FileExts\.bmp\UserChoice 里,新来的会且只会产生提示。

Microsoft 官方的例子见 这里

调用 SHChangeNotify

这一步非常重要,是为了通知所有的程序文件关联已经改变,它们需要把目前的文件关联缓存无效化。对于 Explorer 来说,它会刷新所有窗口里的文件图标。如果忘记做这件事情,就会发生 macOS 上文件图标在安装程序之后不能自动变化的尴尬事情。

例子代码的效果:运行 Install-WindowsPhotoViewer.ps1 之后打开任意 .png 文件,Windows 会弹出对话框提示用户可以选择 Windows 照片查看器作为新的默认应用。

解除文件关联

软件卸载的时候需要删除自己产生的文件关联,这里就用解除上一节注册的文件关联为例子,PowerShell 代码 可见于此

删除 ProgID

删除之前创建的 ProgID,如 HKCU:\Software\Classes\BringBackMsft.PhotoViewer.bmp 等。

注意 不要尝试去把任何扩展名的当前关联设成“不是自己”,Windows 会忽略错误的 ProgID。

至于 OpenWithProgids 里面的自己要不要删除则是见仁见智。首先,如果一个用户强行用你的程序去打开一个你没有声明的文件类型,这是很难追踪的。不过我个人觉得,像之前安装的时候自己加进去的 OpenWithProgids 的值,则应该删除;然而这样并未删除完全,因为 …\FileExts 里面的“打开方式”列表似乎是只进不出的。

Microsoft 官方的例子见 这里

解除程序功能注册

删除之前的程序功能注册,如 HKCU:\BringBackMsft\PhotoViewer

解除可执行文件注册

如果你注册了的话,就删除。

调用 SHChangeNotify

同样很重要!

例子代码的效果:在把 .png 关联到 Windows 照片查看器的情况下,运行 Uninstall-WindowsPhotoViewer.ps1 之后打开任意 .png 文件,Windows 会选择之前的默认应用。

使用(消费)文件关联的例子

一个最常见的需求是:打开一个文件,就像用户在 Explorer 里面双击它一样Windows 文件关联解析示例 的复杂步骤可能会让人望而却步,然而想着如何完美模拟 Windows 查询文件关联是钻牛角尖。首先,正确的查询方式是使用 IQueryAssociations 接口,例如需要自己 host 预览处理程序的时候 就可以这样做。其次,即使你正确解析了默认谓词,想要执行该谓词也是很麻烦的——别以为只要会 CreateProcess 传参数就行了,至少还得会 DDE 吧?还得处理 IContextMenuIContextMenu2IContextMenu3IDropTargetIExecuteCommandIExplorerCommandIExplorerCommandState 吧?(你需要处理的接口只会不断增加,因为 Windows 还要更新。)

简单的答案是:如果只是想“双击”一个文件,可以使用 ShellExecuteEx 完成。

使用 ShellExecuteEx 可以做很多有趣的事情,比如 传入 lpClass 来强行使用一个文件类,再比如 启动“打开方式”对话框(链接中的例子在 Windows 10 上已经不工作了,你应该传入 SEE_MASK_INVOKEIDLIST 或者传入 SEE_MASK_CLASSNAME 并设置 UndecidedUnknown 为文件类)。

请启用 JavaScript 来查看由 Disqus 驱动的评论。