Skip to content

服务

SDK 0.2.5。服务通过稳定 ID 和独立版本范围协商。

插件从 context.Services 获取宿主服务。真正不可缺少的服务写入 Manifest 的 services.required;增强体验但可降级的服务写入 services.optional

当前服务

核心宿主服务

IDC# 接口用途
pcl.loggingIPluginLogger结构化插件日志;也可直接用 context.Logger
pcl.dispatcherIPluginDispatcher在宿主 UI/主线程执行操作;也可用 context.Dispatcher
pcl.notificationsIPluginNotificationService信息与警告提示
pcl.settingsIPluginSettingsStore按插件隔离的键值设置
pcl.commandsIPluginCommandService注册和调用命令
pcl.tasksIPluginTaskService由生命周期管理的后台任务
pcl.instances.readIPluginInstanceReadService只读 Minecraft实例列表
pcl.localizationIPluginLocalizationService读取 Host当前语言和插件本地化字符串
pcl.secure-storageIPluginSecureStorageHost托管的插件隔离凭据存储
pcl.uri-launcherIPluginUriLauncher通过 Host 打开外部 HTTP/HTTPS 链接
pcl.background-tasksIPluginBackgroundTaskService启动器任务管理进度(与 MC 安装下载同界面)
pcl.package-assetsIPluginPackageAssetService已安装签名包内的只读资源(文件表 + SHA-256)

这些 ID定义在 PluginServiceIdsIPluginLoggerIPluginDispatcher 同时通过 context.Loggercontext.Dispatcher 提供便捷入口。

运行时: 上表服务在 PCL.Plugin.Sidecar(CoreCLR) 内向插件提供;AOT 宿主本身不加载插件程序集。见 架构与边界

任务管理进度(pcl.background-tasks

用于插件自己的下载、安装、更新等长任务,进度会进入宿主「任务管理」页(与 Minecraft 安装相同 UI):

csharp
IPluginBackgroundTaskService tasks = context.Services.Require<IPluginBackgroundTaskService>();
using IPluginBackgroundTask task = tasks.Begin("下载资源包", openTaskManager: true);
try
{
    task.Report(new PluginBackgroundTaskProgress("下载中", "1.2 MB / 4.0 MB", Progress: 0.3, SpeedBytesPerSecond: 120_000));
    // ... 使用 task.Token 支持用户取消
    task.Complete("下载完成");
}
catch (OperationCanceledException)
{
    task.Fail("已取消", canceled: true);
}

建议在 Manifest services.optional 中声明 "pcl.background-tasks": ">=0.1 <1.0",以便在不支持该服务的 Host 上降级为静默进度或日志。

UI与导航服务

IDC# 接口NuGet包用途
pcl.uiIPluginUiSurfaceRegistryPCLN.Plugin.Abstractions查询宿主发布的 UI Surface与 Slot
pcl.ui.patchIPluginUiPatchServicePCLN.Plugin.Abstractions注册 Patch、规划顺序并查看冲突
pcl.navigationIPluginNavigationServicePCLN.Plugin.UI导航到已注册或 Host公开的稳定路由
pcl.ui.avaloniaIAvaloniaUiAccessServicePCLN.Plugin.UI.Avalonia受权限控制地访问真实 Avalonia对象和公开 Target
pcl.ui.avalonia.pagesIAvaloniaPluginPageServicePCLN.Plugin.UI.Avalonia注册插件拥有的完整主导航页面并执行导航
pcl.ui.avalonia.windowsIAvaloniaPluginWindowServicePCLN.Plugin.UI.Avalonia注册、显示和枚举插件拥有的窗口

这四个适配服务 ID定义在 PluginUiServiceIdsIAvaloniaPluginPageService 继承 IPluginNavigationService,因此页面服务同时提供 RegisterNavigateAsync

扩展、注册表与运行时补丁

IDC# 接口用途
pcl.registryIPluginRegistryServiceACL 保护的可组合扩展注册表(非 Windows 注册表)
pcl.runtime-patchesIPluginRuntimePatchService受信运行时方法补丁(Mixin/Harmony 风格)
pcl.package-assetsIPluginPackageAssetService已安装签名包解析只读资源并校验文件表 SHA-256

pcl.registry / pcl.runtime-patches 需要 Manifest 声明对应权限(如 registry.readruntime.patch.host)。完整示例见 注册表与运行时注入

pcl.package-assetspcl.files 不同:

  • pcl.files → 插件私有数据目录读写;
  • pcl.package-assets → 安装目录内、签名文件表列出的只读资源(例如包内模板、静态配置)。
csharp
IPluginPackageAssetService packages = context.Services.Require<IPluginPackageAssetService>();
PluginPackageAssetResult result = await packages.ResolveAsync("assets/template.json", cancellationToken);
if (result.IsSuccess)
    context.Logger.Info($"asset @ {result.Asset!.FullPath}");

插件协作与宿主专用服务

IDC# 接口用途
pcl.exportsIPluginExportRegistry在稳定共享契约上导出或导入插件间服务
pcl.marketIPluginMarketClient(宿主内部)宿主管理的在线市场 HTTP 客户端

pcl.exports 定义在 PluginServiceIds。导出契约程序集必须由默认加载上下文共享;运行时会拒绝插件私有类型越过边界。

市场说明(与 Sidecar 对齐):

  • Sidecar / PCL.Plugin 内部使用 IPluginMarketClient(HTTP)完成浏览、下载与签名校验。
  • 服务 ID pcl.market 是稳定预留;当前产品不会把远端市场客户端默认注入到第三方插件的 context.Services
  • 插件若需市场能力,应通过宿主 UI / data-chain 动作或后续公开的可选服务协商,不要假设 TryGet<IPluginMarketClient> 一定成功。

公开契约与 Host可用性

NuGet包中存在接口或服务 ID,只表示插件可以针对稳定契约编译,不表示每个 PCL N或第三方 Host版本都提供实现。尤其是导航、完整 Avalonia页面、窗口、Raw Avalonia、本地化和插件导出服务,应先协商能力:

csharp
PluginApiVersionRange uiRange = PluginApiVersionRange.Parse(">=0.1 <1.0");

if (context.Services.Supports(PluginUiServiceIds.AvaloniaPages, uiRange) &&
    context.Services.TryGet<IAvaloniaPluginPageService>(out var pages))
{
    // 注册完整页面。
}
else
{
    context.Logger.Warn("当前 Host 不提供完整 Avalonia 页面;核心功能继续运行。");
}

默认把可降级能力写入 services.optional。只有插件没有该服务就无法提供任何有效功能,并且 host.minimumVersion 已锁定到明确提供该服务的 Host版本时,才放入 services.required

必需服务

Manifest:

json
"services": {
  "required": {
    "pcl.commands": ">=0.1 <1.0",
    "pcl.settings": ">=0.1 <1.0"
  }
}

代码:

csharp
IPluginCommandService commands = context.Services.Require<IPluginCommandService>();
IPluginSettingsStore settings = context.Services.Require<IPluginSettingsStore>();

Host 会在调用 InitializeAsync 之前验证 required 服务。Require<T>() 仍能让代码在测试或错误宿主实现中快速失败。

可选服务

json
"services": {
  "optional": {
    "pcl.notifications": ">=0.1 <1.0"
  }
}
csharp
if (context.Services.TryGet<IPluginNotificationService>(out var notifications))
    notifications.ShowInformation("操作完成。");
else
    context.Logger.Info("操作完成。");

设置存储

csharp
private static readonly PluginSettingKey<string> ModeKey = new("mode");

string mode = await settings.GetAsync(ModeKey, "safe", cancellationToken);
await settings.SetAsync(ModeKey, "fast", cancellationToken);

Key 是当前插件命名空间内的简单名称,不得包含目录分隔符或 ..。设置存储适合 JSON 可序列化的小型配置;大文件使用 context.Directories.Data

安全存储与外部链接

安全存储适合保存访问令牌、刷新令牌等不能落入明文设置文件的插件私密数据。Key 位于当前插件命名空间内,Host 必须使用系统级安全设施或返回 Unavailable,不能静默降级为明文持久化。

csharp
IPluginSecureStorage secureStorage = context.Services.Require<IPluginSecureStorage>();
PluginSecretKey tokenKey = new("auth.token");

await secureStorage.WriteAsync(tokenKey, tokenBytes, cancellationToken);
PluginSecretReadResult read = await secureStorage.ReadAsync(tokenKey, cancellationToken);
if (read.Status == PluginSecureStorageStatus.Success && read.Value is not null)
{
    // 使用 read.Value 后尽快清零敏感缓冲区。
}

外部链接应通过 IPluginUriLauncher 交给 Host 打开,避免插件直接启动进程或绕过 Host 的确认、审计与协议限制。契约只接受绝对 http / https URI。

csharp
if (context.Services.TryGet<IPluginUriLauncher>(out var launcher))
    await launcher.OpenAsync(new Uri("https://example.com/help"), cancellationToken);

命令

csharp
IPluginRegistration registration = commands.Register(new PluginCommandDescriptor(
    "dev.example.tools.refresh",
    "刷新插件数据",
    async token => await RefreshAsync(token),
    description: "重新读取插件缓存。",
    icon: "lucide/refresh-cw"));

context.Lifetime.Track(registration);

命令 ID 必须全局稳定,推荐以插件 ID 开头。AXAML 按钮也通过命令 ID 绑定行为。

后台任务

csharp
IPluginTaskService tasks = context.Services.Require<IPluginTaskService>();

context.Lifetime.Track(tasks.Run(
    "dev.example.tools.initial-scan",
    token => ScanAsync(token)));

context.Lifetime.Track(tasks.SchedulePeriodic(
    "dev.example.tools.refresh",
    TimeSpan.FromMinutes(10),
    token => RefreshAsync(token)));

宿主停用插件时会取消任务 Token 并等待结束。任务必须响应取消,不能吞掉 OperationCanceledException 后无限运行。

实例只读服务

csharp
IPluginInstanceReadService instances = context.Services.Require<IPluginInstanceReadService>();

foreach (PluginInstanceInfo instance in instances.ListInstances())
{
    context.Logger.Info($"{instance.Id} · {instance.Name}");
}

返回的是公开 DTO,不是 PCL N 内部对象。不要根据 DTO 反射或猜测私有宿主类型。

游戏会话、输出与启动事件

这些服务是只读观察面,适合日志面板、统计、自动诊断和联机状态提示。它们不会暴露 PCL N 内部对象。

csharp
if (context.Services.TryGet<IPluginGameSessionService>(out var sessions))
{
    foreach (PluginGameSessionSnapshot session in sessions.ListSessions())
        context.Logger.Info($"{session.InstanceId}: {session.State}");
}

if (context.Services.TryGet<IPluginGameOutputService>(out var output))
{
    context.Lifetime.Track(output.Subscribe(line =>
        context.Logger.Debug($"[{line.Stream}] {line.Text}")));
}

if (context.Services.TryGet<IPluginLaunchEventService>(out var launches))
{
    context.Lifetime.Track(launches.Subscribe(evt =>
        context.Logger.Info($"Launch event: {evt.Kind} / {evt.Session.InstanceId}")));
}

Manifest 中通常把它们声明为 optional;如果插件的核心功能就是会话监控,才放入 required。需要给出面向用户的权限原因,例如“读取游戏输出以生成崩溃报告”。

受控进程、文件与剪贴板

IPluginProcessService 通过 Host 策略启动子进程。插件不应直接调用 Process.Start;受控服务可以记录审计、限制工作目录、超时和输出大小。

csharp
IPluginProcessService processes = context.Services.Require<IPluginProcessService>();
PluginProcessResult result = await processes.RunAsync(new PluginProcessRequest
{
    FileName = "java",
    Arguments = ["-version"],
    CaptureOutput = true,
    Timeout = TimeSpan.FromSeconds(10)
}, cancellationToken);

context.Logger.Info(result.StandardError);

IPluginFileService 只允许访问插件隔离数据目录内的相对路径,适合保存插件生成的报告、索引和导出文件:

csharp
IPluginFileService files = context.Services.Require<IPluginFileService>();
await files.WriteAsync("reports/latest.txt", Encoding.UTF8.GetBytes("ok"), cancellationToken);
byte[]? content = await files.ReadAsync("reports/latest.txt", cancellationToken);

剪贴板读写必须通过 IPluginClipboardService,避免插件绕过 Host 的用户确认或隐私策略:

csharp
if (context.Services.TryGet<IPluginClipboardService>(out var clipboard))
    await clipboard.WriteTextAsync("诊断报告已生成", cancellationToken);

账户、下载源与启动修改

IPluginAccountReadServiceIPluginDownloadService 是只读目录服务,只返回公开 DTO:

csharp
if (context.Services.TryGet<IPluginAccountReadService>(out var accounts))
{
    foreach (PluginAccountProviderInfo provider in accounts.ListProviders())
        context.Logger.Info($"Account provider: {provider.DisplayName}");
}

if (context.Services.TryGet<IPluginDownloadService>(out var downloads))
{
    foreach (PluginDownloadSourceInfo source in downloads.ListSources())
        context.Logger.Info($"Download source: {source.DisplayName} ({source.Kind})");
}

启动修改通过 IPluginLaunchModificationService 注册纯函数,返回新的 PluginLaunchRequest。修改器必须可重复、无副作用,并把注册项交给 Lifetime:

csharp
IPluginLaunchModificationService launchModify =
    context.Services.Require<IPluginLaunchModificationService>();

context.Lifetime.Track(launchModify.Register(new PluginLaunchModification(
    "dev.example.add-demo-flag",
    request => request with
    {
        GameArguments = request.GameArguments.Concat(["--demo"]).ToArray()
    })));

导航、完整页面与窗口

稳定导航接口位于 PCLN.Plugin.UI,完整 Avalonia页面和窗口接口位于 PCLN.Plugin.UI.Avalonia

csharp
if (context.Services.TryGet<IPluginNavigationService>(out var navigation))
    await navigation.NavigateAsync("dev.example.toolbox.page", cancellationToken);

注册完整页面时使用 IAvaloniaPluginPageService.Register(AvaloniaPluginPageDescriptor);注册独立窗口时使用 IAvaloniaPluginWindowService.Register(AvaloniaPluginWindowDescriptor)。两个 Register 返回值都必须交给 context.Lifetime.Track。完整示例和权限声明见 UI扩展实战

Raw Avalonia使用 IAvaloniaUiAccessService,需要明确的 ui.raw-access 权限。它只应用于公开 UiTargetId,不能作为反射或遍历宿主私有 Visual Tree的替代通道。

本地化

csharp
if (context.Services.TryGet<IPluginLocalizationService>(out var localization))
{
    string title = localization.GetString("toolbox.title", "Toolbox");
    context.Logger.Debug($"{localization.CurrentCulture}: {title}");
}

GetString 的 fallback必须可直接展示,避免 Host缺少语言资源时出现空白 UI。GetStrings 返回的是当前 Host选择文化下的插件字符串快照,不要据此猜测宿主私有资源键。

插件间导出

导出方和导入方必须引用同一个稳定共享契约程序集,不能把插件私有实现类型作为公共 ABI:

csharp
var descriptor = new PluginExportDescriptor(
    "report-provider",
    new PluginApiVersion(0, 1));

context.Lifetime.Track(exports.Export<IReportProvider>(descriptor, provider));

导入方:

csharp
PluginImport<IReportProvider> import = exports.Import<IReportProvider>(
    new PluginExportId("dev.example.reports", "report-provider"),
    PluginApiVersionRange.Parse(">=0.1 <1.0"));

if (import.IsAvailable)
    await import.Require().GenerateAsync(cancellationToken);

pcl.exports 应作为可选服务,并且插件依赖关系仍须在 Manifest中声明;Export Registry不能替代依赖解析和版本约束。

Dispatcher

csharp
await context.Dispatcher.InvokeAsync(
    () => notifications.ShowInformation("已切回 UI 线程。"),
    cancellationToken);

只在需要操作宿主 UI 的地方切换线程;文件、网络和 CPU 工作不要占用 UI 线程。

查询服务版本

csharp
PluginApiVersionRange range = PluginApiVersionRange.Parse(">=0.1 <1.0");
bool available = context.Services.Supports(PluginServiceIds.Ui, range);

服务范围使用空格分隔约束,例如 >=0.1 <1.0。不要把 NuGet 包版本 0.1.0 写成服务版本;两者独立演进。