65.9K
CodeProject 正在变化。 阅读更多。
Home

用 Visual Basic(或 C#)编写 Total Commander 插件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (10投票s)

2009年3月8日

CPOL

16分钟阅读

viewsIcon

65972

downloadIcon

2817

本文介绍并描述了一个允许为 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 插件的解决方案是:

  1. 在 C++/CLI 中编写 Total Commander 和托管代码之间的接口。
  2. 用任何托管语言编写插件。

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 中缺少语句)。
© . All rights reserved.