Compare commits

...

48 Commits

Author SHA1 Message Date
lincube
c3db5af923 0.7.4
首先我加了CI课程表json的读取,然后把天气时钟这个老问题也修了。
2026-03-22 04:57:19 +08:00
lincube
1a7dde34d0 0.7.3.1 2026-03-22 02:53:31 +08:00
lincube
73cdefe296 0.7.3
修东西
2026-03-21 22:40:07 +08:00
lincube
46a8df5900 0.7.2 2026-03-21 16:16:02 +08:00
lincube
2a1c09ae39 0.7.2 2026-03-21 13:08:20 +08:00
lincube
33baaa579d 0.7.1 2026-03-20 22:37:37 +08:00
lincube
20cd6041a7 0.7.0.2 2026-03-20 18:05:42 +08:00
lincube
65a3cf832a Revert "0.7.0.0"
This reverts commit aeae4be060.
2026-03-20 14:22:33 +08:00
lincube
5d48a03f57 Revert "0.7.0.1"
This reverts commit ea8ce1f5ff.
2026-03-20 14:12:40 +08:00
lincube
ea8ce1f5ff 0.7.0.1 2026-03-20 12:16:04 +08:00
lincube
aeae4be060 0.7.0.0 2026-03-20 10:22:40 +08:00
lincube
915739ff7b 0.6.9
改变无声
2026-03-20 00:41:14 +08:00
lincube
cb86ca10e7 0.6.8
小黑板数据持久化。
2026-03-19 16:27:16 +08:00
lincube
b3a74aa072 0.6.7.2
文档组件优化
2026-03-19 08:39:25 +08:00
lincube
b436bfa884 0.6.7.1
多平台适配
2026-03-19 02:02:07 +08:00
lincube
081abeb688 0 6 7
可移动存储组件
2026-03-19 00:17:21 +08:00
lincube
594a62132f 0.6.6
滑动优化
2026-03-18 20:09:00 +08:00
lincube
15e589aedd 0.6.5
流畅性优化测试
2026-03-17 18:36:10 +08:00
lincube
ac4617f5cf 0.6.4 2026-03-17 14:57:41 +08:00
lincube
0645598753 0.6.3.1
最近文件查看优化,课程表组件优化,插件安装优化。
2026-03-17 12:30:30 +08:00
lincube
dadd132b4f 0.6.3
优化了文本框焦点,优化了更新体验,优化了遥测,披露了收集的数据。
2026-03-17 01:01:48 +08:00
lincube
298defb829 0.6.2
删除了视频壁纸功能,为纯色背景添加了自定义颜色选项。
2026-03-16 21:08:54 +08:00
lincube
bcf4be6d50 0.6.1
课表组件修复。加入最近文档组件。
2026-03-16 15:19:46 +08:00
lincube
6c9f6be1b1 0.6.0.1
应用遥测,插件市场
2026-03-16 09:50:48 +08:00
lincube
557b79e8c0 ,0.6.0
重构了设置系统。解决了大量的bug,正式添加了图标。引入了遥测的同意与许可(暂无实际功能)
2026-03-15 21:27:48 +08:00
lincube
f83c6ede1d settings_re11 2026-03-15 17:08:07 +08:00
lincube
c7fb48c8ee settings_re10 2026-03-15 04:35:34 +08:00
lincube
85b70c4a8a settings_re9 2026-03-14 23:52:26 +08:00
lincube
689be7b585 settings_re8 2026-03-14 22:45:09 +08:00
lincube
91f9f3d6fb settings_re7 2026-03-14 16:38:56 +08:00
lincube
8d4f00efcb settings_re6 2026-03-14 15:08:49 +08:00
lincube
e8be0f0576 settings_re5 2026-03-14 13:36:18 +08:00
lincube
5fdaa2539b settings_re4 2026-03-13 22:20:12 +08:00
lincube
3b3f060f33 setting_re3 2026-03-13 09:10:00 +08:00
lincube
c4df243610 setting_re2
设置架构革新中
2026-03-13 00:33:00 +08:00
lincube
40a3a00cfe setting_re1 2026-03-12 21:01:23 +08:00
lincube
4679ee006f 0.5.20
我认为很稳定了,后面就要开始弄插件不稳定了
2026-03-12 12:25:22 +08:00
lincube
6952cb2c3e 0.5.19.1 2026-03-12 10:07:23 +08:00
lincube
d3356f3319 0.5.19
插件系统V2
2026-03-12 09:22:03 +08:00
lincube
57c5e41a5c 0.5.18 2026-03-12 00:34:49 +08:00
lincube
ce2b218dfa 0.5.17 2026-03-12 00:18:04 +08:00
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
lincube
e7a03404ce 0.5.14
二次启动拦截,统一了生命进程API
2026-03-11 09:40:36 +08:00
lincube
2781d7e0d9 0.5.13
插件市场安装优化
2026-03-11 06:38:11 +08:00
lincube
5003ff1be2 0.5.12 2026-03-10 21:25:47 +08:00
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
451 changed files with 39371 additions and 23750 deletions

View File

@@ -0,0 +1,16 @@
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
version = 1
name = "LanMountainDesktop"
[setup]
script = ""
[[actions]]
name = "运行"
icon = "run"
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
[[actions]]
name = "构建"
icon = "tool"
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"

17
.github/FIX_REPORT.md vendored
View File

@@ -8,14 +8,14 @@ MSBUILD : error MSB1003: Specify a project or solution file.
The current working directory does not contain a project or solution file.
```
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
**原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
---
## 🔧 已采取的修复
### 1. 创建解决方案文件
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
### 1. 创建 `.slnx` 解决方案文件
✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
- `LanMountainDesktop/LanMountainDesktop.csproj`
### 2. 验证本地构建工作
@@ -35,10 +35,10 @@ The current working directory does not contain a project or solution file.
## 📋 解决方案文件内容
包含主桌面项目的标准 Visual Studio 解决方案格式:
包含主桌面项目的标准 XML 解决方案格式:
```
LanMountainDesktop.sln
LanMountainDesktop.slnx
└── LanMountainDesktop (Desktop UI - Avalonia)
```
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
```bash
# 1. 添加新创建的解决方案文件
git add LanMountainDesktop.sln
git add LanMountainDesktop.slnx
git add global.json
# 2. 提交
git commit -m "Add solution file for desktop project"
git commit -m "Migrate desktop solution to .slnx"
# 3. 推送
git push origin main
@@ -92,7 +93,7 @@ git push origin v1.0.1
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 |
| `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
---

3
.github/README.md vendored
View File

@@ -36,9 +36,10 @@
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

View File

@@ -1,27 +0,0 @@
name: AirAppMarket Validate
on:
push:
paths:
- "airappmarket/**"
- ".github/workflows/airappmarket-validate.yml"
pull_request:
paths:
- "airappmarket/**"
- ".github/workflows/airappmarket-validate.yml"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Validate AirAppMarket index
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json

View File

@@ -9,7 +9,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
build-windows:
@@ -71,10 +71,10 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build --no-restore -c Release -v minimal
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -101,10 +101,10 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build --no-restore -c Release -v minimal
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -113,3 +113,31 @@ jobs:
path: |
LanMountainDesktop/bin/Release/
retention-days: 7
pack-plugin-packages:
runs-on: ubuntu-latest
name: Pack_Plugin_Packages
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: recursive
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Pack SDK and template packages
shell: pwsh
run: .\scripts\Pack-PluginPackages.ps1 -Configuration Release -OutputPath .\artifacts\nuget
- name: Upload plugin package artifacts
uses: actions/upload-artifact@v4
with:
name: plugin-packages
path: artifacts/nuget/*.nupkg
if-no-files-found: error
retention-days: 14

View File

@@ -8,7 +8,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
analyze:

View File

@@ -18,13 +18,15 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
assembly_version: ${{ steps.version.outputs.assembly_version }}
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
@@ -47,8 +49,15 @@ jobs:
CHECKOUT_REF="${GITHUB_SHA}"
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0")
done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
build-windows:
@@ -73,26 +82,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
$VERSION = "${{ needs.prepare.outputs.version }}"
$csprojFiles = @(
"LanMountainDesktop/LanMountainDesktop.csproj"
)
foreach ($csprojPath in $csprojFiles) {
Write-Host "Updating version in $csprojPath to $VERSION"
$content = Get-Content $csprojPath -Raw
$content = $content -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>"
Set-Content $csprojPath $content
}
shell: pwsh
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -106,7 +105,11 @@ jobs:
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
shell: pwsh
- name: Install Inno Setup
@@ -242,17 +245,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -266,7 +268,11 @@ jobs:
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DEB
run: |
@@ -384,17 +390,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -408,7 +413,11 @@ jobs:
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DMG
run: |

20
.gitignore vendored
View File

@@ -492,3 +492,23 @@ nul
/_build_verify_plugin_services
/LanMountainDesktop.PluginSdk/_build_verify_*/
/_build_obj
# LanMountainDesktop local workspace files
/.arts/
/.knox/
/.lingma/
/.tmp/
/publish-test/
/validator-restore.log
/temp_old_main.axaml
/temp_old_main_utf8.axaml
# LanMountainDesktop local packaging outputs
/build-installer/
/build-deb/
/dmg-temp/
/release-files/
/LanMountainDesktop.app/
/*.deb
/*.dmg
/*.AppImage

View File

@@ -0,0 +1,24 @@
# Checklist
## 1. 课表单双周解析修复
- [x] 单周课程WeekCountDiv=1在单周正确显示
- [x] 双周课程WeekCountDiv=2在双周正确显示
- [x] 每周课程WeekCountDiv=0在所有周正确显示
- [x] 多周轮转2-32周正确计算当前周期位置
## 2. 课程动态移动功能
- [x] 课程结束自动从视图移除
- [x] 新课程自动移入视图可见区域
- [x] 当日课程全部结束后自动切换到次日课程表
## 3. 拖动交互功能
- [x] 课程表支持上下拖动滚动
- [x] 拖动操作流畅、响应及时
## 4. 自动复位功能
- [x] 用户手动拖动后,标记拖动状态
- [x] 当前课程变化时自动复位到最新进行中课程

View File

@@ -0,0 +1,101 @@
# 课程表组件功能优化规格说明书
## Why
当前课程表组件存在以下问题:
1. 单双周课程解析逻辑存在缺陷,无法正确识别单周/双周/每周模式
2. 课程无法动态移动,第一列始终显示进行中的课程,但存在无法正常移动的问题
3. 缺少用户拖动交互功能
4. 缺少拖动后的自动复位机制
## What Changes
- 修复 ClassIsland 课程单双周解析逻辑
- 实现课程动态移动机制(当前课程结束自动上移)
- 实现课程表上下拖动交互功能
- 实现自动复位功能(课程结束后视图复位到最新进行中课程)
## Impact
### Affected specs
- 课程表组件功能规范
### Affected code
- `Services/ClassIslandScheduleDataService.cs` - 课表解析服务
- `Views/Components/ClassScheduleWidget.axaml.cs` - 课表组件
---
## ADDED Requirements
### Requirement: 单双周课程解析
系统 SHALL 能够正确解析包含单双周信息的课程数据。
#### Scenario: 单周课程
- **WHEN** 课程设置为单周上课
- **THEN** 课程仅在单周显示
#### Scenario: 双周课程
- **WHEN** 课程设置为双周上课
- **THEN** 课程仅在双周显示
#### Scenario: 每周课程
- **WHEN** 课程设置为每周上课
- **THEN** 课程在所有周显示
---
### Requirement: 课程动态移动
系统 SHALL 实现课程的动态移动机制。
#### Scenario: 课程结束自动上移
- **WHEN** 当前进行中的课程结束
- **THEN** 课程列表自动向上移动
- **AND THEN** 下一个进行中或即将开始的课程移至视图可见区域
#### Scenario: 新课程移入视图
- **WHEN** 新的课程即将开始
- **THEN** 该课程自动移至视图可见区域
#### Scenario: 当日课程全部结束
- **WHEN** 当日所有课程已结束
- **THEN** 自动显示次日课程表
---
### Requirement: 拖动交互功能
系统 SHALL 提供课程表的上下拖动功能。
#### Scenario: 拖动查看课程
- **WHEN** 用户在课程表区域进行上下拖动
- **THEN** 课程列表随拖动方向滚动
- **AND THEN** 拖动操作流畅、响应及时
---
### Requirement: 自动复位功能
系统 SHALL 在用户手动拖动后自动复位到当前课程。
#### Scenario: 当前课程结束触发复位
- **WHEN** 用户手动拖动课程表后,当前课程结束
- **THEN** 视图自动复位到显示最新进行中课程的位置
---
## MODIFIED Requirements
### Requirement: 课程解析逻辑
**当前**: 单双周解析可能存在缺陷
**修改后**: 正确识别 WeekCountDiv 和 WeekCountDivTotal 参数,准确判断单周/双周/每周模式
---
## REMOVED Requirements
(无)

View File

@@ -0,0 +1,61 @@
# Tasks
## 1. 课表单双周解析修复
- [x] Task 1.1: 分析 ClassIsland 课表单双周数据结构
- [x] 分析 ClassIsland Schedule.json 和 Profile.json 中的周数规则字段
- [x] 确认 WeekCountDiv 和 WeekCountDivTotal 的含义和取值范围
- [x] Task 1.2: 修复 GetCyclePositionsByDate 方法
- [x] 检查单周开始日期的计算逻辑
- [x] 修复周期位置计算公式
- [x] Task 1.3: 修复 CheckRegularClassPlan 方法
- [x] 验证 weekCountDiv 和 weekCountDivTotal 的匹配逻辑
- [x] 确保单周=1、双周=2、每周=0 的正确处理
## 2. 课程动态移动功能
- [x] Task 2.1: 分析当前课程状态检测逻辑
- [x] 查看如何判断课程是否为"当前进行中"
- [x] Task 2.2: 实现定时刷新机制
- [x] 增加更频繁的刷新定时器(每分钟检查一次)
- [x] 实现课程状态变化检测
- [x] Task 2.3: 实现动态移动逻辑
- [x] 课程结束后自动上移
- [x] 新课程自动移入视图
- [x] Task 2.4: 实现次日课程切换
- [x] 当日所有课程结束后自动切换到次日
## 3. 拖动交互功能
- [x] Task 3.1: 实现 ScrollViewer 包裹
- [x] 修改 XAML 使用 ScrollViewer 包裹课程列表
- [x] Task 3.2: 实现拖动手势处理
- [x] 添加 PointerPressed/PointerMoved/PointerReleased 处理
- [x] 实现平滑滚动逻辑
## 4. 自动复位功能
- [x] Task 4.1: 记录用户拖动状态
- [x] 添加用户是否手动拖动的标志位
- [x] Task 4.2: 实现自动复位逻辑
- [x] 检测当前课程变化
- [x] 当用户手动拖动且当前课程变化时自动复位
# Task Dependencies
- Task 1.1 -> Task 1.2 -> Task 1.3
- Task 2.1 -> Task 2.2 -> Task 2.3 -> Task 2.4
- Task 3.1 -> Task 3.2
- Task 4.1 -> Task 4.2
# Parallelizable Tasks
- Task 1.x (解析修复) 与 Task 3.x (拖动) 可以并行开发
- Task 2.x (动态移动) 可以在 Task 1 完成后进行

View File

@@ -0,0 +1,32 @@
# Checklist - 设置页面 Fluent 设计改造
## Phase 1: 分析与准备
- [ ] SettingsExpander 控件分析完成
- [ ] 当前布局问题定位完成
## Phase 2: 窗口布局调整
- [ ] SettingsWindow 内容区域无额外 Border 包裹
- [ ] 窗口整体视觉效果正常
- [ ] 窗口圆角在不同模式下正确显示
## Phase 3: 设置页面改造
- [ ] AppearanceSettingsPage 无额外边框包裹
- [ ] GeneralSettingsPage 无额外边框包裹
- [ ] ComponentsSettingsPage 无额外边框包裹
- [ ] PluginsSettingsPage 无额外边框包裹
- [ ] AboutSettingsPage 无额外边框包裹
## Phase 4: 视觉规范
- [ ] 设置项间距统一
- [ ] 圆角样式统一
- [ ] 页面标题样式统一
## 验证
- [ ] 编译通过,无错误
- [ ] 运行正常,设置页面可正常显示
- [ ] 视觉效果符合 Fluent 设计风格

View File

@@ -0,0 +1,76 @@
# 设置页面 Fluent 设计改造规格说明书
## Why
当前 LanMountainDesktop 设置页面存在以下问题:
1. 右侧详细设置区域被额外边框包裹,未能实现 Fluent Avalonia 控件的完整填充效果
2. 设置项未采用 Fluent 卡片设计风格,仍使用传统 Border + StackPanel 布局
3. 与 ClassIsland 项目的视觉风格差异较大
## What Changes
- 移除页面内容区域的额外 Border 包裹,直接使用 ScrollViewer + StackPanel
- 参考 ClassIsland 项目,引入 SettingsExpander 控件替代传统布局
- 统一设置项的间距、圆角、字体等视觉规范
- 修改窗口布局,移除内容区域的 glass-panel 样式
## Impact
### Affected specs
- 设置页面 UI 布局规范
- Fluent 设计风格适配
### Affected code
- `Views/SettingsPages/*.axaml` - 所有设置页面
- `Views/SettingsWindow.axaml` - 设置窗口布局
- `Styles/GlassModule.axaml` - 样式资源
---
## ADDED Requirements
### Requirement: 设置页面 Fluent 卡片设计
系统 SHALL 提供类似 ClassIsland 的 SettingsExpander 卡片式设置项。
#### Scenario: 设置页面布局
- **WHEN** 用户打开任意设置页面
- **THEN** 页面使用 ScrollViewer 直接包裹内容,无额外 Border 包裹
- **AND THEN** 设置项使用 SettingsExpander 或 Fluent 卡片样式
### Requirement: 移除内容区域额外边框
系统 SHALL 移除右侧内容区域的 glass-panel 边框包裹。
#### Scenario: 内容区域无额外边框
- **WHEN** 用户查看设置页面内容
- **THEN** 内容直接显示在透明背景上,无额外边框包裹
### Requirement: 设置项视觉规范
系统 SHALL 统一设置项的视觉样式。
#### Scenario: 设置项样式
- **WHEN** 开发者创建新的设置项
- **THEN** 使用统一的间距Spacing、圆角、字体大小
- **AND THEN** 参考 ClassIsland 的 SettingsExpander 样式
---
## MODIFIED Requirements
### Requirement: 设置页面布局结构
**当前**: Border → ScrollViewer → Border → StackPanel → 内容
**修改后**: ScrollViewer → StackPanel → 设置项(无额外 Border
---
## REMOVED Requirements
### Requirement: 传统 Border 包裹布局
**Reason**: 实现 Fluent 设计风格,移除视觉噪音
**Migration**: 将现有 Border 包裹改为直接内容布局

View File

@@ -0,0 +1,51 @@
# Tasks - 设置页面 Fluent 设计改造
## Phase 1: 分析与准备
- [ ] Task 1.1: 分析 ClassIsland SettingsExpander 控件实现
- [ ] 查看 ClassIsland.Core 中的 SettingsExpander 定义
- [ ] 分析样式模板和视觉效果
- [ ] 确定是否需要自定义控件或使用现有替代方案
- [ ] Task 1.2: 分析当前设置页面布局问题
- [ ] 定位右侧内容区域的 Border 包裹代码
- [ ] 分析 glass-panel 样式对布局的影响
## Phase 2: 窗口布局调整
- [ ] Task 2.1: 修改 SettingsWindow.axaml 内容区域布局
- [ ] 移除 Frame 外部的 glass-panel Border
- [ ] 直接使用透明背景
- [ ] 验证窗口整体视觉效果
## Phase 3: 设置页面改造
- [ ] Task 3.1: 改造 AppearanceSettingsPage 页面
- [ ] 移除外部的 glass-panel Border
- [ ] 调整内容布局为直接填充
- [ ] 验证视觉效果
- [ ] Task 3.2: 改造 GeneralSettingsPage 页面
- [ ] 移除外部的 glass-panel Border
- [ ] 调整内容布局
- [ ] Task 3.3: 改造其他设置页面
- [ ] ComponentsSettingsPage
- [ ] PluginsSettingsPage
- [ ] AboutSettingsPage
## Phase 4: 视觉规范统一
- [ ] Task 4.1: 统一设置项间距和圆角
- [ ] 定义统一的 Spacing 值
- [ ] 统一圆角大小
- [ ] Task 4.2: 优化页面标题区域样式
- [ ] 调整 Page Header 字体大小
- [ ] 优化 Description 样式
## Task Dependencies
- Task 1.2 依赖 Task 1.1
- Task 2.1 依赖 Task 1.2
- Task 3.x 依赖 Task 2.1
- Task 4.x 依赖 Task 3.x

8
Directory.Build.props Normal file
View File

@@ -0,0 +1,8 @@
<Project>
<PropertyGroup>
<Version>1.0.0</Version>
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -1,21 +0,0 @@
# LanAirApp
## 中文
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
### 目录说明
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目。
- `standards/`:插件清单和目录结构约定。
- `tools/`:插件打包与辅助工具。
### 与宿主的关系
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
## English
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.

View File

@@ -1,16 +0,0 @@
# 插件开发指南
## 中文
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
- `plugin.json`
- 插件入口程序集
- 入口类
- 本地化资源
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
## English
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.

View File

@@ -1,14 +0,0 @@
# 插件打包指南
## 中文
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
- `.laapp` 安装包
- `README.md`
官方市场索引只负责记录链接和校验信息。
## English
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.

View File

@@ -1,41 +0,0 @@
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketCacheService
{
private readonly string _cacheDirectory;
public AirAppMarketCacheService(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_cacheDirectory = Path.Combine(dataDirectory, "cache");
}
public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json");
public void SaveIndexJson(string json)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
Directory.CreateDirectory(_cacheDirectory);
File.WriteAllText(CacheFilePath, json);
}
public bool TryReadIndexJson(out string json)
{
try
{
if (!File.Exists(CacheFilePath))
{
json = string.Empty;
return false;
}
json = File.ReadAllText(CacheFilePath);
return !string.IsNullOrWhiteSpace(json);
}
catch
{
json = string.Empty;
return false;
}
}
}

View File

@@ -1,77 +0,0 @@
using System.Net.Http.Headers;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketIndexService : IDisposable
{
private readonly AirAppMarketCacheService _cacheService;
private readonly HttpClient _httpClient;
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
{
_cacheService = cacheService;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
_httpClient.DefaultRequestHeaders.Accept.Add(
new MediaTypeWithQualityHeaderValue("application/json"));
}
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
{
Exception? networkError = null;
try
{
using var response = await _httpClient.GetAsync(
AirAppMarketDefaults.DefaultIndexUrl,
cancellationToken);
var json = await response.Content.ReadAsStringAsync(cancellationToken);
response.EnsureSuccessStatusCode();
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
_cacheService.SaveIndexJson(json);
return new AirAppMarketLoadResult(true, document, AirAppMarketLoadSource.Network, null, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
networkError = ex;
}
if (_cacheService.TryReadIndexJson(out var cachedJson))
{
try
{
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
return new AirAppMarketLoadResult(
true,
cachedDocument,
AirAppMarketLoadSource.Cache,
networkError?.Message,
null);
}
catch (Exception cacheEx)
{
return new AirAppMarketLoadResult(
false,
null,
null,
null,
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
}
}
return new AirAppMarketLoadResult(false, null, null, null, networkError?.Message ?? "Unknown network error");
}
public void Dispose()
{
_httpClient.Dispose();
}
}

View File

@@ -1,83 +0,0 @@
using System.Security.Cryptography;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly IPluginPackageManager _packageManager;
private readonly HttpClient _httpClient;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(IPluginPackageManager packageManager, string dataDirectory)
{
_packageManager = packageManager;
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<AirAppMarketInstallResult> InstallAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory);
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{
using var response = await _httpClient.GetAsync(
plugin.DownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken))
await using (var destinationStream = File.Create(downloadPath))
{
await responseStream.CopyToAsync(destinationStream, cancellationToken);
}
await using var hashStream = File.OpenRead(downloadPath);
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(downloadPath);
return new AirAppMarketInstallResult(
false,
null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
}
var installResult = _packageManager.InstallPackage(downloadPath);
return new AirAppMarketInstallResult(true, installResult, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new AirAppMarketInstallResult(false, null, ex.Message);
}
}
public void Dispose()
{
_httpClient.Dispose();
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
}

View File

@@ -1,299 +0,0 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal static class AirAppMarketDefaults
{
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json";
}
internal enum AirAppMarketLoadSource
{
Network = 0,
Cache = 1
}
internal enum AirAppMarketInstallState
{
NotInstalled = 0,
UpdateAvailable = 1,
Installed = 2
}
internal sealed record AirAppMarketLoadResult(
bool Success,
AirAppMarketIndexDocument? Document,
AirAppMarketLoadSource? Source,
string? WarningMessage,
string? ErrorMessage);
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginPackageInstallResult? InstallResult,
string? ErrorMessage);
internal sealed class AirAppMarketIndexDocument
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
public static AirAppMarketIndexDocument Load(string json, string sourceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
json.TrimStart('\uFEFF'),
SerializerOptions);
if (document is null)
{
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
}
return document.ValidateAndNormalize(sourceName);
}
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
{
var plugins = Plugins ?? [];
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new AirAppMarketIndexDocument
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
private static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static void EnsureUrl(string url, string propertyName, string sourceName)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
}
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
}
internal sealed class AirAppMarketPluginEntry
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
{
var normalizedTags = (Tags ?? [])
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new AirAppMarketPluginEntry
{
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = normalizedDownloadUrl,
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = normalizedIconUrl,
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
};
}
public string GetVersionSummary()
{
return string.Format(
CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}",
Version,
ApiVersion,
MinHostVersion);
}
}

View File

@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<PluginReleaseOutputDirectory>..\..\releases\</PluginReleaseOutputDirectory>
<PluginReleasePackagePath>$(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp</PluginReleasePackagePath>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<MakeDir Directories="$(PluginReleaseOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<Delete Files="$(PluginReleasePackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
<Copy SourceFiles="$(PluginPackagePath)" DestinationFiles="$(PluginReleasePackagePath)" />
</Target>
</Project>

View File

@@ -1,37 +0,0 @@
{
"market.page_title": "Plugin Marketplace",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin marketplace...",
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
"market.status.load_failed_format": "Failed to load plugin marketplace: {0}",
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.status.install_failed_format": "Failed to install plugin: {0}",
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
"market.list.empty": "The plugin marketplace has not been loaded yet.",
"market.list.no_results": "No plugins match the current search.",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "Loaded",
"market.card.pending_restart": "Restart required",
"market.detail.placeholder": "Select a plugin from the left to inspect details.",
"market.detail.author": "Author",
"market.detail.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"market.detail.not_installed": "Not installed",
"market.detail.market_source": "Market Source",
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
"market.detail.unknown": "Unknown",
"market.button.install": "Install",
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing..."
}

View File

@@ -1,37 +0,0 @@
{
"market.page_title": "插件市场",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场…",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”…",
"market.status.install_success_format": "插件“{0}”已暂存完成,重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.no_results": "没有匹配的插件。",
"market.card.subtitle_format": "{0} · v{1}",
"market.card.loaded": "已加载",
"market.card.pending_restart": "需重启",
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
"market.detail.author": "作者",
"market.detail.version": "版本",
"market.detail.api_version": "API 版本",
"market.detail.min_host_version": "最低宿主版本",
"market.detail.installed_version": "当前已安装版本",
"market.detail.not_installed": "未安装",
"market.detail.market_source": "市场源",
"market.detail.homepage": "主页",
"market.detail.repository": "仓库",
"market.detail.release_notes": "发布说明",
"market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装",
"market.detail.unknown": "未知",
"market.button.install": "安装",
"market.button.update": "更新",
"market.button.installed": "已安装",
"market.button.installing": "安装中…"
}

View File

@@ -1,47 +0,0 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
[PluginEntrance]
public sealed class PluginMarketplacePlugin : PluginBase, IDisposable
{
private AirAppMarketIndexService? _indexService;
private AirAppMarketInstallService? _installService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var packageManager = context.GetService<IPluginPackageManager>()
?? throw new InvalidOperationException(
"The host does not expose IPluginPackageManager. LanMountainDesktop.PluginMarketplace requires a newer host build.");
var cacheService = new AirAppMarketCacheService(context.DataDirectory);
_indexService = new AirAppMarketIndexService(cacheService);
_installService = new AirAppMarketInstallService(packageManager, context.DataDirectory);
context.RegisterService(cacheService);
context.RegisterService(_indexService);
context.RegisterService(_installService);
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"marketplace",
localizer.GetString("market.page_title", "插件市场"),
() => new PluginMarketplaceSettingsView(
context,
localizer,
packageManager,
_indexService,
_installService),
sortOrder: -100));
}
public void Dispose()
{
_installService?.Dispose();
_indexService?.Dispose();
_installService = null;
_indexService = null;
}
}

View File

@@ -1,727 +0,0 @@
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.PluginMarketplace;
internal sealed class PluginMarketplaceSettingsView : UserControl
{
private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000"));
private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1F0EA5E9"));
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private readonly PluginLocalizer _localizer;
private readonly IPluginPackageManager _packageManager;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService _installService;
private readonly Version? _hostVersion;
private readonly TextBox _searchTextBox;
private readonly Button _refreshButton;
private readonly TextBlock _statusTextBlock;
private readonly StackPanel _pluginListHost;
private readonly Border _detailBorder;
private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
public PluginMarketplaceSettingsView(
IPluginContext context,
PluginLocalizer localizer,
IPluginPackageManager packageManager,
AirAppMarketIndexService indexService,
AirAppMarketInstallService installService)
{
_localizer = localizer;
_packageManager = packageManager;
_indexService = indexService;
_installService = installService;
_hostVersion = context.TryGetProperty<string>(PluginHostPropertyKeys.HostVersion, out var hostVersionText) &&
AirAppMarketIndexDocument.TryParseVersion(hostVersionText, out var parsedHostVersion)
? parsedHostVersion
: null;
_searchTextBox = new TextBox
{
MinWidth = 240,
Watermark = T("market.toolbar.search_placeholder", "搜索插件")
};
_searchTextBox.PropertyChanged += (_, e) =>
{
if (e.Property == TextBox.TextProperty)
{
RebuildSurface();
}
};
_refreshButton = new Button
{
Content = T("market.toolbar.refresh", "刷新"),
HorizontalAlignment = HorizontalAlignment.Left
};
_refreshButton.Click += OnRefreshClick;
_statusTextBlock = new TextBlock
{
Text = T("market.status.loading", "正在加载官方插件市场…"),
TextWrapping = TextWrapping.Wrap,
Foreground = WarningBrush
};
_pluginListHost = new StackPanel
{
Spacing = 10
};
_detailBorder = CreatePanelShell();
Content = BuildLayout();
AttachedToVisualTree += async (_, _) =>
{
if (_hasLoadedOnce)
{
return;
}
_hasLoadedOnce = true;
await RefreshAsync();
};
}
private Control BuildLayout()
{
var root = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 16
};
var toolbar = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12
};
toolbar.Children.Add(new StackPanel
{
Spacing = 8,
Children =
{
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
Children =
{
_searchTextBox,
_refreshButton
}
},
_statusTextBlock
}
});
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("360,*"),
ColumnSpacing = 16
};
var listShell = CreatePanelShell();
listShell.Child = new ScrollViewer
{
Content = _pluginListHost
};
contentGrid.Children.Add(listShell);
contentGrid.Children.Add(_detailBorder);
Grid.SetColumn(_detailBorder, 1);
root.Children.Add(toolbar);
root.Children.Add(contentGrid);
Grid.SetRow(contentGrid, 1);
return root;
}
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
await RefreshAsync();
}
private async Task RefreshAsync()
{
if (_isRefreshing)
{
return;
}
_isRefreshing = true;
_refreshButton.IsEnabled = false;
SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush);
try
{
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase);
var result = await _indexService.LoadAsync();
if (!result.Success || result.Document is null)
{
_document = null;
_selectedPlugin = null;
SetStatus(
F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
RebuildSurface();
return;
}
_document = result.Document;
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
? F(
"market.status.loaded_cache_format",
"官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
result.Document.Plugins.Count,
result.WarningMessage ?? T("market.detail.unknown", "未知错误"))
: F(
"market.status.loaded_network_format",
"已从官方源加载 {0} 个插件。",
result.Document.Plugins.Count);
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
RebuildSurface();
}
finally
{
_isRefreshing = false;
_refreshButton.IsEnabled = true;
}
}
private void RebuildSurface()
{
var filteredPlugins = GetFilteredPlugins();
if (filteredPlugins.Count > 0)
{
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins);
}
else
{
_selectedPlugin = null;
}
BuildPluginList(filteredPlugins);
BuildDetailPanel();
}
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
{
if (_document is null)
{
return [];
}
var query = (_searchTextBox.Text ?? string.Empty).Trim();
var source = _document.Plugins;
if (string.IsNullOrWhiteSpace(query))
{
return source.ToList();
}
return source
.Where(plugin =>
plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
private void BuildPluginList(IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
_pluginListHost.Children.Clear();
if (_document is null)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。")));
return;
}
if (plugins.Count == 0)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。")));
return;
}
foreach (var plugin in plugins)
{
_pluginListHost.Children.Add(CreatePluginCard(plugin));
}
}
private Control CreatePluginCard(AirAppMarketPluginEntry plugin)
{
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase);
var button = new Button
{
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Content = new Border
{
Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 10,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 12,
Children =
{
CreateMonogramIcon(plugin.Name, 42),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version),
Foreground = Brushes.Gray,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 56
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(installedPlugin?.IsLoaded == true
? T("market.card.loaded", "已加载")
: T("market.card.pending_restart", "需重启")),
new TextBlock
{
Text = string.Join(" ", plugin.Tags.Take(3)),
VerticalAlignment = VerticalAlignment.Center,
Foreground = Brushes.Gray
}
}
}
}
}
}
};
button.Click += (_, _) =>
{
_selectedPlugin = plugin;
RebuildSurface();
};
return button;
}
private void BuildDetailPanel()
{
if (_selectedPlugin is null)
{
_detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。"));
return;
}
var plugin = _selectedPlugin;
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isCompatible = IsCompatibleWithHost(plugin);
var installButton = new Button
{
Content = _isInstalling
? T("market.button.installing", "安装中…")
: T(ButtonKey(installState), ButtonFallback(installState)),
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed,
HorizontalAlignment = HorizontalAlignment.Left,
MinWidth = 120
};
installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin);
var detailPanel = new StackPanel
{
Spacing = 14,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
Children =
{
CreateMonogramIcon(plugin.Name, 64),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 24,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(plugin.GetVersionSummary()),
CreateStateChip(string.Join(", ", plugin.Tags))
}
},
installButton,
CreateInfoRow(T("market.detail.author", "作者"), plugin.Author),
CreateInfoRow(T("market.detail.version", "版本"), plugin.Version),
CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion),
CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion),
CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")),
CreateInfoRow(T("market.detail.market_source", "市场源"), AirAppMarketDefaults.DefaultIndexUrl),
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
new TextBlock
{
Text = T("market.detail.release_notes", "发布说明"),
FontSize = 18,
FontWeight = FontWeight.SemiBold
},
new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new TextBlock
{
Text = plugin.ReleaseNotes,
TextWrapping = TextWrapping.Wrap
}
}
}
};
if (!isCompatible)
{
detailPanel.Children.Insert(
3,
new TextBlock
{
Text = F(
"market.status.host_incompatible_format",
"当前宿主版本过低,至少需要 {0}。",
plugin.MinHostVersion),
Foreground = ErrorBrush,
TextWrapping = TextWrapping.Wrap
});
}
_detailBorder.Child = new ScrollViewer
{
Content = detailPanel
};
}
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
{
if (_isInstalling)
{
return;
}
_isInstalling = true;
BuildDetailPanel();
SetStatus(
F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name),
WarningBrush);
try
{
var result = await _installService.InstallAsync(plugin);
if (!result.Success || result.InstallResult is null)
{
SetStatus(
F(
"market.status.install_failed_format",
"安装插件失败:{0}",
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
return;
}
_installedPlugins = _packageManager
.GetInstalledPlugins()
.ToDictionary(item => item.Manifest.Id, StringComparer.OrdinalIgnoreCase);
SetStatus(
F(
"market.status.install_success_format",
"插件“{0}”已暂存完成,重启应用后生效。",
result.InstallResult.Manifest.Name),
SuccessBrush);
RebuildSurface();
}
finally
{
_isInstalling = false;
BuildDetailPanel();
}
}
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
string? selectedPluginId,
IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
if (plugins.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(selectedPluginId))
{
var existing = plugins.FirstOrDefault(plugin =>
string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
return existing;
}
}
return plugins[0];
}
private AirAppMarketInstallState ResolveInstallState(
AirAppMarketPluginEntry plugin,
out InstalledPluginInfo? installedPlugin)
{
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
{
return AirAppMarketInstallState.NotInstalled;
}
return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0
? AirAppMarketInstallState.UpdateAvailable
: AirAppMarketInstallState.Installed;
}
private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null ||
!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return true;
}
return _hostVersion >= minHostVersion;
}
private void SetStatus(string message, IBrush foreground)
{
_statusTextBlock.Text = message;
_statusTextBlock.Foreground = foreground;
}
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
{
leftVersion = new Version(0, 0, 0);
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
{
rightVersion = new Version(0, 0, 0);
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));
}
private Border CreatePanelShell()
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16)
};
}
private Control CreateEmptyState(string text)
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(18),
Child = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap
}
};
}
private Border CreateMonogramIcon(string text, double size)
{
var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant();
return new Border
{
Width = size,
Height = size,
CornerRadius = new CornerRadius(size / 2),
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
Child = new TextBlock
{
Text = glyph,
FontSize = Math.Max(16, size * 0.36),
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
}
};
}
private Border CreateStateChip(string text)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#22000000")),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 4),
Child = new TextBlock
{
Text = text,
FontSize = 12
}
};
}
private Control CreateInfoRow(string label, string value)
{
return new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = label,
FontSize = 12,
Foreground = Brushes.Gray
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value,
TextWrapping = TextWrapping.Wrap
}
}
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string F(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args);
}
private static string StateKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
AirAppMarketInstallState.Installed => "market.detail.state.installed",
_ => "market.detail.state.not_installed"
};
}
private static string StateFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "可更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "未安装"
};
}
private static string ButtonKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.button.update",
AirAppMarketInstallState.Installed => "market.button.installed",
_ => "market.button.install"
};
}
private static string ButtonFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "安装"
};
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "LanMountainDesktop.PluginMarketplace",
"name": "LanMountain Plugin Marketplace",
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.PluginMarketplace.dll"
}

View File

@@ -1,30 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
</Target>
</Project>

View File

@@ -1,79 +0,0 @@
{
"settings.page_title": "Plugin Status",
"plugin.name": "LanMountain Sample Plugin",
"plugin.description": "Example plugin used to validate PluginSdk loading, services, communication, and localization.",
"widget.display_name": "Sample Plugin Status Clock",
"widget.category": "Plugins",
"settings.header.title": "Sample Plugin Capability Inspector",
"settings.section.info": "Plugin Info",
"settings.section.capabilities": "Accessible Capabilities",
"settings.section.status": "Live Runtime Status",
"settings.info.plugin_name": "Plugin Name",
"settings.info.plugin_id": "Plugin Id",
"settings.info.version": "Version",
"settings.info.author": "Author",
"settings.info.description": "Description",
"settings.info.plugin_directory": "Plugin Directory",
"settings.info.data_directory": "Data Directory",
"settings.info.host_application": "Host Application",
"settings.info.host_version": "Host Version",
"settings.info.sdk_api_version": "SDK API Version",
"settings.info.state_service_resolved": "State Service Resolved",
"settings.info.clock_service_resolved": "Clock Service Resolved",
"settings.info.message_bus_resolved": "Message Bus Resolved",
"settings.info.component_placed": "Component Placed",
"settings.info.placed_count": "Placed Count",
"settings.info.preview_count": "Preview Count",
"settings.info.placement_ids": "Placement Ids",
"settings.info.last_component_id": "Last Component Id",
"settings.info.last_cell_size": "Last Cell Size",
"settings.info.clock_service_time": "Clock Service Time",
"settings.status.updated_at": "Updated: {0}",
"status.frontend.title": "Frontend Status",
"status.component.title": "Component Status",
"status.backend.title": "Backend Status",
"status.service.title": "Clock Service",
"status.summary.pending": "Pending",
"status.summary.attached": "Attached",
"status.summary.healthy": "Healthy",
"status.summary.faulted": "Faulted",
"status.summary.placed": "Placed",
"status.summary.preview": "Preview",
"status.frontend.detail.pending": "Waiting for a plugin UI surface to connect.",
"status.frontend.detail.settings_connected": "Settings page is connected to plugin services and communication.",
"status.frontend.detail.widget_connected": "Widget surface is connected to plugin services and communication.",
"status.component.detail.pending": "No component instance has been created yet.",
"status.component.detail.none": "No component instance is active.",
"status.component.detail.preview": "Preview instances: {0}; no placed desktop instance is active yet.",
"status.component.detail.placed": "Placed count: {0}; preview count: {1}; placements: {2}",
"status.backend.detail.pending": "Plugin initialization is in progress.",
"status.backend.detail.log_written": "Initialization log written to: {0}",
"status.backend.detail.log_write_failed": "Initialization log write failed: {0}",
"status.service.detail.pending": "Clock service is not attached yet.",
"status.service.detail.attached": "Clock service was attached and is waiting for the first tick.",
"status.service.detail.running": "Clock service is running. Current service time: {0}",
"status.service.detail.write_failed": "Clock state write failed: {0}",
"capability.manifest.title": "IPluginContext.Manifest",
"capability.manifest.detail": "Readable. Current plugin id: {0}; version: {1}.",
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
"capability.directories.detail": "Readable. Plugin directory: {0}; data directory: {1}.",
"capability.properties.title": "IPluginContext.Properties",
"capability.properties.detail": "Readable. Host properties currently exposed: {0}.",
"capability.get_service.title": "IPluginContext.GetService<T>()",
"capability.get_service.detail": "Callable. State service resolved: {0}; clock service resolved: {1}; message bus resolved: {2}.",
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
"capability.register_service.detail": "Callable during plugin initialization. This sample plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container.",
"capability.message_bus.title": "Plugin Communication Bus",
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
"capability.widget_context.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
"widget.subtitle.preview": "Preview surface | placed: {0}",
"widget.subtitle.placement": "Placement {0} | placed: {1}",
"common.dev": "dev",
"common.none": "(none)",
"common.unknown": "(unknown)",
"common.true": "true",
"common.false": "false",
"common.yes": "Yes",
"common.no": "No"
}

View File

@@ -1,79 +0,0 @@
{
"settings.page_title": "插件状态",
"plugin.name": "阑山示例插件",
"plugin.description": "用于验证 PluginSdk 加载、服务、通信与本地化能力的示例插件。",
"widget.display_name": "示例插件状态时钟",
"widget.category": "插件",
"settings.header.title": "示例插件能力检查器",
"settings.section.info": "插件信息",
"settings.section.capabilities": "可访问能力",
"settings.section.status": "实时运行状态",
"settings.info.plugin_name": "插件名称",
"settings.info.plugin_id": "插件 Id",
"settings.info.version": "版本",
"settings.info.author": "作者",
"settings.info.description": "描述",
"settings.info.plugin_directory": "插件目录",
"settings.info.data_directory": "数据目录",
"settings.info.host_application": "宿主应用",
"settings.info.host_version": "宿主版本",
"settings.info.sdk_api_version": "SDK API 版本",
"settings.info.state_service_resolved": "状态服务已解析",
"settings.info.clock_service_resolved": "时钟服务已解析",
"settings.info.message_bus_resolved": "消息总线已解析",
"settings.info.component_placed": "组件是否已放置",
"settings.info.placed_count": "已放置数量",
"settings.info.preview_count": "预览数量",
"settings.info.placement_ids": "放置位置 Id",
"settings.info.last_component_id": "最近组件 Id",
"settings.info.last_cell_size": "最近单元尺寸",
"settings.info.clock_service_time": "时钟服务时间",
"settings.status.updated_at": "更新时间:{0}",
"status.frontend.title": "前端状态",
"status.component.title": "组件状态",
"status.backend.title": "后端状态",
"status.service.title": "时钟服务",
"status.summary.pending": "等待中",
"status.summary.attached": "已挂接",
"status.summary.healthy": "正常",
"status.summary.faulted": "异常",
"status.summary.placed": "已放置",
"status.summary.preview": "预览中",
"status.frontend.detail.pending": "等待插件界面接入。",
"status.frontend.detail.settings_connected": "设置页已接入插件服务与通信。",
"status.frontend.detail.widget_connected": "组件界面已接入插件服务与通信。",
"status.component.detail.pending": "当前还没有创建组件实例。",
"status.component.detail.none": "当前没有活动中的组件实例。",
"status.component.detail.preview": "当前预览实例数量:{0};尚未有已放置的桌面实例。",
"status.component.detail.placed": "已放置数量:{0};预览数量:{1};放置位置:{2}",
"status.backend.detail.pending": "插件初始化进行中。",
"status.backend.detail.log_written": "初始化日志已写入:{0}",
"status.backend.detail.log_write_failed": "初始化日志写入失败:{0}",
"status.service.detail.pending": "时钟服务尚未挂接。",
"status.service.detail.attached": "时钟服务已挂接,正在等待第一次心跳。",
"status.service.detail.running": "时钟服务运行中,当前服务时间:{0}",
"status.service.detail.write_failed": "时钟状态写入失败:{0}",
"capability.manifest.title": "IPluginContext.Manifest",
"capability.manifest.detail": "可读取。当前插件 id{0};版本:{1}。",
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
"capability.directories.detail": "可读取。插件目录:{0};数据目录:{1}。",
"capability.properties.title": "IPluginContext.Properties",
"capability.properties.detail": "可读取。宿主当前暴露的属性:{0}。",
"capability.get_service.title": "IPluginContext.GetService<T>()",
"capability.get_service.detail": "可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
"capability.register_service.detail": "可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。",
"capability.message_bus.title": "插件通信总线",
"capability.message_bus.detail": "这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。",
"capability.widget_context.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "组件可以读取 ComponentId、PlacementId、CellSize并能在同一个插件服务容器上调用 GetService<T>()。",
"widget.subtitle.preview": "预览界面 | 已放置:{0}",
"widget.subtitle.placement": "位置 {0} | 已放置:{1}",
"common.dev": "开发版",
"common.none": "(无)",
"common.unknown": "(未知)",
"common.true": "是",
"common.false": "否",
"common.yes": "是",
"common.no": "否"
}

View File

@@ -1,9 +0,0 @@
# LanMountainDesktop.SamplePlugin
## 中文
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
## English
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.

View File

@@ -1,92 +0,0 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginRuntimeStateService? _stateService;
private SamplePluginClockService? _clockService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
_stateService = new SamplePluginRuntimeStateService(
context.Manifest,
context.PluginDirectory,
context.DataDirectory,
hostName,
hostVersion,
sdkApiVersion,
messageBus,
localizer);
context.RegisterService(_stateService);
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
context.RegisterService(_clockService);
_stateService.AttachClockService(_clockService);
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
var initMessage =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
try
{
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady(localizer.Format(
"status.backend.detail.log_written",
"初始化日志已写入:{0}",
logPath));
}
catch (Exception ex)
{
_stateService.MarkBackendFaulted(localizer.Format(
"status.backend.detail.log_write_failed",
"初始化日志写入失败:{0}",
ex.Message));
throw;
}
_clockService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
localizer.GetString("settings.page_title", "插件状态"),
() => new SamplePluginSettingsView(context)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
localizer.GetString("widget.display_name", "示例插件状态时钟"),
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: localizer.GetString("widget.category", "插件"),
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
}
public void Dispose()
{
_clockService?.Dispose();
_clockService = null;
_stateService = null;
}
private static string GetHostProperty(IPluginContext context, string key, string fallback)
{
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: fallback;
}
}

View File

@@ -1,524 +0,0 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal enum SamplePluginHealthState
{
Healthy,
Pending,
Faulted
}
internal sealed record SamplePluginStatusEntry(
string Key,
string Title,
SamplePluginHealthState State,
string Summary,
string Detail,
DateTimeOffset UpdatedAt);
internal sealed record SamplePluginCapabilityItem(
string Title,
string Detail);
internal sealed record SamplePluginRuntimeSnapshot(
PluginManifest Manifest,
string PluginDirectory,
string DataDirectory,
string HostApplicationName,
string HostVersion,
string SdkApiVersion,
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
bool HasPlacedComponent,
int PlacedCount,
int PreviewCount,
IReadOnlyList<string> PlacementIds,
string? LastComponentId,
double LastCellSize,
DateTimeOffset? ServiceClockTime);
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
internal sealed record SamplePluginStateChangedMessage(string Reason);
internal sealed record SamplePluginComponentInstance(
string ComponentId,
string? PlacementId,
double CellSize)
{
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
}
internal sealed class SamplePluginRuntimeStateService
{
private readonly object _gate = new();
private readonly IPluginMessageBus _messageBus;
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
new(StringComparer.OrdinalIgnoreCase);
private readonly PluginManifest _manifest;
private readonly string _pluginDirectory;
private readonly string _dataDirectory;
private readonly string _hostApplicationName;
private readonly string _hostVersion;
private readonly string _sdkApiVersion;
private readonly PluginLocalizer _localizer;
private SamplePluginStatusEntry _frontend;
private SamplePluginStatusEntry _component;
private SamplePluginStatusEntry _backend;
private SamplePluginStatusEntry _service;
private string? _lastComponentId;
private double _lastCellSize;
private DateTimeOffset? _serviceClockTime;
public SamplePluginRuntimeStateService(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
string hostApplicationName,
string hostVersion,
string sdkApiVersion,
IPluginMessageBus messageBus,
PluginLocalizer localizer)
{
_manifest = manifest;
_pluginDirectory = pluginDirectory;
_dataDirectory = dataDirectory;
_hostApplicationName = hostApplicationName;
_hostVersion = hostVersion;
_sdkApiVersion = sdkApiVersion;
_messageBus = messageBus;
_localizer = localizer;
_frontend = CreateEntry(
"frontend",
T("status.frontend.title", "前端状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.frontend.detail.pending", "等待插件界面接入。"));
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.component.detail.pending", "当前还没有创建组件实例。"));
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.backend.detail.pending", "插件初始化进行中。"));
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.service.detail.pending", "时钟服务尚未挂接。"));
}
public void AttachClockService(SamplePluginClockService clockService)
{
ArgumentNullException.ThrowIfNull(clockService);
lock (_gate)
{
_serviceClockTime = clockService.CurrentTime;
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Pending,
T("status.summary.attached", "已挂接"),
T("status.service.detail.attached", "时钟服务已挂接,正在等待第一次心跳。"));
}
PublishStateChanged("Clock service attached");
}
public void MarkFrontendReady(string detail)
{
lock (_gate)
{
_frontend = CreateEntry(
"frontend",
T("status.frontend.title", "前端状态"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
detail);
}
PublishStateChanged("Frontend updated");
}
public void MarkBackendReady(string detail)
{
lock (_gate)
{
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
detail);
}
PublishStateChanged("Backend updated");
}
public void MarkBackendFaulted(string detail)
{
lock (_gate)
{
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Faulted,
T("status.summary.faulted", "异常"),
detail);
}
PublishStateChanged("Backend faulted");
}
public void MarkClockServiceTick(DateTimeOffset currentTime)
{
lock (_gate)
{
_serviceClockTime = currentTime;
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
Tf(
"status.service.detail.running",
"时钟服务运行中,当前服务时间:{0}",
currentTime.LocalDateTime.ToString("HH:mm:ss")));
}
PublishStateChanged("Clock service tick");
}
public void MarkClockServiceFaulted(string detail)
{
lock (_gate)
{
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Faulted,
T("status.summary.faulted", "异常"),
detail);
}
PublishStateChanged("Clock service faulted");
}
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
{
var instanceId = Guid.NewGuid().ToString("N");
lock (_gate)
{
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
_lastComponentId = componentId;
_lastCellSize = cellSize;
UpdateComponentStatusNoLock();
}
PublishStateChanged("Component attached");
return instanceId;
}
public void UnregisterComponentInstance(string instanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
var removed = false;
lock (_gate)
{
removed = _componentInstances.Remove(instanceId);
if (removed)
{
UpdateComponentStatusNoLock();
}
}
if (removed)
{
PublishStateChanged("Component detached");
}
}
public SamplePluginRuntimeSnapshot GetSnapshot()
{
lock (_gate)
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
return new SamplePluginRuntimeSnapshot(
_manifest,
_pluginDirectory,
_dataDirectory,
_hostApplicationName,
_hostVersion,
_sdkApiVersion,
[_frontend, _component, _backend, _service],
placementIds.Length > 0,
placementIds.Length,
previewCount,
placementIds,
_lastComponentId,
_lastCellSize,
_serviceClockTime);
}
}
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
IPluginContext context,
bool hasStateService,
bool hasClockService,
bool hasMessageBus)
{
ArgumentNullException.ThrowIfNull(context);
var propertyNames = context.Properties.Count == 0
? T("common.none", "(无)")
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
return
[
new SamplePluginCapabilityItem(
T("capability.manifest.title", "IPluginContext.Manifest"),
Tf(
"capability.manifest.detail",
"可读取。当前插件 id{0};版本:{1}。",
context.Manifest.Id,
context.Manifest.Version ?? T("common.dev", "开发版"))),
new SamplePluginCapabilityItem(
T("capability.directories.title", "IPluginContext.PluginDirectory / DataDirectory"),
Tf(
"capability.directories.detail",
"可读取。插件目录:{0};数据目录:{1}。",
context.PluginDirectory,
context.DataDirectory)),
new SamplePluginCapabilityItem(
T("capability.properties.title", "IPluginContext.Properties"),
Tf(
"capability.properties.detail",
"可读取。宿主当前暴露的属性:{0}。",
propertyNames)),
new SamplePluginCapabilityItem(
T("capability.get_service.title", "IPluginContext.GetService<T>()"),
Tf(
"capability.get_service.detail",
"可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
FormatBoolean(hasStateService),
FormatBoolean(hasClockService),
FormatBoolean(hasMessageBus))),
new SamplePluginCapabilityItem(
T("capability.register_service.title", "IPluginContext.RegisterService<TService>()"),
T(
"capability.register_service.detail",
"可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。")),
new SamplePluginCapabilityItem(
T("capability.message_bus.title", "插件通信总线"),
T(
"capability.message_bus.detail",
"这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。")),
new SamplePluginCapabilityItem(
T("capability.widget_context.title", "PluginDesktopComponentContext"),
T(
"capability.widget_context.detail",
"组件可以读取 ComponentId、PlacementId、CellSize并能在同一个插件服务容器上调用 GetService<T>()。"))
];
}
private void UpdateComponentStatusNoLock()
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
if (placementIds.Length > 0)
{
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Healthy,
T("status.summary.placed", "已放置"),
Tf(
"status.component.detail.placed",
"已放置数量:{0};预览数量:{1};放置位置:{2}",
placementIds.Length,
previewCount,
string.Join(", ", placementIds)));
return;
}
if (previewCount > 0)
{
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Healthy,
T("status.summary.preview", "预览中"),
Tf(
"status.component.detail.preview",
"当前预览实例数量:{0};尚未有已放置的桌面实例。",
previewCount));
return;
}
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.component.detail.none", "当前没有活动中的组件实例。"));
}
private void PublishStateChanged(string reason)
{
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
}
private static SamplePluginStatusEntry CreateEntry(
string key,
string title,
SamplePluginHealthState state,
string summary,
string detail)
{
return new SamplePluginStatusEntry(
key,
title,
state,
summary,
detail,
DateTimeOffset.Now);
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
private string FormatBoolean(bool value)
{
return value
? T("common.true", "是")
: T("common.false", "否");
}
}
internal sealed class SamplePluginClockService : IDisposable
{
private readonly object _gate = new();
private readonly string _clockStateFilePath;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly IPluginMessageBus _messageBus;
private readonly PluginLocalizer _localizer;
private readonly Timer _timer;
private DateTimeOffset _currentTime = DateTimeOffset.Now;
private int _disposed;
public SamplePluginClockService(
string dataDirectory,
SamplePluginRuntimeStateService stateService,
IPluginMessageBus messageBus,
PluginLocalizer localizer)
{
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
_stateService = stateService;
_messageBus = messageBus;
_localizer = localizer;
_timer = new Timer(OnTimerTick);
}
public DateTimeOffset CurrentTime
{
get
{
lock (_gate)
{
return _currentTime;
}
}
}
public void Start()
{
PublishTick();
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_timer.Dispose();
}
private void OnTimerTick(object? state)
{
PublishTick();
}
private void PublishTick()
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
var now = DateTimeOffset.Now;
lock (_gate)
{
_currentTime = now;
}
try
{
File.WriteAllText(
_clockStateFilePath,
now.ToString("O", CultureInfo.InvariantCulture));
_stateService.MarkClockServiceTick(now);
_messageBus.Publish(new SamplePluginClockTickMessage(now));
}
catch (Exception ex)
{
_stateService.MarkClockServiceFaulted(_localizer.Format(
"status.service.detail.write_failed",
"时钟状态写入失败:{0}",
ex.Message));
}
}
}

View File

@@ -1,374 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginSettingsView : UserControl
{
private readonly IPluginContext _context;
private readonly PluginLocalizer _localizer;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
private readonly List<IDisposable> _subscriptions = [];
public SamplePluginSettingsView(IPluginContext context)
{
_context = context;
_localizer = PluginLocalizer.Create(context);
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
_stateService.MarkFrontendReady(T(
"status.frontend.detail.settings_connected",
"设置页已接入插件服务与通信。"));
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
Content = new Border
{
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#1F0B1120"), 0),
new GradientStop(Color.Parse("#260C4A6E"), 1)
]
},
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(18),
Child = new StackPanel
{
Spacing = 14,
Children =
{
new TextBlock
{
Text = T("settings.header.title", "示例插件能力检查器"),
FontSize = 22,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
CreateSection(T("settings.section.info", "插件信息"), _pluginInfoPanel),
CreateSection(T("settings.section.capabilities", "可访问能力"), _capabilityPanel),
CreateSection(T("settings.section.status", "实时运行状态"), _statusPanel)
}
}
};
RefreshView();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
SubscribeToPluginBus();
RefreshView();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
}
private void SubscribeToPluginBus()
{
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
}
private void RefreshView()
{
var snapshot = _stateService.GetSnapshot();
RefreshPluginInfo(snapshot);
RefreshCapabilities();
RefreshStatuses(snapshot);
}
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
{
_pluginInfoPanel.Children.Clear();
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.plugin_name", "插件名称"),
T("plugin.name", snapshot.Manifest.Name)));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_id", "插件 Id"), snapshot.Manifest.Id));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.version", "版本"), snapshot.Manifest.Version ?? T("common.dev", "开发版")));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.author", "作者"), snapshot.Manifest.Author ?? T("common.none", "(无)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.description", "描述"),
T("plugin.description", snapshot.Manifest.Description ?? T("common.none", "(无)"))));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_directory", "插件目录"), snapshot.PluginDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.data_directory", "数据目录"), snapshot.DataDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_application", "宿主应用"), snapshot.HostApplicationName));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_version", "宿主版本"), snapshot.HostVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.sdk_api_version", "SDK API 版本"), snapshot.SdkApiVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.state_service_resolved", "状态服务已解析"),
FormatBoolean(_context.GetService<SamplePluginRuntimeStateService>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.clock_service_resolved", "时钟服务已解析"),
FormatBoolean(_context.GetService<SamplePluginClockService>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.message_bus_resolved", "消息总线已解析"),
FormatBoolean(_context.GetService<IPluginMessageBus>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.component_placed", "组件是否已放置"),
snapshot.HasPlacedComponent ? T("common.yes", "是") : T("common.no", "否")));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.placed_count", "已放置数量"), snapshot.PlacedCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.preview_count", "预览数量"), snapshot.PreviewCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.placement_ids", "放置位置 Id"),
snapshot.PlacementIds.Count == 0 ? T("common.none", "(无)") : string.Join(", ", snapshot.PlacementIds)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.last_component_id", "最近组件 Id"),
snapshot.LastComponentId ?? T("common.none", "(无)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.last_cell_size", "最近单元尺寸"),
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : T("common.unknown", "(未知)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.clock_service_time", "时钟服务时间"),
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
}
private void RefreshCapabilities()
{
var capabilities = _stateService.GetCapabilities(
_context,
_context.GetService<SamplePluginRuntimeStateService>() is not null,
_context.GetService<SamplePluginClockService>() is not null,
_context.GetService<IPluginMessageBus>() is not null);
_capabilityPanel.Children.Clear();
foreach (var capability in capabilities)
{
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
}
}
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
{
_statusPanel.Children.Clear();
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
CreateStatusHeader(entry, palette),
new TextBlock
{
Text = entry.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = Tf("settings.status.updated_at", "更新时间:{0}", entry.UpdatedAt.LocalDateTime.ToString("HH:mm:ss")),
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
}
}
}
});
}
}
private Border CreateSection(string title, Control content)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = title,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
content
}
}
};
}
private Control CreateInfoLine(string label, string value)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("180,*"),
ColumnSpacing = 10
};
var labelText = new TextBlock
{
Text = label,
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
};
var valueText = new TextBlock
{
Text = value,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
};
grid.Children.Add(labelText);
grid.Children.Add(valueText);
Grid.SetColumn(valueText, 1);
return grid;
}
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = item.Title,
Foreground = Brushes.White,
FontWeight = FontWeight.SemiBold
},
new TextBlock
{
Text = item.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
private static Control CreateStatusHeader(
SamplePluginStatusEntry entry,
(Color Background, Color Border, Color Dot) palette)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8
};
var dot = new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
};
var title = new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
};
var summary = new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
};
grid.Children.Add(dot);
grid.Children.Add(title);
grid.Children.Add(summary);
Grid.SetColumn(title, 1);
Grid.SetColumn(summary, 2);
return grid;
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F115E59"),
Color.Parse("#665EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#291B1B"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#2B3A2A0D"),
Color.Parse("#66FBBF24"),
Color.Parse("#FBBF24"))
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
private string FormatBoolean(bool value)
{
return value
? T("common.true", "是")
: T("common.false", "否");
}
}

View File

@@ -1,298 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginStatusClockWidget : Border
{
private readonly PluginDesktopComponentContext _context;
private readonly PluginLocalizer _localizer;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly TextBlock _timeTextBlock;
private readonly TextBlock _subtitleTextBlock;
private readonly StackPanel _statusPanel;
private readonly Border _statusHost;
private readonly List<IDisposable> _subscriptions = [];
private string? _instanceId;
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
{
_context = context;
_localizer = PluginLocalizer.Create(context);
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
_timeTextBlock = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Left
};
_subtitleTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
HorizontalAlignment = HorizontalAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 8
};
_statusHost = new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
Child = _statusPanel
};
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF07111F"), 0),
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
BorderThickness = new Thickness(1);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 14,
Children =
{
new StackPanel
{
Spacing = 4,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
_timeTextBlock,
_subtitleTextBlock
}
},
_statusHost
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
if (string.IsNullOrWhiteSpace(_instanceId))
{
_instanceId = _stateService.RegisterComponentInstance(
_context.ComponentId,
_context.PlacementId,
_context.CellSize);
}
_stateService.MarkFrontendReady(T(
"status.frontend.detail.widget_connected",
"组件界面已接入插件服务与通信。"));
SubscribeToPluginBus();
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
if (string.IsNullOrWhiteSpace(_instanceId))
{
return;
}
_stateService.UnregisterComponentInstance(_instanceId);
_instanceId = null;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
RefreshStatusPanel();
}
private void SubscribeToPluginBus()
{
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(() =>
{
UpdateSubtitle();
RefreshStatusPanel();
})));
}
private void RefreshClock(DateTimeOffset currentTime)
{
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
}
private void UpdateSubtitle()
{
var snapshot = _stateService.GetSnapshot();
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
}
private void RefreshStatusPanel()
{
_statusPanel.Children.Clear();
var snapshot = _stateService.GetSnapshot();
var basis = GetLayoutBasis();
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(10, 8),
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = Math.Clamp(basis * 0.038, 8, 11),
Height = Math.Clamp(basis * 0.038, 8, 11),
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = titleSize,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = entry.Summary,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right,
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Detail,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
TextWrapping = TextWrapping.Wrap
}
}
}
});
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
Grid.SetColumnSpan(row.Children[3], 3);
Grid.SetRow(row.Children[3], 1);
}
}
private void ApplyScale()
{
var basis = GetLayoutBasis();
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
}
private double GetLayoutBasis()
{
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F0F766E"),
Color.Parse("#4D5EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#29B91C1C"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#1F7C2D12"),
Color.Parse("#66FDBA74"),
Color.Parse("#FDBA74"))
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
}

View File

@@ -1,9 +0,0 @@
{
"id": "LanMountainDesktop.SamplePlugin",
"name": "LanMountain Sample Plugin",
"description": "Example plugin used to validate PluginSdk loading and isolation.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
}

View File

@@ -1,11 +0,0 @@
# 示例插件目录
## 中文
本目录用于存放阑山桌面的示例插件和参考实现。
当前标准示例为 `LanMountainDesktop.SamplePlugin`
## English
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.

View File

@@ -1,9 +0,0 @@
# 插件标准说明
## 中文
本目录存放插件开发需要遵循的基础约定,包括 `.laapp``plugin.json``Localization/` 以及仓库根目录 README 和安装包等要求。
## English
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.

View File

@@ -1,9 +0,0 @@
{
"id": "LanMountainDesktop.YourPlugin",
"name": "Your Plugin",
"description": "Describe what your plugin adds to LanMountainDesktop.",
"author": "Your Name",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
}

View File

@@ -1,136 +0,0 @@
using System.IO.Compression;
using LanMountainDesktop.PluginSdk;
return await RunAsync(args);
static async Task<int> RunAsync(string[] args)
{
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
{
PrintUsage();
return 0;
}
string? inputDirectory = null;
string? outputPath = null;
var overwrite = false;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--input":
inputDirectory = ReadValue(args, ref i, "--input");
break;
case "--output":
outputPath = ReadValue(args, ref i, "--output");
break;
case "--overwrite":
overwrite = true;
break;
default:
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
}
}
if (string.IsNullOrWhiteSpace(inputDirectory))
{
throw new InvalidOperationException("Missing required argument '--input'.");
}
var fullInputDirectory = Path.GetFullPath(inputDirectory);
if (!Directory.Exists(fullInputDirectory))
{
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
}
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException(
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
manifestPath);
}
var manifest = PluginManifest.Load(manifestPath);
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
if (!File.Exists(entranceAssemblyPath))
{
throw new FileNotFoundException(
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
entranceAssemblyPath);
}
outputPath ??= Path.Combine(
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
BuildPackageFileName(manifest.Id));
var fullOutputPath = Path.GetFullPath(outputPath);
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
}
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
if (string.IsNullOrWhiteSpace(destinationDirectory))
{
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
}
Directory.CreateDirectory(destinationDirectory);
if (File.Exists(fullOutputPath))
{
if (!overwrite)
{
throw new InvalidOperationException(
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
}
File.Delete(fullOutputPath);
}
await Task.Run(() => ZipFile.CreateFromDirectory(
fullInputDirectory,
fullOutputPath,
CompressionLevel.Optimal,
includeBaseDirectory: false));
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
return 0;
}
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
{
var nextIndex = index + 1;
if (nextIndex >= args.Count)
{
throw new InvalidOperationException($"Missing value for '{optionName}'.");
}
index = nextIndex;
return args[nextIndex];
}
static string BuildPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return safeName + PluginSdkInfo.PackageFileExtension;
}
static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
static void PrintUsage()
{
Console.WriteLine("LanMountainDesktop.PluginPackager");
Console.WriteLine("Usage:");
Console.WriteLine(" --input <plugin build directory> Required");
Console.WriteLine(" --output <path to .laapp> Optional");
Console.WriteLine(" --overwrite Optional");
}

View File

@@ -0,0 +1,27 @@
using Avalonia;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
namespace LanMountainDesktop.Appearance;
public static class AppearanceCornerRadiusTokenFactory
{
public static AppearanceCornerRadiusTokens Create(double scale)
{
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(10, normalizedScale),
Radius(14, normalizedScale),
Radius(18, normalizedScale),
Radius(24, normalizedScale),
Radius(30, normalizedScale),
Radius(36, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)
{
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
return new CornerRadius(scaled);
}
}

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using System;
using Avalonia;
namespace LanMountainDesktop.DesktopHost;
public static class DesktopBootstrap
{
public static void InitializeStartupServices(
Action initializeTelemetryIdentity,
Action initializeCrashTelemetry,
Action initializeUsageTelemetry,
Action scheduleStartupCleanup)
{
ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
initializeTelemetryIdentity();
initializeCrashTelemetry();
initializeUsageTelemetry();
scheduleStartupCleanup();
}
public static void InitializeApplication(Application application, Action initializeShell)
{
ArgumentNullException.ThrowIfNull(application);
ArgumentNullException.ThrowIfNull(initializeShell);
initializeShell();
}
}

View File

@@ -0,0 +1,55 @@
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using LanMountainDesktop.Host.Abstractions;
namespace LanMountainDesktop.DesktopHost;
public sealed class DesktopShellHost : IDesktopShellHost
{
private readonly Action _initializePluginRuntime;
private readonly Action _initializeTrayIcon;
private readonly Action<IClassicDesktopStyleApplicationLifetime> _createAndAssignMainWindow;
private readonly Action _performExitCleanup;
private readonly Action _startActivationListener;
private readonly Action _startWeatherRefresh;
public DesktopShellHost(
Action initializePluginRuntime,
Action initializeTrayIcon,
Action<IClassicDesktopStyleApplicationLifetime> createAndAssignMainWindow,
Action performExitCleanup,
Action startActivationListener,
Action startWeatherRefresh)
{
_initializePluginRuntime = initializePluginRuntime;
_initializeTrayIcon = initializeTrayIcon;
_createAndAssignMainWindow = createAndAssignMainWindow;
_performExitCleanup = performExitCleanup;
_startActivationListener = startActivationListener;
_startWeatherRefresh = startWeatherRefresh;
}
public void Initialize()
{
throw new InvalidOperationException("An application instance is required to initialize the desktop shell.");
}
public void Initialize(Application application)
{
ArgumentNullException.ThrowIfNull(application);
_initializePluginRuntime();
_initializeTrayIcon();
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Exit += (_, _) => _performExitCleanup();
_createAndAssignMainWindow(desktop);
_startActivationListener();
}
_startWeatherRefresh();
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace LanMountainDesktop.DesktopHost;
public sealed class DesktopStartupCoordinator
{
private readonly Action _restoreWorkspaceState;
public DesktopStartupCoordinator(Action restoreWorkspaceState)
{
_restoreWorkspaceState = restoreWorkspaceState ?? throw new ArgumentNullException(nameof(restoreWorkspaceState));
}
public void Restore() => _restoreWorkspaceState();
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,18 @@
using System;
namespace LanMountainDesktop.DesktopHost;
public sealed class SettingsWindowHost
{
private readonly Action<string, string?> _openSettingsWindow;
public SettingsWindowHost(Action<string, string?> openSettingsWindow)
{
_openSettingsWindow = openSettingsWindow ?? throw new ArgumentNullException(nameof(openSettingsWindow));
}
public void Open(string source, string? pageId = null)
{
_openSettingsWindow(source, pageId);
}
}

View File

@@ -0,0 +1,19 @@
using System;
namespace LanMountainDesktop.DesktopHost;
public sealed class ShutdownCoordinator
{
private readonly Action<bool, string> _prepareForShutdown;
private readonly Action<string> _resetShutdownIntent;
public ShutdownCoordinator(Action<bool, string> prepareForShutdown, Action<string> resetShutdownIntent)
{
_prepareForShutdown = prepareForShutdown ?? throw new ArgumentNullException(nameof(prepareForShutdown));
_resetShutdownIntent = resetShutdownIntent ?? throw new ArgumentNullException(nameof(resetShutdownIntent));
}
public void Prepare(bool isRestart, string source) => _prepareForShutdown(isRestart, source);
public void Reset(string source) => _resetShutdownIntent(source);
}

View File

@@ -0,0 +1,12 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Shared.Contracts;
namespace LanMountainDesktop.Host.Abstractions;
public sealed record ComponentChromeContext(
string ComponentId,
string? PlacementId,
double CellSize,
double GlobalCornerRadiusScale,
AppearanceCornerRadiusTokens CornerRadiusTokens,
SettingsScope Scope = SettingsScope.App);

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.Host.Abstractions;
public interface IDesktopShellHost
{
void Initialize();
}

View File

@@ -1,11 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.PluginSdk;
public interface IComponentEditorHostContext
{
void RequestRefresh();
void CloseEditor();
void RequestRestart(string? reason = null);
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IComponentSettingsAccessor
{
string ComponentId { get; }
string? PlacementId { get; }
T LoadSnapshot<T>() where T : new();
void SaveSnapshot<T>(T snapshot, IReadOnlyCollection<string>? changedKeys = null);
T LoadSection<T>(string sectionId) where T : new();
void SaveSection<T>(string sectionId, T section, IReadOnlyCollection<string>? changedKeys = null);
void DeleteSection(string sectionId);
T? GetValue<T>(string key);
void SetValue<T>(string key, T value, IReadOnlyCollection<string>? changedKeys = null);
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record HostApplicationLifecycleRequest(
string? Source = null,
string? Reason = null);
public interface IHostApplicationLifecycle
{
bool TryExit(HostApplicationLifecycleRequest? request = null);
bool TryRestart(HostApplicationLifecycleRequest? request = null);
}

View File

@@ -1,6 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.PluginSdk;
public interface IPlugin
{
void Initialize(IPluginContext context);
void Initialize(HostBuilderContext context, IServiceCollection services);
}

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginAppearanceContext
{
PluginAppearanceSnapshot Snapshot { get; }
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
}

View File

@@ -1,27 +1,6 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginContext
[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
public interface IPluginContext : IPluginRuntimeContext
{
PluginManifest Manifest { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IServiceProvider Services { get; }
IReadOnlyDictionary<string, object?> Properties { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
void RegisterService<TService>(TService service)
where TService : class;
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
}

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginExportRegistry
{
IReadOnlyList<PluginServiceExportDescriptor> GetExports();
IReadOnlyList<PluginServiceExportDescriptor> GetExports(Type contractType);
PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId);
TContract? GetExport<TContract>(string providerPluginId)
where TContract : class;
}

View File

@@ -0,0 +1,20 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginRuntimeContext
{
PluginManifest Manifest { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IServiceProvider Services { get; }
IReadOnlyDictionary<string, object?> Properties { get; }
IPluginAppearanceContext Appearance { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
}

View File

@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginSettingsService
{
string PluginId { get; }
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
T LoadComponentSection<T>(string componentId, string? placementId, string sectionId) where T : new();
void SaveComponentSection<T>(
string componentId,
string? placementId,
string sectionId,
T section,
IReadOnlyCollection<string>? changedKeys = null);
void DeleteComponentSection(string componentId, string? placementId, string sectionId);
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsCatalog
{
IReadOnlyList<SettingsSectionDefinition> GetSections();
IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope);
}

View File

@@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsPageHostContext
{
void OpenDrawer(Control content, string? title = null);
void CloseDrawer();
void RequestRestart(string? reason = null);
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsService
{
event EventHandler<SettingsChangedEvent>? Changed;
T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new();
void SaveSnapshot<T>(
SettingsScope scope,
T snapshot,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null);
T LoadSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null) where T : new();
void SaveSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
T section,
string? placementId = null,
IReadOnlyCollection<string>? changedKeys = null);
void DeleteSection(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null);
T? GetValue<T>(
SettingsScope scope,
string key,
string? subjectId = null,
string? placementId = null,
string? sectionId = null);
void SetValue<T>(
SettingsScope scope,
string key,
T value,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null);
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
}

View File

@@ -1,15 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<Version>4.0.0</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Official plugin SDK for LanMountainDesktop, including plugin manifest contracts, runtime interfaces, and registration extensions.</Description>
<PackageTags>LanMountainDesktop;Plugin;SDK;Avalonia</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<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" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.props" Pack="true" PackagePath="buildTransitive\" />
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.targets" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,49 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginAppearanceContext : IPluginAppearanceContext
{
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
Snapshot = snapshot with
{
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
? "Unknown"
: snapshot.ThemeVariant.Trim()
};
}
public PluginAppearanceSnapshot Snapshot { get; }
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{
var scale = Snapshot.GlobalCornerRadiusScale;
var scaled = Math.Max(0d, baseRadius) * scale;
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
return minimum.HasValue || maximum.HasValue
? Math.Clamp(scaled, scaledMin, scaledMax)
: scaled;
}
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
{
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
if (!minimum.HasValue && !maximum.HasValue)
{
return resolved;
}
var clampedMin = minimum ?? resolved;
var clampedMax = maximum ?? resolved;
if (clampedMin > clampedMax)
{
(clampedMin, clampedMax) = (clampedMax, clampedMin);
}
return Math.Clamp(resolved, clampedMin, clampedMax);
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginAppearanceSnapshot(
double GlobalCornerRadiusScale,
PluginCornerRadiusTokens CornerRadiusTokens,
string ThemeVariant);

View File

@@ -1,8 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.PluginSdk;
public abstract class PluginBase : IPlugin
{
public virtual void Initialize(IPluginContext context)
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
{
}
}

View File

@@ -0,0 +1,13 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginCornerRadiusPreset
{
Default = 0,
Micro = 1,
Xs = 2,
Sm = 3,
Md = 4,
Lg = 5,
Xl = 6,
Island = 7
}

View File

@@ -0,0 +1,49 @@
using Avalonia;
using LanMountainDesktop.Shared.Contracts;
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginCornerRadiusTokens(
double Micro,
double Xs,
double Sm,
double Md,
double Lg,
double Xl,
double Island)
{
public double Get(PluginCornerRadiusPreset preset)
{
return preset switch
{
PluginCornerRadiusPreset.Default => Md,
PluginCornerRadiusPreset.Micro => Micro,
PluginCornerRadiusPreset.Xs => Xs,
PluginCornerRadiusPreset.Sm => Sm,
PluginCornerRadiusPreset.Md => Md,
PluginCornerRadiusPreset.Lg => Lg,
PluginCornerRadiusPreset.Xl => Xl,
PluginCornerRadiusPreset.Island => Island,
_ => Md
};
}
public CornerRadius ToCornerRadius(PluginCornerRadiusPreset preset)
{
return new CornerRadius(Get(preset));
}
public static PluginCornerRadiusTokens FromShared(AppearanceCornerRadiusTokens tokens)
{
ArgumentNullException.ThrowIfNull(tokens);
return new PluginCornerRadiusTokens(
tokens.Micro.TopLeft,
tokens.Xs.TopLeft,
tokens.Sm.TopLeft,
tokens.Md.TopLeft,
tokens.Lg.TopLeft,
tokens.Xl.TopLeft,
tokens.Island.TopLeft);
}
}

View File

@@ -10,7 +10,9 @@ public sealed class PluginDesktopComponentContext
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
double cellSize)
double cellSize,
IPluginAppearanceContext appearance,
IPluginSettingsService? pluginSettings = null)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
@@ -18,6 +20,7 @@ public sealed class PluginDesktopComponentContext
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(appearance);
Manifest = manifest;
PluginDirectory = pluginDirectory;
@@ -27,6 +30,8 @@ public sealed class PluginDesktopComponentContext
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
CellSize = Math.Max(1, cellSize);
Appearance = appearance;
PluginSettings = pluginSettings;
}
public PluginManifest Manifest { get; }
@@ -45,6 +50,24 @@ public sealed class PluginDesktopComponentContext
public double CellSize { get; }
public IPluginAppearanceContext Appearance { get; }
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
public IPluginSettingsService? PluginSettings { get; }
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{
return Appearance.ResolveScaledCornerRadius(baseRadius, minimum, maximum);
}
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
{
return Appearance.ResolveCornerRadius(preset, minimum, maximum);
}
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));

View File

@@ -0,0 +1,71 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorContext
{
public PluginDesktopComponentEditorContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
IPluginSettingsService? pluginSettings,
IComponentEditorHostContext hostContext)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(hostContext);
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
PluginSettings = pluginSettings;
HostContext = hostContext;
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public string ComponentId { get; }
public string? PlacementId { get; }
public IPluginSettingsService? PluginSettings { get; }
public IComponentEditorHostContext HostContext { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorRegistration
{
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(editorFactory);
if (preferredWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
}
if (preferredHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
}
if (minScale <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minScale));
}
if (maxScale < minScale)
{
throw new ArgumentOutOfRangeException(nameof(maxScale));
}
ComponentId = componentId.Trim();
EditorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = preferredWidth / preferredHeight;
}
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
: this(
componentId,
(_, context) => editorFactory(context),
preferredWidth,
preferredHeight,
minScale,
maxScale)
{
}
public string ComponentId { get; }
public Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> EditorFactory { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
}

View File

@@ -0,0 +1,28 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentOptions
{
public required string ComponentId { get; init; }
public required string DisplayName { get; init; }
public string IconKey { get; init; } = "PuzzlePiece";
public string Category { get; init; } = "Plugins";
public int MinWidthCells { get; init; } = 2;
public int MinHeightCells { get; init; } = 2;
public bool AllowDesktopPlacement { get; init; } = true;
public bool AllowStatusBarPlacement { get; init; }
public PluginDesktopComponentResizeMode ResizeMode { get; init; } = PluginDesktopComponentResizeMode.Proportional;
public string? DisplayNameLocalizationKey { get; init; }
public PluginCornerRadiusPreset CornerRadiusPreset { get; init; } = PluginCornerRadiusPreset.Default;
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; init; }
}

View File

@@ -5,39 +5,38 @@ namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentRegistration
{
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece",
string category = "Plugins",
int minWidthCells = 2,
int minHeightCells = 2,
bool allowDesktopPlacement = true,
bool allowStatusBarPlacement = false,
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
string? displayNameLocalizationKey = null,
Func<double, double>? cornerRadiusResolver = null)
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
PluginDesktopComponentOptions options)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentNullException.ThrowIfNull(controlFactory);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.ComponentId);
ArgumentException.ThrowIfNullOrWhiteSpace(options.DisplayName);
ArgumentException.ThrowIfNullOrWhiteSpace(options.IconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(options.Category);
ComponentId = componentId.Trim();
DisplayName = displayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
ComponentId = options.ComponentId.Trim();
DisplayName = options.DisplayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
: options.DisplayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = iconKey.Trim();
Category = category.Trim();
MinWidthCells = Math.Max(1, minWidthCells);
MinHeightCells = Math.Max(1, minHeightCells);
AllowDesktopPlacement = allowDesktopPlacement;
AllowStatusBarPlacement = allowStatusBarPlacement;
ResizeMode = resizeMode;
CornerRadiusResolver = cornerRadiusResolver;
IconKey = options.IconKey.Trim();
Category = options.Category.Trim();
MinWidthCells = Math.Max(1, options.MinWidthCells);
MinHeightCells = Math.Max(1, options.MinHeightCells);
AllowDesktopPlacement = options.AllowDesktopPlacement;
AllowStatusBarPlacement = options.AllowStatusBarPlacement;
ResizeMode = options.ResizeMode;
CornerRadiusPreset = options.CornerRadiusPreset;
CornerRadiusResolver = options.CornerRadiusResolver;
}
public PluginDesktopComponentRegistration(
Func<PluginDesktopComponentContext, Control> controlFactory,
PluginDesktopComponentOptions options)
: this((_, context) => controlFactory(context), options)
{
}
public string ComponentId { get; }
@@ -46,7 +45,7 @@ public sealed class PluginDesktopComponentRegistration
public string? DisplayNameLocalizationKey { get; }
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }
@@ -62,5 +61,25 @@ public sealed class PluginDesktopComponentRegistration
public PluginDesktopComponentResizeMode ResizeMode { get; }
public Func<double, double>? CornerRadiusResolver { get; }
public PluginCornerRadiusPreset CornerRadiusPreset { get; }
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; }
public double ResolveCornerRadius(IPluginAppearanceContext appearance, double cellSize)
{
ArgumentNullException.ThrowIfNull(appearance);
var resolved = CornerRadiusResolver is not null
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
? appearance.ResolveScaledCornerRadius(
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
8,
18)
: appearance.ResolveCornerRadius(CornerRadiusPreset);
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
}
}

View File

@@ -26,7 +26,7 @@ public sealed class PluginLocalizer
public string LanguageCode { get; }
public static PluginLocalizer Create(IPluginContext context)
public static PluginLocalizer Create(IPluginRuntimeContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));

View File

@@ -9,7 +9,8 @@ public sealed record PluginManifest(
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null)
string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
@@ -57,6 +58,7 @@ public sealed record PluginManifest(
private PluginManifest NormalizeAndValidate(string manifestPath)
{
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
@@ -65,7 +67,8 @@ public sealed record PluginManifest(
Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts
};
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
@@ -82,7 +85,44 @@ public sealed record PluginManifest(
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
}
return normalized;
}
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
string manifestPath,
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
{
if (sharedContracts is null || sharedContracts.Count == 0)
{
return Array.Empty<PluginSharedContractReference>();
}
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var contract in sharedContracts)
{
if (contract is null)
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
}
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
if (!seenIds.Add(contractKey))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
}
normalized.Add(normalizedContract);
}
return normalized;

View File

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

View File

@@ -0,0 +1,103 @@
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public static class PluginServiceCollectionExtensions
{
public static IServiceCollection AddPluginSettingsSection(
this IServiceCollection services,
string id,
string titleLocalizationKey,
Action<PluginSettingsSectionBuilder> configure,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
var builder = new PluginSettingsSectionBuilder(
id,
titleLocalizationKey,
descriptionLocalizationKey,
iconKey,
sortOrder);
configure(builder);
services.AddSingleton(builder.Build());
return services;
}
public static IServiceCollection AddPluginDesktopComponent<TControl>(
this IServiceCollection services,
PluginDesktopComponentOptions options)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(options);
services.AddSingleton(new PluginDesktopComponentRegistration(
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
options));
return services;
}
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
this IServiceCollection services,
string componentId,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
componentId,
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
preferredWidth,
preferredHeight,
minScale,
maxScale));
return services;
}
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
existing.ContractType == typeof(TContract) &&
existing.ImplementationType == typeof(TImplementation)))
{
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
}
return services;
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
if (contractDescriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginServiceExportDescriptor(
string ProviderPluginId,
Type ContractType,
object ServiceInstance);

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginServiceExportRegistration
{
public PluginServiceExportRegistration(Type contractType, Type implementationType)
{
ArgumentNullException.ThrowIfNull(contractType);
ArgumentNullException.ThrowIfNull(implementationType);
ContractType = contractType;
ImplementationType = implementationType;
}
public Type ContractType { get; }
public Type ImplementationType { get; }
}

View File

@@ -1,30 +0,0 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsPageRegistration
{
public PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentNullException.ThrowIfNull(contentFactory);
Id = id.Trim();
Title = title.Trim();
ContentFactory = contentFactory;
SortOrder = sortOrder;
}
public string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<Control> ContentFactory { get; }
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionBuilder
{
private readonly List<SettingsOptionDefinition> _options = [];
internal PluginSettingsSectionBuilder(
string id,
string titleLocalizationKey,
string? descriptionLocalizationKey,
string iconKey,
int sortOrder)
{
Id = id;
TitleLocalizationKey = titleLocalizationKey;
DescriptionLocalizationKey = descriptionLocalizationKey;
IconKey = iconKey;
SortOrder = sortOrder;
}
public string Id { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
{
ArgumentNullException.ThrowIfNull(option);
_options.Add(option);
return this;
}
public PluginSettingsSectionBuilder AddToggle(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
bool defaultValue = false)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Toggle,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue));
}
public PluginSettingsSectionBuilder AddText(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string defaultValue = "",
string? validationPattern = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Text,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
validationPattern: validationPattern));
}
public PluginSettingsSectionBuilder AddNumber(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
double defaultValue = 0,
double? minimum = null,
double? maximum = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Number,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
minimum: minimum,
maximum: maximum));
}
public PluginSettingsSectionBuilder AddSelect(
string key,
string titleLocalizationKey,
IEnumerable<SettingsOptionChoice> choices,
string? descriptionLocalizationKey = null,
string? defaultValue = null)
{
ArgumentNullException.ThrowIfNull(choices);
var normalizedChoices = choices.ToArray();
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Select,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
normalizedChoices));
}
public PluginSettingsSectionBuilder AddPath(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string defaultValue = "")
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Path,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue));
}
public PluginSettingsSectionBuilder AddList(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
IReadOnlyList<string>? defaultValue = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.List,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue ?? Array.Empty<string>()));
}
internal PluginSettingsSectionRegistration Build()
{
return new PluginSettingsSectionRegistration(
Id,
TitleLocalizationKey,
_options.ToArray(),
DescriptionLocalizationKey,
IconKey,
SortOrder);
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionRegistration
{
public PluginSettingsSectionRegistration(
string id,
string titleLocalizationKey,
IReadOnlyList<SettingsOptionDefinition> options,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
Id = id.Trim();
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
IconKey = iconKey.Trim();
SortOrder = sortOrder;
Options = options ?? [];
}
public string Id { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
}

View File

@@ -0,0 +1,47 @@
using System.Text.Json.Serialization;
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginSharedContractReference(
string Id,
string Version,
string AssemblyName)
{
[JsonIgnore]
public string NormalizedId => Id.Trim();
[JsonIgnore]
public string NormalizedVersion => Version.Trim();
[JsonIgnore]
public string NormalizedAssemblyName => AssemblyName.Trim();
internal PluginSharedContractReference NormalizeAndValidate(string manifestPath)
{
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
Version = RequireValue(Version, nameof(Version), manifestPath),
AssemblyName = RequireValue(AssemblyName, nameof(AssemblyName), manifestPath)
};
if (!System.Version.TryParse(normalized.Version, out _))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares invalid shared contract version '{normalized.Version}' for '{normalized.Id}'.");
}
return normalized;
}
private static string RequireValue(string? value, string propertyName, string manifestPath)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' is missing required shared contract property '{propertyName}'.");
}
return value.Trim();
}
}

View File

@@ -0,0 +1,21 @@
# LanMountainDesktop.PluginSdk
Official SDK package for LanMountainDesktop plugins.
## Includes
- `IPlugin`/`PluginBase` entry abstractions
- `PluginManifest` and shared contract declarations
- desktop component registration extensions
- plugin runtime context and host service abstractions
- build-transitive packaging targets for `.laapp` output
## Quick Start
```xml
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
</ItemGroup>
```
Create `plugin.json` in your plugin project root, then run `dotnet build` to produce both build output and a `.laapp` package.

View File

@@ -0,0 +1,14 @@
namespace LanMountainDesktop.PluginSdk;
public static class SettingsCategories
{
public const string General = "General";
public const string Appearance = "Appearance";
public const string Components = "Components";
public const string Plugins = "Plugins";
public const string PluginMarket = "PluginMarket";
public const string Update = "Update";
public const string About = "About";
public const string Advanced = "Advanced";
public const string External = "External";
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsChangedEvent
{
public SettingsChangedEvent(
SettingsScope scope,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
Scope = scope;
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
SectionId = string.IsNullOrWhiteSpace(sectionId) ? null : sectionId.Trim();
ChangedKeys = changedKeys is { Count: > 0 }
? changedKeys.ToArray()
: [];
}
public SettingsScope Scope { get; }
public string? SubjectId { get; }
public string? PlacementId { get; }
public string? SectionId { get; }
public IReadOnlyCollection<string> ChangedKeys { get; }
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsOptionChoice
{
public SettingsOptionChoice(string value, string titleLocalizationKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
Value = value.Trim();
TitleLocalizationKey = titleLocalizationKey.Trim();
}
public string Value { get; }
public string TitleLocalizationKey { get; }
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsOptionDefinition
{
public SettingsOptionDefinition(
string key,
SettingsOptionType optionType,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
object? defaultValue = null,
IReadOnlyList<SettingsOptionChoice>? choices = null,
double? minimum = null,
double? maximum = null,
string? validationPattern = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
Key = key.Trim();
OptionType = optionType;
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
DefaultValue = defaultValue;
Choices = choices ?? [];
Minimum = minimum;
Maximum = maximum;
ValidationPattern = string.IsNullOrWhiteSpace(validationPattern)
? null
: validationPattern.Trim();
}
public string Key { get; }
public SettingsOptionType OptionType { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public object? DefaultValue { get; }
public IReadOnlyList<SettingsOptionChoice> Choices { get; }
public double? Minimum { get; }
public double? Maximum { get; }
public string? ValidationPattern { get; }
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsOptionType
{
Toggle = 0,
Select = 1,
Text = 2,
Number = 3,
Path = 4,
List = 5
}

View File

@@ -0,0 +1,54 @@
using System;
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public abstract class SettingsPageBase : UserControl
{
public static readonly string DialogHostIdentifier = "LanMountainDesktop.SettingsWindow";
private ISettingsPageHostContext? _hostContext;
public ISettingsPageHostContext? HostContext => _hostContext;
public Uri? NavigationUri { get; set; }
public void InitializeHostContext(ISettingsPageHostContext hostContext)
{
_hostContext = hostContext;
}
public virtual void OnNavigatedTo(object? parameter)
{
}
protected void OpenDrawer(Control content, string? title = null)
{
_hostContext?.OpenDrawer(content, title);
}
protected void OpenDrawer(object content, bool usePageDataContext = false, object? dataContext = null, string? title = null)
{
if (content is Control control && !usePageDataContext)
{
control.DataContext = dataContext ?? DataContext ?? this;
OpenDrawer(control, title);
return;
}
if (content is Control drawerControl)
{
OpenDrawer(drawerControl, title);
}
}
protected void CloseDrawer()
{
_hostContext?.CloseDrawer();
}
protected void RequestRestart(string? reason = null)
{
_hostContext?.RequestRestart(reason);
}
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsPageCategory
{
General = 0,
Appearance = 10,
Components = 20,
Plugins = 30,
PluginMarket = 35,
About = 40
}

View File

@@ -0,0 +1,46 @@
using System;
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class SettingsPageInfoAttribute : Attribute
{
public SettingsPageInfoAttribute(
string id,
string name,
SettingsPageCategory category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Id = id.Trim();
Name = name.Trim();
Category = category;
}
public string Id { get; }
public string Name { get; }
public SettingsPageCategory Category { get; }
public string? TitleLocalizationKey { get; init; }
public string? DescriptionLocalizationKey { get; init; }
public string IconKey { get; init; } = "Settings";
public string? SelectedIconKey { get; init; }
public int SortOrder { get; init; }
public bool HideDefault { get; init; }
public bool HidePageTitle { get; init; }
public bool UseFullWidth { get; init; }
public string? GroupId { get; init; }
public SettingsScope Scope { get; init; } = SettingsScope.App;
}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsScope
{
App = 0,
Launcher = 1,
Plugin = 2,
ComponentInstance = 3
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsSectionDefinition
{
public SettingsSectionDefinition(
string id,
string category,
SettingsScope scope,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string iconKey = "Settings",
int sortOrder = 0,
string? subjectId = null,
IReadOnlyList<SettingsOptionDefinition>? options = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
Id = id.Trim();
Category = category.Trim();
Scope = scope;
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
IconKey = iconKey.Trim();
SortOrder = sortOrder;
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
Options = options ?? [];
}
public string Id { get; }
public string Category { get; }
public SettingsScope Scope { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public string? SubjectId { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
}

View File

@@ -0,0 +1,14 @@
<Project>
<PropertyGroup>
<LanMountainPluginManifestFileName Condition="'$(LanMountainPluginManifestFileName)' == ''">plugin.json</LanMountainPluginManifestFileName>
<LanMountainPluginPackageExtension Condition="'$(LanMountainPluginPackageExtension)' == ''">.laapp</LanMountainPluginPackageExtension>
<LanMountainPluginPackageOutputDirectory Condition="'$(LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</LanMountainPluginPackageOutputDirectory>
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == '' and Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">true</LanMountainPluginEnablePackaging>
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == ''">false</LanMountainPluginEnablePackaging>
</PropertyGroup>
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">
<None Update="$(LanMountainPluginManifestFileName)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,51 @@
<Project>
<Target Name="ValidateLanMountainPluginManifest"
BeforeTargets="Build"
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
<Error Condition="!Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')"
Text="LanMountain plugin packaging is enabled, but '$(LanMountainPluginManifestFileName)' was not found in '$(MSBuildProjectDirectory)'." />
</Target>
<Target Name="CreateLanMountainPluginPackage"
AfterTargets="Build"
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
<PropertyGroup>
<_LanMountainPluginBuildOutputDirectory>$(LanMountainPluginBuildOutputDirectory)</_LanMountainPluginBuildOutputDirectory>
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(TargetDir)</_LanMountainPluginBuildOutputDirectory>
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath)</_LanMountainPluginBuildOutputDirectory>
<_LanMountainPluginAssemblyName>$(LanMountainPluginAssemblyName)</_LanMountainPluginAssemblyName>
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == '' and '$(AssemblyName)' != ''">$(AssemblyName)</_LanMountainPluginAssemblyName>
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == ''">$(MSBuildProjectName)</_LanMountainPluginAssemblyName>
<_LanMountainPluginPackageVersion>$(LanMountainPluginPackageVersion)</_LanMountainPluginPackageVersion>
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == '' and '$(Version)' != ''">$(Version)</_LanMountainPluginPackageVersion>
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == ''">1.0.0</_LanMountainPluginPackageVersion>
<_LanMountainPluginPackageOutputDirectory>$(LanMountainPluginPackageOutputDirectory)</_LanMountainPluginPackageOutputDirectory>
<_LanMountainPluginPackageOutputDirectory Condition="'$(_LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</_LanMountainPluginPackageOutputDirectory>
<_LanMountainPluginPackageFileName>$(LanMountainPluginPackageFileName)</_LanMountainPluginPackageFileName>
<_LanMountainPluginPackageFileName Condition="'$(_LanMountainPluginPackageFileName)' == ''">$(_LanMountainPluginAssemblyName).$(_LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</_LanMountainPluginPackageFileName>
<_LanMountainPluginPackagePath>$(LanMountainPluginPackagePath)</_LanMountainPluginPackagePath>
<_LanMountainPluginPackagePath Condition="'$(_LanMountainPluginPackagePath)' == ''">$(_LanMountainPluginPackageOutputDirectory)$(_LanMountainPluginPackageFileName)</_LanMountainPluginPackagePath>
<_LanMountainPluginManifestOutputPath>$(_LanMountainPluginBuildOutputDirectory)$(LanMountainPluginManifestFileName)</_LanMountainPluginManifestOutputPath>
<_LanMountainPluginDepsPath>$(ProjectDepsFilePath)</_LanMountainPluginDepsPath>
</PropertyGroup>
<Copy SourceFiles="$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)"
DestinationFiles="$(_LanMountainPluginManifestOutputPath)"
SkipUnchangedFiles="true"
Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')" />
<Error Condition="!Exists('$(_LanMountainPluginManifestOutputPath)')"
Text="Plugin manifest '$(_LanMountainPluginManifestOutputPath)' was not found in build output. Ensure '$(LanMountainPluginManifestFileName)' is copied to output." />
<Error Condition="!Exists('$(TargetPath)')"
Text="Plugin assembly '$(TargetPath)' was not found. Build output is incomplete." />
<Error Condition="'$(_LanMountainPluginDepsPath)' != '' and !Exists('$(_LanMountainPluginDepsPath)')"
Text="Plugin deps file '$(_LanMountainPluginDepsPath)' was not found. Plugin packages must include a .deps.json file." />
<MakeDir Directories="$(_LanMountainPluginPackageOutputDirectory)" />
<Delete Files="$(_LanMountainPluginPackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(_LanMountainPluginBuildOutputDirectory)"
DestinationFile="$(_LanMountainPluginPackagePath)" />
<Message Importance="High"
Text="LanMountain plugin package generated: $(_LanMountainPluginPackagePath)" />
</Target>
</Project>

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<PackageId>LanMountainDesktop.PluginTemplate</PackageId>
<Version>1.0.0</Version>
<Authors>LanMountainDesktop</Authors>
<Description>Official dotnet new template package for LanMountainDesktop plugins.</Description>
<PackageTags>LanMountainDesktop;Plugin;Template;dotnet-new</PackageTags>
<PackageType>Template</PackageType>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<IncludeBuildOutput>false</IncludeBuildOutput>
<IsPackable>true</IsPackable>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<Compile Remove="content\**\*.cs" />
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="content\**\*" Pack="true" PackagePath="content\" />
</ItemGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More