Compare commits

...

11 Commits

Author SHA1 Message Date
lincube
03e32ee6cb feat.网速显示组件引入了一套更好的等距。 2026-04-15 15:42:11 +08:00
lincube
c2cc62b58b feat.淡入淡出动画。 2026-04-15 10:49:04 +08:00
lincube
9c529f2992 feat.SDK更新 2026-04-14 16:47:32 +08:00
lincube
1e9ead8bee feat.SDK加入了FA的引用。 2026-04-14 12:25:28 +08:00
lincube
5f7b3a1e7d removed.移除了不附带.NET 10的轻量版安装包。 2026-04-14 00:52:16 +08:00
lincube
b12dd68ba7 fix.开发者调试工具设置无法正常持久化的问题。修复了插件无法进行更新的问题。 2026-04-14 00:22:02 +08:00
lincube
1b22e9df4a feat.新增了插件开发文档 2026-04-13 19:54:37 +08:00
lincube
ce5acf5bd7 fix.修复了快捷方式组件无法正常透明的问题。 2026-04-13 16:26:23 +08:00
lincube
b933f3badf changed.调整了开发者选项 2026-04-13 13:14:58 +08:00
lincube
76d13ac024 feat.开发者调试工具 2026-04-13 08:02:47 +08:00
lincube
99a82d64e3 change.插件设置支持View 2026-04-13 01:23:11 +08:00
74 changed files with 7115 additions and 168 deletions

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -74,10 +74,6 @@ jobs:
- arch: x86
self_contained: true
suffix: ''
# 轻盈版(框架依赖,仅 x64
- arch: x64
self_contained: false
suffix: '-lite'
name: Build_Windows_${{ matrix.arch }}${{ matrix.suffix }}
steps:
@@ -586,11 +582,8 @@ jobs:
## Release ${{ needs.prepare.outputs.version }}
### Windows
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (完整版,包含 .NET 运行时)
- **LanMountainDesktop-Setup-{version}-x64-lite.exe** - 64-bit installer (轻量版,需安装 .NET 10 Runtime)
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (完整版,包含 .NET 运行时)
> **轻量版说明**:轻量版不包含 .NET 运行时,体积更小。首次运行前需安装 [.NET 10 Desktop Runtime](https://dotnet.microsoft.com/download/dotnet/10.0)。
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer (包含 .NET 运行时)
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer (包含 .NET 运行时)
Installation: Double-click the .exe file and follow the wizard.

View File

@@ -0,0 +1,24 @@
* [x] AppSettingsSnapshot 包含 EnableSlideTransition 字段且默认为 false
* [x] DesktopPage 拥有名为 DesktopPageSlideTransform 的 TranslateTransform
* [x] DesktopPage.Transitions 包含 Opacity 和 TranslateTransform.X 两个 DoubleTransition
* [x] 点击"回到 Windows"时播放退场动画Opacity 淡出 或 Opacity+滑动),动画完成后再最小化
* [x] 从最小化恢复时 DesktopPage 先以 Opacity=0 遮住 Normal 中间态FullScreen 生效后播放入场动画
* [x] 动画期间 DesktopPage.IsHitTestVisible 为 false动画完成后恢复
* [x] 动画期间 OnWindowPropertyChanged 不执行强制全屏纠正
* [x] 快速连续操作不会导致动画冲突
* [x] GeneralSettingsPage 在 Windows 平台显示"滑入滑出过渡效果"开关
* [x] GeneralSettingsPage 在非 Windows 平台不显示该开关
* [x] EnableSlideTransition 设置持久化到 AppSettingsSnapshot 且立即生效
* [x] dotnet build 无编译错误

View File

@@ -0,0 +1,138 @@
# 窗口过渡动画 Spec
## Why
当前全屏窗口在"回到 Windows"(最小化)和"恢复应用"时存在严重的视觉问题:
1. 恢复时经历 `Minimized → Normal → FullScreen` 两步跳变,用户会短暂看到无框小窗口
2. 状态切换无任何过渡动画,体验生硬
3. `OnWindowPropertyChanged` 使用 `Dispatcher.UIThread.Post` 延迟纠正,进一步延长了 Normal 中间态的可见时间
## What Changes
-`MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform``TranslateTransform.X` 过渡动画
- 修改 `MainWindow.axaml.cs``OnMinimizeClick`,实现退场动画(滑出/淡出 → 最小化)
- 修改 `App.axaml.cs``RestoreOrCreateMainWindow`,实现入场动画(全屏 → 滑入/淡入)
- 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`,在动画期间暂停强制全屏逻辑
-`AppSettingsSnapshot` 中添加 `EnableSlideTransition` 设置项(默认关闭)
-`GeneralSettingsPageViewModel` 中添加对应 ViewModel 属性
-`GeneralSettingsPage.axaml` 中添加开关 UI仅 Windows 平台显示)
- 添加平台检测逻辑Windows 且开启设置时使用滑入滑出,其他情况使用 Opacity 淡入淡出
## Impact
- Affected specs: 窗口生命周期过渡动画
- Affected code:
- `LanMountainDesktop/Views/MainWindow.axaml` - DesktopPage 添加 TranslateTransform
- `LanMountainDesktop/Views/MainWindow.axaml.cs` - OnMinimizeClick、OnWindowPropertyChanged、新增动画方法
- `LanMountainDesktop/App.axaml.cs` - RestoreOrCreateMainWindow、OnMainWindowPropertyChanged
- `LanMountainDesktop/Models/AppSettingsSnapshot.cs` - 新增 EnableSlideTransition 字段
- `LanMountainDesktop/ViewModels/SettingsViewModels.cs` - GeneralSettingsPageViewModel 新增属性
- `LanMountainDesktop/Views/SettingsPages/GeneralSettingsPage.axaml` - 新增开关 UI
---
## ADDED Requirements
### Requirement: 窗口退场过渡动画
系统 SHALL 在主窗口最小化/隐藏时播放退场过渡动画,消除窗口状态跳变的视觉闪烁。
#### Scenario: Opacity 淡出退场(所有平台默认)
- **WHEN** 用户点击"回到 Windows"或触发最小化
- **THEN** 系统将 `DesktopPage.Opacity` 设为 0触发淡出动画
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPage.Opacity = 1`(窗口已不可见)
#### Scenario: 滑出退场Windows + 开启设置)
- **WHEN** 用户点击"回到 Windows"且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统同时将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 动画完成后执行 `WindowState = Minimized`
- **AND THEN** 最小化完成后重置 `DesktopPageSlideTransform.X = 0``DesktopPage.Opacity = 1`
### Requirement: 窗口入场过渡动画
系统 SHALL 在主窗口恢复时播放入场过渡动画,消除 Normal 中间态的视觉闪烁。
#### Scenario: Opacity 淡入入场(所有平台默认)
- **WHEN** 主窗口从最小化/隐藏状态恢复
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0遮住 Normal 中间态)
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后将 `DesktopPage.Opacity` 设为 1触发淡入动画
#### Scenario: 滑入入场Windows + 开启设置)
- **WHEN** 主窗口从最小化/隐藏状态恢复且运行在 Windows 平台且已开启滑入滑出设置
- **THEN** 系统先将 `DesktopPage.Opacity` 设为 0 且 `DesktopPageSlideTransform.X` 设为屏幕宽度
- **AND THEN** 完成 `Minimized → Normal → FullScreen` 状态切换
- **AND THEN** 等 FullScreen 状态生效后同时将 `DesktopPage.Opacity` 设为 1 且 `DesktopPageSlideTransform.X` 设为 0触发滑入+淡入组合动画
### Requirement: 动画期间交互保护
系统 SHALL 在过渡动画播放期间防止用户交互和状态冲突。
#### Scenario: 动画期间禁止交互
- **WHEN** 退场或入场动画正在播放
- **THEN** `DesktopPage.IsHitTestVisible` 设为 `false`
- **AND THEN** 动画完成后恢复为 `true`
#### Scenario: 动画期间暂停强制全屏
- **WHEN** 入场动画正在播放且窗口临时处于 Normal 状态
- **THEN** `OnWindowPropertyChanged` 不执行强制全屏纠正
- **AND THEN** 入场动画完成后恢复正常强制全屏逻辑
#### Scenario: 防止快速连续操作
- **WHEN** 用户在动画播放期间再次触发最小化或恢复
- **THEN** 系统忽略重复操作,避免动画冲突
### Requirement: 滑入滑出设置项
系统 SHALL 在基本设置页面提供"滑入滑出过渡效果"开关,仅 Windows 平台可见。
#### Scenario: 设置项可见性
- **WHEN** 用户在 Windows 平台打开基本设置页面
- **THEN** 显示"滑入滑出过渡效果"开关
- **WHEN** 用户在非 Windows 平台打开基本设置页面
- **THEN** 不显示该开关
#### Scenario: 设置项默认值
- **WHEN** 用户首次安装应用
- **THEN** `EnableSlideTransition` 默认为 `false`
#### Scenario: 设置持久化
- **WHEN** 用户切换"滑入滑出过渡效果"开关
- **THEN** 设置值立即持久化到 `AppSettingsSnapshot.EnableSlideTransition`
- **AND THEN** 下次窗口过渡时立即生效,无需重启
### Requirement: DesktopPage TranslateTransform 声明
系统 SHALL 在 `DesktopPage` 上声明 `TranslateTransform` 和对应的过渡动画。
#### Scenario: XAML 声明
- **WHEN** MainWindow 初始化
- **THEN** `DesktopPage` 拥有名为 `DesktopPageSlideTransform``TranslateTransform`
- **AND THEN** `DesktopPage.Transitions` 包含 `Opacity``TranslateTransform.X` 两个过渡
- **AND THEN** 过渡时长使用 `FluttermotionToken.Duration.Page`320ms`FluttermotionToken.Duration.Intro`400ms
- **AND THEN** 缓动函数使用 `0.05,0.75,0.10,1.00`DecelerateBezier
## MODIFIED Requirements
### Requirement: OnMinimizeClick 行为
**当前**: 直接设置 `WindowState = WindowState.Minimized`,无动画
**修改后**: 先播放退场动画,动画完成后再设置 `WindowState = WindowState.Minimized`
### Requirement: RestoreOrCreateMainWindow 行为
**当前**: `Show() → Normal → FullScreen`,无过渡动画,用户可见 Normal 中间态
**修改后**: 先将 `DesktopPage` 设为不可见Opacity=0 + 可选滑出位),再执行状态切换,最后播放入场动画
### Requirement: OnWindowPropertyChanged 强制全屏逻辑
**当前**: 任何非 Minimized/FullScreen 状态立即纠正为 FullScreen
**修改后**: 动画期间允许临时 Normal 状态存在,动画完成后恢复强制全屏逻辑
## REMOVED Requirements
无移除的需求。

View File

@@ -0,0 +1,52 @@
# Tasks
- [x] Task 1: 在 `AppSettingsSnapshot` 中添加 `EnableSlideTransition` 字段
- [x] 添加 `public bool EnableSlideTransition { get; set; } = false;`
- [x]`Clone()` 方法中无需特殊处理bool 是值类型)
- [x] Task 2: 在 `MainWindow.axaml``DesktopPage` 上添加 `TranslateTransform` 和过渡动画
- [x] 添加 `<TranslateTransform />`
- [x]`Grid.Transitions` 中添加 `TranslateTransform.X``DoubleTransition`,使用 `FluttermotionToken.Duration.Intro` 和 DecelerateBezier 缓动
- [x] Task 3: 在 `MainWindow.axaml.cs` 中实现退场动画逻辑
- [x] 添加 `_isSlideAnimationActive` 标志位
- [x] 修改 `OnMinimizeClick`,调用新的 `SlideOutAndMinimizeAsync` 方法
- [x] 实现 `SlideOutAndMinimizeAsync`:读取设置 → 播放退场动画Opacity + 可选滑动)→ 等动画完成 → 最小化 → 重置位置
- [x] 动画期间设置 `DesktopPage.IsHitTestVisible = false`
- [x] Task 4: 在 `MainWindow.axaml.cs` 中实现入场动画逻辑
- [x] 添加 `public void PrepareEnterAnimation()` 方法:禁用过渡 → 设置初始位置Opacity=0, X=屏幕宽度或0→ 重新启用过渡
- [x] 添加 `public void PlayEnterAnimation()` 方法触发入场动画Opacity=1, X=0
- [x] 添加 `private bool IsSlideTransitionEnabled()` 方法,从设置中读取
- [x] Task 5: 修改 `App.axaml.cs``RestoreOrCreateMainWindow`
- [x] 在窗口状态切换前调用 `mainWindow.PrepareEnterAnimation()`
- [x] 在 FullScreen 状态生效后调用 `mainWindow.PlayEnterAnimation()`
- [x] Task 6: 修改 `MainWindow.axaml.cs``OnWindowPropertyChanged`
- [x]`_isSlideAnimationActive` 为 true 时跳过强制全屏逻辑
- [x] Task 7: 在 `GeneralSettingsPageViewModel` 中添加 `EnableSlideTransition` 属性
- [x] 添加 `[ObservableProperty] private bool _enableSlideTransition;`
- [x] 添加 `OnEnableSlideTransitionChanged` 持久化方法
- [x] 在构造函数和 `OnSettingsChanged` 中加载/同步该设置
- [x] 添加 `IsSlideTransitionAvailable` 平台检测属性
- [x] Task 8: 在 `GeneralSettingsPage.axaml` 中添加"滑入滑出过渡效果"开关
- [x] 在"运行时设置"分组中添加 `SettingsExpander`
- [x] 仅 Windows 平台显示(使用 `IsVisible` 绑定到 `IsSlideTransitionAvailable`
- [x] 图标使用 `ArrowRight`
- [x] Task 9: 构建验证
- [x] 执行 `dotnet build` 确保无编译错误
# Task Dependencies
- [Task 2] depends on [Task 1]
- [Task 3] depends on [Task 1, Task 2]
- [Task 4] depends on [Task 1, Task 2]
- [Task 5] depends on [Task 4]
- [Task 6] depends on [Task 3]
- [Task 7] depends on [Task 1]
- [Task 8] depends on [Task 7]
- [Task 9] depends on [Task 3, Task 4, Task 5, Task 6, Task 7, Task 8]

View File

@@ -1,5 +1,63 @@
# 更新日志 / Changelog
## [0.8.3.5](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.5) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
-**插件设置页面支持 View 展示**: 插件设置页面现在支持使用 View 进行展示
- 插件开发者可以通过 View 自定义设置页面的 UI 和交互
- 提供更灵活的设置页面展示方式,提升插件用户体验
- 兼容原有的设置方式,平滑过渡
- 🔧 **三指滑动与融合桌面功能开关位置调整**: 将三指滑动与融合桌面功能开关移动到了开发者设置界面
- 优化设置页面结构,将高级功能集中管理
- 普通用户界面更加简洁,开发者可在已有的开发者设置界面中访问相关设置
### 修复 (Fixed)
- 🐛 **快捷方式组件透明问题**: 修复了快捷方式组件无法正常透明的问题
- 问题原因: 组件背景透明属性设置异常或渲染层级问题
- 修复方案: 修正透明属性配置,确保快捷方式组件背景透明效果正常显示
- 🐛 **插件无法正常升级问题**: 修复了插件无法正常升级的问题
- 问题原因: 插件升级流程中存在异常,导致升级操作失败或中断
- 修复方案: 修复插件升级逻辑,确保插件可以正常检测、下载和安装更新
- 🐛 **开发者设置项持久化问题**: 修复了开发者设置项不能正确持久化的问题
- 问题原因: 开发者设置项的保存或读取逻辑存在缺陷,导致设置无法正确保存或恢复
- 修复方案: 修复设置持久化逻辑,确保开发者设置项能够正确保存并在重启后恢复
### 移除 (Removed)
- 🗑️ **不附带 .NET 10 依赖的轻量版安装包**: 移除了不附带 .NET 10 依赖的轻量版安装包
- 简化版本发布和维护流程,统一提供完整依赖的安装包
- 用户无需担心 .NET 运行时环境,安装后即可直接使用
***
## [0.8.3.4](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.4) - 2026-04-12
### 新增 (Added)
-
### 变更 (Changed)
- ♻️ **插件 SDK 更新**: 更新插件 SDK优化插件开发接口和兼容性
### 修复 (Fixed)
- 🐛 **轻量版 .NET 依赖问题(实验性)**: 实验性修复了轻量版在 .NET 环境下的依赖问题
- 问题原因: 轻量版与 .NET 的依赖兼容性存在冲突
- 修复方案: 调整依赖配置,提升兼容性(实验性修复,持续观察中)
### 移除 (Removed)
-
***
## [0.8.3.3](https://github.com/yourorg/LanMountainDesktop/releases/tag/v0.8.3.3) - 2026-04-12
### 新增 (Added)

View File

@@ -0,0 +1,4 @@
using Avalonia.Metadata;
[assembly: XmlnsPrefix("http://lanmountain.tech/schemas/xaml/sdk", "lmd")]
[assembly: XmlnsDefinition("http://lanmountain.tech/schemas/xaml/sdk", "LanMountainDesktop.PluginSdk")]

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>4.0.0</Version>
<Version>4.0.2</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
@@ -20,6 +20,9 @@
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" ExcludeAssets="runtime" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" ExcludeAssets="runtime" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />

View File

@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "4.0.0";
public const string ApiVersion = "4.0.2";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";

View File

@@ -28,6 +28,35 @@ public static class PluginServiceCollectionExtensions
return services;
}
/// <summary>
/// Registers a plugin settings section with a custom AXAML view.
/// The host application will display <typeparamref name="TView"/> directly
/// in the settings window, allowing the plugin to use any Fluent Avalonia controls
/// and custom layouts — just like built-in settings pages.
/// </summary>
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI using AXAML.</typeparam>
public static IServiceCollection AddPluginSettingsSection<TView>(
this IServiceCollection services,
string id,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
where TView : SettingsPageBase
{
ArgumentNullException.ThrowIfNull(services);
var builder = new PluginSettingsSectionBuilder(
id,
titleLocalizationKey,
descriptionLocalizationKey,
iconKey,
sortOrder);
builder.SetCustomView<TView>();
services.AddSingleton(builder.Build());
return services;
}
public static IServiceCollection AddPluginDesktopComponent<TControl>(
this IServiceCollection services,
PluginDesktopComponentOptions options)

View File

@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionBuilder
{
private readonly List<SettingsOptionDefinition> _options = [];
private Type? _customViewType;
internal PluginSettingsSectionBuilder(
string id,
@@ -30,8 +33,46 @@ public sealed class PluginSettingsSectionBuilder
public int SortOrder { get; }
public Type? CustomViewType => _customViewType;
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
/// <summary>
/// Sets a custom AXAML view for this settings section.
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
/// When a custom view is provided, the host application will use it directly
/// instead of generating a page from the declared options, allowing the plugin
/// to use any Fluent Avalonia controls and custom layouts.
/// </summary>
/// <typeparam name="TView">A <see cref="SettingsPageBase"/> subclass that defines the settings UI.</typeparam>
public PluginSettingsSectionBuilder SetCustomView<TView>() where TView : SettingsPageBase
{
_customViewType = typeof(TView);
return this;
}
/// <summary>
/// Sets a custom AXAML view for this settings section.
/// The view type must be a subclass of <see cref="SettingsPageBase"/>.
/// When a custom view is provided, the host application will use it directly
/// instead of generating a page from the declared options.
/// </summary>
/// <param name="viewType">A <see cref="SettingsPageBase"/> subclass type that defines the settings UI.</param>
public PluginSettingsSectionBuilder SetCustomView(Type viewType)
{
ArgumentNullException.ThrowIfNull(viewType);
if (!typeof(SettingsPageBase).IsAssignableFrom(viewType))
{
throw new ArgumentException(
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
nameof(viewType));
}
_customViewType = viewType;
return this;
}
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
{
ArgumentNullException.ThrowIfNull(option);
@@ -142,6 +183,7 @@ public sealed class PluginSettingsSectionBuilder
_options.ToArray(),
DescriptionLocalizationKey,
IconKey,
SortOrder);
SortOrder,
_customViewType);
}
}

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
@@ -10,7 +11,8 @@ public sealed class PluginSettingsSectionRegistration
IReadOnlyList<SettingsOptionDefinition> options,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
int sortOrder = 0,
Type? customViewType = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
@@ -24,6 +26,15 @@ public sealed class PluginSettingsSectionRegistration
IconKey = iconKey.Trim();
SortOrder = sortOrder;
Options = options ?? [];
if (customViewType is not null && !typeof(SettingsPageBase).IsAssignableFrom(customViewType))
{
throw new ArgumentException(
$"Custom view type must be a subclass of {nameof(SettingsPageBase)}.",
nameof(customViewType));
}
CustomViewType = customViewType;
}
public string Id { get; }
@@ -37,4 +48,11 @@ public sealed class PluginSettingsSectionRegistration
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
/// <summary>
/// When set, the host application will instantiate this <see cref="SettingsPageBase"/> subclass
/// instead of generating a page from <see cref="Options"/>.
/// This allows plugins to provide fully custom AXAML views with any Fluent Avalonia controls.
/// </summary>
public Type? CustomViewType { get; }
}

View File

@@ -14,7 +14,7 @@ Official SDK package for LanMountainDesktop plugins.
```xml
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
```

View File

@@ -9,5 +9,6 @@ public enum SettingsPageCategory
PluginCatalog = 35,
[Obsolete("Use PluginCatalog instead.")]
PluginMarket = 35,
About = 40
About = 40,
Dev = 50
}

View File

@@ -47,7 +47,7 @@
"pluginSdkVersion": {
"type": "parameter",
"datatype": "text",
"defaultValue": "4.0.0",
"defaultValue": "4.0.2",
"description": "LanMountainDesktop.PluginSdk package version.",
"replaces": "__PLUGIN_SDK_VERSION__"
}

View File

@@ -10,6 +10,38 @@ public sealed class Plugin : PluginBase
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
_ = context;
// ── Option 1: Declarative settings (simple key-value options) ──────────
// The host generates a settings page automatically from the declared options.
// Supported option types: Toggle, Text, Number, Select, Path, List.
//
// services.AddPluginSettingsSection(
// "my-plugin-settings",
// "My Plugin Settings",
// section => section
// .AddToggle("enable_feature", "Enable Feature", defaultValue: true)
// .AddNumber("refresh_interval", "Refresh Interval", defaultValue: 30, minimum: 5, maximum: 120),
// iconKey: "PuzzlePiece");
// ── Option 2: Custom AXAML view (full Fluent Avalonia controls) ────────
// Provide a SettingsPageBase subclass to use any Fluent Avalonia control
// (SettingsExpander, ColorPicker, Slider, etc.) — just like built-in pages.
//
// services.AddPluginSettingsSection<MyCustomSettingsPage>(
// "my-plugin-settings",
// "My Plugin Settings",
// iconKey: "PuzzlePiece");
//
// Or mix both: declare options AND set a custom view on the builder:
//
// services.AddPluginSettingsSection(
// "my-plugin-settings",
// "My Plugin Settings",
// section => section
// .SetCustomView<MyCustomSettingsPage>()
// .AddToggle("enable_feature", "Enable Feature"),
// iconKey: "PuzzlePiece");
_ = services;
}
}

View File

@@ -4,7 +4,7 @@
"description": "__PLUGIN_DESCRIPTION__",
"author": "__PLUGIN_AUTHOR__",
"version": "1.0.0",
"apiVersion": "4.0.0",
"apiVersion": "4.0.2",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": []
}

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>LanMountainDesktop.PluginUpgradeHelper</AssemblyName>
<RootNamespace>LanMountainDesktop.PluginUpgradeHelper</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,372 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginUpgradeHelper;
internal static class Program
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private const string LogFileName = "plugin-upgrade-helper.log";
private static int Main(string[] args)
{
var logPath = Path.Combine(Path.GetTempPath(), "LanMountainDesktop", LogFileName);
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
File.AppendAllText(logPath, $"\n[{DateTime.Now:O}] PluginUpgradeHelper started. Args: {string.Join(" ", args)}\n");
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
string.IsNullOrWhiteSpace(pluginsDirectory))
{
LogError(logPath, "Missing required argument: --plugins-dir");
return 1;
}
if (!parsedArgs.TryGetValue("parent-pid", out var parentPidStr) ||
!int.TryParse(parentPidStr, out var parentPid))
{
LogError(logPath, "Missing or invalid argument: --parent-pid");
return 1;
}
parsedArgs.TryGetValue("launch", out var launchCommand);
LogInfo(logPath, $"Waiting for parent process {parentPid} to exit...");
WaitForParentProcess(parentPid);
LogInfo(logPath, $"Processing pending upgrades in '{pluginsDirectory}'...");
var upgradeResults = ProcessPendingUpgrades(pluginsDirectory, logPath);
LogInfo(logPath, $"Upgrades completed. Success: {upgradeResults.SuccessCount}, Failed: {upgradeResults.FailureCount}");
if (!string.IsNullOrWhiteSpace(launchCommand))
{
LogInfo(logPath, $"Launching application: {launchCommand}");
LaunchApplication(launchCommand, parsedArgs);
}
return upgradeResults.FailureCount > 0 ? 2 : 0;
}
catch (Exception ex)
{
LogError(logPath, $"Unexpected error: {ex}");
return 1;
}
}
private static void WaitForParentProcess(int parentPid)
{
try
{
var parentProcess = Process.GetProcessById(parentPid);
parentProcess.WaitForExit(TimeSpan.FromSeconds(30));
}
catch (ArgumentException)
{
// Process already exited
}
catch (Exception)
{
// Ignore errors, continue anyway
}
Thread.Sleep(500);
}
private static UpgradeResults ProcessPendingUpgrades(string pluginsDirectory, string logPath)
{
var pendingUpgradesPath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
var successCount = 0;
var failureCount = 0;
if (!File.Exists(pendingUpgradesPath))
{
LogInfo(logPath, "No pending upgrades found.");
return new UpgradeResults(0, 0);
}
List<PendingUpgrade>? pendingUpgrades;
try
{
var json = File.ReadAllText(pendingUpgradesPath);
pendingUpgrades = JsonSerializer.Deserialize<List<PendingUpgrade>>(json);
}
catch (Exception ex)
{
LogError(logPath, $"Failed to read pending upgrades: {ex.Message}");
return new UpgradeResults(0, 0);
}
if (pendingUpgrades is null || pendingUpgrades.Count == 0)
{
LogInfo(logPath, "No pending upgrades to process.");
return new UpgradeResults(0, 0);
}
Directory.CreateDirectory(pluginsDirectory);
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var upgrade in pendingUpgrades)
{
if (!upgrade.IsValid())
{
LogWarn(logPath, $"Skipping invalid upgrade entry for plugin '{upgrade.PluginId}'.");
failureCount++;
continue;
}
try
{
LogInfo(logPath, $"Processing upgrade for plugin '{upgrade.PluginId}' to version '{upgrade.TargetVersion}'...");
var manifest = ReadManifestFromPackage(upgrade.SourcePackagePath);
var destinationPath = Path.Combine(pluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
RemoveExistingPluginPackages(pluginsDirectory, manifest.Id, destinationPath, pendingDeletionDir, logPath);
File.Copy(upgrade.SourcePackagePath, destinationPath, overwrite: true);
LogInfo(logPath, $"Successfully upgraded plugin '{upgrade.PluginId}' to '{upgrade.TargetVersion}'.");
successCount++;
}
catch (Exception ex)
{
LogError(logPath, $"Failed to upgrade plugin '{upgrade.PluginId}': {ex.Message}");
failureCount++;
}
}
try
{
File.Delete(pendingUpgradesPath);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending upgrades file: {ex.Message}");
}
CleanupPendingDeletions(pendingDeletionDir, logPath);
return new UpgradeResults(successCount, failureCount);
}
private static void RemoveExistingPluginPackages(
string pluginsDirectory,
string pluginId,
string destinationPath,
string pendingDeletionDir,
string logPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), ".runtime"));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*.laapp", SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
TryDeleteOrMoveFile(existingPackagePath, pendingDeletionDir, logPath);
}
catch
{
// Ignore unrelated or malformed packages
}
}
}
private static void TryDeleteOrMoveFile(string filePath, string pendingDeletionDir, string logPath)
{
try
{
File.Delete(filePath);
LogInfo(logPath, $"Deleted existing package: {filePath}");
}
catch (IOException)
{
var fileName = Path.GetFileName(filePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
try
{
File.Move(filePath, pendingPath);
LogInfo(logPath, $"Moved existing package to pending deletion: {filePath} -> {pendingPath}");
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to move existing package '{filePath}': {ex.Message}");
}
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir, string logPath)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch (Exception ex)
{
LogWarn(logPath, $"Failed to delete pending file '{pendingFile}': {ex.Message}");
}
}
try
{
if (Directory.GetFiles(pendingDeletionDir).Length == 0 &&
Directory.GetDirectories(pendingDeletionDir).Length == 0)
{
Directory.Delete(pendingDeletionDir);
}
}
catch
{
// Ignore
}
}
private static void LaunchApplication(string launchCommand, Dictionary<string, string> args)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = launchCommand,
UseShellExecute = true,
WorkingDirectory = args.TryGetValue("working-dir", out var workingDir)
? workingDir
: AppContext.BaseDirectory
};
if (args.TryGetValue("launch-args", out var launchArgs) && !string.IsNullOrWhiteSpace(launchArgs))
{
startInfo.Arguments = launchArgs;
}
Process.Start(startInfo);
}
catch (Exception ex)
{
Debug.WriteLine($"[PluginUpgradeHelper] Failed to launch application: {ex}");
}
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + ".laapp";
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static void LogInfo(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [INFO] {message}\n");
}
private static void LogWarn(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [WARN] {message}\n");
}
private static void LogError(string logPath, string message)
{
File.AppendAllText(logPath, $"[{DateTime.Now:O}] [ERROR] {message}\n");
}
private sealed record PendingUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}
private sealed record UpgradeResults(int SuccessCount, int FailureCount);
}

View File

@@ -135,6 +135,9 @@ internal static class Program
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
var pendingDeletionDir = Path.Combine(pluginsDirectory, ".pending-deletions");
Directory.CreateDirectory(pendingDeletionDir);
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
@@ -154,13 +157,58 @@ internal static class Program
continue;
}
DeleteFileWithRetry(existingPackagePath);
TryRemoveExistingPackage(existingPackagePath, pendingDeletionDir);
}
catch
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
CleanupPendingDeletions(pendingDeletionDir);
}
private static void TryRemoveExistingPackage(string existingPackagePath, string pendingDeletionDir)
{
try
{
DeleteFileWithRetry(existingPackagePath);
}
catch (IOException)
{
var fileName = Path.GetFileName(existingPackagePath);
var pendingPath = Path.Combine(pendingDeletionDir, $"{fileName}.{Guid.NewGuid():N}.pending");
try
{
File.Move(existingPackagePath, pendingPath);
}
catch (IOException moveEx)
{
throw new IOException(
$"Cannot delete or move existing plugin package '{existingPackagePath}'. " +
$"The file may be in use by another process. Error: {moveEx.Message}", moveEx);
}
}
}
private static void CleanupPendingDeletions(string pendingDeletionDir)
{
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
// Ignore cleanup failures for pending deletions.
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)

View File

@@ -8,6 +8,7 @@
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop.PluginUpgradeHelper/LanMountainDesktop.PluginUpgradeHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
</Solution>

View File

@@ -404,10 +404,7 @@ public partial class App : Application
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
if (_trayComponentLibraryMenuItem is not null)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
RefreshFusedDesktopMenuItemVisibility();
if (_trayRestartMenuItem is not null)
{
@@ -420,6 +417,30 @@ public partial class App : Application
}
}
private void RefreshFusedDesktopMenuItemVisibility()
{
if (_trayComponentLibraryMenuItem is null)
{
return;
}
// 仅在 Windows 上支持融合桌面功能
if (!OperatingSystem.IsWindows())
{
_trayComponentLibraryMenuItem.IsVisible = false;
return;
}
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
_trayComponentLibraryMenuItem.IsVisible = appSnapshot.EnableFusedDesktop;
if (_trayComponentLibraryMenuItem.IsVisible)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
}
private void DisposeTrayIcon()
{
if (_trayIcon is null)
@@ -545,13 +566,14 @@ public partial class App : Application
try
{
// 先隐藏透明覆盖层窗口
if (_transparentOverlayWindow is not null && _transparentOverlayWindow.IsVisible)
{
_transparentOverlayWindow.Hide();
}
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.PrepareEnterAnimation();
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
@@ -572,6 +594,12 @@ public partial class App : Application
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
Dispatcher.UIThread.Post(() =>
{
mainWindow.PlayEnterAnimation();
}, DispatcherPriority.Background);
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
@@ -687,6 +715,16 @@ public partial class App : Application
ApplyCurrentCultureFromSettings();
RefreshTrayIconContent();
}
// 检查融合桌面设置是否变更
var fusedDesktopChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.EnableFusedDesktop), StringComparer.OrdinalIgnoreCase);
if (fusedDesktopChanged)
{
RefreshFusedDesktopMenuItemVisibility();
}
}, DispatcherPriority.Background);
}

View File

@@ -152,8 +152,16 @@ public sealed class AppSettingsSnapshot
public bool EnableThreeFingerSwipe { get; set; } = false;
public bool EnableSlideTransition { get; set; } = false;
public bool EnableFusedDesktop { get; set; } = false;
public List<string> DisabledPluginIds { get; set; } = [];
public bool IsDevModeEnabled { get; set; }
public string? DevPluginPath { get; set; }
#region Study Settings
public bool StudyEnabled { get; set; } = true;

View File

@@ -6,6 +6,7 @@ using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
@@ -19,6 +20,7 @@ public sealed class Program
public static void Main(string[] args)
{
AppLogger.Initialize();
DevPluginOptions.Parse(args);
RegisterGlobalExceptionLogging();
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);

View File

@@ -58,6 +58,14 @@ internal sealed class FusedDesktopManagerService : IFusedDesktopManagerService
{
if (!OperatingSystem.IsWindows()) return;
// 检查融合桌面功能是否启用
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (!appSnapshot.EnableFusedDesktop)
{
AppLogger.Info("FusedDesktop", "Fused desktop is disabled. Skipping initialization.");
return;
}
EnsureRegistries();
ReloadWidgets();
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Diagnostics;
using System.IO;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
@@ -9,6 +10,8 @@ namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
private const string UpgradeHelperExecutableName = "LanMountainDesktop.PluginUpgradeHelper.exe";
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
App? app = null;
@@ -50,28 +53,14 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
App? app = null;
try
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
app = Application.Current as App;
if (HasPendingPluginUpgrades())
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
return TryRestartWithUpgradeHelper(request);
}
Process.Start(startInfo);
app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
return TryRestartDirectly(request);
}
catch (Exception ex)
{
@@ -80,4 +69,92 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
return false;
}
}
private static bool HasPendingPluginUpgrades()
{
try
{
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var pendingUpgradesPath = Path.Combine(pluginsDirectory, ".pending-plugin-upgrades.json");
return File.Exists(pendingUpgradesPath);
}
catch
{
return false;
}
}
private bool TryRestartWithUpgradeHelper(HostApplicationLifecycleRequest? request)
{
AppLogger.Info("HostLifecycle", "Detected pending plugin upgrades. Using upgrade helper for restart.");
var helperPath = ResolveUpgradeHelperPath();
if (!File.Exists(helperPath))
{
AppLogger.Warn("HostLifecycle", $"Upgrade helper not found at '{helperPath}'. Falling back to direct restart.");
return TryRestartDirectly(request);
}
var pluginsDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"Extensions",
"Plugins");
var startInfo = AppRestartService.CreateRestartStartInfo();
var launchCommand = startInfo?.FileName ?? Process.GetCurrentProcess().MainModule?.FileName ?? AppContext.BaseDirectory;
var launchArgs = startInfo?.Arguments ?? "";
var helperStartInfo = new ProcessStartInfo
{
FileName = helperPath,
Arguments = $"--plugins-dir \"{pluginsDirectory}\" --parent-pid {Environment.ProcessId} --launch \"{launchCommand}\" --launch-args \"{launchArgs}\" --working-dir \"{AppContext.BaseDirectory}\"",
UseShellExecute = true,
WorkingDirectory = AppContext.BaseDirectory
};
AppLogger.Info("HostLifecycle", $"Starting upgrade helper: {helperStartInfo.FileName} {helperStartInfo.Arguments}");
Process.Start(helperStartInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
return TryExit(request);
}
private bool TryRestartDirectly(HostApplicationLifecycleRequest? request)
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
Process.Start(startInfo);
var app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
}
private static string ResolveUpgradeHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginUpgradeHelper", UpgradeHelperExecutableName);
}
}

View File

@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
namespace LanMountainDesktop.Services;
public sealed class PendingPluginUpgradeService
{
private const string PendingUpgradesFileName = ".pending-plugin-upgrades.json";
private static readonly Lock Gate = new();
private readonly string _pendingUpgradesFilePath;
public PendingPluginUpgradeService(string pluginsDirectory)
{
_pendingUpgradesFilePath = Path.Combine(pluginsDirectory, PendingUpgradesFileName);
}
public IReadOnlyList<PendingPluginUpgrade> GetPendingUpgrades()
{
lock (Gate)
{
return ReadPendingUpgradesCore();
}
}
public void AddPendingUpgrade(string pluginId, string sourcePackagePath, string targetVersion)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
ArgumentException.ThrowIfNullOrWhiteSpace(sourcePackagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(targetVersion);
lock (Gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
upgrades.Add(new PendingPluginUpgrade(
pluginId,
Path.GetFullPath(sourcePackagePath),
targetVersion,
DateTimeOffset.UtcNow));
SavePendingUpgradesCore(upgrades);
AppLogger.Info(
"PendingPluginUpgrade",
$"Added pending upgrade. PluginId='{pluginId}'; TargetVersion='{targetVersion}'; SourcePath='{sourcePackagePath}'.");
}
}
public void RemovePendingUpgrade(string pluginId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
lock (Gate)
{
var upgrades = ReadPendingUpgradesCore().ToList();
var removed = upgrades.RemoveAll(u =>
string.Equals(u.PluginId, pluginId, StringComparison.OrdinalIgnoreCase));
if (removed > 0)
{
SavePendingUpgradesCore(upgrades);
AppLogger.Info(
"PendingPluginUpgrade",
$"Removed pending upgrade. PluginId='{pluginId}'.");
}
}
}
public void ClearPendingUpgrades()
{
lock (Gate)
{
if (File.Exists(_pendingUpgradesFilePath))
{
File.Delete(_pendingUpgradesFilePath);
AppLogger.Info("PendingPluginUpgrade", "Cleared all pending upgrades.");
}
}
}
public bool HasPendingUpgrades()
{
lock (Gate)
{
return ReadPendingUpgradesCore().Count > 0;
}
}
private List<PendingPluginUpgrade> ReadPendingUpgradesCore()
{
if (!File.Exists(_pendingUpgradesFilePath))
{
return [];
}
try
{
var json = File.ReadAllText(_pendingUpgradesFilePath);
var upgrades = JsonSerializer.Deserialize<List<PendingPluginUpgrade>>(json);
return upgrades?.Where(u => u.IsValid()).ToList() ?? [];
}
catch (Exception ex)
{
AppLogger.Warn(
"PendingPluginUpgrade",
$"Failed to read pending upgrades from '{_pendingUpgradesFilePath}'.",
ex);
return [];
}
}
private void SavePendingUpgradesCore(List<PendingPluginUpgrade> upgrades)
{
try
{
var directory = Path.GetDirectoryName(_pendingUpgradesFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(upgrades, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(_pendingUpgradesFilePath, json);
}
catch (Exception ex)
{
AppLogger.Error(
"PendingPluginUpgrade",
$"Failed to save pending upgrades to '{_pendingUpgradesFilePath}'.",
ex);
throw;
}
}
}
public sealed record PendingPluginUpgrade(
string PluginId,
string SourcePackagePath,
string TargetVersion,
DateTimeOffset CreatedAt)
{
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(PluginId) &&
!string.IsNullOrWhiteSpace(SourcePackagePath) &&
!string.IsNullOrWhiteSpace(TargetVersion) &&
File.Exists(SourcePackagePath);
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services.PluginMarket;
@@ -204,6 +205,10 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
string? pluginId,
bool isBuiltIn)
{
var isDevModeEnabled = _settingsFacade.Settings
.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App)
.IsDevModeEnabled;
foreach (var pageType in assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(SettingsPageBase).IsAssignableFrom(type)))
{
@@ -214,6 +219,12 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
}
var category = isBuiltIn ? pageInfo.Category : SettingsPageCategory.Plugins;
if (category == SettingsPageCategory.Dev && !isDevModeEnabled)
{
continue;
}
var sortOrder = isBuiltIn ? pageInfo.SortOrder : 100 + pageInfo.SortOrder;
var title = ResolveLocalizedText(pageInfo.TitleLocalizationKey, pageInfo.Name);
var description = ResolveLocalizedText(pageInfo.DescriptionLocalizationKey, null);
@@ -256,6 +267,29 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
? null
: localizer.GetString(section.DescriptionLocalizationKey, section.DescriptionLocalizationKey);
Func<ISettingsPageHostContext, Control> factory;
if (section.CustomViewType is not null)
{
var customViewType = section.CustomViewType;
var pluginServices = loadedPlugin.Services;
factory = hostContext => CreatePage(pluginServices, customViewType, hostContext);
}
else
{
factory = hostContext =>
{
var page = new GeneratedPluginSettingsPage(
new PluginGeneratedSettingsPageViewModel(
_settingsFacade.Settings,
loadedPlugin.Manifest.Id,
section,
localizer));
page.InitializeHostContext(hostContext);
return page;
};
}
_pages.Add(new SettingsPageDescriptor(
pageId,
title,
@@ -270,17 +304,7 @@ internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
hidePageTitle: false,
useFullWidth: false,
groupId: null,
hostContext =>
{
var page = new GeneratedPluginSettingsPage(
new PluginGeneratedSettingsPageViewModel(
_settingsFacade.Settings,
loadedPlugin.Manifest.Id,
section,
localizer));
page.InitializeHostContext(hostContext);
return page;
}));
factory));
}
}

View File

@@ -72,6 +72,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
_localizationService = new();
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
}
private string L(string key)
@@ -279,6 +280,7 @@ internal sealed class SettingsWindowService : ISettingsWindowService
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
var devModeChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.IsDevModeEnabled), StringComparer.OrdinalIgnoreCase);
var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
@@ -291,14 +293,13 @@ internal sealed class SettingsWindowService : ISettingsWindowService
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
if (languageChanged)
if (languageChanged || devModeChanged)
{
var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);
_window.ReloadPages(devModeChanged ? "dev" : _viewModel.CurrentPageId);
_window.RefreshShellText();
}
@@ -311,6 +312,31 @@ internal sealed class SettingsWindowService : ISettingsWindowService
}, DispatcherPriority.Background);
}
private void OnAppSettingsSaved(string instanceId)
{
Dispatcher.UIThread.Post(() =>
{
if (_window is null || _viewModel is null)
{
return;
}
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var devPageVisible = _pageRegistry.GetPages().Any(p => p.PageId == "dev");
if (snapshot.IsDevModeEnabled && !devPageVisible)
{
_pageRegistry.Rebuild();
_window.ReloadPages("dev");
}
else if (!snapshot.IsDevModeEnabled && devPageVisible)
{
_pageRegistry.Rebuild();
_window.ReloadPages(null);
}
}, DispatcherPriority.Background);
}
private void ApplyTheme(SettingsWindow window)
{
var appearanceSnapshot = _appearanceThemeService.GetCurrent();

View File

@@ -174,9 +174,6 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
private bool _isInitializing;
private bool _disposed;
[ObservableProperty]
private bool _enableThreeFingerSwipe;
public GeneralSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
@@ -204,7 +201,7 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
SelectedRenderMode = RenderModes.FirstOrDefault(option =>
string.Equals(option.Value, normalizedRenderMode, StringComparison.OrdinalIgnoreCase))
?? RenderModes[0];
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
EnableSlideTransition = appSnapshot.EnableSlideTransition;
_isInitializing = false;
RefreshPreview();
@@ -236,33 +233,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
{
return;
}
// 如果是其他设置变更,重新加载我们的设置
_isInitializing = true;
try
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
EnableThreeFingerSwipe = appSnapshot.EnableThreeFingerSwipe;
}
finally
{
_isInitializing = false;
}
}
partial void OnEnableThreeFingerSwipeChanged(bool value)
{
if (_isInitializing)
if (changedKeys.Contains(nameof(AppSettingsSnapshot.EnableSlideTransition)))
{
return;
EnableSlideTransition = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App).EnableSlideTransition;
}
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.EnableThreeFingerSwipe = value;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.EnableThreeFingerSwipe)]);
}
public event Action? RestartRequested;
@@ -282,6 +257,11 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
[ObservableProperty]
private SelectionOption _selectedRenderMode = new(AppRenderingModeHelper.Default, "Default");
[ObservableProperty]
private bool _enableSlideTransition;
public bool IsSlideTransitionAvailable => System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows);
[ObservableProperty]
private string _pageTitle = string.Empty;
@@ -381,6 +361,24 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase, IDispo
}
}
partial void OnEnableSlideTransitionChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableSlideTransition), value);
}
private void SaveField<T>(string key, T value)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var property = typeof(AppSettingsSnapshot).GetProperty(key);
if (property is not null && property.CanWrite)
{
property.SetValue(snapshot, value);
}
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
}
private IReadOnlyList<SelectionOption> CreateLanguageOptions()
{
return
@@ -3088,3 +3086,104 @@ public sealed class PluginGeneratedSettingsPageViewModel
public string? Description { get; }
}
public sealed partial class DevSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private bool _isInitializing;
public DevSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
_isInitializing = true;
LoadSettings();
_isInitializing = false;
// 监听设置变更,防止被意外重置
_settingsFacade.Settings.Changed += OnSettingsChanged;
}
[ObservableProperty]
private bool _isDevModeEnabled;
[ObservableProperty]
private string _devPluginPath = string.Empty;
[ObservableProperty]
private bool _enableThreeFingerSwipe;
[ObservableProperty]
private bool _enableFusedDesktop;
partial void OnIsDevModeEnabledChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.IsDevModeEnabled), value);
}
partial void OnDevPluginPathChanged(string value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.DevPluginPath), value);
}
partial void OnEnableThreeFingerSwipeChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), value);
}
partial void OnEnableFusedDesktopChanged(bool value)
{
if (_isInitializing) return;
SaveField(nameof(AppSettingsSnapshot.EnableFusedDesktop), value);
}
private void LoadSettings()
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
IsDevModeEnabled = snapshot.IsDevModeEnabled;
DevPluginPath = snapshot.DevPluginPath ?? string.Empty;
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
if (e.Scope != SettingsScope.App)
{
return;
}
var changedKeys = e.ChangedKeys?.ToArray();
if (changedKeys is null || changedKeys.Length == 0)
{
return;
}
// 如果是其他设置变更,重新加载我们的设置
_isInitializing = true;
try
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
EnableThreeFingerSwipe = snapshot.EnableThreeFingerSwipe;
EnableFusedDesktop = snapshot.EnableFusedDesktop;
}
finally
{
_isInitializing = false;
}
}
private void SaveField<T>(string key, T value)
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var property = typeof(AppSettingsSnapshot).GetProperty(key);
if (property is not null && property.CanWrite)
{
property.SetValue(snapshot, value);
}
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, snapshot, changedKeys: [key]);
}
}

View File

@@ -30,7 +30,10 @@
FontWeight="SemiBold"
Margin="2,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontFamily="Consolas, Courier New, monospace"
MinWidth="42"
TextAlignment="Right"/>
</StackPanel>
<!-- 分隔符 -->
@@ -55,7 +58,10 @@
FontWeight="SemiBold"
Margin="2,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"/>
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontFamily="Consolas, Courier New, monospace"
MinWidth="42"
TextAlignment="Right"/>
</StackPanel>
<!-- 网络类型图标 -->

View File

@@ -317,31 +317,32 @@ public partial class NetworkSpeedWidget : UserControl, IDesktopComponentWidget
private static string FormatSpeed(long bytesPerSecond)
{
// 根据数值大小决定显示格式始终保持3个字符宽度
// 例如: 1.23, 12.3, 123
// 根据数值大小选择合适的单位确保显示始终在1-99.9范围内
// 当数值达到100时自动切换到更大的单位
return bytesPerSecond switch
{
>= 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
>= 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
>= 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
>= 100L * 1024 * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0 * 1024.0), "G"),
>= 100L * 1024 * 1024 => FormatWithThreeDigits(bytesPerSecond / (1024.0 * 1024.0), "M"),
>= 100L * 1024 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"),
>= 100 => FormatWithThreeDigits(bytesPerSecond / 1024.0, "K"), // 100B+ 显示为 0.1K
_ => FormatWithThreeDigits(bytesPerSecond, "B")
};
}
/// <summary>
/// 格式化数字始终保持3个有效数字的显示宽度
/// 格式化数字始终保持3位数字+小数点,确保宽度恒定
/// 数值范围始终在1-99.9之间
/// </summary>
private static string FormatWithThreeDigits(double value, string unit)
{
// 根据数值大小决定小数位数,确保总宽度一致
// 始终保持3位数字小数点始终存在
// 数值范围: 0.0 - 99.9
// < 10: 显示两位小数 (如 1.23)
// 10-99: 显示一位小数 (如 12.3)
// >= 100: 显示整数 (如 123)
// >= 10: 显示一位小数 (如 12.3, 99.9)
string formatted = value switch
{
< 10 => $"{value:F2}",
< 100 => $"{value:F1}",
_ => $"{value:F0}"
< 10 => $"{value:F2}", // 1.23
_ => $"{value:F1}" // 12.3, 99.9
};
return formatted + unit;

View File

@@ -8,9 +8,6 @@
x:Class="LanMountainDesktop.Views.Components.ShortcutWidget">
<Border x:Name="RootBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
ClipToBounds="True">
<Grid RowDefinitions="*,Auto"

View File

@@ -25,6 +25,7 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
private bool _showBackground = true;
private double _currentCellSize = 48;
private bool _isDisposed;
private bool _chromeApplied;
private const double TapMovementThreshold = 10;
private const long TapTimeThresholdMs = 500;
@@ -40,9 +41,32 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
{
InitializeComponent();
DoubleTapped += OnDoubleTapped;
Loaded += OnLoaded;
UpdateDisplay();
}
private void OnLoaded(object? sender, RoutedEventArgs e)
{
// ApplyChrome() may have been called before the control was attached to the visual tree,
// causing FindResource() to fail. Re-apply now that resources are available.
if (!_chromeApplied)
{
ApplyChrome();
}
// Subscribe to theme changes so the background follows theme updates.
var themeService = HostAppearanceThemeProvider.GetOrCreate();
themeService.Changed += OnAppearanceThemeChanged;
}
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
if (_isDisposed || _showBackground)
{
ApplyChrome();
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
@@ -258,13 +282,25 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
_chromeApplied = true;
return;
}
// 恢复默认的实心背景样式
RootBorder.Background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush ?? Brushes.Transparent;
RootBorder.BorderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush ?? Brushes.Transparent;
// FindResource requires the control to be attached to the visual tree.
// If it returns null, _chromeApplied stays false so OnLoaded will retry.
var background = this.FindResource("AdaptiveSurfaceRaisedBrush") as IBrush;
var borderBrush = this.FindResource("AdaptiveButtonBorderBrush") as IBrush;
if (background is null || borderBrush is null)
{
_chromeApplied = false;
return;
}
RootBorder.Background = background;
RootBorder.BorderBrush = borderBrush;
RootBorder.BorderThickness = new Thickness(1);
_chromeApplied = true;
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
@@ -391,6 +427,10 @@ public partial class ShortcutWidget : UserControl, IDesktopComponentWidget, ICom
}
_isDisposed = true;
var themeService = HostAppearanceThemeProvider.GetOrCreate();
themeService.Changed -= OnAppearanceThemeChanged;
_gestureStates.Clear();
}
}

View File

@@ -77,7 +77,9 @@ public partial class MainWindow
string.Equals(key, nameof(AppSettingsSnapshot.UpdateChannel), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateMode), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadSource), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase)))
string.Equals(key, nameof(AppSettingsSnapshot.UpdateDownloadThreads), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableThreeFingerSwipe), StringComparison.OrdinalIgnoreCase) ||
string.Equals(key, nameof(AppSettingsSnapshot.EnableSlideTransition), StringComparison.OrdinalIgnoreCase)))
{
return;
}

View File

@@ -98,9 +98,13 @@
<Grid x:Name="DesktopPage"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Grid.RenderTransform>
<TranslateTransform />
</Grid.RenderTransform>
<Grid.Transitions>
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Page}" Easing="0.05,0.75,0.10,1.00" />
<DoubleTransition Property="TranslateTransform.X" Duration="{StaticResource FluttermotionToken.Duration.Intro}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Grid.Transitions>

View File

@@ -132,6 +132,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private double _currentDesktopCellGap;
private double _currentDesktopEdgeInset;
private string _gridSpacingPreset = "Relaxed";
private bool _isSlideAnimationActive;
private TranslateTransform? _desktopPageSlideTransform;
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private bool _statusBarClockTransparentBackground;
@@ -833,7 +835,103 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
private void OnMinimizeClick(object? sender, RoutedEventArgs e)
{
if (_isSlideAnimationActive)
{
return;
}
SlideOutAndMinimizeAsync();
}
private TranslateTransform GetDesktopPageSlideTransform()
{
if (_desktopPageSlideTransform is not null)
{
return _desktopPageSlideTransform;
}
_desktopPageSlideTransform = DesktopPage.RenderTransform as TranslateTransform;
if (_desktopPageSlideTransform is null)
{
_desktopPageSlideTransform = new TranslateTransform();
DesktopPage.RenderTransform = _desktopPageSlideTransform;
}
return _desktopPageSlideTransform;
}
private async void SlideOutAndMinimizeAsync()
{
_isSlideAnimationActive = true;
DesktopPage.IsHitTestVisible = false;
var useSlide = IsSlideTransitionEnabled();
var slideTransform = GetDesktopPageSlideTransform();
if (useSlide)
{
slideTransform.X = Bounds.Width;
}
DesktopPage.Opacity = 0;
await Task.Delay(useSlide
? FluttermotionToken.Intro
: FluttermotionToken.Page);
if (!_isSlideAnimationActive)
{
return;
}
WindowState = WindowState.Minimized;
slideTransform.X = 0;
DesktopPage.Opacity = 1;
DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false;
}
public void PrepareEnterAnimation()
{
_isSlideAnimationActive = false;
var useSlide = IsSlideTransitionEnabled();
var slideTransform = GetDesktopPageSlideTransform();
var savedTransitions = DesktopPage.Transitions;
DesktopPage.Transitions = null;
DesktopPage.Opacity = 0;
if (useSlide)
{
slideTransform.X = Bounds.Width > 0 ? Bounds.Width : 1920;
}
DesktopPage.Transitions = savedTransitions;
DesktopPage.IsHitTestVisible = false;
_isSlideAnimationActive = true;
}
public void PlayEnterAnimation()
{
var slideTransform = GetDesktopPageSlideTransform();
DesktopPage.Opacity = 1;
slideTransform.X = 0;
DesktopPage.IsHitTestVisible = true;
_isSlideAnimationActive = false;
}
private bool IsSlideTransitionEnabled()
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return false;
}
var snapshot = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
return snapshot.EnableSlideTransition;
}
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
@@ -848,8 +946,18 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
return;
}
if (_isSlideAnimationActive)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_isSlideAnimationActive)
{
return;
}
if (WindowState is not (WindowState.Minimized or WindowState.FullScreen))
{
WindowState = WindowState.FullScreen;

View File

@@ -36,7 +36,8 @@
<StackPanel Classes="about-page-container">
<Border x:Name="AboutHeroCard"
Classes="about-hero-card"
Height="240">
Height="240"
PointerPressed="OnAboutHeroCardPointerPressed">
<Image Source="/Assets/about_banner.png"
Stretch="Uniform"
HorizontalAlignment="Center"

View File

@@ -1,9 +1,16 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Views.SettingsPages;
@@ -19,6 +26,10 @@ namespace LanMountainDesktop.Views.SettingsPages;
public partial class AboutSettingsPage : SettingsPageBase
{
private const double HeroAspectRatio = 9d / 16d;
private const int DevModeActivationClicks = 5;
private int _heroCardClickCount;
private DateTime _lastHeroCardClickTime = DateTime.MinValue;
public AboutSettingsPage()
: this(new AboutSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
@@ -60,4 +71,81 @@ public partial class AboutSettingsPage : SettingsPageBase
AboutHeroCard.Height = targetHeight;
}
private void OnAboutHeroCardPointerPressed(object? sender, PointerPressedEventArgs e)
{
var now = DateTime.UtcNow;
var elapsed = now - _lastHeroCardClickTime;
if (elapsed.TotalSeconds > 3)
{
_heroCardClickCount = 1;
}
else
{
_heroCardClickCount++;
}
_lastHeroCardClickTime = now;
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
if (snapshot.IsDevModeEnabled)
{
if (_heroCardClickCount >= 3)
{
_heroCardClickCount = 0;
Debug.WriteLine("[AboutSettingsPage] Developer mode is already enabled.");
}
return;
}
var remaining = DevModeActivationClicks - _heroCardClickCount;
if (remaining <= 0)
{
_heroCardClickCount = 0;
PromptEnableDevMode(settingsFacade);
}
else if (remaining <= 2)
{
Debug.WriteLine($"[AboutSettingsPage] 再点击 {remaining} 次即可启用开发者模式。");
}
}
private async void PromptEnableDevMode(ISettingsFacadeService settingsFacade)
{
var dialog = new ContentDialog
{
Title = "启用开发者模式",
Content = "开发者模式提供了插件调试、热重载等高级功能,仅供开发和调试用途。\n\n" +
"请注意:开发者不对以非开发用途使用此功能造成的任何后果负责,也不接受以非开发用途使用时产生的 Bug 反馈。\n\n" +
"确定要启用开发者模式吗?",
PrimaryButtonText = "启用",
CloseButtonText = "取消",
DefaultButton = ContentDialogButton.Close
};
var result = await dialog.ShowAsync();
if (result != ContentDialogResult.Primary)
{
return;
}
var snapshot = settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
snapshot.IsDevModeEnabled = true;
settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(AppSettingsSnapshot.IsDevModeEnabled)]);
AppLogger.Info("DevMode", "Developer mode enabled via About page activation.");
if (this.FindAncestorOfType<SettingsWindow>() is { } settingsWindow)
{
settingsWindow.RebuildAndNavigateToDevPage();
}
}
}

View File

@@ -0,0 +1,109 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:LanMountainDesktop.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
x:Class="LanMountainDesktop.Views.SettingsPages.DevSettingsPage"
x:DataType="vm:DevSettingsPageViewModel">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Classes="settings-page-container settings-page-animated">
<ui:InfoBar IsOpen="True"
IsClosable="False"
Severity="Warning"
Title="开发者模式"
Message="开发者模式仅供开发和调试用途。开发者不对以非开发用途使用此功能造成的任何后果负责。"
Margin="0,0,0,16" />
<ui:SettingsExpander Header="启用开发者模式"
Description="启用后可使用插件调试、开发者插件路径等高级功能">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="DeveloperBoard" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding IsDevModeEnabled}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="启用三指滑动"
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面(实验性功能)">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Gesture" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="启用融合桌面"
Description="允许将组件放置在 Windows 系统桌面上(实验性功能,重启后生效)">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Apps" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableFusedDesktop}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />
<ui:SettingsExpander Header="开发者插件路径"
Description="指定开发中的插件目录路径,无需打包即可直接加载。多个路径用分号分隔。">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="FolderLink" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<TextBox Text="{Binding DevPluginPath}"
Watermark="C:\path\to\plugin\bin\Debug\net10.0"
Width="360"
MinWidth="200" />
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<Separator Classes="settings-separator" />
<ui:SettingsExpander Header="命令行参数"
Description="也可以通过命令行参数或环境变量指定开发者插件路径">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="WindowConsole" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpanderItem>
<StackPanel Margin="0,8,0,0" Spacing="8">
<TextBlock Text="命令行参数:" FontWeight="SemiBold" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--dev-plugin &lt;path&gt; 或 -dp &lt;path&gt;"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="环境变量:" FontWeight="SemiBold" Margin="0,8,0,0" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="LMD_DEV_PLUGIN=&lt;path&gt;"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="其他参数:" FontWeight="SemiBold" Margin="0,8,0,0" />
<Border Background="{DynamicResource ControlFillColorDefaultBrush}"
CornerRadius="8"
Padding="12,8">
<StackPanel Spacing="4">
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--dev-mode / -dev 启用开发者模式" />
<TextBlock FontFamily="Cascadia Code, Consolas, monospace"
FontSize="12"
Text="--hot-reload / -hr 启用热重载(预留)" />
</StackPanel>
</Border>
</StackPanel>
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,30 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.ViewModels;
namespace LanMountainDesktop.Views.SettingsPages;
[SettingsPageInfo(
"dev",
"开发者",
SettingsPageCategory.Dev,
IconKey = "DeveloperBoard",
SortOrder = 0,
TitleLocalizationKey = "settings.dev.title",
DescriptionLocalizationKey = "settings.dev.description")]
public partial class DevSettingsPage : SettingsPageBase
{
public DevSettingsPage()
: this(new DevSettingsPageViewModel(HostSettingsFacadeProvider.GetOrCreate()))
{
}
public DevSettingsPage(DevSettingsPageViewModel viewModel)
{
ViewModel = viewModel;
DataContext = ViewModel;
InitializeComponent();
}
public DevSettingsPageViewModel ViewModel { get; }
}

View File

@@ -14,13 +14,6 @@
Text="{Binding BasicHeader}"
Margin="0,0,0,4" />
<ui:SettingsExpander Header="启用三指滑动"
Description="使用三根手指或鼠标右键拖动自由滑动页面,在第一页向右滑动可回到 Windows 桌面">
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableThreeFingerSwipe}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
<ui:SettingsExpander Header="{Binding LanguageHeader}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Settings" />
@@ -113,6 +106,17 @@
</ui:SettingsExpanderItem>
</ui:SettingsExpander>
<ui:SettingsExpander Header="滑入滑出过渡效果"
Description="启用后,进入和退出桌面时使用滑入滑出动画(仅 Windows"
IsVisible="{Binding IsSlideTransitionAvailable}">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="ArrowRight" />
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding EnableSlideTransition}" />
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</StackPanel>
</ScrollViewer>
</UserControl>

View File

@@ -97,6 +97,12 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
NavigateTo(pageId ?? ViewModel.Pages.FirstOrDefault()?.PageId);
}
public void RebuildAndNavigateToDevPage()
{
_pageRegistry.Rebuild();
ReloadPages("dev");
}
public void OpenDrawer(Control content, string? title = null)
{
if (DrawerContentHost is null)
@@ -734,8 +740,11 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
"Hourglass" => Symbol.Hourglass,
"Alert" => Symbol.Alert, // 铃铛图标
"Bell" => Symbol.Alert, // Bell也映射到Alert图标
"Alert" => Symbol.Alert,
"Bell" => Symbol.Alert,
"DeveloperBoard" => Symbol.DeveloperBoard,
"FolderLink" => Symbol.FolderLink,
"WindowConsole" => Symbol.WindowConsole,
_ => Symbol.Settings
};
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Plugins;
public sealed class DevPluginOptions
{
private static readonly string[] DevPluginPathArgs = ["--dev-plugin", "-dp"];
private static readonly string[] DevModeArgs = ["--dev-mode", "-dev"];
private static readonly string[] HotReloadArgs = ["--hot-reload", "-hr"];
private static readonly string EnvDevPluginPath = "LMD_DEV_PLUGIN";
private static readonly string EnvDevMode = "LMD_DEV_MODE";
public static DevPluginOptions Current { get; } = new();
public bool IsDevMode { get; private set; }
public string? DevPluginPath { get; private set; }
public bool EnableHotReload { get; private set; }
public IReadOnlyList<string> DevPluginPaths { get; private set; } = Array.Empty<string>();
private DevPluginOptions() { }
public static DevPluginOptions Parse(string[] args)
{
var options = Current;
options.IsDevMode = TryGetFlag(args, DevModeArgs) ||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "1", StringComparison.Ordinal) ||
string.Equals(Environment.GetEnvironmentVariable(EnvDevMode), "true", StringComparison.OrdinalIgnoreCase);
options.DevPluginPath = TryGetValue(args, DevPluginPathArgs) ??
Environment.GetEnvironmentVariable(EnvDevPluginPath)?.Trim();
options.EnableHotReload = TryGetFlag(args, HotReloadArgs);
if (!options.IsDevMode && !string.IsNullOrWhiteSpace(options.DevPluginPath))
{
options.IsDevMode = true;
}
options.DevPluginPaths = ResolveDevPluginPaths(options.DevPluginPath);
if (options.IsDevMode)
{
AppLogger.Info(
"DevPlugin",
$"Developer mode enabled. DevPluginPath='{options.DevPluginPath}'; EnableHotReload={options.EnableHotReload}; ResolvedPaths={options.DevPluginPaths.Count}.");
}
return options;
}
internal void ApplySettingsFromSnapshot(bool isDevMode, string? devPluginPath)
{
if (isDevMode && !IsDevMode)
{
IsDevMode = true;
}
if (!string.IsNullOrWhiteSpace(devPluginPath) && string.IsNullOrWhiteSpace(DevPluginPath))
{
DevPluginPath = devPluginPath;
}
var allPaths = new List<string>(DevPluginPaths);
if (!string.IsNullOrWhiteSpace(devPluginPath))
{
foreach (var path in ResolveDevPluginPaths(devPluginPath))
{
if (!allPaths.Contains(path, StringComparer.OrdinalIgnoreCase))
{
allPaths.Add(path);
}
}
}
DevPluginPaths = allPaths;
}
private static IReadOnlyList<string> ResolveDevPluginPaths(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return Array.Empty<string>();
}
var paths = rawPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var resolved = new List<string>();
foreach (var path in paths)
{
try
{
var fullPath = Path.GetFullPath(path);
if (Directory.Exists(fullPath) || File.Exists(fullPath))
{
resolved.Add(fullPath);
}
else
{
AppLogger.Warn("DevPlugin", $"Developer plugin path '{path}' does not exist. It will be skipped.");
}
}
catch (Exception ex)
{
AppLogger.Warn("DevPlugin", $"Failed to resolve developer plugin path '{path}': {ex.Message}");
}
}
return resolved;
}
private static bool TryGetFlag(string[] args, string[] names)
{
return args.Any(arg => names.Any(name => string.Equals(arg, name, StringComparison.OrdinalIgnoreCase)));
}
private static string? TryGetValue(string[] args, string[] names)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (names.Any(name => string.Equals(args[i], name, StringComparison.OrdinalIgnoreCase)))
{
return args[i + 1]?.Trim();
}
}
return null;
}
}

View File

@@ -5,7 +5,8 @@ namespace LanMountainDesktop.Services;
public enum PluginCatalogSourceKind
{
Package = 0,
Manifest = 1
Manifest = 1,
DevPlugin = 2
}
public sealed record PluginCatalogEntry(
@@ -16,4 +17,5 @@ public sealed record PluginCatalogEntry(
bool IsLoaded,
string? ErrorMessage,
int SettingsPageCount,
int WidgetCount);
int WidgetCount,
bool IsDevPlugin = false);

View File

@@ -146,7 +146,7 @@ public sealed class PluginLoader
try
{
Directory.CreateDirectory(dataDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory);
ValidatePluginRuntimeAssets(manifest, assemblyPath, pluginDirectory, _options.IsDevMode);
AppLogger.Info(
"PluginLoader",
$"LoadCore starting. PluginId='{manifest.Id}'; AssemblyPath='{assemblyPath}'; PluginDirectory='{pluginDirectory}'; DataDirectory='{dataDirectory}'.");
@@ -721,13 +721,23 @@ public sealed class PluginLoader
private static void ValidatePluginRuntimeAssets(
PluginManifest manifest,
string assemblyPath,
string pluginDirectory)
string pluginDirectory,
bool isDevMode)
{
var depsFilePath = Path.ChangeExtension(assemblyPath, ".deps.json");
if (!File.Exists(depsFilePath))
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
if (isDevMode)
{
AppLogger.Warn(
"PluginLoader",
$"Plugin '{manifest.Id}' is missing '{Path.GetFileName(depsFilePath)}'. In developer mode this is allowed, but dependency resolution may fail at runtime.");
}
else
{
throw new InvalidOperationException(
$"Plugin '{manifest.Id}' targets API {PluginSdkInfo.ApiVersion} and must include '{Path.GetFileName(depsFilePath)}' next to its main assembly.");
}
}
var runtimesDirectory = Path.Combine(pluginDirectory, "runtimes");

View File

@@ -19,6 +19,8 @@ public sealed class PluginLoaderOptions
public string PackagedDataDirectoryName { get; init; } = PluginSdkInfo.PackagedDataDirectoryName;
public bool IsDevMode { get; init; }
public ISet<string> SharedAssemblyNames { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
typeof(IPlugin).Assembly.GetName().Name!

View File

@@ -784,12 +784,28 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
}
RefreshInstalledSnapshot();
SetStatus(
F(
"market.status.install_success_format",
"Plugin '{0}' has been staged. Restart the app to apply it.",
result.Manifest.Name),
SuccessBrush);
if (result.RestartRequired)
{
SetStatus(
F(
"market.status.upgrade_staged_format",
"Plugin '{0}' v{1} has been downloaded. Restart to complete the upgrade.",
result.Manifest.Name,
result.Manifest.Version),
WarningBrush);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
}
else
{
SetStatus(
F(
"market.status.install_success_format",
"Plugin '{0}' has been installed successfully.",
result.Manifest.Name),
SuccessBrush);
}
RebuildSurface();
}
catch (OperationCanceledException) when (_lifetimeCts.IsCancellationRequested)
@@ -1015,14 +1031,22 @@ internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
var leftParsed = AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion);
var rightParsed = AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion);
if (!leftParsed && !rightParsed)
{
leftVersion = new Version(0, 0, 0);
return 0;
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
if (!leftParsed)
{
rightVersion = new Version(0, 0, 0);
return -1;
}
if (!rightParsed)
{
return 1;
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
@@ -20,7 +21,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly AirAppMarketReleaseResolverService _releaseResolverService;
private readonly PendingPluginUpgradeService _pendingUpgradeService;
private readonly string _downloadsDirectory;
private readonly Version? _hostVersion;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
{
@@ -33,6 +36,8 @@ internal sealed class AirAppMarketInstallService : IDisposable
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_downloadService = new ResumableDownloadService(_httpClient);
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
_pendingUpgradeService = new PendingPluginUpgradeService(runtime.PluginsDirectory);
_hostVersion = typeof(App).Assembly.GetName().Version;
}
public async Task<AirAppMarketInstallResult> InstallAsync(
@@ -41,18 +46,6 @@ internal sealed class AirAppMarketInstallService : IDisposable
{
ArgumentNullException.ThrowIfNull(plugin);
if (OperatingSystem.IsWindows())
{
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
return new AirAppMarketInstallResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
}
}
Directory.CreateDirectory(_downloadsDirectory);
var sources = plugin.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
@@ -67,6 +60,39 @@ internal sealed class AirAppMarketInstallService : IDisposable
"PluginMarket",
$"Starting install. PluginId='{plugin.Id}'; Version='{plugin.Version}'; Sources='{string.Join(", ", sources.Select(source => source.SourceKind.ToString()))}'.");
var compatibilityError = ValidateCompatibility(plugin);
if (!string.IsNullOrWhiteSpace(compatibilityError))
{
AppLogger.Warn("PluginMarket", $"Compatibility check failed. PluginId='{plugin.Id}'; Error='{compatibilityError}'.");
return new AirAppMarketInstallResult(false, null, compatibilityError);
}
var isUpgrade = IsPluginInstalled(plugin.Id);
if (isUpgrade)
{
return await InstallUpgradeAsync(plugin, sources, cancellationToken).ConfigureAwait(false);
}
return await InstallNewAsync(plugin, sources, cancellationToken).ConfigureAwait(false);
}
private async Task<AirAppMarketInstallResult> InstallNewAsync(
AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources,
CancellationToken cancellationToken)
{
if (OperatingSystem.IsWindows())
{
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
return new AirAppMarketInstallResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
}
}
var sourceErrors = new List<string>();
foreach (var source in sources)
{
@@ -93,6 +119,88 @@ internal sealed class AirAppMarketInstallService : IDisposable
return new AirAppMarketInstallResult(false, null, combinedMessage);
}
private async Task<AirAppMarketInstallResult> InstallUpgradeAsync(
AirAppMarketPluginEntry plugin,
IReadOnlyList<AirAppMarketPluginPackageSourceEntry> sources,
CancellationToken cancellationToken)
{
AppLogger.Info("PluginMarket", $"Detected upgrade scenario. Downloading package for deferred upgrade. PluginId='{plugin.Id}'.");
foreach (var source in sources)
{
var downloadResult = await DownloadPackageAsync(plugin, source, cancellationToken).ConfigureAwait(false);
if (downloadResult.Success && !string.IsNullOrWhiteSpace(downloadResult.PackagePath))
{
_pendingUpgradeService.AddPendingUpgrade(plugin.Id, downloadResult.PackagePath, plugin.Version);
AppLogger.Info(
"PluginMarket",
$"Upgrade staged for next restart. PluginId='{plugin.Id}'; Version='{plugin.Version}'; PackagePath='{downloadResult.PackagePath}'.");
var manifest = ReadManifestFromPackage(downloadResult.PackagePath);
return new AirAppMarketInstallResult(true, manifest, null, RestartRequired: true);
}
}
return new AirAppMarketInstallResult(
false,
null,
$"Failed to download upgrade package for plugin '{plugin.Id}' from all available sources.");
}
private bool IsPluginInstalled(string pluginId)
{
return _runtime.Catalog.Any(entry =>
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
}
private string? ValidateCompatibility(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null)
{
return null;
}
if (!string.IsNullOrWhiteSpace(plugin.MinHostVersion))
{
if (!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return $"Plugin '{plugin.Id}' declares invalid minimum host version '{plugin.MinHostVersion}'.";
}
if (_hostVersion < minHostVersion)
{
return $"Plugin '{plugin.Id}' requires host version {plugin.MinHostVersion} or newer. Current host version is {_hostVersion}.";
}
}
if (!string.IsNullOrWhiteSpace(plugin.ApiVersion))
{
if (!AirAppMarketIndexDocument.TryParseVersion(plugin.ApiVersion, out var pluginApiVersion) ||
pluginApiVersion is null)
{
return $"Plugin '{plugin.Id}' declares invalid API version '{plugin.ApiVersion}'.";
}
var hostApiVersion = PluginSdkInfo.ApiVersion;
if (hostApiVersion is not null)
{
if (!AirAppMarketIndexDocument.TryParseVersion(hostApiVersion, out var hostApiVersionParsed) ||
hostApiVersionParsed is null)
{
AppLogger.Warn("PluginMarket", $"Host API version '{hostApiVersion}' could not be parsed. Skipping API version check.");
}
else if (pluginApiVersion.Major != hostApiVersionParsed.Major)
{
return $"Plugin '{plugin.Id}' uses incompatible API version {plugin.ApiVersion}. Host API version is {hostApiVersion}. Major version must match.";
}
}
}
return null;
}
private async Task<AirAppMarketInstallAttemptResult> TryInstallFromSourceAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
@@ -275,6 +383,71 @@ internal sealed class AirAppMarketInstallService : IDisposable
}
}
private async Task<DownloadPackageResult> DownloadPackageAsync(
AirAppMarketPluginEntry plugin,
AirAppMarketPluginPackageSourceEntry source,
CancellationToken cancellationToken)
{
var packagePath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}-{SanitizeFileName(source.SourceKind.ToString())}-{Guid.NewGuid():N}.laapp");
try
{
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, source, cancellationToken).ConfigureAwait(false);
AppLogger.Info(
"PluginMarket",
$"Downloading upgrade package for '{plugin.Id}' from '{resolvedDownloadUrl}'.");
var acquireResult = await AcquirePackageAsync(plugin, source, resolvedDownloadUrl, packagePath, cancellationToken).ConfigureAwait(false);
if (!acquireResult.Success)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, acquireResult.ErrorMessage);
}
var verificationResult = await VerifyPackageAsync(plugin, packagePath, cancellationToken).ConfigureAwait(false);
if (!verificationResult.Success)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, verificationResult.ErrorMessage);
}
return new DownloadPackageResult(true, packagePath, null);
}
catch (OperationCanceledException)
{
TryDeleteFile(packagePath);
throw;
}
catch (Exception ex)
{
TryDeleteFile(packagePath);
return new DownloadPackageResult(false, null, ex.Message);
}
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = System.IO.Compression.ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, "plugin.json", StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' does not contain 'plugin.json'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException($"Plugin package '{packagePath}' contains multiple 'plugin.json' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
public void Dispose()
{
_httpClient.Dispose();
@@ -299,4 +472,9 @@ internal sealed class AirAppMarketInstallService : IDisposable
private sealed record AirAppMarketVerificationResult(
bool Success,
string? ErrorMessage);
private sealed record DownloadPackageResult(
bool Success,
string? PackagePath,
string? ErrorMessage);
}

View File

@@ -305,7 +305,8 @@ internal sealed record AirAppMarketLoadResult(
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginManifest? Manifest,
string? ErrorMessage);
string? ErrorMessage,
bool RestartRequired = false);
internal sealed class AirAppMarketIndexDocument
{

View File

@@ -85,6 +85,7 @@ public sealed class PluginRuntimeService : IDisposable
Directory.CreateDirectory(PluginsDirectory);
ApplyPendingPluginDeletions();
UnloadInstalledPlugins();
MergeDevSettingsFromSnapshot();
AppLogger.Info("PluginRuntime", $"Loading installed plugins from '{PluginsDirectory}'.");
var disabledPluginIds = GetDisabledPluginIds();
@@ -108,19 +109,30 @@ public sealed class PluginRuntimeService : IDisposable
var selectedPluginIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var candidate in candidates)
{
var isDevPlugin = candidate.SourceKind == PluginCatalogSourceKind.DevPlugin;
if (!selectedPluginIds.Add(candidate.Manifest.Id))
{
var duplicateFailure = PluginLoadResult.Failure(
candidate.SourcePath,
candidate.Manifest,
new InvalidOperationException(
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
_loadResults.Add(duplicateFailure);
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
continue;
if (isDevPlugin)
{
AppLogger.Info(
"DevPlugin",
$"Developer plugin '{candidate.Manifest.Id}' overrides an already-registered plugin from '{candidate.SourcePath}'.");
}
else
{
var duplicateFailure = PluginLoadResult.Failure(
candidate.SourcePath,
candidate.Manifest,
new InvalidOperationException(
$"Duplicate plugin id '{candidate.Manifest.Id}' was found. Source '{candidate.SourcePath}' was ignored because a higher-priority source was already selected."));
_loadResults.Add(duplicateFailure);
LogPluginFailure("CatalogSelection", duplicateFailure, treatAsError: false);
continue;
}
}
var isEnabled = !disabledPluginIds.Contains(candidate.Manifest.Id);
var isEnabled = isDevPlugin || !disabledPluginIds.Contains(candidate.Manifest.Id);
if (!isEnabled)
{
_catalog.Add(new PluginCatalogEntry(
@@ -172,6 +184,10 @@ public sealed class PluginRuntimeService : IDisposable
PluginsDirectory,
services: _hostServices,
hostProperties),
PluginCatalogSourceKind.DevPlugin => _loader.LoadFromManifest(
candidate.SourcePath,
services: _hostServices,
hostProperties),
_ => _loader.LoadFromManifest(
candidate.SourcePath,
services: _hostServices,
@@ -192,7 +208,8 @@ public sealed class PluginRuntimeService : IDisposable
true,
null,
loadResult.LoadedPlugin.SettingsSections.Count,
loadResult.LoadedPlugin.DesktopComponents.Count));
loadResult.LoadedPlugin.DesktopComponents.Count,
IsDevPlugin: isDevPlugin));
AppLogger.Info(
"PluginRuntime",
$"Plugin loaded. PluginId='{loadResult.LoadedPlugin.Manifest.Id}'; SourcePath='{loadResult.SourcePath}'; ManifestVersion='{loadResult.LoadedPlugin.Manifest.Version ?? "<unknown>"}'; ApiVersion='{loadResult.LoadedPlugin.Manifest.ApiVersion ?? "<unknown>"}'; SourceKind='{candidate.SourceKind}'; SettingsSections={loadResult.LoadedPlugin.SettingsSections.Count}; Widgets={loadResult.LoadedPlugin.DesktopComponents.Count}; Editors={loadResult.LoadedPlugin.DesktopComponentEditors.Count}.");
@@ -208,7 +225,8 @@ public sealed class PluginRuntimeService : IDisposable
false,
loadResult.Error?.Message,
0,
0));
0,
IsDevPlugin: isDevPlugin));
LogPluginFailure("Load", loadResult, treatAsError: true);
Debug.WriteLine($"[PluginRuntime] Failed to load plugin from '{loadResult.SourcePath}': {loadResult.Error}");
}
@@ -229,6 +247,14 @@ public sealed class PluginRuntimeService : IDisposable
return false;
}
var catalogEntry = _catalog.FirstOrDefault(entry =>
string.Equals(entry.Manifest.Id, pluginId, StringComparison.OrdinalIgnoreCase));
if (catalogEntry.IsDevPlugin && !isEnabled)
{
AppLogger.Warn("DevPlugin", $"Cannot disable developer plugin '{pluginId}'. Developer plugins are always enabled in dev mode.");
return false;
}
var snapshot = LoadAppSettingsSnapshot();
var disabledPluginIds = snapshot.DisabledPluginIds is { Count: > 0 }
? new HashSet<string>(snapshot.DisabledPluginIds, StringComparer.OrdinalIgnoreCase)
@@ -459,12 +485,74 @@ public sealed class PluginRuntimeService : IDisposable
}
}
DiscoverDevPluginCandidates(candidates, failures);
return candidates
.OrderBy(candidate => candidate.SourceKind)
.OrderByDescending(candidate => candidate.SourceKind)
.ThenBy(candidate => candidate.SourcePath, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private void DiscoverDevPluginCandidates(List<PluginCandidate> candidates, List<PluginLoadResult> failures)
{
var devOptions = DevPluginOptions.Current;
if (!devOptions.IsDevMode || devOptions.DevPluginPaths.Count == 0)
{
return;
}
AppLogger.Info("DevPlugin", $"Scanning developer plugin paths. Count={devOptions.DevPluginPaths.Count}.");
foreach (var devPath in devOptions.DevPluginPaths)
{
if (File.Exists(devPath) && string.Equals(Path.GetExtension(devPath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
{
try
{
var manifest = ReadManifestFromPackage(devPath);
candidates.Add(new PluginCandidate(devPath, manifest, PluginCatalogSourceKind.DevPlugin));
AppLogger.Info("DevPlugin", $"Found developer plugin package. PluginId='{manifest.Id}'; Path='{devPath}'.");
}
catch (Exception ex)
{
var failure = PluginLoadResult.Failure(devPath, null, ex);
failures.Add(failure);
AppLogger.Warn("DevPlugin", $"Failed to read developer plugin package '{devPath}'.", ex);
}
continue;
}
if (Directory.Exists(devPath))
{
var manifestPath = Path.Combine(devPath, PluginSdkInfo.ManifestFileName);
if (File.Exists(manifestPath))
{
try
{
var manifest = PluginManifest.Load(manifestPath);
candidates.Add(new PluginCandidate(manifestPath, manifest, PluginCatalogSourceKind.DevPlugin));
AppLogger.Info("DevPlugin", $"Found developer plugin manifest. PluginId='{manifest.Id}'; Path='{manifestPath}'.");
}
catch (Exception ex)
{
var failure = PluginLoadResult.Failure(manifestPath, null, ex);
failures.Add(failure);
AppLogger.Warn("DevPlugin", $"Failed to load developer plugin manifest '{manifestPath}'.", ex);
}
}
else
{
AppLogger.Warn("DevPlugin", $"Developer plugin directory '{devPath}' does not contain '{PluginSdkInfo.ManifestFileName}'. Skipping.");
}
continue;
}
AppLogger.Warn("DevPlugin", $"Developer plugin path '{devPath}' is neither a file nor a directory. Skipping.");
}
}
private IEnumerable<string> EnumerateCandidatePaths(string searchPattern)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(PluginsDirectory), ".runtime"));
@@ -582,7 +670,8 @@ public sealed class PluginRuntimeService : IDisposable
private static PluginLoaderOptions CreateOptions()
{
var options = new PluginLoaderOptions();
var devOptions = DevPluginOptions.Current;
var options = new PluginLoaderOptions { IsDevMode = devOptions.IsDevMode };
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(IServiceCollection).Assembly);
AddSharedAssembly(options, typeof(HostBuilderContext).Assembly);
@@ -596,6 +685,10 @@ public sealed class PluginRuntimeService : IDisposable
}
if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentAvaloniaUI", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentIcons.Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "FluentIcons.Avalonia.Fluent", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "Material.Icons.Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase))
{
AddSharedAssembly(options, assembly);
@@ -614,6 +707,31 @@ public sealed class PluginRuntimeService : IDisposable
}
}
private void MergeDevSettingsFromSnapshot()
{
var devOptions = DevPluginOptions.Current;
try
{
var snapshot = LoadAppSettingsSnapshot();
if (snapshot.IsDevModeEnabled && !devOptions.IsDevMode)
{
devOptions.ApplySettingsFromSnapshot(isDevMode: true, devPluginPath: snapshot.DevPluginPath);
AppLogger.Info("DevPlugin", $"Developer mode enabled via settings. DevPluginPath='{snapshot.DevPluginPath}'.");
}
else if (!string.IsNullOrWhiteSpace(snapshot.DevPluginPath) && string.IsNullOrWhiteSpace(devOptions.DevPluginPath))
{
devOptions.ApplySettingsFromSnapshot(isDevMode: devOptions.IsDevMode, devPluginPath: snapshot.DevPluginPath);
AppLogger.Info("DevPlugin", $"Developer plugin path merged from settings. DevPluginPath='{snapshot.DevPluginPath}'.");
}
}
catch (Exception ex)
{
AppLogger.Warn("DevPlugin", "Failed to merge developer settings from snapshot.", ex);
}
}
private void CollectContributions(LoadedPlugin loadedPlugin)
{
_exportRegistry.ReplaceExports(loadedPlugin.Manifest.Id, loadedPlugin.ExportedServices);
@@ -659,11 +777,6 @@ public sealed class PluginRuntimeService : IDisposable
private void ApplyPendingPluginDeletions()
{
var pendingPaths = ReadPendingPluginDeletions();
if (pendingPaths.Count == 0)
{
return;
}
var remainingPaths = new List<string>();
foreach (var path in pendingPaths)
{
@@ -674,6 +787,41 @@ public sealed class PluginRuntimeService : IDisposable
}
SavePendingPluginDeletions(remainingPaths);
CleanupPendingDeletionDirectory();
}
private void CleanupPendingDeletionDirectory()
{
var pendingDeletionDir = Path.Combine(PluginsDirectory, ".pending-deletions");
if (!Directory.Exists(pendingDeletionDir))
{
return;
}
foreach (var pendingFile in Directory.EnumerateFiles(pendingDeletionDir, "*.pending"))
{
try
{
File.Delete(pendingFile);
}
catch
{
// Ignore cleanup failures for pending deletions.
}
}
try
{
if (Directory.GetFiles(pendingDeletionDir).Length == 0 &&
Directory.GetDirectories(pendingDeletionDir).Length == 0)
{
Directory.Delete(pendingDeletionDir);
}
}
catch
{
// Ignore directory cleanup failures.
}
}
private string ResolvePluginRemovalTargetPath(PluginCatalogEntry entry)
@@ -826,6 +974,13 @@ public sealed class PluginRuntimeService : IDisposable
_settingsCatalogService.RemovePluginSections(pluginId);
}
private enum PluginCatalogSourceKind
{
Package = 0,
Manifest = 1,
DevPlugin = 2
}
private sealed record PluginCandidate(
string SourcePath,
PluginManifest Manifest,

View File

@@ -87,7 +87,7 @@ dotnet new install LanMountainDesktop.PluginTemplate
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.1)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)

View File

@@ -39,7 +39,7 @@
### 当前阶段
- 产品版本:`1.0.0`
- Plugin SDK API 基线:`4.0.0`
- Plugin SDK API 基线:`4.0.1`
- 当前重点:持续完善宿主体验、设置页体验、组件能力与插件生态
- 近期需求入口:以 `.trae/specs/` 中的 feature spec 为准
@@ -59,4 +59,4 @@
LanMountainDesktop is a cross-platform desktop enhancement product built with Avalonia UI and .NET 10. It targets students, office users, and customization-focused users who want a programmable desktop surface for information, tools, and plugin-driven extensions.
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.0`.
This repository is the source of truth for the desktop host, plugin runtime, Plugin SDK, shared contracts, and core appearance/settings infrastructure. The current product version is `1.0.0`, and the active Plugin SDK baseline in this repository is `4.0.1`.

View File

@@ -0,0 +1,118 @@
# 🧭 阑山桌面插件开发文档导航
欢迎来到 **LanMountainDesktop阑山桌面** 插件开发文档!
这套文档将帮助你从零开始,一步步掌握插件开发的完整流程,最终发布你的作品到插件市场。
---
## 📖 文档概述
**目标读者:**
- 有一定 .NET/C# 基础的开发者
- 熟悉或愿意学习 Avalonia UI 框架的开发者
- 想要为阑山桌面扩展功能的创意开发者
**你能学到什么:**
- 🚀 快速搭建插件开发环境
- 🧩 创建桌面组件Widgets
- ⚙️ 集成设置页面
- 🎨 适配主题和外观
- 🐛 调试和故障排除
- 🚀 CI/CD 自动化构建
- 📦 发布到插件市场
---
## 🛤️ 推荐阅读路径
### 🌱 新手路径(从零开始)
如果你从未开发过阑山桌面插件,请按以下顺序阅读:
1. **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** - 安装必要工具和模板
2. **[02-三分钟创建第一个插件](01-快速开始/02-三分钟创建第一个插件.md)** - 快速上手,建立信心
3. **[03-插件项目结构详解](01-快速开始/03-插件项目结构详解.md)** - 理解项目组成
4. **[04-调试运行指南](01-快速开始/04-调试运行指南.md)** - 学会调试技巧
5. **[01-插件生命周期](02-核心概念与原理/01-插件生命周期.md)** - 理解运行原理
6. **[02-桌面组件系统](02-核心概念与原理/02-桌面组件系统.md)** - 创建你的第一个组件
7. **[01-开发天气组件](04-实战案例/01-开发天气组件.md)** - 完整实战案例
**预计时间:** 2-3 小时即可开发出第一个可用插件
### 🚀 有经验路径(已有 .NET/Avalonia 基础)
如果你已有相关经验,可以跳过基础部分:
1. **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** - 快速配置环境
2. **[02-核心概念与原理/](02-核心概念与原理/)** - 了解阑山桌面的特殊机制
3. **[03-API实践指南/](03-API实践指南/)** - 查阅具体 API 用法
4. **[04-实战案例/](04-实战案例/)** - 参考完整示例
---
## 🔍 快速问题索引
| 我想知道... | 查看文档 |
|------------|---------|
| 如何搭建开发环境? | [01-开发环境准备](01-快速开始/01-开发环境准备.md) |
| 如何创建第一个插件? | [02-三分钟创建第一个插件](01-快速开始/02-三分钟创建第一个插件.md) |
| plugin.json 各字段含义? | [03-插件项目结构详解](01-快速开始/03-插件项目结构详解.md) |
| 如何调试插件代码? | [04-调试运行指南](01-快速开始/04-调试运行指南.md) |
| 插件什么时候初始化?能做什么? | [01-插件生命周期](02-核心概念与原理/01-插件生命周期.md) |
| 什么是桌面组件?如何创建? | [02-桌面组件系统](02-核心概念与原理/02-桌面组件系统.md) |
| 如何添加设置页面? | [03-设置系统集成](02-核心概念与原理/03-设置系统集成.md) + [04-开发设置页面](04-实战案例/04-开发设置页面.md) |
| 如何适配暗色模式? | [04-外观与主题系统](02-核心概念与原理/04-外观与主题系统.md) |
| 插件之间如何通信? | [05-插件间通信](02-核心概念与原理/05-插件间通信.md) |
| 完整的组件开发示例? | [01-开发天气组件](04-实战案例/01-开发天气组件.md) |
| 如何排查插件不加载的问题? | [03-常见问题排查](05-调试与故障排除/03-常见问题排查.md) |
| 如何配置 GitHub Actions | [01-GitHub Actions入门](06-CI-CD与自动化/01-GitHub Actions入门.md) |
| 如何自动打包 .laapp | [03-自动打包与发布](06-CI-CD与自动化/03-自动打包与发布.md) |
| 如何发布到插件市场? | [03-发布到插件市场](07-发布与运营/03-发布到插件市场.md) |
---
## 📚 相关资源
### 官方资源
| 资源 | 位置 | 说明 |
|-----|------|------|
| **Plugin SDK 源码** | `LanMountainDesktop.PluginSdk/` | SDK 的完整源码和 XML 注释 |
| **插件模板** | `LanMountainDesktop.PluginTemplate/` | `dotnet new` 模板源码 |
| **共享契约** | `LanMountainDesktop.Shared.Contracts/` | 宿主与插件共享的类型定义 |
| **架构文档** | `docs/ARCHITECTURE.md` | 宿主应用架构说明 |
| **视觉规范** | `docs/VISUAL_SPEC.md` | UI 设计规范 |
| **圆角规范** | `docs/CORNER_RADIUS_SPEC.md` | 圆角设计系统 |
| **开发指南** | `docs/DEVELOPMENT.md` | 宿主开发指南 |
### 外部资源
| 资源 | 链接 | 说明 |
|-----|------|------|
| **示例插件仓库** | `LanMountainDesktop.SamplePlugin` | 官方示例插件(独立仓库) |
| **Avalonia UI 文档** | https://docs.avaloniaui.net/ | UI 框架官方文档 |
| **FluentAvalonia** | https://github.com/amwx/FluentAvalonia | 主题控件库 |
| **.NET 文档** | https://learn.microsoft.com/dotnet/ | .NET 官方文档 |
---
## 💡 获取帮助
如果在开发过程中遇到问题:
1. **查阅本文档** - 使用上方快速索引找到相关章节
2. **查看示例代码** - 参考 `LanMountainDesktop.PluginTemplate/content/` 中的模板代码
3. **阅读 SDK 源码** - `LanMountainDesktop.PluginSdk/` 中有详细的 XML 注释
4. **搜索 Issues** - 在 GitHub 仓库搜索是否有人遇到类似问题
5. **提交 Issue** - 如果确认是 bug欢迎提交 Issue
---
## 🎯 下一步
准备好开始了吗?点击 **[01-开发环境准备](01-快速开始/01-开发环境准备.md)** 开始你的插件开发之旅!
---
*最后更新2026年4月*

View File

@@ -0,0 +1,220 @@
# 01-开发环境准备
在开始开发阑山桌面插件之前,你需要准备好开发环境。本指南将带你完成所有必要的安装和配置。
---
## ✅ 系统要求
### 支持的操作系统
| 操作系统 | 版本要求 | 备注 |
|---------|---------|------|
| **Windows** | Windows 10 版本 1809 或更高 | 推荐开发平台 |
| **Windows** | Windows 11 | 最佳体验 |
| **Linux** | Ubuntu 20.04+ / Debian 10+ | 支持开发和运行 |
| **macOS** | macOS 12+ | 支持开发和运行 |
### 硬件要求
- **处理器**x64 或 ARM64 架构
- **内存**:至少 4GB RAM推荐 8GB
- **磁盘空间**:至少 2GB 可用空间
---
## 🛠️ 安装 .NET SDK
阑山桌面插件基于 **.NET 10** 开发,你需要安装对应版本的 SDK。
### 下载安装
1. 访问 [.NET 10 下载页面](https://dotnet.microsoft.com/download/dotnet/10.0)
2. 下载适合你操作系统的 SDK 安装包
3. 运行安装程序,按提示完成安装
### 验证安装
打开终端PowerShell、CMD 或 Bash运行以下命令
```powershell
# 检查 .NET SDK 版本
dotnet --version
```
**预期输出示例:**
```
10.0.100
```
⚠️ **如果版本低于 10.0**,请重新下载安装最新版 .NET 10 SDK。
---
## 💻 安装 IDE集成开发环境
你可以选择以下任一 IDE 进行开发:
### 选项 1Visual Studio 2022推荐 Windows 用户)
**优点:** 功能最全,调试体验最佳
1. 下载 [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
2. 安装时选择以下工作负载:
-**.NET 桌面开发**
-**Avalonia UI 开发**(可选,如需 Avalonia 设计器)
### 选项 2JetBrains Rider跨平台推荐
**优点:** 跨平台智能提示强大Avalonia 支持好
1. 下载 [Rider](https://www.jetbrains.com/rider/)
2. 安装后打开,会自动检测 .NET SDK
### 选项 3Visual Studio Code轻量级
**优点:** 免费,轻量,插件丰富
1. 下载 [VS Code](https://code.visualstudio.com/)
2. 安装以下扩展:
- **C# Dev Kit**Microsoft 官方)
- **Avalonia for VS Code**(可选)
---
## 📦 安装插件模板
阑山桌面提供了官方的 `dotnet new` 模板,帮助你快速创建插件项目。
### 安装模板
```powershell
# 安装最新版插件模板
dotnet new install LanMountainDesktop.PluginTemplate
```
**成功提示:**
```
模板名 短名称 语言 标签
------------------------------------- ---------- ---- ------------
LanMountainDesktop Plugin lmd-plugin C# LanMountainDesktop/Plugin
```
### 验证安装
```powershell
# 列出已安装的模板,查找 lmd-plugin
dotnet new list | findstr lmd
```
Linux/macOS
```bash
dotnet new list | grep lmd
```
---
## 🎮 获取宿主应用
插件需要在阑山桌面宿主中运行,你需要获取宿主应用:
### 方式 1下载 Release 版本(推荐)
1. 访问 GitHub Releases 页面
2. 下载最新版本的安装包(.exe / .deb / .dmg
3. 安装并运行阑山桌面
### 方式 2从源码构建
如果你想调试宿主或了解内部机制:
```powershell
# 克隆仓库
git clone https://github.com/your-org/LanMountainDesktop.git
cd LanMountainDesktop
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
---
## 🔍 环境验证清单
在继续之前,请确认以下检查项都已完成:
| 检查项 | 验证命令 | 预期结果 |
|-------|---------|---------|
| ✅ .NET SDK 版本 | `dotnet --version` | 10.0.xxx |
| ✅ 模板已安装 | `dotnet new list \| findstr lmd` | 显示 lmd-plugin |
| ✅ IDE 可创建项目 | 在 IDE 中新建项目 | 能看到 C# 项目模板 |
| ✅ 宿主可运行 | 双击 LanMountainDesktop.exe | 应用正常启动 |
---
## ⚠️ 常见问题
### 问题 1dotnet 命令找不到
**现象:** 运行 `dotnet` 提示不是内部或外部命令
**解决:**
1. 确认 .NET SDK 已正确安装
2. 重启终端或 IDE
3. 检查环境变量 PATH 是否包含 `C:\Program Files\dotnet\`
### 问题 2模板安装失败
**现象:** `dotnet new install` 报错或卡住
**解决:**
1. 检查网络连接(需要访问 nuget.org
2. 尝试指定版本号:
```powershell
dotnet new install LanMountainDesktop.PluginTemplate::1.0.0
```
3. 清除模板缓存后重试:
```powershell
dotnet new uninstall LanMountainDesktop.PluginTemplate
dotnet new install LanMountainDesktop.PluginTemplate
```
### 问题 3SDK 版本不匹配
**现象:** 构建时提示 SDK 版本不符合 global.json 要求
**解决:**
1. 检查项目根目录的 `global.json` 文件
2. 安装对应版本的 .NET SDK
3. 或使用以下命令使用已安装的版本:
```powershell
dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
```
---
## 🎯 下一步
环境准备完成!接下来:
👉 **[02-三分钟创建第一个插件](02-三分钟创建第一个插件.md)** - 立即开始创建你的第一个插件!
---
## 📚 参考资源
- [.NET 10 下载](https://dotnet.microsoft.com/download/dotnet/10.0)
- [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
- [JetBrains Rider](https://www.jetbrains.com/rider/)
- [VS Code](https://code.visualstudio.com/)
- [Avalonia UI 文档](https://docs.avaloniaui.net/)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,236 @@
# 02-三分钟创建第一个插件
本指南将帮助你在三分钟内创建并运行你的第一个阑山桌面插件。让我们开始吧!
---
## 🎯 目标
完成本指南后,你将:
- ✅ 创建一个可运行的插件项目
- ✅ 在宿主中成功加载插件
- ✅ 在插件列表中看到你的插件
---
## ⚡ 步骤一创建项目30秒
打开终端,运行以下命令:
```powershell
# 创建插件项目
dotnet new lmd-plugin -n MyFirstPlugin
# 进入项目目录
cd MyFirstPlugin
```
**成功标志:** 命令执行后没有报错,且生成了 `MyFirstPlugin` 文件夹。
---
## 📝 步骤二配置插件信息30秒
打开 `plugin.json` 文件,修改以下字段:
```json
{
"id": "com.yourname.myfirstplugin",
"name": "我的第一个插件",
"description": "这是一个测试插件",
"author": "你的名字",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyFirstPlugin.dll",
"sharedContracts": []
}
```
⚠️ **重要提示:**
- `id` 必须是唯一的,建议使用反向域名格式(如 `com.yourname.pluginname`
- `apiVersion` 必须与 SDK 版本匹配
- 保存文件时使用 **UTF-8** 编码
---
## 🔨 步骤三构建项目30秒
在终端中运行:
```powershell
# 构建项目
dotnet build
```
**成功标志:** 看到类似以下的输出:
```
生成成功。
0 个警告
0 个错误
```
---
## 📦 步骤四找到插件包15秒
构建完成后,插件包位于:
```
MyFirstPlugin/
└── bin/
└── Debug/
└── net10.0/
└── MyFirstPlugin.laapp <-- 这就是插件包!
```
⚠️ **什么是 .laapp 文件?**
- `.laapp` 是阑山桌面的插件包格式
- 本质上是一个 ZIP 压缩包,包含插件 DLL 和资源文件
- 不要解压,直接安装即可
---
## 🚀 步骤五安装到宿主30秒
1. **启动阑山桌面**(如果尚未运行)
2. **打开设置**
- 右键点击桌面上的阑山桌面图标
- 选择「设置」
3. **进入插件管理**
- 在设置窗口左侧选择「插件」
4. **安装本地插件**
- 点击「安装本地插件」按钮
- 选择刚才生成的 `.laapp` 文件
- 点击「打开」
5. **重启宿主**
- 安装完成后,点击「重启」按钮
- 阑山桌面将重新启动
---
## ✅ 步骤六验证安装15秒
重启后,再次打开设置 → 插件:
🎉 **成功标志:**
- 在插件列表中看到「我的第一个插件」
- 状态显示为「已启用」
- 作者显示为你设置的名字
![插件列表示意图](此处应有截图位置)
---
## 📂 生成的项目结构
你的项目现在包含以下文件:
```
MyFirstPlugin/
├── plugin.json # 插件清单文件
├── MyFirstPlugin.csproj # 项目文件
├── Plugin.cs # 插件入口类
├── README.md # 项目说明
└── Localization/ # 本地化文件夹
├── zh-CN.json # 中文资源
└── en-US.json # 英文资源
```
---
## 🔍 查看插件代码
打开 `Plugin.cs`,你会看到:
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyFirstPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 插件初始化代码
// 在这里注册组件、设置页面等
}
}
```
**关键点:**
- `[PluginEntrance]` 特性标记入口类
- 继承 `PluginBase` 基类
- `Initialize` 方法是插件的初始化入口
---
## 🎉 恭喜!
你已经成功创建并安装了第一个阑山桌面插件!
虽然这个插件目前还没有任何功能,但你已经掌握了:
- ✅ 使用模板创建项目
- ✅ 配置插件信息
- ✅ 构建插件包
- ✅ 安装到宿主
---
## 🚦 常见问题
### 问题 1构建失败提示找不到 SDK
**现象:** 错误信息包含 "SDK not found"
**解决:**
1. 确认已安装 .NET 10 SDK`dotnet --version`
2. 检查 `global.json` 中的版本要求
### 问题 2宿主提示插件安装失败
**现象:** 安装时弹出错误对话框
**排查步骤:**
1. 检查 `plugin.json` 是否为有效的 JSON 格式
2. 确认 `id` 字段唯一且合法(只能包含字母、数字、点号)
3. 确认 `apiVersion` 与 SDK 版本匹配
### 问题 3插件列表中不显示
**现象:** 安装后重启,但列表中没有
**排查步骤:**
1. 确认已点击「重启」按钮
2. 检查日志文件:`%LOCALAPPDATA%\LanMountainDesktop\logs\`
3. 确认 `.laapp` 文件完整未损坏
---
## 🎯 下一步
现在你的插件已经能运行了,接下来学习:
👉 **[03-插件项目结构详解](03-插件项目结构详解.md)** - 深入理解每个文件的作用
或者直接进入实战:
👉 **[02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)** - 创建你的第一个桌面组件!
---
## 💡 小贴士
- **快速重建**:修改代码后,只需运行 `dotnet build` 即可重新生成 `.laapp`
- **自动安装**:可以在 IDE 中配置构建后自动复制到宿主插件目录
- **日志调试**:使用 `ILogger` 记录日志,在 `%LOCALAPPDATA%\LanMountainDesktop\logs\` 查看
---
*最后更新2026年4月*

View File

@@ -0,0 +1,350 @@
# 03-插件项目结构详解
了解插件项目的每个文件和文件夹的作用,是开发高质量插件的基础。本文将详细解析插件项目的完整结构。
---
## 📂 项目结构概览
使用模板创建的插件项目结构如下:
```
MyPlugin/
├── plugin.json # 插件清单(必需)
├── MyPlugin.csproj # 项目文件(必需)
├── Plugin.cs # 入口类(必需)
├── README.md # 项目说明(推荐)
├── .gitignore # Git忽略文件可选
└── Localization/ # 本地化文件夹(可选)
├── zh-CN.json # 中文资源
└── en-US.json # 英文资源
```
---
## 📋 plugin.json - 插件清单
这是插件最重要的配置文件,定义了插件的元数据。
### 完整示例
```json
{
"id": "com.example.myplugin",
"name": "我的插件",
"description": "这是一个示例插件",
"author": "作者名称",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyPlugin.dll",
"sharedContracts": [],
"website": "https://example.com",
"icon": "icon.png",
"tags": ["工具", "实用"]
}
```
### 字段详解
| 字段 | 必需 | 说明 | 示例 |
|-----|------|------|------|
| `id` | ✅ | 唯一标识符,反向域名格式 | `com.yourname.plugin` |
| `name` | ✅ | 显示名称 | `天气插件` |
| `description` | ✅ | 简短描述 | `显示实时天气信息` |
| `author` | ✅ | 作者名称 | `张三` |
| `version` | ✅ | 版本号(语义化版本) | `1.0.0` |
| `apiVersion` | ✅ | SDK API 版本 | `4.0.1` |
| `entranceAssembly` | ✅ | 入口程序集文件名 | `MyPlugin.dll` |
| `sharedContracts` | ✅ | 共享契约类型列表 | `[]` |
| `website` | ❌ | 项目网站 | `https://github.com/...` |
| `icon` | ❌ | 图标文件名 | `icon.png` |
| `tags` | ❌ | 标签数组 | `["天气", "工具"]` |
### 重要规则
⚠️ **id 字段规则:**
- 只能包含小写字母、数字、点号(`.`
- 必须全局唯一
- 建议使用反向域名格式:`com.yourname.pluginname`
- 一经发布不可更改
⚠️ **version 字段规则:**
- 使用语义化版本格式:`主版本.次版本.修订号`
- 示例:`1.0.0``2.1.3-beta`
⚠️ **apiVersion 字段规则:**
- 必须与引用的 SDK 版本兼容
- 当前最新版本:`4.0.1`
- 不兼容时宿主将拒绝加载插件
---
## 🔧 .csproj - 项目文件
定义了项目的构建配置和依赖项。
### 完整示例
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
```
### 关键配置项
| 配置项 | 说明 | 推荐值 |
|-------|------|--------|
| `TargetFramework` | 目标框架 | `net10.0` |
| `LangVersion` | C# 语言版本 | `latest` |
| `Nullable` | 可空引用类型 | `enable` |
### SDK 引用
```xml
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
```
⚠️ **版本必须匹配:**
- SDK 版本必须与 `plugin.json` 中的 `apiVersion` 兼容
- 建议使用最新稳定版
### 资源文件配置
确保 `plugin.json` 和本地化文件被正确复制到输出目录:
```xml
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
```
---
## 🚪 Plugin.cs - 入口类
插件的入口点,负责初始化逻辑。
### 基本结构
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 在这里注册组件、设置页面、服务等
// 示例:注册桌面组件
// services.AddPluginDesktopComponent<MyWidget>(...);
// 示例:注册设置页面
// services.AddPluginSettingsSection(...);
}
}
```
### 关键特性
| 特性/类 | 说明 |
|--------|------|
| `[PluginEntrance]` | 标记插件入口类,必须有且仅有一个 |
| `PluginBase` | 插件基类,提供基础功能和日志访问 |
| `Initialize` | 初始化方法,宿主启动时调用 |
### Initialize 方法参数
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
```
| 参数 | 类型 | 说明 |
|-----|------|------|
| `context` | `HostBuilderContext` | 宿主构建上下文,可访问配置 |
| `services` | `IServiceCollection` | 依赖注入服务集合,用于注册组件和服务 |
---
## 🌍 Localization - 本地化文件夹
存放多语言资源文件,支持插件的国际化。
### 文件夹结构
```
Localization/
├── zh-CN.json # 简体中文
├── zh-TW.json # 繁体中文
├── en-US.json # 英文(美国)
├── ja-JP.json # 日文
└── ko-KR.json # 韩文
```
### 资源文件格式
```json
{
"PluginName": "我的插件",
"Settings": {
"Title": "设置",
"RefreshInterval": "刷新间隔"
},
"Messages": {
"Loading": "加载中...",
"Error": "出错了:{0}"
}
}
```
### 在代码中使用
```csharp
// 获取本地化字符串
var localizer = serviceProvider.GetRequiredService<IStringLocalizer<MyPlugin>>();
var pluginName = localizer["PluginName"];
var message = localizer["Messages.Error", errorDetails];
```
### 支持的语言代码
| 语言 | 代码 |
|-----|------|
| 简体中文 | `zh-CN` |
| 繁体中文 | `zh-TW` |
| 英文 | `en-US` |
| 日文 | `ja-JP` |
| 韩文 | `ko-KR` |
---
## 📦 构建输出结构
运行 `dotnet build` 后,生成的输出结构:
```
bin/Debug/net10.0/
├── MyPlugin.dll # 插件程序集
├── MyPlugin.pdb # 调试符号
├── plugin.json # 插件清单(复制)
├── Localization/ # 本地化文件夹(复制)
│ └── zh-CN.json
├── MyPlugin.laapp # 插件包(由 SDK 自动生成)
└── ...(依赖项 DLL
```
### .laapp 包结构
`.laapp` 文件本质是一个 ZIP 压缩包,包含:
```
MyPlugin.laapp
├── plugin.json # 清单文件
├── MyPlugin.dll # 主程序集
├── Localization/ # 本地化资源
└── ...(其他依赖 DLL
```
---
## 🔗 与其他 .NET 项目的区别
| 特性 | 普通 .NET 应用 | 阑山桌面插件 |
|-----|---------------|-------------|
| 入口点 | `Program.cs``Main` | `Plugin.cs``Initialize` |
| 运行方式 | 独立运行 | 由宿主加载运行 |
| 依赖注入 | 自行配置 | 使用宿主提供的 `IServiceCollection` |
| 输出格式 | `.exe``.dll` | `.laapp` 包 |
| 资源访问 | 直接访问 | 通过 SDK API 访问宿主资源 |
| 热重载 | 支持 | 不支持(需重启宿主) |
---
## 🎯 最佳实践
### 项目组织建议
```
MyPlugin/
├── plugin.json
├── MyPlugin.csproj
├── Plugin.cs # 入口类(保持简洁)
├── README.md
├── .gitignore
├── Localization/ # 本地化资源
├── Services/ # 服务类文件夹
│ ├── WeatherService.cs
│ └── DataService.cs
├── Views/ # 视图文件夹
│ ├── WeatherWidget.axaml
│ ├── WeatherWidget.axaml.cs
│ └── SettingsPage.axaml
└── ViewModels/ # 视图模型文件夹
├── WeatherViewModel.cs
└── SettingsViewModel.cs
```
### 文件命名规范
| 类型 | 命名约定 | 示例 |
|-----|---------|------|
| 入口类 | `Plugin` | `Plugin.cs` |
| 组件视图 | `{Name}Widget` | `WeatherWidget.axaml` |
| 设置页面 | `{Name}SettingsPage` | `WeatherSettingsPage.axaml` |
| 服务类 | `{Name}Service` | `WeatherService.cs` |
| 视图模型 | `{Name}ViewModel` | `WeatherViewModel.cs` |
---
## 📚 参考资源
- [Plugin SDK 源码](../../LanMountainDesktop.PluginSdk/)
- [插件模板](../../LanMountainDesktop.PluginTemplate/content/)
- [02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)
- [03-设置系统集成](../02-核心概念与原理/03-设置系统集成.md)
---
## 🎯 下一步
理解了项目结构后,接下来学习:
👉 **[04-调试运行指南](04-调试运行指南.md)** - 掌握调试技巧
或者深入了解核心概念:
👉 **[01-插件生命周期](../02-核心概念与原理/01-插件生命周期.md)** - 理解插件运行机制
---
*最后更新2026年4月*

View File

@@ -0,0 +1,380 @@
# 04-调试运行指南
掌握插件调试技巧,能大幅提升开发效率。本文介绍阑山桌面插件的各种调试方法和常见问题排查。
---
## 🔄 调试方式概述
阑山桌面插件有两种主要调试方式:
| 方式 | 适用场景 | 优点 | 缺点 |
|-----|---------|------|------|
| **附加到进程** | 日常开发调试 | 不改变项目结构 | 每次需手动附加 |
| **独立调试** | 深度调试、单元测试 | 启动即调试 | 配置较复杂 |
---
## 🎯 方式一:附加到进程(推荐)
这是日常开发中最常用的调试方式。
### 步骤
1. **启动阑山桌面**
- 正常启动宿主应用(非调试模式)
- 确保你的插件已安装
2. **在 IDE 中打开插件项目**
- 使用 Visual Studio / Rider / VS Code 打开项目
3. **设置断点**
- 在你想要调试的代码行左侧点击,设置断点
- 常见断点位置:
- `Plugin.Initialize()` - 插件初始化
- 组件构造函数
- 设置页面加载方法
4. **附加到进程**
**Visual Studio**
- 菜单:`调试``附加到进程`
- 或快捷键:`Ctrl+Alt+P`
- 在列表中找到 `LanMountainDesktop.exe`
- 点击`附加`
**Rider**
- 菜单:`Run``Attach to Process`
- 或快捷键:`Ctrl+Alt+F5`
- 选择 `LanMountainDesktop.exe`
**VS Code**
-`Ctrl+Shift+D` 打开调试面板
- 点击`创建 launch.json 文件`
- 选择 `.NET Core Attach`
- 选择 `LanMountainDesktop` 进程
5. **触发调试**
- 在阑山桌面中操作,触发插件代码
- 例如:添加组件、打开设置页面等
- 程序会在断点处暂停
### 附加配置VS Code
创建 `.vscode/launch.json`
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "附加到阑山桌面",
"type": "coreclr",
"request": "attach",
"processName": "LanMountainDesktop"
}
]
}
```
---
## 🔧 方式二:独立调试
适用于深度调试或单元测试。
### 配置步骤
1. **修改 .csproj 临时引用宿主**
```xml
<ItemGroup>
<!-- 临时添加,仅用于调试 -->
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
</ItemGroup>
```
2. **创建调试启动配置**
**Visual Studio**
- 右键项目 → `属性` → `调试`
- 启动外部程序:选择 `LanMountainDesktop.exe`
- 工作目录:设为宿主输出目录
**VS Code launch.json**
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "启动阑山桌面(调试)",
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/../LanMountainDesktop/bin/Debug/net10.0/LanMountainDesktop.exe",
"args": [],
"cwd": "${workspaceFolder}/../LanMountainDesktop/bin/Debug/net10.0",
"stopAtEntry": false
}
]
}
```
3. **启动调试**
- 按 `F5` 启动
- 宿主会以调试模式启动
- 插件代码中的断点会直接命中
⚠️ **注意:** 发布插件前务必移除临时引用!
---
## 📝 日志调试
当断点调试不方便时,日志是最有效的调试手段。
### 使用 ILogger
```csharp
using Microsoft.Extensions.Logging;
public class MyService
{
private readonly ILogger<MyService> _logger;
public MyService(ILogger<MyService> logger)
{
_logger = logger;
}
public void DoWork()
{
_logger.LogInformation("开始执行任务");
try
{
// 业务逻辑
_logger.LogDebug("处理数据: {Data}", data);
}
catch (Exception ex)
{
_logger.LogError(ex, "任务执行失败");
}
_logger.LogInformation("任务完成");
}
}
```
### 日志级别
| 级别 | 使用场景 |
|-----|---------|
| `LogTrace` | 最详细的跟踪信息 |
| `LogDebug` | 调试信息 |
| `LogInformation` | 一般信息 |
| `LogWarning` | 警告信息 |
| `LogError` | 错误信息 |
| `LogCritical` | 严重错误 |
### 查看日志文件
日志文件位置:
```
Windows: %LOCALAPPDATA%\LanMountainDesktop\logs\
Linux: ~/.local/share/LanMountainDesktop/logs/
macOS: ~/Library/Application Support/LanMountainDesktop/logs/
```
日志文件命名格式:
```
log-20240413.txt
log-20240413_001.txt
```
### 实时查看日志
**Windows PowerShell**
```powershell
Get-Content "$env:LOCALAPPDATA\LanMountainDesktop\logs\log-$(Get-Date -Format 'yyyyMMdd').txt" -Wait
```
**Linux/macOS**
```bash
tail -f ~/.local/share/LanMountainDesktop/logs/log-$(date +%Y%m%d).txt
```
---
## 🚫 热重载限制
⚠️ **重要:** 阑山桌面插件**不支持**热重载Hot Reload
### 原因
插件运行在独立的 `AssemblyLoadContext` 中,.NET 不支持卸载已加载的程序集。因此:
- 修改代码后必须重新构建
- 必须重启宿主才能加载新版本
- 无法使用 `dotnet watch`
### 高效开发流程
```
修改代码 → dotnet build → 重启宿主 → 测试
```
**加速技巧:**
1. **创建批处理脚本**`rebuild-and-run.ps1`
```powershell
dotnet build
Stop-Process -Name "LanMountainDesktop" -ErrorAction SilentlyContinue
Start-Process "C:\Path\To\LanMountainDesktop.exe"
```
2. **使用 Rider 的外部工具**
- 配置构建后自动复制 `.laapp` 到插件目录
---
## 🐛 常见问题排查
### 问题 1断点不命中
**可能原因:**
- 插件未重新构建
- PDB 符号文件未生成
- 附加到了错误的进程
**解决步骤:**
1. 确认已重新构建:`dotnet build`
2. 检查输出目录是否有 `.pdb` 文件
3. 确认附加的是 `LanMountainDesktop.exe`(不是 `LanMountainDesktop.dll`
4. 尝试清理重建:
```powershell
dotnet clean
dotnet build
```
### 问题 2插件不加载
**排查步骤:**
1. **检查日志**
```powershell
Get-Content "$env:LOCALAPPDATA\LanMountainDesktop\logs\log-$(Get-Date -Format 'yyyyMMdd').txt" | Select-String "MyPlugin"
```
2. **验证 plugin.json**
- JSON 格式是否有效
- `id` 是否合法(只含小写字母、数字、点号)
- `apiVersion` 是否与 SDK 版本匹配
3. **检查 .laapp 包**
- 用压缩软件打开,确认文件完整
- 确认 `plugin.json` 和 DLL 存在
### 问题 3依赖项找不到
**现象:** `FileNotFoundException` 或 `Could not load file or assembly`
**解决:**
1. 确保所有依赖项都复制到输出目录
2. 在 `.csproj` 中添加:
```xml
<PropertyGroup>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
```
### 问题 4调试时宿主卡顿
**原因:** 断点暂停导致 UI 线程阻塞
**解决:**
- 使用 `Debugger.Break()` 代替断点
- 或使用日志代替断点调试
---
## 💡 调试技巧
### 1. 条件断点
当需要在特定条件下暂停时使用:
**Visual Studio**
- 右键断点 → `条件`
- 输入条件表达式,如:`count > 10`
### 2. 日志点Tracepoint
不暂停程序,只输出日志:
**Visual Studio**
- 右键断点 → `操作`
- 勾选 `将消息输出到输出窗口`
- 输入消息模板:`变量值: {variableName}`
### 3. 异常设置
自动在抛出异常时中断:
**Visual Studio**
- `调试` → `窗口` → `异常设置`
- 勾选 `Common Language Runtime Exceptions`
### 4. 立即窗口
在调试时执行代码:
**Visual Studio**
- 快捷键:`Ctrl+Alt+I`
- 可查看变量值、调用方法
---
## 📊 性能调试
### 使用 Diagnostic Tools
**Visual Studio**
- 调试时自动显示 CPU 和内存使用情况
- `调试` → `窗口` → `诊断工具`
### 内存泄漏排查
```csharp
// 在可疑位置添加诊断代码
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var memory = GC.GetTotalMemory(true);
Debug.WriteLine($"内存使用: {memory / 1024 / 1024} MB");
```
---
## 🎯 下一步
掌握了调试技巧后,接下来学习核心概念:
👉 **[01-插件生命周期](../02-核心概念与原理/01-插件生命周期.md)** - 理解插件运行机制
或者查看实战案例:
👉 **[01-开发天气组件](../04-实战案例/01-开发天气组件.md)** - 完整开发流程
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md)
- [Visual Studio 调试文档](https://docs.microsoft.com/visualstudio/debugger/)
- [Rider 调试文档](https://www.jetbrains.com/help/rider/Debugging.html)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,398 @@
# 01-插件生命周期
理解插件的生命周期,是开发稳定可靠插件的基础。本文详细讲解插件从加载到卸载的完整过程。
---
## 🔄 生命周期概览
```
┌─────────────────────────────────────────────────────────────┐
│ 阑山桌面启动 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 1. 发现插件 │
│ - 扫描插件目录 │
│ - 解析 plugin.json │
│ - 验证 API 版本兼容性 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. 加载插件 │
│ - 创建 AssemblyLoadContext │
│ - 加载插件 DLL │
│ - 查找入口类(带 [PluginEntrance] 特性) │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. 初始化Initialize
│ - 调用 Plugin.Initialize() │
│ - 注册组件、设置页面、服务 │
│ - ⚠️ 此时 UI 尚未完全就绪 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 4. 运行中 │
│ - 组件被添加到桌面 │
│ - 用户与组件交互 │
│ - 设置页面被打开 │
└───────────────────────┬─────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 5. 停用/卸载 │
│ - 用户禁用插件 │
│ - 或关闭阑山桌面 │
│ - 释放资源(当前版本无显式卸载回调) │
└─────────────────────────────────────────────────────────────┘
```
---
## 📋 各阶段详解
### 阶段 1发现插件
**时机:** 阑山桌面启动时
**过程:**
1. 扫描 `%LOCALAPPDATA%\LanMountainDesktop\plugins\` 目录
2. 读取每个 `.laapp` 包中的 `plugin.json`
3. 验证 `apiVersion` 是否与宿主兼容
4. 检查 `id` 是否唯一
**可能失败的原因:**
- `plugin.json` 格式错误
- `apiVersion` 不兼容
- `id` 与其他插件冲突
---
### 阶段 2加载插件
**时机:** 发现成功后
**过程:**
1. 创建独立的 `AssemblyLoadContext`
2. 加载插件 DLL 及其依赖项
3. 查找带有 `[PluginEntrance]` 特性的类
4. 实例化插件入口类
**代码示例:**
```csharp
[PluginEntrance] // ← 这个特性标记入口类
public sealed class Plugin : PluginBase
{
// 插件实例在此阶段被创建
}
```
⚠️ **重要:** 此阶段**不要**执行耗时操作,只应进行简单的字段初始化。
---
### 阶段 3初始化Initialize
**时机:** 插件加载完成后
**这是插件开发中最重要的阶段!**
#### 方法签名
```csharp
public override void Initialize(
HostBuilderContext context, // 宿主构建上下文
IServiceCollection services) // 服务注册集合
```
#### 可执行的操作
**可以做的:**
- 注册桌面组件
- 注册设置页面
- 注册服务到依赖注入容器
- 读取配置
- 初始化资源
**不应该做的:**
- 访问 UI 元素UI 尚未就绪)
- 执行耗时阻塞操作
- 创建窗口或对话框
#### 典型初始化代码
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 1. 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
// 2. 注册桌面组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Weather",
DisplayName = "天气",
IconKey = "Weather",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3
});
// 3. 注册设置页面
services.AddPluginSettingsSection(
"myplugin-settings",
"天气设置",
section => section
.AddToggle("auto_refresh", "自动刷新", defaultValue: true)
.AddNumber("interval", "刷新间隔(分钟)", defaultValue: 30),
iconKey: "Settings");
}
```
---
### 阶段 4运行中
**时机:** 初始化完成后,直到插件被禁用或宿主关闭
**特点:**
- 组件可以被添加到桌面
- 用户可以与组件交互
- 设置页面可以被打开
- 定时器可以运行
#### 组件生命周期
```
用户添加组件
┌─────────────────┐
│ 创建组件实例 │ ← 调用构造函数
│ (Dependency │ 注入 IServiceProvider
│ Injection) │
└────────┬────────┘
┌─────────────────┐
│ 组件初始化 │ ← 可在此时加载数据
│ (Loaded事件) │
└────────┬────────┘
┌─────────────────┐
│ 渲染显示 │ ← 用户看到组件
└────────┬────────┘
┌────┴────┐
▼ ▼
用户交互 定时更新
│ │
└────┬────┘
┌─────────────────┐
│ 组件移除 │ ← 用户删除组件
│ (Unloaded事件) │ 或关闭宿主
└─────────────────┘
```
---
### 阶段 5停用/卸载
**时机:**
- 用户在设置中禁用插件
- 卸载插件
- 关闭阑山桌面
**当前限制:**
- SDK v4 暂无显式的卸载回调方法
- 资源释放依赖 .NET 垃圾回收
- 建议:
- 使用 `IDisposable` 模式管理资源
- 在组件卸载事件中清理资源
---
## ⏱️ 启动时序图
```
阑山桌面 插件系统 你的插件
│ │ │
│── 启动 ───────►│ │
│ │ │
│ │── 发现插件 ───►│
│ │ │ (读取 plugin.json)
│ │◄───────────────│
│ │ │
│ │── 加载 DLL ───►│
│ │ │ (AssemblyLoadContext)
│ │◄───────────────│
│ │ │
│ │── 创建实例 ───►│
│ │ │ (调用构造函数)
│ │◄───────────────│
│ │ │
│ │── Initialize ─►│
│ │ │ (注册组件/服务)
│ │◄───────────────│
│ │ │
│◄───────────────│ │
│ │ │
│── UI就绪 ─────►│ │
│ │ │
│ │── 用户添加组件 ─►│
│ │ │ (创建组件实例)
```
---
## 💡 最佳实践
### 1. Initialize 方法保持轻量
```csharp
// ✅ 好的做法:快速注册
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<IDataService, DataService>();
services.AddPluginDesktopComponent<MyWidget>(options);
}
// ❌ 避免:耗时操作
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 不要这样做!
var data = FetchDataFromInternet().Result; // 阻塞!
services.AddSingleton(data);
}
```
### 2. 延迟加载数据
```csharp
public class MyWidget : Border
{
private readonly IDataService _dataService;
public MyWidget(PluginDesktopComponentContext context)
{
_dataService = context.ServiceProvider.GetRequiredService<IDataService>();
// 在 Loaded 事件中加载数据,而不是构造函数
Loaded += async (_, _) =>
{
await LoadDataAsync();
};
}
private async Task LoadDataAsync()
{
var data = await _dataService.GetDataAsync();
// 更新 UI
}
}
```
### 3. 正确处理资源释放
```csharp
public class MyWidget : Border, IDisposable
{
private readonly Timer _timer;
private bool _disposed;
public MyWidget()
{
_timer = new Timer(OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
// 在卸载时释放资源
Unloaded += (_, _) => Dispose();
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_timer?.Dispose();
}
}
```
### 4. 避免循环依赖
```csharp
// ❌ 避免:服务之间相互依赖
public class ServiceA
{
public ServiceA(ServiceB b) { } // 循环依赖风险
}
public class ServiceB
{
public ServiceB(ServiceA a) { }
}
// ✅ 好的做法:使用接口解耦
public class ServiceA
{
public ServiceA(IServiceB b) { }
}
```
---
## 🐛 常见问题
### 问题 1Initialize 中访问 UI 报错
**现象:** `InvalidOperationException` 或空引用
**原因:** Initialize 在 UI 就绪前调用
**解决:** 延迟到组件创建后再访问 UI
### 问题 2服务注册顺序问题
**现象:** 依赖注入找不到服务
**原因:** 服务注册顺序不正确
**解决:** 先注册服务,再注册依赖这些服务的组件
### 问题 3插件加载慢
**现象:** 宿主启动变慢
**原因:** Initialize 中执行耗时操作
**解决:** 将耗时操作移到后台线程或延迟执行
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [IPlugin 接口](../../LanMountainDesktop.PluginSdk/IPlugin.cs)
- [02-桌面组件系统](02-桌面组件系统.md)
- [03-设置系统集成](03-设置系统集成.md)
---
## 🎯 下一步
理解生命周期后,学习如何创建桌面组件:
👉 **[02-桌面组件系统](02-桌面组件系统.md)** - 创建可视化组件
---
*最后更新2026年4月*

View File

@@ -0,0 +1,393 @@
# 02-桌面组件系统
桌面组件Desktop Component是阑山桌面插件的核心功能。本文详细讲解组件系统的工作原理和开发方法。
---
## 🎯 什么是桌面组件
桌面组件是显示在阑山桌面上的可视化元素,用户可以自由:
- 添加/删除组件
- 拖动调整位置
- 调整大小
- 配置属性
**常见组件示例:**
- 时钟组件 - 显示当前时间
- 天气组件 - 显示天气信息
- 日历组件 - 显示日期和日程
- 系统监控 - 显示 CPU/内存使用率
---
## 📐 网格系统
阑山桌面使用网格系统管理组件布局。
### 网格概念
```
┌─────────────────────────────────────────┐
│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │
│ │ 2x2│ │ 2x2│ │ 2x2│ │ 2x2│ │
│ │ 格 │ │ 格 │ │ 格 │ │ 格 │ │
│ └────┘ └────┘ └────┘ └────┘ │
│ ┌────┐ ┌────────┐ ┌────┐ │
│ │ 2x2│ │ 4x2 │ │ 2x2│ │
│ │ 格 │ │ 格 │ │ 格 │ │
│ └────┘ └────────┘ └────┘ │
│ ┌────────┐ ┌────────┐ │
│ │ 4x3 │ │ 4x3 │ │
│ │ 格 │ │ 格 │ │
│ └────────┘ └────────┘ │
└─────────────────────────────────────────┘
每格大小:约 60-80 像素(根据 DPI 自动调整)
```
### 组件尺寸
组件尺寸以**格数**为单位:
| 属性 | 说明 | 示例 |
|-----|------|------|
| `MinWidthCells` | 最小宽度(格数) | 4 = 4格宽 |
| `MinHeightCells` | 最小高度(格数) | 3 = 3格高 |
**常见尺寸参考:**
| 组件类型 | 推荐尺寸 | 实际像素(约) |
|---------|---------|--------------|
| 小图标 | 2x2 | 120x120 |
| 天气卡片 | 4x3 | 240x180 |
| 时钟 | 4x4 | 240x240 |
| 日历 | 6x4 | 360x240 |
| 宽面板 | 8x3 | 480x180 |
---
## 🏗️ 创建组件
### 步骤 1创建组件类
组件是继承自 Avalonia 控件的类:
```csharp
using Avalonia.Controls;
using LanMountainDesktop.PluginSdk;
namespace MyPlugin;
public class WeatherWidget : Border // 继承自 Border 或其他控件
{
public WeatherWidget(PluginDesktopComponentContext context)
{
// 组件初始化
InitializeComponent(context);
}
private void InitializeComponent(PluginDesktopComponentContext context)
{
// 设置背景
Background = new SolidColorBrush(Colors.Transparent);
// 设置圆角(使用宿主主题)
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 创建内容
var textBlock = new TextBlock
{
Text = "天气组件",
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
Child = textBlock;
}
}
```
### 步骤 2注册组件
`Plugin.Initialize` 中注册:
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Weather", // 唯一标识
DisplayName = "天气", // 显示名称
IconKey = "Weather", // 图标Fluent 图标名)
Category = "工具", // 分类
MinWidthCells = 4, // 最小宽度(格)
MinHeightCells = 3, // 最小高度(格)
CornerRadiusPreset = PluginCornerRadiusPreset.Component // 圆角预设
});
}
```
### PluginDesktopComponentOptions 详解
| 属性 | 必需 | 说明 | 示例 |
|-----|------|------|------|
| `ComponentId` | ✅ | 唯一标识符 | `"MyPlugin.Weather"` |
| `DisplayName` | ✅ | 显示名称 | `"天气"` |
| `IconKey` | ✅ | 图标键名 | `"Weather"``"Clock"` |
| `Category` | ✅ | 分类 | `"工具"``"信息"` |
| `MinWidthCells` | ✅ | 最小宽度(格) | `4` |
| `MinHeightCells` | ✅ | 最小高度(格) | `3` |
| `CornerRadiusPreset` | ❌ | 圆角预设 | `PluginCornerRadiusPreset.Component` |
| `ResizeMode` | ❌ | 调整大小模式 | `PluginDesktopComponentResizeMode.Free` |
### 常用 Fluent 图标
| 图标键名 | 用途 |
|---------|------|
| `Weather` | 天气相关 |
| `Clock` | 时钟、时间 |
| `Calendar` | 日历、日期 |
| `Settings` | 设置 |
| `Home` | 主页 |
| `Search` | 搜索 |
| `Star` | 收藏 |
| `Heart` | 喜欢 |
| `Info` | 信息 |
| `Warning` | 警告 |
完整图标列表:[Fluent UI System Icons](https://github.com/microsoft/fluentui-system-icons)
---
## 🎨 组件外观
### 圆角设置
插件必须使用宿主提供的圆角系统,以保持视觉一致性:
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 获取组件标准圆角
var cornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
CornerRadius = cornerRadius;
}
```
**可用的圆角预设:**
| 预设 | 用途 |
|-----|------|
| `Micro` | 微小元素 |
| `Xs` | 小元素 |
| `Sm` | 小卡片 |
| `Md` | 普通按钮/卡片 |
| `Lg` | 大面板 |
| `Xl` | 强调容器 |
| `Component` | **桌面组件标准** |
| `Default` | 自适应 |
### 背景与透明
```csharp
// 透明背景(推荐,让宿主壁纸透出)
Background = new SolidColorBrush(Colors.Transparent);
// 毛玻璃效果
Background = new SolidColorBrush(Color.Parse("#40FFFFFF"));
// 纯色背景
Background = new SolidColorBrush(Color.Parse("#FF2D2D2D"));
```
### 响应主题变化
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 订阅主题变化
context.Appearance.AppearanceChanged += (_, _) =>
{
UpdateAppearance();
};
UpdateAppearance();
}
private void UpdateAppearance()
{
// 根据当前主题更新颜色
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
Foreground = new SolidColorBrush(isDark ? Colors.White : Colors.Black);
}
```
---
## 📏 尺寸与布局
### 获取实际尺寸
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 订阅尺寸变化
SizeChanged += OnSizeChanged;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
// 获取当前实际尺寸(像素)
var width = Bounds.Width;
var height = Bounds.Height;
// 根据尺寸调整内容
if (width < 200)
{
// 小尺寸模式
ShowCompactView();
}
else
{
// 完整模式
ShowFullView();
}
}
```
### 自适应布局
```csharp
private void UpdateLayout()
{
var width = Bounds.Width;
var height = Bounds.Height;
// 根据宽高比调整布局
if (width > height * 2)
{
// 宽屏模式 - 水平排列
_layout.Orientation = Orientation.Horizontal;
}
else
{
// 正常模式 - 垂直排列
_layout.Orientation = Orientation.Vertical;
}
}
```
---
## 🔄 组件生命周期事件
```csharp
public WeatherWidget(PluginDesktopComponentContext context)
{
// 组件加载完成(此时已添加到视觉树)
Loaded += OnLoaded;
// 组件卸载(用户删除或关闭宿主)
Unloaded += OnUnloaded;
// 尺寸变化
SizeChanged += OnSizeChanged;
}
private async void OnLoaded(object? sender, RoutedEventArgs e)
{
// 加载数据
await LoadDataAsync();
// 启动定时器
_timer = new Timer(OnTimerTick, null, TimeSpan.Zero, TimeSpan.FromMinutes(5));
}
private void OnUnloaded(object? sender, RoutedEventArgs e)
{
// 清理资源
_timer?.Dispose();
_httpClient?.Dispose();
}
```
---
## 💾 组件设置持久化
组件可以保存自己的设置:
```csharp
public class WeatherWidget : Border
{
private readonly IComponentSettingsAccessor _settings;
public WeatherWidget(PluginDesktopComponentContext context)
{
// 获取设置访问器
_settings = context.Settings;
// 读取设置
var city = _settings.GetValue<string>("city", defaultValue: "北京");
var autoRefresh = _settings.GetValue<bool>("auto_refresh", defaultValue: true);
// 保存设置
_settings.SetValue("city", "上海");
}
}
```
---
## 🐛 常见问题
### 问题 1组件不显示在库中
**排查:**
1. 确认已调用 `AddPluginDesktopComponent`
2. 检查 `ComponentId` 是否唯一
3. 确认组件类是 `public`
### 问题 2组件显示异常
**排查:**
1. 检查构造函数参数是否正确(需要 `PluginDesktopComponentContext`
2. 确认没有抛出未处理异常
3. 查看日志文件
### 问题 3圆角不生效
**原因:** 插件无法访问宿主 XAML 资源
**解决:** 使用代码设置圆角(见上文)
### 问题 4尺寸不正确
**排查:**
1. 检查 `MinWidthCells``MinHeightCells` 设置
2. 确认内容没有强制尺寸
---
## 📚 参考资源
- [PluginDesktopComponentOptions 源码](../../LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs)
- [04-外观与主题系统](04-外观与主题系统.md)
- [01-开发天气组件](../04-实战案例/01-开发天气组件.md)
---
## 🎯 下一步
学习如何添加设置页面:
👉 **[03-设置系统集成](03-设置系统集成.md)** - 让用户配置你的组件
---
*最后更新2026年4月*

View File

@@ -0,0 +1,353 @@
# 03-设置系统集成
设置系统允许插件在阑山桌面的设置窗口中添加自己的配置页面,让用户可以自定义插件行为。
---
## 🎯 设置系统概述
阑山桌面提供两种设置页面模式:
| 模式 | 适用场景 | 复杂度 | 灵活性 |
|-----|---------|--------|--------|
| **声明式设置** | 简单的键值配置 | 低 | 中 |
| **自定义设置页** | 复杂交互、自定义控件 | 中 | 高 |
---
## 📝 声明式设置
通过链式 API 声明配置项,宿主自动生成设置页面。
### 基本用法
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginSettingsSection(
sectionId: "myplugin-settings", // 设置节唯一标识
displayName: "我的插件设置", // 显示名称
configure: section => // 配置设置项
{
section
.AddToggle("enabled", "启用插件", defaultValue: true)
.AddText("api_key", "API密钥", defaultValue: "")
.AddNumber("interval", "刷新间隔(秒)", defaultValue: 60, minimum: 10, maximum: 3600)
.AddSelect("theme", "主题", new[]
{
new SettingsOptionChoice("light", "浅色"),
new SettingsOptionChoice("dark", "深色"),
new SettingsOptionChoice("auto", "跟随系统")
}, defaultValue: "auto");
},
iconKey: "Settings"); // 图标
}
```
### 支持的设置类型
| 方法 | 类型 | 用途 | 示例 |
|-----|------|------|------|
| `AddToggle` | 布尔 | 开关选项 | 启用/禁用 |
| `AddText` | 字符串 | 文本输入 | API密钥、用户名 |
| `AddNumber` | 数值 | 数字输入 | 刷新间隔、数量 |
| `AddSelect` | 枚举 | 下拉选择 | 主题、语言 |
| `AddPath` | 路径 | 文件/文件夹选择 | 保存路径 |
| `AddList` | 列表 | 字符串列表 | 服务器地址列表 |
### 各类型详解
#### Toggle开关
```csharp
.AddToggle(
key: "auto_update", // 设置键
displayName: "自动更新", // 显示名称
defaultValue: true, // 默认值
description: "启动时检查更新" // 可选描述
)
```
#### Text文本
```csharp
.AddText(
key: "api_key",
displayName: "API密钥",
defaultValue: "",
placeholder: "请输入API密钥", // 占位符
isPassword: true // 密码输入(掩码显示)
)
```
#### Number数值
```csharp
.AddNumber(
key: "refresh_interval",
displayName: "刷新间隔",
defaultValue: 60,
minimum: 10, // 最小值
maximum: 3600, // 最大值
increment: 10 // 步进值
)
```
#### Select选择
```csharp
.AddSelect(
key: "display_mode",
displayName: "显示模式",
choices: new[]
{
new SettingsOptionChoice("compact", "紧凑"),
new SettingsOptionChoice("normal", "标准"),
new SettingsOptionChoice("detailed", "详细")
},
defaultValue: "normal"
)
```
#### Path路径
```csharp
.AddPath(
key: "save_location",
displayName: "保存位置",
defaultValue: "",
pathType: SettingsPathType.Folder, // Folder 或 File
dialogTitle: "选择保存文件夹"
)
```
---
## 🎨 自定义设置页
当声明式设置无法满足需求时,可以创建自定义设置页面。
### 步骤 1创建设置页类
```csharp
using LanMountainDesktop.PluginSdk;
using FluentAvalonia.UI.Controls;
namespace MyPlugin;
public class MySettingsPage : SettingsPageBase
{
private readonly IPluginSettingsService _settingsService;
public MySettingsPage(IPluginSettingsService settingsService)
{
_settingsService = settingsService;
InitializeComponent();
}
private void InitializeComponent()
{
// 创建页面内容
var panel = new StackPanel { Spacing = 16 };
// 添加自定义控件
var expander = new SettingsExpander
{
Header = "高级设置",
Description = "配置插件的高级选项"
};
var toggle = new ToggleSwitch
{
Content = "启用实验性功能"
};
expander.Items.Add(toggle);
panel.Children.Add(expander);
// 添加颜色选择器示例
var colorPicker = new ColorPicker
{
Header = "主题颜色"
};
panel.Children.Add(colorPicker);
Content = panel;
}
}
```
### 步骤 2注册自定义设置页
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginSettingsSection<MySettingsPage>(
sectionId: "myplugin-advanced",
displayName: "高级设置",
iconKey: "Settings");
}
```
### 混合模式
可以同时使用声明式设置和自定义视图:
```csharp
services.AddPluginSettingsSection(
sectionId: "myplugin-settings",
displayName: "插件设置",
configure: section => section
.SetCustomView<MyCustomSettingsPage>() // 设置自定义视图
.AddToggle("enabled", "启用") // 同时声明设置项
.AddText("api_key", "API密钥"),
iconKey: "Settings");
```
---
## 💾 读取和保存设置
### 在服务中读取设置
```csharp
public class WeatherService
{
private readonly IPluginSettingsService _settings;
public WeatherService(IPluginSettingsService settings)
{
_settings = settings;
// 读取设置
var apiKey = _settings.GetValue<string>("api_key", "");
var autoRefresh = _settings.GetValue<bool>("auto_update", true);
var interval = _settings.GetValue<int>("refresh_interval", 60);
// 监听设置变化
_settings.SettingsChanged += (sender, e) =>
{
if (e.Key == "refresh_interval")
{
UpdateTimerInterval(e.NewValue);
}
};
}
}
```
### 在组件中读取设置
```csharp
public class WeatherWidget : Border
{
public WeatherWidget(PluginDesktopComponentContext context)
{
// 通过 context 获取设置
var settings = context.Settings;
var city = settings.GetValue<string>("city", "北京");
var unit = settings.GetValue<string>("temperature_unit", "celsius");
// 监听设置变化
context.Settings.SettingsChanged += (_, e) =>
{
if (e.Key == "city")
{
RefreshWeather(e.NewValue);
}
};
}
}
```
### 保存设置
```csharp
// 在设置页面中保存
private void SaveButton_Click(object? sender, RoutedEventArgs e)
{
_settingsService.SetValue("api_key", ApiKeyTextBox.Text);
_settingsService.SetValue("auto_update", AutoUpdateToggle.IsChecked ?? false);
// 设置会自动持久化,无需手动保存文件
}
```
---
## 🔔 设置变更通知
### 订阅变更事件
```csharp
public class MyService
{
public MyService(IPluginSettingsService settings)
{
settings.SettingsChanged += OnSettingsChanged;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
Console.WriteLine($"设置变更: {e.Key}");
Console.WriteLine($"旧值: {e.OldValue}");
Console.WriteLine($"新值: {e.NewValue}");
// 根据变更的键执行相应操作
switch (e.Key)
{
case "refresh_interval":
UpdateRefreshTimer((int)e.NewValue);
break;
case "theme":
ApplyTheme((string)e.NewValue);
break;
}
}
}
```
---
## 🐛 常见问题
### 问题 1设置不保存
**排查:**
1. 确认 `sectionId` 唯一且合法
2. 检查设置键名是否正确
3. 查看日志是否有权限错误
### 问题 2设置页面不显示
**排查:**
1. 确认已调用 `AddPluginSettingsSection`
2. 检查 `sectionId` 是否唯一
3. 确认设置页类是 `public`
### 问题 3设置变更通知不触发
**原因:** 可能订阅的是不同实例
**解决:** 确保使用注入的 `IPluginSettingsService`
---
## 📚 参考资源
- [IPluginSettingsService 源码](../../LanMountainDesktop.PluginSdk/IPluginSettingsService.cs)
- [SettingsPageBase 源码](../../LanMountainDesktop.PluginSdk/SettingsPageBase.cs)
- [04-开发设置页面](../04-实战案例/04-开发设置页面.md)
---
## 🎯 下一步
学习外观系统:
👉 **[04-外观与主题系统](04-外观与主题系统.md)** - 适配宿主主题
---
*最后更新2026年4月*

View File

@@ -0,0 +1,308 @@
# 04-外观与主题系统
阑山桌面支持暗色/浅色主题切换,插件需要适配宿主的视觉风格,保持界面一致性。
---
## 🎨 主题系统概述
阑山桌面使用 Avalonia UI 的主题系统,支持:
- **浅色主题** - 明亮背景,深色文字
- **深色主题** - 深色背景,浅色文字
- **跟随系统** - 自动匹配 Windows/macOS 主题
---
## 🌗 检测当前主题
### 在组件中检测
```csharp
using Avalonia;
using Avalonia.Styling;
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 检测当前主题
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
// 根据主题设置颜色
UpdateTheme(isDark);
// 监听主题变化
if (Application.Current != null)
{
Application.Current.ActualThemeVariantChanged += (_, _) =>
{
var newIsDark = Application.Current.ActualThemeVariant == ThemeVariant.Dark;
UpdateTheme(newIsDark);
};
}
}
private void UpdateTheme(bool isDark)
{
Background = new SolidColorBrush(
isDark ? Color.Parse("#FF1E1E1E") : Color.Parse("#FFFFFFFF"));
Foreground = new SolidColorBrush(
isDark ? Colors.White : Colors.Black);
}
}
```
---
## 📐 圆角系统
插件必须使用宿主提供的圆角系统,确保与内置组件视觉一致。
### 为什么插件不能使用 XAML 资源
插件运行在独立的 `AssemblyLoadContext` 中,无法直接访问宿主的资源字典。因此 `{DynamicResource DesignCornerRadiusComponent}` 在插件 XAML 中无效。
### 使用代码设置圆角
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 方法 1使用预设推荐
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 方法 2带最小/最大值限制
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component,
minimum: new CornerRadius(8),
maximum: new CornerRadius(24));
// 方法 3自定义基础值应用全局缩放
CornerRadius = context.Appearance.ResolveScaledCornerRadius(
baseRadius: 16,
minimum: 8,
maximum: 32);
}
}
```
### 圆角预设
| 预设 | 默认值 | 用途 |
|-----|-------|------|
| `Micro` | 6px | 微小元素 |
| `Xs` | 12px | 小元素、图标容器 |
| `Sm` | 14px | 小卡片 |
| `Md` | 20px | 普通按钮/卡片 |
| `Lg` | 28px | 大面板 |
| `Xl` | 32px | 强调容器 |
| `Island` | 36px | 大型容器 |
| `Component` | 18px | **桌面组件标准** |
| `Default` | 自适应 | 根据尺寸自动计算 |
### 内部元素圆角
组件内部的卡片、按钮应使用更小的圆角:
```csharp
// 组件根容器 - 使用 Component 预设
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
// 内部卡片 - 使用 Md 预设
var innerCard = new Border
{
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Md),
Background = new SolidColorBrush(Colors.LightGray)
};
// 按钮 - 使用 Sm 预设
var button = new Button
{
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Sm)
};
```
---
## 🎨 颜色系统
### 推荐的颜色策略
```csharp
// 透明背景(推荐)- 让宿主壁纸透出
Background = new SolidColorBrush(Colors.Transparent);
// 毛玻璃效果
Background = new SolidColorBrush(Color.Parse(isDark ? "#40FFFFFF" : "#40000000"));
// 卡片背景
Background = new SolidColorBrush(Color.Parse(isDark ? "#FF2D2D2D" : "#FFFFFFFF"));
// 强调色(使用系统强调色)
var accentColor = Color.Parse("#FF0078D4"); // 阑山桌面主色调
```
### 文字颜色
```csharp
// 主要文字
Foreground = new SolidColorBrush(isDark ? Colors.White : Colors.Black);
// 次要文字
Foreground = new SolidColorBrush(isDark ? Color.Parse("#FFCCCCCC") : Color.Parse("#FF666666"));
// 禁用文字
Foreground = new SolidColorBrush(isDark ? Color.Parse("#FF666666") : Color.Parse("#FF999999"));
```
---
## 🔄 响应外观变化
### 订阅外观变化事件
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 订阅外观变化
context.Appearance.AppearanceChanged += (_, _) =>
{
UpdateAppearance();
};
// 初始化
UpdateAppearance();
}
private void UpdateAppearance()
{
// 重新应用圆角(用户可能调整了全局圆角设置)
var context = ...; // 获取 context
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
// 更新主题颜色
var isDark = Application.Current?.ActualThemeVariant == ThemeVariant.Dark;
UpdateThemeColors(isDark);
}
}
```
---
## 🧩 使用 FluentAvalonia 控件
推荐使用 FluentAvalonia 控件库,它们自动适配主题:
```xml
<Window xmlns:ui="using:FluentAvalonia.UI.Controls">
<ui:SettingsExpander Header="设置项">
<ui:SettingsExpander.IconSource>
<ui:FontIconSource Glyph="&#xE713;" />
</ui:SettingsExpander.IconSource>
</ui:SettingsExpander>
</Window>
```
### 常用 FluentAvalonia 控件
| 控件 | 用途 |
|-----|------|
| `SettingsExpander` | 设置项展开器 |
| `SettingsCard` | 设置卡片 |
| `ColorPicker` | 颜色选择器 |
| `NumberBox` | 数字输入框 |
| `ToggleSwitch` | 开关 |
---
## 💡 最佳实践
### 1. 始终使用透明背景
```csharp
// ✅ 好的做法
Background = new SolidColorBrush(Colors.Transparent);
// ❌ 避免硬编码背景色
Background = new SolidColorBrush(Colors.White);
```
### 2. 组件根容器必须使用 Component 圆角
```csharp
// ✅ 正确
CornerRadius = context.ResolveCornerRadius(PluginCornerRadiusPreset.Component);
// ❌ 错误 - 硬编码
CornerRadius = new CornerRadius(18);
```
### 3. 响应主题变化
```csharp
// ✅ 订阅变化事件
Application.Current.ActualThemeVariantChanged += OnThemeChanged;
// ❌ 只在构造函数中设置一次
```
### 4. 使用语义化颜色
```csharp
// ✅ 根据用途选择颜色
var primaryText = isDark ? Colors.White : Colors.Black;
var secondaryText = isDark ? Color.Parse("#FFCCCCCC") : Color.Parse("#FF666666");
// ❌ 避免随意使用颜色
var textColor = Color.Parse("#FF123456");
```
---
## 🐛 常见问题
### 问题 1圆角不生效
**原因:** 在 XAML 中使用 `{DynamicResource}`
**解决:** 在代码中设置圆角(见上文)
### 问题 2主题切换后颜色不对
**原因:** 没有订阅主题变化事件
**解决:** 添加 `ActualThemeVariantChanged` 事件处理
### 问题 3组件与内置组件风格不一致
**排查:**
1. 检查圆角是否使用 `PluginCornerRadiusPreset.Component`
2. 检查背景是否透明
3. 检查是否使用了 FluentAvalonia 控件
---
## 📚 参考资源
- [CORNER_RADIUS_SPEC.md](../../docs/CORNER_RADIUS_SPEC.md)
- [VISUAL_SPEC.md](../../docs/VISUAL_SPEC.md)
- [FluentAvalonia 文档](https://github.com/amwx/FluentAvalonia)
---
## 🎯 下一步
学习插件间通信:
👉 **[05-插件间通信](05-插件间通信.md)** - 与其他插件协作
---
*最后更新2026年4月*

View File

@@ -0,0 +1,375 @@
# 05-插件间通信
插件之间可以通过消息总线和服务导出进行通信,实现功能协作和数据共享。
---
## 🎯 通信方式概述
| 方式 | 适用场景 | 方向 |
|-----|---------|------|
| **消息总线** | 事件通知、广播 | 多对多 |
| **服务导出** | 功能共享、API 暴露 | 一对多 |
| **共享契约** | 数据交换 | 双向 |
---
## 📢 消息总线
使用 `IPluginMessageBus` 发布和订阅消息。
### 发布消息
```csharp
public class WeatherService
{
private readonly IPluginMessageBus _messageBus;
public WeatherService(IPluginMessageBus messageBus)
{
_messageBus = messageBus;
}
public async Task UpdateWeatherAsync()
{
var weather = await FetchWeatherAsync();
// 发布天气更新消息
_messageBus.Publish(new WeatherUpdatedMessage
{
City = weather.City,
Temperature = weather.Temperature,
Condition = weather.Condition
});
}
}
// 定义消息
public class WeatherUpdatedMessage
{
public string City { get; set; } = "";
public double Temperature { get; set; }
public string Condition { get; set; } = "";
}
```
### 订阅消息
```csharp
public class AnotherPluginService
{
public AnotherPluginService(IPluginMessageBus messageBus)
{
// 订阅天气更新消息
messageBus.Subscribe<WeatherUpdatedMessage>(OnWeatherUpdated);
}
private void OnWeatherUpdated(WeatherUpdatedMessage message)
{
Console.WriteLine($"收到天气更新: {message.City} {message.Temperature}°C");
// 根据天气更新自己的状态
UpdateDisplay(message);
}
}
```
### 取消订阅
```csharp
public class MyService : IDisposable
{
private readonly IPluginMessageBus _messageBus;
private readonly Guid _subscriptionId;
public MyService(IPluginMessageBus messageBus)
{
_messageBus = messageBus;
_subscriptionId = messageBus.Subscribe<WeatherUpdatedMessage>(OnWeatherUpdated);
}
public void Dispose()
{
// 取消订阅,避免内存泄漏
_messageBus.Unsubscribe<WeatherUpdatedMessage>(_subscriptionId);
}
}
```
---
## 🔌 服务导出
插件可以将服务导出,供其他插件使用。
### 导出服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
// 导出服务供其他插件使用
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
description: "提供天气查询服务");
}
// 定义服务接口
public interface IWeatherService
{
Task<WeatherInfo> GetCurrentWeatherAsync(string city);
Task<List<WeatherForecast>> GetForecastAsync(string city, int days);
}
// 实现服务
public class WeatherService : IWeatherService
{
public async Task<WeatherInfo> GetCurrentWeatherAsync(string city)
{
// 实现天气查询
}
public async Task<List<WeatherForecast>> GetForecastAsync(string city, int days)
{
// 实现天气预报
}
}
```
### 使用其他插件的服务
```csharp
public class MyWidget : Border
{
public MyWidget(PluginDesktopComponentContext context)
{
// 获取其他插件导出的服务
var weatherService = context.ServiceProvider
.GetExportedService<IWeatherService>("MyPlugin.WeatherService");
if (weatherService != null)
{
// 使用服务
LoadWeatherAsync(weatherService);
}
}
private async void LoadWeatherAsync(IWeatherService weatherService)
{
var weather = await weatherService.GetCurrentWeatherAsync("北京");
UpdateUI(weather);
}
}
```
### 服务导出选项
```csharp
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
description: "提供天气查询服务",
version: "1.0.0",
isPublic: true); // 是否公开给其他插件
```
---
## 📦 共享契约
通过 `sharedContracts` 在插件间共享类型定义。
### 定义共享契约
```csharp
// 在共享类库项目中定义
namespace MyPlugin.Shared;
public interface IWeatherData
{
string City { get; }
double Temperature { get; }
string Condition { get; }
}
public class WeatherData : IWeatherData
{
public string City { get; set; } = "";
public double Temperature { get; set; }
public string Condition { get; set; } = "";
}
```
### 在 plugin.json 中声明
```json
{
"id": "com.example.weather",
"name": "天气插件",
"sharedContracts": [
"MyPlugin.Shared.IWeatherData",
"MyPlugin.Shared.WeatherData"
]
}
```
### 使用共享类型
```csharp
// 插件 A 发布数据
public class WeatherService
{
public WeatherData GetWeather()
{
return new WeatherData
{
City = "北京",
Temperature = 25.5,
Condition = "晴"
};
}
}
// 插件 B 接收数据
public class ConsumerService
{
public void ProcessWeather(IWeatherData weather)
{
Console.WriteLine($"{weather.City}: {weather.Temperature}°C");
}
}
```
---
## 🔒 安全考虑
### 服务导出安全
```csharp
// 只导出必要的接口,不暴露实现细节
public interface IPublicApi
{
Task<Data> GetDataAsync();
}
internal class InternalService : IPublicApi
{
// 内部实现细节不暴露
private readonly SecretKey _key;
public async Task<Data> GetDataAsync()
{
// 实现
}
}
```
### 消息验证
```csharp
private void OnMessageReceived(MyMessage message)
{
// 验证消息来源
if (message.SenderId != "TrustedPlugin")
{
return; // 忽略不信任来源的消息
}
// 处理消息
}
```
---
## 💡 最佳实践
### 1. 使用接口定义服务契约
```csharp
// ✅ 好的做法 - 定义接口
public interface IWeatherService { }
// ❌ 避免 - 直接导出实现类
services.AddPluginServiceExport<WeatherService>(...);
```
### 2. 处理服务不可用情况
```csharp
// ✅ 优雅处理服务缺失
var service = context.ServiceProvider
.GetExportedService<IWeatherService>("key");
if (service == null)
{
// 显示提示或降级处理
ShowServiceUnavailableMessage();
return;
}
```
### 3. 及时取消消息订阅
```csharp
// ✅ 在 Dispose 中取消订阅
public void Dispose()
{
_messageBus.Unsubscribe<MyMessage>(_subscriptionId);
}
```
### 4. 版本兼容性
```csharp
// 在服务导出中包含版本信息
services.AddPluginServiceExport<IWeatherService>(
serviceKey: "MyPlugin.WeatherService",
version: "2.0.0", // 语义化版本
description: "天气服务 v2");
```
---
## 🐛 常见问题
### 问题 1消息收不到
**排查:**
1. 确认消息类型完全一致(包括命名空间)
2. 检查订阅是否在消息发布之前
3. 确认没有取消订阅
### 问题 2服务找不到
**排查:**
1. 确认服务已导出(`AddPluginServiceExport`
2. 检查 `serviceKey` 是否正确
3. 确认依赖的插件已安装并启用
### 问题 3类型转换错误
**原因:** 共享契约类型不匹配
**解决:** 确保所有插件使用相同版本的共享契约程序集
---
## 📚 参考资源
- [IPluginMessageBus 源码](../../LanMountainDesktop.PluginSdk/IPluginMessageBus.cs)
- [IPluginExportRegistry 源码](../../LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs)
- [Shared.Contracts](../../LanMountainDesktop.Shared.Contracts/)
---
## 🎯 下一步
查看实战案例:
👉 **[01-开发天气组件](../04-实战案例/01-开发天气组件.md)** - 完整插件开发流程
---
*最后更新2026年4月*

View File

@@ -0,0 +1,307 @@
# 01-PluginBase详解
`PluginBase` 是插件的基类,提供基础功能和生命周期管理。本文详细讲解其用法和扩展点。
---
## 🎯 PluginBase 概述
```csharp
public abstract class PluginBase : IPlugin
{
// 日志记录器
protected ILogger? Logger { get; }
// 初始化方法(必须实现)
public abstract void Initialize(HostBuilderContext context, IServiceCollection services);
}
```
---
## 📝 基本用法
### 最小实现
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace MyPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 插件初始化逻辑
}
}
```
---
## 🔧 Initialize 方法详解
### 方法签名
```csharp
public abstract void Initialize(
HostBuilderContext context, // 宿主构建上下文
IServiceCollection services // 服务注册集合
);
```
### 参数说明
| 参数 | 类型 | 用途 |
|-----|------|------|
| `context` | `HostBuilderContext` | 访问宿主配置、环境信息 |
| `services` | `IServiceCollection` | 注册服务、组件、设置页面 |
### context 使用示例
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 访问配置
var configValue = context.Configuration["MySetting"];
// 判断运行环境
var isDevelopment = context.HostingEnvironment.IsDevelopment();
// 获取应用名称
var appName = context.HostingEnvironment.ApplicationName;
}
```
---
## 📝 日志记录
### 使用 Logger 属性
```csharp
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 记录日志
Logger?.LogInformation("插件初始化开始");
try
{
// 初始化逻辑
services.AddSingleton<IMyService, MyService>();
Logger?.LogInformation("插件初始化完成");
}
catch (Exception ex)
{
Logger?.LogError(ex, "插件初始化失败");
throw;
}
}
}
```
### 日志级别
```csharp
Logger?.LogTrace("详细跟踪信息");
Logger?.LogDebug("调试信息");
Logger?.LogInformation("一般信息");
Logger?.LogWarning("警告信息");
Logger?.LogError("错误信息");
Logger?.LogCritical("严重错误");
```
---
## 🔌 服务注册
### 注册单例服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 单例 - 整个应用生命周期只有一个实例
services.AddSingleton<IWeatherService, WeatherService>();
}
```
### 注册作用域服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 作用域 - 每个作用域一个实例
services.AddScoped<IDataContext, DataContext>();
}
```
### 注册瞬态服务
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 瞬态 - 每次请求都创建新实例
services.AddTransient<IValidator, Validator>();
}
```
### 带配置的服务注册
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddSingleton<IWeatherService>(provider =>
{
var httpClient = provider.GetRequiredService<HttpClient>();
var logger = provider.GetRequiredService<ILogger<WeatherService>>();
var apiKey = context.Configuration["WeatherApiKey"];
return new WeatherService(httpClient, logger, apiKey);
});
}
```
---
## 🧩 完整示例
```csharp
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace WeatherPlugin;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Logger?.LogInformation("天气插件初始化开始");
try
{
// 1. 注册 HTTP 客户端
services.AddHttpClient("weather", client =>
{
client.BaseAddress = new Uri("https://api.weather.com/");
client.Timeout = TimeSpan.FromSeconds(30);
});
// 2. 注册服务
services.AddSingleton<IWeatherService, WeatherService>();
services.AddSingleton<ILocationService, LocationService>();
// 3. 注册桌面组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "WeatherPlugin.Widget",
DisplayName = "天气",
IconKey = "Weather",
Category = "信息",
MinWidthCells = 4,
MinHeightCells = 3
});
// 4. 注册设置页面
services.AddPluginSettingsSection(
"weather-settings",
"天气设置",
section => section
.AddText("api_key", "API密钥", isPassword: true)
.AddText("default_city", "默认城市", defaultValue: "北京")
.AddToggle("auto_refresh", "自动刷新", defaultValue: true)
.AddNumber("refresh_interval", "刷新间隔(分钟)",
defaultValue: 30, minimum: 5, maximum: 120),
iconKey: "Settings");
Logger?.LogInformation("天气插件初始化完成");
}
catch (Exception ex)
{
Logger?.LogError(ex, "天气插件初始化失败");
throw;
}
}
}
```
---
## 💡 最佳实践
### 1. 使用 try-catch 包装初始化逻辑
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
try
{
// 初始化逻辑
}
catch (Exception ex)
{
Logger?.LogError(ex, "初始化失败");
throw; // 重新抛出,让宿主知道初始化失败
}
}
```
### 2. 按依赖顺序注册服务
```csharp
// ✅ 先注册被依赖的服务
services.AddSingleton<IDataService, DataService>();
// 再注册依赖它们的服务
services.AddSingleton<IWeatherService, WeatherService>(); // 依赖 IDataService
// 最后注册组件
services.AddPluginDesktopComponent<WeatherWidget>(options);
```
### 3. 记录初始化过程
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Logger?.LogInformation("开始初始化...");
Logger?.LogDebug("注册服务...");
services.AddSingleton<IMyService, MyService>();
Logger?.LogDebug("注册组件...");
services.AddPluginDesktopComponent<MyWidget>(options);
Logger?.LogInformation("初始化完成");
}
```
---
## 📚 参考资源
- [PluginBase 源码](../../LanMountainDesktop.PluginSdk/PluginBase.cs)
- [IPlugin 接口](../../LanMountainDesktop.PluginSdk/IPlugin.cs)
- [Microsoft.Extensions.DependencyInjection 文档](https://docs.microsoft.com/dotnet/api/microsoft.extensions.dependencyinjection)
---
## 🎯 下一步
学习组件注册 API
👉 **[02-组件注册与配置](02-组件注册与配置.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,182 @@
# 02-组件注册与配置
`AddPluginDesktopComponent` 是注册桌面组件的核心 API。本文详细讲解其用法和配置选项。
---
## 🎯 API 概览
```csharp
public static IServiceCollection AddPluginDesktopComponent<TComponent>(
this IServiceCollection services,
PluginDesktopComponentOptions options)
where TComponent : class, IControl
```
---
## 📋 基本用法
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.MyWidget",
DisplayName = "我的组件",
IconKey = "Home",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3
});
}
```
---
## 🔧 PluginDesktopComponentOptions
### 完整属性列表
| 属性 | 类型 | 必需 | 说明 |
|-----|------|------|------|
| `ComponentId` | `string` | ✅ | 唯一标识符 |
| `DisplayName` | `string` | ✅ | 显示名称 |
| `IconKey` | `string` | ✅ | 图标键名 |
| `Category` | `string` | ✅ | 分类 |
| `MinWidthCells` | `int` | ✅ | 最小宽度(格) |
| `MinHeightCells` | `int` | ✅ | 最小高度(格) |
| `CornerRadiusPreset` | `PluginCornerRadiusPreset` | ❌ | 圆角预设 |
| `ResizeMode` | `PluginDesktopComponentResizeMode` | ❌ | 调整大小模式 |
### ComponentId
```csharp
ComponentId = "MyPlugin.WeatherWidget"
```
- 必须唯一
- 建议使用 `插件ID.组件名` 格式
- 一经发布不可更改
### DisplayName
```csharp
DisplayName = "天气"
```
- 显示在组件库中
- 支持本地化(通过资源文件)
### IconKey
```csharp
IconKey = "Weather"
```
使用 [Fluent UI System Icons](https://github.com/microsoft/fluentui-system-icons) 的图标名。
### Category
```csharp
Category = "工具"
```
常用分类:
- `工具` - 实用工具
- `信息` - 信息展示
- `娱乐` - 娱乐相关
- `系统` - 系统监控
### MinWidthCells / MinHeightCells
```csharp
MinWidthCells = 4, // 4格宽约240像素
MinHeightCells = 3 // 3格高约180像素
```
---
## 🎨 圆角配置
```csharp
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "MyPlugin.Widget",
DisplayName = "我的组件",
IconKey = "Home",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 3,
CornerRadiusPreset = PluginCornerRadiusPreset.Component
});
```
---
## 📐 调整大小模式
```csharp
services.AddPluginDesktopComponent<MyWidget>(
new PluginDesktopComponentOptions
{
// ...
ResizeMode = PluginDesktopComponentResizeMode.Free
});
```
| 模式 | 说明 |
|-----|------|
| `Free` | 自由调整大小 |
| `Fixed` | 固定大小 |
| `AspectRatio` | 保持宽高比 |
---
## 🧩 完整示例
```csharp
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
// 天气组件
services.AddPluginDesktopComponent<WeatherWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "WeatherPlugin.Widget",
DisplayName = "天气",
IconKey = "Weather",
Category = "信息",
MinWidthCells = 4,
MinHeightCells = 3,
CornerRadiusPreset = PluginCornerRadiusPreset.Component,
ResizeMode = PluginDesktopComponentResizeMode.Free
});
// 时钟组件
services.AddPluginDesktopComponent<ClockWidget>(
new PluginDesktopComponentOptions
{
ComponentId = "ClockPlugin.Widget",
DisplayName = "时钟",
IconKey = "Clock",
Category = "工具",
MinWidthCells = 4,
MinHeightCells = 4,
CornerRadiusPreset = PluginCornerRadiusPreset.Component,
ResizeMode = PluginDesktopComponentResizeMode.AspectRatio
});
}
```
---
## 📚 参考资源
- [PluginDesktopComponentOptions 源码](../../LanMountainDesktop.PluginSdk/PluginDesktopComponentOptions.cs)
- [02-桌面组件系统](../02-核心概念与原理/02-桌面组件系统.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,147 @@
# 03-设置API详解
设置 API 允许插件添加配置页面和持久化用户设置。
---
## 🎯 API 概览
### 声明式设置
```csharp
services.AddPluginSettingsSection(
string sectionId,
string displayName,
Action<PluginSettingsSectionBuilder> configure,
string iconKey);
```
### 自定义设置页
```csharp
services.AddPluginSettingsSection<TPage>(
string sectionId,
string displayName,
string iconKey)
where TPage : SettingsPageBase;
```
---
## 📋 声明式设置详解
### 基本用法
```csharp
services.AddPluginSettingsSection(
"myplugin-settings",
"我的设置",
section => section
.AddToggle("enabled", "启用", defaultValue: true)
.AddText("name", "名称", defaultValue: ""),
iconKey: "Settings");
```
### 设置类型
#### Toggle开关
```csharp
.AddToggle(
key: "auto_update",
displayName: "自动更新",
defaultValue: true,
description: "启动时检查更新")
```
#### Text文本
```csharp
.AddText(
key: "api_key",
displayName: "API密钥",
defaultValue: "",
placeholder: "请输入",
isPassword: false)
```
#### Number数值
```csharp
.AddNumber(
key: "interval",
displayName: "刷新间隔",
defaultValue: 60,
minimum: 10,
maximum: 3600,
increment: 10)
```
#### Select选择
```csharp
.AddSelect(
key: "theme",
displayName: "主题",
choices: new[]
{
new SettingsOptionChoice("light", "浅色"),
new SettingsOptionChoice("dark", "深色")
},
defaultValue: "light")
```
#### Path路径
```csharp
.AddPath(
key: "save_path",
displayName: "保存路径",
defaultValue: "",
pathType: SettingsPathType.Folder,
dialogTitle: "选择文件夹")
```
---
## 🔧 读取和保存设置
### 使用 IPluginSettingsService
```csharp
public class MyService
{
private readonly IPluginSettingsService _settings;
public MyService(IPluginSettingsService settings)
{
_settings = settings;
// 读取
var value = _settings.GetValue<string>("key", "default");
// 保存
_settings.SetValue("key", "new value");
// 监听变化
_settings.SettingsChanged += (s, e) =>
{
if (e.Key == "key")
{
HandleChange(e.NewValue);
}
};
}
}
```
---
## 📚 参考资源
- [IPluginSettingsService 源码](../../LanMountainDesktop.PluginSdk/IPluginSettingsService.cs)
- [03-设置系统集成](../02-核心概念与原理/03-设置系统集成.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,106 @@
# 04-外观API详解
外观 API 提供圆角、主题等视觉相关的功能。
---
## 🎯 IPluginAppearanceContext
```csharp
public interface IPluginAppearanceContext
{
// 获取圆角值
CornerRadius ResolveCornerRadius(PluginCornerRadiusPreset preset);
// 获取带限制的圆角值
CornerRadius ResolveCornerRadius(
PluginCornerRadiusPreset preset,
CornerRadius? minimum,
CornerRadius? maximum);
// 获取缩放后的圆角值
CornerRadius ResolveScaledCornerRadius(
double baseRadius,
double? minimum,
double? maximum);
// 外观变化事件
event EventHandler? AppearanceChanged;
}
```
---
## 📐 圆角 API
### 获取圆角值
```csharp
public MyWidget(PluginDesktopComponentContext context)
{
// 使用预设
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
}
```
### 带限制的圆角
```csharp
var radius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component,
minimum: new CornerRadius(8),
maximum: new CornerRadius(24));
```
### 缩放圆角
```csharp
var radius = context.Appearance.ResolveScaledCornerRadius(
baseRadius: 16,
minimum: 8,
maximum: 32);
```
---
## 🎨 圆角预设
| 预设 | 值 | 用途 |
|-----|---|------|
| Micro | 6px | 微小元素 |
| Xs | 12px | 小元素 |
| Sm | 14px | 小卡片 |
| Md | 20px | 普通按钮 |
| Lg | 28px | 大面板 |
| Xl | 32px | 强调容器 |
| Island | 36px | 大型容器 |
| Component | 18px | 桌面组件 |
| Default | 自适应 | 自动计算 |
---
## 🔄 响应外观变化
```csharp
public MyWidget(PluginDesktopComponentContext context)
{
context.Appearance.AppearanceChanged += (_, _) =>
{
// 重新应用圆角
CornerRadius = context.Appearance.ResolveCornerRadius(
PluginCornerRadiusPreset.Component);
};
}
```
---
## 📚 参考资源
- [IPluginAppearanceContext 源码](../../LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs)
- [04-外观与主题系统](../02-核心概念与原理/04-外观与主题系统.md)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,73 @@
# 05-本地化支持
本地化 API 支持多语言资源管理。
---
## 🎯 资源文件
### 文件位置
```
Localization/
├── zh-CN.json # 简体中文
├── en-US.json # 英文
├── ja-JP.json # 日文
└── ko-KR.json # 韩文
```
### 资源格式
```json
{
"PluginName": "我的插件",
"Settings": {
"Title": "设置",
"Save": "保存"
},
"Messages": {
"Hello": "你好,{0}!",
"Error": "错误:{0}"
}
}
```
---
## 📝 使用本地化
### 注入 IStringLocalizer
```csharp
public class MyService
{
private readonly IStringLocalizer<MyService> _localizer;
public MyService(IStringLocalizer<MyService> localizer)
{
_localizer = localizer;
}
public void DoWork()
{
// 简单字符串
var name = _localizer["PluginName"];
// 带参数
var message = _localizer["Messages.Hello", "用户"];
// 嵌套键
var title = _localizer["Settings.Title"];
}
}
```
---
## 📚 参考资源
- [Microsoft.Extensions.Localization 文档](https://docs.microsoft.com/dotnet/api/microsoft.extensions.localization)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,215 @@
# 01-GitHub Actions入门
GitHub Actions 是自动化构建、测试和发布插件的强大工具。本文介绍如何为插件项目配置 CI/CD 流程。
---
## 🎯 什么是 GitHub Actions
GitHub Actions 是 GitHub 提供的持续集成/持续部署CI/CD服务可以
- ✅ 自动构建插件
- ✅ 运行单元测试
- ✅ 打包 .laapp 文件
- ✅ 自动发布到 GitHub Releases
---
## 📁 工作流文件位置
```
.github/workflows/
├── build.yml # 构建工作流
├── release.yml # 发布工作流
└── code-quality.yml # 代码质量检查
```
---
## 🚀 基础工作流示例
### 最简单的构建工作流
```yaml
# .github/workflows/build.yml
name: Build Plugin
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
build:
runs-on: windows-latest
steps:
# 1. 检出代码
- name: Checkout
uses: actions/checkout@v4
# 2. 设置 .NET
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# 3. 还原依赖
- name: Restore
run: dotnet restore
# 4. 构建
- name: Build
run: dotnet build --configuration Release --no-restore
# 5. 上传构建产物
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-package
path: bin/Release/net10.0/*.laapp
```
---
## 📋 工作流详解
### 触发条件on
```yaml
on:
# 推送到指定分支时触发
push:
branches: [main, master, develop]
# 创建 Pull Request 时触发
pull_request:
branches: [main, master]
# 手动触发
workflow_dispatch:
# 定时触发每天凌晨2点
schedule:
- cron: '0 2 * * *'
# 创建标签时触发(用于发布)
push:
tags:
- 'v*'
```
### 运行环境runs-on
```yaml
jobs:
build:
runs-on: windows-latest # Windows 环境
# 或
runs-on: ubuntu-latest # Linux 环境
# 或
runs-on: macos-latest # macOS 环境
```
### 矩阵构建(多平台)
```yaml
jobs:
build:
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
dotnet: ['10.0.x']
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ matrix.dotnet }}
```
---
## 🔧 常用 Actions
### 检出代码
```yaml
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 获取完整历史(用于生成版本号)
```
### 设置 .NET
```yaml
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
```
### 上传产物
```yaml
- uses: actions/upload-artifact@v4
with:
name: plugin-package
path: bin/Release/net10.0/*.laapp
retention-days: 30 # 保留30天
```
### 下载产物
```yaml
- uses: actions/download-artifact@v4
with:
name: plugin-package
path: ./artifacts
```
---
## 💡 最佳实践
### 1. 缓存依赖
```yaml
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
```
### 2. 使用语义化版本
```yaml
- name: Get Version
id: version
run: |
VERSION=$(echo ${GITHUB_REF#refs/tags/} | sed 's/^v//')
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
```
### 3. 条件执行
```yaml
- name: Deploy
if: github.ref == 'refs/heads/main' # 只在 main 分支执行
run: echo "Deploying..."
```
---
## 🎯 下一步
学习自动打包配置:
👉 **[02-配置自动构建](02-配置自动构建.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,130 @@
# 02-配置自动构建
配置 GitHub Actions 自动构建插件项目。
---
## 🎯 完整构建工作流
```yaml
# .github/workflows/build.yml
name: Build
on:
push:
branches: [main, master]
paths-ignore:
- '**.md'
- '.gitignore'
pull_request:
branches: [main, master]
env:
DOTNET_VERSION: '10.0.x'
CONFIGURATION: 'Release'
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Test
run: dotnet test --no-build --verbosity normal
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: plugin-${{ github.run_number }}
path: bin/${{ env.CONFIGURATION }}/net10.0/*.laapp
```
---
## 🔧 关键配置说明
### 路径过滤
```yaml
on:
push:
paths-ignore:
- '**.md' # 忽略文档修改
- '.gitignore' # 忽略 gitignore 修改
- 'docs/**' # 忽略 docs 文件夹
```
### 环境变量
```yaml
env:
DOTNET_VERSION: '10.0.x'
CONFIGURATION: 'Release'
PLUGIN_NAME: 'MyPlugin'
```
### 构建步骤
```yaml
steps:
# 1. 检出
- uses: actions/checkout@v4
# 2. 设置 .NET
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# 3. 缓存
- uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ hashFiles('**/*.csproj') }}
# 4. 还原
- run: dotnet restore
# 5. 构建
- run: dotnet build -c ${{ env.CONFIGURATION }} --no-restore
# 6. 测试
- run: dotnet test --no-build
# 7. 上传
- uses: actions/upload-artifact@v4
with:
name: plugin
path: bin/Release/net10.0/*.laapp
```
---
## 📚 参考资源
- [GitHub Actions 文档](https://docs.github.com/actions)
- [.NET CI/CD 指南](https://docs.microsoft.com/dotnet/devops/github-actions-overview)
---
*最后更新2026年4月*

View File

@@ -0,0 +1,156 @@
# 03-自动打包与发布
配置 GitHub Actions 自动打包 .laapp 并发布到 GitHub Releases。
---
## 🎯 发布工作流
```yaml
# .github/workflows/release.yml
name: Release
on:
push:
tags:
- 'v*'
env:
DOTNET_VERSION: '10.0.x'
jobs:
build-and-release:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Get Version
id: version
run: |
$version = $env:GITHUB_REF -replace 'refs/tags/v', ''
echo "VERSION=$version" >> $env:GITHUB_OUTPUT
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Package
run: |
$version = "${{ steps.version.outputs.VERSION }}"
Rename-Item -Path "bin/Release/net10.0/MyPlugin.laapp" -NewName "MyPlugin-$version.laapp"
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: bin/Release/net10.0/*.laapp
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
---
## 📋 发布流程
### 1. 创建标签
```bash
# 创建版本标签
git tag -a v1.0.0 -m "Release version 1.0.0"
# 推送标签到 GitHub
git push origin v1.0.0
```
### 2. 自动触发
推送标签后GitHub Actions 会自动:
1. 检出代码
2. 构建项目
3. 打包 .laapp
4. 创建 Release
5. 上传产物
### 3. 查看 Release
在 GitHub 仓库页面 → Releases 查看自动创建的发布。
---
## 🔧 高级配置
### 预发布版本
```yaml
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: bin/Release/net10.0/*.laapp
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
```
### 生成变更日志
```yaml
- name: Generate Changelog
id: changelog
uses: mikepenz/release-changelog-builder-action@v4
with:
configuration: .github/changelog-config.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create Release
uses: softprops/action-gh-release@v1
with:
body: ${{ steps.changelog.outputs.changelog }}
files: bin/Release/net10.0/*.laapp
```
---
## 💡 最佳实践
### 版本号管理
```yaml
- name: Update Version
run: |
$version = "${{ github.ref_name }}" -replace '^v', ''
$json = Get-Content plugin.json | ConvertFrom-Json
$json.version = $version
$json | ConvertTo-Json | Set-Content plugin.json
```
### 多文件发布
```yaml
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
bin/Release/net10.0/*.laapp
README.md
LICENSE
```
---
## 🎯 下一步
学习多平台构建:
👉 **[04-多平台构建策略](04-多平台构建策略.md)**
---
*最后更新2026年4月*

View File

@@ -0,0 +1,92 @@
# 01-插件打包规范
了解 .laapp 包的规范和结构,确保插件能正确安装和运行。
---
## 📦 .laapp 文件格式
`.laapp` 是阑山桌面的插件包格式,本质上是一个 **ZIP 压缩包**
### 文件结构
```
MyPlugin.laapp
├── plugin.json # 插件清单(必需)
├── MyPlugin.dll # 主程序集(必需)
├── Localization/ # 本地化文件夹
│ ├── zh-CN.json
│ └── en-US.json
└── *.dll # 依赖项
```
---
## 📋 plugin.json 规范
### 必需字段
```json
{
"id": "com.example.myplugin",
"name": "我的插件",
"description": "插件描述",
"author": "作者名",
"version": "1.0.0",
"apiVersion": "4.0.1",
"entranceAssembly": "MyPlugin.dll",
"sharedContracts": []
}
```
### 字段验证规则
| 字段 | 规则 |
|-----|------|
| `id` | 小写字母、数字、点号,反向域名格式 |
| `version` | 语义化版本x.y.z |
| `apiVersion` | 必须与 SDK 版本兼容 |
| `entranceAssembly` | 必须与 DLL 文件名一致 |
---
## 🔨 构建配置
### .csproj 关键配置
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.1" />
</ItemGroup>
<!-- 确保资源文件复制到输出目录 -->
<ItemGroup>
<None Update="plugin.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Localization\**\*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
```
---
## ✅ 打包检查清单
- [ ] `plugin.json` 格式正确
- [ ] `id` 全局唯一
- [ ] `apiVersion` 与 SDK 版本匹配
- [ ] DLL 文件名与 `entranceAssembly` 一致
- [ ] 所有依赖项已包含
- [ ] 本地化文件完整
---
*最后更新2026年4月*

View File

@@ -0,0 +1,75 @@
# 02-版本管理策略
合理的版本管理是插件维护的基础。
---
## 🎯 语义化版本SemVer
版本格式:`主版本.次版本.修订号`
| 版本变化 | 说明 | 示例 |
|---------|------|------|
| 主版本Major | 破坏性变更 | 1.0.0 → 2.0.0 |
| 次版本Minor | 新功能,向后兼容 | 1.0.0 → 1.1.0 |
| 修订号Patch | Bug 修复 | 1.0.0 → 1.0.1 |
---
## 📋 版本示例
| 版本 | 含义 |
|-----|------|
| `1.0.0` | 首个正式版 |
| `1.1.0` | 新增功能 |
| `1.1.1` | 修复 Bug |
| `2.0.0-beta` | 2.0 测试版 |
| `2.0.0-rc1` | 2.0 候选版 |
---
## 🔄 版本更新流程
### 1. 更新版本号
```json
// plugin.json
{
"version": "1.1.0"
}
```
### 2. 更新 CHANGELOG.md
```markdown
## [1.1.0] - 2024-04-13
### 新增
- 添加天气预警功能
- 支持多城市管理
### 修复
- 修复定位失败问题
```
### 3. 创建 Git 标签
```bash
git add .
git commit -m "Release v1.1.0"
git tag -a v1.1.0 -m "Release version 1.1.0"
git push origin main --tags
```
---
## 💡 最佳实践
- 使用 GitHub Releases 管理版本
- 每个版本都写更新日志
- 测试版使用 `-beta``-alpha` 后缀
- 保持向后兼容,避免频繁主版本升级
---
*最后更新2026年4月*

View File

@@ -0,0 +1,71 @@
# 03-发布到插件市场
将插件发布到阑山桌面插件市场,让更多用户使用。
---
## 🎯 发布流程
### 1. 准备材料
- 插件包(.laapp
- 插件图标256x256 PNG
- 截图(至少 1 张)
- 详细描述
- 更新日志
### 2. 提交审核
1. 访问阑山桌面开发者门户
2. 登录开发者账号
3. 点击「提交新插件」
4. 填写插件信息
5. 上传 .laapp 文件
6. 提交审核
### 3. 审核标准
| 检查项 | 要求 |
|-------|------|
| 功能完整性 | 插件能正常运行 |
| 安全性 | 无恶意代码 |
| 用户体验 | 界面美观,操作流畅 |
| 文档完整 | 有基本使用说明 |
---
## 📋 元数据要求
### 必需信息
```json
{
"id": "com.example.plugin",
"name": "插件名称",
"description": "简短描述50字以内",
"author": "作者名",
"version": "1.0.0",
"tags": ["工具", "天气"],
"website": "https://github.com/..."
}
```
### 图标规范
- 格式PNG
- 尺寸256x256 像素
- 背景:透明
- 风格:与阑山桌面一致
---
## 🚀 发布后
- 关注用户反馈
- 及时修复 Bug
- 定期更新功能
- 维护更新日志
---
*最后更新2026年4月*

View File

@@ -0,0 +1,82 @@
# 04-更新与维护
插件发布后的持续维护和更新策略。
---
## 🔄 更新策略
### 热修复Hotfix
发现严重 Bug 时立即发布:
```bash
# 1. 修复 Bug
git checkout -b hotfix/critical-bug
# ... 修复代码 ...
# 2. 更新版本号(修订号+1
# plugin.json: "version": "1.0.1"
# 3. 提交并发布
git commit -m "Fix critical bug"
git tag -a v1.0.1 -m "Hotfix v1.0.1"
git push origin main --tags
```
### 功能更新
```bash
# 1. 开发新功能
git checkout -b feature/new-feature
# 2. 完成功能后合并到 main
# 3. 更新版本号(次版本+1
# plugin.json: "version": "1.1.0"
# 4. 发布
git tag -a v1.1.0 -m "Release v1.1.0"
git push origin main --tags
```
---
## 📊 维护清单
### 日常维护
- [ ] 监控用户反馈
- [ ] 查看崩溃报告
- [ ] 回复用户问题
- [ ] 更新依赖包
### 定期维护
- [ ] 每季度检查 SDK 更新
- [ ] 每年评估功能需求
- [ ] 定期更新文档
---
## 🐛 Bug 处理流程
1. **收集信息** - 复现步骤、环境信息
2. **定位问题** - 本地调试复现
3. **修复验证** - 修复后充分测试
4. **发布更新** - 按热修复流程发布
5. **通知用户** - 在 Release 中说明
---
## 💡 最佳实践
- 保持向后兼容
- 及时响应用户反馈
- 定期发布小更新
- 维护清晰的更新日志
- 废弃功能提前通知
---
*最后更新2026年4月*