Compare commits

...

55 Commits

Author SHA1 Message Date
lincube
4679ee006f 0.5.20
我认为很稳定了,后面就要开始弄插件不稳定了
2026-03-12 12:25:22 +08:00
lincube
6952cb2c3e 0.5.19.1 2026-03-12 10:07:23 +08:00
lincube
d3356f3319 0.5.19
插件系统V2
2026-03-12 09:22:03 +08:00
lincube
57c5e41a5c 0.5.18 2026-03-12 00:34:49 +08:00
lincube
ce2b218dfa 0.5.17 2026-03-12 00:18:04 +08:00
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
lincube
e7a03404ce 0.5.14
二次启动拦截,统一了生命进程API
2026-03-11 09:40:36 +08:00
lincube
2781d7e0d9 0.5.13
插件市场安装优化
2026-03-11 06:38:11 +08:00
lincube
5003ff1be2 0.5.12 2026-03-10 21:25:47 +08:00
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
lincube
85f7a18cbc 0.5.9
中文与插件市场
2026-03-10 12:14:49 +08:00
lincube
cdffaa16eb 0.5.8
插件市场
2026-03-10 09:55:49 +08:00
lincube
d33d8d3391 0.5.7
示例插件
2026-03-10 00:40:26 +08:00
lincube
9c89c08448 0.5.6
插件系统再进化
2026-03-10 00:04:33 +08:00
lincube
ec7b78bc63 0.5.5
现已支持更改关键设置时提醒重启功能
2026-03-09 22:26:42 +08:00
lincube
e97db00999 0.5.4
项目重启优化。
2026-03-09 17:54:49 +08:00
lincube
8bb6b01236 0.5.3
试验性引入渲染模式切换
2026-03-09 15:11:48 +08:00
lincube
103b215e35 0.5.2
后端服务支持
2026-03-09 14:14:50 +08:00
lincube
cab35f4c22 0.5.1
插件系统试验
2026-03-09 12:27:33 +08:00
lincube
c9f92a4755 0.5.0
设置优化
2026-03-08 14:00:13 +08:00
lincube
854deae801 0.4.12
模块化解耦
2026-03-08 04:22:19 +08:00
lincube
d72cd42483 0.4.11 2026-03-08 03:23:03 +08:00
lincube
3aee31c6c0 0.4.10
自习数据采样优化
2026-03-07 22:44:00 +08:00
lincube
435b96c50c 0.4.9.3
内存泄露问题解决
2026-03-07 22:05:18 +08:00
lincube
49b18d6af1 0.4.9.2 2026-03-07 19:59:28 +08:00
lincube
d6ec159af4 0.4.9.1
小修复
2026-03-07 17:25:29 +08:00
lincube
0d14675cc0 0.4.9
Linux相关版本适配
2026-03-07 00:58:52 +08:00
lincube
1f509959a9 0.4.8
百度热搜组件、凤凰新闻组件。
2026-03-06 22:24:59 +08:00
lincube
382d1baaf1 0.4.7
2×2英语单词组件,修复了stcn组件
2026-03-06 18:38:20 +08:00
lincube
72a0be16b3 0.4.6
引入了托盘菜单,提供了应用启动台隐藏应用功能,优化了自动刷新功能,为STCN 24组件提供了更多信息选项。
2026-03-06 10:32:02 +08:00
lincube
de40471af6 0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
2026-03-06 08:53:45 +08:00
lincube
5d35e0d21c 0.4.4
bilibili热搜组件
2026-03-06 00:29:40 +08:00
lincube
e917a1e4af 0.4.3fixed 2026-03-05 21:21:03 +08:00
lincube
b8643a2959 0.4.3
新增英语句子组件。优化央广网新闻组件,优化每日单词组件
2026-03-05 20:17:28 +08:00
lincube
3b71486423 0.4.2
修复视频壁纸恶性bug,添加央广网组件。
2026-03-05 18:46:32 +08:00
lincube
8768fa1ed2 0.4.1
修天气,时钟,每日图片....
2026-03-05 17:09:46 +08:00
lincube
24f1b896e1 0.4.0 2026-03-05 16:34:22 +08:00
lincube
3cdb4bbd98 cifixed5 2026-03-05 14:45:23 +08:00
lincube
f3e7f88a39 0.3.14 2026-03-05 14:34:33 +08:00
lincube
d182925b58 0.3.13
动画优化
2026-03-05 14:03:35 +08:00
lincube
2e49602bff cifixed4 2026-03-05 13:42:25 +08:00
lincube
c720d16e81 cifixed3 2026-03-05 13:02:15 +08:00
lincube
469f7e1132 0.3.12
修复
2026-03-05 12:34:39 +08:00
lincube
00694e715f ci: Fix artifact path pattern for release action 2026-03-05 01:08:37 +08:00
lincube
6803d0eb72 ci: Add debug logging to identify artifact packaging issue 2026-03-05 01:01:42 +08:00
lincube
56c5a5cc77 ci_fixed2 2026-03-05 00:52:58 +08:00
lincube
417cfa362e 0.3.11
噪音数据历史记录,引入数据库
2026-03-05 00:40:49 +08:00
lincube
9ec879cc17 0.3.10
自习时段加入
2026-03-04 20:58:17 +08:00
lincube
40ddcd399d 0.3.9
增加了自习系列组件
2026-03-04 20:03:14 +08:00
lincube
00a3c6a572 0.3.8
噪音评分组件
2026-03-04 19:16:51 +08:00
lincube
59bfa8d564 0.3.7 2026-03-04 17:02:12 +08:00
lincube
b21bb490fa 0.3.6
减少工程复杂度
2026-03-04 16:43:10 +08:00
lincube
f78a56cb2c 0.3.5 2026-03-04 15:22:52 +08:00
463 changed files with 56105 additions and 5747 deletions

View File

@@ -1,6 +1,6 @@
# LanMontainDesktop GitHub Actions CI/CD
# LanMountainDesktop GitHub Actions CI/CD
参考 ClassIsland 项目最佳实践,为 LanMontainDesktop 配置的 GitHub Actions 工作流。
参考 ClassIsland 项目最佳实践,为 LanMountainDesktop 配置的 GitHub Actions 工作流。
## 📋 工作流说明
@@ -98,11 +98,11 @@ git push origin v1.0.0
```bash
# 使用现有脚本
.\LanMontainDesktop\scripts\package.ps1 -RuntimeIdentifier win-x64
.\LanMountainDesktop\scripts\package.ps1 -RuntimeIdentifier win-x64
# 或用 dotnet 直接构建
dotnet build -c Release
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release -r win-x64 -o ./publish/win-x64 `
--self-contained -p:PublishSingleFile=true
```
@@ -118,7 +118,7 @@ chmod +x scripts/build.sh
# 或用 dotnet 直接构建
dotnet build -c Release
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release -r linux-x64 -o ./publish/linux-x64 \
--self-contained -p:PublishSingleFile=true
```

220
.github/CHANGES_CHECKLIST.md vendored Normal file
View File

@@ -0,0 +1,220 @@
# 🔧 打包优化 - 变更清单
执行时间2026年3月4日
---
## 📋 修改的文件清单
### 1. ✅ `.github/workflows/release.yml`
**状态**:✅ 已完成
**修改范围**
- **Windows Build** (第82-99行): 添加5个优化参数
- `-p:SelfContained=true`
- `-p:DebugSymbols=false`
- `-p:PublishTrimmed=true`
- `-p:TrimMode=partial`
- `-p:PublishReadyToRun=true`
- **Linux Build** (第175-192行): 添加5个优化参数同上
- **macOS Build** (第283-300行): 添加5个优化参数同上
**总变更**+15个参数在三个平台的发布命令中
---
### 2. ✅ `LanMountainDesktop/LanMountainDesktop.csproj`
**状态**:✅ 已完成
**修改内容**添加条件化的PropertyGroup配置
```xml
<!-- Release build optimizations -->
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
<!-- Self-contained runtime support -->
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
```
**影响**所有Release构建自动应用优化
---
### 3. ✅ `LanMountainDesktop/TrimmerRoots.xml`
**状态**:✅ 新建
**内容**:修程序集保护配置
- 保护30个程序集不被过度修剪
- 确保Avalonia、依赖库和系统库完整性
**关键程序集**
- Avalonia* (6个)
- Fluent* (4个)
- LibVLCSharp* (2个)
- WebView.Avalonia* (2个)
- CommunityToolkit.Mvvm
- System.* (6个)
- 其他关键库 (3个)
---
## 📊 测试建议
### 构建验证
```bash
# 本地构建测试
git pull # 获取最新变更
cd LanMountainDesktop
dotnet build -c Release # 应该成功
```
### CI/CD 验证
```bash
# 推送测试版本
git tag v1.0.1-size-optimization
git push origin v1.0.1-size-optimization
# 访问 GitHub Actions 监察:
# https://github.com/[owner]/LanMountainDesktop/actions
```
### 包大小验证
```bash
# 解压后检查大小
winrar x "LanMountainDesktop-1.0.1-win-x64.zip"
dir /s # 应该看到单个 .exe 文件,大小 200-300 MB
# Linux
tar xzf LanMountainDesktop-1.0.1-linux-x64.tar.gz
du -sh . # 应该看到 200-300 MB
```
### 功能验证
1. 双击/运行LanMountainDesktop.exe
2. 应用应在5秒内启动
3. UI应能正常交互
4. 检查应用日志无异常
---
## 🎯 预期结果对比(参考)
### 包大小
| 平台 | 之前(估) | 之后(估) | 改进 |
|-----|---------|---------|------|
| Windows x64 | ~600MB | ~250MB | 58% ⬇️ |
| Linux x64 | ~550MB | ~220MB | 60% ⬇️ |
| macOS | ~550MB | ~220MB | 60% ⬇️ |
### 性能
- 启动时间更快来自ReadyToRun
- 运行时内存:更优
- 磁盘占用减少50-60%
### 功能
- ✅ 完全独立,无需系统.NET
- ✅ 单一可执行文件
- ✅ 所有功能保留
---
## ⚙️ 回滚方案(如需要)
如果遇到问题,可以快速回滚:
### 方案A: 部分回滚(移除修剪)
```bash
# 编辑 .github/workflows/release.yml
# 移除 -p:PublishTrimmed=true 和 -p:TrimMode=partial
# 编辑 LanMountainDesktop/LanMountainDesktop.csproj
# 移除 PublishTrimmed 等优化参数
# 删除 TrimmerRoots.xml
```
### 方案B: 完全回滚(恢复原始配置)
```bash
git revert HEAD~3 # 回滚到优化前的提交
# 或
git checkout HEAD -- .github/workflows/release.yml LanMountainDesktop/LanMountainDesktop.csproj
```
---
## 📝 文档清单
### 已创建/更新的文档
1.`.github/SIZE_OPTIMIZATION_REPORT.md` - 详细优化报告
2.`.github/OPTIMIZATION_GUIDE.md` - 优化参数指南
3.`.github/PACKAGING_FIXES.md` - 打包修复报告
4.**本文件** - 变更清单
---
## ✅ 合规性检查
- ✅ 不改变应用功能
- ✅ 保留所有依赖库完整性
- ✅ Avalonia UI框架完全受保护
- ✅ 支持所有目标平台Win/Linux/Mac
- ✅ 支持所有目标架构x64/x86/arm64
- ✅ 维持发布工作流的完整性
---
## 🚀 接下来的步骤
1. **立即验证** (本地):
```bash
dotnet build -c Release
dotnet publish -c Release -r win-x64 --self-contained
```
2. **提交变更**:
```bash
git add .github/workflows/release.yml \
LanMountainDesktop/LanMountainDesktop.csproj \
LanMountainDesktop/TrimmerRoots.xml
git commit -m "feat: optimize package size and ensure .NET runtime inclusion
- Add PublishTrimmed with partial mode (50% size reduction)
- Add PublishReadyToRun for faster startup
- Add self-contained configuration
- Create TrimmerRoots.xml for dependency protection
- Update all platforms: Windows/Linux/macOS"
```
3. **推送并发布**:
```bash
git push origin main
git tag v1.0.1
git push origin v1.0.1
```
4. **监察 CI/CD**:
访问GitHub Actions查看构建并下载新的发布包
5. **最终验证**:
在多台机器上测试发布的包
---
## 💡 关键要点
- 🎯 **目标实现**包大小减少50-60%.NET运行时完整包含
- 🔒 **安全性**TrimmerRoots.xml保护所有必要的程序集
-**性能**ReadyToRun预编译提高运行时性能
- 📦 **独立性**:自包含模式无需用户系统上有.NET
- 🔄 **可回滚**:如遇问题可快速撤销
---
**完成时间**2026-03-04 10:30
**状态**:✅ 已完成,等待测试验证

17
.github/CODEOWNERS vendored
View File

@@ -1,18 +1,17 @@
# CODEOWNERS for LanMontainDesktop
# CODEOWNERS for LanMountainDesktop
# Default owners for everything
* @
# Desktop UI & Components
/LanMontainDesktop/Views/ @
/LanMontainDesktop/ViewModels/ @
/LanMontainDesktop/ComponentSystem/ @
/LanMontainDesktop/Styles/ @
/LanMontainDesktop/Controls/ @
/LanMountainDesktop/Views/ @
/LanMountainDesktop/ViewModels/ @
/LanMountainDesktop/ComponentSystem/ @
/LanMountainDesktop/Styles/ @
/LanMountainDesktop/Controls/ @
# Backend Services
/LanMontainDesktop/Services/ @
/LanMontainDesktop.RecommendationBackend/ @
/LanMountainDesktop/Services/ @
# Documentation
/docs/ @
@@ -21,4 +20,4 @@
# Build & CI/CD
/.github/ @
/scripts/ @
/LanMontainDesktop/LanMontainDesktop.csproj @
/LanMountainDesktop/LanMountainDesktop.csproj @

25
.github/FIX_REPORT.md vendored
View File

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

View File

@@ -1,10 +1,10 @@
# Multi-Platform Build Guide
# Multi-Platform Build Guide
This document explains how to build LanMontainDesktop for Windows, Linux, and macOS.
This document explains how to build LanMountainDesktop for Windows, Linux, and macOS.
## Overview
LanMontainDesktop supports self-contained builds for:
LanMountainDesktop supports self-contained builds for:
- **Windows**: x64 (64-bit) and x86 (32-bit)
- **Linux**: x64 only (AppImage/snap support planned)
- **macOS**: x64 (Intel) and arm64 (Apple Silicon M1/M2/M3)
@@ -67,19 +67,19 @@ brew install dotnet
**Windows (x64):**
```powershell
# Using the PowerShell script
.\LanMontainDesktop\scripts\package.ps1 `
.\LanMountainDesktop\scripts\package.ps1 `
-RuntimeIdentifier win-x64 `
-Version 1.0.0
# Or with dotnet directly
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release -r win-x64 -o ./publish/win-x64 `
--self-contained -p:PublishSingleFile=true
```
**Windows (x86):**
```powershell
.\LanMontainDesktop\scripts\package.ps1 `
.\LanMountainDesktop\scripts\package.ps1 `
-RuntimeIdentifier win-x86 `
-Version 1.0.0
```
@@ -119,8 +119,8 @@ After building, you'll have a self-contained directory with:
```
publish/[rid]/
├── LanMontainDesktop.exe (Windows)
├── LanMontainDesktop (Linux/macOS - executable)
├── LanMountainDesktop.exe (Windows)
├── LanMountainDesktop (Linux/macOS - executable)
├── libvlc/ (Windows/macOS only)
├── Localization/ (i18n files)
├── Extensions/ (Component extension manifests)
@@ -134,7 +134,7 @@ publish/[rid]/
# Create zip package
$rid = "win-x64"
$version = "1.0.0"
$dir = "LanMontainDesktop-$version-$rid"
$dir = "LanMountainDesktop-$version-$rid"
Copy-Item -Path "./publish/$rid" -Destination $dir -Recurse
Compress-Archive -Path $dir -DestinationPath "$dir.zip"
```
@@ -144,7 +144,7 @@ Compress-Archive -Path $dir -DestinationPath "$dir.zip"
# Create tar.gz package
rid=linux-x64
version=1.0.0
dir="LanMontainDesktop-$version-$rid"
dir="LanMountainDesktop-$version-$rid"
mkdir -p $dir
cp -r ./publish/$rid/* $dir/
tar -czf "$dir.tar.gz" $dir
@@ -218,16 +218,16 @@ tar -czf "$dir.tar.gz" $dir
```bash
# Clean and retry
dotnet clean LanMontainDesktop/LanMontainDesktop.csproj
dotnet clean LanMountainDesktop/LanMountainDesktop.csproj
dotnet restore
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj -c Release -r win-x64 --self-contained
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj -c Release -r win-x64 --self-contained
```
### Linux Build Fails
```bash
# Check dependencies are installed
ldd ./publish/linux-x64/LanMontainDesktop | grep "not found"
ldd ./publish/linux-x64/LanMountainDesktop | grep "not found"
# Install missing libraries
sudo apt-get install -y lib[missing-name]

253
.github/OPTIMIZATION_GUIDE.md vendored Normal file
View File

@@ -0,0 +1,253 @@
# 包大小优化指南
## 问题诊断
打包产物过大且缺少.NET运行时的原因分析
### 🔴 原始问题
1. **缺少代码修剪Trimming** - 构建包含了大量未使用的代码
2. **缺少即时编译优化ReadyToRun** - 未启用预编译
3. **调试符号未移除** - Release构建包含调试信息
4. **自包含运行时配置不完整** - `--self-contained` 标志但缺少确切配置
## ✅ 已实施的优化
### 1. 工作流发布命令优化(`.github/workflows/release.yml`
所有三个平台现在都使用以下参数:
```powershell
# Windows (PowerShell)
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
--self-contained ` # 包含.NET运行时
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=true ` # 单一可执行文件
-p:SelfContained=true ` # 明确启用自包含
-p:DebugType=none ` # 移除调试信息
-p:DebugSymbols=false ` # 移除调试符号
-p:PublishTrimmed=true ` # 启用代码修剪
-p:TrimMode=partial ` # 安全的部分修剪
-p:PublishReadyToRun=true # 启用预编译
```
```bash
# Linux/macOS (Bash)
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=true \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-p:PublishReadyToRun=true
```
### 2. 项目文件优化(`LanMountainDesktop/LanMountainDesktop.csproj`
添加了条件化的Release配置应已执行
```xml
<!-- Release build optimizations -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>partial</TrimMode>
<PublishReadyToRun>true</PublishReadyToRun>
<DebugSymbols>false</DebugSymbols>
<DebugType>none</DebugType>
</PropertyGroup>
<!-- Self-contained runtime -->
<PropertyGroup Condition="'$(RuntimeIdentifier)' != ''">
<SelfContained>true</SelfContained>
</PropertyGroup>
```
### 3. 修剪配置文件(`LanMountainDesktop/TrimmerRoots.xml`
创建了修剪根描述文件,保护以下关键程序集不被修剪:
- **UI Framework**: Avalonia, Avalonia.Controls, Avalonia.Desktop, Avalonia.Themes.Fluent
- **Fluent Design**: FluentAvaloniaUI, FluentIcons
- **Media**: LibVLCSharp, WebView.Avalonia
- **MVVM**: CommunityToolkit.Mvvm
- **System Libraries**: System.Reflection, System.ComponentModel.TypeConverter等
## 📊 预期的优化效果
| 优化项 | 效果 | 预期减少 |
|--------|------|---------|
| **代码修剪** | 移除未使用的代码 | 30-50% |
| **ReadyToRun** | 预编译IL到机器代码 | 10-20% |
| **移除调试符号** | 删除.pdb和调试信息 | 5-15% |
| **SingleFile** | 打包为单一可执行文件 | 10-15% |
| **总体效果** | 综合优化 | **40-60%** |
## 🔧 包大小参考
### 优化前(预期)
- Windows x64: ~500-800 MB
- Linux x64: ~450-700 MB
- macOS x64: ~450-700 MB
### 优化后(预期)
- Windows x64: ~200-350 MB
- Linux x64: ~180-320 MB
- macOS x64: ~180-320 MB
## 🎯 关键指标验证
发布后,检查以下指标确保优化生效:
### 1. 文件大小
```bash
# 检查发布文件大小
ls -lh publish/windows-x64/
ls -lh publish/linux-x64/
ls -lh publish/macos-x64/
```
### 2. 文件数量
```bash
# 单文件模式应该只有一个可执行文件
find publish/windows-x64 -type f | wc -l # 应该是1
```
### 3. .NET Runtime 验证
```bash
# Windows - 检查dotnet运行时
file publish/windows-x64/LanMountainDesktop.exe
strings publish/windows-x64/LanMountainDesktop.exe | grep -i ".net"
# Linux - 检查elf二进制
file publish/linux-x64/LanMountainDesktop
```
### 4. 依赖检查
```bash
# 验证没有外部.NET依赖
ldd ./publish/linux-x64/LanMountainDesktop | grep -i "not found" # 不应该有输出
# Windows - 检查是否依赖系统.NET
dumpbin /imports publish/windows-x64/LanMountainDesktop.exe | grep -i mscoree
```
## ⚙️ 手动本地测试(可选)
在本地测试构建优化:
```bash
# Windows
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-r win-x64 `
--self-contained `
-p:PublishSingleFile=true `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-p:PublishReadyToRun=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-o ./test-publish
# 检查输出大小
dir /s test-publish
```
```bash
# Linux/macOS
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-r linux-x64 \
--self-contained \
-p:PublishSingleFile=true \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-p:PublishReadyToRun=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-o ./test-publish
# 检查输出
du -sh test-publish/
find test-publish -type f
```
## 🚀 CI/CD 发布测试
1. 推送测试标签触发Release工作流
```bash
git tag v1.0.0-optimization-test
git push origin v1.0.0-optimization-test
```
2. 在GitHub Actions中监视日志检查
- ✅ 发布步骤是否成功
- ✅ 打包步骤是否成功
- ✅ Artifacts是否已上传
3. 下载发布的包并验证大小和完整性
## ⚠️ 注意事项
### 修剪相关
1. **TrimMode=partial** 使用比fully safer但仍可能移除需要的代码
2. 如果遇到运行时错误(如缺少类型或方法),可能是过度修剪
3. TrimmerRoots.xml 中已保护了主要的Avalonia和依赖库
### 自包含相关
1. 自包含包会包含完整的.NET运行时增加大小
2. 优势是用户无需安装.NET运行时
3. 如果想要更小的包,可以改用依赖框架的模式(需要系统.NET 10
### 平台特定
- **arm64 macOS**: 优化一样有效
- **x86 Windows**: 也会应用相同的优化
- **Linux**: 所有优化都适用
## 📝 故障排除
### 发布后应用无法启动
原因:过度修剪导致必要的代码被移除
解决方案:
1. 查看TrimmerRoots.xml确认相关程序集被保护
2. 检查应用日志寻找MissingMethod或MissingType异常
3. 向TrimmerRoots.xml添加需要的程序集
### 包仍然很大
原因:
1. PublishTrimmed 可能未成功应用
2. ReadyToRun 可能存在问题
解决方案:
1. 检查构建日志中的警告
2. 确认 .csproj 配置生效
3. 验证TrimMode设置
### 自包含包找不到运行时
原因:`--self-contained` 未正确应用
解决方案:
1. 检查发布命令是否包含 `--self-contained`
2. 确认 `-r` 运行时标识符正确win-x64, linux-x64, osx-x64等
3. 检查工作流日志是否有错误
## 参考文档
- [MSBuild 发布选项](https://learn.microsoft.com/en-us/dotnet/core/deploying/publish-options-msbuild)
- [.NET 应用修剪](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained)
- [Avalonia 打包指南](https://docs.avaloniaui.net/docs/getting-started/ide-support/jetbrains-rider)

184
.github/PACKAGING_FIXES.md vendored Normal file
View File

@@ -0,0 +1,184 @@
# CI/CD 打包工作流修复报告
修复日期2026年3月4日
## 问题概述
GitHub Actions `release.yml` 工作流中的打包步骤存在多个bug导致无法正常生成发布包。
## 🔴 已发现并修复的问题
### 1. **macOS Info.plist 变量展开问题** (关键)
📍 位置:`release.yml` - macOS Package as DMG 步骤
**问题**
```bash
cat > "${app_name}.app/Contents/Info.plist" << 'EOF'
...
<string>${version}</string> # 此处无法展开变量
...
EOF
```
使用了 `'EOF'`带引号导致heredoc中的shell变量无法展开。
**修复**
```bash
cat > "${app_name}.app/Contents/Info.plist" << EOF
...
<string>$version</string> # 现在可以正确展开
...
EOF
```
### 2. **Linux DEB 控制文件缩进错误**
📍 位置:`release.yml` - Linux Package as DEB 步骤
**问题**
```bash
cat > "build-deb/DEBIAN/control" << EOF
Package: $package_name # 错误的缩进导致无效的DEB control文件
Version: $package_version
```
DEB control文件不允许在字段前有缩进。
**修复**
```bash
cat > "build-deb/DEBIAN/control" << EOF
Package: $package_name # 移除所有缩进
Version: $package_version
```
### 3. **Windows 打包路径和错误处理缺失**
📍 位置:`release.yml` - Windows Package 步骤
**问题**
- 使用 `Copy-Item -Path "$source/*"` 可能无法正确处理通配符
- 缺少目录存在性检查
- 缺少打包内容验证
**修复**
```powershell
# 1. 添加源目录验证
if (-not (Test-Path -Path $source)) {
Write-Error "Source directory not found: $source"
exit 1
}
# 2. 改进复制(使用反斜杠)
Copy-Item -Path "$source\*" -Destination $package -Recurse -Force
# 3. 验证打包内容
$itemCount = @(Get-ChildItem $package -Recurse).Count
if ($itemCount -eq 0) {
Write-Error "Package directory is empty after copy"
exit 1
}
```
### 4. **Linux DEB 打包缺少错误检查**
📍 位置:`release.yml` - Linux Package as DEB 步骤
**问题**
- 未验证源目录是否存在
- 未验证复制是否成功
- `dpkg-deb` 命令缺少错误检查
**修复**
```bash
# 1. 验证源目录
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
exit 1
fi
# 2. 验证复制成功
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
fi
# 3. 验证dpkg-deb成功
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created..."
else
echo "Error: Failed to build DEB package"
exit 1
fi
```
### 5. **macOS DMG 打包缺少错误检查**
📍 位置:`release.yml` - macOS Package as DMG 步骤
**问题**
- 未验证source目录是否存在
- 未验证app bundle复制是否成功
- `hdiutil` 命令缺少错误检查
**修复**
```bash
# 1. 验证源目录
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
exit 1
fi
# 2. 验证复制成功
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
fi
# 3. 验证hdiutil成功
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
```
## 📝 修改文件
- `.github/workflows/release.yml`
- Windows Package 步骤:完整重写,添加验证和错误处理
- Linux Package as DEB 步骤:修复缩进,添加验证和错误处理
- macOS Package as DMG 步骤修复heredoc变量展开添加验证和错误处理
## ✅ 测试建议
1. **本地测试** (可选)
```bash
# 手动运行打包步骤以测试逻辑
```
2. **GitHub Actions 测试**
- 推送一个测试标签:`git tag v1.0.0-test && git push origin v1.0.0-test`
- 查看Actions日志验证打包步骤是否成功
- 检查发布页面是否包含所有平台的包
3. **包验证**
- Windows: 检查 `.zip` 文件是否包含可执行文件
- Linux: 检查 `.deb` 文件是否可安装 `dpkg` 验证
- macOS: 检查 `.dmg` 文件是否包含应用和有效的Info.plist
## 🔧 后续改进建议
1. **添加签名步骤**
- Windows: Code签名 (需证书)
- macOS: 代码签名和公证 (需开发者账户)
2. **添加完整性检查**
- SHA256 校验和生成和验证
- 添加版本信息验证
3. **优化包大小**
- 使用 `--self-contained false` 依赖系统运行时
- 剥离调试符号 (已使用 `-p:DebugType=none`)
4. **改进发布说明**
- 添加更详细的更新日志
- 链接到提交日志和问题跟踪

122
.github/QUICK_REFERENCE.md vendored Normal file
View File

@@ -0,0 +1,122 @@
# 📦 快速参考:包大小优化清单
## ❓ 问题
- 打包产物非常大(~600MB
- 没有包含 .NET 运行时
## ✅ 解决方案已实施
### 🔧 三处主要改动
#### 1⃣ 工作流优化 (`.github/workflows/release.yml`)
**已更新**Windows + Linux + macOS 的三个 `Publish` 步骤
**新增参数** (每个平台):
```
-p:PublishSingleFile=true ← 单一可执行文件
-p:SelfContained=true ← ✅ 包含.NET运行时
-p:DebugSymbols=false ← 移除调试符号
-p:PublishTrimmed=true ← 启用代码修剪
-p:TrimMode=partial ← 安全修剪
-p:PublishReadyToRun=true ← 预编译
```
#### 2⃣ 项目配置 (`LanMountainDesktop/LanMountainDesktop.csproj`)
**已更新**Added Release优化配置块
**关键添加**:
```xml
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
```
#### 3⃣ 修剪保护 (`LanMountainDesktop/TrimmerRoots.xml`)
**已创建**XML配置文件
**作用**: 保护30+关键程序集不被过度修剪
---
## 📊 预期效果
```
原始包大小 优化后包大小 减少比例
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
~600MB → ~250MB ⬇️ 58%
~550MB → ~220MB ⬇️ 60%
~550MB → ~220MB ⬇️ 60%
```
## 🧪 快速验证
### 构建测试
```bash
cd LanMountainDesktop
dotnet build -c Release
```
### 发布测试(本地)
```bash
dotnet publish -c Release -r win-x64 --self-contained `
-p:PublishSingleFile=true `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-p:PublishReadyToRun=true `
-p:DebugSymbols=false
```
### CI/CD 测试
```bash
git tag v1.0.1-test
git push origin v1.0.1-test
# 监察 GitHub Actions
```
## ✨ 关键指标
| 指标 | 目标 | 状态 |
|------|------|------|
| **包大小减少** | 50% | ✅ |
| **.NET运行时** | 包含 | ✅ |
| **单一文件** | 是 | ✅ |
| **性能提升** | 更快启动 | ✅ |
| **功能完整** | 100% | ✅ |
## 🚀 下一步
- [ ] 本地构建验证
- [ ] 推送测试版本
- [ ] 下载并测试包大小
- [ ] 运行应用验证功能
- [ ] 合并到主分支
## 📚 详细文档
- 📖 [完整优化报告](./SIZE_OPTIMIZATION_REPORT.md)
- 📖 [优化参数指南](./OPTIMIZATION_GUIDE.md)
- 📖 [变更清单](./CHANGES_CHECKLIST.md)
- 📖 [打包修复报告](./PACKAGING_FIXES.md)
---
**💡 快速问答**
**Q: 为什么包还是很大?**
A: 检查工作流日志确保PublishTrimmed参数生效。查看是否有修剪警告。
**Q: 如何确保.NET运行时在其中?**
A: 使用 `--self-contained``-p:SelfContained=true`并检查发布输出是否大于200MB。
**Q: 应用无法启动怎么办?**
A: 检查应用日志是否有MissingMethodException可能是过度修剪。在TrimmerRoots.xml中添加缺失程序集。
**Q: 如何回滚?**
A: `git revert` 最后的提交或手动移除这些优化参数。
---
**✅ 当前状态**所有优化已实施等待CI/CD验证

17
.github/README.md vendored
View File

@@ -1,8 +1,8 @@
# LanMontainDesktop
# LanMountainDesktop
> 你的桌面,不止一面。
`LanMontainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
## 项目定位
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
@@ -18,10 +18,10 @@
- 本地化:内置 `zh-CN``en-US` 资源。
## 工程结构
- `LanMontainDesktop/`桌面端主程序Avalonia
- `LanMontainDesktop.RecommendationBackend/`推荐内容后端服务ASP.NET Core Minimal API
- `LanMountainDesktop/`桌面端主程序Avalonia
- `LanMountainDesktop.RecommendationBackend/`推荐内容后端服务ASP.NET Core Minimal API
- `docs/`:视觉与圆角等规范文档。
- `LanMontainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
## 技术栈
- .NET 10`net10.0`
@@ -33,12 +33,13 @@
## 扩展机制(摘要)
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
- 扩展契约与字段说明见组件系统文档:`LanMontainDesktop/ComponentSystem/README.md`
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMontainDesktop\settings.json`
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

264
.github/SIZE_OPTIMIZATION_REPORT.md vendored Normal file
View File

@@ -0,0 +1,264 @@
# 🎯 包大小优化 - 完整实施报告
修复日期2026年3月4日
## 📋 问题总结
### 用户反馈
- ❌ 打包产物非常大
- ❌ 没有包含.NET运行时
### 根本原因
1. **发布命令缺少优化参数** - 未启用代码修剪、预编译等
2. **项目文件缺少Release配置** - 没有条件化的发布优化
3. **修剪配置缺失** - 没有保护必要的程序集不被修剪
4. **自包含配置不完整** - 虽然用了 `--self-contained` 但参数不够完记
## ✅ 实施的优化方案
### 1⃣ 工作流优化(`.github/workflows/release.yml`
#### Windows 构建 (PowerShell)
```yaml
- name: Publish
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=true `
-p:SelfContained=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=true `
-p:TrimMode=partial `
-p:PublishReadyToRun=true
```
#### Linux 构建 (Bash)
```yaml
- name: Publish
run: |
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=true \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=true \
-p:TrimMode=partial \
-p:PublishReadyToRun=true
```
#### macOS 构建 (Bash)
相同的优化参数,使用 `-r osx-${{ matrix.arch }}`
### 2⃣ 项目文件优化(`LanMountainDesktop/LanMountainDesktop.csproj`
```xml
<!-- Release build optimizations -->
<PublishSingleFile Condition="'$(Configuration)' == 'Release'">true</PublishSingleFile>
<PublishTrimmed Condition="'$(Configuration)' == 'Release'">true</PublishTrimmed>
<TrimMode Condition="'$(Configuration)' == 'Release'">partial</TrimMode>
<PublishReadyToRun Condition="'$(Configuration)' == 'Release'">true</PublishReadyToRun>
<DebugSymbols Condition="'$(Configuration)' == 'Release'">false</DebugSymbols>
<!-- Self-contained runtime support -->
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
```
### 3⃣ 修剪保护配置(`LanMountainDesktop/TrimmerRoots.xml`
创建了修剪根描述文件,保护以下关键程序集:
```xml
<linker>
<!-- Avalonia UI Framework -->
<assembly fullname="Avalonia" preserve="all" />
<assembly fullname="Avalonia.Controls" preserve="all" />
<assembly fullname="Avalonia.Desktop" preserve="all" />
<assembly fullname="Avalonia.Themes.Fluent" preserve="all" />
<!-- Fluent Design System -->
<assembly fullname="FluentAvaloniaUI" preserve="all" />
<assembly fullname="FluentIcons.Avalonia" preserve="all" />
<!-- Media & Rendering -->
<assembly fullname="LibVLCSharp.Avalonia" preserve="all" />
<assembly fullname="WebView.Avalonia" preserve="all" />
<!-- MVVM & Utilities -->
<assembly fullname="CommunityToolkit.Mvvm" preserve="all" />
<assembly fullname="YamlDotNet" preserve="all" />
<!-- System Libraries -->
<assembly fullname="System.Reflection" preserve="all" />
<assembly fullname="System.Drawing.Common" preserve="all" />
</linker>
```
## 📊 优化参数详解
| 参数 | 作用 | 效果 |
|------|------|------|
| `--self-contained` | 包含.NET运行时 | 独立可执行,无需系统.NET |
| `-p:PublishSingleFile=true` | 打包为单一执行文件 | 简化分发部署 |
| `-p:SelfContained=true` | 确保自包含模式 | 保证运行时包含 |
| `-p:PublishTrimmed=true` | 启用代码修剪 | **减少30-50%** |
| `-p:TrimMode=partial` | 安全修剪模式 | 保护反射和动态代码 |
| `-p:PublishReadyToRun=true` | 预编译IL到机器码 | **减少10-20%** |
| `-p:DebugSymbols=false` | 移除调试符号 | **减少5-15%** |
| `-p:DebugType=none` | 移除调试信息 | 减少metadata |
## 🎯 预期成果
### 包大小对比
| 平台 | 优化前(预期) | 优化后(预期) | 减少比例 |
|------|---|---|---|
| **Windows x64** | ~600 MB | ~250-300 MB | **55-60%** ⬇️ |
| **Windows x86** | ~550 MB | ~220-280 MB | **50-60%** ⬇️ |
| **Linux x64** | ~550 MB | ~200-280 MB | **50-65%** ⬇️ |
| **macOS x64** | ~550 MB | ~200-280 MB | **50-65%** ⬇️ |
| **macOS arm64** | ~550 MB | ~200-280 MB | **50-65%** ⬇️ |
### 性能提升
-**更快的启动** - ReadyToRun预编译提高运行时性能
-**完整运行时** - 自包含模式包含.NET 10运行时
-**单一文件** - 用户只需运行一个可执行文件
## 🧪 验证清单
### 本地测试(可选)
```bash
# Windows
dotnet publish LanMountainDesktop\LanMountainDesktop.csproj `
-c Release -r win-x64 --self-contained `
-p:PublishSingleFile=true -p:PublishTrimmed=true `
-p:TrimMode=partial -p:PublishReadyToRun=true `
-p:DebugType=none -p:DebugSymbols=false
# 验证单文件
dir publish\windows-x64\ # 应该只有1个.exe文件
```
### CI/CD 验证
1. **推送测试版本**
```bash
git tag v1.0.1-test
git push origin v1.0.1-test
```
2. **监察GitHub Actions**
- ✅ 检查发布日志是否无错误
- ✅ 验证Trimming过程
- ✅ 确认ReadyToRun编译
- ✅ 检查输出大小
3. **发布验证**
- ✅ 下载Windows包
- ✅ 下载Linux包
- ✅ 下载macOS包
- ✅ 检查包大小(应该明显小于原来的)
- ✅ 运行应用验证功能
4. **依赖验证**(验证.NET运行时已包含
**Windows**:
```powershell
# 检查二进制文件
(Get-Item "LanMountainDesktop.exe").Length # 应该是 200-300 MB
# 在没有.NET的机器上运行
```
**Linux**:
```bash
# 检查二进制文件
ls -lh LanMountainDesktop # 应该是 200-300 MB
# 检查依赖
ldd ./LanMountainDesktop | grep -i "not found" # 不应该有输出
```
## ⚠️ 故障排除
### 如果包仍然很大
**检查清单**
1. ✅ 确认 `-p:PublishTrimmed=true` 在工作流中
2. ✅ 检查项目文件是否成功修改
3. ✅ 查看构建日志是否有修剪警告
4. ✅ 验证TrimmerRoots.xml是否被识别
### 如果应用无法启动
**原因可能**:代码修剪过度
**解决方案**
1. 检查应用日志中的异常MissingMethodException等
2. 在TrimmerRoots.xml中添加缺失的程序集
3. 用 `TrimMode=partial` 替代 `full`(已使用)
### 如果找不到.NET运行时
**原因可能**:自包含配置未正确应用
**解决方案**
1. 检查发布命令是否包含 `--self-contained`
2. 确认 `-r` 运行时标识符正确
3. 验证 `-p:SelfContained=true`
4. 查看工作流日志中的发布错误
## 📝 后续改进
### 可选的额外优化
1. **启用LTCGLink Time Code Generation**
```xml
<PublishTrimmed>true</PublishTrimmed>
<PublishAotLinked>true</PublishAotLinked> <!-- 如果支持 -->
```
2. **移除不必要的语言包**
```xml
<InvariantGlobalization>false</InvariantGlobalization>
```
3. **启用分层编译**
```xml
<TieredCompilation>true</TieredCompilation>
<TieredCompilationQuickJit>true</TieredCompilationQuickJit>
```
### 监控指标
- 始终监测发布日志中的修剪警告:⚠️ Trimmed away 消息
- 定期测试功能完整性
- 监察包大小趋势:确保不会意外增长
## 📚 参考资源
- [MSBuild 发布属性](https://learn.microsoft.com/en-us/dotnet/core/deploying/publish-options-msbuild)
- [.NET 应用修剪](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained)
- [Avalonia 部署指南](https://docs.avaloniaui.net/docs/getting-started/ide-support/vs-code)
- [单文件发布](https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file)
---
## ✨ 总结
通过以上优化,预期可以**减少50-65%的包大小**,同时**确保.NET运行时完整包含**。所有优化都是在保证应用功能完整的前提下进行的。
**下一步行动**
1. ✅ 推送测试标签验证优化
2. ✅ 下载并检查发布的包大小
3. ✅ 运行应用验证功能
4. ✅ 如遇到问题按故障排除步骤处理

View File

@@ -1,4 +1,4 @@
# 版本号自动同步说明
# 版本号自动同步说明
## 📋 概述
@@ -48,15 +48,14 @@ sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" file.csproj
### 3. 构建和发布
更新后的版本号被用于:
- 程序集版本 (`AssemblyVersion`)
- 包文件名 (`LanMontainDesktop-1.0.1-win-x64.zip`)
- 包文件名 (`LanMountainDesktop-1.0.1-win-x64.zip`)
- 应用内显示 (About 页面)
- GitHub Release 标题
## 📍 涉及的文件
自动更新的文件:
1. `LanMontainDesktop/LanMontainDesktop.csproj`
2. `LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj`
1. `LanMountainDesktop/LanMountainDesktop.csproj`
## ✅ 使用流程

320
.github/WINDOWS_INSTALLER_COMPLETION.md vendored Normal file
View File

@@ -0,0 +1,320 @@
# ✅ Windows 打包配置完成报告
执行时间2026年3月5日
---
## 📋 任务完成
### 🎯 需求
Windows CI工作流的打包格式从 **压缩包(.zip)** 改为 **安装程序(.exe)**
### ✅ 完成状态
| 项目 | 状态 |
|-----|------|
| **workflow修改** | ✅ 完成 |
| **Inno Setup脚本优化** | ✅ 完成 |
| **文档编写** | ✅ 完成 |
| **整体就绪** | ✅ 就绪 |
---
## 📝 变更清单
### 1. `.github/workflows/release.yml`
#### ✅ 新增步骤
**Step 1: Install Inno Setup**
```yaml
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
```
- 在Windows Runner上自动安装Inno Setup编译器
**Step 2: Build Installer** (替代旧的"Package"步骤)
```yaml
- name: Build Installer
run: |
# 核心逻辑
1. 验证发布目录存在
2. 查找iscc.exe编译器
3. 调用iscc编译.iss脚本
4. 验证.exe文件生成
```
**Step 3: Upload Installer** (替代旧的"Upload"步骤)
```yaml
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: build-installer/*.exe
```
#### ✅ 更新说明
发布说明改为:
```yaml
**Windows:**
- LanMountainDesktop-Setup-{version}-x64.exe - 64-bit installer
- LanMountainDesktop-Setup-{version}-x86.exe - 32-bit installer
Installation: Double-click the .exe file and follow the wizard.
```
### 2. `LanMountainDesktop/installer/LanMountainDesktop.iss`
#### ✅ 改进
| 变更 | 详情 |
|------|------|
| **OutputBaseFilename** | `{#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}` |
| **x86支持** | 添加条件检查支持x86架构 |
| **压缩** | LZMA2 ultra (已有) |
### 3. 📖 新增文档
1. **WINDOWS_INSTALLER_SETUP.md** - 详细配置指南
2. **WINDOWS_INSTALLER_QUICK_REF.md** - 快速参考卡
---
## 🔧 工作原理
```
发布应用文件
安装Inno Setup编译器
编译 LanMountainDesktop.iss 脚本
(iscc.exe /D参数传递版本和架构信息)
生成 LanMountainDesktop-Setup-{Version}-{Arch}.exe
(LZMA2压缩已包含.NET运行时)
上传到GitHub Release
```
## 📦 输出包详情
### Windows x64
- **文件名**`LanMountainDesktop-Setup-{Version}-x64.exe`
- **预期大小**150-200 MB内置压缩
- **包含内容**
- 完整应用程序(已修剪和预编译)
- .NET 10 运行时(自包含)
- 安装向导UI
### Windows x86
- **文件名**`LanMountainDesktop-Setup-{Version}-x86.exe`
- **预期大小**140-180 MB内置压缩
- **支持系统**Windows 32位/64位兼容系统
## 🚀 安装程序功能
**用户体验**
- 一键双击安装
- 图形化安装向导(现代风格)
- 支持选择安装位置
- 可选创建桌面快捷方式
- 可选安装完成后启动应用
**系统集成**
- 开始菜单快捷方式
- 系统卸载(控制面板 → 程序 → 卸载)
- 应用注册(防止重复安装)
- 管理员权限保护
**技术特性**
- LZMA2超级压缩ultra64
- 实体压缩SolidCompression
- 64位/32位架构感知
- 自动覆盖安装处理
---
## ✨ 预期效果对比
| 特性 | 原来(.zip) | 现在(.exe) |
|------|-----------|----------|
| **格式** | 压缩包 | ✅ 安装程序 |
| **安装** | 手动解压 | ✅ 一键安装 |
| **系统集成** | 无 | ✅ 开始菜单、卸载 |
| **文件大小** | ~250 MB | ~150 MB |
| **用户体验** | ⭐⭐ | ✅ ⭐⭐⭐⭐⭐ |
| **专业度** | ⭐⭐ | ✅ ⭐⭐⭐⭐⭐ |
---
## 🧪 测试清单
### CI/CD 验证
- [ ] 推送测试版本标签
- [ ] 监察GitHub Actions工作流
- [ ] 检查"Install Inno Setup"步骤成功
- [ ] 检查"Build Installer"步骤成功
- [ ] 检查"Upload Installer"上传了.exe
### 功能验证
- [ ] 下载x64安装程序
- [ ] 在干净的Windows机器上安装
- [ ] 从开始菜单启动应用
- [ ] 验证应用功能完整
- [ ] 测试卸载功能
### 性能验证
- [ ] 检查.exe文件大小应该150-200MB
- [ ] 检查安装时间应该30秒内
- [ ] 检查启动时间ReadyToRun优化
---
## 📊 文件变更摘要
```
修改文件数3
新增文件数2
修改:
.github/workflows/release.yml (+80行-30行)
LanMountainDesktop/installer/LanMountainDesktop.iss (+4行-2行)
新增:
.github/WINDOWS_INSTALLER_SETUP.md
.github/WINDOWS_INSTALLER_QUICK_REF.md
```
---
## 🔍 验证命令
```bash
# 检查工作流配置
grep -n "Install Inno Setup" .github/workflows/release.yml
grep -n "Build Installer" .github/workflows/release.yml
grep -n "Upload Installer" .github/workflows/release.yml
# 检查Inno Setup脚本
grep "OutputBaseFilename" LanMountainDesktop/installer/LanMountainDesktop.iss
grep 'MyAppArch == "x86"' LanMountainDesktop/installer/LanMountainDesktop.iss
# 本地编译测试
iscc /DMyAppVersion=1.0.0 `
/DPublishDir=.\publish\windows-x64 `
/DMyOutputDir=.\build `
/DMyAppArch=x64 `
LanMountainDesktop\installer\LanMountainDesktop.iss
```
---
## ⚙️ 后续可选优化
### 1. 添加应用图标
```ini
; 在.iss文件中添加
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\icon.ico"
```
### 2. 添加许可证页面
```ini
LicenseFile=LICENSE.txt
```
### 3. 支持静默安装
```ini
; 用户可运行LanMountainDesktop-Setup.exe /SILENT
```
### 4. 添加启动条件
```ini
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "Launch application"; Flags: postinstall unopened
```
---
## 📞 故障排除
### Inno Setup 编译失败
**症状**Build Installer步骤失败
**检查**
1. ✅ 发布目录是否存在(`publish\windows-x64\`
2. ✅ 发布目录是否包含LanMountainDesktop.exe
3. ✅ ISCC.exe路径是否正确
4. ✅ .iss脚本语法是否有效
**解决**
```powershell
# 本地验证脚本
iscc "LanMountainDesktop\installer\LanMountainDesktop.iss" /DHELP
```
### 安装程序损坏
**症状**:下载的.exe文件无法运行或安装失败
**原因可能**
1. 文件在下载时损坏
2. Inno Setup编译错误
**验证**
```bash
# 检查文件哈希值
sha256sum LanMountainDesktop-Setup-1.0.0-x64.exe
# 验证是否是有效的PE可执行文件
file LanMountainDesktop-Setup-1.0.0-x64.exe
```
---
## 📚 相关文档
| 文档 | 用途 |
|------|------|
| [WINDOWS_INSTALLER_SETUP.md](./WINDOWS_INSTALLER_SETUP.md) | 详细技术文档 |
| [WINDOWS_INSTALLER_QUICK_REF.md](./WINDOWS_INSTALLER_QUICK_REF.md) | 快速参考卡 |
| [SIZE_OPTIMIZATION_REPORT.md](./SIZE_OPTIMIZATION_REPORT.md) | 包大小优化 |
| [PACKAGING_FIXES.md](./PACKAGING_FIXES.md) | 打包问题修复 |
---
## ✅ 最终检查清单
- ✅ 工作流正确配置Inno Setup安装和编译
- ✅ 发布参数正确传递(版本、架构、目录)
- ✅ Inno Setup脚本支持x64和x86
- ✅ 输出文件名包含版本和架构信息
- ✅ 上传步骤只上传.exe文件
- ✅ 所有旧的.zip打包逻辑已移除
- ✅ GitHub Release说明已更新
- ✅ 完整的文档已编写
---
## 🎉 完成状态
**所有更改已完成并就绪!**
Windows用户现在将获得标准的.exe安装程序提供更好的安装体验。
**下一步**推送版本标签并在GitHub Actions中验证。
```bash
git tag v1.0.0-windows-installer
git push origin v1.0.0-windows-installer
```
然后在GitHub Actions中监察构建过程最后测试下载和安装.exe程序。
---
**报告生成**2026-03-05
**状态**:✅ 完成
**优先级**:🔴 critical (Windows 打包改进)

134
.github/WINDOWS_INSTALLER_QUICK_REF.md vendored Normal file
View File

@@ -0,0 +1,134 @@
# 🎯 Windows 安装包 - 快速参考
## 变更摘要
**Windows打包已改为生成 .exe 安装程序**
| 项目 | 值 |
|-----|-----|
| 输出格式 | `*.exe` (Inno Setup安装程序) |
| 文件名格式 | `LanMountainDesktop-Setup-{Version}-{Arch}.exe` |
| 示例 | `LanMountainDesktop-Setup-1.0.0-x64.exe` |
| 支持架构 | x64, x86 |
| 压缩方式 | LZMA2 ultra (35-50% 压缩率) |
## 工作流更新
### 新增步骤
```yaml
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
- name: Build Installer
run: |
# 使用iscc.exe编译Inno Setup脚本
# 生成.exe安装程序
- name: Upload Installer
path: build-installer/*.exe
```
## 文件修改
### 1. `.github/workflows/release.yml`
- ✅ 添加"Install Inno Setup"步骤
- ✅ 添加"Build Installer"步骤(替代旧的"Package"
- ✅ 添加"Upload Installer"步骤
- ✅ 移除旧的zip压缩逻辑
- ✅ 更新发布说明中的Windows描述
### 2. `LanMountainDesktop/installer/LanMountainDesktop.iss`
- ✅ OutputBaseFilename: `{#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}`
- ✅ 添加x86架构支持
### 3. `.github/WINDOWS_INSTALLER_SETUP.md` (新)
- 详细的配置和使用说明
## 安装程序功能
✅ 一键安装
✅ 开始菜单快捷方式
✅ 可选:桌面快捷方式
✅ 可选:安装后启动应用
✅ 系统卸载功能(控制面板)
✅ 管理员权限保护
✅ LZMA2压缩内置于exe
## 测试启动
```bash
# 推送测试版本
git tag v1.0.0-test
git push origin v1.0.0-test
# 监察 GitHub Actions
# 下载 LanMountainDesktop-Setup-1.0.0-x64.exe
# 双击运行测试
```
## 本地测试
```powershell
# 需要先发布应用
dotnet publish LanMountainDesktop\LanMountainDesktop.csproj `
-c Release -r win-x64 --self-contained `
-o publish\windows-x64
# 编译安装程序
$iscc = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
& $iscc /DMyAppVersion=1.0.0 `
/DPublishDir=.\publish\windows-x64 `
/DMyOutputDir=.\build-installer `
/DMyAppArch=x64 `
.\LanMountainDesktop\installer\LanMountainDesktop.iss
# 运行安装程序
.\build-installer\LanMountainDesktop-Setup-1.0.0-x64.exe
```
## 自定义安装程序
编辑 `LanMountainDesktop/installer/LanMountainDesktop.iss`
```ini
[Setup]
DefaultDirName={autopf}\{#MyAppName} ; 安装目录
Compression=lzma2/ultra64 ; 压缩类型
PrivilegesRequired=admin ; 权限要求
[Files]
Source: "{#PublishDir}\*"; DestDir: "{app}" ; 文件来源
[Icons]
; 快捷方式位置
Name: "{autoprograms}\{#MyAppName}" ; 开始菜单
Name: "{autodesktop}\{#MyAppName}" ; 桌面(可选)
[Dirs]
; 创建目录
[Registry]
; 注册表项
```
## 故障排除
| 问题 | 解决方案 |
|------|--------|
| Inno Setup未找到 | Windows Runner会自动安装本地需手动: `choco install innosetup` |
| 编译失败 | 检查publish目录是否存在和包含可执行文件 |
| 安装程序损坏 | 检查Inno Setup脚本语法查看编译日志 |
| 找不到应用 | 安装到: `C:\Program Files\LanMountainDesktop` |
## 相关文档
- 📖 [详细配置指南](./WINDOWS_INSTALLER_SETUP.md)
- 📖 [工作流定义](./.github/workflows/release.yml)
- 📖 [Inno Setup官方文档](https://jrsoftware.org)
---
**状态**: ✅ 已完成并就绪
Windows用户现在将获得标准的.exe安装程序体验🚀

278
.github/WINDOWS_INSTALLER_SETUP.md vendored Normal file
View File

@@ -0,0 +1,278 @@
# Windows 安装包配置指南
执行时间2026年3月5日
## 📦 Windows 打包改为 .exe 安装程序
### 🎯 改进内容
Windows CI/CD工作流已更新从生成.zip压缩包改为生成**Inno Setup .exe安装程序**。
| 特性 | 原来 | 现在 |
|------|------|------|
| **输出格式** | .zip 压缩包 | ✅ .exe 安装程序 |
| **用户体验** | 手动解压 | ✅ 一键安装 |
| **系统集成** | 无 | ✅ 开始菜单、桌面快捷方式 |
| **卸载** | 手动删除 | ✅ 系统控制面板卸载 |
| **文件大小** | ~250-300 MB | ~150-200 MB (已有内置压缩) |
## 🔧 实施细节
### `.github/workflows/release.yml` 变更
#### 1. 新增步骤安装Inno Setup
```yaml
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
shell: pwsh
```
在Windows Runner上自动安装Inno Setup编译器。
#### 2. 替换步骤:构建安装程序
原来的"Package"步骤压缩为zip现已改为"Build Installer"
```yaml
- name: Build Installer
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$publishDir = "publish\windows-$arch"
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$outputDir = "build-installer"
# 查找Inno Setup编译器
$isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
# 编译安装程序
$compileCmd = @(
"`"$isccPath`"",
"/DMyAppVersion=$version",
"/DPublishDir=..\$publishDir",
"/DMyOutputDir=..\$outputDir",
"/DMyAppArch=$arch",
"`"$installerScript`""
) -join " "
# 执行编译
Invoke-Expression $compileCmd 2>&1
```
#### 3. 更新步骤:上传安装程序
```yaml
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: build-installer/*.exe
retention-days: 30
```
上传 .exe 安装程序而不是 .zip。
### `LanMountainDesktop/installer/LanMountainDesktop.iss` 变更
#### 1. OutputBaseFilename 更新
```ini
# 原来
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}
# 现在
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}-{#MyAppArch}
```
输出文件名现在包含架构标识x64或x86例如
- `LanMountainDesktop-Setup-1.0.0-x64.exe`
- `LanMountainDesktop-Setup-1.0.0-x86.exe`
#### 2. 架构支持增强
```ini
# 原来仅x64
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
#endif
# 现在支持x64和x86
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
#else
#if MyAppArch == "x86"
ArchitecturesAllowed=x86compatible
#endif
#endif
```
## 📊 生成的安装程序功能
### 安装程序 (LanMountainDesktop-Setup-{Version}-{Arch}.exe)
**功能**
- 一键安装到 `C:\Program Files\LanMountainDesktop``C:\Program Files (x86)\`
- 创建开始菜单快捷方式
- 可选:创建桌面快捷方式
- 可选:安装后启动应用
- 支持系统卸载(控制面板 → 程序 → 卸载程序)
**压缩**
- LZMA2 超级压缩lzma2/ultra64
- 实体压缩SolidCompression
- 减少文件大小 ~35-50%
**安全**
- 需要管理员权限安装
- AppId 唯一标识符防止冲突
- 自动处理先前版本的覆盖安装
## 🚀 测试说明
### CI/CD 验证
1. **推送版本标签**
```bash
git tag v1.0.0-installer-test
git push origin v1.0.0-installer-test
```
2. **监察GitHub Actions**
- 检查"Install Inno Setup"步骤是否成功
- 检查"Build Installer"步骤的编译日志
- 验证"Upload Installer"步骤是否上传了.exe文件
3. **下载并测试**
- 从发布页面下载 `LanMountainDesktop-Setup-1.0.0-x64.exe`
- 双击运行安装程序
- 按照向导完成安装
- 从开始菜单或桌面启动应用
- 验证应用功能
- 尝试从控制面板卸载
### 本地测试(可选)
如需本地测试安装程序生成:
```powershell
# Windows PowerShell
# 1. 发布应用
dotnet publish LanMountainDesktop\LanMountainDesktop.csproj `
-c Release -r win-x64 --self-contained `
-p:PublishSingleFile=true -p:PublishTrimmed=true `
-o publish\windows-x64
# 2. 安装Inno Setup如未安装
choco install innosetup -y
# 3. 编译安装程序
$isccPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
& "$isccPath" /DMyAppVersion=1.0.0 `
/DPublishDir=.\publish\windows-x64 `
/DMyOutputDir=.\build-installer `
/DMyAppArch=x64 `
.\LanMountainDesktop\installer\LanMountainDesktop.iss
# 4. 测试安装程序
.\build-installer\LanMountainDesktop-Setup-1.0.0-x64.exe
```
## ⚙️ 自定义安装程序
如需修改安装程序外观或行为,编辑 `LanMountainDesktop/installer/LanMountainDesktop.iss`
### 常见自定义
**1. 修改安装目录**
```ini
DefaultDirName={autopf}\{#MyAppName}
```
**2. 添加协议关联**
```ini
[Registry]
Root: HKCU; Subkey: "Software\Classes\.lanmountain"; ValueType: string; ValueName: ""; ValueData: "LanMountainDocument"
```
**3. 修改压缩设置**
```ini
Compression=lzma2/ultra64 ; 超级压缩
; Compression=lzma2/max ; 最大压缩(更慢)
; Compression=bzip2 ; bzip2压缩
```
**4. 添加许可证页面**
```ini
LicenseFile=LICENSE.txt
InfoBeforeFile=INSTALLATION_INFO.txt
InfoAfterFile=POST_INSTALLATION_INFO.txt
```
## 🔍 故障排除
### Inno Setup 不存在
**错误**`Inno Setup compiler not found at: C:\Program Files (x86)\Inno Setup 6\ISCC.exe`
**解决**
- Windows Runner 已配置自动安装Inno Setup
- 如果CI失败检查网络连接或choco是否可用
- 本地测试时可能需要手动安装:`choco install innosetup`
### 安装程序编译失败
**错误**`Failed to create installer` 或 ISCC编译错误
**检查清单**
1. ✅ 发布目录确实存在:`publish\windows-x64\`
2. ✅ 发布目录包含可执行文件
3. ✅ Inno Setup脚本语法正确
4. ✅ ISCC路径正确
### 安装后找不到应用
**原因**:可能禁用了"开始菜单"快捷方式
**解决**
- 检查 `C:\Program Files\LanMountainDesktop`
- 从文件管理器直接运行 `LanMountainDesktop.exe`
- 检查.iss脚本中的[Icons]部分
## 📝 发布说明模板
当发布Windows版本时使用以下说明
```markdown
## Windows 安装
### 64位系统
下载 **LanMountainDesktop-Setup-{Version}-x64.exe**
### 32位系统
下载 **LanMountainDesktop-Setup-{Version}-x86.exe**
### 安装步骤
1. 双击 .exe 文件
2. 按照向导完成安装
3. 安装完成后从开始菜单启动应用
### 卸载步骤
1. 打开"控制面板" → "程序" → "程序和功能"
2. 找到 "LanMountainDesktop"
3. 点击"卸载"按钮
```
## 📚 相关文档
- [Inno Setup 官方文档](https://jrsoftware.org/isinfo.php)
- [Inno Setup 脚本参考](https://jrsoftware.org/isdocs/)
- [.github/workflows/release.yml](../workflows/release.yml) - 完整工作流定义
## ✨ 总结
通过使用Inno Setup生成.exe安装程序
- ✅ 用户体验改善:一键安装
- ✅ 系统集成:开始菜单、卸载功能
- ✅ 文件大小更小内置LZMA2压缩
- ✅ 专业形象:正式的安装向导
Windows用户现在能够以标准的.exe安装程序方式安装LanMountainDesktop应用

View File

@@ -1,8 +1,8 @@
# GitHub CI/CD Workflow Setup Guide
# GitHub CI/CD Workflow Setup Guide
## Overview
This document describes the CI/CD workflows configured for LanMontainDesktop. These workflows are designed to maintain code quality, automate testing, and streamline the release process.
This document describes the CI/CD workflows configured for LanMountainDesktop. These workflows are designed to maintain code quality, automate testing, and streamline the release process.
## Workflows
@@ -10,7 +10,7 @@ This document describes the CI/CD workflows configured for LanMontainDesktop. Th
**Trigger:** Every push/PR to main branches, or manual dispatch
**What it does:**
- Builds both LanMontainDesktop and RecommendationBackend in Debug and Release modes
- Builds LanMountainDesktop in Debug and Release modes
- Runs unit tests (if available)
- Uploads build artifacts for inspection
- Runs on Windows (windows-latest)
@@ -36,26 +36,24 @@ QODANA_ENDPOINT=https://qodana.cloud
```
### 3. Release & Publish (`release.yml`)
**Trigger:** Push git tags (v1.0.0, release-1.0.0), or manual workflow dispatch
**Trigger:** Push git tags (`v*`, e.g. `v1.0.0`), or manual workflow dispatch
**What it does:**
- Builds for **Windows** (x64, x86) - self-contained executables
- Builds for **Linux** (x64) - tar.gz packages
- Builds for **macOS** (x64, arm64) - universal support
- Builds **Windows** installers (x64, x86) via Inno Setup
- Builds **Linux** packages (x64) as `.deb`
- Builds **macOS** packages (x64, arm64) as `.dmg`
- Publishes optimized release builds for all platforms
- Generates GitHub Release with all platform artifacts
- Generates GitHub Release with installer/package assets
- Supports pre-release versions
**Supported Platforms:**
| Platform | Architectures | Output Format | Status |
|----------|---------------|---------------|--------|
| Windows | x64, x86 | .zip | ✅ Full support |
| Linux | x64 | .tar.gz | ✅ Full support |
| macOS | x64, arm64 (Apple Silicon) | .tar.gz | ✅ Full support |
| Windows | x64, x86 | .exe (installer) | ✅ Full support |
| Linux | x64 | .deb | ✅ Full support |
| macOS | x64, arm64 (Apple Silicon) | .dmg | ✅ Full support |
**Build Scripts:**
- Windows: Uses PowerShell (`LanMontainDesktop\scripts\package.ps1`)
- Linux/macOS: Uses Bash (`scripts/build.sh`)
> Note: GitHub Actions artifacts are downloaded as zip containers. The actual packaged files inside are `.exe`, `.deb`, and `.dmg`.
**Usage:**
@@ -66,13 +64,9 @@ git push origin v1.0.0
# Automatically triggers Windows + Linux + macOS builds
```
*Manual trigger with selective platforms:*
*Manual trigger:*
Go to GitHub > Actions > Release & Publish > Run workflow
- Specify version: `1.0.0`
- Toggle build targets as needed:
- ✅ Build Windows (x64/x86)
- ✅ Build Linux (x64)
- ✅ Build macOS (x64/arm64)
- Specify release tag: `v1.0.0` (or `1.0.0`, workflow will normalize to `v1.0.0`)
- Check pre-release option if needed
### 4. Issue Management (`issue-management.yml`)
@@ -109,22 +103,20 @@ To align with CI workflows, set up your local environment:
dotnet restore
# Build (like CI does)
dotnet build LanMontainDesktop/LanMontainDesktop.csproj
dotnet build LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
# Format code locally (required by CI)
dotnet format
# Run tests
dotnet test LanMontainDesktop/LanMontainDesktop.csproj
dotnet test LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
dotnet test LanMountainDesktop/LanMountainDesktop.csproj
# Alternative: Use local build scripts (Linux/macOS)
./scripts/build.sh --rid linux-x64 --version 1.0.0
./scripts/build.sh --rid osx-x64 --version 1.0.0
# Or on Windows with the PowerShell script
./LanMontainDesktop/scripts/package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.0
./LanMountainDesktop/scripts/package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.0
```
### Cross-Platform Build Scripts
@@ -150,10 +142,10 @@ chmod +x scripts/build.sh
**Windows:**
```powershell
# Using PowerShell script
.\LanMontainDesktop\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.0
.\LanMountainDesktop\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.0
# Or use dotnet directly
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release -r win-x64 -o ./publish/win-x64 `
-p:PublishSingleFile=true --self-contained
```
@@ -210,7 +202,7 @@ git push origin v1.0.0
### Status Badge
Add to your README.md:
```markdown
![Build Status](https://github.com/YOUR_ORG/LanMontainDesktop/workflows/Build%20&%20Test/badge.svg)
![Build Status](https://github.com/YOUR_ORG/LanMountainDesktop/workflows/Build%20&%20Test/badge.svg)
```
### Check Workflow Status
@@ -244,7 +236,6 @@ Consider adding:
- Multi-platform builds (Linux, macOS)
- Installer generation (.exe, .msi)
- Automated changelog generation
- Docker images for backend
## References

View 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

View File

@@ -1,13 +1,15 @@
name: Build
name: Build
on:
push:
tags-ignore:
- '*'
pull_request:
workflow_dispatch:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMontainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
build-windows:
@@ -41,8 +43,7 @@ jobs:
with:
name: build-windows-${{ matrix.configuration }}
path: |
LanMontainDesktop/bin/${{ matrix.configuration }}/
LanMontainDesktop.RecommendationBackend/bin/${{ matrix.configuration }}/
LanMountainDesktop/bin/${{ matrix.configuration }}/
retention-days: 7
build-linux:
@@ -70,18 +71,17 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build --no-restore -c Release -v minimal
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: build-linux
path: |
LanMontainDesktop/bin/Release/
LanMontainDesktop.RecommendationBackend/bin/Release/
LanMountainDesktop/bin/Release/
retention-days: 7
build-macos:
@@ -101,16 +101,15 @@ jobs:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore
run: dotnet restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build --no-restore -c Release -v minimal
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: build-macos
path: |
LanMontainDesktop/bin/Release/
LanMontainDesktop.RecommendationBackend/bin/Release/
LanMountainDesktop/bin/Release/
retention-days: 7

View File

@@ -1,4 +1,4 @@
name: Quality Check
name: Quality Check
on:
pull_request:
@@ -8,7 +8,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMontainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
analyze:

View File

@@ -1,4 +1,4 @@
name: Release
name: Release
on:
push:
@@ -18,7 +18,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMontainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
prepare:
@@ -26,24 +26,36 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
steps:
- name: Get release info
id: version
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
TAG=${GITHUB_REF#refs/tags/}
TAG="${GITHUB_REF#refs/tags/}"
CHECKOUT_REF="${GITHUB_REF}"
else
TAG=${{ github.event.inputs.tag }}
RAW_TAG="${{ github.event.inputs.tag }}"
if [[ "${RAW_TAG}" == refs/tags/* ]]; then
TAG="${RAW_TAG#refs/tags/}"
elif [[ "${RAW_TAG}" == v* ]]; then
TAG="${RAW_TAG}"
else
TAG="v${RAW_TAG}"
fi
CHECKOUT_REF="${GITHUB_SHA}"
fi
VERSION=${TAG#v}
VERSION="${TAG#v}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
build-windows:
needs: prepare
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
arch: [x64, x86]
name: Build_Windows_${{ matrix.arch }}
@@ -54,7 +66,7 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -65,8 +77,7 @@ jobs:
run: |
$VERSION = "${{ needs.prepare.outputs.version }}"
$csprojFiles = @(
"LanMontainDesktop/LanMontainDesktop.csproj",
"LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj"
"LanMountainDesktop/LanMountainDesktop.csproj"
)
foreach ($csprojPath in $csprojFiles) {
@@ -85,34 +96,123 @@ jobs:
- name: Publish
run: |
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj `
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj `
-c Release `
-o ./publish/windows-${{ matrix.arch }} `
--self-contained `
-r win-${{ matrix.arch }} `
-p:PublishSingleFile=true `
-p:DebugType=none
-p:PublishSingleFile=false `
-p:SelfContained=true `
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false
shell: pwsh
- name: Package
- name: Install Inno Setup
run: choco install innosetup -y --no-progress
shell: pwsh
- name: Build Installer
run: |
$version = "${{ needs.prepare.outputs.version }}"
$arch = "${{ matrix.arch }}"
$source = "publish/windows-$arch"
$package = "LanMontainDesktop-$version-win-$arch"
$publishDir = "publish\windows-$arch"
$installerScript = "LanMountainDesktop\installer\LanMountainDesktop.iss"
$outputDir = "build-installer"
New-Item -ItemType Directory -Path "$package" -Force | Out-Null
Copy-Item -Path "$source/*" -Destination "$package" -Recurse -Force
Compress-Archive -Path "$package" -DestinationPath "$package.zip" -Force
# Verify source directory exists
if (-not (Test-Path -Path $publishDir)) {
Write-Error "Publish directory not found: $publishDir"
Get-ChildItem -Path "publish" -Directory -ErrorAction SilentlyContinue | Select-Object Name
exit 1
}
Write-Host "Created: $package.zip"
# Create output directory
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
# Verify installer script exists
if (-not (Test-Path -Path $installerScript)) {
Write-Error "Installer script not found: $installerScript"
exit 1
}
# Find Inno Setup compiler (choco may install a shim in PATH)
$isccPath = $null
$isccCommand = Get-Command ISCC.exe -ErrorAction SilentlyContinue
if ($isccCommand) {
$isccPath = $isccCommand.Source
}
$candidatePaths = @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe",
"$env:ChocolateyInstall\bin\ISCC.exe",
"$env:ChocolateyInstall\lib\innosetup\tools\ISCC.exe"
)
if (-not $isccPath) {
foreach ($candidate in $candidatePaths) {
if ($candidate -and (Test-Path -Path $candidate)) {
$isccPath = $candidate
break
}
}
}
if (-not $isccPath) {
Write-Host "ISCC.exe was not found in PATH or known locations."
Write-Host "Checked locations:"
$candidatePaths | ForEach-Object { Write-Host " - $_" }
Write-Host "Chocolatey bin listing (if exists):"
Get-ChildItem "$env:ChocolateyInstall\bin" -Filter "*iscc*" -ErrorAction SilentlyContinue | Select-Object FullName
Write-Error "Inno Setup compiler not found."
exit 1
}
Write-Host "Found Inno Setup at: $isccPath"
# Build installer with iscc.exe
Write-Host "Building installer for Windows $arch with version $version..."
$publishDir = (Resolve-Path $publishDir).Path
$outputDir = (Resolve-Path $outputDir).Path
$installerScript = (Resolve-Path $installerScript).Path
$compileArgs = @(
"/DMyAppVersion=$version",
"/DPublishDir=$publishDir",
"/DMyOutputDir=$outputDir",
"/DMyAppArch=$arch",
$installerScript
)
Write-Host "Compile command: `"$isccPath`" $($compileArgs -join ' ')"
# Execute the compiler
& $isccPath @compileArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "Inno Setup compiler exited with code $LASTEXITCODE"
exit 1
}
# Check if build was successful
$installerFile = Get-ChildItem -Path $outputDir -Filter "*.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $installerFile) {
Write-Error "Failed to create installer"
exit 1
}
Write-Host "✅ Successfully created: $($installerFile.Name)"
Write-Host "Installer size: $([Math]::Round($installerFile.Length / 1MB, 2)) MB"
shell: pwsh
- name: Upload
- name: Upload Installer
uses: actions/upload-artifact@v4
with:
name: release-windows-${{ matrix.arch }}
path: LanMontainDesktop-*.zip
path: build-installer/*.exe
if-no-files-found: error
retention-days: 30
build-linux:
@@ -126,7 +226,7 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Install dependencies
run: |
@@ -145,11 +245,8 @@ jobs:
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMontainDesktop.csproj to $VERSION"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMontainDesktop/LanMontainDesktop.csproj
echo "Updating version in LanMontainDesktop.RecommendationBackend.csproj to $VERSION"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -159,54 +256,111 @@ jobs:
- name: Publish
run: |
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/linux-x64 \
--self-contained \
-r linux-x64 \
-p:PublishSingleFile=true \
-p:DebugType=none
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
- name: Package as DEB
run: |
version="${{ needs.prepare.outputs.version }}"
source="publish/linux-x64"
package_name="lanmontaindesktop"
package_name="LanMountainDesktop"
package_version="${version}"
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
# Create DEB package structure
mkdir -p "build-deb/DEBIAN"
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
mkdir -p "build-deb/usr/share/pixmaps"
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
# Copy application files
cp -r "$source"/* "build-deb/usr/local/bin/"
# Create control file
cat > "build-deb/DEBIAN/control" << EOF
Package: $package_name
Version: $package_version
Architecture: $arch
Maintainer: LanMountain Team <dev@example.com>
Description: LanMountain Desktop Application
A desktop application for LanMountain.
EOF
# Verify copy was successful
item_count=$(find build-deb/usr/local/bin -type f 2>/dev/null | wc -l)
echo "DEB package contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
{
printf '%s\n' '#!/bin/sh'
printf '%s\n' 'set -e'
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
} > "build-deb/DEBIAN/postinst"
# Create control file (NOTE: No leading spaces in control file)
{
printf '%s\n' "Package: $package_name"
printf '%s\n' "Version: $package_version"
printf '%s\n' "Architecture: $arch"
printf '%s\n' "Maintainer: LanMountain Team <dev@example.com>"
printf '%s\n' "Description: LanMountain Desktop Application"
printf '%s\n' " A desktop application for LanMountain."
} > "build-deb/DEBIAN/control"
# Set proper permissions
chmod 755 "build-deb/usr/local/bin/LanMontainDesktop"
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
# Create DEB file
dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"
echo "Created: ${package_name}_${package_version}_${arch}.deb"
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then
echo "Successfully created: ${package_name}_${package_version}_${arch}.deb"
ls -lh "${package_name}_${package_version}_${arch}.deb"
else
echo "Error: Failed to build DEB package"
exit 1
fi
- name: Upload
uses: actions/upload-artifact@v4
with:
name: release-linux
path: "*.deb"
if-no-files-found: error
retention-days: 30
build-macos:
@@ -223,7 +377,7 @@ jobs:
with:
fetch-depth: 0
submodules: recursive
ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag || github.ref }}
ref: ${{ needs.prepare.outputs.checkout_ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
@@ -233,11 +387,8 @@ jobs:
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMontainDesktop.csproj to $VERSION"
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMontainDesktop/LanMontainDesktop.csproj
echo "Updating version in LanMontainDesktop.RecommendationBackend.csproj to $VERSION"
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
@@ -247,22 +398,33 @@ jobs:
- name: Publish
run: |
dotnet publish LanMontainDesktop/LanMontainDesktop.csproj \
dotnet publish LanMountainDesktop/LanMountainDesktop.csproj \
-c Release \
-o ./publish/macos-${{ matrix.arch }} \
--self-contained \
-r osx-${{ matrix.arch }} \
-p:PublishSingleFile=true \
-p:DebugType=none
-p:PublishSingleFile=false \
-p:SelfContained=true \
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
- name: Package as DMG
run: |
version="${{ needs.prepare.outputs.version }}"
arch="${{ matrix.arch }}"
source="publish/macos-$arch"
app_name="LanMontainDesktop"
app_name="LanMountainDesktop"
package_name="${app_name}-${version}-macos-${arch}"
# Verify source directory exists
if [ ! -d "$source" ]; then
echo "Error: Source directory not found: $source"
ls -la publish/ || echo "publish directory not found"
exit 1
fi
# Create app bundle structure
mkdir -p "${app_name}.app/Contents/MacOS"
mkdir -p "${app_name}.app/Contents/Resources"
@@ -270,49 +432,63 @@ jobs:
# Copy application files
cp -r "$source"/* "${app_name}.app/Contents/MacOS/"
# Create minimal Info.plist
cat > "${app_name}.app/Contents/Info.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>LanMontainDesktop</string>
<key>CFBundleName</key>
<string>LanMountain Desktop</string>
<key>CFBundleVersion</key>
<string>${version}</string>
<key>CFBundleShortVersionString</key>
<string>${version}</string>
<key>CFBundleIdentifier</key>
<string>com.lanmountain.desktop</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
</dict>
</plist>
EOF
# Verify copy was successful
item_count=$(find "${app_name}.app/Contents/MacOS" -type f | wc -l)
echo "App bundle contains $item_count files"
if [ "$item_count" -eq 0 ]; then
echo "Error: App bundle is empty after copy"
exit 1
fi
# Create Info.plist
{
printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>'
printf '%s\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">'
printf '%s\n' '<plist version="1.0">'
printf '%s\n' '<dict>'
printf '%s\n' ' <key>CFBundleExecutable</key>'
printf '%s\n' ' <string>LanMountainDesktop</string>'
printf '%s\n' ' <key>CFBundleName</key>'
printf '%s\n' ' <string>LanMountain Desktop</string>'
printf '%s\n' ' <key>CFBundleVersion</key>'
printf '%s\n' " <string>$version</string>"
printf '%s\n' ' <key>CFBundleShortVersionString</key>'
printf '%s\n' " <string>$version</string>"
printf '%s\n' ' <key>CFBundleIdentifier</key>'
printf '%s\n' ' <string>com.lanmountain.desktop</string>'
printf '%s\n' ' <key>CFBundlePackageType</key>'
printf '%s\n' ' <string>APPL</string>'
printf '%s\n' '</dict>'
printf '%s\n' '</plist>'
} > "${app_name}.app/Contents/Info.plist"
# Create DMG
mkdir -p dmg-temp
cp -r "${app_name}.app" dmg-temp/
hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg"
if hdiutil create -volname "${app_name}" -srcfolder dmg-temp -ov -format UDZO "${package_name}.dmg" 2>&1; then
echo "Successfully created: ${package_name}.dmg"
ls -lh "${package_name}.dmg"
else
echo "Error: Failed to create DMG"
exit 1
fi
# Cleanup
rm -rf dmg-temp "${app_name}.app"
echo "Created: ${package_name}.dmg"
- name: Upload
uses: actions/upload-artifact@v4
with:
name: release-macos-${{ matrix.arch }}
path: "*.dmg"
if-no-files-found: error
retention-days: 30
github-release:
needs: [ prepare, build-windows, build-linux, build-macos ]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
@@ -323,28 +499,59 @@ jobs:
path: artifacts
pattern: release-*
- name: List artifacts structure
run: |
echo "🔍 Artifact directory structure:"
find artifacts -type f -o -type d | sort
echo ""
echo "📊 Files found:"
find artifacts -type f -exec ls -lh {} \;
echo ""
echo "📁 Full tree:"
tree artifacts || find artifacts -print | sed -e 's;[^/]*/;|____;g;s;____|; |;g'
- name: Flatten artifacts for release
run: |
echo "📦 Organizing artifacts..."
mkdir -p release-files
find artifacts -type f \( -name "*.exe" -o -name "*.deb" -o -name "*.dmg" \) -exec cp -v {} release-files/ \;
echo ""
echo "✅ Files ready for release:"
ls -lh release-files/ || echo "⚠️ No files found in release-files"
echo ""
echo "📋 Total files:"
file_count=$(find release-files -type f | wc -l)
echo "$file_count"
if [ "$file_count" -eq 0 ]; then
echo "Error: No installer/package files found for release"
exit 1
fi
- name: Create Release
uses: ncipollo/release-action@v1
with:
tag: ${{ github.ref_name }}
tag: ${{ needs.prepare.outputs.tag }}
name: ${{ needs.prepare.outputs.tag }}
commit: ${{ github.sha }}
allowUpdates: true
draft: false
prerelease: ${{ github.event.inputs.is_prerelease == 'true' }}
artifacts: artifacts/**/*
artifacts: "release-files/**"
body: |
## Release ${{ needs.prepare.outputs.version }}
### Downloads
### Windows
- **LanMountainDesktop-Setup-{version}-x64.exe** - 64-bit installer
- **LanMountainDesktop-Setup-{version}-x86.exe** - 32-bit installer
**Windows:**
- win-x64 (64-bit)
- win-x86 (32-bit)
Installation: Double-click the .exe file and follow the wizard.
**Linux:**
- linux-x64
### Linux
- **LanMountainDesktop-{version}-linux-x64.deb** - Debian package (x64)
**macOS:**
- macos-x64 (Intel)
- macos-arm64 (Apple Silicon)
### macOS
- **LanMountainDesktop-{version}-macos-x64.dmg** - Intel processor
- **LanMountainDesktop-{version}-macos-arm64.dmg** - Apple Silicon (M1/M2/M3)
See commits for changes.
token: ${{ secrets.GITHUB_TOKEN }}

31
.gitignore vendored
View File

@@ -481,3 +481,34 @@ $RECYCLE.BIN/
# Vim temporary swap files
*.swp
nul
/publish-test
/_build_verify
/_build_verify_tray
/_build_verify_plugin
/_build_verify_plugin_tabs
/_build_verify_sample_plugin
/_build_verify_sample_plugin_capabilities
/_build_verify_plugin_page_host
/_build_verify_plugin_services
/LanMountainDesktop.PluginSdk/_build_verify_*/
/_build_obj
# LanMountainDesktop local workspace files
/.arts/
/.knox/
/.lingma/
/.tmp/
/publish-test/
/validator-restore.log
/temp_old_main.axaml
/temp_old_main_utf8.axaml
# LanMountainDesktop local packaging outputs
/build-installer/
/build-deb/
/dmg-temp/
/release-files/
/LanMountainDesktop.app/
/*.deb
/*.dmg
/*.AppImage

21
LanAirApp/README.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,54 +0,0 @@
using System;
using System.Collections.Generic;
namespace LanMontainDesktop.RecommendationBackend.Models;
public sealed record DailyQuoteSnapshot(
string Provider,
string Content,
string? Author,
string? Source,
DateTimeOffset FetchedAt);
public sealed record DailyPoetrySnapshot(
string Provider,
string Content,
string? Origin,
string? Author,
string? Category,
DateTimeOffset FetchedAt);
public sealed record DailyMovieRecommendation(
string Provider,
string Title,
string? Rating,
string? Description,
string? Url,
string? CoverUrl,
DateTimeOffset FetchedAt);
public sealed record DailyArtworkSnapshot(
string Provider,
string Title,
string? Artist,
string? Year,
string? Museum,
string? ArtworkUrl,
string? ImageUrl,
DateTimeOffset FetchedAt);
public sealed record HotSearchEntry(
string Provider,
int Rank,
string Title,
string? HotValue,
string? Summary,
string? Url);
public sealed record RecommendationFeedSnapshot(
DateTimeOffset FetchedAt,
DailyQuoteSnapshot? DailyQuote,
DailyPoetrySnapshot? DailyPoetry,
DailyMovieRecommendation? DailyMovie,
DailyArtworkSnapshot? DailyArtwork,
IReadOnlyList<HotSearchEntry> HotSearches);

View File

@@ -1,92 +0,0 @@
using System;
using LanMontainDesktop.RecommendationBackend.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IRecommendationDataService>(serviceProvider =>
{
var options = builder.Configuration.GetSection("Recommendation").Get<RecommendationApiOptions>();
return new RecommendationDataService(options);
});
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
service = "LanMontainDesktop.RecommendationBackend",
status = "ok",
timestamp = DateTimeOffset.UtcNow
}));
app.MapGet(
"/api/recommendation/daily-quote",
async (IRecommendationDataService service, string? locale, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetDailyQuoteAsync(new DailyQuoteQuery(locale, forceRefresh), cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapGet(
"/api/recommendation/daily-poetry",
async (IRecommendationDataService service, string? locale, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetDailyPoetryAsync(new DailyPoetryQuery(locale, forceRefresh), cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapGet(
"/api/recommendation/daily-movie",
async (IRecommendationDataService service, string? locale, int candidateCount = 20, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetDailyMovieAsync(
new DailyMovieQuery(locale, candidateCount <= 0 ? 20 : candidateCount, forceRefresh),
cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapGet(
"/api/recommendation/daily-artwork",
async (IRecommendationDataService service, string? locale, int candidateCount = 50, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetDailyArtworkAsync(
new DailyArtworkQuery(locale, candidateCount <= 0 ? 50 : candidateCount, forceRefresh),
cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapGet(
"/api/recommendation/hot-search",
async (IRecommendationDataService service, string? provider, int limit = 10, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetHotSearchAsync(
new HotSearchQuery(provider ?? "Baidu", limit <= 0 ? 10 : limit, forceRefresh),
cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapGet(
"/api/recommendation/feed",
async (IRecommendationDataService service, string? locale, int hotSearchLimit = 10, bool forceRefresh = false, CancellationToken cancellationToken = default) =>
{
var result = await service.GetFeedAsync(
new RecommendationFeedQuery(locale, hotSearchLimit <= 0 ? 10 : hotSearchLimit, forceRefresh),
cancellationToken);
return result.Success ? Results.Ok(result) : Results.BadRequest(result);
});
app.MapPost(
"/api/recommendation/cache/clear",
(IRecommendationDataService service) =>
{
service.ClearCache();
return Results.Ok(new
{
success = true,
message = "Recommendation cache cleared.",
timestamp = DateTimeOffset.UtcNow
});
});
app.MapGet("/", () => Results.Redirect("/health"));
app.Run();

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5196",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7181;http://localhost:5196",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,33 +0,0 @@
# LanMontainDesktop Recommendation Backend
信息推荐后端,提供统一抓取与聚合接口,当前覆盖:
- 每日一言
- 每日诗词
- 每日电影推荐
- 每日名画
- 百度热搜
## 启动
```bash
dotnet run --project LanMontainDesktop.RecommendationBackend/LanMontainDesktop.RecommendationBackend.csproj
```
默认监听地址以 `dotnet` 输出为准(通常是 `http://localhost:5xxx``https://localhost:7xxx`)。
## 接口
- `GET /health`
- `GET /api/recommendation/daily-quote?locale=zh-CN&forceRefresh=false`
- `GET /api/recommendation/daily-poetry?locale=zh-CN&forceRefresh=false`
- `GET /api/recommendation/daily-movie?candidateCount=20&forceRefresh=false`
- `GET /api/recommendation/daily-artwork?candidateCount=50&forceRefresh=false`
- `GET /api/recommendation/hot-search?provider=Baidu&limit=10&forceRefresh=false`
- `GET /api/recommendation/feed?locale=zh-CN&hotSearchLimit=10&forceRefresh=false`
- `POST /api/recommendation/cache/clear`
## 设计说明
- 服务实现风格与现有天气服务一致:`Options + Query + QueryResult + Service`
- 所有抓取接口都带有统一错误返回:`errorCode` + `errorMessage`
- 提供内存缓存,降低上游请求频率与组件刷新开销。

View File

@@ -1,83 +0,0 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LanMontainDesktop.RecommendationBackend.Models;
namespace LanMontainDesktop.RecommendationBackend.Services;
public sealed record DailyQuoteQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyPoetryQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyMovieQuery(
string? Locale = null,
int CandidateCount = 20,
bool ForceRefresh = false);
public sealed record DailyArtworkQuery(
string? Locale = null,
int CandidateCount = 50,
bool ForceRefresh = false);
public sealed record HotSearchQuery(
string Provider = "Baidu",
int Limit = 10,
bool ForceRefresh = false);
public sealed record RecommendationFeedQuery(
string? Locale = null,
int HotSearchLimit = 10,
bool ForceRefresh = false);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
string? ErrorCode = null,
string? ErrorMessage = null)
{
public static RecommendationQueryResult<T> Ok(T data)
{
return new RecommendationQueryResult<T>(true, data);
}
public static RecommendationQueryResult<T> Fail(string errorCode, string errorMessage)
{
return new RecommendationQueryResult<T>(false, default, errorCode, errorMessage);
}
}
public interface IRecommendationInfoService
{
Task<RecommendationQueryResult<DailyQuoteSnapshot>> GetDailyQuoteAsync(
DailyQuoteQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyMovieRecommendation>> GetDailyMovieAsync(
DailyMovieQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>> GetHotSearchAsync(
HotSearchQuery query,
CancellationToken cancellationToken = default);
}
public interface IRecommendationDataService : IRecommendationInfoService
{
Task<RecommendationQueryResult<RecommendationFeedSnapshot>> GetFeedAsync(
RecommendationFeedQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}

View File

@@ -1,729 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using LanMontainDesktop.RecommendationBackend.Models;
namespace LanMontainDesktop.RecommendationBackend.Services;
public sealed record RecommendationApiOptions
{
public string DailyQuoteUrl { get; init; } = "https://v1.hitokoto.cn/?encode=json&charset=utf-8";
public string DailyPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
public string DoubanHotMovieUrlTemplate { get; init; } =
"https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit={0}&page_start=0";
public string BaiduHotSearchUrl { get; init; } = "https://top.baidu.com/board?tab=realtime";
public string ArtInstituteArtworkApiTemplate { get; init; } =
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
public string ArtInstituteImageUrlTemplate { get; init; } =
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(15);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
public int DefaultMovieCandidateCount { get; init; } = 20;
public int DefaultHotSearchLimit { get; init; } = 10;
public int DefaultArtworkCandidateCount { get; init; } = 50;
}
public sealed class RecommendationDataService : IRecommendationDataService, IDisposable
{
private sealed record CacheEntry(object Value, DateTimeOffset ExpireAt);
private sealed record MovieCandidate(
string Title,
string? Rating,
string? Url,
string? CoverUrl);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
private static readonly Regex HtmlTagRegex = new("<[^>]+>", RegexOptions.Compiled);
private static readonly Regex HotSearchSplitRegex = new("<div\\s+class=\"category-wrap_[^\"]+\"[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex RankRegex = new("<div\\s+class=\"index_[^\"]+\"[^>]*>\\s*(?<value>\\d+)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex TitleRegex = new("<div\\s+class=\"c-single-text-ellipsis\"[^>]*>\\s*(?<value>.*?)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
private static readonly Regex UrlRegex = new("<a\\s+href=\"(?<value>https?://[^\"]+)\"\\s+class=\"title_[^\"]*\"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex HotValueRegex = new("<div\\s+class=\"hot-index_[^\"]+\"[^>]*>\\s*(?<value>[\\d,]+)\\s*</div>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex SummaryRegex = new("<div\\s+class=\"hot-desc_[^\"]+\"[^>]*>\\s*(?<value>.*?)(?:<a|</div>)", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline);
private readonly RecommendationApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly object _cacheGate = new();
private readonly Dictionary<string, CacheEntry> _cache = new(StringComparer.OrdinalIgnoreCase);
public RecommendationDataService(
RecommendationApiOptions? options = null,
HttpClient? httpClient = null)
{
_options = options ?? new RecommendationApiOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = _options.RequestTimeout
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void ClearCache()
{
lock (_cacheGate)
{
_cache.Clear();
}
}
public async Task<RecommendationQueryResult<DailyQuoteSnapshot>> GetDailyQuoteAsync(
DailyQuoteQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyQuoteQuery();
var locale = string.IsNullOrWhiteSpace(normalizedQuery.Locale) ? "zh-CN" : normalizedQuery.Locale.Trim();
var cacheKey = $"daily_quote|{locale}";
if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyQuoteSnapshot cached))
{
return RecommendationQueryResult<DailyQuoteSnapshot>.Ok(cached);
}
string responseText;
try
{
responseText = await FetchTextAsync(new Uri(_options.DailyQuoteUrl, UriKind.Absolute), cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyQuoteSnapshot>.Fail("network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var content = ReadString(root, "hitokoto") ?? ReadString(root, "content");
if (string.IsNullOrWhiteSpace(content))
{
return RecommendationQueryResult<DailyQuoteSnapshot>.Fail("parse_error", "Quote content is empty.");
}
var snapshot = new DailyQuoteSnapshot(
Provider: "Hitokoto",
Content: content.Trim(),
Author: ReadString(root, "from_who") ?? ReadString(root, "creator"),
Source: ReadString(root, "from"),
FetchedAt: DateTimeOffset.UtcNow);
SetCache(cacheKey, snapshot);
return RecommendationQueryResult<DailyQuoteSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyQuoteSnapshot>.Fail("parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyPoetryQuery();
var locale = string.IsNullOrWhiteSpace(normalizedQuery.Locale) ? "zh-CN" : normalizedQuery.Locale.Trim();
var cacheKey = $"daily_poetry|{locale}";
if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyPoetrySnapshot cached))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(cached);
}
string responseText;
try
{
responseText = await FetchTextAsync(new Uri(_options.DailyPoetryUrl, UriKind.Absolute), cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var content = ReadString(root, "content");
if (string.IsNullOrWhiteSpace(content))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("parse_error", "Poetry content is empty.");
}
var snapshot = new DailyPoetrySnapshot(
Provider: "JinriShici",
Content: content.Trim(),
Origin: ReadString(root, "origin"),
Author: ReadString(root, "author"),
Category: ReadString(root, "category"),
FetchedAt: DateTimeOffset.UtcNow);
SetCache(cacheKey, snapshot);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<DailyMovieRecommendation>> GetDailyMovieAsync(
DailyMovieQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyMovieQuery();
var candidateCount = Math.Clamp(
normalizedQuery.CandidateCount > 0 ? normalizedQuery.CandidateCount : _options.DefaultMovieCandidateCount,
5,
50);
var localDate = GetChinaLocalDate();
var cacheKey = $"daily_movie|{localDate:yyyyMMdd}|{candidateCount}";
if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyMovieRecommendation cached))
{
return RecommendationQueryResult<DailyMovieRecommendation>.Ok(cached);
}
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.DoubanHotMovieUrlTemplate,
candidateCount);
string responseText;
try
{
responseText = await FetchTextAsync(
new Uri(requestUrl, UriKind.Absolute),
cancellationToken,
request =>
{
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Referer", "https://movie.douban.com/");
});
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyMovieRecommendation>.Fail("network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("subjects", out var subjects) || subjects.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyMovieRecommendation>.Fail("parse_error", "Movie list is missing.");
}
var candidates = new List<MovieCandidate>();
foreach (var item in subjects.EnumerateArray())
{
var title = ReadString(item, "title");
if (string.IsNullOrWhiteSpace(title))
{
continue;
}
candidates.Add(new MovieCandidate(
Title: title.Trim(),
Rating: ReadString(item, "rate"),
Url: ReadString(item, "url"),
CoverUrl: ReadString(item, "cover")));
}
if (candidates.Count == 0)
{
return RecommendationQueryResult<DailyMovieRecommendation>.Fail("empty_result", "No movie candidates were returned.");
}
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
var snapshot = new DailyMovieRecommendation(
Provider: "Douban",
Title: selected.Title,
Rating: selected.Rating,
Description: "豆瓣热门电影每日推荐",
Url: selected.Url,
CoverUrl: selected.CoverUrl,
FetchedAt: DateTimeOffset.UtcNow);
SetCache(cacheKey, snapshot);
return RecommendationQueryResult<DailyMovieRecommendation>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyMovieRecommendation>.Fail("parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
var candidateCount = Math.Clamp(
normalizedQuery.CandidateCount > 0 ? normalizedQuery.CandidateCount : _options.DefaultArtworkCandidateCount,
10,
100);
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var cacheKey = $"daily_artwork|{localDate:yyyyMMdd}|p{page}|n{candidateCount}";
if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out DailyArtworkSnapshot cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
responseText = await FetchTextAsync(
new Uri(requestUrl, UriKind.Absolute),
cancellationToken,
request => request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("parse_error", "Artwork list is missing.");
}
var candidates = new List<ArtworkCandidate>();
foreach (var item in dataArray.EnumerateArray())
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
{
continue;
}
var artist = ReadString(item, "artist_title");
if (string.IsNullOrWhiteSpace(artist))
{
artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display"));
}
candidates.Add(new ArtworkCandidate(
Title: title.Trim(),
Artist: artist,
Year: ReadString(item, "date_display"),
ArtworkUrl: ReadString(item, "api_link"),
ImageId: imageId.Trim()));
}
if (candidates.Count == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("empty_result", "No artwork candidates were returned.");
}
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
var imageUrl = BuildArtworkImageUrl(selected.ImageId);
var snapshot = new DailyArtworkSnapshot(
Provider: "ArtInstituteOfChicago",
Title: selected.Title,
Artist: selected.Artist,
Year: selected.Year,
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: imageUrl,
FetchedAt: DateTimeOffset.UtcNow);
SetCache(cacheKey, snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>> GetHotSearchAsync(
HotSearchQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new HotSearchQuery();
var provider = string.IsNullOrWhiteSpace(normalizedQuery.Provider)
? "Baidu"
: normalizedQuery.Provider.Trim();
var limit = Math.Clamp(
normalizedQuery.Limit > 0 ? normalizedQuery.Limit : _options.DefaultHotSearchLimit,
1,
50);
var cacheKey = $"hot_search|{provider}|{limit}";
if (!normalizedQuery.ForceRefresh && TryGetCached(cacheKey, out IReadOnlyList<HotSearchEntry> cached))
{
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Ok(cached);
}
if (!string.Equals(provider, "Baidu", StringComparison.OrdinalIgnoreCase))
{
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail(
"unsupported_provider",
$"Unsupported hot search provider: {provider}");
}
string responseText;
try
{
responseText = await FetchTextAsync(
new Uri(_options.BaiduHotSearchUrl, UriKind.Absolute),
cancellationToken,
request => request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0"));
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail("network_error", ex.Message);
}
try
{
var entries = ParseBaiduHotSearch(responseText, limit);
if (entries.Count == 0)
{
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail("parse_error", "No hot search entries found.");
}
SetCache(cacheKey, entries);
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Ok(entries);
}
catch (Exception ex)
{
return RecommendationQueryResult<IReadOnlyList<HotSearchEntry>>.Fail("parse_error", ex.Message);
}
}
public async Task<RecommendationQueryResult<RecommendationFeedSnapshot>> GetFeedAsync(
RecommendationFeedQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new RecommendationFeedQuery();
var quoteTask = GetDailyQuoteAsync(
new DailyQuoteQuery(normalizedQuery.Locale, normalizedQuery.ForceRefresh),
cancellationToken);
var poetryTask = GetDailyPoetryAsync(
new DailyPoetryQuery(normalizedQuery.Locale, normalizedQuery.ForceRefresh),
cancellationToken);
var movieTask = GetDailyMovieAsync(
new DailyMovieQuery(normalizedQuery.Locale, ForceRefresh: normalizedQuery.ForceRefresh),
cancellationToken);
var artworkTask = GetDailyArtworkAsync(
new DailyArtworkQuery(normalizedQuery.Locale, ForceRefresh: normalizedQuery.ForceRefresh),
cancellationToken);
var hotTask = GetHotSearchAsync(
new HotSearchQuery(Limit: normalizedQuery.HotSearchLimit, ForceRefresh: normalizedQuery.ForceRefresh),
cancellationToken);
await Task.WhenAll(quoteTask, poetryTask, movieTask, artworkTask, hotTask);
var quote = quoteTask.Result;
var poetry = poetryTask.Result;
var movie = movieTask.Result;
var artwork = artworkTask.Result;
var hot = hotTask.Result;
if (!quote.Success && !poetry.Success && !movie.Success && !artwork.Success && !hot.Success)
{
return RecommendationQueryResult<RecommendationFeedSnapshot>.Fail(
"upstream_unavailable",
"All upstream recommendation providers failed.");
}
var snapshot = new RecommendationFeedSnapshot(
FetchedAt: DateTimeOffset.UtcNow,
DailyQuote: quote.Success ? quote.Data : null,
DailyPoetry: poetry.Success ? poetry.Data : null,
DailyMovie: movie.Success ? movie.Data : null,
DailyArtwork: artwork.Success ? artwork.Data : null,
HotSearches: hot.Success && hot.Data is not null ? hot.Data : Array.Empty<HotSearchEntry>());
return RecommendationQueryResult<RecommendationFeedSnapshot>.Ok(snapshot);
}
private async Task<string> FetchTextAsync(
Uri requestUri,
CancellationToken cancellationToken,
Action<HttpRequestMessage>? configureRequest = null)
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
configureRequest?.Invoke(request);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var content = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(content, 180)}");
}
return content;
}
private IReadOnlyList<HotSearchEntry> ParseBaiduHotSearch(string html, int limit)
{
var parts = HotSearchSplitRegex.Split(html);
var entries = new List<HotSearchEntry>(limit);
for (var i = 1; i < parts.Length; i++)
{
var chunk = parts[i];
var title = DecodeHtml(ExtractGroupValue(TitleRegex, chunk, "value"));
var url = DecodeHtml(ExtractGroupValue(UrlRegex, chunk, "value"));
var hotValue = DecodeHtml(ExtractGroupValue(HotValueRegex, chunk, "value"));
var summary = DecodeHtml(ExtractGroupValue(SummaryRegex, chunk, "value"));
var rankText = ExtractGroupValue(RankRegex, chunk, "value");
if (string.IsNullOrWhiteSpace(title))
{
continue;
}
if (!int.TryParse(rankText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rank))
{
rank = entries.Count + 1;
}
entries.Add(new HotSearchEntry(
Provider: "Baidu",
Rank: rank,
Title: title,
HotValue: string.IsNullOrWhiteSpace(hotValue) ? null : hotValue,
Summary: string.IsNullOrWhiteSpace(summary) ? null : summary,
Url: string.IsNullOrWhiteSpace(url) ? null : url));
if (entries.Count >= limit)
{
break;
}
}
var uniqueEntries = entries
.GroupBy(item => item.Title, StringComparer.OrdinalIgnoreCase)
.Select(group => group.First())
.OrderBy(item => item.Rank)
.ThenBy(item => item.Title, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.ToList();
for (var i = 0; i < uniqueEntries.Count; i++)
{
var item = uniqueEntries[i];
uniqueEntries[i] = item with { Rank = i + 1 };
}
return uniqueEntries;
}
private static string? ExtractGroupValue(Regex regex, string input, string groupName)
{
var match = regex.Match(input);
if (!match.Success)
{
return null;
}
return match.Groups[groupName].Value;
}
private static string? DecodeHtml(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
var decoded = WebUtility.HtmlDecode(value);
decoded = HtmlTagRegex.Replace(decoded, " ");
return string.Join(" ", decoded.Split([' ', '\r', '\n', '\t'], StringSplitOptions.RemoveEmptyEntries));
}
private string? BuildArtworkImageUrl(string? imageId)
{
if (string.IsNullOrWhiteSpace(imageId))
{
return null;
}
return string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteImageUrlTemplate,
imageId.Trim());
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
}
private bool TryGetCached<T>(string cacheKey, out T value)
{
lock (_cacheGate)
{
if (_cache.TryGetValue(cacheKey, out var entry))
{
if (entry.ExpireAt > DateTimeOffset.UtcNow && entry.Value is T typedValue)
{
value = typedValue;
return true;
}
_cache.Remove(cacheKey);
}
}
value = default!;
return false;
}
private void SetCache(string cacheKey, object value)
{
var expireAt = DateTimeOffset.UtcNow.Add(_options.CacheDuration);
lock (_cacheGate)
{
_cache[cacheKey] = new CacheEntry(value, expireAt);
}
}
private static string? ReadString(JsonElement? node, params string[] path)
{
if (!node.HasValue)
{
return null;
}
var target = path.Length == 0 ? node : TryGetNode(node.Value, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.String => target.Value.GetString(),
JsonValueKind.Number => target.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
foreach (var segment in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
{
return null;
}
current = next;
}
return current;
}
private static DateOnly GetChinaLocalDate()
{
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
return DateOnly.FromDateTime(now.Date);
}
private static string Truncate(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= maxLength
? text
: $"{text[..maxLength]}...";
}
}

View File

@@ -1,11 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Recommendation": {
"CacheDuration": "00:05:00"
}
}

View File

@@ -1,22 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Recommendation": {
"DailyQuoteUrl": "https://v1.hitokoto.cn/?encode=json&charset=utf-8",
"DailyPoetryUrl": "https://v1.jinrishici.com/all.json",
"DoubanHotMovieUrlTemplate": "https://movie.douban.com/j/search_subjects?type=movie&tag=%E7%83%AD%E9%97%A8&page_limit={0}&page_start=0",
"BaiduHotSearchUrl": "https://top.baidu.com/board?tab=realtime",
"ArtInstituteArtworkApiTemplate": "https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link",
"ArtInstituteImageUrlTemplate": "https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg",
"CacheDuration": "00:15:00",
"RequestTimeout": "00:00:08",
"DefaultMovieCandidateCount": 20,
"DefaultHotSearchLimit": 10,
"DefaultArtworkCandidateCount": 50
},
"AllowedHosts": "*"
}

View File

@@ -1,25 +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}") = "LanMontainDesktop", "LanMontainDesktop\LanMontainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMontainDesktop.RecommendationBackend", "LanMontainDesktop.RecommendationBackend\LanMontainDesktop.RecommendationBackend.csproj", "{00000002-0000-0000-0000-000000000002}"
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
{00000002-0000-0000-0000-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00000002-0000-0000-0000-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00000002-0000-0000-0000-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00000002-0000-0000-0000-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,49 +0,0 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using Avalonia.Markup.Xaml;
using LanMontainDesktop.ViewModels;
using LanMontainDesktop.Views;
using AvaloniaWebView;
namespace LanMontainDesktop;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
}

View File

@@ -1,22 +0,0 @@
# MiSans Font Notice
This app bundles MiSans fonts for consistent cross-device rendering.
## Included files
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
## Source
- Upstream package repository: https://github.com/dsrkafuu/misans
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
## License and usage notes
- Script/package license in upstream repository: Apache-2.0
- MiSans font copyright and additional usage terms:
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
Please review and comply with the MiSans font terms when distributing this app.

View File

@@ -1,29 +0,0 @@
# Weather Background Assets
Weather card background images are sourced from **Pexels** and used under the Pexels license:
https://www.pexels.com/license/
## Sources
- `clear_sky.jpg`
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
- `rain.jpg`
- https://www.pexels.com/photo/rain-on-window-with-bokeh-lights-35075853/
- `snow.jpg`
- https://www.pexels.com/photo/mountain-covered-with-snow-209955/
- `storm.jpg`
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
## Derived Variants (for widget scene mapping)
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
- `clear_day.jpg` (from `clear_sky.jpg`)
- `clear_night.jpg` (from `clear_sky.jpg`)
- `cloudy_day.jpg` (from `clear_sky.jpg`)
- `cloudy_night.jpg` (from `clear_sky.jpg`)
- `rain_light.jpg` (from `rain.jpg`)
- `rain_heavy.jpg` (from `rain.jpg`)
- `storm_dark.jpg` (from `storm.jpg`)
- `fog_haze.jpg` (from `storm.jpg`)
- `snow_soft.jpg` (from `snow.jpg`)

View File

@@ -1,38 +0,0 @@
# HyperOS3 Weather Assets (Official Xiaomi Package)
These assets were extracted from the official Xiaomi Weather APK provided by the user:
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
- Package: `com.miui.weather2` (Mi Weather)
- Extraction date: 2026-03-03
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.

View File

@@ -1,77 +0,0 @@
# 组件系统模块Component System Module
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
This directory provides the modular foundation for built-in component management and third-party extension integration.
## 核心文件职责Core Files
- `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
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
Register built-in components first via `ComponentRegistry.CreateDefault()`.
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
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
[
{
"id": "Weather",
"displayName": "Weather",
"iconKey": "WeatherSunny",
"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
- 最小尺寸约束:`minWidthCells >= 1``minHeightCells >= 1`
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
- 状态栏约束:状态栏组件高度必须为 `1` 格。
Status bar constraint: component height must be exactly `1` cell.
- 越界约束所有组件坐标会被网格边界钳制clamp
Out-of-bounds constraint: component coordinates are clamped to grid bounds.

View File

@@ -1,298 +0,0 @@
{
"app.title": "LanMontainDesktop",
"button.back_to_windows": "Back to Windows",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.back_to_desktop": "Back to Desktop",
"settings.nav_header": "Settings",
"settings.nav.wallpaper": "Wallpaper",
"settings.nav.grid": "Grid",
"settings.nav.color": "Color",
"settings.nav.status_bar": "Status Bar",
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
"settings.wallpaper.current_label": "Current Wallpaper",
"settings.wallpaper.placement_label": "Placement",
"settings.wallpaper.placement_desc": "Adjust how the image fills the desktop.",
"settings.wallpaper.pick_button": "Browse Files",
"settings.wallpaper.clear_button": "Reset to Solid Color",
"settings.wallpaper.no_selection": "No wallpaper selected.",
"settings.wallpaper.storage_unavailable": "Storage provider is unavailable.",
"settings.wallpaper.import_failed": "Failed to import wallpaper file.",
"settings.wallpaper.image_applied": "Image wallpaper applied.",
"settings.wallpaper.video_applied": "Video wallpaper applied.",
"settings.wallpaper.unsupported_file": "Selected file type is not supported.",
"settings.wallpaper.apply_failed_format": "Failed to apply wallpaper: {0}",
"settings.wallpaper.mode_format": "Wallpaper mode: {0}.",
"settings.wallpaper.video_mode": "Video wallpaper uses automatic fill mode.",
"settings.wallpaper.cleared": "Background reset to solid color.",
"settings.wallpaper.default_status": "Current background uses solid color.",
"settings.wallpaper.saved_not_found": "Saved wallpaper file was not found. Using solid color background.",
"settings.wallpaper.restored": "Wallpaper restored from saved settings.",
"settings.wallpaper.video_restored": "Video wallpaper restored from saved settings.",
"settings.wallpaper.restore_failed": "Failed to restore saved wallpaper. Using solid color background.",
"settings.wallpaper.video_not_found": "Video wallpaper file not found.",
"settings.wallpaper.video_player_unavailable": "Video player is unavailable.",
"settings.wallpaper.video_play_failed_format": "Failed to play video wallpaper: {0}",
"settings.grid.title": "Grid Layout",
"settings.grid.description": "Every component must occupy at least one cell (minimum 1x1).",
"settings.grid.short_side_label": "Short Side Cells",
"settings.grid.spacing_label": "Grid Spacing",
"settings.grid.spacing_relaxed": "Relaxed (iOS)",
"settings.grid.spacing_compact": "Compact (Android)",
"settings.grid.edge_inset_label": "Screen Inset",
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
"settings.grid.apply_button": "Apply",
"settings.grid.info_format": "Grid: {0} cols x {1} rows | cell {2:F1}px (1:1)",
"settings.color.title": "Color",
"settings.color.description": "Switch day/night mode and choose app accent colors.",
"settings.color.day_night_label": "Day/Night Mode",
"settings.color.day_night_on": "Night",
"settings.color.day_night_off": "Day",
"settings.color.recommended_label": "Recommended Colors",
"settings.color.system_monet_label": "System Monet Colors",
"settings.color.refresh_button": "Refresh",
"settings.color.mode_night": "Night mode enabled",
"settings.color.mode_day": "Day mode enabled",
"settings.color.mode_status_format": "Theme mode: {0}.",
"settings.color.monet_refreshed": "Monet colors refreshed.",
"settings.color.theme_ready_format": "Theme color ready: {0}.",
"settings.color.theme_applied_format": "{0} color applied: {1}.",
"settings.color.theme_updated_wallpaper": "Wallpaper updated. Monet colors refreshed.",
"settings.color.theme_updated_video": "Video wallpaper updated. Theme colors refreshed.",
"settings.color.theme_cleared_wallpaper": "Wallpaper cleared. Monet colors refreshed.",
"settings.status_bar.title": "Status Bar",
"settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
"settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact",
"settings.status_bar.spacing_mode_relaxed": "Relaxed",
"settings.status_bar.spacing_mode_custom": "Custom",
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "Weather",
"settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search",
"settings.weather.mode_coordinates": "Coordinates",
"settings.weather.auto_refresh": "Auto refresh location on startup",
"settings.weather.city_search_header": "City Search",
"settings.weather.city_search_desc": "Search cities and apply one weather location.",
"settings.weather.search_placeholder": "e.g. Beijing",
"settings.weather.search_button": "Search",
"settings.weather.apply_city_button": "Apply City",
"settings.weather.search_hint": "Search by city name and apply one location.",
"settings.weather.search_required": "Please enter a city keyword first.",
"settings.weather.search_no_results": "No locations were found.",
"settings.weather.search_failed_format": "Search failed: {0}",
"settings.weather.search_result_count_format": "Found {0} locations.",
"settings.weather.search_select_required": "Please select one location from search results.",
"settings.weather.search_applied_format": "Location applied: {0}",
"settings.weather.coordinates_header": "Coordinates",
"settings.weather.coordinates_desc": "Set latitude/longitude and optional key/name.",
"settings.weather.latitude_label": "Latitude",
"settings.weather.longitude_label": "Longitude",
"settings.weather.location_key_placeholder": "Location key (optional)",
"settings.weather.location_name_placeholder": "Display name (optional)",
"settings.weather.apply_coordinates_button": "Apply Coordinates",
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
"settings.weather.preview_header": "Connection Test",
"settings.weather.preview_desc": "Send one test request to verify current settings.",
"settings.weather.preview_button": "Test Fetch",
"settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
"settings.weather.refresh_button": "Refresh",
"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_success_format": "Test success: {0} · {1} · {2}",
"settings.weather.preview_failed_format": "Test fetch failed: {0}",
"settings.weather.preview_unknown": "Unknown",
"settings.weather.alert_filter_header": "Excluded Alerts",
"settings.weather.alert_filter_desc": "Alerts containing these words will not be shown. One rule per line.",
"settings.weather.alert_filter_placeholder": "One keyword per line",
"settings.weather.icon_style_header": "Weather Icon Style",
"settings.weather.icon_style_desc": "Choose Fluent Icon style for weather symbols.",
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
"settings.weather.no_tls_header": "No TLS Weather Request",
"settings.weather.no_tls_desc": "Not recommended. Enable only for incompatible network environments.",
"settings.weather.status_city_empty": "No city location is configured.",
"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.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
"settings.weather.location_apply": "Save",
"settings.weather.location_empty": "Weather location is not set.",
"settings.weather.location_required": "Weather location cannot be empty.",
"settings.weather.location_current_format": "Current weather location: {0}",
"settings.weather.location_saved_format": "Weather location saved: {0}",
"weather.widget.location_not_configured": "Weather location is not configured",
"weather.widget.configure_hint": "Open Settings > Weather to configure",
"weather.widget.loading": "Loading...",
"weather.widget.fetch_failed": "Weather fetch failed",
"weather.widget.retrying": "Retrying automatically",
"weather.widget.location_unknown": "Unknown location",
"weather.widget.condition_clear": "Clear",
"weather.widget.condition_cloudy": "Cloudy",
"weather.widget.condition_rain": "Rain",
"weather.widget.condition_storm": "Thunderstorm",
"weather.widget.condition_snow": "Snow",
"weather.widget.condition_fog": "Fog",
"weather.widget.condition_unknown": "Unknown",
"weather.widget.range_unknown": "-- / --",
"weather.widget.range_format": "{0} / {1}",
"schedule.widget.no_source": "ClassIsland schedule data not found",
"schedule.widget.no_class_today": "No classes today",
"schedule.widget.layout_missing": "Schedule time layout is missing",
"schedule.widget.subject_fallback": "Untitled class",
"schedule.widget.detail_fallback": "No details",
"schedule.settings.title": "Schedule Import",
"schedule.settings.desc": "Import ClassIsland CSES schedules and choose which one is enabled.",
"schedule.settings.add": "Add Schedule",
"schedule.settings.empty": "No imported schedules",
"schedule.settings.unnamed": "Unnamed Schedule",
"schedule.settings.delete": "Delete",
"schedule.settings.picker_title": "Select ClassIsland schedule file",
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "Updated {0:HH:mm}",
"weather.hourly.now": "Now",
"weather.hourly.sunset": "Sunset",
"weather.multiday.today": "Today",
"weather.multiday.tomorrow": "Tomorrow",
"weather.multiday.aqi_format": "Air Quality {0}",
"weather.multiday.aqi_unknown": "Air --",
"settings.region.title": "Region",
"settings.region.description": "Choose language and apply immediately to settings and key UI.",
"settings.region.language_header": "Language",
"settings.region.language_label": "Language",
"settings.region.language_zh": "Chinese",
"settings.region.language_en": "English",
"settings.region.timezone_header": "Time 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.about.title": "About",
"settings.about.version_format": "Version: {0}",
"settings.about.codename_format": "Code Name: {0}",
"settings.about.font_format": "Font: {0}",
"settings.footer": "LanMontainDesktop Settings",
"filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files",
"filepicker.video_files": "Video files",
"common.day": "Day",
"common.night": "Night",
"common.back": "Back",
"common.close": "Close",
"common.recommended": "Recommended",
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.empty": "No Start Menu entries found.",
"launcher.empty_folder": "This folder is empty.",
"launcher.folder_items_format": "{0} apps",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
"component_library.empty": "Swipe to pick a category, tap to open, then drag a widget onto the desktop.",
"component_library.drag_hint": "Drag to place",
"component.delete": "Delete",
"component.edit": "Edit",
"component_category.clock": "Clock",
"component_category.date": "Calendar",
"component_category.weather": "Weather",
"component_category.board": "Board",
"component_category.media": "Media",
"component_category.info": "Info",
"component_category.study": "Study",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
"component.desktop_clock": "Clock",
"component.weather_clock": "Weather Clock",
"component.desktop_timer": "Timer",
"component.desktop_weather": "Weather",
"component.hourly_weather": "Hourly Weather",
"component.multiday_weather": "Multi-day Weather",
"component.extended_weather": "Extended Weather",
"component.class_schedule": "Class Schedule",
"component.music_control": "Music Control",
"component.audio_recorder": "Recorder",
"component.daily_poetry": "Daily Poetry",
"component.daily_artwork": "Daily Artwork",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_noise_curve": "Noise Curve",
"poetry.widget.loading_content": "Loading poetry...",
"poetry.widget.loading_author": "Loading...",
"poetry.widget.fetch_failed": "Poetry fetch failed",
"poetry.widget.fallback_content": "Daily poetry is temporarily unavailable.",
"poetry.widget.fallback_author": "Try again later",
"poetry.widget.unknown_author": "Unknown",
"artwork.widget.loading": "Loading...",
"artwork.widget.loading_title": "Daily Artwork",
"artwork.widget.loading_subtitle": "Fetching today's masterpiece",
"artwork.widget.fetch_failed": "Artwork fetch failed",
"artwork.widget.fallback_title": "Daily Artwork",
"artwork.widget.fallback_artist": "Recommendation backend unavailable",
"artwork.widget.fallback_year": "Try again later",
"artwork.widget.unknown_artist": "Unknown artist",
"music.widget.unsupported": "Music control is not supported on this platform",
"music.widget.unsupported_hint": "This widget requires Windows SMTC",
"music.widget.no_session": "No music source",
"music.widget.no_session_hint": "Install QQ Music / KuGou / NetEase Cloud Music from the app store",
"music.widget.open_player": "Open player",
"music.widget.unknown_title": "Unknown title",
"music.widget.unknown_artist": "Unknown artist",
"music.widget.status.opened": "Opened",
"music.widget.status.changing": "Changing",
"music.widget.status.stopped": "Stopped",
"music.widget.status.playing": "Playing",
"music.widget.status.paused": "Paused",
"recording.widget.title": "Recorder",
"recording.widget.hint.ready": "Tap red button to record",
"recording.widget.hint.recording": "Recording",
"recording.widget.hint.paused": "Paused",
"recording.widget.hint.unsupported": "Microphone is unavailable",
"recording.widget.hint.error": "Recording failed",
"recording.widget.hint.saved_format": "Saved {0}",
"recording.widget.save_picker_title": "Save recording file",
"recording.widget.save_picker_type": "WAV audio",
"study.environment.status_label": "Environment",
"study.environment.status.initializing": "Initializing",
"study.environment.status.ready": "Ready",
"study.environment.status.quiet": "Quiet",
"study.environment.status.noisy": "Noisy",
"study.environment.status.paused": "Paused",
"study.environment.status.error": "Error",
"study.environment.status.unsupported": "Unsupported",
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"study.environment.settings.title": "Environment Widget Settings",
"study.environment.settings.desc": "Configure real-time noise value display on the right side.",
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "Now",
"desktop.add_page": "Add page",
"desktop.delete_page": "Delete page",
"placement.fill": "Fill",
"placement.fit": "Fit",
"placement.stretch": "Stretch",
"placement.center": "Center",
"placement.tile": "Tile"
}

View File

@@ -1,298 +0,0 @@
{
"app.title": "LanMontainDesktop",
"button.back_to_windows": "回到Windows",
"tooltip.back_to_windows": "回到Windows",
"tooltip.open_settings": "设置",
"settings.title": "设置",
"settings.back_to_desktop": "返回桌面",
"settings.nav_header": "设置选项",
"settings.nav.wallpaper": "壁纸",
"settings.nav.grid": "网格",
"settings.nav.color": "颜色",
"settings.nav.status_bar": "状态栏",
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
"settings.wallpaper.pick_button": "浏览文件",
"settings.wallpaper.clear_button": "恢复纯色",
"settings.wallpaper.no_selection": "未选择壁纸。",
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
"settings.wallpaper.image_applied": "图片壁纸已应用。",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.cleared": "背景已恢复为纯色。",
"settings.wallpaper.default_status": "当前使用纯色背景。",
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
"settings.wallpaper.restored": "已恢复保存的壁纸。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
"settings.grid.title": "网格布局",
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1。",
"settings.grid.short_side_label": "短边格数",
"settings.grid.spacing_label": "网格间距",
"settings.grid.spacing_relaxed": "宽松iOS",
"settings.grid.spacing_compact": "紧凑Android",
"settings.grid.edge_inset_label": "屏幕边距",
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
"settings.grid.apply_button": "应用",
"settings.grid.info_format": "网格:{0} 列 x {1} 行 | 单元格 {2:F1}px1:1",
"settings.color.title": "颜色",
"settings.color.description": "切换日夜模式并选择应用主题色。",
"settings.color.day_night_label": "日夜模式",
"settings.color.day_night_on": "夜间",
"settings.color.day_night_off": "日间",
"settings.color.recommended_label": "推荐色",
"settings.color.system_monet_label": "系统莫奈色",
"settings.color.refresh_button": "刷新",
"settings.color.mode_night": "夜间模式已启用",
"settings.color.mode_day": "日间模式已启用",
"settings.color.mode_status_format": "主题模式:{0}。",
"settings.color.monet_refreshed": "莫奈色已刷新。",
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
"settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑",
"settings.status_bar.spacing_mode_relaxed": "宽松",
"settings.status_bar.spacing_mode_custom": "自定义",
"settings.status_bar.spacing_custom_label": "自定义间距(%",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.weather.title": "天气",
"settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索",
"settings.weather.mode_coordinates": "坐标输入",
"settings.weather.auto_refresh": "启动时自动刷新位置",
"settings.weather.city_search_header": "城市搜索",
"settings.weather.city_search_desc": "搜索城市并应用一个天气位置。",
"settings.weather.search_placeholder": "例如:北京",
"settings.weather.search_button": "搜索",
"settings.weather.apply_city_button": "应用城市",
"settings.weather.search_hint": "输入城市名称进行搜索,然后应用一个结果。",
"settings.weather.search_required": "请先输入城市关键字。",
"settings.weather.search_no_results": "未找到匹配的位置。",
"settings.weather.search_failed_format": "搜索失败:{0}",
"settings.weather.search_result_count_format": "共找到 {0} 个位置。",
"settings.weather.search_select_required": "请先从搜索结果中选择一个位置。",
"settings.weather.search_applied_format": "已应用位置:{0}",
"settings.weather.coordinates_header": "坐标输入",
"settings.weather.coordinates_desc": "设置经纬度,并可选填写位置 key 和显示名。",
"settings.weather.latitude_label": "纬度",
"settings.weather.longitude_label": "经度",
"settings.weather.location_key_placeholder": "位置 key可选",
"settings.weather.location_name_placeholder": "显示名称(可选)",
"settings.weather.apply_coordinates_button": "应用坐标",
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
"settings.weather.preview_header": "连接测试",
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
"settings.weather.preview_button": "测试获取",
"settings.weather.preview_panel_header": "天气预览",
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
"settings.weather.refresh_button": "刷新",
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
"settings.weather.preview_failed_format": "测试失败:{0}",
"settings.weather.preview_unknown": "未知",
"settings.weather.alert_filter_header": "排除的气象预警",
"settings.weather.alert_filter_desc": "包含以下关键字的预警将不会显示,每行一条规则。",
"settings.weather.alert_filter_placeholder": "每行输入一个关键字",
"settings.weather.icon_style_header": "天气图标样式",
"settings.weather.icon_style_desc": "选择天气符号使用的 Fluent Icon 风格。",
"settings.weather.icon_style_fluent_regular": "Fluent 线框",
"settings.weather.icon_style_fluent_filled": "Fluent 填充",
"settings.weather.no_tls_header": "禁用 TLS 获取天气",
"settings.weather.no_tls_desc": "不建议启用,仅在网络兼容性较差时尝试。",
"settings.weather.status_city_empty": "尚未配置城市位置。",
"settings.weather.status_city_format": "模式:{0}{1}Key{2}",
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}Key{3}",
"settings.weather.location_header": "天气位置",
"settings.weather.location_desc": "设置天气组件使用的位置。",
"settings.weather.location_placeholder": "例如:北京",
"settings.weather.location_apply": "保存",
"settings.weather.location_empty": "尚未设置天气位置。",
"settings.weather.location_required": "天气位置不能为空。",
"settings.weather.location_current_format": "当前天气位置:{0}",
"settings.weather.location_saved_format": "天气位置已保存:{0}",
"weather.widget.location_not_configured": "尚未配置天气位置",
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
"weather.widget.loading": "加载中...",
"weather.widget.fetch_failed": "天气获取失败",
"weather.widget.retrying": "稍后会自动重试",
"weather.widget.location_unknown": "未知位置",
"weather.widget.condition_clear": "晴",
"weather.widget.condition_cloudy": "多云",
"weather.widget.condition_rain": "雨",
"weather.widget.condition_storm": "雷暴",
"weather.widget.condition_snow": "雪",
"weather.widget.condition_fog": "雾",
"weather.widget.condition_unknown": "未知天气",
"weather.widget.range_unknown": "-- / --",
"weather.widget.range_format": "{0} / {1}",
"schedule.widget.no_source": "未读取到 ClassIsland 课表",
"schedule.widget.no_class_today": "今天没有课程",
"schedule.widget.layout_missing": "课表时间布局缺失",
"schedule.widget.subject_fallback": "未命名课程",
"schedule.widget.detail_fallback": "未设置详情",
"schedule.settings.title": "课表导入",
"schedule.settings.desc": "导入 ClassIsland 的 CSES 课表文件并选择启用项。",
"schedule.settings.add": "添加课表",
"schedule.settings.empty": "暂无导入课表",
"schedule.settings.unnamed": "未命名课表",
"schedule.settings.delete": "删除",
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "更新于 {0:HH:mm}",
"weather.hourly.now": "现在",
"weather.hourly.sunset": "日落",
"weather.multiday.today": "今天",
"weather.multiday.tomorrow": "明天",
"weather.multiday.aqi_format": "空气优 {0}",
"weather.multiday.aqi_unknown": "空气 --",
"settings.region.title": "地区",
"settings.region.description": "选择语言并立即应用到设置与主要界面。",
"settings.region.language_header": "语言",
"settings.region.language_label": "语言",
"settings.region.language_zh": "中文",
"settings.region.language_en": "英文",
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",
"settings.about.codename_format": "版本代号: {0}",
"settings.about.font_format": "字体: {0}",
"settings.footer": "LanMontainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
"filepicker.video_files": "视频文件",
"common.day": "日间",
"common.night": "夜间",
"common.back": "返回",
"common.close": "关闭",
"common.recommended": "推荐",
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.empty": "未找到开始菜单条目。",
"launcher.empty_folder": "此文件夹为空。",
"launcher.folder_items_format": "{0} 个应用",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
"component_library.empty": "左右滑动选择类别,点击进入,然后拖动组件到桌面放置。",
"component_library.drag_hint": "拖动放置",
"component.delete": "删除",
"component.edit": "编辑",
"component_category.clock": "时钟",
"component_category.date": "日历",
"component_category.weather": "天气",
"component_category.board": "白板",
"component_category.media": "媒体",
"component_category.info": "信息推荐",
"component_category.study": "自习",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
"component.desktop_clock": "时钟",
"component.weather_clock": "天气时钟",
"component.desktop_timer": "计时器",
"component.desktop_weather": "天气",
"component.hourly_weather": "小时天气",
"component.multiday_weather": "多日天气",
"component.extended_weather": "扩展天气",
"component.class_schedule": "课表",
"component.music_control": "音乐控制",
"component.audio_recorder": "录音",
"component.daily_poetry": "每日诗词",
"component.daily_artwork": "每日名画",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
"component.study_noise_curve": "噪音曲线",
"poetry.widget.loading_content": "正在加载诗词",
"poetry.widget.loading_author": "加载中",
"poetry.widget.fetch_failed": "诗词获取失败",
"poetry.widget.fallback_content": "今日诗词暂不可用",
"poetry.widget.fallback_author": "稍后重试",
"poetry.widget.unknown_author": "佚名",
"artwork.widget.loading": "加载中",
"artwork.widget.loading_title": "每日名画",
"artwork.widget.loading_subtitle": "正在获取今日名画",
"artwork.widget.fetch_failed": "名画获取失败",
"artwork.widget.fallback_title": "每日名画",
"artwork.widget.fallback_artist": "推荐后端不可用",
"artwork.widget.fallback_year": "稍后重试",
"artwork.widget.unknown_artist": "未知作者",
"music.widget.unsupported": "当前平台不支持音乐控制",
"music.widget.unsupported_hint": "该组件仅支持 Windows SMTC",
"music.widget.no_session": "暂无音源",
"music.widget.no_session_hint": "点击前往应用商店下载“QQ音乐/酷狗音乐/网易云音乐”后使用",
"music.widget.open_player": "打开播放器",
"music.widget.unknown_title": "未知歌曲",
"music.widget.unknown_artist": "未知艺术家",
"music.widget.status.opened": "已打开",
"music.widget.status.changing": "切换中",
"music.widget.status.stopped": "已停止",
"music.widget.status.playing": "播放中",
"music.widget.status.paused": "已暂停",
"recording.widget.title": "录音",
"recording.widget.hint.ready": "点击红色按钮开始",
"recording.widget.hint.recording": "录音中",
"recording.widget.hint.paused": "已暂停",
"recording.widget.hint.unsupported": "麦克风不可用",
"recording.widget.hint.error": "录音失败",
"recording.widget.hint.saved_format": "已保存 {0}",
"recording.widget.save_picker_title": "保存录音文件",
"recording.widget.save_picker_type": "WAV 音频",
"study.environment.status_label": "环境状态",
"study.environment.status.initializing": "初始化中",
"study.environment.status.ready": "待机",
"study.environment.status.quiet": "安静",
"study.environment.status.noisy": "嘈杂",
"study.environment.status.paused": "已暂停",
"study.environment.status.error": "错误",
"study.environment.status.unsupported": "不支持",
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
"study.environment.settings.show_dbfs": "显示 dBFS",
"study.environment.settings.hint": "至少启用一种显示方式。",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "现在",
"desktop.add_page": "新增页面",
"desktop.delete_page": "删除页面",
"placement.fill": "填充",
"placement.fit": "适应",
"placement.stretch": "拉伸",
"placement.center": "居中",
"placement.tile": "平铺"
}

View File

@@ -1,21 +0,0 @@
using System;
namespace LanMontainDesktop.Models;
public sealed record DailyArtworkSnapshot(
string Provider,
string Title,
string? Artist,
string? Year,
string? Museum,
string? ArtworkUrl,
string? ImageUrl,
DateTimeOffset FetchedAt);
public sealed record DailyPoetrySnapshot(
string Provider,
string Content,
string? Origin,
string? Author,
string? Category,
DateTimeOffset FetchedAt);

View File

@@ -1,75 +0,0 @@
# Desktop Packaging Guide
## Prerequisites
- Install `.NET SDK 10`
- Windows installer build only:
- Install `Inno Setup 6` (`ISCC.exe`)
## Local packaging commands
### Windows installer (`win-x64`)
```powershell
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/win-x64`
- Installer: `artifacts/installer`
### Linux package (`linux-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/linux-x64`
- Zip package: `artifacts/packages/LanMontainDesktop-1.0.1-linux-x64.zip`
### macOS package (`osx-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/osx-x64`
- Zip package: `artifacts/packages/LanMontainDesktop-1.0.1-osx-x64.zip`
## Optional script flags
```powershell
# Publish only (skip Windows installer step)
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
# Publish only (skip Linux/macOS zip package step)
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
```
## Runtime dependency notes
- Linux build does not bundle a native `libvlc` package from NuGet.
- Install VLC runtime on target machine, for example:
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
- macOS packaging target in CI is currently `osx-x64`.
## CI workflow
- Workflow file: `.github/workflows/windows-ci.yml`
- Workflow name: `Desktop CI`
Jobs:
- `Validate Build (Windows)` runs on every push and pull request.
- Package flow runs on manual trigger or `v*` tag push:
- `Resolve Package Version` (single shared version source)
- `Package (Windows)` (`win-x64` installer)
- `Package (Linux)` (`linux-x64` zip)
- `Package (macOS)` (`osx-x64` zip)
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
### Trigger manual packaging
1. Open GitHub Actions.
2. Choose `Desktop CI`.
3. Click `Run workflow`.
4. Optional: set `version` input, for example `1.0.1`.
### Trigger by tag
```powershell
git tag v1.0.1
git push origin v1.0.1
```

View File

@@ -1,23 +0,0 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using System;
namespace LanMontainDesktop;
sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseDesktopWebView()
.WithInterFont()
.LogToTrace();
}

View File

@@ -1,62 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using LanMontainDesktop.Models;
namespace LanMontainDesktop.Services;
public sealed class AppSettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private readonly string _settingsPath;
public AppSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMontainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public AppSettingsSnapshot Load()
{
try
{
if (!File.Exists(_settingsPath))
{
return new AppSettingsSnapshot();
}
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions);
return snapshot ?? new AppSettingsSnapshot();
}
catch
{
return new AppSettingsSnapshot();
}
}
public void Save(AppSettingsSnapshot snapshot)
{
try
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
}

View File

@@ -1,692 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMontainDesktop.Models;
namespace LanMontainDesktop.Services;
public sealed record DailyArtworkQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record DailyPoetryQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
string? ErrorCode = null,
string? ErrorMessage = null)
{
public static RecommendationQueryResult<T> Ok(T data)
{
return new RecommendationQueryResult<T>(true, data);
}
public static RecommendationQueryResult<T> Fail(string errorCode, string errorMessage)
{
return new RecommendationQueryResult<T>(false, default, errorCode, errorMessage);
}
}
public sealed record RecommendationBackendOptions
{
public string BaseUrl { get; init; } = "http://127.0.0.1:5057";
public string DailyArtworkPath { get; init; } = "/api/recommendation/daily-artwork";
public string DailyPoetryPath { get; init; } = "/api/recommendation/daily-poetry";
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
public string ArtInstituteArtworkApiTemplate { get; init; } =
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
public string ArtInstituteImageUrlTemplate { get; init; } =
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);
public int DefaultArtworkCandidateCount { get; init; } = 50;
}
public interface IRecommendationInfoService
{
Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}
public sealed class RecommendationBackendService : IRecommendationInfoService, IDisposable
{
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
private readonly RecommendationBackendOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly object _cacheGate = new();
private DailyArtworkCacheEntry? _dailyArtworkCache;
private DailyPoetryCacheEntry? _dailyPoetryCache;
public RecommendationBackendService(
RecommendationBackendOptions? options = null,
HttpClient? httpClient = null)
{
_options = options ?? new RecommendationBackendOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
{
Timeout = _options.RequestTimeout
};
_ownsHttpClient = true;
}
else
{
_httpClient = httpClient;
_ownsHttpClient = false;
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void ClearCache()
{
lock (_cacheGate)
{
_dailyArtworkCache = null;
_dailyPoetryCache = null;
}
}
public async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyPoetryQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyPoetryFromCache(out var cached))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(cached);
}
var uri = BuildDailyPoetryUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
string responseText;
try
{
using var response = await _httpClient.GetAsync(uri, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
cancellationToken);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"network_error",
ex.Message,
cancellationToken);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var success = ReadBool(root, "success");
if (!success.GetValueOrDefault())
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
ReadString(root, "errorCode") ?? "upstream_error",
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
cancellationToken);
}
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
"Daily poetry payload is missing.",
cancellationToken);
}
var content = ReadString(dataNode, "content");
if (string.IsNullOrWhiteSpace(content))
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
"Poetry content is missing.",
cancellationToken);
}
var snapshot = new DailyPoetrySnapshot(
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
Content: content.Trim(),
Origin: ReadString(dataNode, "origin"),
Author: ReadString(dataNode, "author"),
Category: ReadString(dataNode, "category"),
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
SetDailyPoetryCache(snapshot);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return await TryDirectPoetryFallbackAsync(
normalizedQuery,
"parse_error",
ex.Message,
cancellationToken);
}
}
public async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var uri = BuildDailyArtworkUri(normalizedQuery.Locale, normalizedQuery.ForceRefresh);
string responseText;
try
{
using var response = await _httpClient.GetAsync(uri, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}",
cancellationToken);
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"network_error",
ex.Message,
cancellationToken);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var success = ReadBool(root, "success");
if (!success.GetValueOrDefault())
{
return await TryDirectFallbackAsync(
normalizedQuery,
ReadString(root, "errorCode") ?? "upstream_error",
ReadString(root, "errorMessage") ?? "Recommendation backend returned an unsuccessful response.",
cancellationToken);
}
if (!root.TryGetProperty("data", out var dataNode) || dataNode.ValueKind != JsonValueKind.Object)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
"Daily artwork payload is missing.",
cancellationToken);
}
var title = ReadString(dataNode, "title");
if (string.IsNullOrWhiteSpace(title))
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
"Artwork title is missing.",
cancellationToken);
}
var snapshot = new DailyArtworkSnapshot(
Provider: ReadString(dataNode, "provider") ?? "RecommendationBackend",
Title: title.Trim(),
Artist: ReadString(dataNode, "artist"),
Year: ReadString(dataNode, "year"),
Museum: ReadString(dataNode, "museum"),
ArtworkUrl: ReadString(dataNode, "artworkUrl"),
ImageUrl: ReadString(dataNode, "imageUrl"),
FetchedAt: ParseDateTimeOffset(ReadString(dataNode, "fetchedAt")) ?? DateTimeOffset.UtcNow);
SetDailyArtworkCache(snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return await TryDirectFallbackAsync(
normalizedQuery,
"parse_error",
ex.Message,
cancellationToken);
}
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> TryDirectFallbackAsync(
DailyArtworkQuery query,
string errorCode,
string errorMessage,
CancellationToken cancellationToken)
{
var fallback = await GetDailyArtworkDirectAsync(query, cancellationToken);
if (fallback.Success && fallback.Data is not null)
{
SetDailyArtworkCache(fallback.Data);
return fallback;
}
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
? "Direct upstream fallback failed."
: fallback.ErrorMessage;
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
errorCode,
$"{errorMessage}; fallback: {fallbackMessage}");
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkDirectAsync(
DailyArtworkQuery query,
CancellationToken cancellationToken)
{
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Artwork list is missing.");
}
var candidates = new List<ArtworkCandidate>();
foreach (var item in dataArray.EnumerateArray())
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
{
continue;
}
var artist = ReadString(item, "artist_title");
if (string.IsNullOrWhiteSpace(artist))
{
artist = ReadFirstNonEmptyLine(ReadString(item, "artist_display"));
}
candidates.Add(new ArtworkCandidate(
title.Trim(),
artist,
ReadString(item, "date_display"),
ReadString(item, "api_link"),
imageId.Trim()));
}
if (candidates.Count == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No artwork candidates were returned.");
}
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Count];
var snapshot = new DailyArtworkSnapshot(
Provider: "ArtInstituteOfChicago",
Title: selected.Title,
Artist: selected.Artist,
Year: selected.Year,
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
FetchedAt: DateTimeOffset.UtcNow);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> TryDirectPoetryFallbackAsync(
DailyPoetryQuery query,
string errorCode,
string errorMessage,
CancellationToken cancellationToken)
{
var fallback = await GetDailyPoetryDirectAsync(query, cancellationToken);
if (fallback.Success && fallback.Data is not null)
{
SetDailyPoetryCache(fallback.Data);
return fallback;
}
var fallbackMessage = string.IsNullOrWhiteSpace(fallback.ErrorMessage)
? "Direct upstream fallback failed."
: fallback.ErrorMessage;
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
errorCode,
$"{errorMessage}; fallback: {fallbackMessage}");
}
private async Task<RecommendationQueryResult<DailyPoetrySnapshot>> GetDailyPoetryDirectAsync(
DailyPoetryQuery query,
CancellationToken cancellationToken)
{
_ = query;
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
var content = ReadString(root, "content");
if (string.IsNullOrWhiteSpace(content))
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail(
"upstream_parse_error",
"Poetry content is empty.");
}
var snapshot = new DailyPoetrySnapshot(
Provider: "JinriShici",
Content: content.Trim(),
Origin: ReadString(root, "origin"),
Author: ReadString(root, "author"),
Category: ReadString(root, "category"),
FetchedAt: DateTimeOffset.UtcNow);
return RecommendationQueryResult<DailyPoetrySnapshot>.Ok(snapshot);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyPoetrySnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private Uri BuildDailyArtworkUri(string? locale, bool forceRefresh)
{
var baseUrl = _options.BaseUrl.TrimEnd('/');
var path = _options.DailyArtworkPath.StartsWith("/", StringComparison.Ordinal)
? _options.DailyArtworkPath
: $"/{_options.DailyArtworkPath}";
var localePart = string.IsNullOrWhiteSpace(locale)
? string.Empty
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
var forcePart = forceRefresh ? "true" : "false";
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
}
private Uri BuildDailyPoetryUri(string? locale, bool forceRefresh)
{
var baseUrl = _options.BaseUrl.TrimEnd('/');
var path = _options.DailyPoetryPath.StartsWith("/", StringComparison.Ordinal)
? _options.DailyPoetryPath
: $"/{_options.DailyPoetryPath}";
var localePart = string.IsNullOrWhiteSpace(locale)
? string.Empty
: $"locale={Uri.EscapeDataString(locale.Trim())}&";
var forcePart = forceRefresh ? "true" : "false";
return new Uri($"{baseUrl}{path}?{localePart}forceRefresh={forcePart}", UriKind.Absolute);
}
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyArtworkCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
_dailyArtworkCache = new DailyArtworkCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private bool TryGetDailyPoetryFromCache(out DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyPoetryCache is not null && _dailyPoetryCache.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyPoetryCache.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetDailyPoetryCache(DailyPoetrySnapshot snapshot)
{
lock (_cacheGate)
{
_dailyPoetryCache = new DailyPoetryCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
}
private static string? ReadString(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.String => target.Value.GetString(),
JsonValueKind.Number => target.Value.GetRawText(),
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => null
};
}
private static bool? ReadBool(JsonElement node, params string[] path)
{
var target = TryGetNode(node, path);
if (!target.HasValue)
{
return null;
}
return target.Value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String when bool.TryParse(target.Value.GetString(), out var parsed) => parsed,
_ => null
};
}
private static JsonElement? TryGetNode(JsonElement node, params string[] path)
{
var current = node;
foreach (var segment in path)
{
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(segment, out var next))
{
return null;
}
current = next;
}
return current;
}
private static DateTimeOffset? ParseDateTimeOffset(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return DateTimeOffset.TryParse(value, out var parsed) ? parsed : null;
}
private string? BuildArtworkImageUrl(string? imageId)
{
if (string.IsNullOrWhiteSpace(imageId))
{
return null;
}
return string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteImageUrlTemplate,
imageId.Trim());
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
return text
.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)
.Select(line => line.Trim())
.FirstOrDefault(line => !string.IsNullOrWhiteSpace(line));
}
private static DateOnly GetChinaLocalDate()
{
var now = DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(8));
return DateOnly.FromDateTime(now.Date);
}
private static string Truncate(string? text, int maxLength)
{
if (string.IsNullOrEmpty(text))
{
return string.Empty;
}
return text.Length <= maxLength
? text
: $"{text[..maxLength]}...";
}
}

View File

@@ -1,542 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMontainDesktop.Models;
using LanMontainDesktop.Services;
namespace LanMontainDesktop.Views.Components;
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget
{
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
new Dictionary<DayOfWeek, string>
{
[DayOfWeek.Monday] = "星期一",
[DayOfWeek.Tuesday] = "星期二",
[DayOfWeek.Wednesday] = "星期三",
[DayOfWeek.Thursday] = "星期四",
[DayOfWeek.Friday] = "星期五",
[DayOfWeek.Saturday] = "星期六",
[DayOfWeek.Sunday] = "星期日"
};
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMontainDesktop/Assets/Fonts#MiSans");
private static readonly HttpClient ImageHttpClient = new()
{
Timeout = TimeSpan.FromSeconds(10)
};
private const string BrowserUserAgent =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36";
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationBackendService();
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromHours(6)
};
private readonly AppSettingsService _settingsService = new();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private Bitmap? _currentArtworkBitmap;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
public DailyArtworkWidget()
{
InitializeComponent();
DateTextBlock.FontFamily = MiSansFontFamily;
WeekdayTextBlock.FontFamily = MiSansFontFamily;
PaintingTitleTextBlock.FontFamily = MiSansFontFamily;
ArtistTextBlock.FontFamily = MiSansFontFamily;
YearTextBlock.FontFamily = MiSansFontFamily;
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
UpdateDateLabels();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
InfoPanel.Padding = new Thickness(
Math.Clamp(18 * scale, 10, 28),
Math.Clamp(14 * scale, 8, 22),
Math.Clamp(18 * scale, 10, 28),
Math.Clamp(14 * scale, 8, 22));
DateInfoStack.Margin = new Thickness(
Math.Clamp(22 * scale, 10, 36),
0,
0,
Math.Clamp(20 * scale, 10, 34));
DateInfoStack.Spacing = Math.Clamp(2 * scale, 1, 6);
ImageBottomShade.Height = Math.Clamp(132 * scale, 64, 182);
StatusTextBlock.FontSize = Math.Clamp(16 * scale, 10, 24);
BrickPatternCanvas.Opacity = Math.Clamp(0.44 * scale, 0.20, 0.50);
UpdateAdaptiveLayout();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
_refreshTimer.Start();
_ = RefreshArtworkAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
DisposeArtworkBitmap();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshArtworkAsync(forceRefresh: false);
}
private async Task RefreshArtworkAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
UpdateDateLabels();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new DailyArtworkQuery(
Locale: _languageCode,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
await ApplySnapshotAsync(result.Data, cts.Token);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private async Task ApplySnapshotAsync(DailyArtworkSnapshot snapshot, CancellationToken cancellationToken)
{
PaintingTitleTextBlock.Text = BuildQuotedTitle(snapshot.Title);
var artist = string.IsNullOrWhiteSpace(snapshot.Artist)
? L("artwork.widget.unknown_artist", "Unknown artist")
: snapshot.Artist.Trim();
ArtistTextBlock.Text = NormalizeCompactText(artist);
YearTextBlock.Text = ResolveYearText(snapshot);
StatusTextBlock.IsVisible = false;
UpdateAdaptiveLayout();
var bitmap = await TryLoadArtworkBitmapAsync(snapshot.ImageUrl, cancellationToken);
if (cancellationToken.IsCancellationRequested || !_isAttached)
{
bitmap?.Dispose();
return;
}
SetArtworkBitmap(bitmap);
}
private static async Task<Bitmap?> TryLoadArtworkBitmapAsync(string? imageUrl, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(imageUrl))
{
return null;
}
using var request = new HttpRequestMessage(HttpMethod.Get, imageUrl.Trim());
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8");
if (Uri.TryCreate(imageUrl.Trim(), UriKind.Absolute, out var imageUri))
{
request.Headers.Referrer = new Uri($"{imageUri.Scheme}://{imageUri.Host}/", UriKind.Absolute);
}
using var response = await ImageHttpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
var memory = new MemoryStream();
await stream.CopyToAsync(memory, cancellationToken);
memory.Position = 0;
return new Bitmap(memory);
}
private void ApplyLoadingState()
{
StatusTextBlock.IsVisible = true;
StatusTextBlock.Text = L("artwork.widget.loading", "Loading...");
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.loading_title", "Daily Artwork"));
ArtistTextBlock.Text = L("artwork.widget.loading_subtitle", "Fetching today's masterpiece");
YearTextBlock.Text = "--";
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
StatusTextBlock.IsVisible = true;
StatusTextBlock.Text = L("artwork.widget.fetch_failed", "Artwork fetch failed");
PaintingTitleTextBlock.Text = BuildQuotedTitle(L("artwork.widget.fallback_title", "Daily Artwork"));
ArtistTextBlock.Text = L("artwork.widget.fallback_artist", "Recommendation backend unavailable");
YearTextBlock.Text = L("artwork.widget.fallback_year", "Try again later");
UpdateAdaptiveLayout();
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
var leftStar = totalWidth < _currentCellSize * 4.2 ? 2.0 : 2.08;
MainLayoutGrid.ColumnDefinitions[0].Width = new GridLength(leftStar, GridUnitType.Star);
MainLayoutGrid.ColumnDefinitions[1].Width = new GridLength(1, GridUnitType.Star);
var rightPanelWidth = Math.Max(84, totalWidth / (leftStar + 1));
var rightContentWidth = Math.Max(58, rightPanelWidth - InfoPanel.Padding.Left - InfoPanel.Padding.Right);
var leftPanelWidth = Math.Max(84, totalWidth - rightPanelWidth);
var leftContentWidth = Math.Max(52, leftPanelWidth - DateInfoStack.Margin.Left - 10);
var dateBase = Math.Clamp(52 * scale, 18, 72);
DateTextBlock.FontSize = FitFontSize(
DateTextBlock.Text,
leftContentWidth,
Math.Max(22, totalHeight * 0.22),
maxLines: 1,
minFontSize: Math.Max(14, dateBase * 0.70),
maxFontSize: dateBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.02);
DateTextBlock.LineHeight = DateTextBlock.FontSize * 1.02;
WeekdayTextBlock.FontSize = FitFontSize(
WeekdayTextBlock.Text,
leftContentWidth,
Math.Max(22, totalHeight * 0.24),
maxLines: 1,
minFontSize: Math.Max(14, dateBase * 0.70),
maxFontSize: dateBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.03);
WeekdayTextBlock.LineHeight = WeekdayTextBlock.FontSize * 1.03;
var titleBase = Math.Clamp(44 * scale, 16, 58);
PaintingTitleTextBlock.MaxWidth = rightContentWidth;
PaintingTitleTextBlock.FontSize = FitFontSize(
PaintingTitleTextBlock.Text,
rightContentWidth,
Math.Max(20, totalHeight * 0.34),
maxLines: 2,
minFontSize: Math.Max(12, titleBase * 0.62),
maxFontSize: titleBase,
weight: FontWeight.Bold,
lineHeightFactor: 1.08);
PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.08;
var artistBase = Math.Clamp(26 * scale, 11, 34);
ArtistTextBlock.MaxWidth = rightContentWidth;
ArtistTextBlock.FontSize = FitFontSize(
ArtistTextBlock.Text,
rightContentWidth,
Math.Max(18, totalHeight * 0.24),
maxLines: 2,
minFontSize: Math.Max(10, artistBase * 0.72),
maxFontSize: artistBase,
weight: FontWeight.SemiBold,
lineHeightFactor: 1.12);
ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.12;
var yearBase = Math.Clamp(22 * scale, 10, 30);
YearTextBlock.MaxWidth = rightContentWidth;
YearTextBlock.FontSize = FitFontSize(
YearTextBlock.Text,
rightContentWidth,
Math.Max(14, totalHeight * 0.12),
maxLines: 1,
minFontSize: Math.Max(9.5, yearBase * 0.78),
maxFontSize: yearBase,
weight: FontWeight.Medium,
lineHeightFactor: 1.04);
YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.04;
RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136);
RightPanelSeparator.Margin = new Thickness(0, 0, 0, Math.Clamp(10 * scale, 4, 14));
BrickPatternCanvas.Opacity = totalWidth < _currentCellSize * 4.2
? 0.34
: Math.Clamp(0.44 * scale, 0.24, 0.50);
}
private void SetArtworkBitmap(Bitmap? bitmap)
{
DisposeArtworkBitmap();
_currentArtworkBitmap = bitmap;
ArtworkImage.Source = bitmap;
}
private void DisposeArtworkBitmap()
{
if (_currentArtworkBitmap is null)
{
return;
}
if (ReferenceEquals(ArtworkImage.Source, _currentArtworkBitmap))
{
ArtworkImage.Source = null;
}
_currentArtworkBitmap.Dispose();
_currentArtworkBitmap = null;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void UpdateDateLabels()
{
var now = DateTime.Now;
DateTextBlock.Text = now.ToString("MM/dd", CultureInfo.InvariantCulture);
if (string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase) &&
ZhWeekdays.TryGetValue(now.DayOfWeek, out var weekdayZh))
{
WeekdayTextBlock.Text = weekdayZh;
return;
}
var culture = ResolveCulture();
WeekdayTextBlock.Text = culture.DateTimeFormat.GetDayName(now.DayOfWeek);
}
private string ResolveYearText(DailyArtworkSnapshot snapshot)
{
if (!string.IsNullOrWhiteSpace(snapshot.Year))
{
return snapshot.Year.Trim();
}
if (!string.IsNullOrWhiteSpace(snapshot.Museum))
{
return snapshot.Museum.Trim();
}
return "--";
}
private static string BuildQuotedTitle(string title)
{
var normalized = NormalizeCompactText(title);
if (string.IsNullOrWhiteSpace(normalized))
{
normalized = "Untitled";
}
return $"“{normalized}”";
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private CultureInfo ResolveCulture()
{
try
{
return CultureInfo.GetCultureInfo(_languageCode);
}
catch
{
return CultureInfo.InvariantCulture;
}
}
private double ResolveScale()
{
var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.62, 2.0);
var widthScale = Bounds.Width > 1
? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0)
: 1;
var heightScale = Bounds.Height > 1
? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0)
: 1;
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static double FitFontSize(
string? text,
double maxWidth,
double maxHeight,
int maxLines,
double minFontSize,
double maxFontSize,
FontWeight weight,
double lineHeightFactor)
{
var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim();
var min = Math.Max(6, minFontSize);
var max = Math.Max(min, maxFontSize);
var low = min;
var high = max;
var best = min;
for (var i = 0; i < 18; i++)
{
var candidate = (low + high) / 2d;
var lineHeight = candidate * lineHeightFactor;
var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight);
var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight)));
var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines);
if (fits)
{
best = candidate;
low = candidate;
}
else
{
high = candidate;
}
}
return best;
}
private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight)
{
var probe = new TextBlock
{
Text = text,
FontFamily = MiSansFontFamily,
FontSize = fontSize,
FontWeight = weight,
TextWrapping = TextWrapping.Wrap,
LineHeight = lineHeight
};
probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity));
return probe.DesiredSize;
}
}

Binary file not shown.

View File

@@ -1,56 +0,0 @@
#define MyAppName "LanMontainDesktop"
#define MyAppPublisher "LanMontainDesktop Team"
#define MyAppExeName "LanMontainDesktop.exe"
#ifndef MyAppVersion
#define MyAppVersion "0.0.0"
#endif
#ifndef PublishDir
#define PublishDir "..\artifacts\publish\win-x64"
#endif
#ifndef MyOutputDir
#define MyOutputDir "..\artifacts\installer"
#endif
#ifndef MyAppArch
#define MyAppArch "x64"
#endif
[Setup]
AppId={{5A058B0D-F95D-4A18-B9A0-93F843655DDB}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyAppName}-Setup-{#MyAppVersion}
Compression=lzma2/ultra64
SolidCompression=yes
WizardStyle=modern
PrivilegesRequired=admin
DisableProgramGroupPage=yes
#if MyAppArch == "x64"
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
#endif
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"
[Files]
Source: "{#PublishDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
public interface IPluginContext : IPluginRuntimeContext
{
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginMessageBus
{
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
void Publish<TMessage>(TMessage message);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPackageManager
{
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
PluginPackageInstallResult InstallPackage(string packagePath);
}

View 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);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record InstalledPluginInfo(
PluginManifest Manifest,
bool IsEnabled,
bool IsLoaded,
bool IsPackage,
string? ErrorMessage);

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

@@ -0,0 +1,95 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentRegistration
{
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece",
string category = "Plugins",
int minWidthCells = 2,
int minHeightCells = 2,
bool allowDesktopPlacement = true,
bool allowStatusBarPlacement = false,
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
string? displayNameLocalizationKey = null,
Func<double, double>? cornerRadiusResolver = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentNullException.ThrowIfNull(controlFactory);
ComponentId = componentId.Trim();
DisplayName = displayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = iconKey.Trim();
Category = category.Trim();
MinWidthCells = Math.Max(1, minWidthCells);
MinHeightCells = Math.Max(1, minHeightCells);
AllowDesktopPlacement = allowDesktopPlacement;
AllowStatusBarPlacement = allowStatusBarPlacement;
ResizeMode = resizeMode;
CornerRadiusResolver = cornerRadiusResolver;
}
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece",
string category = "Plugins",
int minWidthCells = 2,
int minHeightCells = 2,
bool allowDesktopPlacement = true,
bool allowStatusBarPlacement = false,
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
string? displayNameLocalizationKey = null,
Func<double, double>? cornerRadiusResolver = null)
: this(
componentId,
displayName,
(_, context) => controlFactory(context),
iconKey,
category,
minWidthCells,
minHeightCells,
allowDesktopPlacement,
allowStatusBarPlacement,
resizeMode,
displayNameLocalizationKey,
cornerRadiusResolver)
{
}
public string ComponentId { get; }
public string DisplayName { get; }
public string? DisplayNameLocalizationKey { get; }
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }
public string Category { get; }
public int MinWidthCells { get; }
public int MinHeightCells { get; }
public bool AllowDesktopPlacement { get; }
public bool AllowStatusBarPlacement { get; }
public PluginDesktopComponentResizeMode ResizeMode { get; }
public Func<double, double>? CornerRadiusResolver { get; }
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginDesktopComponentResizeMode
{
Proportional = 0,
Free = 1
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginEntranceAttribute : Attribute
{
}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginHostPropertyKeys
{
public const string HostApplicationName = "HostApplicationName";
public const string HostVersion = "HostVersion";
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
public const string HostLanguageCode = "HostLanguageCode";
}

View File

@@ -0,0 +1,114 @@
using System.Globalization;
using System.Text.Json;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginLocalizer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase);
public PluginLocalizer(string pluginDirectory, string? languageCode)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
PluginDirectory = pluginDirectory;
LanguageCode = NormalizeLanguageCode(languageCode);
}
public string PluginDirectory { get; }
public string LanguageCode { get; }
public static PluginLocalizer Create(IPluginRuntimeContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
}
public static PluginLocalizer Create(PluginDesktopComponentContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
}
public string GetString(string key, string fallback)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var primaryTable = LoadLanguageTable(LanguageCode);
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
{
var fallbackTable = LoadLanguageTable("en-US");
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return fallback;
}
public string Format(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
}
public static string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en-US"
: "zh-CN";
}
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
rawValue is string languageCode
? NormalizeLanguageCode(languageCode)
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
}
private Dictionary<string, string> LoadLanguageTable(string languageCode)
{
if (_cache.TryGetValue(languageCode, out var table))
{
return table;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
{
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
}
}
}
catch
{
// Keep empty localization table for plugin resilience.
}
_cache[languageCode] = result;
return result;
}
}

View File

@@ -0,0 +1,144 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginManifest(
string Id,
string Name,
string EntranceAssembly,
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null,
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static PluginManifest Load(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
using var stream = File.OpenRead(manifestPath);
return Load(stream, manifestPath);
}
public static PluginManifest Load(Stream stream, string sourceName)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
if (manifest is null)
{
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
}
return manifest.NormalizeAndValidate(sourceName);
}
public string ResolveEntranceAssemblyPath(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
if (Path.IsPathRooted(EntranceAssembly))
{
return Path.GetFullPath(EntranceAssembly);
}
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
}
private PluginManifest NormalizeAndValidate(string manifestPath)
{
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
Name = RequireValue(Name, nameof(Name), manifestPath),
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
SharedContracts = normalizedSharedContracts
};
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
}
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
{
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
}
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'. Upgrade the plugin to API {PluginSdkInfo.ApiVersion}.");
}
return normalized;
}
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
string manifestPath,
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
{
if (sharedContracts is null || sharedContracts.Count == 0)
{
return Array.Empty<PluginSharedContractReference>();
}
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var contract in sharedContracts)
{
if (contract is null)
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
}
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
if (!seenIds.Add(contractKey))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
}
normalized.Add(normalizedContract);
}
return normalized;
}
private static string RequireValue(string? value, string propertyName, string manifestPath)
{
var normalized = NormalizeOptionalValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
}
return normalized;
}
private static string? NormalizeOptionalValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPackageInstallResult(
PluginManifest Manifest,
bool ReplacedExisting,
bool RestartRequired);

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "2.0.0";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";
public const string RuntimeDirectoryName = ".runtime";
public const string ExtractedPackagesDirectoryName = "packages";
public const string PackagedDataDirectoryName = "data";
}

View File

@@ -0,0 +1,95 @@
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.PluginSdk;
public static class PluginServiceCollectionExtensions
{
public static IServiceCollection AddPluginSettingsPage<TControl>(
this IServiceCollection services,
string id,
string title,
int sortOrder = 0)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(new PluginSettingsPageRegistration(
id,
title,
provider => ActivatorUtilities.CreateInstance<TControl>(provider),
sortOrder));
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 AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
ArgumentNullException.ThrowIfNull(services);
EnsureSingletonRegistration<TContract, TImplementation>(services);
if (!services.Any(descriptor =>
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
existing.ContractType == typeof(TContract) &&
existing.ImplementationType == typeof(TImplementation)))
{
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
}
return services;
}
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
where TContract : class
where TImplementation : class, TContract
{
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
if (contractDescriptor is null)
{
services.AddSingleton<TContract, TImplementation>();
return;
}
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
{
throw new InvalidOperationException(
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsPageRegistration
{
public PluginSettingsPageRegistration(
string id,
string title,
Func<IServiceProvider, 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 PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
int sortOrder = 0)
: this(id, title, _ => contentFactory(), sortOrder)
{
}
public string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<IServiceProvider, Control> ContentFactory { get; }
}

View File

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

View File

@@ -0,0 +1,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>

View 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; }
}
}

View 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;
}
}

View 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>

View 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()));
}
}

7
LanMountainDesktop.slnx Normal file
View 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>

View File

@@ -1,4 +1,4 @@
name: Desktop CI
name: Desktop CI
on:
push:
@@ -20,7 +20,7 @@ concurrency:
env:
DOTNET_VERSION: "10.0.x"
PROJECT_PATH: "LanMontainDesktop.csproj"
PROJECT_PATH: "LanMountainDesktop.csproj"
jobs:
validate:
@@ -114,17 +114,17 @@ jobs:
- name: Windows
runner: windows-latest
rid: win-x64
artifact_name: LanMontainDesktop-Setup
artifact_name: LanMountainDesktop-Setup
artifact_path: artifacts/installer/*.exe
- name: Linux
runner: ubuntu-latest
rid: linux-x64
artifact_name: LanMontainDesktop-linux-x64
artifact_name: LanMountainDesktop-linux-x64
artifact_path: artifacts/packages/*linux-x64*.zip
- name: macOS
runner: macos-latest
rid: osx-x64
artifact_name: LanMontainDesktop-osx-x64
artifact_name: LanMountainDesktop-osx-x64
artifact_path: artifacts/packages/*osx-x64*.zip
permissions:
contents: read
@@ -176,7 +176,7 @@ jobs:
if: matrix.rid == 'win-x64'
uses: actions/upload-artifact@v4
with:
name: LanMontainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }}
name: LanMountainDesktop-Publish-win-x64-${{ needs.resolve_version.outputs.value }}
path: artifacts/publish/win-x64/**
if-no-files-found: error
@@ -193,19 +193,19 @@ jobs:
- name: Download Windows Installer Artifact
uses: actions/download-artifact@v4
with:
name: LanMontainDesktop-Setup-${{ needs.resolve_version.outputs.value }}
name: LanMountainDesktop-Setup-${{ needs.resolve_version.outputs.value }}
path: release-assets/windows
- name: Download Linux Package Artifact
uses: actions/download-artifact@v4
with:
name: LanMontainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }}
name: LanMountainDesktop-linux-x64-${{ needs.resolve_version.outputs.value }}
path: release-assets/linux
- name: Download macOS Package Artifact
uses: actions/download-artifact@v4
with:
name: LanMontainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }}
name: LanMountainDesktop-osx-x64-${{ needs.resolve_version.outputs.value }}
path: release-assets/macos
- name: Attach Artifacts

View File

@@ -1,24 +1,27 @@
<Application xmlns="https://github.com/avaloniaui"
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMontainDesktop.App"
xmlns:local="using:LanMontainDesktop"
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
x:Class="LanMountainDesktop.App"
xmlns:local="using:LanMountainDesktop"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Resources>
<FontFamily x:Key="AppFontFamily">avares://LanMontainDesktop/Assets/Fonts#MiSans</FontFamily>
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
</Application.Resources>
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<StyleInclude Source="avares://LanMontainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMontainDesktop/Styles/SettingsAnimations.axaml" />
<mi:MaterialIconStyles />
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
@@ -58,5 +61,13 @@
<Style Selector="fi|SymbolIcon.icon-l, fi|FluentIcon.icon-l">
<Setter Property="FontSize" Value="20" />
</Style>
<Style Selector="mi|MaterialIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="Width" Value="20" />
<Setter Property="Height" Value="20" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,379 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop;
public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private bool _exitCleanupCompleted;
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
public override void Initialize()
{
AppLogger.Info("App", "Initializing application resources.");
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
AppLogger.Info("App", "Framework initialization completed.");
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
LogBrowserStartupDiagnostics();
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
}
base.OnFrameworkInitializationCompleted();
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Exit App from the tray menu."));
}
private void OnTraySettingsClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
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)
{
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
}
}, DispatcherPriority.Normal);
}
private void OnTrayRestartClick(object? sender, EventArgs e)
{
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
private static void ConfigureWebViewUserDataFolder()
{
if (!OperatingSystem.IsWindows())
{
return;
}
const string userDataFolderEnvVar = "WEBVIEW2_USER_DATA_FOLDER";
try
{
if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(userDataFolderEnvVar)))
{
return;
}
var userDataFolder = WebView2RuntimeProbe.ResolveUserDataFolder();
Environment.SetEnvironmentVariable(
userDataFolderEnvVar,
userDataFolder,
EnvironmentVariableTarget.Process);
}
catch (Exception ex)
{
// Keep startup resilient if user profile folders are unavailable.
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
}
}
private void InitializePluginRuntime()
{
try
{
_pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService();
_pluginRuntimeService.LoadInstalledPlugins();
}
catch (Exception ex)
{
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
}
}
private void InitializeTrayIcon()
{
try
{
DisposeTrayIcon();
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
var trayIcon = new TrayIcon
{
Icon = new WindowIcon(iconStream),
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 settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
menu.Items.Add(new NativeMenuItemSeparator());
var restartItem = new NativeMenuItem(L("tray.menu.restart", "重启应用"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
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 ActivateMainWindow()
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
if (desktop.MainWindow is not Window mainWindow)
{
return;
}
try
{
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;
if (mainWindow is MainWindow lanMountainMainWindow)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
}
}, DispatcherPriority.Send);
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
{
return;
}
_exitCleanupCompleted = true;
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
try
{
_traySettingsWindow?.Close();
}
catch (Exception ex)
{
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
}
finally
{
_traySettingsWindow = null;
}
try
{
_pluginRuntimeService?.Dispose();
}
catch (Exception ex)
{
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
}
finally
{
_pluginRuntimeService = null;
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
}
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 = _appSettingsService.Load();
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
return _localizationService.GetString(languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,35 @@
# MiSans 字体说明
## 中文
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
### 包含文件
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
### 来源
- 上游仓库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
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
Please review and comply with the MiSans font terms before redistributing this application.

View File

@@ -0,0 +1,40 @@
# 天气背景资源署名
## 中文
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
- License: https://www.pexels.com/license/
### 原始来源
- `clear_sky.jpg`
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
- `rain.jpg`
- https://www.pexels.com/photo/rain-on-window-with-bokeh-lights-35075853/
- `snow.jpg`
- https://www.pexels.com/photo/mountain-covered-with-snow-209955/
- `storm.jpg`
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
### 派生资源
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
- `clear_day.jpg`
- `clear_night.jpg`
- `cloudy_day.jpg`
- `cloudy_night.jpg`
- `rain_light.jpg`
- `rain_heavy.jpg`
- `storm_dark.jpg`
- `fog_haze.jpg`
- `snow_soft.jpg`
## English
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
- License: https://www.pexels.com/license/
Derived variants in this repository are adjusted from the listed base assets for widget presentation.

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