mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
298defb829 | ||
|
|
bcf4be6d50 | ||
|
|
6c9f6be1b1 | ||
|
|
557b79e8c0 | ||
|
|
f83c6ede1d | ||
|
|
c7fb48c8ee | ||
|
|
85b70c4a8a | ||
|
|
689be7b585 | ||
|
|
91f9f3d6fb | ||
|
|
8d4f00efcb | ||
|
|
e8be0f0576 | ||
|
|
5fdaa2539b | ||
|
|
3b3f060f33 | ||
|
|
c4df243610 | ||
|
|
40a3a00cfe | ||
|
|
4679ee006f | ||
|
|
6952cb2c3e | ||
|
|
d3356f3319 | ||
|
|
57c5e41a5c | ||
|
|
ce2b218dfa | ||
|
|
efdfa68dab | ||
|
|
87110f1d69 | ||
|
|
e7a03404ce | ||
|
|
2781d7e0d9 | ||
|
|
5003ff1be2 | ||
|
|
e1be072b97 | ||
|
|
4df740e3df | ||
|
|
85f7a18cbc | ||
|
|
cdffaa16eb | ||
|
|
d33d8d3391 | ||
|
|
9c89c08448 | ||
|
|
ec7b78bc63 | ||
|
|
e97db00999 | ||
|
|
8bb6b01236 | ||
|
|
103b215e35 | ||
|
|
cab35f4c22 | ||
|
|
c9f92a4755 | ||
|
|
854deae801 | ||
|
|
d72cd42483 | ||
|
|
3aee31c6c0 | ||
|
|
435b96c50c |
17
.github/FIX_REPORT.md
vendored
17
.github/FIX_REPORT.md
vendored
@@ -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
3
.github/README.md
vendored
@@ -36,9 +36,10 @@
|
||||
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||
|
||||
## 当前状态
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
|
||||
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||
- 当前体验以 Windows 为主要目标平台。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
## 运行说明
|
||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||
|
||||
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
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
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
@@ -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: |
|
||||
|
||||
30
.gitignore
vendored
30
.gitignore
vendored
@@ -482,3 +482,33 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
nul
|
||||
/publish-test
|
||||
/_build_verify
|
||||
/_build_verify_tray
|
||||
/_build_verify_plugin
|
||||
/_build_verify_plugin_tabs
|
||||
/_build_verify_sample_plugin
|
||||
/_build_verify_sample_plugin_capabilities
|
||||
/_build_verify_plugin_page_host
|
||||
/_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
|
||||
|
||||
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal file
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal 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] 当前课程变化时自动复位到最新进行中课程
|
||||
101
.trae/specs/class-schedule-enhancement/spec.md
Normal file
101
.trae/specs/class-schedule-enhancement/spec.md
Normal 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
|
||||
|
||||
(无)
|
||||
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal file
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal 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 完成后进行
|
||||
32
.trae/specs/settings-page-fluent-redesign/checklist.md
Normal file
32
.trae/specs/settings-page-fluent-redesign/checklist.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Checklist - 设置页面 Fluent 设计改造
|
||||
|
||||
## Phase 1: 分析与准备
|
||||
|
||||
- [ ] SettingsExpander 控件分析完成
|
||||
- [ ] 当前布局问题定位完成
|
||||
|
||||
## Phase 2: 窗口布局调整
|
||||
|
||||
- [ ] SettingsWindow 内容区域无额外 Border 包裹
|
||||
- [ ] 窗口整体视觉效果正常
|
||||
- [ ] 窗口圆角在不同模式下正确显示
|
||||
|
||||
## Phase 3: 设置页面改造
|
||||
|
||||
- [ ] AppearanceSettingsPage 无额外边框包裹
|
||||
- [ ] GeneralSettingsPage 无额外边框包裹
|
||||
- [ ] ComponentsSettingsPage 无额外边框包裹
|
||||
- [ ] PluginsSettingsPage 无额外边框包裹
|
||||
- [ ] AboutSettingsPage 无额外边框包裹
|
||||
|
||||
## Phase 4: 视觉规范
|
||||
|
||||
- [ ] 设置项间距统一
|
||||
- [ ] 圆角样式统一
|
||||
- [ ] 页面标题样式统一
|
||||
|
||||
## 验证
|
||||
|
||||
- [ ] 编译通过,无错误
|
||||
- [ ] 运行正常,设置页面可正常显示
|
||||
- [ ] 视觉效果符合 Fluent 设计风格
|
||||
76
.trae/specs/settings-page-fluent-redesign/spec.md
Normal file
76
.trae/specs/settings-page-fluent-redesign/spec.md
Normal 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 包裹改为直接内容布局
|
||||
51
.trae/specs/settings-page-fluent-redesign/tasks.md
Normal file
51
.trae/specs/settings-page-fluent-redesign/tasks.md
Normal 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
|
||||
33
LanAirApp/README.md
Normal file
33
LanAirApp/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# LanAirApp (Mirror)
|
||||
|
||||
## 中文
|
||||
|
||||
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
|
||||
|
||||
### 这份镜像的角色
|
||||
|
||||
- 提供本地工作区里的 `airappmarket` 索引副本
|
||||
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
|
||||
- 不承担宿主运行时职责
|
||||
|
||||
### 权威来源
|
||||
|
||||
- 插件市场与开发文档:独立 `LanAirApp` 仓库
|
||||
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
|
||||
|
||||
## English
|
||||
|
||||
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
|
||||
|
||||
### Role of this mirror
|
||||
|
||||
- keep a local copy of the `airappmarket` index for workspace integration
|
||||
- keep mirrored docs, tools, and sample templates for local development
|
||||
- avoid duplicating host runtime responsibilities
|
||||
|
||||
### Sources of truth
|
||||
|
||||
- Plugin market and developer docs: standalone `LanAirApp`
|
||||
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
|
||||
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 插件开发指南
|
||||
|
||||
## 中文
|
||||
|
||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||
|
||||
- `plugin.json`
|
||||
- 插件入口程序集
|
||||
- 入口类
|
||||
- 本地化资源
|
||||
|
||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||
|
||||
## English
|
||||
|
||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 插件打包指南
|
||||
|
||||
## 中文
|
||||
|
||||
阑山桌面插件的标准安装格式为 `.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.
|
||||
@@ -0,0 +1,30 @@
|
||||
<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>$(MSBuildThisFileDirectory)artifacts\Packages\</PluginPackageOutputDirectory>
|
||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).$(Version).laapp</PluginPackagePath>
|
||||
<LegacyLoosePluginOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Loose\</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>
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"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.close_desktop.display_name": "Close Desktop",
|
||||
"widget.close_desktop.text": "Close Desktop",
|
||||
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
|
||||
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
|
||||
"widget.close_desktop.failed": "Host rejected the exit request",
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"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": "否"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
## 中文
|
||||
|
||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||
|
||||
## English
|
||||
|
||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||
@@ -0,0 +1,100 @@
|
||||
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",
|
||||
"Initialization log written: {0}",
|
||||
logPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkBackendFaulted(localizer.Format(
|
||||
"status.backend.detail.log_write_failed",
|
||||
"Initialization log failed: {0}",
|
||||
ex.Message));
|
||||
throw;
|
||||
}
|
||||
|
||||
_clockService.Start();
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||
localizer.GetString("widget.display_name", "Sample Plugin Status Clock"),
|
||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||
iconKey: "PuzzlePiece",
|
||||
category: localizer.GetString("widget.category", "Plugins"),
|
||||
minWidthCells: 4,
|
||||
minHeightCells: 4,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
||||
localizer.GetString("widget.close_desktop.display_name", "Close Desktop"),
|
||||
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
||||
iconKey: "DismissCircle",
|
||||
category: localizer.GetString("widget.category", "Plugins"),
|
||||
minWidthCells: 2,
|
||||
minHeightCells: 1,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Free,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginCloseDesktopWidget : Border
|
||||
{
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _statusTextBlock;
|
||||
|
||||
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Text = T("widget.close_desktop.text", "关闭桌面"),
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_statusTextBlock = new TextBlock
|
||||
{
|
||||
Text = _hostApplicationLifecycle is null
|
||||
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
|
||||
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var contentGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 14,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children =
|
||||
{
|
||||
CreateIconShell(),
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 2,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children =
|
||||
{
|
||||
_titleTextBlock,
|
||||
_statusTextBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetColumn(contentGrid.Children[1], 1);
|
||||
|
||||
var actionButton = new Button
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalContentAlignment = VerticalAlignment.Stretch,
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(0),
|
||||
IsEnabled = _hostApplicationLifecycle is not null,
|
||||
Content = contentGrid
|
||||
};
|
||||
actionButton.Click += OnButtonClick;
|
||||
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF0B1220"), 0),
|
||||
new GradientStop(Color.Parse("#FF172554"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
|
||||
BorderThickness = new Thickness(1);
|
||||
CornerRadius = new CornerRadius(18);
|
||||
Padding = new Thickness(14, 10);
|
||||
Child = actionButton;
|
||||
|
||||
SizeChanged += OnSizeChanged;
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private Border CreateIconShell()
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Width = 36,
|
||||
Height = 36,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(Color.Parse("#33F87171")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
|
||||
BorderThickness = new Thickness(1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "⏻",
|
||||
FontSize = 18,
|
||||
Foreground = Brushes.White,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "SamplePlugin.CloseDesktopWidget",
|
||||
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
|
||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
|
||||
|
||||
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentGrid.Children[0] is Border iconShell)
|
||||
{
|
||||
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
|
||||
iconShell.Width = iconSize;
|
||||
iconShell.Height = iconSize;
|
||||
if (iconShell.Child is TextBlock iconText)
|
||||
{
|
||||
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
|
||||
}
|
||||
}
|
||||
|
||||
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
|
||||
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
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", "否");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
11
LanAirApp/samples/README.md
Normal file
11
LanAirApp/samples/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 示例插件目录
|
||||
|
||||
## 中文
|
||||
|
||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||
|
||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||
9
LanAirApp/standards/README.md
Normal file
9
LanAirApp/standards/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 插件标准说明
|
||||
|
||||
## 中文
|
||||
|
||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||
9
LanAirApp/standards/plugin.template.json
Normal file
9
LanAirApp/standards/plugin.template.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
# 移除视频壁纸功能 - 技术设计文档
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 设计目标
|
||||
|
||||
本设计文档描述如何从 LanMountainDesktop 项目中完全移除视频壁纸功能,包括:
|
||||
- 移除 LibVLC 相关依赖
|
||||
- 清理主窗口中的视频壁纸代码
|
||||
- 简化壁纸设置页面
|
||||
- 清理本地化资源
|
||||
|
||||
### 1.2 技术约束
|
||||
|
||||
- 保持现有图片壁纸和纯色壁纸功能完整
|
||||
- 确保应用构建和运行正常
|
||||
- 不引入新的外部依赖
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构变更
|
||||
|
||||
### 2.1 变更概览图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 变更前架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MainWindow │
|
||||
│ ├── DesktopWallpaperLayer (背景层) │
|
||||
│ │ ├── DesktopWallpaperImageLayer (图片层) │
|
||||
│ │ ├── DesktopVideoWallpaperImage (视频海报层) │
|
||||
│ │ └── DesktopVideoWallpaperView (VLC视频播放层) │
|
||||
│ ├── _libVlc, _videoWallpaperPlayer, _videoWallpaperMedia │
|
||||
│ └── StartVideoWallpaper(), StopVideoWallpaper() │
|
||||
│ │
|
||||
│ WallpaperSettingsPage │
|
||||
│ ├── 类型选择: Image | Video | SolidColor │
|
||||
│ └── 视频预览区域 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 变更后架构 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ MainWindow │
|
||||
│ ├── DesktopWallpaperLayer (背景层) │
|
||||
│ │ └── DesktopWallpaperImageLayer (图片层) │
|
||||
│ └── (移除所有视频相关字段和方法) │
|
||||
│ │
|
||||
│ WallpaperSettingsPage │
|
||||
│ ├── 类型选择: Image | SolidColor │
|
||||
│ └── (移除视频预览区域) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 组件变更清单
|
||||
|
||||
| 组件 | 变更类型 | 说明 |
|
||||
|------|----------|------|
|
||||
| LanMountainDesktop.csproj | 修改 | 移除 LibVLC 包引用 |
|
||||
| MainWindow.axaml | 修改 | 移除视频控件和命名空间 |
|
||||
| MainWindow.axaml.cs | 修改 | 移除视频相关字段和清理代码 |
|
||||
| MainWindow.SettingsHardCut.Stubs.cs | 修改 | 移除视频壁纸方法 |
|
||||
| AppearanceThemeService.cs | 修改 | 移除视频种子提取器 |
|
||||
| WallpaperSettingsPage.axaml | 修改 | 移除视频类型UI |
|
||||
| WallpaperSettingsPageViewModel.cs | 修改 | 移除视频相关属性 |
|
||||
| SettingsContracts.cs | 修改 | 移除 Video 枚举值 |
|
||||
| SettingsDomainServices.cs | 修改 | 移除视频扩展名检测 |
|
||||
| zh-CN.json | 修改 | 移除视频相关本地化文本 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 项目依赖变更 (LanMountainDesktop.csproj)
|
||||
|
||||
#### 3.1.1 移除的包引用
|
||||
|
||||
```xml
|
||||
<!-- 移除以下包引用 -->
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="..." />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="..." />
|
||||
```
|
||||
|
||||
#### 3.1.2 变更影响
|
||||
|
||||
- 减少约 100MB+ 的依赖包大小
|
||||
- 简化构建和发布流程
|
||||
- 移除平台特定的原生库依赖
|
||||
|
||||
---
|
||||
|
||||
### 3.2 主窗口 XAML 变更 (MainWindow.axaml)
|
||||
|
||||
#### 3.2.1 移除命名空间声明
|
||||
|
||||
```xml
|
||||
<!-- 移除此行 -->
|
||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
```
|
||||
|
||||
#### 3.2.2 移除视频壁纸控件
|
||||
|
||||
移除以下控件(约第126-137行):
|
||||
|
||||
```xml
|
||||
<!-- 移除 DesktopVideoWallpaperImage -->
|
||||
<Image x:Name="DesktopVideoWallpaperImage"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
|
||||
<!-- 移除 DesktopVideoWallpaperView -->
|
||||
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 主窗口代码变更 (MainWindow.axaml.cs)
|
||||
|
||||
#### 3.3.1 移除 using 声明
|
||||
|
||||
```csharp
|
||||
// 移除以下 using(如果存在)
|
||||
using LibVLCSharp.Shared;
|
||||
using LibVLCSharp.Avalonia;
|
||||
```
|
||||
|
||||
#### 3.3.2 移除静态字段
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第68-71行)
|
||||
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.3.3 移除实例字段
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第123-146行)
|
||||
private Bitmap? _videoWallpaperPosterBitmap;
|
||||
private string? _videoWallpaperPosterPath;
|
||||
private string? _wallpaperVideoPath;
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _videoWallpaperPlayer;
|
||||
private Media? _videoWallpaperMedia;
|
||||
private readonly object _desktopVideoFrameSync = new();
|
||||
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
||||
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
||||
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
||||
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
|
||||
private IntPtr _desktopVideoFrameBufferPtr;
|
||||
private byte[]? _desktopVideoStagingBuffer;
|
||||
private WriteableBitmap? _desktopVideoBitmap;
|
||||
private int _desktopVideoFrameWidth;
|
||||
private int _desktopVideoFrameHeight;
|
||||
private int _desktopVideoFramePitch;
|
||||
private int _desktopVideoFrameBufferSize;
|
||||
private int _desktopVideoFrameDirtyFlag;
|
||||
```
|
||||
|
||||
#### 3.3.4 修改 OnClosed 方法
|
||||
|
||||
移除视频相关清理代码(约第336-350行):
|
||||
|
||||
```csharp
|
||||
// 移除以下代码行
|
||||
StopVideoWallpaper();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_videoWallpaperPlayer?.Dispose();
|
||||
_videoWallpaperPlayer = null;
|
||||
_desktopVideoFrameRefreshTimer?.Stop();
|
||||
_desktopVideoFrameRefreshTimer = null;
|
||||
_videoWallpaperPosterBitmap?.Dispose();
|
||||
_videoWallpaperPosterBitmap = null;
|
||||
_videoWallpaperPosterPath = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 主窗口 Stub 方法变更 (MainWindow.SettingsHardCut.Stubs.cs)
|
||||
|
||||
#### 3.4.1 移除 using 声明
|
||||
|
||||
```csharp
|
||||
// 移除以下 using(第19-20行)
|
||||
using LibVLCSharp.Shared;
|
||||
using LibVLCSharp.Avalonia;
|
||||
```
|
||||
|
||||
#### 3.4.2 移除方法
|
||||
|
||||
移除以下完整方法:
|
||||
|
||||
| 方法名 | 行号范围 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `StartVideoWallpaper` | 337-383 | 启动视频壁纸播放 |
|
||||
| `StopVideoWallpaper` | 385-395 | 停止视频壁纸播放 |
|
||||
| `TryCaptureVideoWallpaperPosterFrame` | 666-751 | 捕获视频海报帧 |
|
||||
| `ApplyVideoWallpaperPosterVisibility` | 647-664 | 控制视频海报可见性 |
|
||||
|
||||
#### 3.4.3 修改 UpdateWallpaperDisplay 方法
|
||||
|
||||
简化为仅处理图片壁纸:
|
||||
|
||||
```csharp
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
// 移除视频分支,仅保留图片处理
|
||||
StopVideoWallpaper(); // 移除此调用
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
```
|
||||
|
||||
修改后:
|
||||
|
||||
```csharp
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.4 修改 ApplyWallpaperBrush 方法
|
||||
|
||||
移除所有 `ApplyVideoWallpaperPosterVisibility` 调用:
|
||||
|
||||
```csharp
|
||||
// 移除以下调用
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
|
||||
```
|
||||
|
||||
#### 3.4.5 修改 SetWallpaperState 方法
|
||||
|
||||
移除视频类型处理分支(约第238-247行):
|
||||
|
||||
```csharp
|
||||
// 移除以下代码块
|
||||
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
|
||||
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
|
||||
{
|
||||
_wallpaperMediaType = WallpaperMediaType.Video;
|
||||
_wallpaperVideoPath = _wallpaperPath;
|
||||
_wallpaperDisplayState = File.Exists(_wallpaperPath)
|
||||
? WallpaperDisplayState.CurrentValidWallpaper
|
||||
: WallpaperDisplayState.TemporarilyUnavailable;
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 外观主题服务变更 (AppearanceThemeService.cs)
|
||||
|
||||
#### 3.5.1 移除接口和类
|
||||
|
||||
移除以下代码(约第92-184行):
|
||||
|
||||
```csharp
|
||||
// 移除接口
|
||||
internal interface IVideoWallpaperSeedExtractor
|
||||
{
|
||||
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
|
||||
}
|
||||
|
||||
// 移除实现类
|
||||
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
|
||||
{
|
||||
// ... 整个类实现
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.6 壁纸设置页面 XAML 变更 (WallpaperSettingsPage.axaml)
|
||||
|
||||
#### 3.6.1 移除视频预览区域
|
||||
|
||||
移除以下代码(约第29-44行):
|
||||
|
||||
```xml
|
||||
<Border Background="#FFF6F7F9"
|
||||
IsVisible="{Binding IsVideo}">
|
||||
<StackPanel HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="12">
|
||||
<fi:FluentIcon Icon="Video"
|
||||
Width="72"
|
||||
Height="72"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<TextBlock Text="{Binding VideoModeHintText}"
|
||||
Width="300"
|
||||
TextAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
```
|
||||
|
||||
#### 3.6.2 移除视频模式提示文本
|
||||
|
||||
移除以下代码(约第150-154行):
|
||||
|
||||
```xml
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
IsVisible="{Binding IsVideo}"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding VideoModeHintText}"
|
||||
TextWrapping="Wrap" />
|
||||
```
|
||||
|
||||
#### 3.6.3 修改填充方式设置可见性绑定
|
||||
|
||||
```xml
|
||||
<!-- 修改前 -->
|
||||
IsVisible="{Binding IsImageOrVideo}"
|
||||
|
||||
<!-- 修改后 -->
|
||||
IsVisible="{Binding IsImage}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.7 壁纸设置 ViewModel 变更 (WallpaperSettingsPageViewModel.cs)
|
||||
|
||||
#### 3.7.1 移除属性
|
||||
|
||||
```csharp
|
||||
// 移除以下属性
|
||||
[ObservableProperty]
|
||||
private bool _isImageOrVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _isVideo;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _videoModeHintText = string.Empty;
|
||||
```
|
||||
|
||||
#### 3.7.2 修改 CreateWallpaperTypes 方法
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
|
||||
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
|
||||
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
|
||||
];
|
||||
}
|
||||
|
||||
// 修改后
|
||||
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
|
||||
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.7.3 修改 UpdateVisibility 方法
|
||||
|
||||
移除 IsVideo 和 IsImageOrVideo 的赋值:
|
||||
|
||||
```csharp
|
||||
// 移除以下行
|
||||
IsVideo = SelectedWallpaperType?.Value == "Video";
|
||||
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
|
||||
```
|
||||
|
||||
#### 3.7.4 修改 RefreshLocalizedText 方法
|
||||
|
||||
```csharp
|
||||
// 移除以下行
|
||||
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.8 设置契约变更 (SettingsContracts.cs)
|
||||
|
||||
#### 3.8.1 修改 WallpaperMediaType 枚举
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image,
|
||||
Video
|
||||
}
|
||||
|
||||
// 修改后
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.9 设置域服务变更 (SettingsDomainServices.cs)
|
||||
|
||||
#### 3.9.1 移除视频扩展名集合
|
||||
|
||||
```csharp
|
||||
// 移除以下字段(约第150-153行)
|
||||
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.9.2 修改 DetectMediaType 方法
|
||||
|
||||
```csharp
|
||||
// 修改前
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.Trim());
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
if (ImageExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Image;
|
||||
}
|
||||
|
||||
if (VideoExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Video;
|
||||
}
|
||||
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
// 修改后
|
||||
public WallpaperMediaType DetectMediaType(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path.Trim());
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
if (ImageExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Image;
|
||||
}
|
||||
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.10 本地化文件变更 (zh-CN.json)
|
||||
|
||||
#### 3.10.1 移除的本地化键
|
||||
|
||||
```json
|
||||
// 移除以下键值对
|
||||
"settings.wallpaper.type.video": "视频",
|
||||
"settings.wallpaper.video_applied": "视频壁纸已应用。",
|
||||
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
|
||||
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
|
||||
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
|
||||
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
|
||||
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}"
|
||||
```
|
||||
|
||||
#### 3.10.2 修改描述文本
|
||||
|
||||
```json
|
||||
// 修改前
|
||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||
|
||||
// 修改后
|
||||
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型变更
|
||||
|
||||
### 4.1 WallpaperMediaType 枚举简化
|
||||
|
||||
```
|
||||
变更前: None | Image | Video
|
||||
变更后: None | Image
|
||||
```
|
||||
|
||||
### 4.2 设置存储兼容性
|
||||
|
||||
现有用户设置中如果包含 `Type: "Video"` 的壁纸配置:
|
||||
- 应用将无法识别该类型
|
||||
- 将回退到纯色背景
|
||||
- 用户需要重新选择图片壁纸
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险评估
|
||||
|
||||
### 5.1 潜在风险
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 现有视频壁纸用户设置失效 | 中 | 应用会自动回退到纯色背景 |
|
||||
| 遗漏的视频相关代码引用 | 低 | 编译器会报告未定义类型错误 |
|
||||
| 本地化键遗漏 | 低 | 运行时会显示键名而非翻译文本 |
|
||||
|
||||
### 5.2 回滚策略
|
||||
|
||||
如需回滚,可通过 Git 恢复以下文件:
|
||||
- LanMountainDesktop.csproj
|
||||
- MainWindow.axaml / .axaml.cs
|
||||
- MainWindow.SettingsHardCut.Stubs.cs
|
||||
- AppearanceThemeService.cs
|
||||
- WallpaperSettingsPage.axaml
|
||||
- WallpaperSettingsPageViewModel.cs
|
||||
- SettingsContracts.cs
|
||||
- SettingsDomainServices.cs
|
||||
- zh-CN.json
|
||||
|
||||
---
|
||||
|
||||
## 6. 验证清单
|
||||
|
||||
### 6.1 编译验证
|
||||
|
||||
- [ ] 项目编译无错误
|
||||
- [ ] 无 LibVLC 相关类型引用警告
|
||||
- [ ] 无未使用变量警告
|
||||
|
||||
### 6.2 功能验证
|
||||
|
||||
- [ ] 应用正常启动
|
||||
- [ ] 图片壁纸正常显示
|
||||
- [ ] 纯色壁纸正常显示
|
||||
- [ ] 壁纸设置页面正常打开
|
||||
- [ ] 类型选择器仅显示"图片"和"纯色"
|
||||
- [ ] 壁纸导入功能正常工作
|
||||
|
||||
### 6.3 清理验证
|
||||
|
||||
- [ ] 无 LibVLC 相关 DLL 在输出目录
|
||||
- [ ] 无视频相关本地化文本残留
|
||||
- [ ] 无视频相关 UI 控件残留
|
||||
@@ -0,0 +1,206 @@
|
||||
# 移除视频壁纸功能规格说明书
|
||||
|
||||
## Why
|
||||
|
||||
当前 LanMountainDesktop 项目包含视频壁纸功能,该功能引入了以下复杂性和依赖:
|
||||
1. 引入了 LibVLCSharp.Avalonia、VideoLAN.LibVLC.Windows、VideoLAN.LibVLC.Mac 等重型依赖
|
||||
2. 在主窗口中残留大量视频壁纸相关代码和字段
|
||||
3. 在设置页面中保留了视频类型选择器和相关 UI 元素
|
||||
4. 在本地化文件中保留了视频壁纸相关文本
|
||||
5. 增加了应用复杂度和维护成本
|
||||
|
||||
用户决定移除该功能以简化代码库。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 移除 LibVLCSharp.Avalonia 及 VideoLAN.LibVLC.* NuGet 依赖
|
||||
- 移除 AppearanceThemeService.cs 中的 LibVlcVideoWallpaperSeedExtractor 类和 IVideoWallpaperSeedExtractor 接口
|
||||
- 移除 MainWindow.axaml.cs 中的视频壁纸相关字段和清理代码
|
||||
- 移除 MainWindow.SettingsHardCut.Stubs.cs 中的视频壁纸相关方法
|
||||
- 移除 MainWindow.axaml 中的 DesktopVideoWallpaperImage 和 DesktopVideoWallpaperView 控件
|
||||
- 移除 WallpaperSettingsPage.axaml 中的视频类型选择器和视频模式提示
|
||||
- 移除 WallpaperSettingsPageViewModel.cs 中的 IsVideo、VideoModeHintText 等属性
|
||||
- 移除 SettingsContracts.cs 中 WallpaperMediaType 枚举的 Video 值
|
||||
- 移除 SettingsDomainServices.cs 中 WallpaperMediaService 类的视频扩展名检测逻辑
|
||||
- 移除本地化文件中的视频壁纸相关文本
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected specs
|
||||
- 壁纸设置功能规格
|
||||
- 主窗口桌面层规格
|
||||
|
||||
### Affected code
|
||||
- `LanMountainDesktop.csproj` - NuGet 依赖配置
|
||||
- `Services/AppearanceThemeService.cs` - 视频壁纸种子提取器
|
||||
- `Views/MainWindow.axaml.cs` - 主窗口字段和清理逻辑
|
||||
- `Views/MainWindow.SettingsHardCut.Stubs.cs` - 视频壁纸控制方法
|
||||
- `Views/MainWindow.axaml` - 视频壁纸 UI 控件
|
||||
- `Views/SettingsPages/WallpaperSettingsPage.axaml` - 壁纸设置页面 UI
|
||||
- `ViewModels/WallpaperSettingsPageViewModel.cs` - 壁纸设置 ViewModel
|
||||
- `Services/Settings/SettingsContracts.cs` - 壁纸媒体类型枚举
|
||||
- `Services/Settings/SettingsDomainServices.cs` - 壁纸媒体服务
|
||||
- `Localization/zh-CN.json` - 本地化文本
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: 视频壁纸播放功能
|
||||
|
||||
**Reason**: 用户决定移除视频壁纸功能以简化代码库,减少重型依赖
|
||||
|
||||
**Migration**:
|
||||
- 用户如需动态壁纸,可使用静态图片壁纸替代
|
||||
- 现有视频壁纸设置将被重置为纯色背景
|
||||
|
||||
#### Scenario: 视频壁纸播放
|
||||
- **GIVEN** 用户选择了视频文件作为壁纸
|
||||
- **WHEN** 系统检测到视频格式
|
||||
- **THEN** 系统不再支持视频壁纸播放
|
||||
- **AND THEN** 系统提示用户该文件类型不受支持
|
||||
|
||||
### Requirement: LibVLC 依赖
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要 LibVLC 库
|
||||
|
||||
**Migration**: 从项目依赖中移除以下包:
|
||||
- LibVLCSharp.Avalonia
|
||||
- VideoLAN.LibVLC.Windows
|
||||
- VideoLAN.LibVLC.Mac
|
||||
|
||||
### Requirement: 视频壁纸种子提取
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要从视频中提取颜色种子
|
||||
|
||||
**Migration**: 移除 `LibVlcVideoWallpaperSeedExtractor` 类和 `IVideoWallpaperSeedExtractor` 接口
|
||||
|
||||
### Requirement: 视频壁纸 UI 控件
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要视频显示控件
|
||||
|
||||
**Migration**: 移除 `DesktopVideoWallpaperImage` 和 `DesktopVideoWallpaperView` 控件
|
||||
|
||||
### Requirement: 视频类型选择器
|
||||
|
||||
**Reason**: 移除视频壁纸功能后不再需要视频类型选项
|
||||
|
||||
**Migration**: 从壁纸类型选择器中移除"视频"选项
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 壁纸媒体类型检测
|
||||
|
||||
**当前**: 支持检测 None、Image、Video 三种类型
|
||||
|
||||
**修改后**: 仅支持检测 None、Image 两种类型
|
||||
|
||||
#### Scenario: 检测媒体类型
|
||||
- **WHEN** 用户选择壁纸文件
|
||||
- **THEN** 系统仅检测图片格式(.png, .jpg, .jpeg, .bmp, .gif, .webp)
|
||||
- **AND THEN** 视频格式文件将被识别为不受支持的类型
|
||||
|
||||
### Requirement: 壁纸类型选项
|
||||
|
||||
**当前**: 提供图片、视频、纯色三种类型选项
|
||||
|
||||
**修改后**: 仅提供图片、纯色两种类型选项
|
||||
|
||||
#### Scenario: 壁纸类型选择
|
||||
- **WHEN** 用户打开壁纸设置页面
|
||||
- **THEN** 类型选择器仅显示"图片"和"纯色"选项
|
||||
- **AND THEN** "视频"选项不再显示
|
||||
|
||||
### Requirement: 壁纸设置页面预览
|
||||
|
||||
**当前**: 根据类型显示图片预览、视频预览或纯色预览
|
||||
|
||||
**修改后**: 根据类型显示图片预览或纯色预览
|
||||
|
||||
#### Scenario: 预览显示
|
||||
- **WHEN** 用户选择壁纸类型
|
||||
- **THEN** 系统仅显示图片预览或纯色预览
|
||||
- **AND THEN** 视频预览区域不再显示
|
||||
|
||||
### Requirement: 主窗口壁纸显示
|
||||
|
||||
**当前**: 支持显示静态图片壁纸和视频壁纸
|
||||
|
||||
**修改后**: 仅支持显示静态图片壁纸
|
||||
|
||||
#### Scenario: 壁纸显示更新
|
||||
- **WHEN** 用户应用新壁纸
|
||||
- **THEN** 系统仅处理静态图片壁纸显示
|
||||
- **AND THEN** 视频壁纸播放逻辑不再执行
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 清理残留代码
|
||||
|
||||
系统 SHALL 完全移除视频壁纸功能相关的所有代码和资源。
|
||||
|
||||
#### Scenario: 主窗口字段清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下字段:
|
||||
- `_videoWallpaperPosterBitmap`
|
||||
- `_videoWallpaperPosterPath`
|
||||
- `_libVlc`
|
||||
- `_videoWallpaperPlayer`
|
||||
- `_videoWallpaperMedia`
|
||||
- `_wallpaperVideoPath`
|
||||
|
||||
#### Scenario: 主窗口方法清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下方法:
|
||||
- `StartVideoWallpaper`
|
||||
- `StopVideoWallpaper`
|
||||
- `TryCaptureVideoWallpaperPosterFrame`
|
||||
- `ApplyVideoWallpaperPosterVisibility`
|
||||
- `UpdateWallpaperDisplay` 中的视频处理分支
|
||||
|
||||
#### Scenario: ViewModel 属性清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下属性:
|
||||
- `IsVideo`
|
||||
- `VideoModeHintText`
|
||||
- `IsImageOrVideo`(改为 `IsImage`)
|
||||
|
||||
#### Scenario: 本地化文本清理
|
||||
- **WHEN** 执行代码清理
|
||||
- **THEN** 移除以下本地化键:
|
||||
- `settings.wallpaper.type.video`
|
||||
- `settings.wallpaper.video_applied`
|
||||
- `settings.wallpaper.video_mode`
|
||||
- `settings.wallpaper.video_restored`
|
||||
- `settings.wallpaper.video_not_found`
|
||||
- `settings.wallpaper.video_player_unavailable`
|
||||
- `settings.wallpaper.video_play_failed_format`
|
||||
|
||||
### Requirement: 依赖项清理
|
||||
|
||||
系统 SHALL 从项目文件中移除 LibVLC 相关 NuGet 包引用。
|
||||
|
||||
#### Scenario: NuGet 包移除
|
||||
- **WHEN** 执行依赖清理
|
||||
- **THEN** 移除以下包引用:
|
||||
- `LibVLCSharp.Avalonia`
|
||||
- `VideoLAN.LibVLC.Windows`
|
||||
- `VideoLAN.LibVLC.Mac`
|
||||
|
||||
### Requirement: 构建验证
|
||||
|
||||
系统 SHALL 在移除视频壁纸功能后保持正常构建和运行。
|
||||
|
||||
#### Scenario: 构建成功
|
||||
- **WHEN** 执行项目构建
|
||||
- **THEN** 构建成功无错误
|
||||
- **AND THEN** 所有现有测试通过
|
||||
|
||||
#### Scenario: 应用启动
|
||||
- **WHEN** 启动应用程序
|
||||
- **THEN** 应用正常启动
|
||||
- **AND THEN** 壁纸设置功能正常工作(仅支持图片和纯色)
|
||||
@@ -0,0 +1,600 @@
|
||||
# 移除视频壁纸功能 - 编码任务清单
|
||||
|
||||
## 任务概览
|
||||
|
||||
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1: 移除项目依赖
|
||||
|
||||
**优先级**: P0 (最高)
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从项目文件中移除 LibVLC 相关的 NuGet 包引用。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
### 输出
|
||||
|
||||
- 修改后的 `LanMountainDesktop.csproj`,移除以下包引用:
|
||||
- `LibVLCSharp.Avalonia`
|
||||
- `VideoLAN.LibVLC.Windows`
|
||||
- `VideoLAN.LibVLC.Mac`
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 项目文件中不再包含 LibVLC 相关包引用
|
||||
- [ ] 执行 `dotnet restore` 成功
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 LanMountainDesktop.csproj,移除以下 PackageReference 节点:
|
||||
1. <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
2. <PackageReference Include="VideoLAN.LibVLC.Windows" ... />
|
||||
3. <PackageReference Include="VideoLAN.LibVLC.Mac" ... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 2: 移除主窗口 XAML 视频控件
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml 中移除视频壁纸相关的 XAML 控件和命名空间声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 LibVLC 命名空间声明
|
||||
- 移除 `DesktopVideoWallpaperImage` 控件
|
||||
- 移除 `DesktopVideoWallpaperView` 控件
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] XAML 中无 `xmlns:vlc` 命名空间
|
||||
- [ ] XAML 中无 `DesktopVideoWallpaperImage` 元素
|
||||
- [ ] XAML 中无 `DesktopVideoWallpaperView` 元素
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml:
|
||||
1. 移除第 9 行: xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
2. 移除第 126-131 行: <Image x:Name="DesktopVideoWallpaperImage" ... />
|
||||
3. 移除第 133-137 行: <vlc:VideoView x:Name="DesktopVideoWallpaperView" ... />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 3: 移除主窗口代码视频字段
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml.cs 中移除视频壁纸相关的字段声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `SupportedVideoExtensions` 静态字段
|
||||
- 移除所有视频相关实例字段
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `SupportedVideoExtensions` 字段
|
||||
- [ ] 无 `_videoWallpaperPosterBitmap` 字段
|
||||
- [ ] 无 `_videoWallpaperPosterPath` 字段
|
||||
- [ ] 无 `_wallpaperVideoPath` 字段
|
||||
- [ ] 无 `_libVlc` 字段
|
||||
- [ ] 无 `_videoWallpaperPlayer` 字段
|
||||
- [ ] 无 `_videoWallpaperMedia` 字段
|
||||
- [ ] 无 `_desktopVideoFrameSync` 及相关视频帧处理字段
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml.cs:
|
||||
1. 移除第 68-71 行的 SupportedVideoExtensions 定义
|
||||
2. 移除第 123-146 行的所有视频相关字段
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 4: 移除主窗口 OnClosed 清理代码
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 3
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.axaml.cs 的 OnClosed 方法中移除视频相关清理代码。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.axaml.cs` (OnClosed 方法)
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 OnClosed 方法,无视频清理逻辑
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] OnClosed 方法中无 `StopVideoWallpaper()` 调用
|
||||
- [ ] OnClosed 方法中无 `_videoWallpaperMedia` 相关清理
|
||||
- [ ] OnClosed 方法中无 `_videoWallpaperPlayer` 相关清理
|
||||
- [ ] OnClosed 方法中无 `_libVlc` 相关清理
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.axaml.cs 的 OnClosed 方法,移除以下代码行:
|
||||
- StopVideoWallpaper();
|
||||
- _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null;
|
||||
- _videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer = null;
|
||||
- _desktopVideoFrameRefreshTimer?.Stop(); _desktopVideoFrameRefreshTimer = null;
|
||||
- _videoWallpaperPosterBitmap?.Dispose(); _videoWallpaperPosterBitmap = null;
|
||||
- _videoWallpaperPosterPath = null;
|
||||
- _libVlc?.Dispose(); _libVlc = null;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 5: 移除主窗口 Stub 方法
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 20 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 MainWindow.SettingsHardCut.Stubs.cs 中移除视频壁纸相关方法和 using 声明。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 LibVLC using 声明
|
||||
- 移除 `StartVideoWallpaper` 方法
|
||||
- 移除 `StopVideoWallpaper` 方法
|
||||
- 移除 `TryCaptureVideoWallpaperPosterFrame` 方法
|
||||
- 移除 `ApplyVideoWallpaperPosterVisibility` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `using LibVLCSharp.Shared;`
|
||||
- [ ] 无 `using LibVLCSharp.Avalonia;`
|
||||
- [ ] 无 `StartVideoWallpaper` 方法定义
|
||||
- [ ] 无 `StopVideoWallpaper` 方法定义
|
||||
- [ ] 无 `TryCaptureVideoWallpaperPosterFrame` 方法定义
|
||||
- [ ] 无 `ApplyVideoWallpaperPosterVisibility` 方法定义
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.SettingsHardCut.Stubs.cs:
|
||||
1. 移除第 19-20 行的 using 声明
|
||||
2. 移除 StartVideoWallpaper 方法(第 337-383 行)
|
||||
3. 移除 StopVideoWallpaper 方法(第 385-395 行)
|
||||
4. 移除 ApplyVideoWallpaperPosterVisibility 方法(第 647-664 行)
|
||||
5. 移除 TryCaptureVideoWallpaperPosterFrame 方法(第 666-751 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 6: 简化壁纸状态处理逻辑
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 5
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
修改 MainWindow.SettingsHardCut.Stubs.cs 中的壁纸状态处理方法,移除视频类型分支。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 `SetWallpaperState` 方法
|
||||
- 简化的 `UpdateWallpaperDisplay` 方法
|
||||
- 简化的 `ApplyWallpaperBrush` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `SetWallpaperState` 中无视频类型检测分支
|
||||
- [ ] `SetWallpaperState` 中无 `_wallpaperVideoPath` 赋值
|
||||
- [ ] `UpdateWallpaperDisplay` 中无 `StopVideoWallpaper()` 调用
|
||||
- [ ] `ApplyWallpaperBrush` 中无 `ApplyVideoWallpaperPosterVisibility` 调用
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 MainWindow.SettingsHardCut.Stubs.cs:
|
||||
|
||||
1. SetWallpaperState 方法:
|
||||
- 移除 requestedTypeIsVideo 变量定义
|
||||
- 移除视频类型检测 if 块(SupportedVideoExtensions.Contains 检查)
|
||||
|
||||
2. UpdateWallpaperDisplay 方法:
|
||||
- 移除视频类型分支,仅保留 ApplyWallpaperBrush() 调用
|
||||
|
||||
3. ApplyWallpaperBrush 方法:
|
||||
- 移除所有 ApplyVideoWallpaperPosterVisibility 调用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 7: 移除外观主题服务视频提取器
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 1
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 AppearanceThemeService.cs 中移除视频壁纸种子提取器接口和实现类。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/AppearanceThemeService.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `IVideoWallpaperSeedExtractor` 接口
|
||||
- 移除 `LibVlcVideoWallpaperSeedExtractor` 类
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `IVideoWallpaperSeedExtractor` 接口定义
|
||||
- [ ] 无 `LibVlcVideoWallpaperSeedExtractor` 类定义
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 AppearanceThemeService.cs:
|
||||
移除第 92-184 行的接口和类定义:
|
||||
- IVideoWallpaperSeedExtractor 接口
|
||||
- LibVlcVideoWallpaperSeedExtractor 类
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 8: 简化壁纸设置页面 XAML
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 无
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 WallpaperSettingsPage.axaml 中移除视频预览区域和相关 UI 元素。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除视频预览 Border 区域
|
||||
- 移除视频模式提示 TextBlock
|
||||
- 修改填充方式可见性绑定
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无视频预览 Border(IsVisible="{Binding IsVideo}")
|
||||
- [ ] 无 VideoModeHintText 绑定的 TextBlock
|
||||
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 WallpaperSettingsPage.axaml:
|
||||
1. 移除第 29-44 行的视频预览 Border
|
||||
2. 移除第 150-154 行的视频模式提示 TextBlock
|
||||
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 9: 简化壁纸设置 ViewModel
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 8
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `_isImageOrVideo`、`_isVideo`、`_videoModeHintText` 属性
|
||||
- 修改 `CreateWallpaperTypes` 方法
|
||||
- 修改 `UpdateVisibility` 方法
|
||||
- 修改 `RefreshLocalizedText` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `IsImageOrVideo` 属性
|
||||
- [ ] 无 `IsVideo` 属性
|
||||
- [ ] 无 `VideoModeHintText` 属性
|
||||
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
|
||||
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
|
||||
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 WallpaperSettingsPageViewModel.cs:
|
||||
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
|
||||
2. 移除第 85-86 行的 _isVideo 字段和属性
|
||||
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
|
||||
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
|
||||
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
|
||||
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 10: 简化壁纸媒体类型枚举
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 简化的 `WallpaperMediaType` 枚举
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `WallpaperMediaType` 枚举仅包含 `None` 和 `Image`
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 SettingsContracts.cs:
|
||||
修改第 11-16 行的枚举定义:
|
||||
public enum WallpaperMediaType
|
||||
{
|
||||
None,
|
||||
Image
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 11: 简化壁纸媒体服务
|
||||
|
||||
**优先级**: P1
|
||||
**依赖**: 任务 10
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除 `VideoExtensions` 字段
|
||||
- 简化 `DetectMediaType` 方法
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `VideoExtensions` 字段定义
|
||||
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 SettingsDomainServices.cs:
|
||||
1. 移除第 150-153 行的 VideoExtensions 字段定义
|
||||
2. 修改 DetectMediaType 方法,移除视频检测分支
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 12: 清理本地化文件
|
||||
|
||||
**优先级**: P2
|
||||
**依赖**: 无
|
||||
**预估工作量**: 5 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
|
||||
|
||||
### 输入
|
||||
|
||||
- `LanMountainDesktop/Localization/zh-CN.json`
|
||||
|
||||
### 输出
|
||||
|
||||
- 移除视频相关本地化键
|
||||
- 修改壁纸描述文本
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无 `settings.wallpaper.type.video` 键
|
||||
- [ ] 无 `settings.wallpaper.video_applied` 键
|
||||
- [ ] 无 `settings.wallpaper.video_mode` 键
|
||||
- [ ] 无 `settings.wallpaper.video_restored` 键
|
||||
- [ ] 无 `settings.wallpaper.video_not_found` 键
|
||||
- [ ] 无 `settings.wallpaper.video_player_unavailable` 键
|
||||
- [ ] 无 `settings.wallpaper.video_play_failed_format` 键
|
||||
- [ ] `settings.wallpaper.description` 文本已更新
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
编辑 zh-CN.json:
|
||||
1. 移除以下键值对:
|
||||
- "settings.wallpaper.type.video"
|
||||
- "settings.wallpaper.video_applied"
|
||||
- "settings.wallpaper.video_mode"
|
||||
- "settings.wallpaper.video_restored"
|
||||
- "settings.wallpaper.video_not_found"
|
||||
- "settings.wallpaper.video_player_unavailable"
|
||||
- "settings.wallpaper.video_play_failed_format"
|
||||
|
||||
2. 修改描述文本:
|
||||
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 13: 构建验证
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 1-12 全部完成
|
||||
**预估工作量**: 10 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
验证项目在移除视频壁纸功能后能够正常构建。
|
||||
|
||||
### 输入
|
||||
|
||||
- 整个项目
|
||||
|
||||
### 输出
|
||||
|
||||
- 构建成功确认
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] `dotnet build` 执行成功,无编译错误
|
||||
- [ ] 无 LibVLC 相关类型未定义错误
|
||||
- [ ] 无未使用变量警告(或已处理)
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
在项目根目录执行:
|
||||
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
|
||||
|
||||
检查输出:
|
||||
- 确认无编译错误
|
||||
- 确认无 LibVLC 相关类型引用错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务 14: 功能验证
|
||||
|
||||
**优先级**: P0
|
||||
**依赖**: 任务 13
|
||||
**预估工作量**: 15 分钟
|
||||
|
||||
### 描述
|
||||
|
||||
验证应用在移除视频壁纸功能后核心功能正常工作。
|
||||
|
||||
### 输入
|
||||
|
||||
- 构建后的应用
|
||||
|
||||
### 输出
|
||||
|
||||
- 功能验证报告
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 应用正常启动
|
||||
- [ ] 图片壁纸正常显示
|
||||
- [ ] 纯色壁纸正常显示
|
||||
- [ ] 壁纸设置页面正常打开
|
||||
- [ ] 类型选择器仅显示"图片"和"纯色"选项
|
||||
- [ ] 壁纸导入功能正常工作
|
||||
|
||||
### 执行提示
|
||||
|
||||
```
|
||||
运行应用:
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
|
||||
手动验证:
|
||||
1. 应用启动无崩溃
|
||||
2. 打开设置 -> 壁纸页面
|
||||
3. 确认类型选择器仅有"图片"和"纯色"
|
||||
4. 测试选择图片壁纸
|
||||
5. 测试选择纯色壁纸
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 任务依赖关系图
|
||||
|
||||
```
|
||||
任务 1 (移除依赖)
|
||||
├── 任务 2 (XAML控件)
|
||||
├── 任务 3 (代码字段)
|
||||
│ └── 任务 4 (OnClosed清理)
|
||||
├── 任务 5 (Stub方法)
|
||||
│ └── 任务 6 (状态处理逻辑)
|
||||
└── 任务 7 (主题服务)
|
||||
|
||||
任务 8 (设置页面XAML)
|
||||
└── 任务 9 (设置ViewModel)
|
||||
|
||||
任务 10 (枚举简化)
|
||||
└── 任务 11 (媒体服务)
|
||||
|
||||
任务 12 (本地化) - 独立
|
||||
|
||||
任务 13 (构建验证) - 依赖所有任务
|
||||
└── 任务 14 (功能验证)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
按以下顺序执行可确保依赖关系正确:
|
||||
|
||||
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
|
||||
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
|
||||
3. **第三批** (可并行): 任务 4, 任务 6
|
||||
4. **第四批**: 任务 13
|
||||
5. **第五批**: 任务 14
|
||||
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IComponentEditorHostContext
|
||||
{
|
||||
void RequestRefresh();
|
||||
|
||||
void CloseEditor();
|
||||
|
||||
void RequestRestart(string? reason = null);
|
||||
}
|
||||
24
LanMountainDesktop.PluginSdk/IComponentSettingsAccessor.cs
Normal file
24
LanMountainDesktop.PluginSdk/IComponentSettingsAccessor.cs
Normal 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);
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal 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);
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
9
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(HostBuilderContext context, IServiceCollection services);
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||
public interface IPluginContext : IPluginRuntimeContext
|
||||
{
|
||||
}
|
||||
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal 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;
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginMessageBus
|
||||
{
|
||||
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||
|
||||
void Publish<TMessage>(TMessage message);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPackageManager
|
||||
{
|
||||
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||
|
||||
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||
}
|
||||
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface 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);
|
||||
}
|
||||
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal file
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal 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);
|
||||
}
|
||||
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface ISettingsCatalog
|
||||
{
|
||||
IReadOnlyList<SettingsSectionDefinition> GetSections();
|
||||
|
||||
IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope);
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal file
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal 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);
|
||||
}
|
||||
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal file
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal 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);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record InstalledPluginInfo(
|
||||
PluginManifest Manifest,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
bool IsPackage,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>3.0.0</Version>
|
||||
</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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
11
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
11
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
{
|
||||
public PluginDesktopComponentContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize,
|
||||
IPluginSettingsService? pluginSettings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
PluginSettings = pluginSettings;
|
||||
}
|
||||
|
||||
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 double CellSize { get; }
|
||||
|
||||
public IPluginSettingsService? PluginSettings { 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<IServiceProvider, 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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: 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;
|
||||
}
|
||||
|
||||
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)
|
||||
: this(
|
||||
componentId,
|
||||
displayName,
|
||||
(_, context) => controlFactory(context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public int MinWidthCells { get; }
|
||||
|
||||
public int MinHeightCells { get; }
|
||||
|
||||
public bool AllowDesktopPlacement { get; }
|
||||
|
||||
public bool AllowStatusBarPlacement { get; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginDesktopComponentResizeMode
|
||||
{
|
||||
Proportional = 0,
|
||||
Free = 1
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginHostPropertyKeys
|
||||
{
|
||||
public const string HostApplicationName = "HostApplicationName";
|
||||
public const string HostVersion = "HostVersion";
|
||||
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
|
||||
public const string HostLanguageCode = "HostLanguageCode";
|
||||
}
|
||||
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLocalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginLocalizer(string pluginDirectory, string? languageCode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
|
||||
PluginDirectory = pluginDirectory;
|
||||
LanguageCode = NormalizeLanguageCode(languageCode);
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string LanguageCode { get; }
|
||||
|
||||
public static PluginLocalizer Create(IPluginRuntimeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public static PluginLocalizer Create(PluginDesktopComponentContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public string GetString(string key, string fallback)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
var primaryTable = LoadLanguageTable(LanguageCode);
|
||||
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fallbackTable = LoadLanguageTable("en-US");
|
||||
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public string Format(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
|
||||
}
|
||||
|
||||
public static string NormalizeLanguageCode(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? "en-US"
|
||||
: "zh-CN";
|
||||
}
|
||||
|
||||
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
|
||||
rawValue is string languageCode
|
||||
? NormalizeLanguageCode(languageCode)
|
||||
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> LoadLanguageTable(string languageCode)
|
||||
{
|
||||
if (_cache.TryGetValue(languageCode, out var table))
|
||||
{
|
||||
return table;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
if (data is not null)
|
||||
{
|
||||
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep empty localization table for plugin resilience.
|
||||
}
|
||||
|
||||
_cache[languageCode] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
147
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
147
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null,
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PluginManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
public static PluginManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||
SharedContracts = normalizedSharedContracts
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"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. " +
|
||||
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild 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;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPackageInstallResult(
|
||||
PluginManifest Manifest,
|
||||
bool ReplacedExisting,
|
||||
bool RestartRequired);
|
||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "3.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string ExtractedPackagesDirectoryName = "packages";
|
||||
public const string PackagedDataDirectoryName = "data";
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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,
|
||||
string componentId,
|
||||
string displayName,
|
||||
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)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||
componentId,
|
||||
displayName,
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver));
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginServiceExportDescriptor(
|
||||
string ProviderPluginId,
|
||||
Type ContractType,
|
||||
object ServiceInstance);
|
||||
@@ -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; }
|
||||
}
|
||||
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal file
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal file
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal 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";
|
||||
}
|
||||
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal file
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal 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; }
|
||||
}
|
||||
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal file
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal 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; }
|
||||
}
|
||||
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal 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; }
|
||||
}
|
||||
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsOptionType
|
||||
{
|
||||
Toggle = 0,
|
||||
Select = 1,
|
||||
Text = 2,
|
||||
Number = 3,
|
||||
Path = 4,
|
||||
List = 5
|
||||
}
|
||||
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal file
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsPageCategory
|
||||
{
|
||||
General = 0,
|
||||
Appearance = 10,
|
||||
Components = 20,
|
||||
Plugins = 30,
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
}
|
||||
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal file
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal 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;
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsScope
|
||||
{
|
||||
App = 0,
|
||||
Launcher = 1,
|
||||
Plugin = 2,
|
||||
ComponentInstance = 3
|
||||
}
|
||||
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal 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; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var result = new HelperResult();
|
||||
string? resultPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var parsedArgs = ParseArgs(args);
|
||||
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||
string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||
}
|
||||
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
resultPath = Path.GetFullPath(resultPath);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = true,
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(resultDirectory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
resultPath,
|
||||
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}),
|
||||
Encoding.UTF8);
|
||||
}
|
||||
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or malformed packages while replacing an install target.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private sealed class HelperResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentSettingsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_MigratesLegacySnapshotFileToCanonicalDocument()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
File.WriteAllText(
|
||||
sandbox.SettingsPath,
|
||||
"""
|
||||
{
|
||||
"DesktopClockSecondHandMode": "Sweep",
|
||||
"ImportedClassSchedules": [
|
||||
{
|
||||
"Id": "spring-2026",
|
||||
"DisplayName": "Spring 2026",
|
||||
"FilePath": "C:\\Schedules\\spring-2026.yaml"
|
||||
}
|
||||
],
|
||||
"ActiveImportedClassScheduleId": "spring-2026"
|
||||
}
|
||||
""");
|
||||
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var snapshot = service.Load();
|
||||
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(snapshot.ImportedClassSchedules);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
File.WriteAllText(
|
||||
sandbox.SettingsPath,
|
||||
"""
|
||||
{
|
||||
"DefaultSettings": {
|
||||
"DesktopClockSecondHandMode": "Tick"
|
||||
},
|
||||
"InstanceSettings": {
|
||||
"DesktopClock::clock-2x2": {
|
||||
"DesktopClockSecondHandMode": "Sweep"
|
||||
}
|
||||
},
|
||||
"PluginSettings": {
|
||||
"DesktopClock::clock-2x2": {
|
||||
"SampleFlag": true
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var pluginSettings = service.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
service.SaveForComponent(
|
||||
"DesktopClock",
|
||||
"clock-2x2",
|
||||
new ComponentSettingsSnapshot
|
||||
{
|
||||
DesktopClockSecondHandMode = "Sweep"
|
||||
});
|
||||
service.SaveForComponent(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2",
|
||||
new ComponentSettingsSnapshot
|
||||
{
|
||||
ImportedClassSchedules =
|
||||
[
|
||||
new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = "spring-2026",
|
||||
DisplayName = "Spring 2026",
|
||||
FilePath = "C:\\Schedules\\spring-2026.yaml"
|
||||
}
|
||||
],
|
||||
ActiveImportedClassScheduleId = "spring-2026"
|
||||
});
|
||||
service.SavePluginSettings(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2",
|
||||
new SamplePluginSettings
|
||||
{
|
||||
SampleFlag = true,
|
||||
Title = "schedule-settings"
|
||||
});
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
|
||||
var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2");
|
||||
var pluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2");
|
||||
|
||||
Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(classScheduleSnapshot.ImportedClassSchedules);
|
||||
Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsSandbox : IDisposable
|
||||
{
|
||||
private readonly string _directoryPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.ComponentSettingsTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public ComponentSettingsSandbox()
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
}
|
||||
|
||||
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||
|
||||
public ComponentSettingsService CreateService()
|
||||
{
|
||||
return new ComponentSettingsService(_directoryPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_directoryPath))
|
||||
{
|
||||
Directory.Delete(_directoryPath, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Temporary test directories are best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SamplePluginSettings
|
||||
{
|
||||
public bool SampleFlag { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class UiExceptionGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler()
|
||||
{
|
||||
var handlerCalled = false;
|
||||
|
||||
await UiExceptionGuard.RunGuardedUiActionAsync(
|
||||
() => throw new InvalidOperationException("boom"),
|
||||
"UnitTest.NonFatal",
|
||||
onHandledException: ex =>
|
||||
{
|
||||
handlerCalled = ex is InvalidOperationException;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
Assert.True(handlerCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunGuardedUiActionAsync_RethrowsFatalException()
|
||||
{
|
||||
await Assert.ThrowsAsync<OutOfMemoryException>(() =>
|
||||
UiExceptionGuard.RunGuardedUiActionAsync(
|
||||
() => throw new OutOfMemoryException("fatal"),
|
||||
"UnitTest.Fatal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFatalException_ReturnsExpectedClassification()
|
||||
{
|
||||
Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException()));
|
||||
Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException()));
|
||||
Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException()));
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
</Solution>
|
||||
@@ -1,4 +1,4 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sty="using:FluentAvalonia.Styling"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
@@ -16,27 +16,13 @@
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="LanMountainDesktop">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||
|
||||
@@ -1,94 +1,172 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
using AvaloniaWebView;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
|
||||
private enum DesktopShellState
|
||||
{
|
||||
ForegroundDesktop = 0,
|
||||
MinimizedToTaskbar = 1,
|
||||
TrayOnly = 2
|
||||
}
|
||||
|
||||
private enum ShutdownIntent
|
||||
{
|
||||
None = 0,
|
||||
ExitRequested = 1,
|
||||
RestartRequested = 2
|
||||
}
|
||||
|
||||
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||
private ISettingsWindowService? _settingsWindowService;
|
||||
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||
private bool _exitCleanupCompleted;
|
||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||
private ShutdownIntent _shutdownIntent;
|
||||
|
||||
private TrayIcons? _trayIcons;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
private MainWindow? _mainWindow;
|
||||
private bool _mainWindowClosed;
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
|
||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||
public ISettingsFacadeService SettingsFacade => _settingsFacade;
|
||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
||||
|
||||
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
||||
{
|
||||
EnsureSettingsWindowService();
|
||||
AppLogger.Info(
|
||||
"SettingsFacade",
|
||||
$"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
|
||||
_settingsWindowService?.Open(new SettingsWindowOpenRequest(
|
||||
Source: source,
|
||||
Owner: _mainWindow is { IsVisible: true } ? _mainWindow : null,
|
||||
PageId: pageTag));
|
||||
}
|
||||
|
||||
public App()
|
||||
{
|
||||
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AppLogger.Info("App", "Initializing application resources.");
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
ApplyThemeFromSettings();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
EnsureSettingsWindowService();
|
||||
EnsureWeatherLocationRefreshService();
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
RegisterUiUnhandledExceptionGuard();
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
InitializeTrayIcon();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.MainWindow = new MainWindow
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
desktop.Exit += (_, _) =>
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
};
|
||||
|
||||
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
StartWeatherLocationRefreshIfNeeded();
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Exit App from the tray menu."));
|
||||
}
|
||||
|
||||
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Restart App from the tray menu."));
|
||||
}
|
||||
|
||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
OpenIndependentSettingsModule("TrayMenu");
|
||||
}
|
||||
|
||||
private void OnTrayComponentLibraryClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_mainWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryStartCurrentProcess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryStartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = args[0],
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
startInfo.ArgumentList.Add(args[i]);
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
_detachedComponentLibraryWindowService.Open(_mainWindow);
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -125,9 +203,615 @@ public partial class App : Application
|
||||
userDataFolder,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
||||
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
var trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = _appLogoService.CreateTrayIcon(),
|
||||
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||
Menu = BuildTrayMenu(),
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
_trayIcons = [trayIcon];
|
||||
TrayIcon.SetIcons(this, _trayIcons);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private NativeMenu BuildTrayMenu()
|
||||
{
|
||||
var menu = new NativeMenu();
|
||||
|
||||
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
|
||||
showDesktopItem.Click += OnTrayShowDesktopClick;
|
||||
menu.Items.Add(showDesktopItem);
|
||||
|
||||
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
|
||||
settingsItem.Click += OnTraySettingsClick;
|
||||
menu.Items.Add(settingsItem);
|
||||
|
||||
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
|
||||
componentLibraryItem.Click += OnTrayComponentLibraryClick;
|
||||
menu.Items.Add(componentLibraryItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
|
||||
restartItem.Click += OnTrayRestartClick;
|
||||
menu.Items.Add(restartItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
|
||||
exitItem.Click += OnTrayExitClick;
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcons is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrayIcon.SetIcons(this, null);
|
||||
foreach (var trayIcon in _trayIcons)
|
||||
{
|
||||
trayIcon.Dispose();
|
||||
}
|
||||
|
||||
_trayIcons = null;
|
||||
}
|
||||
|
||||
private void EnsureSettingsWindowService()
|
||||
{
|
||||
_settingsPageRegistry ??= new SettingsPageRegistry(
|
||||
_settingsFacade,
|
||||
_hostApplicationLifecycle,
|
||||
_localizationService,
|
||||
() => _pluginRuntimeService);
|
||||
_settingsWindowService ??= new SettingsWindowService(
|
||||
_settingsPageRegistry,
|
||||
_hostApplicationLifecycle,
|
||||
_settingsFacade);
|
||||
}
|
||||
|
||||
private void EnsureWeatherLocationRefreshService()
|
||||
{
|
||||
_weatherLocationRefreshService ??= new WeatherLocationRefreshService(
|
||||
_settingsFacade,
|
||||
_locationService,
|
||||
_localizationService);
|
||||
}
|
||||
|
||||
private void StartWeatherLocationRefreshIfNeeded()
|
||||
{
|
||||
EnsureWeatherLocationRefreshService();
|
||||
if (_weatherLocationRefreshService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _weatherLocationRefreshService.TryRefreshOnStartupAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Weather.Location", "Failed to refresh weather location during startup.", ex);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void ApplyThemeFromSettings()
|
||||
{
|
||||
var snapshot = _appearanceThemeService.GetCurrent();
|
||||
RequestedThemeVariant = snapshot.IsNightMode
|
||||
? ThemeVariant.Dark
|
||||
: ThemeVariant.Light;
|
||||
ApplyAdaptiveThemeResources();
|
||||
}
|
||||
|
||||
private void ApplyCurrentCultureFromSettings()
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
|
||||
CultureInfo culture;
|
||||
try
|
||||
{
|
||||
culture = CultureInfo.GetCultureInfo(languageCode);
|
||||
}
|
||||
catch (CultureNotFoundException)
|
||||
{
|
||||
culture = CultureInfo.GetCultureInfo("zh-CN");
|
||||
}
|
||||
|
||||
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||
Thread.CurrentThread.CurrentCulture = culture;
|
||||
Thread.CurrentThread.CurrentUICulture = culture;
|
||||
}
|
||||
|
||||
private void ActivateMainWindow()
|
||||
{
|
||||
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
|
||||
}
|
||||
|
||||
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||
mainWindow.ShowInTaskbar = true;
|
||||
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||
|
||||
if (showSingleInstanceNotice)
|
||||
{
|
||||
mainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
internal void PrepareForShutdown(bool isRestart, string source)
|
||||
{
|
||||
void Mark()
|
||||
{
|
||||
_shutdownIntent = isRestart
|
||||
? ShutdownIntent.RestartRequested
|
||||
: ShutdownIntent.ExitRequested;
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Shutdown intent marked. Intent='{_shutdownIntent}'; Source='{source}'; CurrentShellState='{_desktopShellState}'.");
|
||||
}
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Mark();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(Mark, DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
internal void ResetShutdownIntent(string source)
|
||||
{
|
||||
void Reset()
|
||||
{
|
||||
if (_shutdownIntent == ShutdownIntent.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"DesktopShell",
|
||||
$"Shutdown intent cleared without process exit. PreviousIntent='{_shutdownIntent}'; Source='{source}'.");
|
||||
_shutdownIntent = ShutdownIntent.None;
|
||||
}
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
Reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.InvokeAsync(Reset, DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
if (e.Scope != SettingsScope.App)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var changedKeys = e.ChangedKeys?.ToArray();
|
||||
var refreshAll = changedKeys is null || changedKeys.Length == 0;
|
||||
var liveAppearance = _appearanceThemeService.GetCurrent();
|
||||
var themeChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)));
|
||||
var languageChanged =
|
||||
refreshAll ||
|
||||
changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (themeChanged)
|
||||
{
|
||||
ApplyThemeFromSettings();
|
||||
}
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
ApplyCurrentCultureFromSettings();
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
InitializeTrayIcon();
|
||||
}
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
|
||||
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void ApplyAdaptiveThemeResources()
|
||||
{
|
||||
_appearanceThemeService.ApplyThemeResources(Resources);
|
||||
}
|
||||
|
||||
private void RegisterUiUnhandledExceptionGuard()
|
||||
{
|
||||
if (_uiUnhandledExceptionHooked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += OnUiThreadUnhandledException;
|
||||
_uiUnhandledExceptionHooked = true;
|
||||
}
|
||||
|
||||
private void OnUiThreadUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
if (!IsKnownWebViewStartupException(e.Exception))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
AppLogger.Warn(
|
||||
"WebView2",
|
||||
"Suppressed a known WebView startup exception from AvaloniaWebView.Navigate to keep the host process alive.",
|
||||
e.Exception);
|
||||
}
|
||||
|
||||
private static bool IsKnownWebViewStartupException(Exception exception)
|
||||
{
|
||||
if (exception is not NullReferenceException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var stackTrace = exception.StackTrace ?? string.Empty;
|
||||
return stackTrace.Contains("AvaloniaWebView.WebView.Navigate", StringComparison.Ordinal) &&
|
||||
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void PerformExitCleanup()
|
||||
{
|
||||
if (_exitCleanupCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_exitCleanupCompleted = true;
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||
|
||||
try
|
||||
{
|
||||
var (analytics, crashReport) = App.AnalyticsServices;
|
||||
analytics?.SendShutdownEvent();
|
||||
crashReport?.SendShutdownEvent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UpdateWorkflow", "Failed to apply pending update during exit cleanup.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pluginRuntimeService = null;
|
||||
}
|
||||
|
||||
_settingsWindowService?.Close();
|
||||
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
||||
{
|
||||
disposableRegistry.Dispose();
|
||||
}
|
||||
|
||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||
DisposeTrayIcon();
|
||||
}
|
||||
|
||||
private MainWindow CreateAndAssignMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
{
|
||||
var mainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
ShowInTaskbar = true
|
||||
};
|
||||
|
||||
AttachMainWindow(mainWindow);
|
||||
desktop.MainWindow = mainWindow;
|
||||
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
|
||||
LogBrowserStartupDiagnostics();
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
private MainWindow GetOrCreateMainWindow(
|
||||
IClassicDesktopStyleApplicationLifetime desktop,
|
||||
string reason)
|
||||
{
|
||||
if (_mainWindow is not null && !_mainWindowClosed)
|
||||
{
|
||||
return _mainWindow;
|
||||
}
|
||||
|
||||
if (desktop.MainWindow is MainWindow desktopMainWindow && !_mainWindowClosed)
|
||||
{
|
||||
AttachMainWindow(desktopMainWindow);
|
||||
return desktopMainWindow;
|
||||
}
|
||||
|
||||
return CreateAndAssignMainWindow(desktop, reason);
|
||||
}
|
||||
|
||||
private void AttachMainWindow(MainWindow mainWindow)
|
||||
{
|
||||
if (ReferenceEquals(_mainWindow, mainWindow))
|
||||
{
|
||||
_mainWindowClosed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mainWindow is not null)
|
||||
{
|
||||
_mainWindow.Closing -= OnMainWindowClosing;
|
||||
_mainWindow.Closed -= OnMainWindowClosed;
|
||||
_mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
|
||||
}
|
||||
|
||||
_mainWindow = mainWindow;
|
||||
_mainWindowClosed = false;
|
||||
mainWindow.Closing += OnMainWindowClosing;
|
||||
mainWindow.Closed += OnMainWindowClosed;
|
||||
mainWindow.PropertyChanged += OnMainWindowPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnMainWindowClosing(object? sender, WindowClosingEventArgs e)
|
||||
{
|
||||
if (sender is not MainWindow mainWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window closing requested. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'; WindowState='{mainWindow.WindowState}'; IsVisible={mainWindow.IsVisible}.");
|
||||
|
||||
if (_shutdownIntent is ShutdownIntent.ExitRequested or ShutdownIntent.RestartRequested)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window close allowed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = true;
|
||||
HideMainWindowToTray(mainWindow, "MainWindowClosing");
|
||||
}
|
||||
|
||||
private void OnMainWindowClosed(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is not MainWindow mainWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow.Closing -= OnMainWindowClosing;
|
||||
mainWindow.Closed -= OnMainWindowClosed;
|
||||
mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
|
||||
|
||||
if (ReferenceEquals(_mainWindow, mainWindow))
|
||||
{
|
||||
_mainWindow = null;
|
||||
}
|
||||
|
||||
_mainWindowClosed = true;
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window closed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
|
||||
|
||||
if (_shutdownIntent == ShutdownIntent.None)
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMainWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is not MainWindow mainWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Property != Window.WindowStateProperty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_shutdownIntent != ShutdownIntent.None || !mainWindow.IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "MainWindowMinimized");
|
||||
return;
|
||||
}
|
||||
|
||||
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
||||
}
|
||||
|
||||
private void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||
{
|
||||
try
|
||||
{
|
||||
mainWindow.ShowInTaskbar = false;
|
||||
mainWindow.Hide();
|
||||
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||
{
|
||||
if (_desktopShellState == state)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var previous = _desktopShellState;
|
||||
_desktopShellState = state;
|
||||
AppLogger.Info(
|
||||
"DesktopShell",
|
||||
$"Shell state changed. Previous='{previous}'; Current='{state}'; Source='{source}'.");
|
||||
}
|
||||
|
||||
private void LogBrowserStartupDiagnostics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new DesktopLayoutSettingsService().Load();
|
||||
var browserPlacements = snapshot.DesktopComponentPlacements
|
||||
.Where(placement => string.Equals(
|
||||
placement.ComponentId,
|
||||
BuiltInComponentIds.DesktopBrowser,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
|
||||
|
||||
AppLogger.Info(
|
||||
"StartupDiagnostics",
|
||||
$"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " +
|
||||
$"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " +
|
||||
$"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " +
|
||||
$"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " +
|
||||
$"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " +
|
||||
$"WebViewRuntimeMessage={runtimeAvailability.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
# MiSans Font Notice
|
||||
# MiSans 字体说明
|
||||
|
||||
This app bundles MiSans fonts for consistent cross-device rendering.
|
||||
## 中文
|
||||
|
||||
## Included files
|
||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
||||
|
||||
### 包含文件
|
||||
|
||||
- `MiSans-Regular.ttf`
|
||||
- `MiSans-Semibold.ttf`
|
||||
- `MiSans-Bold.ttf`
|
||||
|
||||
## Source
|
||||
### 来源
|
||||
|
||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
||||
|
||||
### 许可与使用说明
|
||||
|
||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
||||
|
||||
## English
|
||||
|
||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
||||
|
||||
### Sources
|
||||
|
||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
||||
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
|
||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
||||
|
||||
## License and usage notes
|
||||
|
||||
- Script/package license in upstream repository: Apache-2.0
|
||||
- MiSans font copyright and additional usage terms:
|
||||
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
Please review and comply with the MiSans font terms when distributing this app.
|
||||
Please review and comply with the MiSans font terms before redistributing this application.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Weather Background Assets
|
||||
# 天气背景资源署名
|
||||
|
||||
Weather card background images are sourced from **Pexels** and used under the Pexels license:
|
||||
https://www.pexels.com/license/
|
||||
## 中文
|
||||
|
||||
## Sources
|
||||
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
### 原始来源
|
||||
|
||||
- `clear_sky.jpg`
|
||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
|
||||
- `storm.jpg`
|
||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||
|
||||
## Derived Variants (for widget scene mapping)
|
||||
### 派生资源
|
||||
|
||||
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
|
||||
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||
|
||||
- `clear_day.jpg` (from `clear_sky.jpg`)
|
||||
- `clear_night.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_day.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_night.jpg` (from `clear_sky.jpg`)
|
||||
- `rain_light.jpg` (from `rain.jpg`)
|
||||
- `rain_heavy.jpg` (from `rain.jpg`)
|
||||
- `storm_dark.jpg` (from `storm.jpg`)
|
||||
- `fog_haze.jpg` (from `storm.jpg`)
|
||||
- `snow_soft.jpg` (from `snow.jpg`)
|
||||
- `clear_day.jpg`
|
||||
- `clear_night.jpg`
|
||||
- `cloudy_day.jpg`
|
||||
- `cloudy_night.jpg`
|
||||
- `rain_light.jpg`
|
||||
- `rain_heavy.jpg`
|
||||
- `storm_dark.jpg`
|
||||
- `fog_haze.jpg`
|
||||
- `snow_soft.jpg`
|
||||
|
||||
## English
|
||||
|
||||
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
||||
# HyperOS3 天气资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||
|
||||
### 提取来源
|
||||
|
||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2` (Mi Weather)
|
||||
- Extraction date: 2026-03-03
|
||||
- Package: `com.miui.weather2`
|
||||
- Extraction date: `2026-03-03`
|
||||
|
||||
Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
||||
### 用途说明
|
||||
|
||||
Extracted weather icon paths inside APK (`res/*.webp`):
|
||||
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
|
||||
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
|
||||
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
|
||||
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
|
||||
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
|
||||
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
|
||||
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
|
||||
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
|
||||
- `res/mH1.webp` -> `Icons/icon_snow.webp`
|
||||
- `res/jB.webp` -> `Icons/icon_sleet.webp`
|
||||
- `res/Wl.webp` -> `Icons/icon_haze.webp`
|
||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
||||
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||
- 使用时应遵守小米相关许可与使用条款。
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
### 额外派生资源
|
||||
|
||||
## Soft Widget Icon Set (2026-03-05)
|
||||
|
||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
||||
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
||||
## English
|
||||
|
||||
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||
|
||||
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||
|
||||
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 999 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal file
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="375" height="374.999991" viewBox="0 0 375 374.999991">
|
||||
<defs>
|
||||
<clipPath id="clip-0">
|
||||
<path clip-rule="nonzero" d="M 196.875 178.398438 L 285.058594 178.398438 L 285.058594 266.582031 L 196.875 266.582031 Z M 196.875 178.398438 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-1">
|
||||
<path clip-rule="nonzero" d="M 240.96875 178.398438 C 216.617188 178.398438 196.875 198.140625 196.875 222.492188 C 196.875 246.839844 216.617188 266.582031 240.96875 266.582031 C 265.320312 266.582031 285.058594 246.839844 285.058594 222.492188 C 285.058594 198.140625 265.320312 178.398438 240.96875 178.398438 Z M 240.96875 178.398438 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-2">
|
||||
<path clip-rule="nonzero" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-3">
|
||||
<path clip-rule="nonzero" d="M 44.96875 0.398438 C 20.617188 0.398438 0.875 20.140625 0.875 44.492188 C 0.875 68.839844 20.617188 88.582031 44.96875 88.582031 C 69.320312 88.582031 89.058594 68.839844 89.058594 44.492188 C 89.058594 20.140625 69.320312 0.398438 44.96875 0.398438 Z M 44.96875 0.398438 "/>
|
||||
</clipPath>
|
||||
<clipPath id="clip-4">
|
||||
<rect x="0" y="0" width="90" height="89"/>
|
||||
</clipPath>
|
||||
<g id="source-5" clip-path="url(#clip-4)">
|
||||
<g clip-path="url(#clip-2)">
|
||||
<g clip-path="url(#clip-3)">
|
||||
<path fill-rule="nonzero" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</defs>
|
||||
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(100%, 100%, 100%)" fill-opacity="1"/>
|
||||
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(0%, 0%, 0%)" fill-opacity="1"/>
|
||||
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00219613 10.500482 L 127.627201 10.500482 " transform="matrix(0.75, 0, 0, 0.75, 93.623353, 248.98792)"/>
|
||||
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00244625 10.500708 L 127.622243 10.500708 " transform="matrix(0.75, 0, 0, 0.75, 189.341915, 110.327595)"/>
|
||||
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.492181 33.974247 C 11.537452 2.677252 36.02023 2.673354 83.94005 33.972962 " transform="matrix(-0.0333864, -0.749256, 0.749256, -0.0333864, 70.972996, 265.851083)"/>
|
||||
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.498903 37.532093 C 10.670763 1.489257 37.447477 1.488034 90.82363 37.533419 " transform="matrix(0.0300175, 0.749399, -0.749399, 0.0300175, 310.455894, 109.212543)"/>
|
||||
<g clip-path="url(#clip-0)">
|
||||
<g clip-path="url(#clip-1)">
|
||||
<use xlink:href="#source-5" transform="matrix(1, 0, 0, 1, 196, 178)"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal file
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
.stage {
|
||||
width: 512px;
|
||||
height: 512px;
|
||||
background: #000;
|
||||
}
|
||||
.stage img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="stage">
|
||||
<img src="./logo_nightly.svg" alt="logo" />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public static class BuiltInComponentIds
|
||||
{
|
||||
@@ -40,4 +40,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public static class ComponentColorSchemeHelper
|
||||
{
|
||||
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
|
||||
{
|
||||
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string GetCurrentGlobalThemeColorMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = HostAppearanceThemeProvider.GetOrCreate();
|
||||
var appearance = service.GetCurrent();
|
||||
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
@@ -327,6 +327,15 @@ public sealed class ComponentRegistry
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||
"Office Recent Documents",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Date,
|
||||
"Calendar",
|
||||
@@ -385,6 +394,13 @@ public sealed class ComponentRegistry
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
|
||||
{
|
||||
var merged = _definitions.Values.ToList();
|
||||
merged.AddRange(definitions);
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
||||
{
|
||||
return _definitions.TryGetValue(componentId, out definition!);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public sealed record DesktopComponentEditorContext(
|
||||
DesktopComponentDefinition Definition,
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
ISettingsService SettingsService,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
IComponentEditorHostContext HostContext);
|
||||
|
||||
public sealed class DesktopComponentEditorRegistration
|
||||
{
|
||||
public DesktopComponentEditorRegistration(
|
||||
string componentId,
|
||||
Func<DesktopComponentEditorContext, 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 string ComponentId { get; }
|
||||
|
||||
public Func<DesktopComponentEditorContext, Control> EditorFactory { get; }
|
||||
|
||||
public double PreferredWidth { get; }
|
||||
|
||||
public double PreferredHeight { get; }
|
||||
|
||||
public double MinScale { get; }
|
||||
|
||||
public double MaxScale { get; }
|
||||
|
||||
public double AspectRatio { get; }
|
||||
}
|
||||
|
||||
public sealed class DesktopComponentEditorDescriptor
|
||||
{
|
||||
internal DesktopComponentEditorDescriptor(
|
||||
DesktopComponentDefinition definition,
|
||||
Func<DesktopComponentEditorContext, Control> editorFactory,
|
||||
double preferredWidth,
|
||||
double preferredHeight,
|
||||
double minScale,
|
||||
double maxScale,
|
||||
double aspectRatio)
|
||||
{
|
||||
Definition = definition;
|
||||
_editorFactory = editorFactory;
|
||||
PreferredWidth = preferredWidth;
|
||||
PreferredHeight = preferredHeight;
|
||||
MinScale = minScale;
|
||||
MaxScale = maxScale;
|
||||
AspectRatio = aspectRatio;
|
||||
}
|
||||
|
||||
private readonly Func<DesktopComponentEditorContext, Control> _editorFactory;
|
||||
|
||||
public DesktopComponentDefinition Definition { get; }
|
||||
|
||||
public double PreferredWidth { get; }
|
||||
|
||||
public double PreferredHeight { get; }
|
||||
|
||||
public double MinScale { get; }
|
||||
|
||||
public double MaxScale { get; }
|
||||
|
||||
public double AspectRatio { get; }
|
||||
|
||||
public Control CreateEditor(DesktopComponentEditorContext context)
|
||||
{
|
||||
return _editorFactory(context);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class DesktopComponentEditorRegistry
|
||||
{
|
||||
private readonly Dictionary<string, DesktopComponentEditorDescriptor> _descriptors;
|
||||
|
||||
public DesktopComponentEditorRegistry(
|
||||
ComponentRegistry componentRegistry,
|
||||
IEnumerable<DesktopComponentEditorRegistration> registrations)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(componentRegistry);
|
||||
ArgumentNullException.ThrowIfNull(registrations);
|
||||
|
||||
_descriptors = new Dictionary<string, DesktopComponentEditorDescriptor>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var registration in registrations)
|
||||
{
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_descriptors[registration.ComponentId] = new DesktopComponentEditorDescriptor(
|
||||
definition,
|
||||
registration.EditorFactory,
|
||||
registration.PreferredWidth,
|
||||
registration.PreferredHeight,
|
||||
registration.MinScale,
|
||||
registration.MaxScale,
|
||||
registration.AspectRatio);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetDescriptor(string componentId, out DesktopComponentEditorDescriptor descriptor)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
return _descriptors.TryGetValue(componentId.Trim(), out descriptor!);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DesktopComponentEditorDescriptor> GetAll()
|
||||
{
|
||||
return _descriptors.Values.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public sealed record DesktopComponentRuntimeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
ISettingsFacadeService SettingsFacade,
|
||||
ISettingsService SettingsService,
|
||||
IAppearanceThemeService AppearanceTheme,
|
||||
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentPlacementContextAware
|
||||
{
|
||||
void SetComponentPlacementContext(string componentId, string? placementId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user