插件系统与模块加载
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 空壳记录 |
| 可回收 ALC | DisablePluginsAssemblyLoadContext,isCollectible: true 的独立 AssemblyLoadContext,禁用插件加载后可整体 Unload() |
| 加载顺序 | InitPlugins 中由 ComparerBuilder 构建的 SortedSet<PluginResult<IPlugin>> 排序结果 |
整个插件子系统被 #if (WINDOWS || MACCATALYST || MACOS || LINUX) && !(IOS || ANDROID) 包裹——移动端平台直接不编译加载器,插件仅在桌面平台可用。
Architecture
上图对应 PluginsCore 的真实调用链:宿主启动时调用 PluginsCore.InitPlugins,内部依次执行 LoadAssemblies → VerifyAssemblies → GetExports,最后按比较器装入 SortedSet<PluginResult<IPlugin>> 返回。禁用插件的程序集进入独立的可回收 ALC,导出元数据后被 Unload() 释放。
类型结构
PluginBase<TPlugin> 是官方插件的抽象基类(泛型自引用便于强类型 DI 注册),DisablePlugin 是 PluginsCore 内部的 sealed record,仅复制元数据并把所有行为方法实现为空——这是"禁用但不失联"的关键设计(见下文)。
核心契约:IPlugin 接口
IPlugin 定义在 BD.WTTS.Client/Plugins/Abstractions/IPlugin.cs,是宿主与插件之间的唯一边界。它混合了三类职责:可用性校验、UI 贡献、生命周期与 DI/IPC/AutoMapper 配置。
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 记录对其逐一复制可以完整还原属性集:
| 属性 | 类型 | 说明 |
|---|---|---|
Id | Guid | 插件唯一标识 |
Name / UniqueEnglishName | string | 显示名 / 唯一英文名(参与排序与黑名单匹配) |
Version | string | 插件版本 |
Description | string? | 描述 |
StoreUrl / HelpUrl / AuthorStoreUrl | string | 商店/帮助/作者主页链接 |
SettingsPageViewType | Type? | 设置页视图类型(禁用壳固定返回 null) |
Author | string? | 作者 |
AssemblyLocation | string | 插件程序集路径 |
AppDataDirectory / CacheDirectory | string | 插件数据/缓存目录 |
IsOfficial | bool | 是否官方插件(参与排序优先级) |
Icon | object? | 图标 |
InstallTime / ReleaseTime | DateTimeOffset | 安装/发布时间 |
LoadError | string?(可写) | 加载失败原因;写入非空值会把 hasValue 置 false |
Source: PluginsCore.cs
核心流程:PluginsCore 加载管线
第 1 步:程序集发现与加载(LoadAssemblies)
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内容来自同方法内部数组字面量)
三个关键行为:
- 双路径发现:DEBUG 构建直接从源码树
src/BD.WTTS.Client.Plugins.{模块}/bin/Debug/{tfm}/加载(免去复制部署,加速开发迭代);发布构建从安装目录AppContext.BaseDirectory/modules/加载。 - 目录名即模块名:每个模块目录必须包含
BD.WTTS.Client.Plugins.{目录名}.dll作为入口判定条件(File.Exists(dllPath)检查通过后才枚举该目录下所有*.dll,SearchOption.AllDirectories),目录名同时用于disablePluginNames匹配。 - 加载结果携带禁用标记:返回
HashSet<PluginResult<Assembly>>,每个程序集绑定bool disable,后续管线据此决定进入哪个容器分支。
单个程序集加载失败(如损坏、被杀软锁定)只记录 Log.Error 并跳过,不会中断整个加载流程;modules/{x}/ 内部某个 dll 加载失败时 EachDirectories 返回 true 直接放弃该目录其余文件。
第 2 步:双 ALC 隔离与禁用插件卸载
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)
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)
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 与初始化的最终遍历顺序:
- 启用在前、禁用在后(
OrderBy(x => x.IsDisable)) - 官方插件优先(
IsOfficial降序) - 内置顺序号:
Accelerator(25) → GameAccount(35) → GameList(45) → Authenticator(55) → ArchiSteamFarmPlus(65) → SteamIdleCard(75) → GameTools(85),第三方插件为ushort.MaxValue - 英文名字典序兜底,保证排序稳定无并列
最后两行 disablePluginsAssemblyLoadContext.Unload(); disablePluginsAssemblyLoadContext = null!; 是整个禁用机制的收尾:禁用插件的程序集在这之后被 GC 回收,其 SettingsPageViewType 已在 DisablePlugin 中固定为 null,宿主 UI 遍历时自然跳过其配置页。
端到端时序
官方插件的声明模式
每个官方插件位于独立项目 src/BD.WTTS.Client.Plugins.{Name}/Plugins/Plugin.cs,采用统一模板:sealed class Plugin : PluginBase<Plugin>,并(在桌面平台条件下)标注 [CompositionExport(typeof(IPlugin))]。
以加速器插件为例(其余 6 个插件结构完全一致,仅命名空间不同):
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 值 | 功能域 |
|---|---|---|
Accelerator | 25 | 网络加速 |
GameAccount | 35 | 游戏账号切换 |
GameList | 45 | 游戏库管理 |
Authenticator | 55 | 令牌验证器 |
ArchiSteamFarmPlus | 65 | ASF 集成 |
SteamIdleCard | 75 | 挂卡 |
GameTools | 85 | 游戏工具 |
| (任意第三方模块) | ushort.MaxValue | 目录名排序兜底 |
Source: PluginsCore.cs
禁用插件外壳:DisablePlugin 记录
DisablePlugin 是 PluginsCore 内部 sealed record class,实现 IPlugin 但所有行为成员均为空实现,仅保留元数据。它有两个防御性设计值得注意:
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
新增一个插件需要三件事:
- 新建项目
BD.WTTS.Client.Plugins.{Name},并按官方模板实现Plugin : PluginBase<Plugin>, IPlugin,桌面平台下标注[CompositionExport(typeof(IPlugin))]; - 部署到
modules/{Name}/BD.WTTS.Client.Plugins.{Name}.dll(DEBUG 下放入源码树约定路径即可被发现); - 若希望控制初始化顺序,在
InitPlugins的OrderPluginswitch 中为{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相关章节