mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
The current working directory does not contain a project or solution file.
|
||||||
```
|
```
|
||||||
|
|
||||||
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
**原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 已采取的修复
|
## 🔧 已采取的修复
|
||||||
|
|
||||||
### 1. 创建解决方案文件
|
### 1. 创建 `.slnx` 解决方案文件
|
||||||
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
|
✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
|
||||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||||
|
|
||||||
### 2. 验证本地构建工作
|
### 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)
|
└── LanMountainDesktop (Desktop UI - Avalonia)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 添加新创建的解决方案文件
|
# 1. 添加新创建的解决方案文件
|
||||||
git add LanMountainDesktop.sln
|
git add LanMountainDesktop.slnx
|
||||||
|
git add global.json
|
||||||
|
|
||||||
# 2. 提交
|
# 2. 提交
|
||||||
git commit -m "Add solution file for desktop project"
|
git commit -m "Migrate desktop solution to .slnx"
|
||||||
|
|
||||||
# 3. 推送
|
# 3. 推送
|
||||||
git push origin main
|
git push origin main
|
||||||
@@ -92,7 +93,7 @@ git push origin v1.0.1
|
|||||||
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
|
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
|
||||||
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
|
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
|
||||||
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
|
| `.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`。
|
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
|
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
|
||||||
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||||
- 当前体验以 Windows 为主要目标平台。
|
- 当前体验以 Windows 为主要目标平台。
|
||||||
|
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||||
|
|
||||||
## 运行说明
|
## 运行说明
|
||||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||||
|
|||||||
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: AirAppMarket Validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "airappmarket/**"
|
||||||
|
- ".github/workflows/airappmarket-validate.yml"
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "airappmarket/**"
|
||||||
|
- ".github/workflows/airappmarket-validate.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: "10.0.x"
|
||||||
|
|
||||||
|
- name: Validate AirAppMarket index
|
||||||
|
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json
|
||||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.sln
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -71,10 +71,10 @@ jobs:
|
|||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -101,10 +101,10 @@ jobs:
|
|||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
- name: Restore
|
- name: Restore
|
||||||
run: dotnet restore
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Upload artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.sln
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
|||||||
77
.github/workflows/release.yml
vendored
77
.github/workflows/release.yml
vendored
@@ -18,13 +18,15 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
DOTNET_VERSION: '10.0.x'
|
DOTNET_VERSION: '10.0.x'
|
||||||
Solution_Name: LanMountainDesktop.sln
|
Solution_Name: LanMountainDesktop.slnx
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
prepare:
|
prepare:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.version.outputs.version }}
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
assembly_version: ${{ steps.version.outputs.assembly_version }}
|
||||||
|
informational_version: ${{ steps.version.outputs.informational_version }}
|
||||||
tag: ${{ steps.version.outputs.tag }}
|
tag: ${{ steps.version.outputs.tag }}
|
||||||
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
|
||||||
|
|
||||||
@@ -47,8 +49,15 @@ jobs:
|
|||||||
CHECKOUT_REF="${GITHUB_SHA}"
|
CHECKOUT_REF="${GITHUB_SHA}"
|
||||||
fi
|
fi
|
||||||
VERSION="${TAG#v}"
|
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 "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||||
echo "version=${VERSION}" >> $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
|
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
build-windows:
|
build-windows:
|
||||||
@@ -73,26 +82,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
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
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -106,7 +105,11 @@ jobs:
|
|||||||
-p:DebugType=none `
|
-p:DebugType=none `
|
||||||
-p:DebugSymbols=false `
|
-p:DebugSymbols=false `
|
||||||
-p:PublishTrimmed=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
|
shell: pwsh
|
||||||
|
|
||||||
- name: Install Inno Setup
|
- name: Install Inno Setup
|
||||||
@@ -242,17 +245,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
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
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -266,7 +268,11 @@ jobs:
|
|||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
-p:PublishTrimmed=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
|
- name: Package as DEB
|
||||||
run: |
|
run: |
|
||||||
@@ -384,17 +390,16 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
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
|
- name: Restore
|
||||||
run: dotnet restore ${{ env.Solution_Name }}
|
run: dotnet restore ${{ env.Solution_Name }}
|
||||||
|
|
||||||
- name: Build
|
- 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
|
- name: Publish
|
||||||
run: |
|
run: |
|
||||||
@@ -408,7 +413,11 @@ jobs:
|
|||||||
-p:DebugType=none \
|
-p:DebugType=none \
|
||||||
-p:DebugSymbols=false \
|
-p:DebugSymbols=false \
|
||||||
-p:PublishTrimmed=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
|
- name: Package as DMG
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -492,3 +492,23 @@ nul
|
|||||||
/_build_verify_plugin_services
|
/_build_verify_plugin_services
|
||||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||||
/_build_obj
|
/_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
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
# LanAirApp
|
# LanAirApp (Mirror)
|
||||||
|
|
||||||
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
|
## 中文
|
||||||
|
|
||||||
这里集中放置:
|
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
|
||||||
- 插件开发标准
|
|
||||||
- 插件打包与构建工具
|
|
||||||
- 插件开发与打包文档
|
|
||||||
- 示例插件
|
|
||||||
|
|
||||||
目录结构:
|
### 这份镜像的角色
|
||||||
- `docs/`:插件开发文档、打包文档
|
|
||||||
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
|
|
||||||
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
|
|
||||||
- `standards/`:插件标准文件与模板
|
|
||||||
- `tools/`:插件打包与构建工具
|
|
||||||
|
|
||||||
面向用户的安装流程:
|
- 提供本地工作区里的 `airappmarket` 索引副本
|
||||||
1. 将插件构建或打包为 `.laapp` 文件。
|
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
|
||||||
2. 打开 `设置 -> 插件`。
|
- 不承担宿主运行时职责
|
||||||
3. 点击 `打开 .laapp 插件包`。
|
|
||||||
4. 选择插件包完成安装。
|
|
||||||
|
|
||||||
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`。
|
### 权威来源
|
||||||
|
|
||||||
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin`、`IPluginContext`、清单模型与扩展注册接口。
|
- 插件市场与开发文档:独立 `LanAirApp` 仓库
|
||||||
|
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||||
|
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
|
||||||
|
|
||||||
|
### Role of this mirror
|
||||||
|
|
||||||
|
- keep a local copy of the `airappmarket` index for workspace integration
|
||||||
|
- keep mirrored docs, tools, and sample templates for local development
|
||||||
|
- avoid duplicating host runtime responsibilities
|
||||||
|
|
||||||
|
### Sources of truth
|
||||||
|
|
||||||
|
- Plugin market and developer docs: standalone `LanAirApp`
|
||||||
|
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||||
|
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
|
||||||
|
|||||||
@@ -1,41 +1,16 @@
|
|||||||
# 插件开发文档
|
# 插件开发指南
|
||||||
|
|
||||||
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
|
## 中文
|
||||||
|
|
||||||
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`。
|
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||||
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
|
|
||||||
|
|
||||||
## 必需文件
|
|
||||||
- `plugin.json`
|
- `plugin.json`
|
||||||
- `plugin.json` 中声明的入口程序集
|
- 插件入口程序集
|
||||||
- 使用插件入口特性标记的入口类型
|
- 入口类
|
||||||
|
- 本地化资源
|
||||||
|
|
||||||
## 推荐开发流程
|
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||||
1. 以 `LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
|
|
||||||
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
|
|
||||||
3. 实现 `IPlugin` 或继承 `PluginBase`。
|
|
||||||
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
|
|
||||||
5. 将输出内容打包为 `.laapp` 文件。
|
|
||||||
|
|
||||||
## 运行时能力
|
## English
|
||||||
- 插件可以注册自己的设置页。
|
|
||||||
- 插件可以注册自己的桌面组件。
|
|
||||||
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
|
|
||||||
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
|
|
||||||
|
|
||||||
## 多语言建议
|
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||||
- 插件应当内置 `Localization/zh-CN.json` 与 `Localization/en-US.json`。
|
|
||||||
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
|
|
||||||
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
|
|
||||||
|
|
||||||
## 目录建议
|
|
||||||
一个标准插件项目建议至少包含:
|
|
||||||
- `plugin.json`
|
|
||||||
- `Localization/zh-CN.json`
|
|
||||||
- `Localization/en-US.json`
|
|
||||||
- 插件程序集与依赖文件
|
|
||||||
|
|
||||||
## 示例项目与工具
|
|
||||||
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
|
|
||||||
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
|
|
||||||
- 标准模板:`LanAirApp/standards/plugin.template.json`
|
|
||||||
|
|||||||
@@ -1,34 +1,14 @@
|
|||||||
# 插件打包文档
|
# 插件打包指南
|
||||||
|
|
||||||
LanMountainDesktop 插件的安装包格式固定为 `.laapp`。
|
## 中文
|
||||||
|
|
||||||
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
|
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
||||||
|
|
||||||
## `.laapp` 格式说明
|
- `.laapp` 安装包
|
||||||
- 本质上是一个标准 zip 压缩包
|
- `README.md`
|
||||||
- 包根目录必须包含 `plugin.json`
|
|
||||||
- 包根目录还必须包含入口程序集及其依赖
|
|
||||||
|
|
||||||
## 建议打包内容
|
官方市场索引只负责记录链接和校验信息。
|
||||||
- `plugin.json`
|
|
||||||
- `YourPlugin.dll`
|
|
||||||
- 依赖程序集
|
|
||||||
- `Localization/zh-CN.json`
|
|
||||||
- `Localization/en-US.json`
|
|
||||||
- 插件运行所需的其他资源文件
|
|
||||||
|
|
||||||
## 使用打包工具
|
## English
|
||||||
```powershell
|
|
||||||
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
|
|
||||||
```
|
|
||||||
|
|
||||||
## 应用内安装流程
|
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.
|
||||||
1. 打开 `设置 -> 插件`
|
|
||||||
2. 点击 `打开 .laapp 插件包`
|
|
||||||
3. 选择要安装的插件包
|
|
||||||
4. 如果插件注册了设置页或组件,安装后重启应用
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
|
|
||||||
- 包内应尽量避免无关开发产物。
|
|
||||||
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
|
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||||
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
<PluginPackageOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Packages\</PluginPackageOutputDirectory>
|
||||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).$(Version).laapp</PluginPackagePath>
|
||||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
<LegacyLoosePluginOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Loose\</LegacyLoosePluginOutputDirectory>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
||||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||||
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
||||||
|
"widget.close_desktop.display_name": "Close Desktop",
|
||||||
|
"widget.close_desktop.text": "Close Desktop",
|
||||||
|
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
|
||||||
|
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
|
||||||
|
"widget.close_desktop.failed": "Host rejected the exit request",
|
||||||
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
||||||
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
||||||
"common.dev": "dev",
|
"common.dev": "dev",
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
# LanMountainDesktop.SamplePlugin
|
# LanMountainDesktop.SamplePlugin
|
||||||
|
|
||||||
这是阑山桌面的**示例开发插件**。
|
## 中文
|
||||||
|
|
||||||
它用于演示以下能力:
|
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||||
- 插件入口与 `plugin.json` 清单
|
|
||||||
- 插件服务注册
|
|
||||||
- 插件设置页注册
|
|
||||||
- 插件桌面组件注册
|
|
||||||
- 插件内通信与状态更新
|
|
||||||
- `.laapp` 打包与安装流程
|
|
||||||
- 插件多语言资源组织方式
|
|
||||||
|
|
||||||
如果你要开发自己的插件,建议以这个目录为模板开始。
|
## English
|
||||||
|
|
||||||
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
|
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||||
|
|||||||
@@ -43,37 +43,45 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
|||||||
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
||||||
_stateService.MarkBackendReady(localizer.Format(
|
_stateService.MarkBackendReady(localizer.Format(
|
||||||
"status.backend.detail.log_written",
|
"status.backend.detail.log_written",
|
||||||
"初始化日志已写入:{0}",
|
"Initialization log written: {0}",
|
||||||
logPath));
|
logPath));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_stateService.MarkBackendFaulted(localizer.Format(
|
_stateService.MarkBackendFaulted(localizer.Format(
|
||||||
"status.backend.detail.log_write_failed",
|
"status.backend.detail.log_write_failed",
|
||||||
"初始化日志写入失败:{0}",
|
"Initialization log failed: {0}",
|
||||||
ex.Message));
|
ex.Message));
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
_clockService.Start();
|
_clockService.Start();
|
||||||
|
|
||||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
|
||||||
"status",
|
|
||||||
localizer.GetString("settings.page_title", "插件状态"),
|
|
||||||
() => new SamplePluginSettingsView(context)));
|
|
||||||
|
|
||||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||||
localizer.GetString("widget.display_name", "示例插件状态时钟"),
|
localizer.GetString("widget.display_name", "Sample Plugin Status Clock"),
|
||||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||||
iconKey: "PuzzlePiece",
|
iconKey: "PuzzlePiece",
|
||||||
category: localizer.GetString("widget.category", "插件"),
|
category: localizer.GetString("widget.category", "Plugins"),
|
||||||
minWidthCells: 4,
|
minWidthCells: 4,
|
||||||
minHeightCells: 4,
|
minHeightCells: 4,
|
||||||
allowDesktopPlacement: true,
|
allowDesktopPlacement: true,
|
||||||
allowStatusBarPlacement: false,
|
allowStatusBarPlacement: false,
|
||||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||||
|
|
||||||
|
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||||
|
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
||||||
|
localizer.GetString("widget.close_desktop.display_name", "Close Desktop"),
|
||||||
|
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
||||||
|
iconKey: "DismissCircle",
|
||||||
|
category: localizer.GetString("widget.category", "Plugins"),
|
||||||
|
minWidthCells: 2,
|
||||||
|
minHeightCells: 1,
|
||||||
|
allowDesktopPlacement: true,
|
||||||
|
allowStatusBarPlacement: false,
|
||||||
|
resizeMode: PluginDesktopComponentResizeMode.Free,
|
||||||
|
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.SamplePlugin;
|
||||||
|
|
||||||
|
internal sealed class SamplePluginCloseDesktopWidget : Border
|
||||||
|
{
|
||||||
|
private readonly PluginLocalizer _localizer;
|
||||||
|
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
|
||||||
|
private readonly TextBlock _titleTextBlock;
|
||||||
|
private readonly TextBlock _statusTextBlock;
|
||||||
|
|
||||||
|
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
|
||||||
|
{
|
||||||
|
_localizer = PluginLocalizer.Create(context);
|
||||||
|
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||||
|
|
||||||
|
_titleTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = T("widget.close_desktop.text", "关闭桌面"),
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
FontWeight = FontWeight.SemiBold,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
_statusTextBlock = new TextBlock
|
||||||
|
{
|
||||||
|
Text = _hostApplicationLifecycle is null
|
||||||
|
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
|
||||||
|
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
|
||||||
|
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
|
||||||
|
var contentGrid = new Grid
|
||||||
|
{
|
||||||
|
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||||
|
ColumnSpacing = 14,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
CreateIconShell(),
|
||||||
|
new StackPanel
|
||||||
|
{
|
||||||
|
Spacing = 2,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Children =
|
||||||
|
{
|
||||||
|
_titleTextBlock,
|
||||||
|
_statusTextBlock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid.SetColumn(contentGrid.Children[1], 1);
|
||||||
|
|
||||||
|
var actionButton = new Button
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch,
|
||||||
|
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalContentAlignment = VerticalAlignment.Stretch,
|
||||||
|
Background = Brushes.Transparent,
|
||||||
|
BorderThickness = new Thickness(0),
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
IsEnabled = _hostApplicationLifecycle is not null,
|
||||||
|
Content = contentGrid
|
||||||
|
};
|
||||||
|
actionButton.Click += OnButtonClick;
|
||||||
|
|
||||||
|
Background = new LinearGradientBrush
|
||||||
|
{
|
||||||
|
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||||
|
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||||
|
GradientStops =
|
||||||
|
[
|
||||||
|
new GradientStop(Color.Parse("#FF0B1220"), 0),
|
||||||
|
new GradientStop(Color.Parse("#FF172554"), 0.55),
|
||||||
|
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
|
||||||
|
BorderThickness = new Thickness(1);
|
||||||
|
CornerRadius = new CornerRadius(18);
|
||||||
|
Padding = new Thickness(14, 10);
|
||||||
|
Child = actionButton;
|
||||||
|
|
||||||
|
SizeChanged += OnSizeChanged;
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Border CreateIconShell()
|
||||||
|
{
|
||||||
|
return new Border
|
||||||
|
{
|
||||||
|
Width = 36,
|
||||||
|
Height = 36,
|
||||||
|
CornerRadius = new CornerRadius(999),
|
||||||
|
Background = new SolidColorBrush(Color.Parse("#33F87171")),
|
||||||
|
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
|
||||||
|
BorderThickness = new Thickness(1),
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
Child = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "⏻",
|
||||||
|
FontSize = 18,
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Center,
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextAlignment = TextAlignment.Center
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "SamplePlugin.CloseDesktopWidget",
|
||||||
|
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
|
{
|
||||||
|
ApplyScale();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyScale()
|
||||||
|
{
|
||||||
|
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
|
||||||
|
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
|
||||||
|
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
|
||||||
|
|
||||||
|
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentGrid.Children[0] is Border iconShell)
|
||||||
|
{
|
||||||
|
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
|
||||||
|
iconShell.Width = iconSize;
|
||||||
|
iconShell.Height = iconSize;
|
||||||
|
if (iconShell.Child is TextBlock iconText)
|
||||||
|
{
|
||||||
|
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
|
||||||
|
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string T(string key, string fallback)
|
||||||
|
{
|
||||||
|
return _localizer.GetString(key, fallback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# 示例插件
|
# 示例插件目录
|
||||||
|
|
||||||
本目录用于存放阑山桌面的示例开发插件。
|
## 中文
|
||||||
|
|
||||||
当前示例:
|
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||||
- `LanMountainDesktop.SamplePlugin`
|
|
||||||
|
|
||||||
说明:
|
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||||
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
|
|
||||||
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
|
## English
|
||||||
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。
|
|
||||||
|
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
# 插件标准文件
|
# 插件标准说明
|
||||||
|
|
||||||
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
|
## 中文
|
||||||
|
|
||||||
当前标准:
|
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||||
- 安装包扩展名:`.laapp`
|
|
||||||
- 插件清单文件名:`plugin.json`
|
|
||||||
- 多语言资源目录:`Localization/`
|
|
||||||
- 建议内置语言文件:`zh-CN.json`、`en-US.json`
|
|
||||||
|
|
||||||
创建新插件时,建议优先参考本目录中的模板文件。
|
## English
|
||||||
|
|
||||||
|
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
public interface IPlugin
|
public interface IPlugin
|
||||||
{
|
{
|
||||||
void Initialize(IPluginContext context);
|
void Initialize(HostBuilderContext context, IServiceCollection services);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
public interface IPluginContext
|
[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||||
|
public interface IPluginContext : IPluginRuntimeContext
|
||||||
{
|
{
|
||||||
PluginManifest Manifest { get; }
|
|
||||||
|
|
||||||
string PluginDirectory { get; }
|
|
||||||
|
|
||||||
string DataDirectory { get; }
|
|
||||||
|
|
||||||
IServiceProvider Services { get; }
|
|
||||||
|
|
||||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
|
||||||
|
|
||||||
T? GetService<T>();
|
|
||||||
|
|
||||||
bool TryGetProperty<T>(string key, out T? value);
|
|
||||||
|
|
||||||
void RegisterService<TService>(TService service)
|
|
||||||
where TService : class;
|
|
||||||
|
|
||||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
|
||||||
|
|
||||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginPackageManager
|
||||||
|
{
|
||||||
|
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||||
|
|
||||||
|
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||||
|
}
|
||||||
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginRuntimeContext
|
||||||
|
{
|
||||||
|
PluginManifest Manifest { get; }
|
||||||
|
|
||||||
|
string PluginDirectory { get; }
|
||||||
|
|
||||||
|
string DataDirectory { get; }
|
||||||
|
|
||||||
|
IServiceProvider Services { get; }
|
||||||
|
|
||||||
|
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||||
|
|
||||||
|
T? GetService<T>();
|
||||||
|
|
||||||
|
bool TryGetProperty<T>(string key, out T? value);
|
||||||
|
}
|
||||||
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal file
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface IPluginSettingsService
|
||||||
|
{
|
||||||
|
string PluginId { get; }
|
||||||
|
|
||||||
|
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
|
||||||
|
|
||||||
|
T LoadComponentSection<T>(string componentId, string? placementId, string sectionId) where T : new();
|
||||||
|
|
||||||
|
void SaveComponentSection<T>(
|
||||||
|
string componentId,
|
||||||
|
string? placementId,
|
||||||
|
string sectionId,
|
||||||
|
T section,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null);
|
||||||
|
|
||||||
|
void DeleteComponentSection(string componentId, string? placementId, string sectionId);
|
||||||
|
}
|
||||||
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface ISettingsCatalog
|
||||||
|
{
|
||||||
|
IReadOnlyList<SettingsSectionDefinition> GetSections();
|
||||||
|
|
||||||
|
IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope);
|
||||||
|
}
|
||||||
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal file
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface ISettingsPageHostContext
|
||||||
|
{
|
||||||
|
void OpenDrawer(Control content, string? title = null);
|
||||||
|
|
||||||
|
void CloseDrawer();
|
||||||
|
|
||||||
|
void RequestRestart(string? reason = null);
|
||||||
|
}
|
||||||
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal file
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
event EventHandler<SettingsChangedEvent>? Changed;
|
||||||
|
|
||||||
|
T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new();
|
||||||
|
|
||||||
|
void SaveSnapshot<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
T snapshot,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null);
|
||||||
|
|
||||||
|
T LoadSection<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string subjectId,
|
||||||
|
string sectionId,
|
||||||
|
string? placementId = null) where T : new();
|
||||||
|
|
||||||
|
void SaveSection<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string subjectId,
|
||||||
|
string sectionId,
|
||||||
|
T section,
|
||||||
|
string? placementId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null);
|
||||||
|
|
||||||
|
void DeleteSection(
|
||||||
|
SettingsScope scope,
|
||||||
|
string subjectId,
|
||||||
|
string sectionId,
|
||||||
|
string? placementId = null);
|
||||||
|
|
||||||
|
T? GetValue<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string key,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null);
|
||||||
|
|
||||||
|
void SetValue<T>(
|
||||||
|
SettingsScope scope,
|
||||||
|
string key,
|
||||||
|
T value,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null);
|
||||||
|
|
||||||
|
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
|
||||||
|
}
|
||||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record InstalledPluginInfo(
|
||||||
|
PluginManifest Manifest,
|
||||||
|
bool IsEnabled,
|
||||||
|
bool IsLoaded,
|
||||||
|
bool IsPackage,
|
||||||
|
string? ErrorMessage);
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.0</Version>
|
<Version>3.0.0</Version>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
public abstract class PluginBase : IPlugin
|
public abstract class PluginBase : IPlugin
|
||||||
{
|
{
|
||||||
public virtual void Initialize(IPluginContext context)
|
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ public sealed class PluginDesktopComponentContext
|
|||||||
IReadOnlyDictionary<string, object?> properties,
|
IReadOnlyDictionary<string, object?> properties,
|
||||||
string componentId,
|
string componentId,
|
||||||
string? placementId,
|
string? placementId,
|
||||||
double cellSize)
|
double cellSize,
|
||||||
|
IPluginSettingsService? pluginSettings = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(manifest);
|
ArgumentNullException.ThrowIfNull(manifest);
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||||
@@ -27,6 +28,7 @@ public sealed class PluginDesktopComponentContext
|
|||||||
ComponentId = componentId.Trim();
|
ComponentId = componentId.Trim();
|
||||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||||
CellSize = Math.Max(1, cellSize);
|
CellSize = Math.Max(1, cellSize);
|
||||||
|
PluginSettings = pluginSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PluginManifest Manifest { get; }
|
public PluginManifest Manifest { get; }
|
||||||
@@ -45,6 +47,8 @@ public sealed class PluginDesktopComponentContext
|
|||||||
|
|
||||||
public double CellSize { get; }
|
public double CellSize { get; }
|
||||||
|
|
||||||
|
public IPluginSettingsService? PluginSettings { get; }
|
||||||
|
|
||||||
public T? GetService<T>()
|
public T? GetService<T>()
|
||||||
{
|
{
|
||||||
return (T?)Services.GetService(typeof(T));
|
return (T?)Services.GetService(typeof(T));
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration
|
|||||||
public PluginDesktopComponentRegistration(
|
public PluginDesktopComponentRegistration(
|
||||||
string componentId,
|
string componentId,
|
||||||
string displayName,
|
string displayName,
|
||||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||||
string iconKey = "PuzzlePiece",
|
string iconKey = "PuzzlePiece",
|
||||||
string category = "Plugins",
|
string category = "Plugins",
|
||||||
int minWidthCells = 2,
|
int minWidthCells = 2,
|
||||||
@@ -40,13 +40,42 @@ public sealed class PluginDesktopComponentRegistration
|
|||||||
CornerRadiusResolver = cornerRadiusResolver;
|
CornerRadiusResolver = cornerRadiusResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public PluginDesktopComponentRegistration(
|
||||||
|
string componentId,
|
||||||
|
string displayName,
|
||||||
|
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
string category = "Plugins",
|
||||||
|
int minWidthCells = 2,
|
||||||
|
int minHeightCells = 2,
|
||||||
|
bool allowDesktopPlacement = true,
|
||||||
|
bool allowStatusBarPlacement = false,
|
||||||
|
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
string? displayNameLocalizationKey = null,
|
||||||
|
Func<double, double>? cornerRadiusResolver = null)
|
||||||
|
: this(
|
||||||
|
componentId,
|
||||||
|
displayName,
|
||||||
|
(_, context) => controlFactory(context),
|
||||||
|
iconKey,
|
||||||
|
category,
|
||||||
|
minWidthCells,
|
||||||
|
minHeightCells,
|
||||||
|
allowDesktopPlacement,
|
||||||
|
allowStatusBarPlacement,
|
||||||
|
resizeMode,
|
||||||
|
displayNameLocalizationKey,
|
||||||
|
cornerRadiusResolver)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public string ComponentId { get; }
|
public string ComponentId { get; }
|
||||||
|
|
||||||
public string DisplayName { get; }
|
public string DisplayName { get; }
|
||||||
|
|
||||||
public string? DisplayNameLocalizationKey { get; }
|
public string? DisplayNameLocalizationKey { get; }
|
||||||
|
|
||||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||||
|
|
||||||
public string IconKey { get; }
|
public string IconKey { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class PluginLocalizer
|
|||||||
|
|
||||||
public string LanguageCode { get; }
|
public string LanguageCode { get; }
|
||||||
|
|
||||||
public static PluginLocalizer Create(IPluginContext context)
|
public static PluginLocalizer Create(IPluginRuntimeContext context)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ public sealed record PluginManifest(
|
|||||||
string? Description = null,
|
string? Description = null,
|
||||||
string? Author = null,
|
string? Author = null,
|
||||||
string? Version = null,
|
string? Version = null,
|
||||||
string? ApiVersion = null)
|
string? ApiVersion = null,
|
||||||
|
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||||
{
|
{
|
||||||
@@ -57,6 +58,7 @@ public sealed record PluginManifest(
|
|||||||
|
|
||||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||||
{
|
{
|
||||||
|
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||||
var normalized = this with
|
var normalized = this with
|
||||||
{
|
{
|
||||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||||
@@ -65,7 +67,8 @@ public sealed record PluginManifest(
|
|||||||
Description = NormalizeOptionalValue(Description),
|
Description = NormalizeOptionalValue(Description),
|
||||||
Author = NormalizeOptionalValue(Author),
|
Author = NormalizeOptionalValue(Author),
|
||||||
Version = NormalizeOptionalValue(Version),
|
Version = NormalizeOptionalValue(Version),
|
||||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||||
|
SharedContracts = normalizedSharedContracts
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||||
@@ -82,7 +85,44 @@ public sealed record PluginManifest(
|
|||||||
if (requestedVersion.Major != currentVersion.Major)
|
if (requestedVersion.Major != currentVersion.Major)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
|
||||||
|
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
|
||||||
|
$"This host only supports v{currentVersion.Major}.x plugins. " +
|
||||||
|
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
|
||||||
|
string manifestPath,
|
||||||
|
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
|
||||||
|
{
|
||||||
|
if (sharedContracts is null || sharedContracts.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<PluginSharedContractReference>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
|
||||||
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var contract in sharedContracts)
|
||||||
|
{
|
||||||
|
if (contract is null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
|
||||||
|
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
||||||
|
if (!seenIds.Add(contractKey))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.Add(normalizedContract);
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginPackageInstallResult(
|
||||||
|
PluginManifest Manifest,
|
||||||
|
bool ReplacedExisting,
|
||||||
|
bool RestartRequired);
|
||||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
|||||||
|
|
||||||
public static class PluginSdkInfo
|
public static class PluginSdkInfo
|
||||||
{
|
{
|
||||||
public const string ApiVersion = "1.0.0";
|
public const string ApiVersion = "3.0.0";
|
||||||
public const string ManifestFileName = "plugin.json";
|
public const string ManifestFileName = "plugin.json";
|
||||||
public const string PackageFileExtension = ".laapp";
|
public const string PackageFileExtension = ".laapp";
|
||||||
public const string DataDirectoryName = "Data";
|
public const string DataDirectoryName = "Data";
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public static class PluginServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddPluginSettingsSection(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string id,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
Action<PluginSettingsSectionBuilder> configure,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
int sortOrder = 0)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
ArgumentNullException.ThrowIfNull(configure);
|
||||||
|
|
||||||
|
var builder = new PluginSettingsSectionBuilder(
|
||||||
|
id,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
iconKey,
|
||||||
|
sortOrder);
|
||||||
|
configure(builder);
|
||||||
|
services.AddSingleton(builder.Build());
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string componentId,
|
||||||
|
string displayName,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
string category = "Plugins",
|
||||||
|
int minWidthCells = 2,
|
||||||
|
int minHeightCells = 2,
|
||||||
|
bool allowDesktopPlacement = true,
|
||||||
|
bool allowStatusBarPlacement = false,
|
||||||
|
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||||
|
string? displayNameLocalizationKey = null,
|
||||||
|
Func<double, double>? cornerRadiusResolver = null)
|
||||||
|
where TControl : Control
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||||
|
componentId,
|
||||||
|
displayName,
|
||||||
|
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||||
|
iconKey,
|
||||||
|
category,
|
||||||
|
minWidthCells,
|
||||||
|
minHeightCells,
|
||||||
|
allowDesktopPlacement,
|
||||||
|
allowStatusBarPlacement,
|
||||||
|
resizeMode,
|
||||||
|
displayNameLocalizationKey,
|
||||||
|
cornerRadiusResolver));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
|
||||||
|
this IServiceCollection services,
|
||||||
|
string componentId,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
where TControl : Control
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
|
||||||
|
componentId,
|
||||||
|
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||||
|
preferredWidth,
|
||||||
|
preferredHeight,
|
||||||
|
minScale,
|
||||||
|
maxScale));
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
||||||
|
where TContract : class
|
||||||
|
where TImplementation : class, TContract
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(services);
|
||||||
|
|
||||||
|
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||||
|
|
||||||
|
if (!services.Any(descriptor =>
|
||||||
|
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
|
||||||
|
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
|
||||||
|
existing.ContractType == typeof(TContract) &&
|
||||||
|
existing.ImplementationType == typeof(TImplementation)))
|
||||||
|
{
|
||||||
|
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||||
|
where TContract : class
|
||||||
|
where TImplementation : class, TContract
|
||||||
|
{
|
||||||
|
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
|
||||||
|
if (contractDescriptor is null)
|
||||||
|
{
|
||||||
|
services.AddSingleton<TContract, TImplementation>();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginServiceExportDescriptor(
|
||||||
|
string ProviderPluginId,
|
||||||
|
Type ContractType,
|
||||||
|
object ServiceInstance);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginServiceExportRegistration
|
||||||
|
{
|
||||||
|
public PluginServiceExportRegistration(Type contractType, Type implementationType)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(contractType);
|
||||||
|
ArgumentNullException.ThrowIfNull(implementationType);
|
||||||
|
|
||||||
|
ContractType = contractType;
|
||||||
|
ImplementationType = implementationType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Type ContractType { get; }
|
||||||
|
|
||||||
|
public Type ImplementationType { get; }
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
public sealed class PluginSettingsPageRegistration
|
|
||||||
{
|
|
||||||
public PluginSettingsPageRegistration(
|
|
||||||
string id,
|
|
||||||
string title,
|
|
||||||
Func<Control> contentFactory,
|
|
||||||
int sortOrder = 0)
|
|
||||||
{
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
|
||||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
|
||||||
ArgumentNullException.ThrowIfNull(contentFactory);
|
|
||||||
|
|
||||||
Id = id.Trim();
|
|
||||||
Title = title.Trim();
|
|
||||||
ContentFactory = contentFactory;
|
|
||||||
SortOrder = sortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Id { get; }
|
|
||||||
|
|
||||||
public string Title { get; }
|
|
||||||
|
|
||||||
public int SortOrder { get; }
|
|
||||||
|
|
||||||
public Func<Control> ContentFactory { get; }
|
|
||||||
}
|
|
||||||
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal file
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginSettingsSectionBuilder
|
||||||
|
{
|
||||||
|
private readonly List<SettingsOptionDefinition> _options = [];
|
||||||
|
|
||||||
|
internal PluginSettingsSectionBuilder(
|
||||||
|
string id,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey,
|
||||||
|
string iconKey,
|
||||||
|
int sortOrder)
|
||||||
|
{
|
||||||
|
Id = id;
|
||||||
|
TitleLocalizationKey = titleLocalizationKey;
|
||||||
|
DescriptionLocalizationKey = descriptionLocalizationKey;
|
||||||
|
IconKey = iconKey;
|
||||||
|
SortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string TitleLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string? DescriptionLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string IconKey { get; }
|
||||||
|
|
||||||
|
public int SortOrder { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(option);
|
||||||
|
_options.Add(option);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddToggle(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
bool defaultValue = false)
|
||||||
|
{
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.Toggle,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddText(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string defaultValue = "",
|
||||||
|
string? validationPattern = null)
|
||||||
|
{
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.Text,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue,
|
||||||
|
validationPattern: validationPattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddNumber(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
double defaultValue = 0,
|
||||||
|
double? minimum = null,
|
||||||
|
double? maximum = null)
|
||||||
|
{
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.Number,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue,
|
||||||
|
minimum: minimum,
|
||||||
|
maximum: maximum));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddSelect(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
IEnumerable<SettingsOptionChoice> choices,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string? defaultValue = null)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(choices);
|
||||||
|
var normalizedChoices = choices.ToArray();
|
||||||
|
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.Select,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue,
|
||||||
|
normalizedChoices));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddPath(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string defaultValue = "")
|
||||||
|
{
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.Path,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PluginSettingsSectionBuilder AddList(
|
||||||
|
string key,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
IReadOnlyList<string>? defaultValue = null)
|
||||||
|
{
|
||||||
|
return AddOption(new SettingsOptionDefinition(
|
||||||
|
key,
|
||||||
|
SettingsOptionType.List,
|
||||||
|
titleLocalizationKey,
|
||||||
|
descriptionLocalizationKey,
|
||||||
|
defaultValue ?? Array.Empty<string>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
internal PluginSettingsSectionRegistration Build()
|
||||||
|
{
|
||||||
|
return new PluginSettingsSectionRegistration(
|
||||||
|
Id,
|
||||||
|
TitleLocalizationKey,
|
||||||
|
_options.ToArray(),
|
||||||
|
DescriptionLocalizationKey,
|
||||||
|
IconKey,
|
||||||
|
SortOrder);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class PluginSettingsSectionRegistration
|
||||||
|
{
|
||||||
|
public PluginSettingsSectionRegistration(
|
||||||
|
string id,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
IReadOnlyList<SettingsOptionDefinition> options,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string iconKey = "PuzzlePiece",
|
||||||
|
int sortOrder = 0)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||||
|
|
||||||
|
Id = id.Trim();
|
||||||
|
TitleLocalizationKey = titleLocalizationKey.Trim();
|
||||||
|
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
|
||||||
|
? null
|
||||||
|
: descriptionLocalizationKey.Trim();
|
||||||
|
IconKey = iconKey.Trim();
|
||||||
|
SortOrder = sortOrder;
|
||||||
|
Options = options ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string TitleLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string? DescriptionLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string IconKey { get; }
|
||||||
|
|
||||||
|
public int SortOrder { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed record PluginSharedContractReference(
|
||||||
|
string Id,
|
||||||
|
string Version,
|
||||||
|
string AssemblyName)
|
||||||
|
{
|
||||||
|
[JsonIgnore]
|
||||||
|
public string NormalizedId => Id.Trim();
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string NormalizedVersion => Version.Trim();
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string NormalizedAssemblyName => AssemblyName.Trim();
|
||||||
|
|
||||||
|
internal PluginSharedContractReference NormalizeAndValidate(string manifestPath)
|
||||||
|
{
|
||||||
|
var normalized = this with
|
||||||
|
{
|
||||||
|
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||||
|
Version = RequireValue(Version, nameof(Version), manifestPath),
|
||||||
|
AssemblyName = RequireValue(AssemblyName, nameof(AssemblyName), manifestPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!System.Version.TryParse(normalized.Version, out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' declares invalid shared contract version '{normalized.Version}' for '{normalized.Id}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin manifest '{manifestPath}' is missing required shared contract property '{propertyName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal file
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public static class SettingsCategories
|
||||||
|
{
|
||||||
|
public const string General = "General";
|
||||||
|
public const string Appearance = "Appearance";
|
||||||
|
public const string Components = "Components";
|
||||||
|
public const string Plugins = "Plugins";
|
||||||
|
public const string PluginMarket = "PluginMarket";
|
||||||
|
public const string Update = "Update";
|
||||||
|
public const string About = "About";
|
||||||
|
public const string Advanced = "Advanced";
|
||||||
|
public const string External = "External";
|
||||||
|
}
|
||||||
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal file
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class SettingsChangedEvent
|
||||||
|
{
|
||||||
|
public SettingsChangedEvent(
|
||||||
|
SettingsScope scope,
|
||||||
|
string? subjectId = null,
|
||||||
|
string? placementId = null,
|
||||||
|
string? sectionId = null,
|
||||||
|
IReadOnlyCollection<string>? changedKeys = null)
|
||||||
|
{
|
||||||
|
Scope = scope;
|
||||||
|
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
|
||||||
|
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||||
|
SectionId = string.IsNullOrWhiteSpace(sectionId) ? null : sectionId.Trim();
|
||||||
|
ChangedKeys = changedKeys is { Count: > 0 }
|
||||||
|
? changedKeys.ToArray()
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public SettingsScope Scope { get; }
|
||||||
|
|
||||||
|
public string? SubjectId { get; }
|
||||||
|
|
||||||
|
public string? PlacementId { get; }
|
||||||
|
|
||||||
|
public string? SectionId { get; }
|
||||||
|
|
||||||
|
public IReadOnlyCollection<string> ChangedKeys { get; }
|
||||||
|
}
|
||||||
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal file
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class SettingsOptionChoice
|
||||||
|
{
|
||||||
|
public SettingsOptionChoice(string value, string titleLocalizationKey)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(value);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
||||||
|
|
||||||
|
Value = value.Trim();
|
||||||
|
TitleLocalizationKey = titleLocalizationKey.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Value { get; }
|
||||||
|
|
||||||
|
public string TitleLocalizationKey { get; }
|
||||||
|
}
|
||||||
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class SettingsOptionDefinition
|
||||||
|
{
|
||||||
|
public SettingsOptionDefinition(
|
||||||
|
string key,
|
||||||
|
SettingsOptionType optionType,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
object? defaultValue = null,
|
||||||
|
IReadOnlyList<SettingsOptionChoice>? choices = null,
|
||||||
|
double? minimum = null,
|
||||||
|
double? maximum = null,
|
||||||
|
string? validationPattern = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
||||||
|
|
||||||
|
Key = key.Trim();
|
||||||
|
OptionType = optionType;
|
||||||
|
TitleLocalizationKey = titleLocalizationKey.Trim();
|
||||||
|
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
|
||||||
|
? null
|
||||||
|
: descriptionLocalizationKey.Trim();
|
||||||
|
DefaultValue = defaultValue;
|
||||||
|
Choices = choices ?? [];
|
||||||
|
Minimum = minimum;
|
||||||
|
Maximum = maximum;
|
||||||
|
ValidationPattern = string.IsNullOrWhiteSpace(validationPattern)
|
||||||
|
? null
|
||||||
|
: validationPattern.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Key { get; }
|
||||||
|
|
||||||
|
public SettingsOptionType OptionType { get; }
|
||||||
|
|
||||||
|
public string TitleLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string? DescriptionLocalizationKey { get; }
|
||||||
|
|
||||||
|
public object? DefaultValue { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SettingsOptionChoice> Choices { get; }
|
||||||
|
|
||||||
|
public double? Minimum { get; }
|
||||||
|
|
||||||
|
public double? Maximum { get; }
|
||||||
|
|
||||||
|
public string? ValidationPattern { get; }
|
||||||
|
}
|
||||||
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum SettingsOptionType
|
||||||
|
{
|
||||||
|
Toggle = 0,
|
||||||
|
Select = 1,
|
||||||
|
Text = 2,
|
||||||
|
Number = 3,
|
||||||
|
Path = 4,
|
||||||
|
List = 5
|
||||||
|
}
|
||||||
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal file
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public abstract class SettingsPageBase : UserControl
|
||||||
|
{
|
||||||
|
public static readonly string DialogHostIdentifier = "LanMountainDesktop.SettingsWindow";
|
||||||
|
|
||||||
|
private ISettingsPageHostContext? _hostContext;
|
||||||
|
|
||||||
|
public ISettingsPageHostContext? HostContext => _hostContext;
|
||||||
|
|
||||||
|
public Uri? NavigationUri { get; set; }
|
||||||
|
|
||||||
|
public void InitializeHostContext(ISettingsPageHostContext hostContext)
|
||||||
|
{
|
||||||
|
_hostContext = hostContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual void OnNavigatedTo(object? parameter)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OpenDrawer(Control content, string? title = null)
|
||||||
|
{
|
||||||
|
_hostContext?.OpenDrawer(content, title);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void OpenDrawer(object content, bool usePageDataContext = false, object? dataContext = null, string? title = null)
|
||||||
|
{
|
||||||
|
if (content is Control control && !usePageDataContext)
|
||||||
|
{
|
||||||
|
control.DataContext = dataContext ?? DataContext ?? this;
|
||||||
|
OpenDrawer(control, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content is Control drawerControl)
|
||||||
|
{
|
||||||
|
OpenDrawer(drawerControl, title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void CloseDrawer()
|
||||||
|
{
|
||||||
|
_hostContext?.CloseDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void RequestRestart(string? reason = null)
|
||||||
|
{
|
||||||
|
_hostContext?.RequestRestart(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum SettingsPageCategory
|
||||||
|
{
|
||||||
|
General = 0,
|
||||||
|
Appearance = 10,
|
||||||
|
Components = 20,
|
||||||
|
Plugins = 30,
|
||||||
|
PluginMarket = 35,
|
||||||
|
About = 40
|
||||||
|
}
|
||||||
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal file
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||||
|
public sealed class SettingsPageInfoAttribute : Attribute
|
||||||
|
{
|
||||||
|
public SettingsPageInfoAttribute(
|
||||||
|
string id,
|
||||||
|
string name,
|
||||||
|
SettingsPageCategory category)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||||
|
|
||||||
|
Id = id.Trim();
|
||||||
|
Name = name.Trim();
|
||||||
|
Category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Name { get; }
|
||||||
|
|
||||||
|
public SettingsPageCategory Category { get; }
|
||||||
|
|
||||||
|
public string? TitleLocalizationKey { get; init; }
|
||||||
|
|
||||||
|
public string? DescriptionLocalizationKey { get; init; }
|
||||||
|
|
||||||
|
public string IconKey { get; init; } = "Settings";
|
||||||
|
|
||||||
|
public string? SelectedIconKey { get; init; }
|
||||||
|
|
||||||
|
public int SortOrder { get; init; }
|
||||||
|
|
||||||
|
public bool HideDefault { get; init; }
|
||||||
|
|
||||||
|
public bool HidePageTitle { get; init; }
|
||||||
|
|
||||||
|
public bool UseFullWidth { get; init; }
|
||||||
|
|
||||||
|
public string? GroupId { get; init; }
|
||||||
|
|
||||||
|
public SettingsScope Scope { get; init; } = SettingsScope.App;
|
||||||
|
}
|
||||||
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public enum SettingsScope
|
||||||
|
{
|
||||||
|
App = 0,
|
||||||
|
Launcher = 1,
|
||||||
|
Plugin = 2,
|
||||||
|
ComponentInstance = 3
|
||||||
|
}
|
||||||
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
public sealed class SettingsSectionDefinition
|
||||||
|
{
|
||||||
|
public SettingsSectionDefinition(
|
||||||
|
string id,
|
||||||
|
string category,
|
||||||
|
SettingsScope scope,
|
||||||
|
string titleLocalizationKey,
|
||||||
|
string? descriptionLocalizationKey = null,
|
||||||
|
string iconKey = "Settings",
|
||||||
|
int sortOrder = 0,
|
||||||
|
string? subjectId = null,
|
||||||
|
IReadOnlyList<SettingsOptionDefinition>? options = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||||
|
|
||||||
|
Id = id.Trim();
|
||||||
|
Category = category.Trim();
|
||||||
|
Scope = scope;
|
||||||
|
TitleLocalizationKey = titleLocalizationKey.Trim();
|
||||||
|
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
|
||||||
|
? null
|
||||||
|
: descriptionLocalizationKey.Trim();
|
||||||
|
IconKey = iconKey.Trim();
|
||||||
|
SortOrder = sortOrder;
|
||||||
|
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
|
||||||
|
Options = options ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; }
|
||||||
|
|
||||||
|
public string Category { get; }
|
||||||
|
|
||||||
|
public SettingsScope Scope { get; }
|
||||||
|
|
||||||
|
public string TitleLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string? DescriptionLocalizationKey { get; }
|
||||||
|
|
||||||
|
public string IconKey { get; }
|
||||||
|
|
||||||
|
public int SortOrder { get; }
|
||||||
|
|
||||||
|
public string? SubjectId { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageVersion>$(Version)</PackageVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
|
||||||
|
internal static class Program
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan[] RetryDelays =
|
||||||
|
[
|
||||||
|
TimeSpan.FromMilliseconds(120),
|
||||||
|
TimeSpan.FromMilliseconds(250),
|
||||||
|
TimeSpan.FromMilliseconds(500)
|
||||||
|
];
|
||||||
|
|
||||||
|
private static async Task<int> Main(string[] args)
|
||||||
|
{
|
||||||
|
var result = new HelperResult();
|
||||||
|
string? resultPath = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var parsedArgs = ParseArgs(args);
|
||||||
|
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||||
|
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||||
|
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||||
|
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||||
|
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||||
|
string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||||
|
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||||
|
resultPath = Path.GetFullPath(resultPath);
|
||||||
|
|
||||||
|
if (!File.Exists(fullSourcePath))
|
||||||
|
{
|
||||||
|
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||||
|
Directory.CreateDirectory(fullPluginsDirectory);
|
||||||
|
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||||
|
var stagingPath = destinationPath + ".incoming";
|
||||||
|
DeleteFileWithRetry(stagingPath);
|
||||||
|
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||||
|
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||||
|
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||||
|
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
InstalledPackagePath = destinationPath,
|
||||||
|
ManifestId = manifest.Id,
|
||||||
|
ManifestName = manifest.Name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result = new HelperResult
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
ErrorMessage = ex.Message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||||
|
{
|
||||||
|
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||||
|
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(resultDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
resultPath,
|
||||||
|
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true
|
||||||
|
}),
|
||||||
|
Encoding.UTF8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Success ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
for (var i = 0; i < args.Length; i++)
|
||||||
|
{
|
||||||
|
var current = args[i];
|
||||||
|
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = current[2..];
|
||||||
|
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
values[key] = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(packagePath);
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (entries.Length == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entries.Length > 1)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = entries[0].Open();
|
||||||
|
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||||
|
{
|
||||||
|
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||||
|
foreach (var existingPackagePath in Directory
|
||||||
|
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||||
|
.Select(Path.GetFullPath)
|
||||||
|
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||||
|
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeleteFileWithRetry(existingPackagePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore unrelated or malformed packages while replacing an install target.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||||
|
{
|
||||||
|
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||||
|
{
|
||||||
|
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void DeleteFileWithRetry(string filePath)
|
||||||
|
{
|
||||||
|
Retry(() =>
|
||||||
|
{
|
||||||
|
if (File.Exists(filePath))
|
||||||
|
{
|
||||||
|
File.Delete(filePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Retry(Action action)
|
||||||
|
{
|
||||||
|
Exception? lastException = null;
|
||||||
|
|
||||||
|
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
action();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
lastException = ex;
|
||||||
|
if (attempt >= RetryDelays.Length)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
Thread.Sleep(RetryDelays[attempt]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastException is not null)
|
||||||
|
{
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildInstalledPackageFileName(string pluginId)
|
||||||
|
{
|
||||||
|
var invalidChars = Path.GetInvalidFileNameChars();
|
||||||
|
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||||
|
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string EnsureTrailingSeparator(string path)
|
||||||
|
{
|
||||||
|
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||||
|
? path
|
||||||
|
: path + Path.DirectorySeparatorChar;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class HelperResult
|
||||||
|
{
|
||||||
|
public bool Success { get; init; }
|
||||||
|
|
||||||
|
public string? InstalledPackagePath { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestId { get; init; }
|
||||||
|
|
||||||
|
public string? ManifestName { get; init; }
|
||||||
|
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class ComponentSettingsServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Load_MigratesLegacySnapshotFileToCanonicalDocument()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
File.WriteAllText(
|
||||||
|
sandbox.SettingsPath,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"DesktopClockSecondHandMode": "Sweep",
|
||||||
|
"ImportedClassSchedules": [
|
||||||
|
{
|
||||||
|
"Id": "spring-2026",
|
||||||
|
"DisplayName": "Spring 2026",
|
||||||
|
"FilePath": "C:\\Schedules\\spring-2026.yaml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ActiveImportedClassScheduleId": "spring-2026"
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
var snapshot = service.Load();
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.Single(snapshot.ImportedClassSchedules);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||||
|
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||||
|
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
File.WriteAllText(
|
||||||
|
sandbox.SettingsPath,
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"DefaultSettings": {
|
||||||
|
"DesktopClockSecondHandMode": "Tick"
|
||||||
|
},
|
||||||
|
"InstanceSettings": {
|
||||||
|
"DesktopClock::clock-2x2": {
|
||||||
|
"DesktopClockSecondHandMode": "Sweep"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PluginSettings": {
|
||||||
|
"DesktopClock::clock-2x2": {
|
||||||
|
"SampleFlag": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""");
|
||||||
|
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2");
|
||||||
|
var pluginSettings = service.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.True(pluginSettings.SampleFlag);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||||
|
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||||
|
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService()
|
||||||
|
{
|
||||||
|
using var sandbox = new ComponentSettingsSandbox();
|
||||||
|
var service = sandbox.CreateService();
|
||||||
|
|
||||||
|
service.SaveForComponent(
|
||||||
|
"DesktopClock",
|
||||||
|
"clock-2x2",
|
||||||
|
new ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
DesktopClockSecondHandMode = "Sweep"
|
||||||
|
});
|
||||||
|
service.SaveForComponent(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2",
|
||||||
|
new ComponentSettingsSnapshot
|
||||||
|
{
|
||||||
|
ImportedClassSchedules =
|
||||||
|
[
|
||||||
|
new ImportedClassScheduleSnapshot
|
||||||
|
{
|
||||||
|
Id = "spring-2026",
|
||||||
|
DisplayName = "Spring 2026",
|
||||||
|
FilePath = "C:\\Schedules\\spring-2026.yaml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
ActiveImportedClassScheduleId = "spring-2026"
|
||||||
|
});
|
||||||
|
service.SavePluginSettings(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2",
|
||||||
|
new SamplePluginSettings
|
||||||
|
{
|
||||||
|
SampleFlag = true,
|
||||||
|
Title = "schedule-settings"
|
||||||
|
});
|
||||||
|
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
var reloadedService = sandbox.CreateService();
|
||||||
|
|
||||||
|
var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||||
|
var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2");
|
||||||
|
var pluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>(
|
||||||
|
"DesktopClassSchedule",
|
||||||
|
"class-schedule-2x2");
|
||||||
|
|
||||||
|
Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode);
|
||||||
|
Assert.Single(classScheduleSnapshot.ImportedClassSchedules);
|
||||||
|
Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId);
|
||||||
|
Assert.True(pluginSettings.SampleFlag);
|
||||||
|
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||||
|
|
||||||
|
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||||
|
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||||
|
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||||
|
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ComponentSettingsSandbox : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _directoryPath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"LanMountainDesktop.ComponentSettingsTests",
|
||||||
|
Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
public ComponentSettingsSandbox()
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(_directoryPath);
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||||
|
|
||||||
|
public ComponentSettingsService CreateService()
|
||||||
|
{
|
||||||
|
return new ComponentSettingsService(_directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
ComponentSettingsService.ResetCacheForTests();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_directoryPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_directoryPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Temporary test directories are best-effort cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SamplePluginSettings
|
||||||
|
{
|
||||||
|
public bool SampleFlag { get; set; }
|
||||||
|
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Tests;
|
||||||
|
|
||||||
|
public sealed class UiExceptionGuardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler()
|
||||||
|
{
|
||||||
|
var handlerCalled = false;
|
||||||
|
|
||||||
|
await UiExceptionGuard.RunGuardedUiActionAsync(
|
||||||
|
() => throw new InvalidOperationException("boom"),
|
||||||
|
"UnitTest.NonFatal",
|
||||||
|
onHandledException: ex =>
|
||||||
|
{
|
||||||
|
handlerCalled = ex is InvalidOperationException;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.True(handlerCalled);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RunGuardedUiActionAsync_RethrowsFatalException()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<OutOfMemoryException>(() =>
|
||||||
|
UiExceptionGuard.RunGuardedUiActionAsync(
|
||||||
|
() => throw new OutOfMemoryException("fatal"),
|
||||||
|
"UnitTest.Fatal"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsFatalException_ReturnsExpectedClassification()
|
||||||
|
{
|
||||||
|
Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException()));
|
||||||
|
Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException()));
|
||||||
|
Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.0.31903.59
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
||||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<Solution>
|
||||||
|
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||||
|
</Solution>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<Application xmlns="https://github.com/avaloniaui"
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:sty="using:FluentAvalonia.Styling"
|
xmlns:sty="using:FluentAvalonia.Styling"
|
||||||
xmlns:fi="using:FluentIcons.Avalonia"
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
@@ -16,29 +16,13 @@
|
|||||||
<local:ViewLocator/>
|
<local:ViewLocator/>
|
||||||
</Application.DataTemplates>
|
</Application.DataTemplates>
|
||||||
|
|
||||||
<TrayIcon.Icons>
|
|
||||||
<TrayIcons>
|
|
||||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
|
||||||
ToolTipText="LanMountainDesktop">
|
|
||||||
<TrayIcon.Menu>
|
|
||||||
<NativeMenu>
|
|
||||||
<NativeMenuItem Header="设置" Click="OnTraySettingsClick" />
|
|
||||||
<NativeMenuItemSeparator />
|
|
||||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
|
||||||
<NativeMenuItemSeparator />
|
|
||||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
|
||||||
</NativeMenu>
|
|
||||||
</TrayIcon.Menu>
|
|
||||||
</TrayIcon>
|
|
||||||
</TrayIcons>
|
|
||||||
</TrayIcon.Icons>
|
|
||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<sty:FluentAvaloniaTheme />
|
<sty:FluentAvaloniaTheme />
|
||||||
<mi:MaterialIconStyles />
|
<mi:MaterialIconStyles />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
|
||||||
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
|
||||||
|
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
|
||||||
|
|
||||||
<Style Selector="Window">
|
<Style Selector="Window">
|
||||||
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
|
||||||
|
|||||||
@@ -1,102 +1,172 @@
|
|||||||
using Avalonia;
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.ApplicationLifetimes;
|
using Avalonia.Controls.ApplicationLifetimes;
|
||||||
using Avalonia.Data.Core;
|
using Avalonia.Data.Core;
|
||||||
using Avalonia.Data.Core.Plugins;
|
using Avalonia.Data.Core.Plugins;
|
||||||
using System;
|
|
||||||
using System.Diagnostics;
|
|
||||||
using System.Linq;
|
|
||||||
using Avalonia.Markup.Xaml;
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Platform;
|
||||||
|
using Avalonia.Styling;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
|
using AvaloniaWebView;
|
||||||
|
using LanMountainDesktop.ComponentSystem;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using LanMountainDesktop.Theme;
|
||||||
using LanMountainDesktop.ViewModels;
|
using LanMountainDesktop.ViewModels;
|
||||||
using LanMountainDesktop.Views;
|
using LanMountainDesktop.Views;
|
||||||
using AvaloniaWebView;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
private SettingsWindow? _traySettingsWindow;
|
private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
|
||||||
|
private enum DesktopShellState
|
||||||
|
{
|
||||||
|
ForegroundDesktop = 0,
|
||||||
|
MinimizedToTaskbar = 1,
|
||||||
|
TrayOnly = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum ShutdownIntent
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
ExitRequested = 1,
|
||||||
|
RestartRequested = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
|
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
|
||||||
|
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
|
||||||
|
private readonly LocalizationService _localizationService = new();
|
||||||
|
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||||
|
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
|
||||||
|
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
|
||||||
|
private ISettingsPageRegistry? _settingsPageRegistry;
|
||||||
|
private ISettingsWindowService? _settingsWindowService;
|
||||||
|
private WeatherLocationRefreshService? _weatherLocationRefreshService;
|
||||||
|
private bool _exitCleanupCompleted;
|
||||||
|
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||||
|
private ShutdownIntent _shutdownIntent;
|
||||||
|
|
||||||
|
private TrayIcons? _trayIcons;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
|
private MainWindow? _mainWindow;
|
||||||
|
private bool _mainWindowClosed;
|
||||||
|
private bool _uiUnhandledExceptionHooked;
|
||||||
|
|
||||||
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
|
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||||
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
|
(Current as App)?._hostApplicationLifecycle;
|
||||||
|
|
||||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||||
|
public ISettingsFacadeService SettingsFacade => _settingsFacade;
|
||||||
|
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||||
|
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
|
||||||
|
|
||||||
|
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
|
||||||
|
{
|
||||||
|
EnsureSettingsWindowService();
|
||||||
|
AppLogger.Info(
|
||||||
|
"SettingsFacade",
|
||||||
|
$"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
|
||||||
|
_settingsWindowService?.Open(new SettingsWindowOpenRequest(
|
||||||
|
Source: source,
|
||||||
|
Owner: _mainWindow is { IsVisible: true } ? _mainWindow : null,
|
||||||
|
PageId: pageTag));
|
||||||
|
}
|
||||||
|
|
||||||
|
public App()
|
||||||
|
{
|
||||||
|
_settingsFacade.Settings.Changed += OnSettingsChanged;
|
||||||
|
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
public override void Initialize()
|
public override void Initialize()
|
||||||
{
|
{
|
||||||
|
AppLogger.Info("App", "Initializing application resources.");
|
||||||
ConfigureWebViewUserDataFolder();
|
ConfigureWebViewUserDataFolder();
|
||||||
AvaloniaWebViewBuilder.Initialize(default);
|
AvaloniaWebViewBuilder.Initialize(default);
|
||||||
AvaloniaXamlLoader.Load(this);
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
ApplyThemeFromSettings();
|
||||||
|
ApplyCurrentCultureFromSettings();
|
||||||
|
EnsureSettingsWindowService();
|
||||||
|
EnsureWeatherLocationRefreshService();
|
||||||
}
|
}
|
||||||
|
|
||||||
public override void OnFrameworkInitializationCompleted()
|
public override void OnFrameworkInitializationCompleted()
|
||||||
{
|
{
|
||||||
|
AppLogger.Info("App", "Framework initialization completed.");
|
||||||
|
RegisterUiUnhandledExceptionGuard();
|
||||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||||
InitializePluginRuntime();
|
InitializePluginRuntime();
|
||||||
|
InitializeTrayIcon();
|
||||||
|
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||||
DisableAvaloniaDataAnnotationValidation();
|
DisableAvaloniaDataAnnotationValidation();
|
||||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.Exit += (_, _) =>
|
||||||
{
|
{
|
||||||
DataContext = new MainWindowViewModel(),
|
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||||
|
PerformExitCleanup();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
|
||||||
|
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StartWeatherLocationRefreshIfNeeded();
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||||
{
|
Source: "TrayMenu",
|
||||||
desktop.Shutdown();
|
Reason: "User selected Exit App from the tray menu."));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
|
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dispatcher.UIThread.Post(() =>
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
|
|
||||||
{
|
|
||||||
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
|
|
||||||
existingWindow.Activate();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var settingsWindow = new SettingsWindow();
|
|
||||||
settingsWindow.Closed += (_, _) =>
|
|
||||||
{
|
|
||||||
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
|
|
||||||
{
|
|
||||||
_traySettingsWindow = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_traySettingsWindow = settingsWindow;
|
|
||||||
settingsWindow.Show();
|
|
||||||
settingsWindow.Activate();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
|
|
||||||
}
|
|
||||||
}, DispatcherPriority.Normal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
AppRestartService.TryRestartApplication();
|
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||||
|
Source: "TrayMenu",
|
||||||
|
Reason: "User selected Restart App from the tray menu."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
OpenIndependentSettingsModule("TrayMenu");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnTrayComponentLibraryClick(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
if (_mainWindow is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_detachedComponentLibraryWindowService.Open(_mainWindow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisableAvaloniaDataAnnotationValidation()
|
private void DisableAvaloniaDataAnnotationValidation()
|
||||||
@@ -133,9 +203,10 @@ public partial class App : Application
|
|||||||
userDataFolder,
|
userDataFolder,
|
||||||
EnvironmentVariableTarget.Process);
|
EnvironmentVariableTarget.Process);
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Keep startup resilient if user profile folders are unavailable.
|
// Keep startup resilient if user profile folders are unavailable.
|
||||||
|
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,12 +215,603 @@ public partial class App : Application
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
_pluginRuntimeService?.Dispose();
|
_pluginRuntimeService?.Dispose();
|
||||||
_pluginRuntimeService = new PluginRuntimeService();
|
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
|
||||||
|
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
|
||||||
_pluginRuntimeService.LoadInstalledPlugins();
|
_pluginRuntimeService.LoadInstalledPlugins();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
|
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InitializeTrayIcon()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
DisposeTrayIcon();
|
||||||
|
|
||||||
|
var trayIcon = new TrayIcon
|
||||||
|
{
|
||||||
|
Icon = _appLogoService.CreateTrayIcon(),
|
||||||
|
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||||
|
Menu = BuildTrayMenu(),
|
||||||
|
IsVisible = true
|
||||||
|
};
|
||||||
|
|
||||||
|
_trayIcons = [trayIcon];
|
||||||
|
TrayIcon.SetIcons(this, _trayIcons);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NativeMenu BuildTrayMenu()
|
||||||
|
{
|
||||||
|
var menu = new NativeMenu();
|
||||||
|
|
||||||
|
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
|
||||||
|
showDesktopItem.Click += OnTrayShowDesktopClick;
|
||||||
|
menu.Items.Add(showDesktopItem);
|
||||||
|
|
||||||
|
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
|
||||||
|
settingsItem.Click += OnTraySettingsClick;
|
||||||
|
menu.Items.Add(settingsItem);
|
||||||
|
|
||||||
|
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
|
||||||
|
componentLibraryItem.Click += OnTrayComponentLibraryClick;
|
||||||
|
menu.Items.Add(componentLibraryItem);
|
||||||
|
|
||||||
|
menu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
|
||||||
|
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
|
||||||
|
restartItem.Click += OnTrayRestartClick;
|
||||||
|
menu.Items.Add(restartItem);
|
||||||
|
|
||||||
|
menu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
|
||||||
|
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
|
||||||
|
exitItem.Click += OnTrayExitClick;
|
||||||
|
menu.Items.Add(exitItem);
|
||||||
|
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisposeTrayIcon()
|
||||||
|
{
|
||||||
|
if (_trayIcons is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TrayIcon.SetIcons(this, null);
|
||||||
|
foreach (var trayIcon in _trayIcons)
|
||||||
|
{
|
||||||
|
trayIcon.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_trayIcons = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureSettingsWindowService()
|
||||||
|
{
|
||||||
|
_settingsPageRegistry ??= new SettingsPageRegistry(
|
||||||
|
_settingsFacade,
|
||||||
|
_hostApplicationLifecycle,
|
||||||
|
_localizationService,
|
||||||
|
() => _pluginRuntimeService);
|
||||||
|
_settingsWindowService ??= new SettingsWindowService(
|
||||||
|
_settingsPageRegistry,
|
||||||
|
_hostApplicationLifecycle,
|
||||||
|
_settingsFacade);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureWeatherLocationRefreshService()
|
||||||
|
{
|
||||||
|
_weatherLocationRefreshService ??= new WeatherLocationRefreshService(
|
||||||
|
_settingsFacade,
|
||||||
|
_locationService,
|
||||||
|
_localizationService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartWeatherLocationRefreshIfNeeded()
|
||||||
|
{
|
||||||
|
EnsureWeatherLocationRefreshService();
|
||||||
|
if (_weatherLocationRefreshService is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _weatherLocationRefreshService.TryRefreshOnStartupAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Weather.Location", "Failed to refresh weather location during startup.", ex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyThemeFromSettings()
|
||||||
|
{
|
||||||
|
var snapshot = _appearanceThemeService.GetCurrent();
|
||||||
|
RequestedThemeVariant = snapshot.IsNightMode
|
||||||
|
? ThemeVariant.Dark
|
||||||
|
: ThemeVariant.Light;
|
||||||
|
ApplyAdaptiveThemeResources();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCurrentCultureFromSettings()
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
|
|
||||||
|
CultureInfo culture;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
culture = CultureInfo.GetCultureInfo(languageCode);
|
||||||
|
}
|
||||||
|
catch (CultureNotFoundException)
|
||||||
|
{
|
||||||
|
culture = CultureInfo.GetCultureInfo("zh-CN");
|
||||||
|
}
|
||||||
|
|
||||||
|
CultureInfo.DefaultThreadCurrentCulture = culture;
|
||||||
|
CultureInfo.DefaultThreadCurrentUICulture = culture;
|
||||||
|
Thread.CurrentThread.CurrentCulture = culture;
|
||||||
|
Thread.CurrentThread.CurrentUICulture = culture;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ActivateMainWindow()
|
||||||
|
{
|
||||||
|
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var mainWindow = GetOrCreateMainWindow(desktop, source);
|
||||||
|
mainWindow.ShowInTaskbar = true;
|
||||||
|
|
||||||
|
if (!mainWindow.IsVisible)
|
||||||
|
{
|
||||||
|
mainWindow.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.WindowState == WindowState.Minimized)
|
||||||
|
{
|
||||||
|
mainWindow.WindowState = WindowState.Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||||
|
{
|
||||||
|
mainWindow.WindowState = WindowState.FullScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.Activate();
|
||||||
|
mainWindow.Topmost = true;
|
||||||
|
mainWindow.Topmost = false;
|
||||||
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
|
||||||
|
if (showSingleInstanceNotice)
|
||||||
|
{
|
||||||
|
mainWindow.ShowSingleInstanceNotice();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Send);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void PrepareForShutdown(bool isRestart, string source)
|
||||||
|
{
|
||||||
|
void Mark()
|
||||||
|
{
|
||||||
|
_shutdownIntent = isRestart
|
||||||
|
? ShutdownIntent.RestartRequested
|
||||||
|
: ShutdownIntent.ExitRequested;
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Shutdown intent marked. Intent='{_shutdownIntent}'; Source='{source}'; CurrentShellState='{_desktopShellState}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
Mark();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.InvokeAsync(Mark, DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void ResetShutdownIntent(string source)
|
||||||
|
{
|
||||||
|
void Reset()
|
||||||
|
{
|
||||||
|
if (_shutdownIntent == ShutdownIntent.None)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Warn(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Shutdown intent cleared without process exit. PreviousIntent='{_shutdownIntent}'; Source='{source}'.");
|
||||||
|
_shutdownIntent = ShutdownIntent.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Dispatcher.UIThread.CheckAccess())
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.InvokeAsync(Reset, DispatcherPriority.Send).GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
|
||||||
|
if (e.Scope != SettingsScope.App)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() =>
|
||||||
|
{
|
||||||
|
var changedKeys = e.ChangedKeys?.ToArray();
|
||||||
|
var refreshAll = changedKeys is null || changedKeys.Length == 0;
|
||||||
|
var liveAppearance = _appearanceThemeService.GetCurrent();
|
||||||
|
var themeChanged =
|
||||||
|
refreshAll ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
|
||||||
|
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)));
|
||||||
|
var languageChanged =
|
||||||
|
refreshAll ||
|
||||||
|
changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (themeChanged)
|
||||||
|
{
|
||||||
|
ApplyThemeFromSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (languageChanged)
|
||||||
|
{
|
||||||
|
ApplyCurrentCultureFromSettings();
|
||||||
|
if (_trayIcons is not null)
|
||||||
|
{
|
||||||
|
InitializeTrayIcon();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
_ = e;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAdaptiveThemeResources()
|
||||||
|
{
|
||||||
|
_appearanceThemeService.ApplyThemeResources(Resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterUiUnhandledExceptionGuard()
|
||||||
|
{
|
||||||
|
if (_uiUnhandledExceptionHooked)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.UnhandledException += OnUiThreadUnhandledException;
|
||||||
|
_uiUnhandledExceptionHooked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnUiThreadUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||||
|
{
|
||||||
|
if (!IsKnownWebViewStartupException(e.Exception))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Handled = true;
|
||||||
|
AppLogger.Warn(
|
||||||
|
"WebView2",
|
||||||
|
"Suppressed a known WebView startup exception from AvaloniaWebView.Navigate to keep the host process alive.",
|
||||||
|
e.Exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKnownWebViewStartupException(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is not NullReferenceException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var stackTrace = exception.StackTrace ?? string.Empty;
|
||||||
|
return stackTrace.Contains("AvaloniaWebView.WebView.Navigate", StringComparison.Ordinal) &&
|
||||||
|
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PerformExitCleanup()
|
||||||
|
{
|
||||||
|
if (_exitCleanupCompleted)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_exitCleanupCompleted = true;
|
||||||
|
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||||
|
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (analytics, crashReport) = App.AnalyticsServices;
|
||||||
|
analytics?.SendShutdownEvent();
|
||||||
|
crashReport?.SendShutdownEvent();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UpdateWorkflow", "Failed to apply pending update during exit cleanup.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_pluginRuntimeService?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pluginRuntimeService = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_settingsWindowService?.Close();
|
||||||
|
if (_settingsPageRegistry is IDisposable disposableRegistry)
|
||||||
|
{
|
||||||
|
disposableRegistry.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
|
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||||
|
DisposeTrayIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainWindow CreateAndAssignMainWindow(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
string reason)
|
||||||
|
{
|
||||||
|
var mainWindow = new MainWindow
|
||||||
|
{
|
||||||
|
DataContext = new MainWindowViewModel(),
|
||||||
|
ShowInTaskbar = true
|
||||||
|
};
|
||||||
|
|
||||||
|
AttachMainWindow(mainWindow);
|
||||||
|
desktop.MainWindow = mainWindow;
|
||||||
|
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
|
||||||
|
LogBrowserStartupDiagnostics();
|
||||||
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
|
||||||
|
return mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainWindow GetOrCreateMainWindow(
|
||||||
|
IClassicDesktopStyleApplicationLifetime desktop,
|
||||||
|
string reason)
|
||||||
|
{
|
||||||
|
if (_mainWindow is not null && !_mainWindowClosed)
|
||||||
|
{
|
||||||
|
return _mainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desktop.MainWindow is MainWindow desktopMainWindow && !_mainWindowClosed)
|
||||||
|
{
|
||||||
|
AttachMainWindow(desktopMainWindow);
|
||||||
|
return desktopMainWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CreateAndAssignMainWindow(desktop, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AttachMainWindow(MainWindow mainWindow)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(_mainWindow, mainWindow))
|
||||||
|
{
|
||||||
|
_mainWindowClosed = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mainWindow is not null)
|
||||||
|
{
|
||||||
|
_mainWindow.Closing -= OnMainWindowClosing;
|
||||||
|
_mainWindow.Closed -= OnMainWindowClosed;
|
||||||
|
_mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mainWindow = mainWindow;
|
||||||
|
_mainWindowClosed = false;
|
||||||
|
mainWindow.Closing += OnMainWindowClosing;
|
||||||
|
mainWindow.Closed += OnMainWindowClosed;
|
||||||
|
mainWindow.PropertyChanged += OnMainWindowPropertyChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMainWindowClosing(object? sender, WindowClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not MainWindow mainWindow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Main window closing requested. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'; WindowState='{mainWindow.WindowState}'; IsVisible={mainWindow.IsVisible}.");
|
||||||
|
|
||||||
|
if (_shutdownIntent is ShutdownIntent.ExitRequested or ShutdownIntent.RestartRequested)
|
||||||
|
{
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Main window close allowed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Cancel = true;
|
||||||
|
HideMainWindowToTray(mainWindow, "MainWindowClosing");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMainWindowClosed(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not MainWindow mainWindow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.Closing -= OnMainWindowClosing;
|
||||||
|
mainWindow.Closed -= OnMainWindowClosed;
|
||||||
|
mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
|
||||||
|
|
||||||
|
if (ReferenceEquals(_mainWindow, mainWindow))
|
||||||
|
{
|
||||||
|
_mainWindow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mainWindowClosed = true;
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Main window closed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
|
||||||
|
|
||||||
|
if (_shutdownIntent == ShutdownIntent.None)
|
||||||
|
{
|
||||||
|
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnMainWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not MainWindow mainWindow)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.Property != Window.WindowStateProperty)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_shutdownIntent != ShutdownIntent.None || !mainWindow.IsVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainWindow.WindowState == WindowState.Minimized)
|
||||||
|
{
|
||||||
|
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "MainWindowMinimized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HideMainWindowToTray(MainWindow mainWindow, string source)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mainWindow.ShowInTaskbar = false;
|
||||||
|
mainWindow.Hide();
|
||||||
|
SetDesktopShellState(DesktopShellState.TrayOnly, source);
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetDesktopShellState(DesktopShellState state, string source)
|
||||||
|
{
|
||||||
|
if (_desktopShellState == state)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = _desktopShellState;
|
||||||
|
_desktopShellState = state;
|
||||||
|
AppLogger.Info(
|
||||||
|
"DesktopShell",
|
||||||
|
$"Shell state changed. Previous='{previous}'; Current='{state}'; Source='{source}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogBrowserStartupDiagnostics()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = new DesktopLayoutSettingsService().Load();
|
||||||
|
var browserPlacements = snapshot.DesktopComponentPlacements
|
||||||
|
.Where(placement => string.Equals(
|
||||||
|
placement.ComponentId,
|
||||||
|
BuiltInComponentIds.DesktopBrowser,
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToList();
|
||||||
|
var runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"StartupDiagnostics",
|
||||||
|
$"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " +
|
||||||
|
$"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " +
|
||||||
|
$"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " +
|
||||||
|
$"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " +
|
||||||
|
$"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " +
|
||||||
|
$"WebViewRuntimeMessage={runtimeAvailability.Message}");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string L(string key, string fallback)
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||||
|
return _localizationService.GetString(languageCode, key, fallback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,35 @@
|
|||||||
# MiSans Font Notice
|
# MiSans 字体说明
|
||||||
|
|
||||||
This app bundles MiSans fonts for consistent cross-device rendering.
|
## 中文
|
||||||
|
|
||||||
## Included files
|
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
||||||
|
|
||||||
|
### 包含文件
|
||||||
|
|
||||||
- `MiSans-Regular.ttf`
|
- `MiSans-Regular.ttf`
|
||||||
- `MiSans-Semibold.ttf`
|
- `MiSans-Semibold.ttf`
|
||||||
- `MiSans-Bold.ttf`
|
- `MiSans-Bold.ttf`
|
||||||
|
|
||||||
## Source
|
### 来源
|
||||||
|
|
||||||
|
- 上游仓库:https://github.com/dsrkafuu/misans
|
||||||
|
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
||||||
|
|
||||||
|
### 许可与使用说明
|
||||||
|
|
||||||
|
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
||||||
|
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
||||||
|
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||||
|
|
||||||
|
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
This project bundles MiSans fonts for more consistent cross-device rendering.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
|
||||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
- Upstream package repository: https://github.com/dsrkafuu/misans
|
||||||
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
|
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
||||||
|
|
||||||
## License and usage notes
|
Please review and comply with the MiSans font terms before redistributing this application.
|
||||||
|
|
||||||
- Script/package license in upstream repository: Apache-2.0
|
|
||||||
- MiSans font copyright and additional usage terms:
|
|
||||||
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
|
||||||
|
|
||||||
Please review and comply with the MiSans font terms when distributing this app.
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# Weather Background Assets
|
# 天气背景资源署名
|
||||||
|
|
||||||
Weather card background images are sourced from **Pexels** and used under the Pexels license:
|
## 中文
|
||||||
https://www.pexels.com/license/
|
|
||||||
|
|
||||||
## Sources
|
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||||
|
|
||||||
|
- License: https://www.pexels.com/license/
|
||||||
|
|
||||||
|
### 原始来源
|
||||||
|
|
||||||
- `clear_sky.jpg`
|
- `clear_sky.jpg`
|
||||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||||
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
|
|||||||
- `storm.jpg`
|
- `storm.jpg`
|
||||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||||
|
|
||||||
## Derived Variants (for widget scene mapping)
|
### 派生资源
|
||||||
|
|
||||||
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
|
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||||
|
|
||||||
- `clear_day.jpg` (from `clear_sky.jpg`)
|
- `clear_day.jpg`
|
||||||
- `clear_night.jpg` (from `clear_sky.jpg`)
|
- `clear_night.jpg`
|
||||||
- `cloudy_day.jpg` (from `clear_sky.jpg`)
|
- `cloudy_day.jpg`
|
||||||
- `cloudy_night.jpg` (from `clear_sky.jpg`)
|
- `cloudy_night.jpg`
|
||||||
- `rain_light.jpg` (from `rain.jpg`)
|
- `rain_light.jpg`
|
||||||
- `rain_heavy.jpg` (from `rain.jpg`)
|
- `rain_heavy.jpg`
|
||||||
- `storm_dark.jpg` (from `storm.jpg`)
|
- `storm_dark.jpg`
|
||||||
- `fog_haze.jpg` (from `storm.jpg`)
|
- `fog_haze.jpg`
|
||||||
- `snow_soft.jpg` (from `snow.jpg`)
|
- `snow_soft.jpg`
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||||
|
|
||||||
|
- License: https://www.pexels.com/license/
|
||||||
|
|
||||||
|
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||||
|
|||||||
@@ -1,45 +1,23 @@
|
|||||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
# HyperOS3 天气资源署名
|
||||||
|
|
||||||
|
## 中文
|
||||||
|
|
||||||
|
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||||
|
|
||||||
|
### 提取来源
|
||||||
|
|
||||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
|
||||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||||
- Package: `com.miui.weather2` (Mi Weather)
|
- Package: `com.miui.weather2`
|
||||||
- Extraction date: 2026-03-03
|
- Extraction date: `2026-03-03`
|
||||||
|
|
||||||
Extracted source paths inside APK:
|
### 用途说明
|
||||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
|
||||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
|
||||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
|
||||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
|
||||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
|
||||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
|
||||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
|
||||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
|
||||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
|
||||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
|
||||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
|
||||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
|
||||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
|
||||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
|
||||||
|
|
||||||
Extracted weather icon paths inside APK (`res/*.webp`):
|
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||||
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
|
- 使用时应遵守小米相关许可与使用条款。
|
||||||
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
|
|
||||||
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
|
|
||||||
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
|
|
||||||
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
|
|
||||||
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
|
|
||||||
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
|
|
||||||
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
|
|
||||||
- `res/mH1.webp` -> `Icons/icon_snow.webp`
|
|
||||||
- `res/jB.webp` -> `Icons/icon_sleet.webp`
|
|
||||||
- `res/Wl.webp` -> `Icons/icon_haze.webp`
|
|
||||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
|
||||||
|
|
||||||
Use only according to Xiaomi's applicable license and usage terms.
|
### 额外派生资源
|
||||||
|
|
||||||
## Soft Widget Icon Set (2026-03-05)
|
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||||
|
|
||||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
|
||||||
|
|
||||||
- `Icons/icon_hero_sun_soft.png`
|
- `Icons/icon_hero_sun_soft.png`
|
||||||
- `Icons/icon_hero_moon_soft.png`
|
- `Icons/icon_hero_moon_soft.png`
|
||||||
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
|
|||||||
- `Icons/icon_mini_snow_soft.png`
|
- `Icons/icon_mini_snow_soft.png`
|
||||||
- `Icons/icon_mini_fog_soft.png`
|
- `Icons/icon_mini_fog_soft.png`
|
||||||
|
|
||||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
## English
|
||||||
|
|
||||||
|
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||||
|
|
||||||
|
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||||
|
|||||||
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 999 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal file
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="375" height="374.999991" viewBox="0 0 375 374.999991">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip-0">
|
||||||
|
<path clip-rule="nonzero" d="M 196.875 178.398438 L 285.058594 178.398438 L 285.058594 266.582031 L 196.875 266.582031 Z M 196.875 178.398438 "/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-1">
|
||||||
|
<path clip-rule="nonzero" d="M 240.96875 178.398438 C 216.617188 178.398438 196.875 198.140625 196.875 222.492188 C 196.875 246.839844 216.617188 266.582031 240.96875 266.582031 C 265.320312 266.582031 285.058594 246.839844 285.058594 222.492188 C 285.058594 198.140625 265.320312 178.398438 240.96875 178.398438 Z M 240.96875 178.398438 "/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-2">
|
||||||
|
<path clip-rule="nonzero" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-3">
|
||||||
|
<path clip-rule="nonzero" d="M 44.96875 0.398438 C 20.617188 0.398438 0.875 20.140625 0.875 44.492188 C 0.875 68.839844 20.617188 88.582031 44.96875 88.582031 C 69.320312 88.582031 89.058594 68.839844 89.058594 44.492188 C 89.058594 20.140625 69.320312 0.398438 44.96875 0.398438 Z M 44.96875 0.398438 "/>
|
||||||
|
</clipPath>
|
||||||
|
<clipPath id="clip-4">
|
||||||
|
<rect x="0" y="0" width="90" height="89"/>
|
||||||
|
</clipPath>
|
||||||
|
<g id="source-5" clip-path="url(#clip-4)">
|
||||||
|
<g clip-path="url(#clip-2)">
|
||||||
|
<g clip-path="url(#clip-3)">
|
||||||
|
<path fill-rule="nonzero" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</defs>
|
||||||
|
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(100%, 100%, 100%)" fill-opacity="1"/>
|
||||||
|
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(0%, 0%, 0%)" fill-opacity="1"/>
|
||||||
|
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00219613 10.500482 L 127.627201 10.500482 " transform="matrix(0.75, 0, 0, 0.75, 93.623353, 248.98792)"/>
|
||||||
|
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00244625 10.500708 L 127.622243 10.500708 " transform="matrix(0.75, 0, 0, 0.75, 189.341915, 110.327595)"/>
|
||||||
|
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.492181 33.974247 C 11.537452 2.677252 36.02023 2.673354 83.94005 33.972962 " transform="matrix(-0.0333864, -0.749256, 0.749256, -0.0333864, 70.972996, 265.851083)"/>
|
||||||
|
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.498903 37.532093 C 10.670763 1.489257 37.447477 1.488034 90.82363 37.533419 " transform="matrix(0.0300175, 0.749399, -0.749399, 0.0300175, 310.455894, 109.212543)"/>
|
||||||
|
<g clip-path="url(#clip-0)">
|
||||||
|
<g clip-path="url(#clip-1)">
|
||||||
|
<use xlink:href="#source-5" transform="matrix(1, 0, 0, 1, 196, 178)"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal file
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.stage {
|
||||||
|
width: 512px;
|
||||||
|
height: 512px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.stage img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="stage">
|
||||||
|
<img src="./logo_nightly.svg" alt="logo" />
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace LanMountainDesktop.ComponentSystem;
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
public static class BuiltInComponentIds
|
public static class BuiltInComponentIds
|
||||||
{
|
{
|
||||||
@@ -40,4 +40,5 @@ public static class BuiltInComponentIds
|
|||||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||||
public const string DesktopBrowser = "DesktopBrowser";
|
public const string DesktopBrowser = "DesktopBrowser";
|
||||||
|
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Views;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
public static class ComponentColorSchemeHelper
|
||||||
|
{
|
||||||
|
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
|
||||||
|
{
|
||||||
|
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetCurrentGlobalThemeColorMode()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var service = HostAppearanceThemeProvider.GetOrCreate();
|
||||||
|
var appearance = service.GetCurrent();
|
||||||
|
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||||
@@ -327,6 +327,15 @@ public sealed class ComponentRegistry
|
|||||||
AllowStatusBarPlacement: false,
|
AllowStatusBarPlacement: false,
|
||||||
AllowDesktopPlacement: true,
|
AllowDesktopPlacement: true,
|
||||||
ResizeMode: DesktopComponentResizeMode.Free),
|
ResizeMode: DesktopComponentResizeMode.Free),
|
||||||
|
new DesktopComponentDefinition(
|
||||||
|
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||||
|
"Office Recent Documents",
|
||||||
|
"Folder",
|
||||||
|
"File",
|
||||||
|
MinWidthCells: 4,
|
||||||
|
MinHeightCells: 2,
|
||||||
|
AllowStatusBarPlacement: false,
|
||||||
|
AllowDesktopPlacement: true),
|
||||||
new DesktopComponentDefinition(
|
new DesktopComponentDefinition(
|
||||||
BuiltInComponentIds.Date,
|
BuiltInComponentIds.Date,
|
||||||
"Calendar",
|
"Calendar",
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
public sealed record DesktopComponentEditorContext(
|
||||||
|
DesktopComponentDefinition Definition,
|
||||||
|
string ComponentId,
|
||||||
|
string? PlacementId,
|
||||||
|
ISettingsFacadeService SettingsFacade,
|
||||||
|
ISettingsService SettingsService,
|
||||||
|
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||||
|
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||||
|
IComponentEditorHostContext HostContext);
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorRegistration
|
||||||
|
{
|
||||||
|
public DesktopComponentEditorRegistration(
|
||||||
|
string componentId,
|
||||||
|
Func<DesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth = 720d,
|
||||||
|
double preferredHeight = 540d,
|
||||||
|
double minScale = 0.85d,
|
||||||
|
double maxScale = 1.45d)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
ArgumentNullException.ThrowIfNull(editorFactory);
|
||||||
|
|
||||||
|
if (preferredWidth <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredHeight <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minScale <= 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(minScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maxScale < minScale)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(maxScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentId = componentId.Trim();
|
||||||
|
EditorFactory = editorFactory;
|
||||||
|
PreferredWidth = preferredWidth;
|
||||||
|
PreferredHeight = preferredHeight;
|
||||||
|
MinScale = minScale;
|
||||||
|
MaxScale = maxScale;
|
||||||
|
AspectRatio = preferredWidth / preferredHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ComponentId { get; }
|
||||||
|
|
||||||
|
public Func<DesktopComponentEditorContext, Control> EditorFactory { get; }
|
||||||
|
|
||||||
|
public double PreferredWidth { get; }
|
||||||
|
|
||||||
|
public double PreferredHeight { get; }
|
||||||
|
|
||||||
|
public double MinScale { get; }
|
||||||
|
|
||||||
|
public double MaxScale { get; }
|
||||||
|
|
||||||
|
public double AspectRatio { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorDescriptor
|
||||||
|
{
|
||||||
|
internal DesktopComponentEditorDescriptor(
|
||||||
|
DesktopComponentDefinition definition,
|
||||||
|
Func<DesktopComponentEditorContext, Control> editorFactory,
|
||||||
|
double preferredWidth,
|
||||||
|
double preferredHeight,
|
||||||
|
double minScale,
|
||||||
|
double maxScale,
|
||||||
|
double aspectRatio)
|
||||||
|
{
|
||||||
|
Definition = definition;
|
||||||
|
_editorFactory = editorFactory;
|
||||||
|
PreferredWidth = preferredWidth;
|
||||||
|
PreferredHeight = preferredHeight;
|
||||||
|
MinScale = minScale;
|
||||||
|
MaxScale = maxScale;
|
||||||
|
AspectRatio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly Func<DesktopComponentEditorContext, Control> _editorFactory;
|
||||||
|
|
||||||
|
public DesktopComponentDefinition Definition { get; }
|
||||||
|
|
||||||
|
public double PreferredWidth { get; }
|
||||||
|
|
||||||
|
public double PreferredHeight { get; }
|
||||||
|
|
||||||
|
public double MinScale { get; }
|
||||||
|
|
||||||
|
public double MaxScale { get; }
|
||||||
|
|
||||||
|
public double AspectRatio { get; }
|
||||||
|
|
||||||
|
public Control CreateEditor(DesktopComponentEditorContext context)
|
||||||
|
{
|
||||||
|
return _editorFactory(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DesktopComponentEditorRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, DesktopComponentEditorDescriptor> _descriptors;
|
||||||
|
|
||||||
|
public DesktopComponentEditorRegistry(
|
||||||
|
ComponentRegistry componentRegistry,
|
||||||
|
IEnumerable<DesktopComponentEditorRegistration> registrations)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(componentRegistry);
|
||||||
|
ArgumentNullException.ThrowIfNull(registrations);
|
||||||
|
|
||||||
|
_descriptors = new Dictionary<string, DesktopComponentEditorDescriptor>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var registration in registrations)
|
||||||
|
{
|
||||||
|
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_descriptors[registration.ComponentId] = new DesktopComponentEditorDescriptor(
|
||||||
|
definition,
|
||||||
|
registration.EditorFactory,
|
||||||
|
registration.PreferredWidth,
|
||||||
|
registration.PreferredHeight,
|
||||||
|
registration.MinScale,
|
||||||
|
registration.MaxScale,
|
||||||
|
registration.AspectRatio);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryGetDescriptor(string componentId, out DesktopComponentEditorDescriptor descriptor)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||||
|
return _descriptors.TryGetValue(componentId.Trim(), out descriptor!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<DesktopComponentEditorDescriptor> GetAll()
|
||||||
|
{
|
||||||
|
return _descriptors.Values.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
namespace LanMountainDesktop.ComponentSystem;
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
public sealed record DesktopComponentRuntimeContext(
|
public sealed record DesktopComponentRuntimeContext(
|
||||||
string ComponentId,
|
string ComponentId,
|
||||||
string? PlacementId,
|
string? PlacementId,
|
||||||
|
ISettingsFacadeService SettingsFacade,
|
||||||
|
ISettingsService SettingsService,
|
||||||
|
IAppearanceThemeService AppearanceTheme,
|
||||||
|
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.ComponentSystem;
|
||||||
|
|
||||||
|
public sealed record DesktopComponentSettingsContext(
|
||||||
|
string ComponentId,
|
||||||
|
string? PlacementId,
|
||||||
|
ISettingsFacadeService SettingsFacade,
|
||||||
|
ISettingsService SettingsService,
|
||||||
|
IAppearanceThemeService AppearanceTheme,
|
||||||
|
IComponentSettingsAccessor ComponentSettingsAccessor,
|
||||||
|
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||||
|
|
||||||
|
public interface IComponentSettingsContextAware
|
||||||
|
{
|
||||||
|
void SetComponentSettingsContext(DesktopComponentSettingsContext context);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using LanMountainDesktop.Services;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.ComponentSystem;
|
|
||||||
|
|
||||||
public interface IComponentSettingsStoreAware
|
|
||||||
{
|
|
||||||
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
|
|
||||||
}
|
|
||||||
@@ -1,77 +1,38 @@
|
|||||||
# 组件系统模块(Component System Module)
|
# 组件系统说明
|
||||||
|
|
||||||
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
|
## 中文
|
||||||
This directory provides the modular foundation for built-in component management and third-party extension integration.
|
|
||||||
|
|
||||||
## 核心文件职责(Core Files)
|
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
|
||||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
|
|
||||||
Built-in component ID constants (for example `Clock`).
|
|
||||||
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
|
|
||||||
Component metadata model (name, category, minimum size, placement permissions).
|
|
||||||
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
|
|
||||||
Placement rules (minimum size, status-bar height rule, grid clamping).
|
|
||||||
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
|
|
||||||
Registry that merges built-in and extension components.
|
|
||||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
|
|
||||||
Extension provider interface contract.
|
|
||||||
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
|
|
||||||
JSON-based extension loader.
|
|
||||||
|
|
||||||
## 第三方扩展契约(Extension Contract)
|
### 主要职责
|
||||||
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
|
|
||||||
Third parties can provide component definitions via `IComponentExtensionProvider`.
|
|
||||||
- 当前内置了 JSON 提供者,运行时扫描目录:
|
|
||||||
Built-in JSON provider scans at runtime:
|
|
||||||
- `Extensions/Components/*.json`(相对应用输出目录)
|
|
||||||
`Extensions/Components/*.json` (relative to app output directory)
|
|
||||||
|
|
||||||
## 加载流程(Load Flow)
|
- 管理内置组件 ID 和元数据
|
||||||
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
|
- 约束组件最小尺寸与可放置区域
|
||||||
Register built-in components first via `ComponentRegistry.CreateDefault()`.
|
- 合并内置组件与扩展组件
|
||||||
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
|
- 通过 JSON 或扩展提供者接入第三方组件
|
||||||
Merge extension components via `.RegisterExtensions(...)`.
|
|
||||||
3. 主窗口通过注册中心校验组件合法性与放置权限。
|
|
||||||
Main window validates component identity and placement permission through the registry.
|
|
||||||
|
|
||||||
## JSON 清单格式(Manifest Schema)
|
### 关键文件
|
||||||
JSON 文件为数组,每一项代表一个组件定义。
|
|
||||||
The JSON file is an array, where each item represents one component definition.
|
|
||||||
|
|
||||||
```json
|
- `BuiltInComponentIds.cs`:内置组件 ID 常量
|
||||||
[
|
- `DesktopComponentDefinition.cs`:组件元数据模型
|
||||||
{
|
- `ComponentPlacementRules.cs`:放置规则
|
||||||
"id": "Weather",
|
- `ComponentRegistry.cs`:组件注册中心
|
||||||
"displayName": "Weather",
|
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
|
||||||
"iconKey": "WeatherSunny",
|
- `Extensions/JsonComponentExtensionProvider.cs`:JSON 扩展加载器
|
||||||
"category": "Status",
|
|
||||||
"minWidthCells": 1,
|
|
||||||
"minHeightCells": 1,
|
|
||||||
"allowStatusBarPlacement": true,
|
|
||||||
"allowDesktopPlacement": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
字段说明(Field notes):
|
### 扩展方式
|
||||||
- `id`:组件唯一 ID(建议英文、稳定不变)。
|
|
||||||
Unique component ID (prefer stable English key).
|
|
||||||
- `displayName`:显示名。
|
|
||||||
Display name.
|
|
||||||
- `iconKey`:图标键(由上层 UI 解释)。
|
|
||||||
Icon key resolved by UI layer.
|
|
||||||
- `category`:组件分类。
|
|
||||||
Component category.
|
|
||||||
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`。
|
|
||||||
Minimum cell size, must satisfy `>= 1`.
|
|
||||||
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
|
|
||||||
Whether placing in top status bar is allowed.
|
|
||||||
- `allowDesktopPlacement`:是否允许放到桌面区域。
|
|
||||||
Whether placing in desktop area is allowed.
|
|
||||||
|
|
||||||
## 放置规则摘要(Placement Rules Summary)
|
- 当前默认扫描 `Extensions/Components/*.json`
|
||||||
- 最小尺寸约束:`minWidthCells >= 1` 且 `minHeightCells >= 1`。
|
- 组件清单定义显示名、分类、最小尺寸和可放置区域
|
||||||
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
|
- 主程序通过注册中心统一验证组件是否合法
|
||||||
- 状态栏约束:状态栏组件高度必须为 `1` 格。
|
|
||||||
Status bar constraint: component height must be exactly `1` cell.
|
## English
|
||||||
- 越界约束:所有组件坐标会被网格边界钳制(clamp)。
|
|
||||||
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
|
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
|
||||||
|
|
||||||
|
### Responsibilities
|
||||||
|
|
||||||
|
- manage built-in component IDs and metadata
|
||||||
|
- enforce placement rules
|
||||||
|
- merge built-in and extension components
|
||||||
|
- support third-party registration through JSON or provider contracts
|
||||||
|
|||||||
19
LanMountainDesktop/Controls/IconText.axaml
Normal file
19
LanMountainDesktop/Controls/IconText.axaml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
x:Class="LanMountainDesktop.Controls.IconText">
|
||||||
|
|
||||||
|
<StackPanel Orientation="Horizontal"
|
||||||
|
Spacing="8"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<fi:FluentIcon x:Name="IconElement"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
FontSize="14"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
<TextBlock x:Name="TextElement"
|
||||||
|
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||||
|
FontSize="14"
|
||||||
|
FontWeight="SemiBold"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</UserControl>
|
||||||
52
LanMountainDesktop/Controls/IconText.axaml.cs
Normal file
52
LanMountainDesktop/Controls/IconText.axaml.cs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using FluentIcons.Avalonia;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Controls;
|
||||||
|
|
||||||
|
public partial class IconText : UserControl
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<Icon> IconProperty =
|
||||||
|
AvaloniaProperty.Register<IconText, Icon>(nameof(Icon), Icon.Info);
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string> TextProperty =
|
||||||
|
AvaloniaProperty.Register<IconText, string>(nameof(Text), string.Empty);
|
||||||
|
|
||||||
|
public IconText()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Icon Icon
|
||||||
|
{
|
||||||
|
get => GetValue(IconProperty);
|
||||||
|
set => SetValue(IconProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Text
|
||||||
|
{
|
||||||
|
get => GetValue(TextProperty);
|
||||||
|
set => SetValue(TextProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
|
||||||
|
if (change.Property == IconProperty)
|
||||||
|
{
|
||||||
|
if (IconElement is not null)
|
||||||
|
{
|
||||||
|
IconElement.Icon = change.GetNewValue<Icon>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (change.Property == TextProperty)
|
||||||
|
{
|
||||||
|
if (TextElement is not null)
|
||||||
|
{
|
||||||
|
TextElement.Text = change.GetNewValue<string?>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
LanMountainDesktop/Controls/SettingsOptionCard.axaml
Normal file
37
LanMountainDesktop/Controls/SettingsOptionCard.axaml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:LanMountainDesktop.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
x:Class="LanMountainDesktop.Controls.SettingsOptionCard"
|
||||||
|
x:Name="Root">
|
||||||
|
<Border Classes="settings-option-card">
|
||||||
|
<Grid RowDefinitions="Auto,Auto"
|
||||||
|
RowSpacing="12">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||||
|
ColumnSpacing="14">
|
||||||
|
<Border x:Name="IconHost"
|
||||||
|
Classes="settings-option-card-icon-host">
|
||||||
|
<fi:SymbolIcon x:Name="CardIcon"
|
||||||
|
Classes="icon-m" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Classes="settings-item-label" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="settings-item-description" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="ActionContentHost"
|
||||||
|
Grid.Column="2"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="DetailsContentHost"
|
||||||
|
Grid.Row="1"
|
||||||
|
Margin="54,0,0,0" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
113
LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
Normal file
113
LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Controls;
|
||||||
|
|
||||||
|
public partial class SettingsOptionCard : UserControl
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<string?> IconKeyProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(IconKey), "Settings");
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> TitleProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(Title));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> DescriptionProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(Description));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> ActionContentProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsOptionCard, object?>(nameof(ActionContent));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> DetailsContentProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsOptionCard, object?>(nameof(DetailsContent));
|
||||||
|
|
||||||
|
public SettingsOptionCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
RefreshVisualState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? IconKey
|
||||||
|
{
|
||||||
|
get => GetValue(IconKeyProperty);
|
||||||
|
set => SetValue(IconKeyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Title
|
||||||
|
{
|
||||||
|
get => GetValue(TitleProperty);
|
||||||
|
set => SetValue(TitleProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Description
|
||||||
|
{
|
||||||
|
get => GetValue(DescriptionProperty);
|
||||||
|
set => SetValue(DescriptionProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? ActionContent
|
||||||
|
{
|
||||||
|
get => GetValue(ActionContentProperty);
|
||||||
|
set => SetValue(ActionContentProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? DetailsContent
|
||||||
|
{
|
||||||
|
get => GetValue(DetailsContentProperty);
|
||||||
|
set => SetValue(DetailsContentProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
|
||||||
|
if (change.Property == IconKeyProperty ||
|
||||||
|
change.Property == TitleProperty ||
|
||||||
|
change.Property == DescriptionProperty ||
|
||||||
|
change.Property == ActionContentProperty ||
|
||||||
|
change.Property == DetailsContentProperty)
|
||||||
|
{
|
||||||
|
RefreshVisualState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshVisualState()
|
||||||
|
{
|
||||||
|
if (CardIcon is null ||
|
||||||
|
IconHost is null ||
|
||||||
|
TitleTextBlock is null ||
|
||||||
|
DescriptionTextBlock is null ||
|
||||||
|
ActionContentHost is null ||
|
||||||
|
DetailsContentHost is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CardIcon.Symbol = MapIcon(IconKey);
|
||||||
|
IconHost.IsVisible = !string.IsNullOrWhiteSpace(IconKey);
|
||||||
|
|
||||||
|
TitleTextBlock.Text = Title ?? string.Empty;
|
||||||
|
DescriptionTextBlock.Text = Description ?? string.Empty;
|
||||||
|
DescriptionTextBlock.IsVisible = !string.IsNullOrWhiteSpace(Description);
|
||||||
|
|
||||||
|
ActionContentHost.Content = ActionContent;
|
||||||
|
ActionContentHost.IsVisible = ActionContent is not null;
|
||||||
|
|
||||||
|
DetailsContentHost.Content = DetailsContent;
|
||||||
|
DetailsContentHost.IsVisible = DetailsContent is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Symbol MapIcon(string? iconKey)
|
||||||
|
{
|
||||||
|
return iconKey?.Trim() switch
|
||||||
|
{
|
||||||
|
"DesignIdeas" => Symbol.Color,
|
||||||
|
"Image" => Symbol.Image,
|
||||||
|
"GridDots" => Symbol.GridDots,
|
||||||
|
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||||
|
"Info" => Symbol.Info,
|
||||||
|
"ArrowSync" => Symbol.ArrowSync,
|
||||||
|
_ => Symbol.Settings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
33
LanMountainDesktop/Controls/SettingsSectionCard.axaml
Normal file
33
LanMountainDesktop/Controls/SettingsSectionCard.axaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:local="using:LanMountainDesktop.Controls"
|
||||||
|
xmlns:fi="using:FluentIcons.Avalonia"
|
||||||
|
x:Class="LanMountainDesktop.Controls.SettingsSectionCard"
|
||||||
|
x:Name="Root">
|
||||||
|
<Border Classes="settings-section-card">
|
||||||
|
<Grid RowDefinitions="Auto,Auto"
|
||||||
|
RowSpacing="16">
|
||||||
|
<Grid ColumnDefinitions="Auto,*"
|
||||||
|
ColumnSpacing="14">
|
||||||
|
<Border x:Name="IconHost"
|
||||||
|
Classes="settings-section-card-icon-host">
|
||||||
|
<fi:SymbolIcon x:Name="CardIcon"
|
||||||
|
Classes="icon-l" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StackPanel Grid.Column="1"
|
||||||
|
Spacing="4">
|
||||||
|
<TextBlock x:Name="TitleTextBlock"
|
||||||
|
Classes="settings-card-header"
|
||||||
|
Margin="0" />
|
||||||
|
<TextBlock x:Name="DescriptionTextBlock"
|
||||||
|
Classes="settings-card-description"
|
||||||
|
Margin="0" />
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<ContentPresenter x:Name="CardContentHost"
|
||||||
|
Grid.Row="1" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</UserControl>
|
||||||
98
LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs
Normal file
98
LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using FluentIcons.Common;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Controls;
|
||||||
|
|
||||||
|
public partial class SettingsSectionCard : UserControl
|
||||||
|
{
|
||||||
|
public static readonly StyledProperty<string?> IconKeyProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(IconKey), "Settings");
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> TitleProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(Title));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<string?> DescriptionProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(Description));
|
||||||
|
|
||||||
|
public static readonly StyledProperty<object?> CardContentProperty =
|
||||||
|
AvaloniaProperty.Register<SettingsSectionCard, object?>(nameof(CardContent));
|
||||||
|
|
||||||
|
public SettingsSectionCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
RefreshVisualState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? IconKey
|
||||||
|
{
|
||||||
|
get => GetValue(IconKeyProperty);
|
||||||
|
set => SetValue(IconKeyProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Title
|
||||||
|
{
|
||||||
|
get => GetValue(TitleProperty);
|
||||||
|
set => SetValue(TitleProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Description
|
||||||
|
{
|
||||||
|
get => GetValue(DescriptionProperty);
|
||||||
|
set => SetValue(DescriptionProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public object? CardContent
|
||||||
|
{
|
||||||
|
get => GetValue(CardContentProperty);
|
||||||
|
set => SetValue(CardContentProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
|
||||||
|
if (change.Property == IconKeyProperty ||
|
||||||
|
change.Property == TitleProperty ||
|
||||||
|
change.Property == DescriptionProperty ||
|
||||||
|
change.Property == CardContentProperty)
|
||||||
|
{
|
||||||
|
RefreshVisualState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshVisualState()
|
||||||
|
{
|
||||||
|
if (CardIcon is null ||
|
||||||
|
IconHost is null ||
|
||||||
|
TitleTextBlock is null ||
|
||||||
|
DescriptionTextBlock is null ||
|
||||||
|
CardContentHost is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CardIcon.Symbol = MapIcon(IconKey);
|
||||||
|
IconHost.IsVisible = !string.IsNullOrWhiteSpace(IconKey);
|
||||||
|
|
||||||
|
TitleTextBlock.Text = Title ?? string.Empty;
|
||||||
|
DescriptionTextBlock.Text = Description ?? string.Empty;
|
||||||
|
DescriptionTextBlock.IsVisible = !string.IsNullOrWhiteSpace(Description);
|
||||||
|
|
||||||
|
CardContentHost.Content = CardContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Symbol MapIcon(string? iconKey)
|
||||||
|
{
|
||||||
|
return iconKey?.Trim() switch
|
||||||
|
{
|
||||||
|
"DesignIdeas" => Symbol.Color,
|
||||||
|
"Image" => Symbol.Image,
|
||||||
|
"GridDots" => Symbol.GridDots,
|
||||||
|
"PuzzlePiece" => Symbol.PuzzlePiece,
|
||||||
|
"Info" => Symbol.Info,
|
||||||
|
"ArrowSync" => Symbol.ArrowSync,
|
||||||
|
_ => Symbol.Settings
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal file
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using System;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using Markdown.Avalonia;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Helpers;
|
||||||
|
|
||||||
|
public static class PluginMarketMarkdownHelper
|
||||||
|
{
|
||||||
|
private static Markdown.Avalonia.Markdown? _engine;
|
||||||
|
|
||||||
|
public static ICommand OpenLinkCommand { get; } = new RelayCommand<object?>(OpenLink);
|
||||||
|
|
||||||
|
public static Markdown.Avalonia.Markdown Engine => _engine ??= new Markdown.Avalonia.Markdown
|
||||||
|
{
|
||||||
|
HyperlinkCommand = OpenLinkCommand
|
||||||
|
};
|
||||||
|
|
||||||
|
private static void OpenLink(object? parameter)
|
||||||
|
{
|
||||||
|
var url = parameter switch
|
||||||
|
{
|
||||||
|
Uri uri => uri.ToString(),
|
||||||
|
string text => text,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(url))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = url,
|
||||||
|
UseShellExecute = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore browser launch failures inside the markdown viewer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.0.0</Version>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
|
||||||
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -40,13 +42,21 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||||
|
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||||
|
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
|
||||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||||
|
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||||
|
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||||
|
<PackageReference Include="PostHog" Version="2.4.0" />
|
||||||
|
<PackageReference Include="Sentry" Version="4.0.0" />
|
||||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
||||||
@@ -55,4 +65,18 @@
|
|||||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||||
|
<ItemGroup>
|
||||||
|
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||||
|
</ItemGroup>
|
||||||
|
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LanMountainDesktop",
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.show_desktop": "Open Desktop",
|
||||||
|
"tray.menu.settings": "Settings",
|
||||||
|
"tray.menu.component_library": "Component Library",
|
||||||
|
"tray.menu.restart": "Restart App",
|
||||||
|
"tray.menu.exit": "Exit App",
|
||||||
"button.back_to_windows": "Back to Windows",
|
"button.back_to_windows": "Back to Windows",
|
||||||
"tooltip.back_to_windows": "Back to Windows",
|
"tooltip.back_to_windows": "Back to Windows",
|
||||||
"tooltip.open_settings": "Settings",
|
"tooltip.open_settings": "Settings",
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
|
"settings.shell.title": "Settings",
|
||||||
|
"settings.shell.subtitle": "LanMountainDesktop independent settings module",
|
||||||
|
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
|
||||||
|
"settings.shell.footer_hint": "Tray-opened settings are managed in this independent settings module.",
|
||||||
"settings.back_to_desktop": "Back to Desktop",
|
"settings.back_to_desktop": "Back to Desktop",
|
||||||
"settings.nav_header": "Settings",
|
"settings.nav_header": "Settings",
|
||||||
|
"settings.nav.group_desktop": "Desktop",
|
||||||
|
"settings.nav.group_system": "System",
|
||||||
|
"settings.nav.group_extensions": "Extensions",
|
||||||
"settings.nav.wallpaper": "Wallpaper",
|
"settings.nav.wallpaper": "Wallpaper",
|
||||||
"settings.nav.grid": "Grid",
|
"settings.nav.grid": "Grid",
|
||||||
"settings.nav.color": "Color",
|
"settings.nav.color": "Color",
|
||||||
@@ -13,6 +26,7 @@
|
|||||||
"settings.nav.weather": "Weather",
|
"settings.nav.weather": "Weather",
|
||||||
"settings.nav.region": "Region",
|
"settings.nav.region": "Region",
|
||||||
"settings.nav.update": "Update",
|
"settings.nav.update": "Update",
|
||||||
|
"settings.nav.privacy": "Privacy",
|
||||||
"settings.nav.launcher": "App Launcher",
|
"settings.nav.launcher": "App Launcher",
|
||||||
"settings.nav.plugins": "Plugins",
|
"settings.nav.plugins": "Plugins",
|
||||||
"settings.nav.about": "About",
|
"settings.nav.about": "About",
|
||||||
@@ -79,7 +93,14 @@
|
|||||||
"settings.status_bar.spacing_mode_custom": "Custom",
|
"settings.status_bar.spacing_mode_custom": "Custom",
|
||||||
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
|
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
|
||||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.privacy.title": "Privacy",
|
||||||
|
"settings.privacy.description": "Manage optional anonymous uploads that help improve the app over time.",
|
||||||
|
"settings.privacy.crash_upload_title": "Anonymous crash data uploads",
|
||||||
|
"settings.privacy.crash_upload_description": "Help us improve application stability.",
|
||||||
|
"settings.privacy.usage_upload_title": "Anonymous usage data uploads",
|
||||||
|
"settings.privacy.usage_upload_description": "Help us improve application features.",
|
||||||
"settings.weather.title": "Weather",
|
"settings.weather.title": "Weather",
|
||||||
|
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
|
||||||
"settings.weather.location_source_header": "Location Source",
|
"settings.weather.location_source_header": "Location Source",
|
||||||
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
|
||||||
"settings.weather.mode_city_search": "City Search",
|
"settings.weather.mode_city_search": "City Search",
|
||||||
@@ -106,12 +127,23 @@
|
|||||||
"settings.weather.apply_coordinates_button": "Apply Coordinates",
|
"settings.weather.apply_coordinates_button": "Apply Coordinates",
|
||||||
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
|
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
|
||||||
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
|
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "Location Service",
|
||||||
|
"settings.weather.location_services_desc": "Use the current Windows location and decide whether it refreshes automatically on startup.",
|
||||||
|
"settings.weather.use_current_location": "Use Current Location",
|
||||||
|
"settings.weather.location_unsupported": "Current platform does not support retrieving the current location.",
|
||||||
|
"settings.weather.location_ready": "You can use the current Windows location.",
|
||||||
|
"settings.weather.location_refreshing": "Requesting current location...",
|
||||||
|
"settings.weather.location_refresh_success_format": "Current location applied: {0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "Failed to get current location: {0}",
|
||||||
"settings.weather.preview_header": "Connection Test",
|
"settings.weather.preview_header": "Connection Test",
|
||||||
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
||||||
"settings.weather.preview_button": "Test Fetch",
|
"settings.weather.preview_button": "Test Fetch",
|
||||||
|
"settings.weather.preview_section": "Weather Preview",
|
||||||
|
"settings.weather.settings_section": "Settings",
|
||||||
"settings.weather.preview_panel_header": "Weather Preview",
|
"settings.weather.preview_panel_header": "Weather Preview",
|
||||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||||
"settings.weather.refresh_button": "Refresh",
|
"settings.weather.refresh_button": "Refresh",
|
||||||
|
"settings.weather.preview_updated_format": "Updated {0}",
|
||||||
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
|
||||||
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
|
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
|
||||||
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
|
||||||
@@ -129,6 +161,15 @@
|
|||||||
"settings.weather.status_city_empty": "No city location is configured.",
|
"settings.weather.status_city_empty": "No city location is configured.",
|
||||||
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
|
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
|
||||||
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
|
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
|
||||||
|
"settings.weather.city_selection_label": "City Selection",
|
||||||
|
"settings.weather.coordinates_selection_label": "Coordinate Location",
|
||||||
|
"settings.weather.location_city_summary_desc": "Select the current city used for weather queries.",
|
||||||
|
"settings.weather.location_coordinates_summary_desc": "Set latitude/longitude and optional location name used for weather queries.",
|
||||||
|
"settings.weather.location_not_selected": "No location selected",
|
||||||
|
"settings.weather.alert_list_label": "Exclude List",
|
||||||
|
"settings.weather.alert_list_desc": "One exclusion rule per line.",
|
||||||
|
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
|
||||||
|
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
|
||||||
"settings.weather.location_header": "Weather Location",
|
"settings.weather.location_header": "Weather Location",
|
||||||
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
||||||
"settings.weather.location_placeholder": "e.g. Beijing",
|
"settings.weather.location_placeholder": "e.g. Beijing",
|
||||||
@@ -198,6 +239,60 @@
|
|||||||
"settings.region.timezone_header": "Time Zone",
|
"settings.region.timezone_header": "Time Zone",
|
||||||
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
|
||||||
"settings.region.applied_format": "Language switched to: {0}",
|
"settings.region.applied_format": "Language switched to: {0}",
|
||||||
|
"settings.region.follow_system": "Follow system default",
|
||||||
|
"settings.general.title": "General",
|
||||||
|
"settings.general.description": "Adjust language, time zone, and runtime behavior.",
|
||||||
|
"settings.general.basic_header": "Basic Settings",
|
||||||
|
"settings.general.runtime_header": "Runtime",
|
||||||
|
"settings.general.preview_header": "Date & Time Preview",
|
||||||
|
"settings.general.preview_time_label": "Time",
|
||||||
|
"settings.general.preview_date_label": "Date",
|
||||||
|
"settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.",
|
||||||
|
"settings.appearance.title": "Appearance",
|
||||||
|
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
|
||||||
|
"settings.appearance.theme_header": "Theme",
|
||||||
|
"settings.color.enable_night_mode_toggle": "Enable night mode",
|
||||||
|
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
||||||
|
"settings.color.theme_color_label": "Theme accent color",
|
||||||
|
"settings.appearance.theme_color_mode_label": "Theme color source",
|
||||||
|
"settings.appearance.theme_color_mode.neutral": "Default neutral",
|
||||||
|
"settings.appearance.theme_color_mode.user": "User theme color Monet",
|
||||||
|
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
|
||||||
|
"settings.appearance.theme_color_mode_desc.neutral": "Use the default white and black neutral surfaces for light and dark mode.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.user": "Use the selected theme color as the Monet seed for the whole shell.",
|
||||||
|
"settings.appearance.theme_color_mode_desc.wallpaper": "Use wallpaper colors. The app wallpaper is preferred, then the system wallpaper.",
|
||||||
|
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
||||||
|
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
||||||
|
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
||||||
|
"component.color_scheme.follow_system": "Follow system color scheme",
|
||||||
|
"component.color_scheme.native": "Use component custom color scheme",
|
||||||
|
"settings.appearance.system_material.none": "None",
|
||||||
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
|
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
|
||||||
|
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
|
||||||
|
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
|
||||||
|
"settings.appearance.preview.primary": "Primary",
|
||||||
|
"settings.appearance.preview.secondary": "Secondary",
|
||||||
|
"settings.appearance.preview.tertiary": "Tertiary",
|
||||||
|
"settings.appearance.preview.neutral": "Neutral",
|
||||||
|
"settings.appearance.preview.seed": "Seed",
|
||||||
|
"settings.appearance.preview.neutral_light": "White",
|
||||||
|
"settings.appearance.preview.neutral_dark": "Black",
|
||||||
|
"settings.appearance.preview.apply_seed": "Apply",
|
||||||
|
"settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates",
|
||||||
|
"settings.appearance.preview.wallpaper_current": "Current",
|
||||||
|
"settings.wallpaper.placement.fill": "Fill",
|
||||||
|
"settings.wallpaper.placement.fit": "Fit",
|
||||||
|
"settings.wallpaper.placement.stretch": "Stretch",
|
||||||
|
"settings.wallpaper.placement.center": "Center",
|
||||||
|
"settings.wallpaper.placement.tile": "Tile",
|
||||||
|
"settings.status_bar.clock_format_label": "Clock format",
|
||||||
|
"settings.status_bar.clock_format.hm": "Hour:Minute",
|
||||||
|
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
|
||||||
|
"settings.components.title": "Components",
|
||||||
|
"settings.components.description": "Adjust desktop grid density and widget placement.",
|
||||||
|
"settings.components.grid_header": "Grid Layout",
|
||||||
"settings.update.title": "Update",
|
"settings.update.title": "Update",
|
||||||
"settings.update.current_version_label": "Current Version",
|
"settings.update.current_version_label": "Current Version",
|
||||||
"settings.update.latest_version_label": "Latest Release",
|
"settings.update.latest_version_label": "Latest Release",
|
||||||
@@ -229,6 +324,7 @@
|
|||||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||||
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
||||||
|
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||||
"settings.about.title": "About",
|
"settings.about.title": "About",
|
||||||
"settings.about.version_format": "Version: {0}",
|
"settings.about.version_format": "Version: {0}",
|
||||||
@@ -249,9 +345,42 @@
|
|||||||
"settings.about.render_mode.current_format": "Current backend: {0}",
|
"settings.about.render_mode.current_format": "Current backend: {0}",
|
||||||
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
|
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
|
||||||
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
|
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
|
||||||
|
"settings.about.description": "Application details.",
|
||||||
|
"settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.",
|
||||||
|
"settings.update.status_card_title": "Update Status",
|
||||||
|
"settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.",
|
||||||
|
"settings.update.preferences_header": "Update Preferences",
|
||||||
|
"settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.",
|
||||||
|
"settings.update.last_checked_label": "Last Checked",
|
||||||
|
"settings.update.source_label": "Download Source",
|
||||||
|
"settings.update.source_github": "GitHub",
|
||||||
|
"settings.update.source_ghproxy": "gh-proxy",
|
||||||
|
"settings.update.source_github_desc": "Download release assets directly from GitHub.",
|
||||||
|
"settings.update.source_ghproxy_desc": "Use the gh-proxy mirror when downloading GitHub release assets.",
|
||||||
|
"settings.update.mode_label": "Update Mode",
|
||||||
|
"settings.update.mode_manual": "Manual Update",
|
||||||
|
"settings.update.mode_download_then_confirm": "Silent Download",
|
||||||
|
"settings.update.mode_silent_on_exit": "Silent Install",
|
||||||
|
"settings.update.mode_manual_desc": "Only check for updates. You decide when downloads and installation happen.",
|
||||||
|
"settings.update.mode_download_then_confirm_desc": "Download updates in the background and ask for confirmation before installing them.",
|
||||||
|
"settings.update.mode_silent_on_exit_desc": "Download updates in the background and install them the next time you exit the app.",
|
||||||
|
"settings.update.channel_stable_desc": "Stable builds prioritize reliability and are recommended for most users.",
|
||||||
|
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
|
||||||
|
"settings.update.download_threads_label": "Download Threads",
|
||||||
|
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
|
||||||
|
"settings.update.install_now_button": "Install Now",
|
||||||
|
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
|
||||||
|
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
|
||||||
|
"settings.about.app_info_header": "Application Information",
|
||||||
|
"settings.about.update_header": "Updates",
|
||||||
|
"settings.about.version_label": "Version",
|
||||||
|
"settings.about.codename_label": "Codename",
|
||||||
|
"settings.about.render_backend_label": "Render Backend",
|
||||||
|
"settings.about.render_backend_format": "Render Backend: {0}",
|
||||||
"settings.restart_dialog.title": "Restart required",
|
"settings.restart_dialog.title": "Restart required",
|
||||||
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
||||||
"settings.restart_dialog.restart": "Restart now",
|
"settings.restart_dialog.restart": "Restart now",
|
||||||
|
"settings.restart_dialog.later": "Later",
|
||||||
"settings.restart_dialog.cancel": "Cancel",
|
"settings.restart_dialog.cancel": "Cancel",
|
||||||
"settings.restart_dock.title": "Restart required",
|
"settings.restart_dock.title": "Restart required",
|
||||||
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
|
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
|
||||||
@@ -278,21 +407,39 @@
|
|||||||
"launcher.context.hide_icon": "Hide Icon",
|
"launcher.context.hide_icon": "Hide Icon",
|
||||||
"launcher.action.hide": "Hide",
|
"launcher.action.hide": "Hide",
|
||||||
"settings.launcher.title": "App Launcher",
|
"settings.launcher.title": "App Launcher",
|
||||||
|
"settings.launcher.description": "Manage hidden apps and folders in the App Launcher.",
|
||||||
"settings.launcher.hidden_header": "Hidden Items",
|
"settings.launcher.hidden_header": "Hidden Items",
|
||||||
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
|
||||||
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
|
||||||
"settings.launcher.hidden_empty": "No hidden items.",
|
"settings.launcher.hidden_empty": "No hidden items.",
|
||||||
|
"settings.launcher.hidden_summary_format": "{0} hidden items",
|
||||||
"settings.launcher.hidden_type_folder": "Folder",
|
"settings.launcher.hidden_type_folder": "Folder",
|
||||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
"settings.launcher.hidden_type_shortcut": "App",
|
||||||
"settings.launcher.restore_button": "Show Again",
|
"settings.launcher.restore_button": "Unhide",
|
||||||
"settings.plugins.title": "Plugins",
|
"settings.plugins.title": "Plugins",
|
||||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||||
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||||
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||||
|
"settings.plugins.description": "Manage installed plugins and review their runtime state.",
|
||||||
|
"settings.plugins.initial_status": "Refresh plugin state to see the latest installed plugins.",
|
||||||
|
"settings.plugins.refresh_button": "Refresh Plugins",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "Loaded {0} installed plugins.",
|
||||||
|
"settings.plugins.refresh_success_format": "Loaded {0} installed plugins and {1} marketplace entries.",
|
||||||
|
"settings.plugins.refresh_failed": "Failed to load plugin market index.",
|
||||||
|
"settings.plugins.marketplace_header": "Marketplace",
|
||||||
|
"settings.plugins.marketplace_empty": "No marketplace plugins are available right now.",
|
||||||
|
"settings.plugins.delete_button_short": "Delete",
|
||||||
|
"settings.plugins.install_button_short": "Install",
|
||||||
|
"settings.plugins.restart_required": "Plugin changes take effect after restart.",
|
||||||
|
"settings.plugins.toggle_unchanged_format": "Plugin '{0}' did not change.",
|
||||||
|
"settings.plugins.delete_failed_name_format": "Failed to remove plugin '{0}'.",
|
||||||
|
"settings.plugins.install_failed_name_format": "Failed to install '{0}'.",
|
||||||
"settings.plugins.installed_header": "Installed Plugins",
|
"settings.plugins.installed_header": "Installed Plugins",
|
||||||
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
|
"settings.plugins.installed_desc": "Review installed plugins and remove them here.",
|
||||||
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
|
"settings.plugins.import_header": "Install From Package",
|
||||||
|
"settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.",
|
||||||
|
"settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.",
|
||||||
"settings.plugins.empty": "No plugins found.",
|
"settings.plugins.empty": "No plugins found.",
|
||||||
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
||||||
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||||
@@ -307,6 +454,7 @@
|
|||||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||||
"settings.plugins.toggle_state_enabled": "enabled",
|
"settings.plugins.toggle_state_enabled": "enabled",
|
||||||
"settings.plugins.toggle_state_disabled": "disabled",
|
"settings.plugins.toggle_state_disabled": "disabled",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}",
|
||||||
"settings.plugins.install_button": "Open .laapp package",
|
"settings.plugins.install_button": "Open .laapp package",
|
||||||
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
||||||
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||||
@@ -316,10 +464,75 @@
|
|||||||
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
||||||
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||||
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
||||||
|
"settings.plugins.delete_button": "Delete plugin",
|
||||||
|
"settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
|
||||||
|
"settings.plugins.delete_failed_format": "Failed to delete plugin: {0}",
|
||||||
|
"settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}",
|
||||||
|
"settings.plugins.publisher_format": "Publisher: {0}",
|
||||||
|
"settings.plugins.publisher_unknown": "Unknown publisher",
|
||||||
"settings.plugins.source_package": ".laapp package",
|
"settings.plugins.source_package": ".laapp package",
|
||||||
"settings.plugins.source_manifest": "Loose manifest",
|
"settings.plugins.source_manifest": "Loose manifest",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||||
|
"settings.nav.plugin_market": "Plugin Market",
|
||||||
|
"settings.plugin_market.title": "Plugin Market",
|
||||||
|
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||||
|
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||||
|
"settings.update.status_idle": "No update check has been performed yet.",
|
||||||
|
"settings.update.status_preferences_saved": "Update preferences saved.",
|
||||||
|
"settings.update.status_check_failed": "Failed to check for updates.",
|
||||||
|
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
|
||||||
|
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
|
||||||
|
"settings.window.drawer_default": "Details",
|
||||||
|
"market.toolbar.search_placeholder": "Search plugins",
|
||||||
|
"market.toolbar.refresh": "Refresh",
|
||||||
|
"market.status.loading": "Loading the official plugin market...",
|
||||||
|
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
|
||||||
|
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
|
||||||
|
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
|
||||||
|
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
|
||||||
|
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
|
||||||
|
"market.status.install_failed_format": "Failed to install plugin: {0}",
|
||||||
|
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
|
||||||
|
"market.list.empty": "The plugin market has not been loaded yet.",
|
||||||
|
"market.list.no_results": "No plugins match the current search.",
|
||||||
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
|
"market.card.loaded": "Loaded",
|
||||||
|
"market.card.pending_restart": "Restart required",
|
||||||
|
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
||||||
|
"market.detail.author": "Publisher",
|
||||||
|
"market.detail.version": "Version",
|
||||||
|
"market.detail.api_version": "API Version",
|
||||||
|
"market.detail.min_host_version": "Minimum Host Version",
|
||||||
|
"market.detail.installed_version": "Installed Version",
|
||||||
|
"market.detail.not_installed": "Not installed",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "Plugin Information",
|
||||||
|
"market.detail.author_subtitle_format": "By {0}",
|
||||||
|
"market.detail.package_size": "Package Size",
|
||||||
|
"market.detail.published_at": "Published At",
|
||||||
|
"market.detail.updated_at": "Updated At",
|
||||||
|
"market.detail.tags": "Tags",
|
||||||
|
"market.detail.project": "Project",
|
||||||
|
"market.detail.state": "Install State",
|
||||||
|
"market.detail.market_source": "Market Source",
|
||||||
|
"market.detail.homepage": "Homepage",
|
||||||
|
"market.detail.repository": "Repository",
|
||||||
|
"market.detail.release_notes": "Release Notes",
|
||||||
|
"market.detail.dependencies": "Dependencies",
|
||||||
|
"market.detail.dependencies_empty": "No shared contract dependencies were declared by this plugin.",
|
||||||
|
"market.detail.readme_loading": "Loading README...",
|
||||||
|
"market.detail.readme_empty": "README is empty.",
|
||||||
|
"market.detail.readme_error_format": "README could not be loaded: {0}",
|
||||||
|
"market.detail.state.not_installed": "Not installed",
|
||||||
|
"market.detail.state.update_available": "Update available",
|
||||||
|
"market.detail.state.installed": "Installed",
|
||||||
|
"market.detail.unknown": "Unknown",
|
||||||
|
"market.button.install": "Install",
|
||||||
|
"market.button.update": "Update",
|
||||||
|
"market.button.installed": "Installed",
|
||||||
|
"market.button.installing": "Installing...",
|
||||||
|
"market.button.restart": "Restart to apply",
|
||||||
"button.component_library": "Edit Desktop",
|
"button.component_library": "Edit Desktop",
|
||||||
"tooltip.component_library": "Edit Desktop",
|
"tooltip.component_library": "Edit Desktop",
|
||||||
"component_library.title": "Widgets",
|
"component_library.title": "Widgets",
|
||||||
@@ -327,6 +540,12 @@
|
|||||||
"component_library.drag_hint": "Drag to place",
|
"component_library.drag_hint": "Drag to place",
|
||||||
"component.delete": "Delete",
|
"component.delete": "Delete",
|
||||||
"component.edit": "Edit",
|
"component.edit": "Edit",
|
||||||
|
"component.editor.instance_scope": "Changes apply to this component instance only.",
|
||||||
|
"component.editor.info_header": "Component Info",
|
||||||
|
"component.editor.id_label": "Component ID",
|
||||||
|
"component.editor.placement_label": "Placement ID",
|
||||||
|
"component.editor.scope_label": "Scope",
|
||||||
|
"component.editor.scope_instance": "Instance-scoped editor",
|
||||||
"component_category.clock": "Clock",
|
"component_category.clock": "Clock",
|
||||||
"component_category.date": "Calendar",
|
"component_category.date": "Calendar",
|
||||||
"component_category.weather": "Weather",
|
"component_category.weather": "Weather",
|
||||||
@@ -362,6 +581,7 @@
|
|||||||
"component.whiteboard": "Blackboard (Portrait)",
|
"component.whiteboard": "Blackboard (Portrait)",
|
||||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||||
"component.browser": "Browser",
|
"component.browser": "Browser",
|
||||||
|
"component.office_recent_documents": "Recent Documents",
|
||||||
"component.holiday_calendar": "Holiday Calendar",
|
"component.holiday_calendar": "Holiday Calendar",
|
||||||
"component.study_environment": "Environment",
|
"component.study_environment": "Environment",
|
||||||
"component.study_session_control": "Study Session Control",
|
"component.study_session_control": "Study Session Control",
|
||||||
@@ -664,7 +884,8 @@
|
|||||||
"placement.fit": "Fit",
|
"placement.fit": "Fit",
|
||||||
"placement.stretch": "Stretch",
|
"placement.stretch": "Stretch",
|
||||||
"placement.center": "Center",
|
"placement.center": "Center",
|
||||||
"placement.tile": "Tile"
|
"placement.tile": "Tile",
|
||||||
}
|
"single_instance.notice.title": "App already running",
|
||||||
|
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||||
|
"single_instance.notice.button": "OK"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
{
|
{
|
||||||
"app.title": "LanMountainDesktop",
|
"app.title": "LanMountainDesktop",
|
||||||
|
"tray.tooltip": "LanMountainDesktop",
|
||||||
|
"tray.menu.show_desktop": "打开桌面",
|
||||||
|
"tray.menu.settings": "设置",
|
||||||
|
"tray.menu.component_library": "独立组件库",
|
||||||
|
"tray.menu.restart": "重启应用",
|
||||||
|
"tray.menu.exit": "退出应用",
|
||||||
"button.back_to_windows": "回到Windows",
|
"button.back_to_windows": "回到Windows",
|
||||||
"tooltip.back_to_windows": "回到Windows",
|
"tooltip.back_to_windows": "回到Windows",
|
||||||
"tooltip.open_settings": "设置",
|
"tooltip.open_settings": "设置",
|
||||||
"settings.title": "设置",
|
"settings.title": "设置",
|
||||||
|
"settings.shell.title": "设置",
|
||||||
|
"settings.shell.subtitle": "LanMountainDesktop 独立设置模块",
|
||||||
|
"settings.shell.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
|
||||||
|
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立设置模块中管理。",
|
||||||
"settings.back_to_desktop": "返回桌面",
|
"settings.back_to_desktop": "返回桌面",
|
||||||
"settings.nav_header": "设置选项",
|
"settings.nav_header": "设置选项",
|
||||||
|
"settings.nav.group_desktop": "桌面",
|
||||||
|
"settings.nav.group_system": "系统",
|
||||||
|
"settings.nav.group_extensions": "扩展",
|
||||||
"settings.nav.wallpaper": "壁纸",
|
"settings.nav.wallpaper": "壁纸",
|
||||||
"settings.nav.grid": "网格",
|
"settings.nav.grid": "网格",
|
||||||
"settings.nav.color": "颜色",
|
"settings.nav.color": "颜色",
|
||||||
@@ -13,15 +26,21 @@
|
|||||||
"settings.nav.weather": "天气",
|
"settings.nav.weather": "天气",
|
||||||
"settings.nav.region": "地区",
|
"settings.nav.region": "地区",
|
||||||
"settings.nav.update": "更新",
|
"settings.nav.update": "更新",
|
||||||
|
"settings.nav.privacy": "隐私",
|
||||||
"settings.nav.launcher": "应用启动台",
|
"settings.nav.launcher": "应用启动台",
|
||||||
"settings.nav.plugins": "插件",
|
"settings.nav.plugins": "插件",
|
||||||
"settings.nav.about": "关于",
|
"settings.nav.about": "关于",
|
||||||
"settings.wallpaper.title": "壁纸",
|
"settings.wallpaper.title": "壁纸",
|
||||||
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
|
||||||
"settings.wallpaper.current_label": "当前壁纸",
|
"settings.wallpaper.current_label": "当前壁纸",
|
||||||
|
"settings.wallpaper.type_label": "壁纸类型",
|
||||||
|
"settings.wallpaper.type.image": "图片",
|
||||||
|
"settings.wallpaper.type.video": "视频",
|
||||||
|
"settings.wallpaper.type.solid_color": "纯色",
|
||||||
|
"settings.wallpaper.color_label": "壁纸颜色",
|
||||||
"settings.wallpaper.placement_label": "显示方式",
|
"settings.wallpaper.placement_label": "显示方式",
|
||||||
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
|
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
|
||||||
"settings.wallpaper.pick_button": "浏览文件",
|
"settings.wallpaper.pick_button": "选择文件",
|
||||||
"settings.wallpaper.clear_button": "恢复纯色",
|
"settings.wallpaper.clear_button": "恢复纯色",
|
||||||
"settings.wallpaper.no_selection": "未选择壁纸。",
|
"settings.wallpaper.no_selection": "未选择壁纸。",
|
||||||
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
|
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
|
||||||
@@ -79,7 +98,14 @@
|
|||||||
"settings.status_bar.spacing_mode_custom": "自定义",
|
"settings.status_bar.spacing_mode_custom": "自定义",
|
||||||
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
|
"settings.status_bar.spacing_custom_label": "自定义间距(%)",
|
||||||
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
|
||||||
|
"settings.privacy.title": "隐私",
|
||||||
|
"settings.privacy.description": "管理可选的匿名上传设置,帮助我们逐步改进应用体验。",
|
||||||
|
"settings.privacy.crash_upload_title": "匿名上传崩溃数据",
|
||||||
|
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
|
||||||
|
"settings.privacy.usage_upload_title": "匿名上传使用数据",
|
||||||
|
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
|
||||||
"settings.weather.title": "天气",
|
"settings.weather.title": "天气",
|
||||||
|
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
|
||||||
"settings.weather.location_source_header": "位置来源",
|
"settings.weather.location_source_header": "位置来源",
|
||||||
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
|
||||||
"settings.weather.mode_city_search": "城市搜索",
|
"settings.weather.mode_city_search": "城市搜索",
|
||||||
@@ -106,12 +132,23 @@
|
|||||||
"settings.weather.apply_coordinates_button": "应用坐标",
|
"settings.weather.apply_coordinates_button": "应用坐标",
|
||||||
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
|
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
|
||||||
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
|
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
|
||||||
|
"settings.weather.location_services_header": "定位服务",
|
||||||
|
"settings.weather.location_services_desc": "使用当前 Windows 定位,并决定是否在启动时自动刷新天气位置。",
|
||||||
|
"settings.weather.use_current_location": "使用当前位置",
|
||||||
|
"settings.weather.location_unsupported": "当前平台不支持获取当前位置。",
|
||||||
|
"settings.weather.location_ready": "可以使用当前 Windows 定位。",
|
||||||
|
"settings.weather.location_refreshing": "正在获取当前位置……",
|
||||||
|
"settings.weather.location_refresh_success_format": "已应用当前位置:{0}",
|
||||||
|
"settings.weather.location_refresh_failed_format": "获取当前位置失败:{0}",
|
||||||
"settings.weather.preview_header": "连接测试",
|
"settings.weather.preview_header": "连接测试",
|
||||||
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||||
"settings.weather.preview_button": "测试获取",
|
"settings.weather.preview_button": "测试获取",
|
||||||
|
"settings.weather.preview_section": "天气预览",
|
||||||
|
"settings.weather.settings_section": "设置",
|
||||||
"settings.weather.preview_panel_header": "天气预览",
|
"settings.weather.preview_panel_header": "天气预览",
|
||||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||||
"settings.weather.refresh_button": "刷新",
|
"settings.weather.refresh_button": "刷新",
|
||||||
|
"settings.weather.preview_updated_format": "更新于 {0}",
|
||||||
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
|
||||||
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
|
||||||
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
|
||||||
@@ -129,6 +166,15 @@
|
|||||||
"settings.weather.status_city_empty": "尚未配置城市位置。",
|
"settings.weather.status_city_empty": "尚未配置城市位置。",
|
||||||
"settings.weather.status_city_format": "模式:{0}|{1}|Key:{2}",
|
"settings.weather.status_city_format": "模式:{0}|{1}|Key:{2}",
|
||||||
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}|Key:{3}",
|
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}|Key:{3}",
|
||||||
|
"settings.weather.city_selection_label": "城市选择",
|
||||||
|
"settings.weather.coordinates_selection_label": "坐标定位",
|
||||||
|
"settings.weather.location_city_summary_desc": "选择当前所在的城市,用于天气查询。",
|
||||||
|
"settings.weather.location_coordinates_summary_desc": "设置经纬度与可选的位置名称,用于天气查询。",
|
||||||
|
"settings.weather.location_not_selected": "未选择位置",
|
||||||
|
"settings.weather.alert_list_label": "排除列表",
|
||||||
|
"settings.weather.alert_list_desc": "一行一条排除项。",
|
||||||
|
"settings.weather.no_tls_toggle": "允许在兼容性较差的网络环境下回退到非 TLS 请求",
|
||||||
|
"settings.weather.footer_hint": "桌面上的天气组件会共享这里配置的天气位置与预警排除规则。",
|
||||||
"settings.weather.location_header": "天气位置",
|
"settings.weather.location_header": "天气位置",
|
||||||
"settings.weather.location_desc": "设置天气组件使用的位置。",
|
"settings.weather.location_desc": "设置天气组件使用的位置。",
|
||||||
"settings.weather.location_placeholder": "例如:北京",
|
"settings.weather.location_placeholder": "例如:北京",
|
||||||
@@ -198,6 +244,60 @@
|
|||||||
"settings.region.timezone_header": "时区",
|
"settings.region.timezone_header": "时区",
|
||||||
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
|
||||||
"settings.region.applied_format": "语言已切换为:{0}",
|
"settings.region.applied_format": "语言已切换为:{0}",
|
||||||
|
"settings.region.follow_system": "跟随系统默认",
|
||||||
|
"settings.general.title": "基本设置",
|
||||||
|
"settings.general.description": "调整语言、时区与运行时行为。",
|
||||||
|
"settings.general.basic_header": "基础设置",
|
||||||
|
"settings.general.runtime_header": "运行设置",
|
||||||
|
"settings.general.preview_header": "日期与时间预览",
|
||||||
|
"settings.general.preview_time_label": "时间",
|
||||||
|
"settings.general.preview_date_label": "日期",
|
||||||
|
"settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。",
|
||||||
|
"settings.appearance.title": "外观",
|
||||||
|
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
|
||||||
|
"settings.appearance.theme_header": "主题",
|
||||||
|
"settings.color.enable_night_mode_toggle": "启用夜间模式",
|
||||||
|
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
||||||
|
"settings.color.theme_color_label": "主题强调色",
|
||||||
|
"settings.appearance.theme_color_mode_label": "主题色来源",
|
||||||
|
"settings.appearance.theme_color_mode.neutral": "默认中性",
|
||||||
|
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
|
||||||
|
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
|
||||||
|
"settings.appearance.theme_color_mode_desc.neutral": "使用标准的日间白底黑字与夜间黑底白字中性色表面。",
|
||||||
|
"settings.appearance.theme_color_mode_desc.user": "使用用户选择的主题色作为整个桌面壳层的 Monet 种子色。",
|
||||||
|
"settings.appearance.theme_color_mode_desc.wallpaper": "使用壁纸颜色。优先取应用壁纸,失败后回退系统桌面壁纸。",
|
||||||
|
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||||
|
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||||
|
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||||
|
"component.color_scheme.follow_system": "跟随系统配色",
|
||||||
|
"component.color_scheme.native": "使用组件自定义配色",
|
||||||
|
"settings.appearance.system_material.none": "无",
|
||||||
|
"settings.appearance.system_material.mica": "Mica",
|
||||||
|
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||||
|
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
|
||||||
|
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
|
||||||
|
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
|
||||||
|
"settings.appearance.preview.primary": "主色",
|
||||||
|
"settings.appearance.preview.secondary": "次色",
|
||||||
|
"settings.appearance.preview.tertiary": "三次色",
|
||||||
|
"settings.appearance.preview.neutral": "中性色",
|
||||||
|
"settings.appearance.preview.seed": "种子色",
|
||||||
|
"settings.appearance.preview.neutral_light": "白色",
|
||||||
|
"settings.appearance.preview.neutral_dark": "黑色",
|
||||||
|
"settings.appearance.preview.apply_seed": "应用",
|
||||||
|
"settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色",
|
||||||
|
"settings.appearance.preview.wallpaper_current": "当前",
|
||||||
|
"settings.wallpaper.placement.fill": "填充",
|
||||||
|
"settings.wallpaper.placement.fit": "适应",
|
||||||
|
"settings.wallpaper.placement.stretch": "拉伸",
|
||||||
|
"settings.wallpaper.placement.center": "居中",
|
||||||
|
"settings.wallpaper.placement.tile": "平铺",
|
||||||
|
"settings.status_bar.clock_format_label": "时钟格式",
|
||||||
|
"settings.status_bar.clock_format.hm": "时:分",
|
||||||
|
"settings.status_bar.clock_format.hms": "时:分:秒",
|
||||||
|
"settings.components.title": "网格",
|
||||||
|
"settings.components.description": "调整桌面网格与布局。",
|
||||||
|
"settings.components.grid_header": "网格布局",
|
||||||
"settings.update.title": "更新",
|
"settings.update.title": "更新",
|
||||||
"settings.update.current_version_label": "当前版本",
|
"settings.update.current_version_label": "当前版本",
|
||||||
"settings.update.latest_version_label": "最新发布",
|
"settings.update.latest_version_label": "最新发布",
|
||||||
@@ -229,6 +329,7 @@
|
|||||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||||
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
||||||
|
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
|
||||||
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
||||||
"settings.about.title": "关于",
|
"settings.about.title": "关于",
|
||||||
"settings.about.version_format": "版本号: {0}",
|
"settings.about.version_format": "版本号: {0}",
|
||||||
@@ -249,9 +350,42 @@
|
|||||||
"settings.about.render_mode.current_format": "当前后端:{0}",
|
"settings.about.render_mode.current_format": "当前后端:{0}",
|
||||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||||
|
"settings.about.description": "应用信息。",
|
||||||
|
"settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。",
|
||||||
|
"settings.update.status_card_title": "更新状态",
|
||||||
|
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
|
||||||
|
"settings.update.preferences_header": "更新偏好",
|
||||||
|
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。",
|
||||||
|
"settings.update.last_checked_label": "上次检查",
|
||||||
|
"settings.update.source_label": "下载源",
|
||||||
|
"settings.update.source_github": "GitHub",
|
||||||
|
"settings.update.source_ghproxy": "gh-proxy",
|
||||||
|
"settings.update.source_github_desc": "直接从 GitHub 下载发布安装包。",
|
||||||
|
"settings.update.source_ghproxy_desc": "下载 GitHub 发布安装包时使用 gh-proxy 镜像。",
|
||||||
|
"settings.update.mode_label": "更新模式",
|
||||||
|
"settings.update.mode_manual": "手动更新",
|
||||||
|
"settings.update.mode_download_then_confirm": "静默下载",
|
||||||
|
"settings.update.mode_silent_on_exit": "静默安装",
|
||||||
|
"settings.update.mode_manual_desc": "仅检查更新,何时下载和安装都由你决定。",
|
||||||
|
"settings.update.mode_download_then_confirm_desc": "后台下载更新,下载完成后由你确认是否安装。",
|
||||||
|
"settings.update.mode_silent_on_exit_desc": "后台下载更新,并在你下次退出应用时静默安装。",
|
||||||
|
"settings.update.channel_stable_desc": "正式版以稳定性优先,适合大多数用户。",
|
||||||
|
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
|
||||||
|
"settings.update.download_threads_label": "下载线程数",
|
||||||
|
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
|
||||||
|
"settings.update.install_now_button": "立即安装",
|
||||||
|
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
|
||||||
|
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
|
||||||
|
"settings.about.app_info_header": "应用信息",
|
||||||
|
"settings.about.update_header": "更新",
|
||||||
|
"settings.about.version_label": "版本",
|
||||||
|
"settings.about.codename_label": "版本代号",
|
||||||
|
"settings.about.render_backend_label": "渲染后端",
|
||||||
|
"settings.about.render_backend_format": "渲染后端:{0}",
|
||||||
"settings.restart_dialog.title": "需要重启应用",
|
"settings.restart_dialog.title": "需要重启应用",
|
||||||
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
|
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
|
||||||
"settings.restart_dialog.restart": "立即重启",
|
"settings.restart_dialog.restart": "立即重启",
|
||||||
|
"settings.restart_dialog.later": "稍后",
|
||||||
"settings.restart_dialog.cancel": "取消",
|
"settings.restart_dialog.cancel": "取消",
|
||||||
"settings.restart_dock.title": "需要重启应用",
|
"settings.restart_dock.title": "需要重启应用",
|
||||||
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
|
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
|
||||||
@@ -278,21 +412,39 @@
|
|||||||
"launcher.context.hide_icon": "隐藏图标",
|
"launcher.context.hide_icon": "隐藏图标",
|
||||||
"launcher.action.hide": "隐藏",
|
"launcher.action.hide": "隐藏",
|
||||||
"settings.launcher.title": "应用启动台",
|
"settings.launcher.title": "应用启动台",
|
||||||
|
"settings.launcher.description": "管理应用启动台中已隐藏的应用与文件夹。",
|
||||||
"settings.launcher.hidden_header": "已隐藏项目",
|
"settings.launcher.hidden_header": "已隐藏项目",
|
||||||
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
|
||||||
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
|
||||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||||
|
"settings.launcher.hidden_summary_format": "共 {0} 个隐藏项目",
|
||||||
"settings.launcher.hidden_type_folder": "文件夹",
|
"settings.launcher.hidden_type_folder": "文件夹",
|
||||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
"settings.launcher.hidden_type_shortcut": "应用",
|
||||||
"settings.launcher.restore_button": "重新显示",
|
"settings.launcher.restore_button": "取消隐藏",
|
||||||
"settings.plugins.title": "插件",
|
"settings.plugins.title": "插件",
|
||||||
"settings.plugins.runtime_header": "插件运行时",
|
"settings.plugins.runtime_header": "插件运行时",
|
||||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||||
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||||
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||||
|
"settings.plugins.description": "管理已安装插件并查看其运行时状态。",
|
||||||
|
"settings.plugins.initial_status": "刷新插件状态以查看最新的已安装插件。",
|
||||||
|
"settings.plugins.refresh_button": "刷新插件",
|
||||||
|
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
|
||||||
|
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
|
||||||
|
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
|
||||||
|
"settings.plugins.marketplace_header": "插件市场",
|
||||||
|
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
|
||||||
|
"settings.plugins.delete_button_short": "删除",
|
||||||
|
"settings.plugins.install_button_short": "安装",
|
||||||
|
"settings.plugins.restart_required": "插件变更将在重启后生效。",
|
||||||
|
"settings.plugins.toggle_unchanged_format": "插件“{0}”没有变化。",
|
||||||
|
"settings.plugins.delete_failed_name_format": "移除插件“{0}”失败。",
|
||||||
|
"settings.plugins.install_failed_name_format": "安装插件“{0}”失败。",
|
||||||
"settings.plugins.installed_header": "已安装插件",
|
"settings.plugins.installed_header": "已安装插件",
|
||||||
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
|
"settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
|
||||||
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
|
"settings.plugins.import_header": "从安装包导入",
|
||||||
|
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
|
||||||
|
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
|
||||||
"settings.plugins.empty": "未找到插件。",
|
"settings.plugins.empty": "未找到插件。",
|
||||||
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||||
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||||
@@ -307,6 +459,7 @@
|
|||||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||||
"settings.plugins.toggle_state_enabled": "启用",
|
"settings.plugins.toggle_state_enabled": "启用",
|
||||||
"settings.plugins.toggle_state_disabled": "禁用",
|
"settings.plugins.toggle_state_disabled": "禁用",
|
||||||
|
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
|
||||||
"settings.plugins.install_button": "打开 .laapp 插件包",
|
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||||
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||||
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||||
@@ -316,10 +469,75 @@
|
|||||||
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||||
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||||
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
||||||
|
"settings.plugins.delete_button": "删除插件",
|
||||||
|
"settings.plugins.delete_success_format": "插件“{0}”已暂存删除。重启应用后会完成移除。",
|
||||||
|
"settings.plugins.delete_failed_format": "删除插件失败:{0}",
|
||||||
|
"settings.plugins.delete_failed_detail_format": "删除插件“{0}”失败:{1}",
|
||||||
|
"settings.plugins.publisher_format": "发布者:{0}",
|
||||||
|
"settings.plugins.publisher_unknown": "未知发布者",
|
||||||
"settings.plugins.source_package": ".laapp 包",
|
"settings.plugins.source_package": ".laapp 包",
|
||||||
"settings.plugins.source_manifest": "散装清单",
|
"settings.plugins.source_manifest": "散装清单",
|
||||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||||
|
"settings.nav.plugin_market": "插件市场",
|
||||||
|
"settings.plugin_market.title": "插件市场",
|
||||||
|
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||||
|
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||||
|
"settings.update.status_idle": "尚未执行更新检查。",
|
||||||
|
"settings.update.status_preferences_saved": "更新偏好已保存。",
|
||||||
|
"settings.update.status_check_failed": "检查更新失败。",
|
||||||
|
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
|
||||||
|
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
|
||||||
|
"settings.window.drawer_default": "详情",
|
||||||
|
"market.toolbar.search_placeholder": "搜索插件",
|
||||||
|
"market.toolbar.refresh": "刷新",
|
||||||
|
"market.status.loading": "正在加载官方插件市场...",
|
||||||
|
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||||
|
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||||
|
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||||
|
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||||
|
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||||
|
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||||
|
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||||
|
"market.list.empty": "插件市场尚未加载。",
|
||||||
|
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||||
|
"market.card.subtitle_format": "{0} | v{1}",
|
||||||
|
"market.card.loaded": "已加载",
|
||||||
|
"market.card.pending_restart": "需要重启",
|
||||||
|
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
||||||
|
"market.detail.author": "发行者",
|
||||||
|
"market.detail.version": "版本",
|
||||||
|
"market.detail.api_version": "API 版本",
|
||||||
|
"market.detail.min_host_version": "最低宿主版本",
|
||||||
|
"market.detail.installed_version": "已安装版本",
|
||||||
|
"market.detail.not_installed": "未安装",
|
||||||
|
"market.detail.readme": "README",
|
||||||
|
"market.detail.plugin_information": "插件信息",
|
||||||
|
"market.detail.author_subtitle_format": "作者:{0}",
|
||||||
|
"market.detail.package_size": "包大小",
|
||||||
|
"market.detail.published_at": "首次发布",
|
||||||
|
"market.detail.updated_at": "最近更新",
|
||||||
|
"market.detail.tags": "标签",
|
||||||
|
"market.detail.project": "项目",
|
||||||
|
"market.detail.state": "安装状态",
|
||||||
|
"market.detail.market_source": "市场源",
|
||||||
|
"market.detail.homepage": "主页",
|
||||||
|
"market.detail.repository": "仓库",
|
||||||
|
"market.detail.release_notes": "发布说明",
|
||||||
|
"market.detail.dependencies": "依赖项",
|
||||||
|
"market.detail.dependencies_empty": "该插件没有声明 SharedContracts 依赖项。",
|
||||||
|
"market.detail.readme_loading": "正在加载 README...",
|
||||||
|
"market.detail.readme_empty": "README 为空。",
|
||||||
|
"market.detail.readme_error_format": "README 加载失败:{0}",
|
||||||
|
"market.detail.state.not_installed": "未安装",
|
||||||
|
"market.detail.state.update_available": "可更新",
|
||||||
|
"market.detail.state.installed": "已安装",
|
||||||
|
"market.detail.unknown": "未知",
|
||||||
|
"market.button.install": "安装",
|
||||||
|
"market.button.update": "更新",
|
||||||
|
"market.button.installed": "已安装",
|
||||||
|
"market.button.installing": "安装中...",
|
||||||
|
"market.button.restart": "重启后应用",
|
||||||
"button.component_library": "桌面编辑",
|
"button.component_library": "桌面编辑",
|
||||||
"tooltip.component_library": "桌面编辑",
|
"tooltip.component_library": "桌面编辑",
|
||||||
"component_library.title": "桌面编辑",
|
"component_library.title": "桌面编辑",
|
||||||
@@ -327,6 +545,12 @@
|
|||||||
"component_library.drag_hint": "拖动放置",
|
"component_library.drag_hint": "拖动放置",
|
||||||
"component.delete": "删除",
|
"component.delete": "删除",
|
||||||
"component.edit": "编辑",
|
"component.edit": "编辑",
|
||||||
|
"component.editor.instance_scope": "设置仅对当前组件实例生效。",
|
||||||
|
"component.editor.info_header": "组件信息",
|
||||||
|
"component.editor.id_label": "组件 ID",
|
||||||
|
"component.editor.placement_label": "实例 ID",
|
||||||
|
"component.editor.scope_label": "作用域",
|
||||||
|
"component.editor.scope_instance": "实例级编辑器",
|
||||||
"component_category.clock": "时钟",
|
"component_category.clock": "时钟",
|
||||||
"component_category.date": "日历",
|
"component_category.date": "日历",
|
||||||
"component_category.weather": "天气",
|
"component_category.weather": "天气",
|
||||||
@@ -362,6 +586,7 @@
|
|||||||
"component.whiteboard": "竖向小黑板",
|
"component.whiteboard": "竖向小黑板",
|
||||||
"component.blackboard_landscape": "横向小黑板",
|
"component.blackboard_landscape": "横向小黑板",
|
||||||
"component.browser": "浏览器",
|
"component.browser": "浏览器",
|
||||||
|
"component.office_recent_documents": "最近文档",
|
||||||
"component.holiday_calendar": "节假日日历",
|
"component.holiday_calendar": "节假日日历",
|
||||||
"component.study_environment": "环境",
|
"component.study_environment": "环境",
|
||||||
"component.study_session_control": "自习时段控制",
|
"component.study_session_control": "自习时段控制",
|
||||||
@@ -664,7 +889,8 @@
|
|||||||
"placement.fit": "适应",
|
"placement.fit": "适应",
|
||||||
"placement.stretch": "拉伸",
|
"placement.stretch": "拉伸",
|
||||||
"placement.center": "居中",
|
"placement.center": "居中",
|
||||||
"placement.tile": "平铺"
|
"placement.tile": "平铺",
|
||||||
}
|
"single_instance.notice.title": "应用已经运行",
|
||||||
|
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||||
|
"single_instance.notice.button": "确定"
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,20 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string? ThemeColor { get; set; }
|
public string? ThemeColor { get; set; }
|
||||||
|
|
||||||
|
public bool UseSystemChrome { get; set; }
|
||||||
|
|
||||||
|
public string ThemeColorMode { get; set; } = "default_neutral";
|
||||||
|
|
||||||
|
public string SystemMaterialMode { get; set; } = "none";
|
||||||
|
|
||||||
|
public string? SelectedWallpaperSeed { get; set; }
|
||||||
|
|
||||||
public string? WallpaperPath { get; set; }
|
public string? WallpaperPath { get; set; }
|
||||||
|
|
||||||
|
public string WallpaperType { get; set; } = "Image";
|
||||||
|
|
||||||
|
public string? WallpaperColor { get; set; }
|
||||||
|
|
||||||
public string WallpaperPlacement { get; set; } = "Fill";
|
public string WallpaperPlacement { get; set; } = "Fill";
|
||||||
|
|
||||||
public int SettingsTabIndex { get; set; } = 0;
|
public int SettingsTabIndex { get; set; } = 0;
|
||||||
@@ -42,7 +54,7 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
public string WeatherExcludedAlerts { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string WeatherIconPackId { get; set; } = "FluentRegular";
|
public string WeatherIconPackId { get; set; } = "HyperOS3";
|
||||||
|
|
||||||
public bool WeatherNoTlsRequests { get; set; }
|
public bool WeatherNoTlsRequests { get; set; }
|
||||||
|
|
||||||
@@ -54,14 +66,33 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public bool IncludePrereleaseUpdates { get; set; }
|
public bool IncludePrereleaseUpdates { get; set; }
|
||||||
|
|
||||||
public string UpdateChannel { get; set; } = string.Empty;
|
public bool UploadAnonymousCrashData { get; set; }
|
||||||
|
|
||||||
|
public bool UploadAnonymousUsageData { get; set; }
|
||||||
|
|
||||||
|
public string? DeviceId { get; set; }
|
||||||
|
|
||||||
|
public string UpdateChannel { get; set; } = "stable";
|
||||||
|
|
||||||
|
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||||
|
|
||||||
|
public string UpdateDownloadSource { get; set; } = "github";
|
||||||
|
|
||||||
|
public int UpdateDownloadThreads { get; set; } = 4;
|
||||||
|
|
||||||
|
public string? PendingUpdateInstallerPath { get; set; }
|
||||||
|
|
||||||
|
public string? PendingUpdateVersion { get; set; }
|
||||||
|
|
||||||
|
public long? PendingUpdatePublishedAtUtcMs { get; set; }
|
||||||
|
|
||||||
|
public long? LastUpdateCheckUtcMs { get; set; }
|
||||||
|
|
||||||
public List<string> TopStatusComponentIds { get; set; } = [];
|
public List<string> TopStatusComponentIds { get; set; } = [];
|
||||||
|
|
||||||
public List<string> PinnedTaskbarActions { get; set; } =
|
public List<string> PinnedTaskbarActions { get; set; } =
|
||||||
[
|
[
|
||||||
TaskbarActionId.MinimizeToWindows.ToString(),
|
TaskbarActionId.MinimizeToWindows.ToString()
|
||||||
TaskbarActionId.OpenSettings.ToString()
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public bool EnableDynamicTaskbarActions { get; set; } = true;
|
public bool EnableDynamicTaskbarActions { get; set; } = true;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
|
|||||||
{
|
{
|
||||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||||
|
|
||||||
|
public string? ColorSchemeSource { get; set; }
|
||||||
|
|
||||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||||
|
|
||||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user