mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
11 Commits
692ca3de3d
...
v0.8.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e32ee6cb | ||
|
|
c2cc62b58b | ||
|
|
9c529f2992 | ||
|
|
1e9ead8bee | ||
|
|
5f7b3a1e7d | ||
|
|
b12dd68ba7 | ||
|
|
1b22e9df4a | ||
|
|
ce5acf5bd7 | ||
|
|
b933f3badf | ||
|
|
76d13ac024 | ||
|
|
99a82d64e3 |
13
.github/workflows/release.yml
vendored
13
.github/workflows/release.yml
vendored
@@ -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.
|
||||
|
||||
|
||||
24
.trae/specs/window-slide-transition/checklist.md
Normal file
24
.trae/specs/window-slide-transition/checklist.md
Normal 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 无编译错误
|
||||
|
||||
138
.trae/specs/window-slide-transition/spec.md
Normal file
138
.trae/specs/window-slide-transition/spec.md
Normal 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
|
||||
|
||||
无移除的需求。
|
||||
52
.trae/specs/window-slide-transition/tasks.md
Normal file
52
.trae/specs/window-slide-transition/tasks.md
Normal 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]
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -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)
|
||||
|
||||
4
LanMountainDesktop.PluginSdk/AssemblyInfo.cs
Normal file
4
LanMountainDesktop.PluginSdk/AssemblyInfo.cs
Normal 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")]
|
||||
@@ -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" />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ public enum SettingsPageCategory
|
||||
PluginCatalog = 35,
|
||||
[Obsolete("Use PluginCatalog instead.")]
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
About = 40,
|
||||
Dev = 50
|
||||
}
|
||||
|
||||
@@ -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__"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
372
LanMountainDesktop.PluginUpgradeHelper/Program.cs
Normal file
372
LanMountainDesktop.PluginUpgradeHelper/Program.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
160
LanMountainDesktop/Services/PendingPluginUpgradeService.cs
Normal file
160
LanMountainDesktop/Services/PendingPluginUpgradeService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 网络类型图标 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal file
109
LanMountainDesktop/Views/SettingsPages/DevSettingsPage.axaml
Normal 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 <path> 或 -dp <path>"
|
||||
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=<path>"
|
||||
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>
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal file
136
LanMountainDesktop/plugins/DevPluginOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
118
docs/Plugins develop/00-索引与导航.md
Normal file
118
docs/Plugins develop/00-索引与导航.md
Normal 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月*
|
||||
220
docs/Plugins develop/01-快速开始/01-开发环境准备.md
Normal file
220
docs/Plugins develop/01-快速开始/01-开发环境准备.md
Normal 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 进行开发:
|
||||
|
||||
### 选项 1:Visual Studio 2022(推荐 Windows 用户)
|
||||
|
||||
**优点:** 功能最全,调试体验最佳
|
||||
|
||||
1. 下载 [Visual Studio 2022](https://visualstudio.microsoft.com/vs/)
|
||||
2. 安装时选择以下工作负载:
|
||||
- ✅ **.NET 桌面开发**
|
||||
- ✅ **Avalonia UI 开发**(可选,如需 Avalonia 设计器)
|
||||
|
||||
### 选项 2:JetBrains Rider(跨平台推荐)
|
||||
|
||||
**优点:** 跨平台,智能提示强大,Avalonia 支持好
|
||||
|
||||
1. 下载 [Rider](https://www.jetbrains.com/rider/)
|
||||
2. 安装后打开,会自动检测 .NET SDK
|
||||
|
||||
### 选项 3:Visual 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 | 应用正常启动 |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### 问题 1:dotnet 命令找不到
|
||||
|
||||
**现象:** 运行 `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
|
||||
```
|
||||
|
||||
### 问题 3:SDK 版本不匹配
|
||||
|
||||
**现象:** 构建时提示 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月*
|
||||
236
docs/Plugins develop/01-快速开始/02-三分钟创建第一个插件.md
Normal file
236
docs/Plugins develop/01-快速开始/02-三分钟创建第一个插件.md
Normal 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月*
|
||||
350
docs/Plugins develop/01-快速开始/03-插件项目结构详解.md
Normal file
350
docs/Plugins develop/01-快速开始/03-插件项目结构详解.md
Normal 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月*
|
||||
380
docs/Plugins develop/01-快速开始/04-调试运行指南.md
Normal file
380
docs/Plugins develop/01-快速开始/04-调试运行指南.md
Normal 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月*
|
||||
398
docs/Plugins develop/02-核心概念与原理/01-插件生命周期.md
Normal file
398
docs/Plugins develop/02-核心概念与原理/01-插件生命周期.md
Normal 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) { }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### 问题 1:Initialize 中访问 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月*
|
||||
393
docs/Plugins develop/02-核心概念与原理/02-桌面组件系统.md
Normal file
393
docs/Plugins develop/02-核心概念与原理/02-桌面组件系统.md
Normal 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月*
|
||||
353
docs/Plugins develop/02-核心概念与原理/03-设置系统集成.md
Normal file
353
docs/Plugins develop/02-核心概念与原理/03-设置系统集成.md
Normal 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月*
|
||||
308
docs/Plugins develop/02-核心概念与原理/04-外观与主题系统.md
Normal file
308
docs/Plugins develop/02-核心概念与原理/04-外观与主题系统.md
Normal 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="" />
|
||||
</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月*
|
||||
375
docs/Plugins develop/02-核心概念与原理/05-插件间通信.md
Normal file
375
docs/Plugins develop/02-核心概念与原理/05-插件间通信.md
Normal 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月*
|
||||
307
docs/Plugins develop/03-API实践指南/01-PluginBase详解.md
Normal file
307
docs/Plugins develop/03-API实践指南/01-PluginBase详解.md
Normal 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月*
|
||||
182
docs/Plugins develop/03-API实践指南/02-组件注册与配置.md
Normal file
182
docs/Plugins develop/03-API实践指南/02-组件注册与配置.md
Normal 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月*
|
||||
147
docs/Plugins develop/03-API实践指南/03-设置API详解.md
Normal file
147
docs/Plugins develop/03-API实践指南/03-设置API详解.md
Normal 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月*
|
||||
106
docs/Plugins develop/03-API实践指南/04-外观API详解.md
Normal file
106
docs/Plugins develop/03-API实践指南/04-外观API详解.md
Normal 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月*
|
||||
73
docs/Plugins develop/03-API实践指南/05-本地化支持.md
Normal file
73
docs/Plugins develop/03-API实践指南/05-本地化支持.md
Normal 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月*
|
||||
215
docs/Plugins develop/06-CI-CD与自动化/01-GitHub Actions入门.md
Normal file
215
docs/Plugins develop/06-CI-CD与自动化/01-GitHub Actions入门.md
Normal 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月*
|
||||
130
docs/Plugins develop/06-CI-CD与自动化/02-配置自动构建.md
Normal file
130
docs/Plugins develop/06-CI-CD与自动化/02-配置自动构建.md
Normal 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月*
|
||||
156
docs/Plugins develop/06-CI-CD与自动化/03-自动打包与发布.md
Normal file
156
docs/Plugins develop/06-CI-CD与自动化/03-自动打包与发布.md
Normal 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月*
|
||||
92
docs/Plugins develop/07-发布与运营/01-插件打包规范.md
Normal file
92
docs/Plugins develop/07-发布与运营/01-插件打包规范.md
Normal 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月*
|
||||
75
docs/Plugins develop/07-发布与运营/02-版本管理策略.md
Normal file
75
docs/Plugins develop/07-发布与运营/02-版本管理策略.md
Normal 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月*
|
||||
71
docs/Plugins develop/07-发布与运营/03-发布到插件市场.md
Normal file
71
docs/Plugins develop/07-发布与运营/03-发布到插件市场.md
Normal 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月*
|
||||
82
docs/Plugins develop/07-发布与运营/04-更新与维护.md
Normal file
82
docs/Plugins develop/07-发布与运营/04-更新与维护.md
Normal 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月*
|
||||
Reference in New Issue
Block a user