用 Visual Basic(或 C#)编写 Total Commander 插件
本文介绍并描述了一个允许为 Total Commander 文件管理器创建托管插件的框架。
引言
Windows Explorer 已经不够用了。有和我一样感受的人可以选择几个替代的文件管理器。其中最受欢迎的之一是 Total Commander - 一个微软 Windows 的共享软件文件管理器。它具有从旧 DOS 时代 Norton Commander 继承而来的经典双窗口视图,并支持许多文件操作。Total Commander 的重要功能之一是通过插件进行扩展的能力。本文介绍如何在托管语言(Visual Basic 或 C#)中编写 Total Commander 插件,并特别关注文件系统插件 (WFX)。
插件架构
Total Commander 本身是用 Delphi (Object Pascal) 语言 (非托管) 编写的。Delphi 编译器生成 C 风格的可执行文件和库。插件接口是为用 C/C++ 编写的插件设计的。作者为插件开发者提供 C 头文件。有四种插件类型:
- 内容插件 (WDX)
- 允许 Total Commander 显示文件的附加详细信息(如 ID3 轨道名称或 Exif 相机型号),并将其用于搜索或重命名。
- 文件系统插件 (WFX)
- 允许 Total Commander 访问其他文件系统,如 Linux 格式的分区、设备文件系统或 FTP 服务器。
- 列表插件 (WLX)
- 允许 Total Commander 显示各种类型文件(如 MP3、HTML 等)的内容。
- 压缩包插件 (WCX)
- 允许 Total Commander 访问 - 显示、提取、打包和操作各种类型存档(如 CAB 或 MSI)的内容。
每个插件都创建为一个 C DLL 库,它导出定义好的函数集。该库的扩展名是 wdx/wfx/wlx/wcx 而不是 DLL,具体取决于其代表的插件类型。插件必须导出的函数集由 Total Commander 插件接口定义。每种插件类型只有少数几个强制性函数,然后有很多可选函数。强制性函数提供了插件最基本必要的功能。对于文件系统插件,只有提供初始化和文件/目录列表的四个函数是强制性的。然后,有用于下载、上传、删除、重命名等的可选函数。当然,插件可以导出的 Total Commander 不识别的函数没有限制。随着 Total Commander 的新版本不断开发,支持的插件函数集也在增长。因此,当使用为较新版本 TC 设计的插件与较旧版本一起使用时,一些函数永远不会被调用。
用 C++ 编写 Total Commander 插件是一项容易的任务。任何能够以 C 风格导出函数的语言,如 Delphi 或 PowerBasic,都可以轻松完成。但是,托管 .NET 语言(如 Visual Basic 或 C#)无法以这种方式导出函数。.NET 可以导出 COM 对象,但不能导出类似 Win32 API 的函数,尽管 .NET 可以使用 DllImportAttribute
导入它们,并且 Visual Basic 有导入 DLL 函数的特殊语法。据我所知,从托管程序集中导出这些函数在技术上也是不可能的,因为 .NET 不提供将函数放在静态地址的方法 - .NET 函数的地址在 JIT 编译时确定。另一个限制,尤其是对于 Visual Basic,是对 TC 插件函数中广泛使用的指针缺乏支持。好吧,对“我可以用 Visual Basic (C#) 编写 Total Commander 插件吗?”这个问题的简单回答是“否”。正如你可能猜到的,“否”不是我愿意接受的答案。
在 .NET 中有一个特殊的语言可以combine 托管和非托管代码在一个程序集中,可以导出 Win32 风格的函数,可以定义全局函数,并且可以操作指针——那就是 C++/CLI。所以,我编写托管 Total Commander 插件的解决方案是:
- 在 C++/CLI 中编写 Total Commander 和托管代码之间的接口。
- 用任何托管语言编写插件。
C++/CLI 接口由 Total Commander 调用,它将所有 丑陋 的 C++ 数据类型,如 char*
,转换为漂亮的托管类型,如 String
。然后,托管插件执行其工作并返回应有的返回值。C++/CLI 接口将托管返回值转换为非托管返回值,并以请求的方式将其传递回 Total Commander。
如果一个接口像上面那样简单实现,你必须为每一个要编写的插件重复编写(复制粘贴)该接口。我想要的是一个通用的解决方案。所以,我创建了一个 C++/CLI 程序集,其中包含一些支持类和结构,用于在托管和非托管代码之间传递数据,最重要的是,它包含了实际插件实现的基类。因此,插件的实现方式是完全面向对象的,这在 .NET 中很常见。Total Commander 插件托管框架的基本组成部分是:
- C++/CLI 非托管 ↔ 托管接口 (Tools.TotalCommander 程序集)
- 插件基类以及支持类和结构都包含在此程序集中。
- 插件实现
- 实现插件的托管程序集 (DLL)。它可以实现多个插件或不同类型的插件。它可以用任何可以派生自插件基类的托管语言编写。
- 插件程序集
- 一个用 C++/CLI 编写的小型程序集。这个程序集代表了 Total Commander 视角下的插件。它具有所需的扩展名(wdx/wfx/wlx/wcx),并且导出所有必需的函数。它初始化插件实例,然后在 Total Commander 调用插件函数时简单地调用插件实例函数。这个程序集由 Total Commander 插件构建器从插件实现生成。
- Total Commander 插件构建器
- 一个命令行工具,它使用插件实现的信息,通过预定义的模板构建插件程序集。特别是,它确保构建正确类型的插件,并且只有插件实现类实现的函数才会被插件库导出。
Total Commander 和托管代码之间的接口
如上所述,这个程序集是用 C++/CLI 编写的,并执行非托管(Total Commander)和托管(插件实现)代码之间的封送处理。它包含一些支持类和结构,其中一些对托管代码可见。它还定义了几个用于指定插件构建器如何构建插件程序集的属性。事实上,这个程序集只包含很少一部分非托管代码——只有从 Christian-Ghisler 提供的头文件中导入的非托管结构定义(而这些头文件包含了一些 Windows SDK 头文件)。但是,此程序集中的代码处理这些非托管类型以及指针和类 C++ 字符串 (char*
)。这是我们在 C# 中避免的,并且在 Visual Basic 中无法做到的。
插件(抽象)基类仅包含接受和返回非托管类型的非虚函数(在 VB 中不可重写),然后包含被实际插件实现覆盖的虚函数(在 VB 中可重写)。虚函数接受和返回托管的、符合 CLS 标准的类型。非虚函数将参数从非托管类型转换为托管类型,并传递给虚函数。当虚函数返回时,它的返回值(以及输出参数值)从托管类型转换为非托管类型,并传递给调用者。调用者实际上是插件程序集中的一个全局函数,该函数将值传递回 Total Commander。虚函数与非虚函数的行为不同。例如,使用异常而不是错误返回码,使用返回值而不是输出参数(有时,输出参数是不可避免的——多个返回值)。
在文件系统插件中,非虚函数和虚函数之间的映射通常是 1:1 的。几乎每个非虚函数(以 FS
前缀开头)都有一个对应的虚函数。强制性函数的虚函数没有实现,这实际上强制插件作者在派生类中实现它们。可选函数具有默认实现,会抛出 NotSupportedException
。可以确保 Total Commander 永远不会调用一个未在插件类中覆盖的可选函数,因为这样的函数不会被插件程序集导出(Total Commander 插件构建器不会为它们生成导出)。从面向对象的角度来看,可以确定可选函数的实际实现除了抛出 NotSupportedException
之外不做任何事情,这是通过应用于该方法的 MethodNotSupportedAttribute
来确定的。
有时,接口提供的抽象级别比非托管 Total Commander 插件接口稍高。它不使用句柄,而是使用实际对象——位图和图标。
插件程序集
插件程序集包含负责创建插件实例的代码,它实际上将函数导出到非托管环境,并将 Total Commander 的函数调用传递给插件抽象基类。由于插件程序集由 Total Commander 插件构建器使用信息从模板生成,因此它针对实际代表的插件进行了定制——并且它始终只代表一个插件。如果插件实现程序集包含多个插件,则会生成多个插件程序集。
插件程序集中最棘手的部分是程序集绑定。插件程序集引用 Tools.TotalCommander
和插件实现程序集。Tools.TotalCommander 引用 Tools,而实现插件的程序集可以引用任何程序集——本地复制的或 GAC 中的。Total Commander 插件通常位于 Total Commander 安装文件夹的 plugins 子文件夹的子文件夹中。现在,问题出现了:插件程序集被加载到 totalcmd.exe 进程中。Totalcmd.exe 位于比插件程序集高两层或更多层的文件夹中。插件程序集引用插件实现程序集和 Tools.TotalCommander,它们都不在 GAC 中。默认情况下,.NET 会在进程启动的同一目录中查找引用。子文件夹不会被检查。因此,找不到引用,插件就会崩溃。Total Commander 可以从中恢复,但插件未加载且无法正常工作。唯一正确加载的程序集是插件程序集,因为它作为看起来像非托管 Win32 API 风格 DLL 的一部分加载。所以,插件程序集必须确保引用会在它们所在的位置被搜索到。我们可以通过几种方式解决这个问题:
- 将所有程序集放在与 totalcmd.exe 相同的目录中
- 这不是个好主意,因为 Total Commander 安装目录中可能会有很多可能冲突的文件。这可能会导致混乱。
- 将所有必要的程序集放在 GAC 中
- 这也不是一个很好的解决方案。首先,它对插件安装过程提出了额外的要求,并需要管理员权限。它阻止了插件在安装到闪存驱动器时随 Total Commander 一起携带。而且,像 Total Commander 插件实现程序集这样的单用途程序集不应该放在 GAC 中。
- 拦截程序集解析
- 我们可以处理
AppDomain.AssemblyResolve
事件,当程序集解析失败时会触发该事件。该事件的处理程序可以加载程序集并返回它。当 Total Commander 使用多个托管插件时,会产生问题。插件可以使用不同版本的 Tools.TotalCommander,或者插件不一定基于此框架。这种程序集解析可能会有效地破坏其他托管插件。 - 将每个插件加载到单独的应用程序域中
- 这是我最终选择的方式。只有插件程序集被加载到默认域中。它会创建另一个域,并将其基目录设置为它所在的目录。然后,它在新创建的应用程序域中创建一个助手类的实例。该类会创建插件类的实例——它能被正确解析,因为所有引用都在其基目录中。插件加载的程序集不会与其他插件干扰,因为应用程序域是分开的。只有一些开销,因为 TC 调用插件程序集中的全局函数,它调用程序集域助手中的函数,它调用插件助手中的函数,它调用插件类中的非虚函数,最后它调用插件类中的虚函数。
Total Commander 插件构建器
一个命令行工具,为插件实现程序集中的每个插件类创建一个插件程序集。它可以从命令行调用,也可以以编程方式使用。它需要访问 C++/CLI 编译器 vcbuild.exe。它用 Visual Basic 编写。使用 Total Commander 插件构建器的最佳方式是在 Visual Studio 的构建后事件中。
它枚举插件实现程序集中的所有类型,对于那些代表 Total Commander 的类型,插件会生成一个插件程序集。在生成插件程序集时,会检查插件类以确定插件类实现了(覆盖了)哪些插件功能。未实现的函数不会在插件程序集中生成。这是通过编写几个 C++ 预处理器 #define
来控制插件程序集的编译方式来实现的。同样,指定了要创建实例的类的名称。通过 C++ 的 #using
指令设置对插件实现程序集的引用。Total Commander 插件构建器还会检查插件实现程序集和插件类的某些属性,以设置插件程序集属性并优化生成行为。
代码
好的,我不会在这里重新输入所有代码。下载附加的示例。代码中只有一些有趣的部分:
创建应用程序域
以下 C++/CLI 代码片段展示了如何创建应用程序域:
namespace Tools{namespace TotalCommanderT{
extern bool RequireInitialize;
extern gcroot<AppDomainHolder^> holder;
//PluginInstanceHolder class keeps plugin instance
PluginInstanceHolder::PluginInstanceHolder(){
this->instance = TC_WFX;
//TC_WFX ide C++ preprocessor macro defined
//to somethig like gcnew MyPluginClass()
}
//AppDomainHolder keeps AppDomain instance
AppDomainHolder::AppDomainHolder(){
this->holder = gcnew PluginInstanceHolder();
}
//Global function initialize is called by plugin functions FsInit
//and FsGetDefRootName which can be called
//as first call to plugin by Total Commander
void Initialize(){
if(!RequireInitialize) return;
RequireInitialize = false;
PluginSelfAssemblyResolver::Setup();
AppDomainSetup^ setup = gcnew AppDomainSetup();
Assembly^ currentAssembly = Assembly::GetExecutingAssembly();
setup->ApplicationBase = IO::Path::GetDirectoryName(currentAssembly->Location);
AppDomain^ pluginDomain = AppDomain::CreateDomain(PLUGIN_NAME,nullptr,setup);
AppDomainHolder^ iholder =
(AppDomainHolder^)pluginDomain->CreateInstanceFromAndUnwrap(
currentAssembly->CodeBase,AppDomainHolder::typeid->FullName);
Tools::TotalCommanderT::holder = iholder;
}
}}
PluginSelfAssemblyResolver
是一个简单的助手类,它允许在 .NET 找不到程序集本身时解析它。它只包含两个函数:
namespace Tools{namespace TotalCommanderT{
Assembly^ PluginSelfAssemblyResolver::OnResolveAssembly(Object^ sender,
ResolveEventArgs^ args){
AssemblyName^ name = gcnew AssemblyName(args->Name);
if(AssemblyName::ReferenceMatchesDefinition(name,
thisAssembly->GetName())) return thisAssembly;
else return nullptr;
}
inline void PluginSelfAssemblyResolver::Setup(){
AppDomain::CurrentDomain->AssemblyResolve +=
gcnew ResolveEventHandler( PluginSelfAssemblyResolver::OnResolveAssembly );
}
}}
定义插件函数
可选和强制性的 Total Commander 插件函数都包装在 #ifdef
-#endif
块中。这些块的相应 #define
由 Total Commander 插件构建器写入 define.h 文件。由于使用了应用程序域的架构,这些函数在插件程序集中出现了三次,签名相同,主体相似;我将它们提取到一个单独的文件 wfxFunctionCalls.h。这个文件在三个不同的位置包含,并带有几个 C++ 预处理器 #define
来控制它的编译方式。每个函数定义如下:
#ifdef TC_FS_INIT
TCPLUGF int FUNC_MODIF FsInit(int PluginNr,tProgressProc pProgressProc,
tLogProc pLogProc,tRequestProc pRequestProc){
return FUNCTION_TARGET->FsInit(PluginNr,pProgressProc,pLogProc,pRequestProc);
}
#endif
TCPLUGF
定义为空(未使用)。我曾考虑过使用 __declspec(dllexport)
来导出函数。后来,我切换到了一个单独的 Exports.def 文件。FUNC_MODIF
要么是 __stdcall
,要么是类名(AppDomainHolder::
或 PluginInstanceHolder::
)。__stdcall
用于导出的函数;内部调用使用托管调用约定。最后,FUNCTION_TARGET
是要调用的实例。对于导出的(全局)函数,它是 Tools::TotalCommanderT::holder
;在 AppDomainHolder
中是 this->holder
;在 PluginInstanceHolder
中是 this->instance
。
WfxFunctionCalls.h 的包含方式如下:
#define TCPLUGF
#define FUNC_MODIF AppDomainHolder::
#define FUNCTION_TARGET this->holder
#include "FunctionCalls.h"
我知道,三次输入可能会更易于理解。但是,有很多函数被输入了三次——我真的很懒,而且,当需要进行一些更改时,只需要做一次(在插件程序集模板中,然后在插件基类和公共头文件中,以及...)。
封送
从非托管到托管代码的封送非常简单。Total Commander 插件接口使用一些结构和常量。对于结构,我创建了托管对应物,并在将对象传递给托管代码之前,先将结构转换为托管对象。在将结构返回给非托管代码之前,再将其转换回来。常量值由托管枚举值表示,并直接转换。Total Commander 和插件之间经常传递字符串。Total Commander 将字符串作为 char*
(有时是 char[MAX_PATH]
)传递和接收——始终以 null 终止。将这些值从非托管代码封送到托管代码很简单,因为 System.String
有一个接受 char*
(System.SByte*
)的构造函数。
注意:在当前版本中,Total Commander 既不向插件传递也不从插件接收 Unicode 字符串(wchar_t*
),尽管 Total Commander 使用的一些 Win32 API 结构被声明为 Unicode。Total Commander 使用当前系统编码。Unicode 支持将在 Total Commander 的未来版本中提供。这不是我的框架的限制,而是 Total Commander 本身的限制。
将字符串传递给非托管代码有点棘手。在 .NET 中可以轻松地枚举字符串的所有字符。但是,这些字符是 Unicode 代码点。它们必须转换为默认系统编码值。最后,我创建了自己的 StringCopy
函数:
namespace Tools{namespace TotalCommanderT{
void StringCopy(String^ source, char* target, int maxlen){
if(source == nullptr)
target[0]=0;
else{
System::Text::Encoding^ enc = System::Text::Encoding::Default;
cli::array<unsigned char>^ bytes = enc->GetBytes(source);
for(int i = 0; i < bytes->Length && i < maxlen-1; i++)
target[i]= bytes[i];
target[source->Length > maxlen-1 ? maxlen-1 : source->Length] = 0;
}
}
void StringCopy(String^ source, wchar_t* target, int maxlen){
StringCopy(source,(char*)(void*)target,maxlen);
}
}}
第二个函数只是将 wchar_t*
视为 char*
,参见上面的注意事项。
该函数使用默认系统编码对字符串进行编码,然后将编码后的字节复制到非托管缓冲区(最多 maxlen - 1
个字符)。最后一个使用字符之后的字符被设置为 nullchar
。
注意:我不确定在默认编码是多字节(例如中文)的系统中,默认编码是否能正常工作。我希望 TC 的未来版本支持 Unicode。
示例插件
示例插件是最简单的插件。它只是访问本地文件系统。Christian Ghisler 提供了 C++ 的类似示例插件。我的插件是用 Visual Basic 编写的。它展示了如何利用托管 Total Commander 插件框架。
注释
Tools.TotalCommander
和示例插件都(不广泛地)使用了我的开源库 Tools。可以在 codeplex.com/Tools 下载它,以及最新版本的插件框架。- 在构建 wfx 示例项目之前,请编辑构建后事件和调试命令行,以反映 Total Commander 的实际位置。在 Vista 上,您需要以管理员权限运行 Visual Studio,或者拥有目标目录(C:\Program Files\totalcmd\plugins\Test\wfx sample\Debug\)的写入权限。
- 示例源代码只是示例源代码。该项目注释掉了几个在进行较大修改时所需的预构建和构建后事件!取消注释这些事件需要几个实用程序来执行这些事件。从 Codeplex 下载完整源代码。
已知问题
- 框架的当前版本仅支持文件系统插件 (WFX)。未来将支持其他类型的插件。
- 框架的当前版本不支持 Unicode。这是由于 Total Commander 对插件的限制。希望在 Total Commander 的未来版本中能够移除此限制。
- 示例插件访问 UNC 路径的唯一方法是用户手动从 Total Commander 命令行切换到 UNC 目录。
- 示例插件中文件/文件夹的属性窗口并非模态于 Total Commander 主窗口。这不是插件框架的限制。我使用 Win32 API 显示属性对话框,但我不知道如何使其模态。
许可证
本文章中的插件框架、插件构建器、示例插件以及任何其他代码均在 codeplex.com/Tools 上的开源许可证下发布。
历史
- 2009-03-08 - 初始发布。
- 2009-03-08 - 更新源代码(AssemblyResolver.cpp 中缺少语句)。