Files
LanMountainDesktop/docs/archive/PLUGIN_DEVELOPMENT.md
2026-06-08 03:54:33 +08:00

16 KiB

插件开发指南

为 LanMountainDesktop 开发自定义插件

目录

快速开始

安装插件模板

# 安装官方插件模板
dotnet new install LanMountainDesktop.PluginTemplate

# 查看可用模板
dotnet new list | findstr lmd

创建新插件

# 创建插件项目
dotnet new lmd-plugin -n MyAwesomePlugin

# 进入项目目录
cd MyAwesomePlugin

# 还原依赖
dotnet restore

# 构建插件
dotnet build

项目结构

MyAwesomePlugin/
├── MyAwesomePlugin.csproj          # 项目文件
├── Plugin.cs                        # 插件入口
├── Components/                      # 组件目录
│   └── MyComponent.cs
├── Views/                           # 视图目录
│   └── MyComponentView.axaml
├── ViewModels/                      # 视图模型
│   └── MyComponentViewModel.cs
├── Settings/                        # 设置页
│   └── MySettingsPage.axaml
└── plugin.json                      # 插件清单

插件架构

插件 SDK 版本

当前 SDK 版本: 5.0.0

<PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="5.0.0" />

插件清单 (plugin.json)

{
  "Id": "com.example.myawesomeplugin",
  "Name": "My Awesome Plugin",
  "Version": "1.0.0",
  "Author": "Your Name",
  "Description": "A plugin that does awesome things",
  "MinHostVersion": "1.0.0",
  "Dependencies": [],
  "Permissions": [
    "FileSystem.Read",
    "Network.Access"
  ]
}

核心接口

IPlugin - 插件入口接口:

public interface IPlugin
{
    string Id { get; }
    string Name { get; }
    string Version { get; }
    
    Task InitializeAsync(IPluginContext context);
    Task ShutdownAsync();
}

IPluginContext - 插件上下文:

public interface IPluginContext
{
    string PluginDirectory { get; }
    IServiceProvider Services { get; }
    ILogger Logger { get; }
    ISettingsService Settings { get; }
}

创建插件

1. 实现插件入口

using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts;

namespace MyAwesomePlugin;

public class Plugin : IPlugin
{
    public string Id => "com.example.myawesomeplugin";
    public string Name => "My Awesome Plugin";
    public string Version => "1.0.0";
    
    private IPluginContext? _context;
    
    public async Task InitializeAsync(IPluginContext context)
    {
        _context = context;
        
        // 注册组件
        var componentRegistry = context.Services.GetService<IComponentRegistry>();
        componentRegistry?.RegisterComponent<MyComponent>();
        
        // 注册设置页
        var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
        settingsRegistry?.RegisterPage<MySettingsPage>("我的插件设置");
        
        // 初始化逻辑
        context.Logger.LogInformation("Plugin initialized");
        
        await Task.CompletedTask;
    }
    
    public async Task ShutdownAsync()
    {
        // 清理资源
        _context?.Logger.LogInformation("Plugin shutting down");
        await Task.CompletedTask;
    }
}

2. 配置项目文件

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    
    <!-- 插件元数据 -->
    <PluginId>com.example.myawesomeplugin</PluginId>
    <PluginName>My Awesome Plugin</PluginName>
    <PluginVersion>1.0.0</PluginVersion>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="LanMountainDesktop.PluginSdk" Version="5.0.0" />
    <PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="5.0.0" />
    <PackageReference Include="Avalonia" Version="12.0.1" />
  </ItemGroup>

  <!-- 复制 plugin.json 到输出目录 -->
  <ItemGroup>
    <None Update="plugin.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>
</Project>

插件生命周期

生命周期阶段

1. 发现 (Discovery)
   ↓
2. 加载 (Load)
   ├─ 加载程序集
   ├─ 验证依赖
   └─ 创建插件实例
   ↓
3. 初始化 (Initialize)
   ├─ 调用 InitializeAsync()
   ├─ 注册组件
   ├─ 注册设置页
   └─ 初始化服务
   ↓
4. 运行 (Running)
   ├─ 组件渲染
   ├─ 事件处理
   └─ 服务调用
   ↓
5. 关闭 (Shutdown)
   ├─ 调用 ShutdownAsync()
   ├─ 清理资源
   └─ 卸载程序集

生命周期钩子

public class Plugin : IPlugin
{
    // 插件加载后立即调用
    public async Task InitializeAsync(IPluginContext context)
    {
        // 注册组件、服务、设置页
        // 初始化资源
    }
    
    // 插件卸载前调用
    public async Task ShutdownAsync()
    {
        // 保存状态
        // 释放资源
        // 取消订阅
    }
}

添加组件

1. 定义组件类

using LanMountainDesktop.PluginSdk.Components;
using LanMountainDesktop.Shared.Contracts;

namespace MyAwesomePlugin.Components;

[Component(
    Id = "com.example.myawesomeplugin.mycomponent",
    Name = "我的组件",
    Description = "一个很棒的组件",
    Category = "工具",
    Icon = "avares://MyAwesomePlugin/Assets/icon.png"
)]
public class MyComponent : ComponentBase
{
    public override string Id => "com.example.myawesomeplugin.mycomponent";
    public override string Name => "我的组件";
    
    // 组件设置
    private string _message = "Hello, World!";
    
    public string Message
    {
        get => _message;
        set => SetProperty(ref _message, value);
    }
    
    // 组件初始化
    public override Task InitializeAsync()
    {
        // 加载设置
        Message = Settings.GetValue("Message", "Hello, World!");
        return Task.CompletedTask;
    }
    
    // 组件更新 (定时调用)
    public override Task UpdateAsync()
    {
        // 更新组件数据
        return Task.CompletedTask;
    }
}

2. 创建组件视图

MyComponentView.axaml:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:vm="using:MyAwesomePlugin.ViewModels"
             x:Class="MyAwesomePlugin.Views.MyComponentView"
             x:DataType="vm:MyComponentViewModel">
  <Border Background="{DynamicResource CardBackgroundBrush}"
          CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
          Padding="16">
    <StackPanel Spacing="8">
      <TextBlock Text="{Binding Component.Name}"
                 FontSize="18"
                 FontWeight="Bold" />
      
      <TextBlock Text="{Binding Component.Message}"
                 TextWrapping="Wrap" />
      
      <Button Content="点击我"
              Command="{Binding ClickCommand}" />
    </StackPanel>
  </Border>
</UserControl>

MyComponentView.axaml.cs:

using Avalonia.Controls;

namespace MyAwesomePlugin.Views;

public partial class MyComponentView : UserControl
{
    public MyComponentView()
    {
        InitializeComponent();
    }
}

3. 创建视图模型

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;

namespace MyAwesomePlugin.ViewModels;

public partial class MyComponentViewModel : ObservableObject
{
    [ObservableProperty]
    private MyComponent _component;
    
    public MyComponentViewModel(MyComponent component)
    {
        _component = component;
    }
    
    [RelayCommand]
    private void Click()
    {
        Component.Message = "按钮被点击了!";
    }
}

4. 注册组件

public async Task InitializeAsync(IPluginContext context)
{
    var componentRegistry = context.Services.GetService<IComponentRegistry>();
    
    // 注册组件
    componentRegistry?.RegisterComponent<MyComponent>(
        componentFactory: () => new MyComponent(),
        viewFactory: (component) => new MyComponentView
        {
            DataContext = new MyComponentViewModel((MyComponent)component)
        }
    );
}

添加设置页

1. 创建设置页视图

MySettingsPage.axaml:

<UserControl xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="MyAwesomePlugin.Settings.MySettingsPage">
  <StackPanel Spacing="16" Margin="24">
    <TextBlock Text="我的插件设置"
               FontSize="24"
               FontWeight="Bold" />
    
    <StackPanel Spacing="8">
      <TextBlock Text="消息内容:" />
      <TextBox x:Name="MessageTextBox"
               Watermark="输入消息..." />
    </StackPanel>
    
    <Button Content="保存"
            Click="SaveButton_Click" />
  </StackPanel>
</UserControl>

MySettingsPage.axaml.cs:

using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.PluginSdk;

namespace MyAwesomePlugin.Settings;

public partial class MySettingsPage : UserControl
{
    private readonly ISettingsService _settings;
    
    public MySettingsPage(ISettingsService settings)
    {
        InitializeComponent();
        _settings = settings;
        
        // 加载设置
        MessageTextBox.Text = _settings.GetValue("Message", "Hello, World!");
    }
    
    private void SaveButton_Click(object? sender, RoutedEventArgs e)
    {
        // 保存设置
        _settings.SetValue("Message", MessageTextBox.Text);
        
        // 显示提示
        // TODO: 显示保存成功提示
    }
}

2. 注册设置页

public async Task InitializeAsync(IPluginContext context)
{
    var settingsRegistry = context.Services.GetService<ISettingsPageRegistry>();
    
    settingsRegistry?.RegisterPage(
        title: "我的插件",
        category: "插件",
        pageFactory: () => new MySettingsPage(context.Settings)
    );
}

使用服务

可用服务

ILogger - 日志服务:

context.Logger.LogInformation("信息日志");
context.Logger.LogWarning("警告日志");
context.Logger.LogError("错误日志");

ISettingsService - 设置服务:

// 读取设置
var value = context.Settings.GetValue("Key", "DefaultValue");

// 写入设置
context.Settings.SetValue("Key", "NewValue");

// 监听设置变化
context.Settings.SettingChanged += (sender, e) =>
{
    if (e.Key == "Key")
    {
        // 设置已变更
    }
};

INotificationService - 通知服务:

var notificationService = context.Services.GetService<INotificationService>();

notificationService?.ShowNotification(
    title: "通知标题",
    message: "通知内容",
    type: NotificationType.Information
);

IHttpClientFactory - HTTP 客户端:

var httpFactory = context.Services.GetService<IHttpClientFactory>();
var httpClient = httpFactory?.CreateClient();

var response = await httpClient.GetStringAsync("https://api.example.com/data");

打包和发布

1. 构建插件

dotnet build -c Release

2. 打包为 .laapp

# 使用官方打包脚本
pwsh ./scripts/Pack-PluginPackages.ps1 -PluginProject ./MyAwesomePlugin/MyAwesomePlugin.csproj

# 或手动打包
cd MyAwesomePlugin/bin/Release/net10.0
zip -r MyAwesomePlugin-1.0.0.laapp *

3. 测试插件

# 安装插件
LanMountainDesktop.Launcher.exe plugin install MyAwesomePlugin-1.0.0.laapp

# 启动应用测试
LanMountainDesktop.Launcher.exe launch

应用内插件市场不会调用 Launcher 安装插件。市场安装会把 .laapp 下载到当前用户的 pending 队列,并在下一次 Host 启动、插件发现前应用;上面的 Launcher 命令仅作为本地维护/兼容入口保留。

4. 发布插件

选项 1: GitHub Release

  1. 创建 GitHub 仓库
  2. 上传 .laapp 文件到 Release
  3. 用户可以手动下载安装

选项 2: 插件市场 (如果可用)

  1. 提交插件到官方市场
  2. 等待审核
  3. 用户可以在应用内浏览和安装

最佳实践

性能优化

  1. 避免阻塞 UI 线程:
// 错误
public override Task UpdateAsync()
{
    Thread.Sleep(1000); // 阻塞!
    return Task.CompletedTask;
}

// 正确
public override async Task UpdateAsync()
{
    await Task.Delay(1000);
}
  1. 使用异步 API:
// 使用 async/await
var data = await httpClient.GetStringAsync(url);
  1. 缓存数据:
private string? _cachedData;
private DateTime _cacheTime;

public async Task<string> GetDataAsync()
{
    if (_cachedData != null && DateTime.Now - _cacheTime < TimeSpan.FromMinutes(5))
        return _cachedData;
    
    _cachedData = await FetchDataAsync();
    _cacheTime = DateTime.Now;
    return _cachedData;
}

资源管理

  1. 实现 IDisposable:
public class MyComponent : ComponentBase, IDisposable
{
    private HttpClient? _httpClient;
    
    public void Dispose()
    {
        _httpClient?.Dispose();
    }
}
  1. 取消订阅事件:
public override Task ShutdownAsync()
{
    context.Settings.SettingChanged -= OnSettingChanged;
    return Task.CompletedTask;
}

错误处理

  1. 捕获异常:
public override async Task UpdateAsync()
{
    try
    {
        await FetchDataAsync();
    }
    catch (HttpRequestException ex)
    {
        Logger.LogError(ex, "Failed to fetch data");
        // 显示错误提示给用户
    }
}
  1. 验证输入:
public void SetUrl(string url)
{
    if (string.IsNullOrWhiteSpace(url))
        throw new ArgumentException("URL cannot be empty", nameof(url));
    
    if (!Uri.TryCreate(url, UriKind.Absolute, out _))
        throw new ArgumentException("Invalid URL format", nameof(url));
    
    _url = url;
}

本地化

  1. 使用资源文件:
// Resources/Strings.resx
// Name: ComponentName, Value: My Component

public override string Name => Resources.Strings.ComponentName;
  1. 支持多语言:
<!-- Resources/Strings.zh-CN.resx -->
<data name="ComponentName" xml:space="preserve">
  <value>我的组件</value>
</data>

安全性

  1. 验证用户输入:
// 防止路径遍历
var safePath = Path.GetFullPath(Path.Combine(pluginDirectory, userInput));
if (!safePath.StartsWith(pluginDirectory))
    throw new SecurityException("Invalid path");
  1. 使用 HTTPS:
// 强制使用 HTTPS
if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
    throw new SecurityException("Only HTTPS URLs are allowed");

示例插件

查看官方示例插件:

  • 天气组件 - 显示天气信息
  • 倒计时组件 - 倒计时功能
  • RSS 阅读器 - 订阅和显示 RSS 源

仓库: https://github.com/YourOrg/LanMountainDesktop.SamplePlugin

相关文档

Public IPC Extension

Plugins can now contribute external IPC capabilities through the Host public IPC entry point.

Recommended registration styles:

services.AddPluginPublicIpc<IMyPluginPublicService, MyPluginPublicService>(
    objectId: "default",
    notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);

Or use the advanced contributor model:

public sealed class MyPluginPublicIpcContributor : IPluginPublicIpcContributor
{
    public void ConfigurePublicIpc(IPluginPublicIpcBuilder builder)
    {
        builder.AddService<IMyPluginPublicService>(
            objectId: "default",
            notifyIds: ["lanmountain.plugin.my-plugin.status.changed"]);
    }
}

Additional notes:

  • Public IPC contracts must be interfaces marked with [IpcPublic].
  • External .NET clients can reference the plugin contract assembly and create strong-typed proxies through the Host public pipe.
  • Plugins can inject IExternalIpcNotificationPublisher to push live events outward through routed notifications.