mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af2e7b4f2f | ||
|
|
798124e500 | ||
|
|
95ecb06668 | ||
|
|
ac7e8db516 | ||
|
|
8ded721f46 | ||
|
|
a559325f5a | ||
|
|
b60368527f | ||
|
|
c8c3f51bff | ||
|
|
685323e057 | ||
|
|
def21c79b1 | ||
|
|
c3db5af923 | ||
|
|
1a7dde34d0 | ||
|
|
73cdefe296 | ||
|
|
46a8df5900 | ||
|
|
2a1c09ae39 | ||
|
|
33baaa579d | ||
|
|
20cd6041a7 | ||
|
|
65a3cf832a | ||
|
|
5d48a03f57 | ||
|
|
ea8ce1f5ff | ||
|
|
aeae4be060 | ||
|
|
915739ff7b | ||
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 | ||
|
|
081abeb688 | ||
|
|
594a62132f | ||
|
|
15e589aedd | ||
|
|
ac4617f5cf | ||
|
|
0645598753 | ||
|
|
dadd132b4f | ||
|
|
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 | ||
|
|
49b18d6af1 | ||
|
|
d6ec159af4 | ||
|
|
0d14675cc0 | ||
|
|
1f509959a9 | ||
|
|
382d1baaf1 | ||
|
|
72a0be16b3 |
16
.codex/environments/environment.toml
Normal file
16
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "LanMountainDesktop"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
|
||||
|
||||
[[actions]]
|
||||
name = "构建"
|
||||
icon = "tool"
|
||||
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
|
||||
17
.github/FIX_REPORT.md
vendored
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)
|
||||
|
||||
38
.github/workflows/build.yml
vendored
38
.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
|
||||
@@ -113,3 +113,31 @@ jobs:
|
||||
path: |
|
||||
LanMountainDesktop/bin/Release/
|
||||
retention-days: 7
|
||||
|
||||
pack-plugin-packages:
|
||||
runs-on: ubuntu-latest
|
||||
name: Pack_Plugin_Packages
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: recursive
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Pack SDK and template packages
|
||||
shell: pwsh
|
||||
run: .\scripts\Pack-PluginPackages.ps1 -Configuration Release -OutputPath .\artifacts\nuget
|
||||
|
||||
- name: Upload plugin package artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: plugin-packages
|
||||
path: artifacts/nuget/*.nupkg
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
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:
|
||||
|
||||
109
.github/workflows/release.yml
vendored
109
.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: |
|
||||
@@ -275,6 +281,8 @@ jobs:
|
||||
package_name="LanMountainDesktop"
|
||||
package_version="${version}"
|
||||
arch="amd64"
|
||||
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
|
||||
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
|
||||
|
||||
# Verify source directory exists
|
||||
if [ ! -d "$source" ]; then
|
||||
@@ -288,6 +296,7 @@ jobs:
|
||||
mkdir -p "build-deb/usr/local/bin"
|
||||
mkdir -p "build-deb/usr/share/applications"
|
||||
mkdir -p "build-deb/usr/share/pixmaps"
|
||||
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
|
||||
|
||||
# Copy application files
|
||||
cp -r "$source"/* "build-deb/usr/local/bin/"
|
||||
@@ -300,6 +309,31 @@ jobs:
|
||||
echo "Error: DEB package is empty after copy"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
|
||||
echo "Error: Linux desktop resources are missing"
|
||||
ls -la "LanMountainDesktop/packaging/linux" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sed \
|
||||
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
|
||||
-e "s|@@ICON@@|lanmountaindesktop|g" \
|
||||
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
|
||||
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
|
||||
{
|
||||
printf '%s\n' '#!/bin/sh'
|
||||
printf '%s\n' 'set -e'
|
||||
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
|
||||
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
|
||||
printf '%s\n' 'fi'
|
||||
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
|
||||
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
|
||||
printf '%s\n' 'fi'
|
||||
} > "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create control file (NOTE: No leading spaces in control file)
|
||||
{
|
||||
@@ -313,6 +347,10 @@ jobs:
|
||||
|
||||
# Set proper permissions
|
||||
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
|
||||
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
|
||||
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
|
||||
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
|
||||
chmod 755 "build-deb/DEBIAN/postinst"
|
||||
|
||||
# Create DEB file
|
||||
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
|
||||
@@ -352,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: |
|
||||
@@ -376,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
|
||||
91
AGENTS.md
Normal file
91
AGENTS.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# LanMountainDesktop AI Guide
|
||||
|
||||
本文件是 AI 助手进入本仓库时的第一入口。面向 Codex、Cursor、Trae 等工具,目标是减少重复探索,快速定位权威文档、关键目录和执行约束。
|
||||
|
||||
## 1. 项目目标与仓库边界
|
||||
|
||||
- 本仓库是阑山桌面桌面宿主、宿主侧插件运行时、Plugin SDK、共享契约与基础外观/设置能力的权威来源。
|
||||
- 不要把插件市场元数据、开发者门户或官方示例插件实现当作本仓库内容维护。
|
||||
- 市场和生态材料属于兄弟仓库 `LanAirApp`。
|
||||
- 官方示例插件属于独立仓库 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
边界详情看:
|
||||
|
||||
- `docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
|
||||
## 2. 关键目录地图
|
||||
|
||||
- `LanMountainDesktop/`: 主宿主应用,包含 UI、服务、组件系统、主题与插件运行时接入
|
||||
- `LanMountainDesktop/ComponentSystem/`: 内置组件定义、注册、扩展加载
|
||||
- `LanMountainDesktop/plugins/`: 宿主侧插件运行时、安装与 market 集成
|
||||
- `LanMountainDesktop/Views/` and `ViewModels/`: UI 页面、窗口与视图模型
|
||||
- `LanMountainDesktop/Services/`: 设置、遥测、启动、持久化、业务服务
|
||||
- `LanMountainDesktop.PluginSdk/`: 插件 SDK 公共接口和默认打包行为
|
||||
- `LanMountainDesktop.Shared.Contracts/`: 宿主/插件共享契约
|
||||
- `LanMountainDesktop.Tests/`: 宿主与 SDK 测试
|
||||
- `.trae/specs/`: feature 级规格、任务拆解和验收清单
|
||||
|
||||
更详细映射看 `docs/ai/CODEBASE_MAP.md`。
|
||||
|
||||
## 3. 常用命令
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.slnx -c Debug
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
dotnet test LanMountainDesktop.slnx -c Debug
|
||||
```
|
||||
|
||||
插件本地包生成:
|
||||
|
||||
```powershell
|
||||
./scripts/Pack-PluginPackages.ps1
|
||||
```
|
||||
|
||||
## 4. 改动前后必做检查
|
||||
|
||||
改动前:
|
||||
|
||||
- 先确认需求是否已经在 `.trae/specs/` 中存在
|
||||
- 先确认产品、架构、专题规范分别以哪份文档为准
|
||||
- 避免沿用旧根目录产品文档中的过时事实
|
||||
|
||||
改动后:
|
||||
|
||||
- 至少检查构建和与改动相关的测试
|
||||
- 如果行为、流程、边界或命令变化,更新对应文档
|
||||
- 如果是新功能或行为调整,补齐或更新 `.trae/specs/<feature>/`
|
||||
|
||||
## 5. 高频区域注意事项
|
||||
|
||||
### UI
|
||||
|
||||
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md` 与 `docs/CORNER_RADIUS_SPEC.md`
|
||||
- 设置页相关改动通常同时落在 `Views/`、`ViewModels/`、`Services/` 和 `.trae/specs/`
|
||||
- UI 启动与窗口生命周期主线在 `Program.cs` 和 `App.axaml.cs`
|
||||
|
||||
### 插件
|
||||
|
||||
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
|
||||
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
|
||||
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
|
||||
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
|
||||
### 设置与主题
|
||||
|
||||
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
|
||||
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
|
||||
|
||||
## 6. 权威来源
|
||||
|
||||
- 产品定位:`docs/PRODUCT.md`
|
||||
- 架构与模块职责:`docs/ARCHITECTURE.md`
|
||||
- 运行、构建、测试、打包:`docs/DEVELOPMENT.md`
|
||||
- feature 规格:`.trae/specs/`
|
||||
- 视觉规范:`docs/VISUAL_SPEC.md`
|
||||
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
|
||||
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
|
||||
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
|
||||
|
||||
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。
|
||||
8
Directory.Build.props
Normal file
8
Directory.Build.props
Normal file
@@ -0,0 +1,8 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>1.0.0</Version>
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net10.0</TargetFramework>
|
||||
<Nullable Condition="'$(Nullable)' == ''">enable</Nullable>
|
||||
<ImplicitUsings Condition="'$(ImplicitUsings)' == ''">enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.Appearance;
|
||||
|
||||
public static class AppearanceCornerRadiusTokenFactory
|
||||
{
|
||||
public static AppearanceCornerRadiusTokens Create(double scale)
|
||||
{
|
||||
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
|
||||
return new AppearanceCornerRadiusTokens(
|
||||
Radius(6, normalizedScale),
|
||||
Radius(10, normalizedScale),
|
||||
Radius(14, normalizedScale),
|
||||
Radius(18, normalizedScale),
|
||||
Radius(24, normalizedScale),
|
||||
Radius(30, normalizedScale),
|
||||
Radius(36, normalizedScale));
|
||||
}
|
||||
|
||||
private static CornerRadius Radius(double value, double scale)
|
||||
{
|
||||
var scaled = Math.Round(value * scale * 2, MidpointRounding.AwayFromZero) / 2d;
|
||||
return new CornerRadius(scaled);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
31
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
31
LanMountainDesktop.DesktopHost/DesktopBootstrap.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public static class DesktopBootstrap
|
||||
{
|
||||
public static void InitializeStartupServices(
|
||||
Action initializeTelemetryIdentity,
|
||||
Action initializeCrashTelemetry,
|
||||
Action initializeUsageTelemetry,
|
||||
Action scheduleStartupCleanup)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
|
||||
ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
|
||||
ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
|
||||
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
||||
|
||||
initializeTelemetryIdentity();
|
||||
initializeCrashTelemetry();
|
||||
initializeUsageTelemetry();
|
||||
scheduleStartupCleanup();
|
||||
}
|
||||
|
||||
public static void InitializeApplication(Application application, Action initializeShell)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(application);
|
||||
ArgumentNullException.ThrowIfNull(initializeShell);
|
||||
initializeShell();
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
55
LanMountainDesktop.DesktopHost/DesktopShellHost.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class DesktopShellHost : IDesktopShellHost
|
||||
{
|
||||
private readonly Action _initializePluginRuntime;
|
||||
private readonly Action _initializeTrayIcon;
|
||||
private readonly Action<IClassicDesktopStyleApplicationLifetime> _createAndAssignMainWindow;
|
||||
private readonly Action _performExitCleanup;
|
||||
private readonly Action _startActivationListener;
|
||||
private readonly Action _startWeatherRefresh;
|
||||
|
||||
public DesktopShellHost(
|
||||
Action initializePluginRuntime,
|
||||
Action initializeTrayIcon,
|
||||
Action<IClassicDesktopStyleApplicationLifetime> createAndAssignMainWindow,
|
||||
Action performExitCleanup,
|
||||
Action startActivationListener,
|
||||
Action startWeatherRefresh)
|
||||
{
|
||||
_initializePluginRuntime = initializePluginRuntime;
|
||||
_initializeTrayIcon = initializeTrayIcon;
|
||||
_createAndAssignMainWindow = createAndAssignMainWindow;
|
||||
_performExitCleanup = performExitCleanup;
|
||||
_startActivationListener = startActivationListener;
|
||||
_startWeatherRefresh = startWeatherRefresh;
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
throw new InvalidOperationException("An application instance is required to initialize the desktop shell.");
|
||||
}
|
||||
|
||||
public void Initialize(Application application)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(application);
|
||||
|
||||
_initializePluginRuntime();
|
||||
_initializeTrayIcon();
|
||||
|
||||
if (application.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Exit += (_, _) => _performExitCleanup();
|
||||
_createAndAssignMainWindow(desktop);
|
||||
_startActivationListener();
|
||||
}
|
||||
|
||||
_startWeatherRefresh();
|
||||
}
|
||||
}
|
||||
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
15
LanMountainDesktop.DesktopHost/DesktopStartupCoordinator.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class DesktopStartupCoordinator
|
||||
{
|
||||
private readonly Action _restoreWorkspaceState;
|
||||
|
||||
public DesktopStartupCoordinator(Action restoreWorkspaceState)
|
||||
{
|
||||
_restoreWorkspaceState = restoreWorkspaceState ?? throw new ArgumentNullException(nameof(restoreWorkspaceState));
|
||||
}
|
||||
|
||||
public void Restore() => _restoreWorkspaceState();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Settings.Core\LanMountainDesktop.Settings.Core.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Appearance\LanMountainDesktop.Appearance.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.DesktopComponents.Runtime\LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
18
LanMountainDesktop.DesktopHost/SettingsWindowHost.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class SettingsWindowHost
|
||||
{
|
||||
private readonly Action<string, string?> _openSettingsWindow;
|
||||
|
||||
public SettingsWindowHost(Action<string, string?> openSettingsWindow)
|
||||
{
|
||||
_openSettingsWindow = openSettingsWindow ?? throw new ArgumentNullException(nameof(openSettingsWindow));
|
||||
}
|
||||
|
||||
public void Open(string source, string? pageId = null)
|
||||
{
|
||||
_openSettingsWindow(source, pageId);
|
||||
}
|
||||
}
|
||||
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
19
LanMountainDesktop.DesktopHost/ShutdownCoordinator.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.DesktopHost;
|
||||
|
||||
public sealed class ShutdownCoordinator
|
||||
{
|
||||
private readonly Action<bool, string> _prepareForShutdown;
|
||||
private readonly Action<string> _resetShutdownIntent;
|
||||
|
||||
public ShutdownCoordinator(Action<bool, string> prepareForShutdown, Action<string> resetShutdownIntent)
|
||||
{
|
||||
_prepareForShutdown = prepareForShutdown ?? throw new ArgumentNullException(nameof(prepareForShutdown));
|
||||
_resetShutdownIntent = resetShutdownIntent ?? throw new ArgumentNullException(nameof(resetShutdownIntent));
|
||||
}
|
||||
|
||||
public void Prepare(bool isRestart, string source) => _prepareForShutdown(isRestart, source);
|
||||
|
||||
public void Reset(string source) => _resetShutdownIntent(source);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
public sealed record ComponentChromeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize,
|
||||
double GlobalCornerRadiusScale,
|
||||
AppearanceCornerRadiusTokens CornerRadiusTokens,
|
||||
SettingsScope Scope = SettingsScope.App);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.Host.Abstractions;
|
||||
|
||||
public interface IDesktopShellHost
|
||||
{
|
||||
void Initialize();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
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);
|
||||
}
|
||||
10
LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs
Normal file
10
LanMountainDesktop.PluginSdk/IPluginAppearanceContext.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginAppearanceContext
|
||||
{
|
||||
PluginAppearanceSnapshot Snapshot { get; }
|
||||
|
||||
double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null);
|
||||
|
||||
double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null);
|
||||
}
|
||||
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);
|
||||
}
|
||||
20
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
20
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginRuntimeContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
IPluginAppearanceContext Appearance { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
}
|
||||
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,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>4.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Official plugin SDK for LanMountainDesktop, including plugin manifest contracts, runtime interfaces, and registration extensions.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;SDK;Avalonia</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.props" Pack="true" PackagePath="buildTransitive\" />
|
||||
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.targets" Pack="true" PackagePath="buildTransitive\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
49
LanMountainDesktop.PluginSdk/PluginAppearanceContext.cs
Normal file
49
LanMountainDesktop.PluginSdk/PluginAppearanceContext.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginAppearanceContext : IPluginAppearanceContext
|
||||
{
|
||||
public PluginAppearanceContext(PluginAppearanceSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
ArgumentNullException.ThrowIfNull(snapshot.CornerRadiusTokens);
|
||||
|
||||
Snapshot = snapshot with
|
||||
{
|
||||
GlobalCornerRadiusScale = Math.Max(0d, snapshot.GlobalCornerRadiusScale),
|
||||
ThemeVariant = string.IsNullOrWhiteSpace(snapshot.ThemeVariant)
|
||||
? "Unknown"
|
||||
: snapshot.ThemeVariant.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
public PluginAppearanceSnapshot Snapshot { get; }
|
||||
|
||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
var scale = Snapshot.GlobalCornerRadiusScale;
|
||||
var scaled = Math.Max(0d, baseRadius) * scale;
|
||||
var scaledMin = minimum.HasValue ? minimum.Value * scale : scaled;
|
||||
var scaledMax = maximum.HasValue ? maximum.Value * scale : scaled;
|
||||
return minimum.HasValue || maximum.HasValue
|
||||
? Math.Clamp(scaled, scaledMin, scaledMax)
|
||||
: scaled;
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
var resolved = Math.Max(0d, Snapshot.CornerRadiusTokens.Get(preset));
|
||||
if (!minimum.HasValue && !maximum.HasValue)
|
||||
{
|
||||
return resolved;
|
||||
}
|
||||
|
||||
var clampedMin = minimum ?? resolved;
|
||||
var clampedMax = maximum ?? resolved;
|
||||
if (clampedMin > clampedMax)
|
||||
{
|
||||
(clampedMin, clampedMax) = (clampedMax, clampedMin);
|
||||
}
|
||||
|
||||
return Math.Clamp(resolved, clampedMin, clampedMax);
|
||||
}
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginAppearanceSnapshot.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginAppearanceSnapshot(
|
||||
double GlobalCornerRadiusScale,
|
||||
PluginCornerRadiusTokens CornerRadiusTokens,
|
||||
string ThemeVariant);
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
13
LanMountainDesktop.PluginSdk/PluginCornerRadiusPreset.cs
Normal file
13
LanMountainDesktop.PluginSdk/PluginCornerRadiusPreset.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginCornerRadiusPreset
|
||||
{
|
||||
Default = 0,
|
||||
Micro = 1,
|
||||
Xs = 2,
|
||||
Sm = 3,
|
||||
Md = 4,
|
||||
Lg = 5,
|
||||
Xl = 6,
|
||||
Island = 7
|
||||
}
|
||||
49
LanMountainDesktop.PluginSdk/PluginCornerRadiusTokens.cs
Normal file
49
LanMountainDesktop.PluginSdk/PluginCornerRadiusTokens.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginCornerRadiusTokens(
|
||||
double Micro,
|
||||
double Xs,
|
||||
double Sm,
|
||||
double Md,
|
||||
double Lg,
|
||||
double Xl,
|
||||
double Island)
|
||||
{
|
||||
public double Get(PluginCornerRadiusPreset preset)
|
||||
{
|
||||
return preset switch
|
||||
{
|
||||
PluginCornerRadiusPreset.Default => Md,
|
||||
PluginCornerRadiusPreset.Micro => Micro,
|
||||
PluginCornerRadiusPreset.Xs => Xs,
|
||||
PluginCornerRadiusPreset.Sm => Sm,
|
||||
PluginCornerRadiusPreset.Md => Md,
|
||||
PluginCornerRadiusPreset.Lg => Lg,
|
||||
PluginCornerRadiusPreset.Xl => Xl,
|
||||
PluginCornerRadiusPreset.Island => Island,
|
||||
_ => Md
|
||||
};
|
||||
}
|
||||
|
||||
public CornerRadius ToCornerRadius(PluginCornerRadiusPreset preset)
|
||||
{
|
||||
return new CornerRadius(Get(preset));
|
||||
}
|
||||
|
||||
public static PluginCornerRadiusTokens FromShared(AppearanceCornerRadiusTokens tokens)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tokens);
|
||||
|
||||
return new PluginCornerRadiusTokens(
|
||||
tokens.Micro.TopLeft,
|
||||
tokens.Xs.TopLeft,
|
||||
tokens.Sm.TopLeft,
|
||||
tokens.Md.TopLeft,
|
||||
tokens.Lg.TopLeft,
|
||||
tokens.Xl.TopLeft,
|
||||
tokens.Island.TopLeft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
IPluginAppearanceContext appearance,
|
||||
IPluginSettingsService? pluginSettings = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
ArgumentNullException.ThrowIfNull(appearance);
|
||||
|
||||
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);
|
||||
Appearance = appearance;
|
||||
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 IPluginAppearanceContext Appearance { get; }
|
||||
|
||||
public double GlobalCornerRadiusScale => Appearance.Snapshot.GlobalCornerRadiusScale;
|
||||
|
||||
public PluginCornerRadiusTokens CornerRadiusTokens => Appearance.Snapshot.CornerRadiusTokens;
|
||||
|
||||
public IPluginSettingsService? PluginSettings { get; }
|
||||
|
||||
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
return Appearance.ResolveScaledCornerRadius(baseRadius, minimum, maximum);
|
||||
}
|
||||
|
||||
public double ResolveCornerRadius(PluginCornerRadiusPreset preset, double? minimum = null, double? maximum = null)
|
||||
{
|
||||
return Appearance.ResolveCornerRadius(preset, minimum, maximum);
|
||||
}
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
}
|
||||
|
||||
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,28 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentOptions
|
||||
{
|
||||
public required string ComponentId { get; init; }
|
||||
|
||||
public required string DisplayName { get; init; }
|
||||
|
||||
public string IconKey { get; init; } = "PuzzlePiece";
|
||||
|
||||
public string Category { get; init; } = "Plugins";
|
||||
|
||||
public int MinWidthCells { get; init; } = 2;
|
||||
|
||||
public int MinHeightCells { get; init; } = 2;
|
||||
|
||||
public bool AllowDesktopPlacement { get; init; } = true;
|
||||
|
||||
public bool AllowStatusBarPlacement { get; init; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; init; } = PluginDesktopComponentResizeMode.Proportional;
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; init; }
|
||||
|
||||
public PluginCornerRadiusPreset CornerRadiusPreset { get; init; } = PluginCornerRadiusPreset.Default;
|
||||
|
||||
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
public PluginDesktopComponentRegistration(
|
||||
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||
PluginDesktopComponentOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.ComponentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.DisplayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.IconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(options.Category);
|
||||
|
||||
ComponentId = options.ComponentId.Trim();
|
||||
DisplayName = options.DisplayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
|
||||
? null
|
||||
: options.DisplayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = options.IconKey.Trim();
|
||||
Category = options.Category.Trim();
|
||||
MinWidthCells = Math.Max(1, options.MinWidthCells);
|
||||
MinHeightCells = Math.Max(1, options.MinHeightCells);
|
||||
AllowDesktopPlacement = options.AllowDesktopPlacement;
|
||||
AllowStatusBarPlacement = options.AllowStatusBarPlacement;
|
||||
ResizeMode = options.ResizeMode;
|
||||
CornerRadiusPreset = options.CornerRadiusPreset;
|
||||
CornerRadiusResolver = options.CornerRadiusResolver;
|
||||
}
|
||||
|
||||
public PluginDesktopComponentRegistration(
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
PluginDesktopComponentOptions options)
|
||||
: this((_, context) => controlFactory(context), options)
|
||||
{
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
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 PluginCornerRadiusPreset CornerRadiusPreset { get; }
|
||||
|
||||
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; }
|
||||
|
||||
public double ResolveCornerRadius(IPluginAppearanceContext appearance, double cellSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(appearance);
|
||||
|
||||
var resolved = CornerRadiusResolver is not null
|
||||
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
|
||||
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
|
||||
? appearance.ResolveScaledCornerRadius(
|
||||
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
|
||||
8,
|
||||
18)
|
||||
: appearance.ResolveCornerRadius(CornerRadiusPreset);
|
||||
|
||||
return double.IsFinite(resolved)
|
||||
? Math.Max(0d, resolved)
|
||||
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
|
||||
}
|
||||
}
|
||||
@@ -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 and rejects v{requestedVersion.Major}.x packages by default. " +
|
||||
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
|
||||
string manifestPath,
|
||||
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
|
||||
{
|
||||
if (sharedContracts is null || sharedContracts.Count == 0)
|
||||
{
|
||||
return Array.Empty<PluginSharedContractReference>();
|
||||
}
|
||||
|
||||
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contract in sharedContracts)
|
||||
{
|
||||
if (contract is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
|
||||
}
|
||||
|
||||
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
|
||||
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
||||
if (!seenIds.Add(contractKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
|
||||
}
|
||||
|
||||
normalized.Add(normalizedContract);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 = "4.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,103 @@
|
||||
using Avalonia.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPluginSettingsSection(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string titleLocalizationKey,
|
||||
Action<PluginSettingsSectionBuilder> configure,
|
||||
string? descriptionLocalizationKey = null,
|
||||
string iconKey = "PuzzlePiece",
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(configure);
|
||||
|
||||
var builder = new PluginSettingsSectionBuilder(
|
||||
id,
|
||||
titleLocalizationKey,
|
||||
descriptionLocalizationKey,
|
||||
iconKey,
|
||||
sortOrder);
|
||||
configure(builder);
|
||||
services.AddSingleton(builder.Build());
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||
this IServiceCollection services,
|
||||
PluginDesktopComponentOptions options)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
options));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
|
||||
this IServiceCollection services,
|
||||
string componentId,
|
||||
double preferredWidth = 720d,
|
||||
double preferredHeight = 540d,
|
||||
double minScale = 0.85d,
|
||||
double maxScale = 1.45d)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
|
||||
componentId,
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
preferredWidth,
|
||||
preferredHeight,
|
||||
minScale,
|
||||
maxScale));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||
|
||||
if (!services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
|
||||
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
|
||||
existing.ContractType == typeof(TContract) &&
|
||||
existing.ImplementationType == typeof(TImplementation)))
|
||||
{
|
||||
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
|
||||
if (contractDescriptor is null)
|
||||
{
|
||||
services.AddSingleton<TContract, TImplementation>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
21
LanMountainDesktop.PluginSdk/README.md
Normal file
21
LanMountainDesktop.PluginSdk/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# LanMountainDesktop.PluginSdk
|
||||
|
||||
Official SDK package for LanMountainDesktop plugins.
|
||||
|
||||
## Includes
|
||||
|
||||
- `IPlugin`/`PluginBase` entry abstractions
|
||||
- `PluginManifest` and shared contract declarations
|
||||
- desktop component registration extensions
|
||||
- plugin runtime context and host service abstractions
|
||||
- build-transitive packaging targets for `.laapp` output
|
||||
|
||||
## Quick Start
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
|
||||
Create `plugin.json` in your plugin project root, then run `dotnet build` to produce both build output and a `.laapp` package.
|
||||
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>
|
||||
<PropertyGroup>
|
||||
<LanMountainPluginManifestFileName Condition="'$(LanMountainPluginManifestFileName)' == ''">plugin.json</LanMountainPluginManifestFileName>
|
||||
<LanMountainPluginPackageExtension Condition="'$(LanMountainPluginPackageExtension)' == ''">.laapp</LanMountainPluginPackageExtension>
|
||||
<LanMountainPluginPackageOutputDirectory Condition="'$(LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</LanMountainPluginPackageOutputDirectory>
|
||||
|
||||
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == '' and Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">true</LanMountainPluginEnablePackaging>
|
||||
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == ''">false</LanMountainPluginEnablePackaging>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">
|
||||
<None Update="$(LanMountainPluginManifestFileName)" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,51 @@
|
||||
<Project>
|
||||
<Target Name="ValidateLanMountainPluginManifest"
|
||||
BeforeTargets="Build"
|
||||
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||
<Error Condition="!Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')"
|
||||
Text="LanMountain plugin packaging is enabled, but '$(LanMountainPluginManifestFileName)' was not found in '$(MSBuildProjectDirectory)'." />
|
||||
</Target>
|
||||
|
||||
<Target Name="CreateLanMountainPluginPackage"
|
||||
AfterTargets="Build"
|
||||
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||
<PropertyGroup>
|
||||
<_LanMountainPluginBuildOutputDirectory>$(LanMountainPluginBuildOutputDirectory)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(TargetDir)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath)</_LanMountainPluginBuildOutputDirectory>
|
||||
<_LanMountainPluginAssemblyName>$(LanMountainPluginAssemblyName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == '' and '$(AssemblyName)' != ''">$(AssemblyName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == ''">$(MSBuildProjectName)</_LanMountainPluginAssemblyName>
|
||||
<_LanMountainPluginPackageVersion>$(LanMountainPluginPackageVersion)</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == '' and '$(Version)' != ''">$(Version)</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == ''">1.0.0</_LanMountainPluginPackageVersion>
|
||||
<_LanMountainPluginPackageOutputDirectory>$(LanMountainPluginPackageOutputDirectory)</_LanMountainPluginPackageOutputDirectory>
|
||||
<_LanMountainPluginPackageOutputDirectory Condition="'$(_LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</_LanMountainPluginPackageOutputDirectory>
|
||||
<_LanMountainPluginPackageFileName>$(LanMountainPluginPackageFileName)</_LanMountainPluginPackageFileName>
|
||||
<_LanMountainPluginPackageFileName Condition="'$(_LanMountainPluginPackageFileName)' == ''">$(_LanMountainPluginAssemblyName).$(_LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</_LanMountainPluginPackageFileName>
|
||||
<_LanMountainPluginPackagePath>$(LanMountainPluginPackagePath)</_LanMountainPluginPackagePath>
|
||||
<_LanMountainPluginPackagePath Condition="'$(_LanMountainPluginPackagePath)' == ''">$(_LanMountainPluginPackageOutputDirectory)$(_LanMountainPluginPackageFileName)</_LanMountainPluginPackagePath>
|
||||
<_LanMountainPluginManifestOutputPath>$(_LanMountainPluginBuildOutputDirectory)$(LanMountainPluginManifestFileName)</_LanMountainPluginManifestOutputPath>
|
||||
<_LanMountainPluginDepsPath>$(ProjectDepsFilePath)</_LanMountainPluginDepsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Copy SourceFiles="$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)"
|
||||
DestinationFiles="$(_LanMountainPluginManifestOutputPath)"
|
||||
SkipUnchangedFiles="true"
|
||||
Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')" />
|
||||
|
||||
<Error Condition="!Exists('$(_LanMountainPluginManifestOutputPath)')"
|
||||
Text="Plugin manifest '$(_LanMountainPluginManifestOutputPath)' was not found in build output. Ensure '$(LanMountainPluginManifestFileName)' is copied to output." />
|
||||
<Error Condition="!Exists('$(TargetPath)')"
|
||||
Text="Plugin assembly '$(TargetPath)' was not found. Build output is incomplete." />
|
||||
<Error Condition="'$(_LanMountainPluginDepsPath)' != '' and !Exists('$(_LanMountainPluginDepsPath)')"
|
||||
Text="Plugin deps file '$(_LanMountainPluginDepsPath)' was not found. Plugin packages must include a .deps.json file." />
|
||||
|
||||
<MakeDir Directories="$(_LanMountainPluginPackageOutputDirectory)" />
|
||||
<Delete Files="$(_LanMountainPluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(_LanMountainPluginBuildOutputDirectory)"
|
||||
DestinationFile="$(_LanMountainPluginPackagePath)" />
|
||||
<Message Importance="High"
|
||||
Text="LanMountain plugin package generated: $(_LanMountainPluginPackagePath)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<PackageId>LanMountainDesktop.PluginTemplate</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Official dotnet new template package for LanMountainDesktop plugins.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;Template;dotnet-new</PackageTags>
|
||||
<PackageType>Template</PackageType>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||
<IsPackable>true</IsPackable>
|
||||
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="content\**\*.cs" />
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
<None Include="content\**\*" Pack="true" PackagePath="content\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# LanMountainDesktop.PluginTemplate
|
||||
|
||||
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||
|
||||
## Install
|
||||
|
||||
```powershell
|
||||
dotnet new install LanMountainDesktop.PluginTemplate
|
||||
```
|
||||
|
||||
## Create a plugin
|
||||
|
||||
```powershell
|
||||
dotnet new lmd-plugin -n YourPluginName
|
||||
```
|
||||
|
||||
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/template",
|
||||
"author": "LanMountainDesktop",
|
||||
"classifications": [
|
||||
"LanMountainDesktop",
|
||||
"Plugin",
|
||||
"Desktop"
|
||||
],
|
||||
"name": "LanMountainDesktop Plugin",
|
||||
"identity": "LanMountainDesktop.PluginTemplate.CSharp",
|
||||
"shortName": "lmd-plugin",
|
||||
"sourceName": "LanMountainDesktop.PluginTemplate",
|
||||
"preferNameDirectory": true,
|
||||
"tags": {
|
||||
"type": "project",
|
||||
"language": "C#"
|
||||
},
|
||||
"symbols": {
|
||||
"pluginId": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "LanMountainDesktop.PluginTemplate",
|
||||
"description": "Plugin manifest id.",
|
||||
"replaces": "__PLUGIN_ID__"
|
||||
},
|
||||
"pluginAuthor": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "Your Name",
|
||||
"description": "Plugin author.",
|
||||
"replaces": "__PLUGIN_AUTHOR__"
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "LanMountain Plugin Template",
|
||||
"description": "Display name shown in plugin manifest.",
|
||||
"replaces": "__PLUGIN_NAME__"
|
||||
},
|
||||
"pluginDescription": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "Plugin generated from the official LanMountainDesktop template.",
|
||||
"description": "Plugin description shown in plugin manifest.",
|
||||
"replaces": "__PLUGIN_DESCRIPTION__"
|
||||
},
|
||||
"pluginSdkVersion": {
|
||||
"type": "parameter",
|
||||
"datatype": "text",
|
||||
"defaultValue": "4.0.0",
|
||||
"description": "LanMountainDesktop.PluginSdk package version.",
|
||||
"replaces": "__PLUGIN_SDK_VERSION__"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="__PLUGIN_SDK_VERSION__" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginTemplate;
|
||||
|
||||
[PluginEntrance]
|
||||
public sealed class Plugin : PluginBase
|
||||
{
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
_ = context;
|
||||
_ = services;
|
||||
}
|
||||
}
|
||||
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# __PLUGIN_NAME__
|
||||
|
||||
Official-style plugin scaffold generated for LanMountainDesktop.
|
||||
|
||||
## Build
|
||||
|
||||
```powershell
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
`LanMountainDesktop.PluginSdk` build targets will generate:
|
||||
|
||||
- plugin output files under `bin/<Configuration>/<TFM>/`
|
||||
- a `.laapp` package in the project root
|
||||
|
||||
## Manifest
|
||||
|
||||
Update `plugin.json` fields as needed before release:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `description`
|
||||
- `author`
|
||||
- `version`
|
||||
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"id": "__PLUGIN_ID__",
|
||||
"name": "__PLUGIN_NAME__",
|
||||
"description": "__PLUGIN_DESCRIPTION__",
|
||||
"author": "__PLUGIN_AUTHOR__",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "4.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||
"sharedContracts": []
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
18
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
18
LanMountainDesktop.Settings.Core/GlobalAppearanceSettings.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Settings.Core;
|
||||
|
||||
public static class GlobalAppearanceSettings
|
||||
{
|
||||
public const double DefaultCornerRadiusScale = 1.0;
|
||||
public const double MinimumCornerRadiusScale = 0.0;
|
||||
public const double MaximumCornerRadiusScale = 2.50;
|
||||
|
||||
public static double NormalizeCornerRadiusScale(double value)
|
||||
{
|
||||
if (double.IsNaN(value) || double.IsInfinity(value))
|
||||
{
|
||||
return DefaultCornerRadiusScale;
|
||||
}
|
||||
|
||||
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Shared.Contracts;
|
||||
|
||||
public sealed record AppearanceCornerRadiusTokens(
|
||||
CornerRadius Micro,
|
||||
CornerRadius Xs,
|
||||
CornerRadius Sm,
|
||||
CornerRadius Md,
|
||||
CornerRadius Lg,
|
||||
CornerRadius Xl,
|
||||
CornerRadius Island);
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||
<IsPackable>true</IsPackable>
|
||||
<Authors>LanMountainDesktop</Authors>
|
||||
<Description>Shared contracts used by LanMountainDesktop host and plugins for cross-boundary communication.</Description>
|
||||
<PackageTags>LanMountainDesktop;Plugin;SharedContracts;Avalonia</PackageTags>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# LanMountainDesktop.Shared.Contracts
|
||||
|
||||
Shared contracts package for LanMountainDesktop host and plugin ecosystems.
|
||||
|
||||
## Includes
|
||||
|
||||
- cross-boundary records used by host/runtime and plugins
|
||||
- contract types intended for stable shared communication
|
||||
|
||||
## Usage
|
||||
|
||||
```xml
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
```
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Appearance;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Host.Abstractions;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(80d, 0d)]
|
||||
[InlineData(120d, 1d)]
|
||||
[InlineData(160d, 2.5d)]
|
||||
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
|
||||
{
|
||||
var registry = new DesktopComponentRuntimeRegistry(
|
||||
ComponentRegistry.CreateDefault(),
|
||||
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
|
||||
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Lg.TopLeft;
|
||||
|
||||
foreach (var descriptor in registry.GetDesktopComponents())
|
||||
{
|
||||
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
|
||||
Assert.Equal(expected, resolved, 3);
|
||||
}
|
||||
}
|
||||
|
||||
private static ComponentChromeContext CreateChromeContext(
|
||||
string componentId,
|
||||
double cellSize,
|
||||
double globalScale)
|
||||
{
|
||||
return new ComponentChromeContext(
|
||||
componentId,
|
||||
null,
|
||||
cellSize,
|
||||
globalScale,
|
||||
AppearanceCornerRadiusTokenFactory.Create(globalScale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentLibraryCollapseStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
|
||||
{
|
||||
var margin = new Thickness(24, 24, 24, 100);
|
||||
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
|
||||
Assert.Equal(margin, state.ExpandedMargin);
|
||||
Assert.Equal(0.75, state.ExpandedOpacity, 3);
|
||||
Assert.False(state.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
|
||||
{
|
||||
var margin = new Thickness(20, 18, 20, 96);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
|
||||
|
||||
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
|
||||
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
|
||||
var restoring = collapsed.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
|
||||
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsing, collapsing.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsed, collapsed.VisualState);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Restoring, restoring.VisualState);
|
||||
|
||||
Assert.Equal(margin, collapsing.ExpandedMargin);
|
||||
Assert.Equal(margin, collapsed.ExpandedMargin);
|
||||
Assert.Equal(margin, restoring.ExpandedMargin);
|
||||
|
||||
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
|
||||
Assert.Equal(1, restoring.ExpandedOpacity, 3);
|
||||
|
||||
Assert.True(collapsing.IsChipVisible);
|
||||
Assert.True(collapsed.IsChipVisible);
|
||||
Assert.False(restoring.IsChipVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
|
||||
{
|
||||
var margin = new Thickness(18, 22, 18, 88);
|
||||
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
|
||||
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
|
||||
|
||||
Assert.Equal(margin, restored.ExpandedMargin);
|
||||
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
|
||||
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
|
||||
Assert.False(restored.IsChipVisible);
|
||||
}
|
||||
}
|
||||
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
257
LanMountainDesktop.Tests/ComponentPreviewImageServiceTests.cs
Normal file
@@ -0,0 +1,257 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentPreviewImageServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var executionOrder = new List<string>();
|
||||
var activeCount = 0;
|
||||
var maxActiveCount = 0;
|
||||
|
||||
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
|
||||
{
|
||||
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
|
||||
return service.QueueGenerationAsync(
|
||||
key,
|
||||
visualSignature: $"sig:{componentTypeId}",
|
||||
async _ =>
|
||||
{
|
||||
var activeNow = Interlocked.Increment(ref activeCount);
|
||||
maxActiveCount = Math.Max(maxActiveCount, activeNow);
|
||||
lock (executionOrder)
|
||||
{
|
||||
executionOrder.Add(componentTypeId);
|
||||
}
|
||||
|
||||
await Task.Delay(40);
|
||||
Interlocked.Decrement(ref activeCount);
|
||||
return CreateImage();
|
||||
});
|
||||
}
|
||||
|
||||
var first = Queue("Clock");
|
||||
var second = Queue("Weather");
|
||||
var third = Queue("Calendar");
|
||||
|
||||
await Task.WhenAll(first, second, third);
|
||||
|
||||
Assert.Equal(1, maxActiveCount);
|
||||
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var generationCount = 0;
|
||||
var bitmap = CreateImage();
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
Task<IImage?> Generation(CancellationToken _)
|
||||
{
|
||||
Interlocked.Increment(ref generationCount);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
|
||||
|
||||
Assert.Same(first, second);
|
||||
|
||||
completion.SetResult(bitmap);
|
||||
var entry = await first;
|
||||
|
||||
Assert.Equal(1, generationCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
|
||||
Assert.Same(bitmap, entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalidate_ResetsSingleKeyToPending()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
var stored = service.Store(key, image, "clock-sig");
|
||||
var previousRevision = stored.Revision;
|
||||
|
||||
var result = service.Invalidate(key);
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
|
||||
Assert.Null(stored.Bitmap);
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.True(stored.Revision > previousRevision);
|
||||
Assert.Equal("clock-sig", stored.VisualSignature);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
|
||||
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
|
||||
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
|
||||
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
|
||||
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var removedClockImage = CreateDisposableImage();
|
||||
var removedWeatherImage = CreateDisposableImage();
|
||||
var keptPlacementImage = CreateDisposableImage();
|
||||
var keptTypeImage = CreateDisposableImage();
|
||||
|
||||
service.Store(removedClock, removedClockImage, "sig-a");
|
||||
service.Store(removedWeather, removedWeatherImage, "sig-b");
|
||||
service.Store(keptPlacement, keptPlacementImage, "sig-c");
|
||||
service.Store(keptType, keptTypeImage, "sig-d");
|
||||
|
||||
var removedCount = service.RemovePlacementPreviews("desk-1");
|
||||
|
||||
Assert.Equal(2, removedCount);
|
||||
Assert.False(service.TryGetEntry(removedClock, out _));
|
||||
Assert.False(service.TryGetEntry(removedWeather, out _));
|
||||
Assert.True(service.TryGetEntry(keptPlacement, out _));
|
||||
Assert.True(service.TryGetEntry(keptType, out _));
|
||||
Assert.True(removedClockImage.IsDisposed);
|
||||
Assert.True(removedWeatherImage.IsDisposed);
|
||||
Assert.False(keptPlacementImage.IsDisposed);
|
||||
Assert.False(keptTypeImage.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
const string matchingSignature = "shared-sig";
|
||||
const string otherSignature = "other-sig";
|
||||
|
||||
var first = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var second = service.Store(
|
||||
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
|
||||
CreateImage(),
|
||||
matchingSignature);
|
||||
var third = service.Store(
|
||||
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
|
||||
CreateImage(),
|
||||
otherSignature);
|
||||
|
||||
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
|
||||
|
||||
Assert.Equal(2, invalidatedCount);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
|
||||
Assert.Null(first.Bitmap);
|
||||
Assert.Null(second.Bitmap);
|
||||
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
|
||||
Assert.NotNull(third.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var first = CreateDisposableImage();
|
||||
var second = CreateDisposableImage();
|
||||
|
||||
service.Store(key, first, "sig-a");
|
||||
service.Store(key, second, "sig-b");
|
||||
|
||||
Assert.True(first.IsDisposed);
|
||||
Assert.False(second.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
service.Store(key, image, "sig-b");
|
||||
|
||||
Assert.False(image.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreFailure_DisposesExistingBitmap()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var image = CreateDisposableImage();
|
||||
|
||||
service.Store(key, image, "sig-a");
|
||||
var entry = service.StoreFailure(key, "sig-a", "failed");
|
||||
|
||||
Assert.True(image.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
|
||||
{
|
||||
var service = new ComponentPreviewImageService();
|
||||
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
|
||||
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var stale = CreateDisposableImage();
|
||||
|
||||
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
|
||||
_ = service.Invalidate(key);
|
||||
completion.SetResult(stale);
|
||||
var entry = await generationTask;
|
||||
|
||||
Assert.True(stale.IsDisposed);
|
||||
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
|
||||
Assert.Null(entry.Bitmap);
|
||||
}
|
||||
|
||||
private static IImage CreateImage() => new TestImage();
|
||||
private static DisposableTestImage CreateDisposableImage() => new();
|
||||
|
||||
private sealed class TestImage : IImage
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DisposableTestImage : IImage, IDisposable
|
||||
{
|
||||
public Size Size => new(1, 1);
|
||||
|
||||
public bool IsDisposed { get; private set; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
IsDisposed = true;
|
||||
}
|
||||
|
||||
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
|
||||
{
|
||||
_ = context;
|
||||
_ = sourceRect;
|
||||
_ = destRect;
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
91
LanMountainDesktop.Tests/CornerRadiusScaleTests.cs
Normal file
91
LanMountainDesktop.Tests/CornerRadiusScaleTests.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class CornerRadiusScaleTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(-1d, 0d)]
|
||||
[InlineData(0d, 0d)]
|
||||
[InlineData(0.33d, 0.33d)]
|
||||
[InlineData(1.234d, 1.234d)]
|
||||
[InlineData(2.5d, 2.5d)]
|
||||
[InlineData(3d, 2.5d)]
|
||||
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
|
||||
{
|
||||
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
|
||||
{
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
|
||||
3);
|
||||
Assert.Equal(
|
||||
GlobalAppearanceSettings.DefaultCornerRadiusScale,
|
||||
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
|
||||
3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
|
||||
{
|
||||
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 0d,
|
||||
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(10),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(18),
|
||||
new CornerRadius(24),
|
||||
new CornerRadius(30),
|
||||
new CornerRadius(36))),
|
||||
ThemeVariant: "Unknown"));
|
||||
|
||||
var context = new PluginDesktopComponentContext(
|
||||
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
|
||||
"C:\\Plugins\\plugin.id",
|
||||
"C:\\Data\\plugin.id",
|
||||
new NullServiceProvider(),
|
||||
new Dictionary<string, object?>(),
|
||||
"component-1",
|
||||
null,
|
||||
96d,
|
||||
appearanceContext);
|
||||
|
||||
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
|
||||
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
|
||||
{
|
||||
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
|
||||
GlobalCornerRadiusScale: 2d,
|
||||
CornerRadiusTokens: new PluginCornerRadiusTokens(
|
||||
Micro: 12d,
|
||||
Xs: 20d,
|
||||
Sm: 28d,
|
||||
Md: 36d,
|
||||
Lg: 48d,
|
||||
Xl: 60d,
|
||||
Island: 72d),
|
||||
ThemeVariant: "Light"));
|
||||
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
|
||||
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
|
||||
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user