Repository Wiki
BeyondDimension/SteamTools

插件系统与模块加载

Watt Toolkit(SteamTools)客户端采用"插件即模块"的架构:每个业务功能(网络加速、令牌验证器、游戏库、ASF 挂卡等)都以独立的 BD.WTTS.Client.Plugins.* 程序集交付,由宿主进程中的 PluginsCore 在启动阶段完成发现、加载、MEF 组合导出、校验、禁用包装与排序。本文深入讲解这一整套模块加载机制的真实实现。

Purpose and Scope

本页覆盖插件系统的框架层实现:

  • 插件契约 IPlugin(BD.WTTS.Client/Plugins/Abstractions/IPlugin.cs)的完整接口与元数据属性
  • 插件加载核心 PluginsCore(BD.WTTS.Client/Plugins/PluginsCore.cs):程序集发现、AssemblyLoadContext 隔离加载、System.Composition(MEF)组合导出、禁用插件包装与排序
  • 官方插件的声明模式(PluginBase<Plugin> + [CompositionExport(typeof(IPlugin))])
  • 加载失败、校验失败、反射调用防护等边界行为

以下内容有意留给兄弟页面,不在本页展开:

  • 各插件自身的业务功能(加速器、令牌验证器、ASF 等)→ 见对应插件页面
  • 插件商店 UI(PluginStorePage / PluginStorePageViewModel)与设置页(Settings_Plugin)→ 见插件商店相关页面
  • IPC 子进程管线(IpcProvider、RunSubProcessMainAsync 的进程侧实现细节)→ 见 IPC 架构页面

Overview

插件系统解决的核心问题是:让一个跨平台(Windows/macOS/Linux,且排除 iOS/Android)的多功能客户端,能够按目录结构装载功能模块、按需禁用模块、并在宿主启动时以确定的顺序初始化它们。

关键概念与术语:

术语含义
模块AppContext.BaseDirectory/modules/{模块名} 目录,内含 BD.WTTS.Client.Plugins.{模块名}.dll 及其依赖
插件实现了 IPlugin 接口、并经 MEF [CompositionExport] 导出的类型
禁用插件名字出现在 disablePluginNames 集合中的模块;其实例被包装为 DisablePlugin 空壳记录
可回收 ALCDisablePluginsAssemblyLoadContext,isCollectible: true 的独立 AssemblyLoadContext,禁用插件加载后可整体 Unload()
加载顺序InitPlugins 中由 ComparerBuilder 构建的 SortedSet<PluginResult<IPlugin>> 排序结果

整个插件子系统被 #if (WINDOWS || MACCATALYST || MACOS || LINUX) && !(IOS || ANDROID) 包裹——移动端平台直接不编译加载器,插件仅在桌面平台可用。

Architecture

Loading diagram...

上图对应 PluginsCore 的真实调用链:宿主启动时调用 PluginsCore.InitPlugins,内部依次执行 LoadAssemblies → VerifyAssemblies → GetExports,最后按比较器装入 SortedSet<PluginResult<IPlugin>> 返回。禁用插件的程序集进入独立的可回收 ALC,导出元数据后被 Unload() 释放。

类型结构

Loading diagram...

PluginBase<TPlugin> 是官方插件的抽象基类(泛型自引用便于强类型 DI 注册),DisablePlugin 是 PluginsCore 内部的 sealed record,仅复制元数据并把所有行为方法实现为空——这是"禁用但不失联"的关键设计(见下文)。

核心契约:IPlugin 接口

IPlugin 定义在 BD.WTTS.Client/Plugins/Abstractions/IPlugin.cs,是宿主与插件之间的唯一边界。它混合了三类职责:可用性校验、UI 贡献、生命周期与 DI/IPC/AutoMapper 配置。

csharp
1public partial interface IPlugin 2{ 3 /// <summary> 4 /// 判断插件是否满足要求,如没有任何要求则返回 <see angword="true"/> 5 /// </summary> 6 bool HasValue([NotNullWhen(false)] out string? error); 7 8 /// <summary> 9 /// 获取当前插件需要加载的菜单项视图模型 10 /// </summary> 11 IEnumerable<MenuTabItemViewModel>? GetMenuTabItems(); 12 13 /// <summary> 14 /// 获取插件的配置项 15 /// </summary> 16 IEnumerable<(Action<IServiceCollection>? @delegate, bool isInvalid, string name)>? GetConfiguration(bool directoryExists); 17 18 /// <summary> 19 /// MainWindowViewModel.Initialize 20 /// </summary> 21 ValueTask OnInitializeAsync(); 22 23 /// <summary> 24 /// 配置按需使用的依赖注入服务 25 /// </summary> 26 void ConfigureDemandServices(IServiceCollection services, Startup startup); 27 28 /// <summary> 29 /// 配置任何进程都必要的依赖注入服务 30 /// </summary> 31 void ConfigureRequiredServices(IServiceCollection services, Startup startup); 32 33 void ConfigureServices(IpcProvider ipcProvider, Startup startup) { } 34 35 /// <summary> 36 /// 配置 AutoMapper 37 /// </summary> 38 void OnAddAutoMapper(IMapperConfigurationExpression cfg); 39 40 /// <summary> 41 /// 未处理的异常 42 /// </summary> 43 void OnUnhandledException(Exception ex, string name, bool? isTerminating = null); 44 45 ValueTask OnExit(); 46 47 /// <summary> 48 /// 启动子进程 IPC 程序的 Main 函数 49 /// </summary> 50 Task<int> RunSubProcessMainAsync(string moduleName, string pipeName, string processId, string? encodedArgs); 51 52 /// <summary> 53 /// 当子进程 IPC 管道连接中 54 /// </summary> 55 ValueTask OnPeerConnected(bool isReconnected); 56 57 /// <summary> 58 /// 解析程序带参数启动时执行指令 59 /// </summary> 60 ValueTask OnCommandRun(params string[] commandParams); 61}

Source: IPlugin.cs

设计意图解读:

  • 两段式 DI 注册:ConfigureRequiredServices(任何进程都必须注册的服务)与 ConfigureDemandServices(按需注册)分离,让插件能区分"主进程必需"与"仅 UI/按需场景"的依赖,减少后台子进程的初始化成本。
  • HasValue 前置校验:以 [NotNullWhen(false)] out string? error 的形式在导出阶段就判定插件是否可用(例如运行环境不满足时返回 false),错误字符串会进入日志与 DisablePlugin.LoadError。
  • GetMenuTabItems / GetConfiguration:插件对宿主 UI 与设置页的贡献点,宿主遍历所有插件聚合菜单与配置项。
  • IPC 钩子:RunSubProcessMainAsync、OnPeerConnected、ConfigureServices(IpcProvider, ...) 让同一个插件程序集既能在主进程运行,也能在 IPC 子进程中作为 Main 入口运行。
  • 注意 ConfigureServices 是默认接口方法(带空实现 { }),插件可选择不覆写——降低实现最小插件的成本。

插件元数据属性

接口的属性部分声明在同目录 IPlugin.Properties.cs(partial 接口拆分)。从 PluginsCore.DisablePlugin 记录对其逐一复制可以完整还原属性集:

属性类型说明
IdGuid插件唯一标识
Name / UniqueEnglishNamestring显示名 / 唯一英文名(参与排序与黑名单匹配)
Versionstring插件版本
Descriptionstring?描述
StoreUrl / HelpUrl / AuthorStoreUrlstring商店/帮助/作者主页链接
SettingsPageViewTypeType?设置页视图类型(禁用壳固定返回 null)
Authorstring?作者
AssemblyLocationstring插件程序集路径
AppDataDirectory / CacheDirectorystring插件数据/缓存目录
IsOfficialbool是否官方插件(参与排序优先级)
Iconobject?图标
InstallTime / ReleaseTimeDateTimeOffset安装/发布时间
LoadErrorstring?(可写)加载失败原因;写入非空值会把 hasValue 置 false

Source: PluginsCore.cs

核心流程:PluginsCore 加载管线

第 1 步:程序集发现与加载(LoadAssemblies)

csharp
1static readonly string[] DefaultModules = new[] { 2 Accelerator, GameAccount, GameList, ArchiSteamFarmPlus, 3 Authenticator, GameTools, SteamIdleCard, 4}; 5 6internal static HashSet<PluginResult<Assembly>>? LoadAssemblies( 7 HashSet<string>? disablePluginNames = null, 8 params string[] loadModules) 9{ 10 HashSet<PluginResult<Assembly>>? assemblies = null; 11#if DEBUG // DEBUG 模式遍历项目查找模块 12 var projPath = ProjectUtils.ProjPath; 13 if (!string.IsNullOrWhiteSpace(projPath)) 14 { 15 var modules = loadModules.Any_Nullable() ? loadModules : DefaultModules; 16 foreach (var item in modules) 17 { 18 var disable = disablePluginNames != null && disablePluginNames.Contains(item); 19 var assemblyPath = Path.Combine(projPath, "src", 20 $"BD.WTTS.Client.Plugins.{item}", "bin", "Debug", ProjectUtils.tfm, 21 $"BD.WTTS.Client.Plugins.{item}.dll"); 22 if (File.Exists(assemblyPath)) 23 { 24 Assembly assembly; 25 try 26 { 27 assembly = LoadFrom(disable, assemblyPath); 28 } 29 catch (Exception e) 30 { 31 Log.Error(TAG, e, $"AssemblyLoadFrom fail, assemblyPath: {assemblyPath}"); 32 continue; 33 } 34 assemblies ??= new(); 35 assemblies.Add(new(disable, assembly)); 36 } 37 } 38 } 39#endif 40 var pluginsPath = Path.Combine(AppContext.BaseDirectory, "modules"); 41 if (Directory.Exists(pluginsPath)) 42 { 43 // ... 按 modules/{目录名}/ 递归枚举 dll 并 LoadFrom(disable, path) 44 } 45 return assemblies; 46}

Source: PluginsCore.cs(节选自 LoadAssemblies,注释代码已省略,DefaultModules 内容来自同方法内部数组字面量)

三个关键行为:

  1. 双路径发现:DEBUG 构建直接从源码树 src/BD.WTTS.Client.Plugins.{模块}/bin/Debug/{tfm}/ 加载(免去复制部署,加速开发迭代);发布构建从安装目录 AppContext.BaseDirectory/modules/ 加载。
  2. 目录名即模块名:每个模块目录必须包含 BD.WTTS.Client.Plugins.{目录名}.dll 作为入口判定条件(File.Exists(dllPath) 检查通过后才枚举该目录下所有 *.dll,SearchOption.AllDirectories),目录名同时用于 disablePluginNames 匹配。
  3. 加载结果携带禁用标记:返回 HashSet<PluginResult<Assembly>>,每个程序集绑定 bool disable,后续管线据此决定进入哪个容器分支。

单个程序集加载失败(如损坏、被杀软锁定)只记录 Log.Error 并跳过,不会中断整个加载流程;modules/{x}/ 内部某个 dll 加载失败时 EachDirectories 返回 true 直接放弃该目录其余文件。

第 2 步:双 ALC 隔离与禁用插件卸载

csharp
1/// <summary> 2/// 禁用的插件使用单独的 <see cref="AssemblyLoadContext"/> 加载与卸载 3/// </summary> 4sealed class DisablePluginsAssemblyLoadContext : AssemblyLoadContext 5{ 6 public DisablePluginsAssemblyLoadContext() : base($"{Constants.HARDCODED_APP_NAME_NEW}.DisablePlugins", true) 7 { 8 } 9 10 protected override Assembly? Load(AssemblyName assemblyName) 11 { 12 var assemblyLoadContext = DefaultAssemblyLoadContext; 13 if (assemblyLoadContext != this) 14 { 15 try 16 { 17 var assembly = assemblyLoadContext.LoadFromAssemblyName(assemblyName); 18 if (assembly != null) 19 { 20 return assembly; 21 } 22 } 23 catch { } 24 } 25 return base.Load(assemblyName); 26 } 27} 28 29static AssemblyLoadContext DefaultAssemblyLoadContext 30{ 31 get 32 { 33 // 在 Windows 上通过自定义 AppHost 程序集加载上下文为 IsolatedComponentLoadContext 而不是 Default 34 var assemblyLoadContext = AssemblyLoadContext.GetLoadContext(typeof(PluginsCore).Assembly); 35 assemblyLoadContext ??= AssemblyLoadContext.Default; 36 return assemblyLoadContext; 37 } 38} 39 40static DisablePluginsAssemblyLoadContext disablePluginsAssemblyLoadContext = new(); 41 42[MethodImpl(MethodImplOptions.AggressiveInlining)] 43static Assembly LoadFrom(bool disable, string assemblyPath) 44{ 45 AssemblyLoadContext? assemblyLoadContext; 46 if (disable && disablePluginsAssemblyLoadContext != null) 47 { 48 assemblyLoadContext = disablePluginsAssemblyLoadContext; 49 } 50 else 51 { 52 assemblyLoadContext = DefaultAssemblyLoadContext; 53 } 54 var assembly = assemblyLoadContext.LoadFromAssemblyPath(assemblyPath); 55 return assembly; 56}

Source: PluginsCore.cs(上下文类)与 PluginsCore.cs(LoadFrom)

设计意图:

  • 为什么禁用插件还要加载? 因为宿主仍需要它的元数据(名称、图标、商店链接)展示在插件管理 UI 中。但业务类型不能留在主 ALC 里——所以禁用插件进入 isCollectible: true 的独立 ALC,元数据被读取并复制进 DisablePlugin 记录后,第 4 步末尾调用 disablePluginsAssemblyLoadContext.Unload() 物理卸载全部禁用插件程序集。
  • ALC 委托回退:DisablePluginsAssemblyLoadContext.Load 先尝试从宿主 ALC 解析同名程序集(优先复用宿主已加载的公共依赖),失败再走默认逻辑,避免同一依赖被加载两份且互不兼容。
  • AppHost 兼容:注释指明 Windows 上自定义 AppHost 的 ALC 是 IsolatedComponentLoadContext 而非 Default,因此 DefaultAssemblyLoadContext 通过 AssemblyLoadContext.GetLoadContext(typeof(PluginsCore).Assembly) 反查宿主实际 ALC,而非直接引用 AssemblyLoadContext.Default。

第 3 步:程序集可用性校验(VerifyAssemblies)与 MEF 导出(GetExports)

csharp
1static IEnumerable<PluginResult<Assembly>> VerifyAssemblies(IEnumerable<PluginResult<Assembly>> assemblies) 2{ 3 foreach (var assembly in assemblies) 4 { 5 var isOk = false; 6 try 7 { 8 // This call is bare minimum to verify if the assembly can load itself 9 assembly.Data.GetTypes(); 10 isOk = true; 11 } 12 catch (Exception e) 13 { 14 Log.Error(TAG, e, $"assembly.GetTypes fail, assembly: {assembly}"); 15 } 16 if (isOk) yield return assembly; 17 } 18} 19 20static HashSet<IPlugin>? GetExports( 21 HashSet<IPlugin> plugins, 22 HashSet<IPlugin> disablePlugins, 23 IEnumerable<Assembly> assemblies) 24{ 25 ConventionBuilder conventions = new(); 26 conventions.ForTypesDerivedFrom<IPlugin>().Export<IPlugin>(); 27 28 var configuration = new ContainerConfiguration().WithAssemblies(assemblies, conventions); 29 30 try 31 { 32 using CompositionHost container = configuration.CreateContainer(); 33 var exports = container.GetExports<IPlugin>().ToArray(); 34 foreach (var plugin in exports) 35 { 36 if (string.IsNullOrWhiteSpace(plugin.UniqueEnglishName) || 37 string.Equals(plugin.UniqueEnglishName, 38 IPlatformService.IPCRoot.moduleName, 39 StringComparison.OrdinalIgnoreCase)) 40 { 41 continue; // 屏蔽此名词 42 } 43 44 var isPluginBase = false; 45 if (plugin.HasValue(out var error)) 46 { 47 if (plugin is PluginBase pluginBase) 48 { 49 isPluginBase = true; 50 plugins.Add(pluginBase); 51 continue; 52 } 53 } 54 Log.Error(TAG, 55 "CompositionHost.GetExports plugin validation failed, name: {name}, isPluginBase: {isPluginBase}, error: {error}.", 56 plugin.UniqueEnglishName, isPluginBase, error); 57 disablePlugins.Add(plugin); 58 } 59 } 60 catch (Exception e) 61 { 62 Log.Error(TAG, e, "CompositionHost.GetExports fail."); 63 return null; 64 } 65 return plugins; 66}

Source: PluginsCore.cs

要点:

  • GetTypes() 作为最小校验:强制 JIT 解析程序集内所有类型,任何缺失依赖、损坏元数据都会在此暴露并被淘汰,防止坏插件进入组合阶段拖垮整个容器。
  • 约定式 MEF 导出:不要求插件手动 [Export]——ConventionBuilder.ForTypesDerivedFrom<IPlugin>().Export<IPlugin>() 让所有 IPlugin 派生类型自动导出(官方插件上的 [CompositionExport(typeof(IPlugin))] 属性与该约定双保险,详见下文示例)。
  • 三级准入过滤:① UniqueEnglishName 为空或与 IPC 根模块名冲突则丢弃;② HasValue 必须通过(环境要求检查);③ 运行时类型必须是 PluginBase 派生类(防止任意 IPlugin 实现绕过基类约定)。任一不过 → 落入 disablePlugins 集合。
  • 容器级失败返回 null:GetExports 整体异常(容器无法构建)时返回 null,由调用方结束本次插件初始化。

第 4 步:初始化、排序与禁用壳包装(InitPlugins)

csharp
1internal static SortedSet<PluginResult<IPlugin>>? InitPlugins( 2 HashSet<string>? disablePluginNames = null, 3 params string[] loadModules) 4{ 5 var assemblies = LoadAssemblies(disablePluginNames, loadModules); 6 if (!assemblies.Any_Nullable()) return null; 7 8 var assemblies_ = VerifyAssemblies(assemblies).ToArray(); 9 if (!assemblies_.Any()) return null; 10 11 static int OrderPlugin(IPlugin plugin) => plugin.UniqueEnglishName switch 12 { 13 Accelerator => 25, 14 GameAccount => 35, 15 GameList => 45, 16 Authenticator => 55, 17 ArchiSteamFarmPlus => 65, 18 SteamIdleCard => 75, 19 GameTools => 85, 20 _ => ushort.MaxValue, 21 }; 22 23 var comparer = ComparerBuilder.For<PluginResult<IPlugin>>() 24 .OrderBy(x => x.IsDisable) 25 .ThenBy(x => x.Data.IsOfficial, descending: true) 26 .ThenBy(x => x.Data.UniqueEnglishName); 27 SortedSet<PluginResult<IPlugin>> pluginResults = new(comparer); 28 HashSet<IPlugin> disablePlugins = new(); 29 HashSet<IPlugin> activePlugins = new(); 30 GetExports(activePlugins, disablePlugins, assemblies_.Where(x => !x.IsDisable).Select(x => x.Data)); 31 foreach (var plugin in activePlugins) 32 { 33 pluginResults.Add(new(false, plugin)); 34 } 35 GetExports(disablePlugins, disablePlugins, assemblies_.Where(x => x.IsDisable).Select(x => x.Data)); 36 foreach (var plugin in disablePlugins) 37 { 38 try 39 { 40 var plugin_ = new DisablePlugin(plugin); 41 pluginResults.Add(new(true, plugin_)); 42 } 43 catch (Exception e) 44 { 45 Log.Error(TAG, e, "Failed to initialize disabled plugin."); 46 } 47 } 48 disablePluginsAssemblyLoadContext.Unload(); 49 disablePluginsAssemblyLoadContext = null!; 50 return pluginResults; 51}

Source: PluginsCore.cs(完整 InitPlugins,comparer 中省略了与 OrderPlugin 重复引用的一行 .ThenBy(x => OrderPlugin(x.Data)) 表述,源码顺序为 IsDisable → IsOfficial desc → OrderPlugin → UniqueEnglishName)

排序比较器确定了 UI 与初始化的最终遍历顺序:

  1. 启用在前、禁用在后(OrderBy(x => x.IsDisable))
  2. 官方插件优先(IsOfficial 降序)
  3. 内置顺序号:Accelerator(25) → GameAccount(35) → GameList(45) → Authenticator(55) → ArchiSteamFarmPlus(65) → SteamIdleCard(75) → GameTools(85),第三方插件为 ushort.MaxValue
  4. 英文名字典序兜底,保证排序稳定无并列

最后两行 disablePluginsAssemblyLoadContext.Unload(); disablePluginsAssemblyLoadContext = null!; 是整个禁用机制的收尾:禁用插件的程序集在这之后被 GC 回收,其 SettingsPageViewType 已在 DisablePlugin 中固定为 null,宿主 UI 遍历时自然跳过其配置页。

端到端时序

Loading diagram...

官方插件的声明模式

每个官方插件位于独立项目 src/BD.WTTS.Client.Plugins.{Name}/Plugins/Plugin.cs,采用统一模板:sealed class Plugin : PluginBase<Plugin>,并(在桌面平台条件下)标注 [CompositionExport(typeof(IPlugin))]。

以加速器插件为例(其余 6 个插件结构完全一致,仅命名空间不同):

csharp
1#if (WINDOWS || MACCATALYST || MACOS || LINUX) && !(IOS || ANDROID) 2[CompositionExport(typeof(IPlugin))] 3#endif 4public sealed class Plugin : PluginBase<Plugin>, IPlugin 5{ 6 // 插件业务实现…… 7}

Sources:

模式解读:

  • 条件编译与加载器一致:属性和整个 PluginsCore 都被同一个平台宏包裹——移动端不编译插件导出,桌面端才参与组合。这保证了"加载器不存在 ⇒ 导出不存在"的编译期对称性。
  • PluginBase<Plugin> 自引用泛型:使每个插件获得一个以自身类型参数化的基类,便于强类型的单例注册与 SettingsPageViewType 等属性由基类统一实现(属性体在 PluginBase.Properties.cs / IPlugin.Properties.cs 中拆分定义)。
  • sealed + 显式 IPlugin:禁止再被继承,并向 MF 约定与属性双重声明导出意图。

已知官方插件与加载顺序

模块名(目录/程序集名后缀)OrderPlugin 值功能域
Accelerator25网络加速
GameAccount35游戏账号切换
GameList45游戏库管理
Authenticator55令牌验证器
ArchiSteamFarmPlus65ASF 集成
SteamIdleCard75挂卡
GameTools85游戏工具
(任意第三方模块)ushort.MaxValue目录名排序兜底

Source: PluginsCore.cs

禁用插件外壳:DisablePlugin 记录

DisablePlugin 是 PluginsCore 内部 sealed record class,实现 IPlugin 但所有行为成员均为空实现,仅保留元数据。它有两个防御性设计值得注意:

csharp
1public DisablePlugin(IPlugin plugin) 2{ 3 StackTrace stackTrace = new(); 4 var isInternalCall = ReflectionHelper.IsInternalCall<DisablePlugin>(stackTrace); 5 if (!isInternalCall) 6 throw new ApplicationException("Disable reflection calls to this constructor."); 7 8 Id = plugin.Id; 9 Name = plugin.Name; 10 UniqueEnglishName = plugin.UniqueEnglishName; 11 Version = plugin.Version; 12 Description = plugin.Description; 13 StoreUrl = plugin.StoreUrl; 14 HelpUrl = plugin.HelpUrl; 15 Author = plugin.Author; 16 AuthorStoreUrl = plugin.AuthorStoreUrl; 17 AssemblyLocation = plugin.AssemblyLocation; 18 AppDataDirectory = plugin.AppDataDirectory; 19 CacheDirectory = plugin.CacheDirectory; 20 IsOfficial = plugin.IsOfficial; 21 Icon = plugin.Icon; 22 InstallTime = plugin.InstallTime; 23 ReleaseTime = plugin.ReleaseTime; 24 25 try 26 { 27 hasValue = plugin.HasValue(out hasNotValueError); 28 } 29 catch (Exception ex) 30 { 31 hasValue = false; 32 hasNotValueError = ex.ToString(); 33 } 34}

Source: PluginsCore.cs

  • 构造器反射防护:通过 ReflectionHelper.IsInternalCall 检查调用栈,仅允许 PluginsCore 内部构造,防止外部(尤其是第三方插件)伪造"禁用壳"来吞掉其他插件。
  • 构造期快照 HasValue:真实插件的 HasValue 只在包装时调用一次,异常被捕获并转成 LoadError,保证禁用壳构造永不抛出(InitPlugins 外层仍再包一层 try/catch 记录 Failed to initialize disabled plugin.)。
  • 可写 LoadError:显式接口实现 string? IPlugin.LoadError 的 setter 会同步翻转 hasValue——宿主可在运行期把插件标记为失效(例如商店校验失败),UI 据此显示禁用原因。
  • 空行为面:GetMenuTabItems 返回 null、OnInitializeAsync 等返回 ValueTask.CompletedTask、RunSubProcessMainAsync 返回 0——宿主可以无差别遍历启用/禁用插件而无需类型判断。

API Reference

PluginsCore.InitPlugins(disablePluginNames, loadModules): SortedSet<PluginResult<IPlugin>>?

插件子系统总入口,internal static,桌面平台条件编译。

参数:

  • disablePluginNames (HashSet<string>?):禁用模块名集合,与 modules/ 目录名(或 DEBUG 下的模块名常量)做精确匹配;null 表示全部启用。
  • loadModules (params string[]):指定只加载的模块名;为空则加载 modules/ 下全部目录(DEBUG 下回退到 7 个内置模块名)。

返回: 按比较器(IsDisable 升序 → IsOfficial 降序 → 内置顺序号 → UniqueEnglishName)排序的结果集;无可加载程序集、全部校验失败或 MEF 容器失败时返回 null。

副作用: 会创建并最终 Unload()(置 null!)disablePluginsAssemblyLoadContext;重复调用在第二次进入禁用分支时 disablePluginsAssemblyLoadContext != null 判断为 false,禁用程序集将落入默认 ALC(不可卸载)——因此该方法设计为启动期一次性调用。

PluginsCore.LoadAssemblies(disablePluginNames, loadModules): HashSet<PluginResult<Assembly>>?

internal static,[MethodImpl(AggressiveInlining)]。从 DEBUG 源码树与 modules/ 目录枚举并加载程序集,返回带禁用标记的程序集集合;未找到任何程序集时返回 null。

IPlugin.HasValue(out string? error): bool

契约级校验。[NotNullWhen(false)]:返回 false 时 error 非空,描述不满足的环境要求。在 MEF 导出循环中被调用,失败插件直接降级进 disablePlugins。

IPlugin.ConfigureRequiredServices(IServiceCollection, Startup) / ConfigureDemandServices(...): void

两段式服务注册。前者面向"任何进程"(含 IPC 子进程)必需的服务;后者仅在需要该插件能力的进程/场景按需注册。

IPlugin.RunSubProcessMainAsync(moduleName, pipeName, processId, encodedArgs): Task<int>

IPC 子进程 Main 入口委托。宿主以模块名路由到对应插件的实现,返回进程退出码;DisablePlugin 固定返回 Task.FromResult(0)。

IPlugin.GetMenuTabItems(): IEnumerable<MenuTabItemViewModel>?

插件向主窗口贡献的菜单页签;禁用壳返回 null,宿主据此隐藏该插件入口。

Failure Modes, Edge Cases & Concurrency

场景源码行为位置
单个插件 dll 损坏/依赖缺失LoadFrom 抛异常 → Log.Error("AssemblyLoadFrom fail") → 跳过该文件;modules/{x}/ 内失败时放弃整目录PluginsCore.cs#L279-L283
程序集可加载但类型解析失败VerifyAssemblies 中 GetTypes() 抛异常 → 淘汰该程序集并记录PluginsCore.cs#L354-L372
MEF 容器整体构建/导出失败GetExports catch → 返回 null → InitPlugins 链路短路,本次无插件PluginsCore.cs#L420-L424
插件 UniqueEnglishName 为空或等于 IPC 根模块名continue 静默丢弃(防命名劫持 IPC 路由)PluginsCore.cs#L391-L397
插件非 PluginBase 派生或 HasValue false记录 plugin validation failed 并降级为禁用插件PluginsCore.cs#L399-L417
禁用插件真实 HasValue 抛异常构造器捕获,异常全文写入 LoadError,壳仍可用PluginsCore.cs#L86-L94
外部反射调用 DisablePlugin 构造器抛 ApplicationException("Disable reflection calls to this constructor.")PluginsCore.cs#L64-L67
平台不支持(iOS/Android)整个 PluginsCore 与导出属性被条件编译剔除,插件层完全不存在PluginsCore.cs#L1

并发与生命周期:所有加载状态(disablePluginsAssemblyLoadContext 静态字段、返回的 SortedSet)均未做线程同步,设计上在宿主启动单线程阶段一次性完成;Unload() 之后 disablePluginsAssemblyLoadContext = null! 使后续 LoadFrom(disable: true) 分支因空检查自动退回默认 ALC,避免 NRE。

Extension Points

新增一个插件需要三件事:

  1. 新建项目 BD.WTTS.Client.Plugins.{Name},并按官方模板实现 Plugin : PluginBase<Plugin>, IPlugin,桌面平台下标注 [CompositionExport(typeof(IPlugin))];
  2. 部署到 modules/{Name}/BD.WTTS.Client.Plugins.{Name}.dll(DEBUG 下放入源码树约定路径即可被发现);
  3. 若希望控制初始化顺序,在 InitPlugins 的 OrderPlugin switch 中为 {Name} 添加顺序号(第三方插件不修改源码则为 ushort.MaxValue,按英文名排序)。

由于 ConventionBuilder.ForTypesDerivedFrom<IPlugin>().Export<IPlugin>() 是约定式导出,只要类型继承自 IPlugin 即被容器发现——但必须同时是 PluginBase 派生才能成为激活插件,这是框架有意收紧的准入线。

相关页面

  • 各插件业务实现:Accelerator、Authenticator、GameList、ArchiSteamFarmPlus 等各有独立页面
  • 插件商店与设置 UI:PluginStorePage、Settings_Plugin、PluginStorePageViewModel、SettingsPageViewModel.Plugin
  • IPC 架构与子进程托管:IPlatformService.IPCRoot、dotnetCampus.Ipc.Pipes 相关章节

Sources

(2 files)
src/BD.WTTS.Client/Plugins
src/BD.WTTS.Client/Plugins/Abstractions