Compare commits

..

43 Commits

Author SHA1 Message Date
lincube
5804627f53 0.8.0.1
修圆角
2026-03-30 20:28:39 +08:00
lincube
7a268489c9 ci.圆角
修了下新的圆角。
2026-03-30 16:34:45 +08:00
lincube
148e4c894a 0.8.0
圆角设计更新
2026-03-30 15:28:51 +08:00
lincube
f84111e837 0.7.9.2
自习设置,优化设置选项卡图标,加入智教hub组件
2026-03-30 02:40:10 +08:00
lincube
bd2313fe7e 0.7.9.1 2026-03-29 15:34:17 +08:00
lincube
372b5b7adc 0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
2026-03-25 11:27:30 +08:00
lincube
74703582e7 0.7.8.1
凤凰网新闻组件优化、央广网新闻组件优化。
2026-03-25 07:44:55 +08:00
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
lincube
af2e7b4f2f 0.7.7
橘鸦新闻
2026-03-24 09:33:56 +08:00
lincube
798124e500 0.7.6.3 2026-03-23 22:43:54 +08:00
lincube
95ecb06668 0.7.6.2
在应用启动台上,也可以正常滑动
2026-03-23 21:13:08 +08:00
lincube
ac7e8db516 0.7.6.1
修复了系统标题栏的问题。
2026-03-23 12:34:04 +08:00
lincube
8ded721f46 0.7.6
加入删除页面二次确认
2026-03-23 12:14:56 +08:00
lincube
a559325f5a 0.7.5.3
设置界面动画优化
2026-03-23 11:25:24 +08:00
lincube
b60368527f 0.7.5.2
日韩支持
2026-03-22 23:30:43 +08:00
lincube
c8c3f51bff 0.7.5.1
精致
2026-03-22 20:29:44 +08:00
lincube
685323e057 0.7.5
顺滑的组件放置与调整
2026-03-22 15:21:29 +08:00
lincube
def21c79b1 0.7.4.1
动画优化
2026-03-22 14:47:15 +08:00
lincube
c3db5af923 0.7.4
首先我加了CI课程表json的读取,然后把天气时钟这个老问题也修了。
2026-03-22 04:57:19 +08:00
lincube
1a7dde34d0 0.7.3.1 2026-03-22 02:53:31 +08:00
lincube
73cdefe296 0.7.3
修东西
2026-03-21 22:40:07 +08:00
lincube
46a8df5900 0.7.2 2026-03-21 16:16:02 +08:00
lincube
2a1c09ae39 0.7.2 2026-03-21 13:08:20 +08:00
lincube
33baaa579d 0.7.1 2026-03-20 22:37:37 +08:00
lincube
20cd6041a7 0.7.0.2 2026-03-20 18:05:42 +08:00
lincube
65a3cf832a Revert "0.7.0.0"
This reverts commit aeae4be060.
2026-03-20 14:22:33 +08:00
lincube
5d48a03f57 Revert "0.7.0.1"
This reverts commit ea8ce1f5ff.
2026-03-20 14:12:40 +08:00
lincube
ea8ce1f5ff 0.7.0.1 2026-03-20 12:16:04 +08:00
lincube
aeae4be060 0.7.0.0 2026-03-20 10:22:40 +08:00
lincube
915739ff7b 0.6.9
改变无声
2026-03-20 00:41:14 +08:00
lincube
cb86ca10e7 0.6.8
小黑板数据持久化。
2026-03-19 16:27:16 +08:00
lincube
b3a74aa072 0.6.7.2
文档组件优化
2026-03-19 08:39:25 +08:00
lincube
b436bfa884 0.6.7.1
多平台适配
2026-03-19 02:02:07 +08:00
lincube
081abeb688 0 6 7
可移动存储组件
2026-03-19 00:17:21 +08:00
lincube
594a62132f 0.6.6
滑动优化
2026-03-18 20:09:00 +08:00
lincube
15e589aedd 0.6.5
流畅性优化测试
2026-03-17 18:36:10 +08:00
lincube
ac4617f5cf 0.6.4 2026-03-17 14:57:41 +08:00
lincube
0645598753 0.6.3.1
最近文件查看优化,课程表组件优化,插件安装优化。
2026-03-17 12:30:30 +08:00
lincube
dadd132b4f 0.6.3
优化了文本框焦点,优化了更新体验,优化了遥测,披露了收集的数据。
2026-03-17 01:01:48 +08:00
lincube
298defb829 0.6.2
删除了视频壁纸功能,为纯色背景添加了自定义颜色选项。
2026-03-16 21:08:54 +08:00
lincube
bcf4be6d50 0.6.1
课表组件修复。加入最近文档组件。
2026-03-16 15:19:46 +08:00
354 changed files with 30263 additions and 6506 deletions

View File

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

45
.github/README.md vendored
View File

@@ -1,45 +0,0 @@
# LanMountainDesktop
> 你的桌面,不止一面。
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
## 项目定位
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。
- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。
- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。
## 核心能力
- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用。
- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览。
- 主题系统支持日夜模式、主题色与调色联动Monet 风格色板)。
- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。
- 本地化:内置 `zh-CN``en-US` 资源。
## 工程结构
- `LanMountainDesktop/`桌面端主程序Avalonia
- `LanMountainDesktop.RecommendationBackend/`推荐内容后端服务ASP.NET Core Minimal API
- `docs/`:视觉与圆角等规范文档。
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
## 技术栈
- .NET 10`net10.0`
- Avalonia 11
- FluentAvalonia + FluentIcons.Avalonia
- LibVLCSharp用于视频相关能力
- WebView.Avalonia嵌入式网页组件能力
## 扩展机制(摘要)
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

133
.github/READMEmd vendored Normal file
View File

@@ -0,0 +1,133 @@
# 阑山桌面 / LanMountainDesktop
> 你的桌面,不止一面
[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4)](https://dotnet.microsoft.com/)
[![Avalonia UI](https://img.shields.io/badge/Avalonia%20UI-11.2-blue)](https://avaloniaui.net/)
[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
> [!IMPORTANT]
> **温馨提示**:本项目有部分成分由**氛围编程 (Vibe Coding)** 方式编写。
>
> 如果您对此类项目有固有的排斥感,请无视此项目,谢谢。
## 简介
**阑山桌面**是一个跨平台桌面环境增强工具,面向需要高频查看信息、追求桌面效率与个性化体验的用户。
基于 Avalonia UI 和 .NET 10 构建,支持 Windows、Linux、macOS 三大平台。
![Platform](https://img.shields.io/badge/Windows-✓-0078D4)
![Platform](https://img.shields.io/badge/Linux-✓-FCC624?logo=linux&logoColor=black)
![Platform](https://img.shields.io/badge/macOS-✓-000000?logo=apple)
## 核心特性
### 📊 信息聚合
- 课程表、日历、天气、新闻、热搜
- 所有信息一目了然,无需频繁切换窗口
### 🎯 效率工具
- 自习环境监测、计时器、知识卡片
- 最近文档、浏览器快捷入口
- 常用工具组件一键触达
### 🎨 个性化桌面
- 自由布局,随心所欲摆放组件
- 多页桌面,工作学习场景分离
- 主题切换、玻璃效果、圆角风格
### 🔌 插件生态
- 通过 `.laapp` 插件扩展功能
- 官方 Plugin SDK 支持自定义组件
- 设置页、组件、集成功能一站式接入
## 为谁而设计
| 用户类型 | 典型场景 |
|---------|---------|
| 🎓 学生用户 | 课程表、自习监测、计时、天气和日常信息聚合 |
| 💼 办公用户 | 日历、资讯、最近文档、常用工具入口 |
| 🎨 效率爱好者 | 自由布局、主题切换、插件扩展 |
| 🇨🇳 中文用户 | 本地化界面、农历和节假日等本地语境支持 |
## 快速开始
### 环境要求
- .NET SDK 10
### 构建与运行
```bash
# 还原依赖
dotnet restore
# 构建项目
dotnet build LanMountainDesktop.slnx -c Debug
# 运行桌面宿主
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
```
### 运行测试
```bash
dotnet test LanMountainDesktop.slnx -c Debug
```
## 插件开发
阑山桌面支持通过 Plugin SDK 开发自定义插件:
```bash
# 安装插件模板
dotnet new install LanMountainDesktop.PluginTemplate
# 创建新插件
dotnet new lmd-plugin -n MyPlugin
```
- **Plugin SDK**: `LanMountainDesktop.PluginSdk` (API 4.0.0)
- **共享契约**: `LanMountainDesktop.Shared.Contracts`
- **迁移指南**: [PLUGIN_SDK_V4_MIGRATION.md](docs/PLUGIN_SDK_V4_MIGRATION.md)
## 项目结构
```
LanMountainDesktop/
├── LanMountainDesktop/ # 桌面宿主应用
├── LanMountainDesktop.PluginSdk/ # 官方插件 SDK
├── LanMountainDesktop.Shared.Contracts/ # 宿主与插件共享契约
├── LanMountainDesktop.Appearance/ # 主题与外观基础设施
├── LanMountainDesktop.Settings.Core/# 设置持久化基础设施
└── LanMountainDesktop.Tests/ # 测试项目
```
## 生态边界
| 项目 | 职责 |
|-----|------|
| **本仓库** | 桌面宿主、插件运行时、Plugin SDK、共享契约 |
| [LanAirApp](https://github.com/yourorg/LanAirApp) | 插件市场元数据、开发者生态材料 |
| [LanMountainDesktop.SamplePlugin](https://github.com/yourorg/LanMountainDesktop.SamplePlugin) | 官方示例插件 |
## 文档索引
- [产品定位](docs/PRODUCT.md) - 产品愿景与目标用户
- [架构说明](docs/ARCHITECTURE.md) - 仓库结构与运行时主线
- [开发指南](docs/DEVELOPMENT.md) - 构建、测试、调试
- [视觉规范](docs/VISUAL_SPEC.md) - 主题、颜色、玻璃层级
- [圆角规范](docs/CORNER_RADIUS_SPEC.md) - 圆角层级与动态规则
- [贡献指南](docs/CONTRIBUTING.md) - PR、spec、文档协作规则
## 技术栈
- **UI 框架**: [Avalonia UI](https://avaloniaui.net/)
- **开发平台**: [.NET 10](https://dotnet.microsoft.com/)
- **支持平台**: Windows 10+, Linux, macOS
## 许可证
[MIT](LICENSE)

View File

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

View File

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

View File

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

View File

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

View File

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

93
AGENTS.md Normal file
View File

@@ -0,0 +1,93 @@
# LanMountainDesktop AI Guide
本文件是 AI 助手进入本仓库时的第一入口。面向 Codex、Cursor、Trae 等工具,目标是减少重复探索,快速定位权威文档、关键目录和执行约束。
## 1. 项目目标与仓库边界
- 本仓库是阑山桌面桌面宿主、宿主侧插件运行时、Plugin SDK、共享契约与基础外观/设置能力的权威来源。
- 不要把插件市场元数据、开发者门户或官方示例插件实现当作本仓库内容维护。
- 市场和生态材料属于兄弟仓库 `LanAirApp`
- 官方示例插件属于独立仓库 `LanMountainDesktop.SamplePlugin`
边界详情看:
- `docs/ECOSYSTEM_BOUNDARIES.md`
- `docs/ARCHITECTURE.md`
## 2. 关键目录地图
- `LanMountainDesktop/`: 主宿主应用,包含 UI、服务、组件系统、主题与插件运行时接入
- `LanMountainDesktop/ComponentSystem/`: 内置组件定义、注册、扩展加载
- `LanMountainDesktop/plugins/`: 宿主侧插件运行时、安装与 market 集成
- `LanMountainDesktop/Views/` and `ViewModels/`: UI 页面、窗口与视图模型
- `LanMountainDesktop/Services/`: 设置、遥测、启动、持久化、业务服务
- `LanMountainDesktop.PluginSdk/`: 插件 SDK 公共接口和默认打包行为
- `LanMountainDesktop.Shared.Contracts/`: 宿主/插件共享契约
- `LanMountainDesktop.Tests/`: 宿主与 SDK 测试
- `.trae/specs/`: feature 级规格、任务拆解和验收清单
更详细映射看 `docs/ai/CODEBASE_MAP.md`
## 3. 常用命令
```bash
dotnet restore
dotnet build LanMountainDesktop.slnx -c Debug
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
dotnet test LanMountainDesktop.slnx -c Debug
```
插件本地包生成:
```powershell
./scripts/Pack-PluginPackages.ps1
```
## 4. 改动前后必做检查
改动前:
- 先确认需求是否已经在 `.trae/specs/` 中存在
- 先确认产品、架构、专题规范分别以哪份文档为准
- 避免沿用旧根目录产品文档中的过时事实
改动后:
- 至少检查构建和与改动相关的测试
- 如果行为、流程、边界或命令变化,更新对应文档
- 如果是新功能或行为调整,补齐或更新 `.trae/specs/<feature>/`
## 5. 高频区域注意事项
### UI
- 主题、资源和视觉语义优先遵守 `docs/VISUAL_SPEC.md``docs/CORNER_RADIUS_SPEC.md`
- **组件圆角**:所有内置与插件组件的根边框必须使用 `{DynamicResource DesignCornerRadiusComponent}` 资源。
- 设置页相关改动通常同时落在 `Views/``ViewModels/``Services/``.trae/specs/`
- UI 启动与窗口生命周期主线在 `Program.cs``App.axaml.cs`
### 插件
- SDK 公共 API 以 `LanMountainDesktop.PluginSdk/` 为准
- 共享契约以 `LanMountainDesktop.Shared.Contracts/` 为准
- market 数据来源默认是兄弟仓库 `..\\LanAirApp`
- 迁移或 breaking change 优先同步 `docs/PLUGIN_SDK_V4_MIGRATION.md`
### 设置与主题
- 设置持久化和 scope 变化优先检查 `LanMountainDesktop.Settings.Core/`
- 外观、圆角、主题资源优先检查 `LanMountainDesktop.Appearance/` 与专题规范
- **圆角统一**桌面组件Widget必须统一使用动态资源 `DesignCornerRadiusComponent`。严禁在组件根容器使用硬编码数值或非组件级令牌(如 `Xs`, `Md` 等),以确保全局圆角缩放设置能正确应用到所有组件。
## 6. 权威来源
- 产品定位:`docs/PRODUCT.md`
- 架构与模块职责:`docs/ARCHITECTURE.md`
- 运行、构建、测试、打包:`docs/DEVELOPMENT.md`
- feature 规格:`.trae/specs/`
- 视觉规范:`docs/VISUAL_SPEC.md`
- 圆角规范:`docs/CORNER_RADIUS_SPEC.md`
- 生态边界:`docs/ECOSYSTEM_BOUNDARIES.md`
- SDK v4 迁移:`docs/PLUGIN_SDK_V4_MIGRATION.md`
如果多个文档都提到同一件事,以 `docs/ai/DOC_SOURCES.md` 列出的权威来源为准。

8
Directory.Build.props Normal file
View File

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

View File

@@ -1,33 +0,0 @@
# LanAirApp (Mirror)
## 中文
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
### 这份镜像的角色
- 提供本地工作区里的 `airappmarket` 索引副本
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
- 不承担宿主运行时职责
### 权威来源
- 插件市场与开发文档:独立 `LanAirApp` 仓库
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
## English
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
### Role of this mirror
- keep a local copy of the `airappmarket` index for workspace integration
- keep mirrored docs, tools, and sample templates for local development
- avoid duplicating host runtime responsibilities
### Sources of truth
- Plugin market and developer docs: standalone `LanAirApp`
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,100 +0,0 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginRuntimeStateService? _stateService;
private SamplePluginClockService? _clockService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
_stateService = new SamplePluginRuntimeStateService(
context.Manifest,
context.PluginDirectory,
context.DataDirectory,
hostName,
hostVersion,
sdkApiVersion,
messageBus,
localizer);
context.RegisterService(_stateService);
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
context.RegisterService(_clockService);
_stateService.AttachClockService(_clockService);
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
var initMessage =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
try
{
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady(localizer.Format(
"status.backend.detail.log_written",
"Initialization log written: {0}",
logPath));
}
catch (Exception ex)
{
_stateService.MarkBackendFaulted(localizer.Format(
"status.backend.detail.log_write_failed",
"Initialization log failed: {0}",
ex.Message));
throw;
}
_clockService.Start();
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
localizer.GetString("widget.display_name", "Sample Plugin Status Clock"),
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: localizer.GetString("widget.category", "Plugins"),
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.CloseDesktop",
localizer.GetString("widget.close_desktop.display_name", "Close Desktop"),
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
iconKey: "DismissCircle",
category: localizer.GetString("widget.category", "Plugins"),
minWidthCells: 2,
minHeightCells: 1,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Free,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
}
public void Dispose()
{
_clockService?.Dispose();
_clockService = null;
_stateService = null;
}
private static string GetHostProperty(IPluginContext context, string key, string fallback)
{
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: fallback;
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,8 @@ public interface IPluginRuntimeContext
IReadOnlyDictionary<string, object?> Properties { get; }
IPluginAppearanceContext Appearance { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);

View File

@@ -4,7 +4,15 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>3.0.0</Version>
<Version>4.0.0</Version>
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Official plugin SDK for LanMountainDesktop, including plugin manifest contracts, runtime interfaces, and registration extensions.</Description>
<PackageTags>LanMountainDesktop;Plugin;SDK;Avalonia</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
</PropertyGroup>
<ItemGroup>
@@ -12,6 +20,13 @@
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.props" Pack="true" PackagePath="buildTransitive\" />
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.targets" Pack="true" PackagePath="buildTransitive\" />
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,67 +5,37 @@ 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)
PluginDesktopComponentOptions options)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentNullException.ThrowIfNull(controlFactory);
ArgumentNullException.ThrowIfNull(options);
ArgumentException.ThrowIfNullOrWhiteSpace(options.ComponentId);
ArgumentException.ThrowIfNullOrWhiteSpace(options.DisplayName);
ArgumentException.ThrowIfNullOrWhiteSpace(options.IconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(options.Category);
ComponentId = componentId.Trim();
DisplayName = displayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
ComponentId = options.ComponentId.Trim();
DisplayName = options.DisplayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(options.DisplayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
: options.DisplayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = iconKey.Trim();
Category = category.Trim();
MinWidthCells = Math.Max(1, minWidthCells);
MinHeightCells = Math.Max(1, minHeightCells);
AllowDesktopPlacement = allowDesktopPlacement;
AllowStatusBarPlacement = allowStatusBarPlacement;
ResizeMode = resizeMode;
CornerRadiusResolver = cornerRadiusResolver;
IconKey = options.IconKey.Trim();
Category = options.Category.Trim();
MinWidthCells = Math.Max(1, options.MinWidthCells);
MinHeightCells = Math.Max(1, options.MinHeightCells);
AllowDesktopPlacement = options.AllowDesktopPlacement;
AllowStatusBarPlacement = options.AllowStatusBarPlacement;
ResizeMode = options.ResizeMode;
CornerRadiusPreset = options.CornerRadiusPreset;
CornerRadiusResolver = options.CornerRadiusResolver;
}
public PluginDesktopComponentRegistration(
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)
PluginDesktopComponentOptions options)
: this((_, context) => controlFactory(context), options)
{
}
@@ -91,5 +61,25 @@ public sealed class PluginDesktopComponentRegistration
public PluginDesktopComponentResizeMode ResizeMode { get; }
public Func<double, double>? CornerRadiusResolver { get; }
public PluginCornerRadiusPreset CornerRadiusPreset { get; }
public Func<IPluginAppearanceContext, double, double>? CornerRadiusResolver { get; }
public double ResolveCornerRadius(IPluginAppearanceContext appearance, double cellSize)
{
ArgumentNullException.ThrowIfNull(appearance);
var resolved = CornerRadiusResolver is not null
? CornerRadiusResolver(appearance, Math.Max(1d, cellSize))
: CornerRadiusPreset == PluginCornerRadiusPreset.Default
? appearance.ResolveScaledCornerRadius(
Math.Clamp(Math.Max(1d, cellSize) * 0.22, 8, 18),
8,
18)
: appearance.ResolveCornerRadius(CornerRadiusPreset);
return double.IsFinite(resolved)
? Math.Max(0d, resolved)
: appearance.ResolveCornerRadius(PluginCornerRadiusPreset.Default);
}
}

View File

@@ -87,8 +87,8 @@ public sealed record PluginManifest(
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
$"This host only supports v{currentVersion.Major}.x plugins. " +
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
$"This host only supports v{currentVersion.Major}.x plugins and rejects v{requestedVersion.Major}.x packages by default. " +
$"Migrate the plugin manifest and code to API {PluginSdkInfo.ApiVersion}, then rebuild and republish the package.");
}
return normalized;

View File

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

View File

@@ -30,34 +30,15 @@ public static class PluginServiceCollectionExtensions
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)
PluginDesktopComponentOptions options)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(options);
services.AddSingleton(new PluginDesktopComponentRegistration(
componentId,
displayName,
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
iconKey,
category,
minWidthCells,
minHeightCells,
allowDesktopPlacement,
allowStatusBarPlacement,
resizeMode,
displayNameLocalizationKey,
cornerRadiusResolver));
options));
return services;
}

View File

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

View File

@@ -6,7 +6,9 @@ public static class SettingsCategories
public const string Appearance = "Appearance";
public const string Components = "Components";
public const string Plugins = "Plugins";
public const string PluginMarket = "PluginMarket";
public const string PluginCatalog = "PluginCatalog";
[Obsolete("Use PluginCatalog instead.")]
public const string PluginMarket = PluginCatalog;
public const string Update = "Update";
public const string About = "About";
public const string Advanced = "Advanced";

View File

@@ -6,6 +6,8 @@ public enum SettingsPageCategory
Appearance = 10,
Components = 20,
Plugins = 30,
PluginCatalog = 35,
[Obsolete("Use PluginCatalog instead.")]
PluginMarket = 35,
About = 40
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
# LanMountainDesktop.PluginTemplate
Official `dotnet new` template package for LanMountainDesktop plugins.
## Install
```powershell
dotnet new install LanMountainDesktop.PluginTemplate
```
## Create a plugin
```powershell
dotnet new lmd-plugin -n YourPluginName
```
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.

View File

@@ -0,0 +1,55 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "LanMountainDesktop",
"classifications": [
"LanMountainDesktop",
"Plugin",
"Desktop"
],
"name": "LanMountainDesktop Plugin",
"identity": "LanMountainDesktop.PluginTemplate.CSharp",
"shortName": "lmd-plugin",
"sourceName": "LanMountainDesktop.PluginTemplate",
"preferNameDirectory": true,
"tags": {
"type": "project",
"language": "C#"
},
"symbols": {
"pluginId": {
"type": "parameter",
"datatype": "text",
"defaultValue": "LanMountainDesktop.PluginTemplate",
"description": "Plugin manifest id.",
"replaces": "__PLUGIN_ID__"
},
"pluginAuthor": {
"type": "parameter",
"datatype": "text",
"defaultValue": "Your Name",
"description": "Plugin author.",
"replaces": "__PLUGIN_AUTHOR__"
},
"pluginName": {
"type": "parameter",
"datatype": "text",
"defaultValue": "LanMountain Plugin Template",
"description": "Display name shown in plugin manifest.",
"replaces": "__PLUGIN_NAME__"
},
"pluginDescription": {
"type": "parameter",
"datatype": "text",
"defaultValue": "Plugin generated from the official LanMountainDesktop template.",
"description": "Plugin description shown in plugin manifest.",
"replaces": "__PLUGIN_DESCRIPTION__"
},
"pluginSdkVersion": {
"type": "parameter",
"datatype": "text",
"defaultValue": "4.0.0",
"description": "LanMountainDesktop.PluginSdk package version.",
"replaces": "__PLUGIN_SDK_VERSION__"
}
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="__PLUGIN_SDK_VERSION__" ExcludeAssets="runtime" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,15 @@
using LanMountainDesktop.PluginSdk;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace LanMountainDesktop.PluginTemplate;
[PluginEntrance]
public sealed class Plugin : PluginBase
{
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
_ = context;
_ = services;
}
}

View File

@@ -0,0 +1,24 @@
# __PLUGIN_NAME__
Official-style plugin scaffold generated for LanMountainDesktop.
## Build
```powershell
dotnet build -c Release
```
`LanMountainDesktop.PluginSdk` build targets will generate:
- plugin output files under `bin/<Configuration>/<TFM>/`
- a `.laapp` package in the project root
## Manifest
Update `plugin.json` fields as needed before release:
- `id`
- `name`
- `description`
- `author`
- `version`

View File

@@ -0,0 +1,10 @@
{
"id": "__PLUGIN_ID__",
"name": "__PLUGIN_NAME__",
"description": "__PLUGIN_DESCRIPTION__",
"author": "__PLUGIN_AUTHOR__",
"version": "1.0.0",
"apiVersion": "4.0.0",
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
"sharedContracts": []
}

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Settings.Core;
public static class GlobalAppearanceSettings
{
public const double DefaultCornerRadiusScale = 1.0;
public const double MinimumCornerRadiusScale = 0.0;
public const double MaximumCornerRadiusScale = 2.50;
public static double NormalizeCornerRadiusScale(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return DefaultCornerRadiusScale;
}
return Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
}
}

View File

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

View File

@@ -0,0 +1,13 @@
using Avalonia;
namespace LanMountainDesktop.Shared.Contracts;
public sealed record AppearanceCornerRadiusTokens(
CornerRadius Micro,
CornerRadius Xs,
CornerRadius Sm,
CornerRadius Md,
CornerRadius Lg,
CornerRadius Xl,
CornerRadius Island,
CornerRadius Component);

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
<IsPackable>true</IsPackable>
<Authors>LanMountainDesktop</Authors>
<Description>Shared contracts used by LanMountainDesktop host and plugins for cross-boundary communication.</Description>
<PackageTags>LanMountainDesktop;Plugin;SharedContracts;Avalonia</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,16 @@
# LanMountainDesktop.Shared.Contracts
Shared contracts package for LanMountainDesktop host and plugin ecosystems.
## Includes
- cross-boundary records used by host/runtime and plugins
- contract types intended for stable shared communication
## Usage
```xml
<ItemGroup>
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="1.0.0" />
</ItemGroup>
```

View File

@@ -0,0 +1,43 @@
using System;
using Avalonia;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
{
[Theory]
[InlineData(80d, 0d)]
[InlineData(120d, 1d)]
[InlineData(160d, 2.5d)]
public void BuiltInDesktopHosts_ResolveToTheUnifiedLgBaseline(double cellSize, double globalScale)
{
var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
foreach (var descriptor in registry.GetDesktopComponents())
{
var resolved = descriptor.ResolveCornerRadius(CreateChromeContext(descriptor.Definition.Id, cellSize, globalScale));
Assert.Equal(expected, resolved, 3);
}
}
private static ComponentChromeContext CreateChromeContext(
string componentId,
double cellSize,
double globalScale)
{
return new ComponentChromeContext(
componentId,
null,
cellSize,
globalScale,
AppearanceCornerRadiusTokenFactory.Create(globalScale));
}
}

View File

@@ -0,0 +1,60 @@
using Avalonia;
using LanMountainDesktop.DesktopEditing;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ComponentLibraryCollapseStateTests
{
[Fact]
public void CreateExpanded_InitializesExpandedStateAndHidesChip()
{
var margin = new Thickness(24, 24, 24, 100);
var state = ComponentLibraryCollapseState.CreateExpanded(margin, 0.75);
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, state.VisualState);
Assert.Equal(margin, state.ExpandedMargin);
Assert.Equal(0.75, state.ExpandedOpacity, 3);
Assert.False(state.IsChipVisible);
}
[Fact]
public void WithVisualState_PreservesStableExpandedSnapshotAcrossTransitions()
{
var margin = new Thickness(20, 18, 20, 96);
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 1);
var collapsing = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
var collapsed = collapsing.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
var restoring = collapsed.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsing, collapsing.VisualState);
Assert.Equal(ComponentLibraryCollapseVisualState.Collapsed, collapsed.VisualState);
Assert.Equal(ComponentLibraryCollapseVisualState.Restoring, restoring.VisualState);
Assert.Equal(margin, collapsing.ExpandedMargin);
Assert.Equal(margin, collapsed.ExpandedMargin);
Assert.Equal(margin, restoring.ExpandedMargin);
Assert.Equal(1, collapsing.ExpandedOpacity, 3);
Assert.Equal(1, collapsed.ExpandedOpacity, 3);
Assert.Equal(1, restoring.ExpandedOpacity, 3);
Assert.True(collapsing.IsChipVisible);
Assert.True(collapsed.IsChipVisible);
Assert.False(restoring.IsChipVisible);
}
[Fact]
public void CreateExpanded_ProducesRestorableSnapshotEvenWhenOriginalOpacityIsLow()
{
var margin = new Thickness(18, 22, 18, 88);
var expanded = ComponentLibraryCollapseState.CreateExpanded(margin, 0.15);
var restored = expanded.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
Assert.Equal(margin, restored.ExpandedMargin);
Assert.Equal(0.15, restored.ExpandedOpacity, 3);
Assert.Equal(ComponentLibraryCollapseVisualState.Expanded, restored.VisualState);
Assert.False(restored.IsChipVisible);
}
}

View File

@@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Media;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ComponentPreviewImageServiceTests
{
[Fact]
public async Task QueueGenerationAsync_ExecutesWorkSeriallyAcrossKeys()
{
var service = new ComponentPreviewImageService();
var executionOrder = new List<string>();
var activeCount = 0;
var maxActiveCount = 0;
Task<ComponentPreviewImageEntry> Queue(string componentTypeId)
{
var key = ComponentPreviewKey.ForComponentType(componentTypeId, widthCells: 2, heightCells: 2);
return service.QueueGenerationAsync(
key,
visualSignature: $"sig:{componentTypeId}",
async _ =>
{
var activeNow = Interlocked.Increment(ref activeCount);
maxActiveCount = Math.Max(maxActiveCount, activeNow);
lock (executionOrder)
{
executionOrder.Add(componentTypeId);
}
await Task.Delay(40);
Interlocked.Decrement(ref activeCount);
return CreateImage();
});
}
var first = Queue("Clock");
var second = Queue("Weather");
var third = Queue("Calendar");
await Task.WhenAll(first, second, third);
Assert.Equal(1, maxActiveCount);
Assert.Equal(["Clock", "Weather", "Calendar"], executionOrder);
}
[Fact]
public async Task QueueGenerationAsync_DeduplicatesConcurrentRequestsForSameKey()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var generationCount = 0;
var bitmap = CreateImage();
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
Task<IImage?> Generation(CancellationToken _)
{
Interlocked.Increment(ref generationCount);
return completion.Task;
}
var first = service.QueueGenerationAsync(key, "clock-sig", Generation);
var second = service.QueueGenerationAsync(key, "clock-sig", Generation);
Assert.Same(first, second);
completion.SetResult(bitmap);
var entry = await first;
Assert.Equal(1, generationCount);
Assert.Equal(ComponentPreviewImageState.Ready, entry.State);
Assert.Same(bitmap, entry.Bitmap);
}
[Fact]
public void Invalidate_ResetsSingleKeyToPending()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var image = CreateDisposableImage();
var stored = service.Store(key, image, "clock-sig");
var previousRevision = stored.Revision;
var result = service.Invalidate(key);
Assert.True(result);
Assert.Equal(ComponentPreviewImageState.Pending, stored.State);
Assert.Null(stored.Bitmap);
Assert.True(image.IsDisposed);
Assert.True(stored.Revision > previousRevision);
Assert.Equal("clock-sig", stored.VisualSignature);
}
[Fact]
public void RemovePlacementPreviews_RemovesOnlyMatchingPlacementEntries()
{
var service = new ComponentPreviewImageService();
var removedClock = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2);
var removedWeather = ComponentPreviewKey.ForPlacementInstance("Weather", "desk-1", widthCells: 4, heightCells: 2);
var keptPlacement = ComponentPreviewKey.ForPlacementInstance("Clock", "desk-2", widthCells: 2, heightCells: 2);
var keptType = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var removedClockImage = CreateDisposableImage();
var removedWeatherImage = CreateDisposableImage();
var keptPlacementImage = CreateDisposableImage();
var keptTypeImage = CreateDisposableImage();
service.Store(removedClock, removedClockImage, "sig-a");
service.Store(removedWeather, removedWeatherImage, "sig-b");
service.Store(keptPlacement, keptPlacementImage, "sig-c");
service.Store(keptType, keptTypeImage, "sig-d");
var removedCount = service.RemovePlacementPreviews("desk-1");
Assert.Equal(2, removedCount);
Assert.False(service.TryGetEntry(removedClock, out _));
Assert.False(service.TryGetEntry(removedWeather, out _));
Assert.True(service.TryGetEntry(keptPlacement, out _));
Assert.True(service.TryGetEntry(keptType, out _));
Assert.True(removedClockImage.IsDisposed);
Assert.True(removedWeatherImage.IsDisposed);
Assert.False(keptPlacementImage.IsDisposed);
Assert.False(keptTypeImage.IsDisposed);
}
[Fact]
public void InvalidateVisualSignature_InvalidatesEveryMatchingEntry()
{
var service = new ComponentPreviewImageService();
const string matchingSignature = "shared-sig";
const string otherSignature = "other-sig";
var first = service.Store(
ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2),
CreateImage(),
matchingSignature);
var second = service.Store(
ComponentPreviewKey.ForPlacementInstance("Clock", "desk-1", widthCells: 2, heightCells: 2),
CreateImage(),
matchingSignature);
var third = service.Store(
ComponentPreviewKey.ForComponentType("Weather", widthCells: 2, heightCells: 1),
CreateImage(),
otherSignature);
var invalidatedCount = service.InvalidateVisualSignature(matchingSignature);
Assert.Equal(2, invalidatedCount);
Assert.Equal(ComponentPreviewImageState.Pending, first.State);
Assert.Equal(ComponentPreviewImageState.Pending, second.State);
Assert.Null(first.Bitmap);
Assert.Null(second.Bitmap);
Assert.Equal(ComponentPreviewImageState.Ready, third.State);
Assert.NotNull(third.Bitmap);
}
[Fact]
public void Store_ReplacingBitmap_DisposesPreviousBitmap_WhenInstanceChanges()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var first = CreateDisposableImage();
var second = CreateDisposableImage();
service.Store(key, first, "sig-a");
service.Store(key, second, "sig-b");
Assert.True(first.IsDisposed);
Assert.False(second.IsDisposed);
}
[Fact]
public void Store_ReplacingBitmap_DoesNotDispose_WhenSameInstanceReused()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var image = CreateDisposableImage();
service.Store(key, image, "sig-a");
service.Store(key, image, "sig-b");
Assert.False(image.IsDisposed);
}
[Fact]
public void StoreFailure_DisposesExistingBitmap()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var image = CreateDisposableImage();
service.Store(key, image, "sig-a");
var entry = service.StoreFailure(key, "sig-a", "failed");
Assert.True(image.IsDisposed);
Assert.Equal(ComponentPreviewImageState.Failed, entry.State);
Assert.Null(entry.Bitmap);
}
[Fact]
public async Task QueueGenerationAsync_DisposesStaleGeneratedBitmap_WhenEntryWasInvalidated()
{
var service = new ComponentPreviewImageService();
var key = ComponentPreviewKey.ForComponentType("Clock", widthCells: 2, heightCells: 2);
var completion = new TaskCompletionSource<IImage?>(TaskCreationOptions.RunContinuationsAsynchronously);
var stale = CreateDisposableImage();
var generationTask = service.QueueGenerationAsync(key, "sig-a", _ => completion.Task);
_ = service.Invalidate(key);
completion.SetResult(stale);
var entry = await generationTask;
Assert.True(stale.IsDisposed);
Assert.Equal(ComponentPreviewImageState.Pending, entry.State);
Assert.Null(entry.Bitmap);
}
private static IImage CreateImage() => new TestImage();
private static DisposableTestImage CreateDisposableImage() => new();
private sealed class TestImage : IImage
{
public Size Size => new(1, 1);
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
{
_ = context;
_ = sourceRect;
_ = destRect;
}
}
private sealed class DisposableTestImage : IImage, IDisposable
{
public Size Size => new(1, 1);
public bool IsDisposed { get; private set; }
public void Dispose()
{
IsDisposed = true;
}
public void Draw(DrawingContext context, Rect sourceRect, Rect destRect)
{
_ = context;
_ = sourceRect;
_ = destRect;
}
}
}

View File

@@ -0,0 +1,93 @@
using Avalonia;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class CornerRadiusScaleTests
{
[Theory]
[InlineData(-1d, 0d)]
[InlineData(0d, 0d)]
[InlineData(0.33d, 0.33d)]
[InlineData(1.234d, 1.234d)]
[InlineData(2.5d, 2.5d)]
[InlineData(3d, 2.5d)]
public void NormalizeCornerRadiusScale_ClampsWithoutSnapping(double input, double expected)
{
Assert.Equal(expected, GlobalAppearanceSettings.NormalizeCornerRadiusScale(input), 3);
}
[Fact]
public void NormalizeCornerRadiusScale_UsesDefaultForInvalidValues()
{
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.NaN),
3);
Assert.Equal(
GlobalAppearanceSettings.DefaultCornerRadiusScale,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(double.PositiveInfinity),
3);
}
[Fact]
public void PluginDesktopComponentContext_AllowsZeroRadiusScaling()
{
var appearanceContext = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 0d,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8))),
ThemeVariant: "Unknown"));
var context = new PluginDesktopComponentContext(
new PluginManifest("plugin.id", "Plugin Name", "plugin.dll"),
"C:\\Plugins\\plugin.id",
"C:\\Data\\plugin.id",
new NullServiceProvider(),
new Dictionary<string, object?>(),
"component-1",
null,
96d,
appearanceContext);
Assert.Equal(0d, context.GlobalCornerRadiusScale, 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d), 3);
Assert.Equal(0d, context.ResolveScaledCornerRadius(12d, 8d, 18d), 3);
}
[Fact]
public void PluginAppearanceContext_ResolveCornerRadius_DoesNotDoubleScalePresetTokens()
{
var context = new PluginAppearanceContext(new PluginAppearanceSnapshot(
GlobalCornerRadiusScale: 2d,
CornerRadiusTokens: new PluginCornerRadiusTokens(
Micro: 12d,
Xs: 20d,
Sm: 28d,
Md: 36d,
Lg: 48d,
Xl: 60d,
Island: 72d,
Component: 16d),
ThemeVariant: "Light"));
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md, maximum: 40d), 3);
Assert.Equal(36d, context.ResolveScaledCornerRadius(18d), 3);
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
}

View File

@@ -0,0 +1,59 @@
using Avalonia;
using Avalonia.Controls;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Shared.Contracts;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
{
[Fact]
public void LegacyCellSizeResolver_AppliesGlobalCornerRadiusScale()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
displayNameLocalizationKey: null,
controlFactory: () => new Border(),
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.30, 10, 40));
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 120, globalScale: 2.0));
Assert.Equal(72.0, resolved, 3);
}
[Fact]
public void ChromeContextResolver_IsNotDoubleScaledByRegistrationWrapper()
{
var registration = new DesktopComponentRuntimeRegistration(
componentId: "test.component",
displayNameLocalizationKey: null,
controlFactory: _ => new Border(),
cornerRadiusResolver: chromeContext => chromeContext.CellSize + chromeContext.GlobalCornerRadiusScale);
var resolver = Assert.IsType<Func<ComponentChromeContext, double>>(registration.CornerRadiusResolver);
var resolved = resolver(CreateChromeContext(cellSize: 50, globalScale: 2.5));
Assert.Equal(52.5, resolved, 3);
}
private static ComponentChromeContext CreateChromeContext(double cellSize, double globalScale)
{
return new ComponentChromeContext(
ComponentId: "test.component",
PlacementId: null,
CellSize: cellSize,
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8)));
}
}

View File

@@ -0,0 +1,15 @@
using LanMountainDesktop.DesktopEditing;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class DesktopEditCommitMathTests
{
[Fact]
public void IsPendingCommitValid_ReturnsTrueOnlyForMatchingActiveVersion()
{
Assert.True(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 4));
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: false, scheduledVersion: 4, currentVersion: 4));
Assert.False(DesktopEditCommitMath.IsPendingCommitValid(isPending: true, scheduledVersion: 4, currentVersion: 5));
}
}

View File

@@ -0,0 +1,173 @@
using Avalonia;
using LanMountainDesktop.DesktopEditing;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class DesktopPlacementMathTests
{
[Fact]
public void ComputeDragStartThreshold_UsesFloorAndCellScale()
{
Assert.Equal(10d, DesktopPlacementMath.ComputeDragStartThreshold(24));
Assert.Equal(14.4d, DesktopPlacementMath.ComputeDragStartThreshold(80), 3);
}
[Fact]
public void HasExceededThreshold_OnlyReturnsTrueAfterEnoughMovement()
{
var start = new Point(20, 20);
Assert.False(DesktopPlacementMath.HasExceededThreshold(start, new Point(27, 25), 10));
Assert.True(DesktopPlacementMath.HasExceededThreshold(start, new Point(31, 20), 10));
}
[Fact]
public void OcclusionHelpers_DetectPointAndRectOverlap()
{
var libraryBounds = new Rect(100, 100, 200, 160);
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(120, 150), libraryBounds));
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Point(80, 90), libraryBounds));
Assert.True(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(250, 120, 120, 80), libraryBounds));
Assert.False(DesktopPlacementMath.IsOccludedByComponentLibrary(new Rect(10, 10, 40, 40), libraryBounds));
}
[Fact]
public void TryGetSnappedCell_ClampsInsideGridBounds()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 4,
RowCount: 5);
var result = DesktopPlacementMath.TryGetSnappedCell(
grid,
pointerInViewport: new Point(490, 520),
pointerOffset: new Point(10, 10),
widthCells: 2,
heightCells: 3,
out var column,
out var row);
Assert.True(result);
Assert.Equal(2, column);
Assert.Equal(2, row);
}
[Fact]
public void GetCellRect_MapsCellsToPixelRect()
{
var grid = new DesktopGridGeometry(
Origin: new Point(12, 24),
CellSize: 80,
CellGap: 8,
ColumnCount: 6,
RowCount: 8);
var rect = DesktopPlacementMath.GetCellRect(grid, column: 2, row: 3, widthCells: 2, heightCells: 3);
Assert.Equal(188, rect.X, 3);
Assert.Equal(288, rect.Y, 3);
Assert.Equal(168, rect.Width, 3);
Assert.Equal(256, rect.Height, 3);
}
[Fact]
public void Session_DoesNotCommitWhilePointerIsStillInsideLibrary()
{
var session = DesktopEditSession.CreatePendingNew(
componentId: "demo",
pageIndex: 0,
widthCells: 2,
heightCells: 2,
startPointerInViewport: new Point(80, 80),
pointerOffsetInViewport: new Point(60, 60),
componentLibraryBounds: new Rect(0, 0, 220, 300));
session = session.WithCurrentPointer(new Point(130, 150));
Assert.True(session.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
Assert.True(session.IsPointerInsideComponentLibrary());
Assert.False(session.CanCommit);
}
[Fact]
public void Session_ResizePreviewStillBlocksWhenPointerRemainsInsideLibrary()
{
var session = DesktopEditSession.CreateResizingExisting(
componentId: "demo",
placementId: "placement-1",
pageIndex: 0,
widthCells: 2,
heightCells: 2,
startPointerInViewport: new Point(80, 80),
componentLibraryBounds: new Rect(0, 0, 220, 300))
.WithCurrentPointer(new Point(130, 150));
Assert.True(session.IsPointerInsideComponentLibrary());
Assert.False(session.CanCommit);
}
[Fact]
public void HasCellPositionChanged_DetectsNoOpAndRealMoves()
{
Assert.False(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 3));
Assert.True(DesktopPlacementMath.HasCellPositionChanged(2, 3, 2, 4));
}
[Fact]
public void HasCellSpanChanged_DetectsNoOpAndRealResizes()
{
Assert.False(DesktopPlacementMath.HasCellSpanChanged(2, 3, 2, 3));
Assert.True(DesktopPlacementMath.HasCellSpanChanged(2, 3, 3, 3));
}
[Fact]
public void CanCommitPlacement_BlocksWhenPlacementIsOccludedByLibrary()
{
var placementRect = new Rect(160, 110, 180, 140);
var occludingLibraryBounds = new Rect(120, 80, 240, 220);
var distantLibraryBounds = new Rect(420, 420, 80, 80);
Assert.False(DesktopPlacementMath.CanCommitPlacement(placementRect, occludingLibraryBounds));
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, distantLibraryBounds));
Assert.True(DesktopPlacementMath.CanCommitPlacement(placementRect, componentLibraryBounds: null));
}
[Fact]
public void Session_AllowsCommitWhenComponentLibraryBoundsAreCleared()
{
var pendingSession = DesktopEditSession.CreatePendingNew(
componentId: "demo",
pageIndex: 0,
widthCells: 2,
heightCells: 2,
startPointerInViewport: new Point(80, 80),
pointerOffsetInViewport: new Point(60, 60),
componentLibraryBounds: null)
.WithCurrentPointer(new Point(200, 180));
Assert.True(pendingSession.HasExceededThreshold(DesktopPlacementMath.ComputeDragStartThreshold(80)));
Assert.False(pendingSession.IsPointerInsideComponentLibrary());
Assert.False(pendingSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
Assert.False(pendingSession.CanCommit);
var resizeSession = DesktopEditSession.CreateResizingExisting(
componentId: "demo",
placementId: "placement-1",
pageIndex: 0,
widthCells: 2,
heightCells: 2,
startPointerInViewport: new Point(80, 80),
componentLibraryBounds: null)
.WithCurrentPointer(new Point(200, 180))
.WithTargetCell(row: 2, column: 3);
Assert.False(resizeSession.IsPointerInsideComponentLibrary());
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
Assert.True(resizeSession.CanCommit);
}
}

View File

@@ -0,0 +1,73 @@
using System.Collections.Generic;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Views.Components;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class InfoRecommendationHostCornerRadiusTests
{
private static readonly string[] WideInfoComponentIds =
[
BuiltInComponentIds.DesktopDailyPoetry,
BuiltInComponentIds.DesktopDailyArtwork,
BuiltInComponentIds.DesktopDailyWord,
BuiltInComponentIds.DesktopCnrDailyNews,
BuiltInComponentIds.DesktopIfengNews,
BuiltInComponentIds.DesktopBilibiliHotSearch,
BuiltInComponentIds.DesktopBaiduHotSearch,
BuiltInComponentIds.DesktopStcn24Forum
];
[Theory]
[InlineData(80d)]
[InlineData(120d)]
[InlineData(160d)]
public void InfoHostRegistrations_ResolveToTheUnifiedLgBaseline(double cellSize)
{
var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
foreach (var componentId in WideInfoComponentIds)
{
AssertResolved(registry, componentId, cellSize);
}
AssertResolved(registry, BuiltInComponentIds.DesktopDailyWord2x2, cellSize);
}
private static void AssertResolved(
DesktopComponentRuntimeRegistry registry,
string componentId,
double cellSize)
{
Assert.True(
registry.TryGetDescriptor(componentId, out var descriptor),
$"Missing runtime registration for '{componentId}'.");
var zero = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 0d));
var unit = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 1d));
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
Assert.Equal(0d, zero, 3);
Assert.Equal(18d, unit, 3);
Assert.Equal(45d, max, 3);
Assert.True(zero <= unit && unit <= max);
}
private static ComponentChromeContext CreateChromeContext(
string componentId,
double cellSize,
double globalScale)
{
return new ComponentChromeContext(
componentId,
null,
cellSize,
globalScale,
AppearanceCornerRadiusTokenFactory.Create(globalScale));
}
}

View File

@@ -4,6 +4,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,157 @@
using System;
using System.IO;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class WhiteboardNotePersistenceServiceTests
{
[Fact]
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
var snapshot = CreateSampleSnapshot();
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
Assert.Single(loaded.Strokes);
Assert.Equal(2, loaded.Strokes[0].Points.Count);
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
}
[Fact]
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-board", CreateSampleSnapshot(), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-board", DateTimeOffset.UtcNow.AddDays(-10), retentionDays: 7);
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
Assert.Empty(loaded.Strokes);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
}
[Fact]
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
{
using var sandbox = new WhiteboardNotePersistenceSandbox();
var service = sandbox.CreateService();
service.SaveNote("DesktopWhiteboard", "expired-a", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "expired-b", CreateSampleSnapshot(), retentionDays: 7);
service.SaveNote("DesktopWhiteboard", "fresh-c", CreateSampleSnapshot(), retentionDays: 15);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-a", DateTimeOffset.UtcNow.AddDays(-9), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "expired-b", DateTimeOffset.UtcNow.AddDays(-8), retentionDays: 7);
sandbox.OverrideSavedTimestamp("DesktopWhiteboard", "fresh-c", DateTimeOffset.UtcNow.AddDays(-2), retentionDays: 15);
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
Assert.Equal(2, deletedCount);
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
}
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
{
return new WhiteboardNoteSnapshot
{
Strokes =
[
new WhiteboardStrokeSnapshot
{
Color = "#FF112233",
InkThickness = 3.5d,
IgnorePressure = true,
Points =
[
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
new WhiteboardStylusPointSnapshot { X = 48, Y = 64, Pressure = 0.7d, Width = 2, Height = 2 }
]
}
]
};
}
private sealed class WhiteboardNotePersistenceSandbox : IDisposable
{
private readonly string _directoryPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.WhiteboardNoteTests",
Guid.NewGuid().ToString("N"));
private readonly string _databasePath;
public WhiteboardNotePersistenceSandbox()
{
Directory.CreateDirectory(_directoryPath);
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
}
public WhiteboardNotePersistenceService CreateService()
{
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
}
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
{
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
UPDATE whiteboard_notes
SET saved_at_utc_ms = $savedAtUtcMs,
expires_at_utc_ms = $expiresAtUtcMs,
updated_at_utc_ms = $updatedAtUtcMs
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.ExecuteNonQuery();
}
public bool Exists(string componentId, string placementId)
{
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT COUNT(1)
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
return Convert.ToInt32(command.ExecuteScalar()) > 0;
}
public void Dispose()
{
try
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
}
catch
{
// Temporary test directories are best-effort cleanup.
}
}
}
}

View File

@@ -1,6 +1,12 @@
<Solution>
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
<Project Path="LanMountainDesktop.Host.Abstractions/LanMountainDesktop.Host.Abstractions.csproj" />
<Project Path="LanMountainDesktop.Shared.Contracts/LanMountainDesktop.Shared.Contracts.csproj" />
<Project Path="LanMountainDesktop.Settings.Core/LanMountainDesktop.Settings.Core.csproj" />
<Project Path="LanMountainDesktop.Appearance/LanMountainDesktop.Appearance.csproj" />
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />

View File

@@ -10,6 +10,8 @@
<Application.Resources>
<FontFamily x:Key="AppFontFamily">avares://LanMountainDesktop/Assets/Fonts#MiSans</FontFamily>
<FontFamily x:Key="AppFontFamilyJP">avares://LanMountainDesktop/Assets/Fonts#MiSans JP</FontFamily>
<FontFamily x:Key="AppFontFamilyKR">avares://LanMountainDesktop/Assets/Fonts#MiSans KR</FontFamily>
</Application.Resources>
<Application.DataTemplates>
@@ -23,6 +25,7 @@
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/NavigationStyles.axaml" />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />
@@ -71,4 +74,5 @@
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Application.Styles>
</Application>

View File

@@ -15,6 +15,7 @@ using Avalonia.Styling;
using Avalonia.Threading;
using AvaloniaWebView;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
@@ -46,6 +47,7 @@ public partial class App : Application
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly FontFamilyService _fontFamilyService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
@@ -56,17 +58,31 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
private TrayIcons? _trayIcons;
private TrayIcon? _trayIcon;
private NativeMenuItem? _trayShowDesktopMenuItem;
private NativeMenuItem? _traySettingsMenuItem;
private NativeMenuItem? _trayComponentLibraryMenuItem;
private NativeMenuItem? _trayRestartMenuItem;
private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
private DesktopShellHost? _desktopShellHost;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
// 隐私政策查看事件
public static event Action? CurrentPrivacyPolicyViewRequested;
// 触发隐私政策查看事件的方法
public static void RaisePrivacyPolicyViewRequested()
{
CurrentPrivacyPolicyViewRequested?.Invoke();
}
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
@@ -86,6 +102,11 @@ public partial class App : Application
public App()
{
if (Design.IsDesignMode)
{
return;
}
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
@@ -93,9 +114,16 @@ public partial class App : Application
public override void Initialize()
{
AppLogger.Info("App", "Initializing application resources.");
AvaloniaXamlLoader.Load(this);
if (Design.IsDesignMode)
{
ApplyDesignTimeTheme();
return;
}
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
ApplyThemeFromSettings();
ApplyCurrentCultureFromSettings();
EnsureSettingsWindowService();
@@ -104,31 +132,55 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
base.OnFrameworkInitializationCompleted();
return;
}
AppLogger.Info("App", "Framework initialization completed.");
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
InitializeTrayIcon();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
base.OnFrameworkInitializationCompleted();
}
private void ApplyDesignTimeTheme()
{
RequestedThemeVariant = ThemeVariant.Light;
try
{
// 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 += (_, _) =>
ApplyAdaptiveThemeResources();
}
catch (Exception ex)
{
AppLogger.Warn("Previewer", "Failed to apply adaptive theme resources in design mode.", ex);
}
}
private void InitializeDesktopShell()
{
_desktopShellHost ??= new DesktopShellHost(
InitializePluginRuntime,
InitializeTrayIcon,
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;
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
},
() =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
};
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
}
StartWeatherLocationRefreshIfNeeded();
base.OnFrameworkInitializationCompleted();
},
() => CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow),
StartWeatherLocationRefreshIfNeeded);
_desktopShellHost.Initialize(this);
}
private void OnTrayExitClick(object? sender, EventArgs e)
@@ -229,18 +281,43 @@ public partial class App : Application
{
try
{
DisposeTrayIcon();
var trayIcon = new TrayIcon
if (_trayIcon is null)
{
Icon = _appLogoService.CreateTrayIcon(),
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
Menu = BuildTrayMenu(),
IsVisible = true
};
_trayShowDesktopMenuItem = new NativeMenuItem();
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
_trayIcons = [trayIcon];
TrayIcon.SetIcons(this, _trayIcons);
_traySettingsMenuItem = new NativeMenuItem();
_traySettingsMenuItem.Click += OnTraySettingsClick;
_trayComponentLibraryMenuItem = new NativeMenuItem();
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
_trayRestartMenuItem = new NativeMenuItem();
_trayRestartMenuItem.Click += OnTrayRestartClick;
_trayExitMenuItem = new NativeMenuItem();
_trayExitMenuItem.Click += OnTrayExitClick;
var trayMenu = new NativeMenu();
trayMenu.Items.Add(_trayShowDesktopMenuItem);
trayMenu.Items.Add(_traySettingsMenuItem);
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayRestartMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayExitMenuItem);
_trayIcon = new TrayIcon
{
Icon = _appLogoService.CreateTrayIcon(),
Menu = trayMenu,
IsVisible = true
};
TrayIcon.SetIcons(this, [_trayIcon]);
}
RefreshTrayIconContent();
}
catch (Exception ex)
{
@@ -248,51 +325,58 @@ public partial class App : Application
}
}
private NativeMenu BuildTrayMenu()
private void RefreshTrayIconContent()
{
var menu = new NativeMenu();
if (_trayIcon is not null)
{
_trayIcon.IsVisible = true;
if (!OperatingSystem.IsLinux())
{
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
}
}
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
showDesktopItem.Click += OnTrayShowDesktopClick;
menu.Items.Add(showDesktopItem);
if (_trayShowDesktopMenuItem is not null)
{
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
}
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
if (_traySettingsMenuItem is not null)
{
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
componentLibraryItem.Click += OnTrayComponentLibraryClick;
menu.Items.Add(componentLibraryItem);
if (_trayComponentLibraryMenuItem is not null)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
menu.Items.Add(new NativeMenuItemSeparator());
if (_trayRestartMenuItem is not null)
{
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
}
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem);
return menu;
if (_trayExitMenuItem is not null)
{
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
}
}
private void DisposeTrayIcon()
{
if (_trayIcons is null)
if (_trayIcon is null)
{
return;
}
TrayIcon.SetIcons(this, null);
foreach (var trayIcon in _trayIcons)
try
{
trayIcon.Dispose();
_trayIcon.IsVisible = false;
}
catch (Exception ex)
{
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
}
_trayIcons = null;
}
private void EnsureSettingsWindowService()
@@ -365,6 +449,21 @@ public partial class App : Application
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
ApplyLanguageSpecificFont(languageCode);
}
private void ApplyLanguageSpecificFont(string languageCode)
{
var fontFamily = _fontFamilyService.GetFontFamilyForLanguage(languageCode);
if (Resources.TryGetValue("AppFontFamily", out var currentFont) &&
currentFont is FontFamily currentFontFamily &&
currentFontFamily.Name == fontFamily.Name)
{
return;
}
Resources["AppFontFamily"] = fontFamily;
}
private void ActivateMainWindow()
@@ -484,6 +583,7 @@ public partial class App : Application
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
@@ -501,11 +601,10 @@ public partial class App : Application
if (languageChanged)
{
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
RefreshTrayIconContent();
}
}, DispatcherPriority.Background);
}
@@ -573,13 +672,13 @@ public partial class App : Application
try
{
var (analytics, crashReport) = App.AnalyticsServices;
analytics?.SendShutdownEvent();
crashReport?.SendShutdownEvent();
TelemetryServices.Usage?.Shutdown(
_shutdownIntent == ShutdownIntent.RestartRequested,
"App.PerformExitCleanup");
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex);
}
try
@@ -613,6 +712,27 @@ public partial class App : Application
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
try
{
TelemetryServices.Crash?.CaptureShutdown(
_shutdownIntent == ShutdownIntent.RestartRequested,
"App.PerformExitCleanup");
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex);
}
try
{
TelemetryServices.Crash?.Dispose();
TelemetryServices.Usage?.Dispose();
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex);
}
}
private MainWindow CreateAndAssignMainWindow(

View File

@@ -0,0 +1,229 @@
# LanMountainDesktop 遥测隐私政策
**生效日期**2026年3月22日\
**最后更新**2026年3月22日
***
## 引言
LanMountainDesktop以下简称"本应用")由 灵方软件Lincube以下简称"我们")开发和维护。我们深知用户隐私的重要性,并致力于保护您的个人信息安全。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
使用本应用即表示您同意本隐私政策的条款。如果您不同意本政策的任何部分,请停止使用本应用。
***
## 1. 数据收集范围
### 1.1 我们收集的数据
本应用提供两类可选的数据收集功能:
| 数据类型 | 收集方式 | 默认状态 | 用途 |
| ------ | ---- | ---- | -------- |
| 启动基线事件 | 自动收集 | 开启 | 统计用户量 |
| 崩溃数据 | 用户授权 | 关闭 | 分析稳定性问题 |
| 行为数据 | 用户授权 | 关闭 | 分析功能使用情况 |
### 1.2 启动基线事件
无论您是否开启其他遥测选项,本应用会在首次启动时发送一次最小化的启动基线事件(`app_first_launch`),用于统计活跃用户量。该事件仅包含:
- 匿名安装标识符Install ID
- 应用版本号
- 启动时间戳
### 1.3 崩溃数据
当您开启"崩溃数据上传"功能时,我们可能收集以下信息:
- **异常信息**:异常类型、错误消息、堆栈跟踪
- **应用信息**:应用版本、构建号、运行时环境
- **系统信息**:操作系统版本、系统架构、可用内存
- **设备信息**:设备型号、屏幕分辨率
- **日志信息**:应用崩溃前的最近日志记录(可能包含您在使用过程中产生的操作记录)
### 1.4 行为数据
当您开启"行为数据分析"功能时,我们可能收集以下信息:
- **会话信息**:应用启动/退出时间、会话持续时间
- **功能使用**:设置页面访问、抽屉操作、组件库操作
- **组件操作**:桌面组件的放置、移动、调整大小、删除操作
- **界面交互**:页面切换、编辑模式进入/退出
***
## 2. 数据使用目的
我们收集的数据将用于以下目的:
### 2.1 启动基线事件
- 统计应用的用户数量和活跃度
- 了解应用的安装分布情况
### 2.2 崩溃数据
- 诊断和修复应用崩溃问题
- 提高应用的稳定性和可靠性
- 识别和解决性能瓶颈
### 2.3 行为数据
- 了解用户如何使用本应用的功能
- 改进用户体验和界面设计
- 指导功能开发和优先级决策
- 分析用户行为模式和趋势
***
## 3. 数据存储与传输
### 3.1 数据传输
您的数据将通过加密连接传输至以下第三方服务:
| 服务提供商 | 服务类型 | 数据内容 | 隐私政策 |
| ------- | ---- | --------- | ----------------------------- |
| PostHog | 产品分析 | 启动事件、行为数据 | <https://posthog.com/privacy> |
| Sentry | 错误监控 | 崩溃数据、异常信息 | <https://sentry.io/privacy/> |
### 3.2 数据存储位置
数据存储于上述第三方服务的服务器,这些服务器可能位于中国境外。我们已与这些服务提供商签订数据处理协议,确保您的数据得到适当保护。
### 3.3 数据保留期限
- **启动基线事件**:保留期限由 PostHog 服务配置决定,通常为 13 个月
- **崩溃数据**:保留期限由 Sentry 服务配置决定,通常为 90 天
- **行为数据**:保留期限由 PostHog 服务配置决定,通常为 13 个月
***
## 4. 用户权利与控制
### 4.1 您的权利
根据适用的数据保护法律,您享有以下权利:
- **知情权**:了解我们收集哪些数据及其用途
- **访问权**:请求获取我们持有的您的个人数据副本
- **更正权**:请求更正不准确或不完整的个人数据
- **删除权**:请求删除您的个人数据
- **撤回同意权**:随时撤回您对数据收集的同意
- **数据可携带权**:以结构化格式接收您的个人数据
### 4.2 如何行使您的权利
您可以通过以下方式行使上述权利:
1. **关闭遥测功能**:在应用设置 > 隐私设置中关闭相应开关
2. **刷新遥测标识**:在应用设置 > 隐私设置中点击"刷新遥测 ID"
3. **联系我们**:通过 GitHub Issues 提交数据相关请求
### 4.3 功能控制
| 功能 | 控制方式 | 效果 |
| ------- | ---- | ----------- |
| 崩溃数据上传 | 设置开关 | 关闭后停止发送崩溃数据 |
| 行为数据分析 | 设置开关 | 关闭后停止发送行为数据 |
| 刷新遥测 ID | 手动触发 | 生成新的匿名标识符 |
***
## 5. 身份标识
### 5.1 匿名标识符
我们使用以下匿名标识符来区分用户和会话:
- **Install ID**:在应用首次安装时随机生成的唯一标识符,用于区分不同的安装实例
- **Telemetry ID**:匿名标识符,用于关联遥测数据
### 5.2 标识符特性
- 这些标识符不包含您的真实身份信息
- 标识符与您的个人身份(如姓名、邮箱、电话)无关联
***
## 6. 数据安全
### 6.1 安全措施
我们采取以下安全措施保护您的数据:
- **传输加密**:所有数据传输均使用 TLS/HTTPS 加密
- **访问控制**:限制对数据的访问权限,仅授权人员可访问
- **匿名化处理**:使用匿名标识符而非个人身份信息
### 6.2 数据泄露响应
如发生数据泄露事件,我们将:
1. 及时评估泄露的影响范围和严重程度
2. 采取必要措施阻止进一步泄露
3. 根据法律要求通知相关监管机构和受影响用户
***
## 7. 第三方服务
### 7.1 PostHog
PostHog 是我们使用的产品分析平台用于收集和分析用户行为数据。PostHog 的隐私政策请参阅:<https://posthog.com/privacy>
### 7.2 Sentry
Sentry 是我们使用的错误监控平台用于收集和分析崩溃数据。Sentry 的隐私政策请参阅:<https://sentry.io/privacy/>
### 7.3 第三方责任
我们仅将上述第三方服务用于本政策所述目的。我们不对这些第三方的隐私实践负责,建议您阅读其隐私政策。
***
## 8. 儿童隐私
本应用不面向 14 周岁以下的儿童。我们不会故意收集儿童的个人信息。如果您是 14 周岁以下儿童的监护人,且发现您的孩子向我们提供了个人信息,请联系我们,我们将采取措施删除相关信息。
***
## 9. 隐私政策更新
我们可能会不时更新本隐私政策。更新后的政策将在本应用内发布,并在政策顶部注明"最后更新"日期。重大变更时,我们将在应用内通过显著方式通知您。
建议您定期查阅本政策,以了解我们如何保护您的信息。继续使用本应用即表示您接受更新后的隐私政策。
***
## 10. 适用法律
本隐私政策的解释和执行适用中华人民共和国法律法规,包括但不限于:
- 《中华人民共和国个人信息保护法》
- 《中华人民共和国数据安全法》
- 《中华人民共和国网络安全法》
- 《信息安全技术 个人信息安全规范》GB/T 35273
***
## 11. 联系我们
如果您对本隐私政策有任何疑问、意见或建议,请通过以下方式联系我们:
- **GitHub 仓库**<https://github.com/wwiinnddyy/LanMountainDesktop>
- **问题反馈**<https://github.com/wwiinnddyy/LanMountainDesktop/issues>
我们将在收到您的请求后 30 日内予以答复。
***
## 12. 条款可分割性
如果本隐私政策的任何条款被有管辖权的法院或监管机构认定为无效或不可执行,该条款应在最小必要范围内进行修改以使其有效和可执行,或如果无法修改,则予以删除。本政策的其余条款将继续有效。
***
**本隐私政策最终解释权归灵方软件Lincube所有。**

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bilibili</title><path d="M17.813 4.653h.854c1.51.054 2.769.578 3.773 1.574 1.004.995 1.524 2.249 1.56 3.76v7.36c-.036 1.51-.556 2.769-1.56 3.773s-2.262 1.524-3.773 1.56H5.333c-1.51-.036-2.769-.556-3.773-1.56S.036 18.858 0 17.347v-7.36c.036-1.511.556-2.765 1.56-3.76 1.004-.996 2.262-1.52 3.773-1.574h.774l-1.174-1.12a1.234 1.234 0 0 1-.373-.906c0-.356.124-.658.373-.907l.027-.027c.267-.249.573-.373.92-.373.347 0 .653.124.92.373L9.653 4.44c.071.071.134.142.187.213h4.267a.836.836 0 0 1 .16-.213l2.853-2.747c.267-.249.573-.373.92-.373.347 0 .662.151.929.4.267.249.391.551.391.907 0 .355-.124.657-.373.906zM5.333 7.24c-.746.018-1.373.276-1.88.773-.506.498-.769 1.13-.786 1.894v7.52c.017.764.28 1.395.786 1.893.507.498 1.134.756 1.88.773h13.334c.746-.017 1.373-.275 1.88-.773.506-.498.769-1.129.786-1.893v-7.52c-.017-.765-.28-1.396-.786-1.894-.507-.497-1.134-.755-1.88-.773zM8 11.107c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c0-.373.129-.689.386-.947.258-.257.574-.386.947-.386zm8 0c.373 0 .684.124.933.373.25.249.383.569.4.96v1.173c-.017.391-.15.711-.4.96-.249.25-.56.374-.933.374s-.684-.125-.933-.374c-.25-.249-.383-.569-.4-.96V12.44c.017-.391.15-.711.4-.96.249-.249.56-.373.933-.373Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>WeChat</title><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.ComponentSystem;
public static class BuiltInComponentIds
{
@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
@@ -40,4 +41,7 @@ public static class BuiltInComponentIds
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
}

View File

@@ -0,0 +1,37 @@
using System;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.ComponentSystem;
public static class ComponentColorSchemeHelper
{
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
{
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
}
public static string GetCurrentGlobalThemeColorMode()
{
try
{
var service = HostAppearanceThemeProvider.GetOrCreate();
var appearance = service.GetCurrent();
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
}
catch
{
return ThemeAppearanceValues.ColorModeDefaultNeutral;
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.ComponentSystem.Extensions;
@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
@@ -327,6 +337,24 @@ public sealed class ComponentRegistry
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
"Office Recent Documents",
"Folder",
"File",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopRemovableStorage,
"Removable Storage",
"Storage",
"File",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.Date,
"Calendar",
@@ -362,7 +390,17 @@ public sealed class ComponentRegistry
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true)
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopZhiJiaoHub,
"智教Hub",
"Image",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};
return new ComponentRegistry(builtIn);

View File

@@ -1,3 +1,4 @@
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
@@ -10,5 +11,6 @@ public sealed record DesktopComponentRuntimeContext(
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
IAppearanceThemeService AppearanceTheme,
ComponentChromeContext Chrome,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);

View File

@@ -0,0 +1,8 @@
using LanMountainDesktop.Host.Abstractions;
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentChromeContextAware
{
void SetComponentChromeContext(ComponentChromeContext context);
}

View File

@@ -0,0 +1,280 @@
using System;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
namespace LanMountainDesktop.DesktopEditing;
internal sealed class ComponentLibraryCollapsePresenter
{
private static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(150);
private static readonly Easing TransitionEasing = new CubicEaseOut();
private const double StableOpacityThreshold = 0.01;
private readonly Border _componentLibraryWindow;
private readonly Border _collapsedChipHost;
private readonly TextBlock _collapsedChipTextBlock;
private readonly Control? _collapsedChipIcon;
private readonly TranslateTransform _windowTranslate = new();
private readonly TranslateTransform _chipTranslate = new();
private readonly ScaleTransform _chipScale = new(1, 1);
private ComponentLibraryCollapseState _state;
private int _transitionVersion;
public ComponentLibraryCollapsePresenter(
Border componentLibraryWindow,
Border collapsedChipHost,
TextBlock collapsedChipTextBlock,
Control? collapsedChipIcon = null)
{
_componentLibraryWindow = componentLibraryWindow ?? throw new ArgumentNullException(nameof(componentLibraryWindow));
_collapsedChipHost = collapsedChipHost ?? throw new ArgumentNullException(nameof(collapsedChipHost));
_collapsedChipTextBlock = collapsedChipTextBlock ?? throw new ArgumentNullException(nameof(collapsedChipTextBlock));
_collapsedChipIcon = collapsedChipIcon;
EnsureTransforms();
_state = ComponentLibraryCollapseState.CreateExpanded(
_componentLibraryWindow.Margin,
_componentLibraryWindow.Opacity <= 0 ? 1 : _componentLibraryWindow.Opacity);
ApplyExpandedSnapshot();
_collapsedChipHost.IsVisible = false;
_collapsedChipHost.IsHitTestVisible = false;
_collapsedChipHost.Opacity = 0;
}
public bool IsCollapsed => _state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed;
public ComponentLibraryCollapseVisualState VisualState => _state.VisualState;
public void SyncExpandedState(Thickness margin, double opacity)
{
var hasStableOpacity = IsStableExpandedOpacity(opacity);
var nextExpandedOpacity = hasStableOpacity ? Math.Clamp(opacity, 0, 1) : _state.ExpandedOpacity;
_state = _state with
{
ExpandedMargin = margin,
ExpandedOpacity = nextExpandedOpacity
};
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded or ComponentLibraryCollapseVisualState.Restoring)
{
ApplyExpandedSnapshot(applyOpacity: hasStableOpacity);
}
}
public void Collapse(string title)
{
_collapsedChipTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Widgets" : title;
if (_state.VisualState is ComponentLibraryCollapseVisualState.Collapsing or ComponentLibraryCollapseVisualState.Collapsed)
{
ShowCollapsedChip(_transitionVersion);
return;
}
var version = ++_transitionVersion;
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsing, isChipVisible: true);
ApplyExpandedSnapshot();
ShowCollapsedChip(version);
SetCollapsedWindowTargets();
DispatcherTimer.RunOnce(
() =>
{
if (version != _transitionVersion)
{
return;
}
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Collapsed, isChipVisible: true);
_componentLibraryWindow.IsVisible = false;
_componentLibraryWindow.IsHitTestVisible = false;
},
TransitionDuration);
}
public void Restore()
{
if (_state.VisualState is ComponentLibraryCollapseVisualState.Expanded)
{
ApplyExpandedSnapshot();
_collapsedChipHost.IsVisible = false;
_collapsedChipHost.IsHitTestVisible = false;
_collapsedChipHost.Opacity = 0;
return;
}
var version = ++_transitionVersion;
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Restoring, isChipVisible: false);
PrepareRestoringWindow();
HideCollapsedChip(version);
Dispatcher.UIThread.Post(
() =>
{
if (version != _transitionVersion)
{
return;
}
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
_windowTranslate.Y = 0;
},
DispatcherPriority.Background);
DispatcherTimer.RunOnce(
() =>
{
if (version != _transitionVersion)
{
return;
}
_state = _state.WithVisualState(ComponentLibraryCollapseVisualState.Expanded, isChipVisible: false);
_componentLibraryWindow.IsVisible = true;
_componentLibraryWindow.IsHitTestVisible = true;
},
TransitionDuration);
}
private void EnsureTransforms()
{
_componentLibraryWindow.RenderTransform = _windowTranslate;
_windowTranslate.Transitions = new Transitions
{
new DoubleTransition
{
Property = TranslateTransform.YProperty,
Duration = TransitionDuration,
Easing = TransitionEasing
}
};
_collapsedChipHost.RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
_collapsedChipHost.RenderTransform = new TransformGroup
{
Children =
{
_chipTranslate,
_chipScale
}
};
_chipTranslate.Transitions = new Transitions
{
new DoubleTransition
{
Property = TranslateTransform.YProperty,
Duration = TransitionDuration,
Easing = TransitionEasing
}
};
_chipScale.Transitions = new Transitions
{
new DoubleTransition
{
Property = ScaleTransform.ScaleXProperty,
Duration = TransitionDuration,
Easing = TransitionEasing
},
new DoubleTransition
{
Property = ScaleTransform.ScaleYProperty,
Duration = TransitionDuration,
Easing = TransitionEasing
}
};
}
private void ApplyExpandedSnapshot(bool applyOpacity = true)
{
_componentLibraryWindow.Margin = _state.ExpandedMargin;
if (applyOpacity)
{
_componentLibraryWindow.Opacity = _state.ExpandedOpacity;
}
_componentLibraryWindow.IsVisible = true;
_componentLibraryWindow.IsHitTestVisible = true;
_windowTranslate.Y = 0;
}
private void SetCollapsedWindowTargets()
{
_componentLibraryWindow.Opacity = 0;
_windowTranslate.Y = 28;
}
private void ShowCollapsedChip(int version)
{
_collapsedChipHost.IsVisible = true;
_collapsedChipHost.IsHitTestVisible = false;
_collapsedChipTextBlock.IsVisible = true;
if (_collapsedChipIcon is not null)
{
_collapsedChipIcon.IsVisible = true;
}
_collapsedChipHost.Opacity = 0;
_chipTranslate.Y = 8;
_chipScale.ScaleX = 0.96;
_chipScale.ScaleY = 0.96;
Dispatcher.UIThread.Post(
() =>
{
if (version != _transitionVersion)
{
return;
}
_collapsedChipHost.Opacity = 1;
_chipTranslate.Y = 0;
_chipScale.ScaleX = 1;
_chipScale.ScaleY = 1;
},
DispatcherPriority.Background);
}
private void HideCollapsedChip(int version)
{
_collapsedChipHost.IsVisible = true;
_collapsedChipHost.IsHitTestVisible = false;
_collapsedChipHost.Opacity = 0;
_chipTranslate.Y = 8;
_chipScale.ScaleX = 0.96;
_chipScale.ScaleY = 0.96;
DispatcherTimer.RunOnce(
() =>
{
if (version != _transitionVersion)
{
return;
}
_collapsedChipHost.IsVisible = false;
},
TransitionDuration);
}
private void PrepareRestoringWindow()
{
_componentLibraryWindow.IsVisible = true;
_componentLibraryWindow.IsHitTestVisible = true;
_componentLibraryWindow.Margin = _state.ExpandedMargin;
_componentLibraryWindow.Opacity = 0;
_windowTranslate.Y = 28;
}
private static bool IsStableExpandedOpacity(double opacity)
{
return !double.IsNaN(opacity) &&
!double.IsInfinity(opacity) &&
opacity > StableOpacityThreshold;
}
}

View File

@@ -0,0 +1,36 @@
using Avalonia;
namespace LanMountainDesktop.DesktopEditing;
internal enum ComponentLibraryCollapseVisualState
{
Expanded,
Collapsing,
Collapsed,
Restoring
}
internal readonly record struct ComponentLibraryCollapseState(
ComponentLibraryCollapseVisualState VisualState,
Thickness ExpandedMargin,
double ExpandedOpacity,
bool IsChipVisible)
{
public static ComponentLibraryCollapseState CreateExpanded(Thickness expandedMargin, double expandedOpacity)
{
return new(
ComponentLibraryCollapseVisualState.Expanded,
expandedMargin,
expandedOpacity,
IsChipVisible: false);
}
public ComponentLibraryCollapseState WithVisualState(ComponentLibraryCollapseVisualState visualState, bool isChipVisible)
{
return this with
{
VisualState = visualState,
IsChipVisible = isChipVisible
};
}
}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.DesktopEditing;
internal static class DesktopEditCommitMath
{
public static bool IsPendingCommitValid(bool isPending, int scheduledVersion, int currentVersion)
{
return isPending && scheduledVersion == currentVersion;
}
}

View File

@@ -0,0 +1,358 @@
using System;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
namespace LanMountainDesktop.DesktopEditing;
internal sealed class DesktopEditGhostView : Border
{
private static readonly TimeSpan FastDuration = TimeSpan.FromMilliseconds(120);
private static readonly Easing StandardEasing = new CubicEaseOut();
private readonly Image _previewImage;
private readonly Border _previewOverlay;
private readonly Border _fallbackCard;
private readonly Border _accentDot;
private readonly TextBlock _titleTextBlock;
private readonly TextBlock _detailTextBlock;
private readonly Border _badgeBorder;
private readonly TextBlock _badgeTextBlock;
private readonly ScaleTransform _scaleTransform = new(1, 1);
private readonly SolidColorBrush _normalBackgroundBrush = new(Color.Parse("#F11B2430"));
private readonly SolidColorBrush _normalBorderBrush = new(Color.Parse("#4D8AA3C1"));
private readonly SolidColorBrush _normalAccentBrush = new(Color.Parse("#FF4F8EF7"));
private readonly SolidColorBrush _normalTextBrush = new(Color.Parse("#FFF5F7FA"));
private readonly SolidColorBrush _normalMutedTextBrush = new(Color.Parse("#BDE2E8F0"));
private readonly SolidColorBrush _normalBadgeBackgroundBrush = new(Color.Parse("#245E86D6"));
private readonly SolidColorBrush _normalBadgeBorderBrush = new(Color.Parse("#557EA7E6"));
private readonly SolidColorBrush _invalidBackgroundBrush = new(Color.Parse("#F01B1022"));
private readonly SolidColorBrush _invalidBorderBrush = new(Color.Parse("#FFE25555"));
private readonly SolidColorBrush _invalidAccentBrush = new(Color.Parse("#FFFF6B6B"));
private readonly SolidColorBrush _invalidBadgeBackgroundBrush = new(Color.Parse("#33FF4D4D"));
private readonly SolidColorBrush _invalidBadgeBorderBrush = new(Color.Parse("#88FF7676"));
private bool _hasPreviewImage;
private bool _isInvalid;
public DesktopEditGhostView()
{
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Padding = new Thickness(14);
Background = _normalBackgroundBrush;
BorderBrush = _normalBorderBrush;
BorderThickness = new Thickness(1);
CornerRadius = new CornerRadius(22);
ClipToBounds = true;
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative);
RenderTransform = _scaleTransform;
Transitions = new Transitions
{
CreateOpacityTransition(FastDuration)
};
_scaleTransform.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
};
_accentDot = new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = _normalAccentBrush,
BorderThickness = new Thickness(0),
VerticalAlignment = VerticalAlignment.Center
};
_titleTextBlock = new TextBlock
{
Foreground = _normalTextBrush,
FontWeight = FontWeight.SemiBold,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
MaxLines = 1
};
_detailTextBlock = new TextBlock
{
Foreground = _normalMutedTextBrush,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
MaxLines = 1
};
_badgeTextBlock = new TextBlock
{
Foreground = _normalTextBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
MaxLines = 1
};
_badgeBorder = new Border
{
HorizontalAlignment = HorizontalAlignment.Left,
VerticalAlignment = VerticalAlignment.Top,
Padding = new Thickness(9, 4),
CornerRadius = new CornerRadius(999),
Background = _normalBadgeBackgroundBrush,
BorderBrush = _normalBadgeBorderBrush,
BorderThickness = new Thickness(1),
Child = _badgeTextBlock
};
_previewImage = new Image
{
Stretch = Stretch.UniformToFill,
IsVisible = false
};
_previewOverlay = new Border
{
Background = new SolidColorBrush(Color.Parse("#1A000000")),
IsVisible = false
};
var headerPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
_accentDot,
_titleTextBlock
}
};
var contentPanel = new StackPanel
{
Spacing = 6,
Children =
{
headerPanel,
_detailTextBlock
}
};
var fallbackGrid = new Grid
{
RowDefinitions = new RowDefinitions
{
new RowDefinition(GridLength.Auto),
new RowDefinition(GridLength.Auto)
},
RowSpacing = 8
};
fallbackGrid.Children.Add(contentPanel);
fallbackGrid.Children.Add(_badgeBorder);
Grid.SetRow(contentPanel, 0);
Grid.SetRow(_badgeBorder, 1);
_badgeBorder.Margin = new Thickness(0, 2, 0, 0);
_fallbackCard = new Border
{
Background = Brushes.Transparent,
Child = fallbackGrid
};
Child = new Grid
{
Children =
{
_previewImage,
_previewOverlay,
_fallbackCard
}
};
UpdatePreviewMetrics(180, 120);
UpdateContent(null, null, null);
ApplyShellChrome();
}
public void UpdateContent(string? title, string? detail, string? badgeText)
{
_titleTextBlock.Text = string.IsNullOrWhiteSpace(title) ? "Component" : title;
_detailTextBlock.Text = string.IsNullOrWhiteSpace(detail) ? string.Empty : detail;
_detailTextBlock.IsVisible = !string.IsNullOrWhiteSpace(detail);
_badgeTextBlock.Text = string.IsNullOrWhiteSpace(badgeText) ? string.Empty : badgeText;
_badgeBorder.IsVisible = !string.IsNullOrWhiteSpace(badgeText);
}
public void SetPreviewImage(IImage? image)
{
_previewImage.Source = image;
_hasPreviewImage = image is not null;
_previewImage.IsVisible = _hasPreviewImage;
_previewOverlay.IsVisible = false;
_fallbackCard.IsVisible = !_hasPreviewImage;
ApplyShellChrome();
}
public void UpdatePreviewMetrics(double width, double height)
{
var normalizedWidth = Math.Max(1, width);
var normalizedHeight = Math.Max(1, height);
var minSide = Math.Max(1, Math.Min(normalizedWidth, normalizedHeight));
CornerRadius = _hasPreviewImage
? new CornerRadius(Math.Clamp(minSide * 0.14, 14, 24))
: new CornerRadius(Math.Clamp(minSide * 0.16, 16, 28));
Padding = _hasPreviewImage
? new Thickness(
Math.Clamp(minSide * 0.02, 1, 4),
Math.Clamp(minSide * 0.02, 1, 4),
Math.Clamp(minSide * 0.02, 1, 4),
Math.Clamp(minSide * 0.02, 1, 4))
: new Thickness(
Math.Clamp(minSide * 0.10, 10, 18),
Math.Clamp(minSide * 0.10, 10, 18),
Math.Clamp(minSide * 0.10, 10, 18),
Math.Clamp(minSide * 0.09, 10, 16));
var titleFontSize = Math.Clamp(minSide * 0.12, 12, 18);
var detailFontSize = Math.Clamp(minSide * 0.085, 10, 13);
var badgeFontSize = Math.Clamp(minSide * 0.08, 9, 12);
var dotSize = Math.Clamp(minSide * 0.07, 8, 12);
var badgeHorizontalPadding = Math.Clamp(minSide * 0.07, 8, 14);
var badgeVerticalPadding = Math.Clamp(minSide * 0.035, 3, 6);
_accentDot.Width = dotSize;
_accentDot.Height = dotSize;
_titleTextBlock.FontSize = titleFontSize;
_detailTextBlock.FontSize = detailFontSize;
_badgeTextBlock.FontSize = badgeFontSize;
_badgeBorder.Padding = new Thickness(badgeHorizontalPadding, badgeVerticalPadding);
}
public void SetInvalid(bool isInvalid)
{
_isInvalid = isInvalid;
if (isInvalid)
{
_accentDot.Background = _invalidAccentBrush;
_badgeBorder.Background = _invalidBadgeBackgroundBrush;
_badgeBorder.BorderBrush = _invalidBadgeBorderBrush;
_titleTextBlock.Foreground = _invalidBorderBrush;
_detailTextBlock.Foreground = _invalidBorderBrush;
_badgeTextBlock.Foreground = _invalidBorderBrush;
if (!_hasPreviewImage)
{
Background = _invalidBackgroundBrush;
BorderBrush = _invalidBorderBrush;
BorderThickness = new Thickness(1);
Opacity = 0.9;
}
else
{
ApplyShellChrome();
}
return;
}
_accentDot.Background = _normalAccentBrush;
_badgeBorder.Background = _normalBadgeBackgroundBrush;
_badgeBorder.BorderBrush = _normalBadgeBorderBrush;
_titleTextBlock.Foreground = _normalTextBrush;
_detailTextBlock.Foreground = _normalMutedTextBrush;
_badgeTextBlock.Foreground = _normalTextBrush;
if (!_hasPreviewImage)
{
Background = _normalBackgroundBrush;
BorderBrush = _normalBorderBrush;
BorderThickness = new Thickness(1);
Opacity = 1.0;
}
else
{
ApplyShellChrome();
}
}
public void SetRestingScale(double scale)
{
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
_scaleTransform.ScaleX = clampedScale;
_scaleTransform.ScaleY = clampedScale;
}
public void AnimateToScale(double scale)
{
var clampedScale = Math.Clamp(scale, 0.85, 1.12);
_scaleTransform.ScaleX = clampedScale;
_scaleTransform.ScaleY = clampedScale;
}
internal bool HasPreviewImage => _hasPreviewImage;
internal void SetScaleTransitionDuration(TimeSpan duration)
{
_scaleTransform.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, duration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, duration)
};
}
internal void SetOpacityTransitionDuration(TimeSpan duration)
{
Transitions = new Transitions
{
CreateOpacityTransition(duration)
};
}
private void ApplyShellChrome()
{
if (_hasPreviewImage)
{
Background = Brushes.Transparent;
BorderBrush = Brushes.Transparent;
BorderThickness = new Thickness(0);
BoxShadow = BoxShadows.Parse("0 14 32 #1A000000");
Opacity = 1.0;
return;
}
BoxShadow = default;
if (_isInvalid)
{
Background = _invalidBackgroundBrush;
BorderBrush = _invalidBorderBrush;
BorderThickness = new Thickness(1);
Opacity = 0.9;
return;
}
Background = _normalBackgroundBrush;
BorderBrush = _normalBorderBrush;
BorderThickness = new Thickness(1);
Opacity = 1.0;
}
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
new()
{
Property = property,
Duration = duration,
Easing = StandardEasing
};
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
new()
{
Property = Visual.OpacityProperty,
Duration = duration,
Easing = StandardEasing
};
}

View File

@@ -0,0 +1,343 @@
using System;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.DesktopEditing;
internal enum DesktopEditGhostVisualStyle
{
StandardLift = 0,
ElevatedFromLibrary
}
internal sealed class DesktopEditOverlayPresenter
{
private static readonly TimeSpan FastDuration = FluttermotionToken.Fast;
private static readonly TimeSpan PickupDuration = TimeSpan.FromMilliseconds(160);
private static readonly TimeSpan CommitSettleDuration = TimeSpan.FromMilliseconds(160);
private static readonly TimeSpan CancelSettleDuration = TimeSpan.FromMilliseconds(120);
private static readonly Easing StandardEasing = new CubicEaseOut();
private readonly Canvas _root;
private readonly DesktopEditGhostView _ghostView;
private readonly Border _candidateOutline;
private readonly ScaleTransform _candidateScale = new(1, 1);
private Rect? _previewRect;
private Rect? _candidateRect;
private bool _isInvalid;
private bool _isVisible;
private int _dismissVersion;
private readonly SolidColorBrush _candidateBrush = new(Color.Parse("#FF0A84FF"));
private readonly SolidColorBrush _candidateInvalidBrush = new(Color.Parse("#FFFF3B30"));
private readonly SolidColorBrush _candidateFillBrush = new(Color.Parse("#140A84FF"));
private readonly SolidColorBrush _candidateInvalidFillBrush = new(Color.Parse("#14FF3B30"));
public DesktopEditOverlayPresenter()
{
_ghostView = new DesktopEditGhostView
{
IsHitTestVisible = false,
Opacity = 1
};
_candidateOutline = new Border
{
IsHitTestVisible = false,
Background = _candidateFillBrush,
BorderBrush = _candidateBrush,
BorderThickness = new Thickness(2),
CornerRadius = new CornerRadius(22),
Opacity = 0,
RenderTransformOrigin = new RelativePoint(0.5, 0.5, RelativeUnit.Relative),
RenderTransform = _candidateScale,
Transitions = new Transitions
{
new DoubleTransition
{
Property = Visual.OpacityProperty,
Duration = FastDuration,
Easing = StandardEasing
}
}
};
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, FastDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, FastDuration)
};
_candidateOutline.SetValue(Panel.ZIndexProperty, 0);
_ghostView.SetValue(Panel.ZIndexProperty, 1);
_root = new Canvas
{
IsHitTestVisible = false,
ClipToBounds = false,
Opacity = 0,
IsVisible = false,
Children =
{
_candidateOutline,
_ghostView
}
};
_root.Transitions = new Transitions
{
CreateOpacityTransition(FastDuration)
};
}
public Control Root => _root;
public void SetViewportSize(Size size)
{
_root.Width = Math.Max(1, size.Width);
_root.Height = Math.Max(1, size.Height);
}
public void SetPreviewRect(Rect rect)
{
_previewRect = Normalize(rect);
ApplyPreviewRect();
}
public void SetCandidateRect(Rect? rect)
{
_candidateRect = rect is null ? null : Normalize(rect.Value);
ApplyCandidateRect();
}
public void UpdateGhostContent(string? title, string? detail = null, string? badge = null)
{
_ghostView.UpdateContent(title, detail, badge);
}
public void SetPreviewImage(IImage? image)
{
_ghostView.SetPreviewImage(image);
}
public void SetInvalid(bool isInvalid)
{
_isInvalid = isInvalid;
_ghostView.SetInvalid(isInvalid);
UpdateCandidateAppearance();
}
public void Show(DesktopEditGhostVisualStyle visualStyle = DesktopEditGhostVisualStyle.StandardLift)
{
_dismissVersion++;
_isVisible = true;
_root.IsVisible = true;
_root.Opacity = 0;
_ghostView.Opacity = 0;
var imageMode = _ghostView.HasPreviewImage;
var initialGhostScale = 0.985;
var targetGhostScale = 1.0;
if (visualStyle == DesktopEditGhostVisualStyle.ElevatedFromLibrary)
{
initialGhostScale = 1.02;
targetGhostScale = 1.06;
}
else if (imageMode)
{
initialGhostScale = 0.992;
targetGhostScale = 1.03;
}
_root.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetOpacityTransitionDuration(PickupDuration);
_ghostView.SetScaleTransitionDuration(PickupDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, PickupDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, PickupDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(PickupDuration)
};
_ghostView.SetRestingScale(initialGhostScale);
_candidateOutline.Opacity = 0;
_candidateScale.ScaleX = 0.97;
_candidateScale.ScaleY = 0.97;
Dispatcher.UIThread.Post(() =>
{
if (!_isVisible)
{
return;
}
_root.Opacity = 1;
_ghostView.Opacity = 1;
_ghostView.SetRestingScale(targetGhostScale);
if (_candidateRect.HasValue)
{
_candidateOutline.Opacity = 1;
_candidateScale.ScaleX = 1;
_candidateScale.ScaleY = 1;
}
}, DispatcherPriority.Background);
}
public void Hide()
{
_dismissVersion++;
_isVisible = false;
_root.Opacity = 0;
_ghostView.Opacity = 0;
_candidateOutline.Opacity = 0;
_candidateScale.ScaleX = 0.96;
_candidateScale.ScaleY = 0.96;
_ghostView.SetRestingScale(0.96);
_ghostView.SetPreviewImage(null);
_root.IsVisible = false;
}
public void Commit()
{
BeginDismiss(isCancel: false);
}
public void Cancel()
{
BeginDismiss(isCancel: true);
}
private void BeginDismiss(bool isCancel)
{
if (!_isVisible)
{
return;
}
var version = ++_dismissVersion;
_isVisible = false;
var settleDuration = isCancel ? CancelSettleDuration : CommitSettleDuration;
_root.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
_ghostView.SetOpacityTransitionDuration(settleDuration);
_ghostView.SetScaleTransitionDuration(settleDuration);
_candidateScale.Transitions = new Transitions
{
CreateScaleTransition(ScaleTransform.ScaleXProperty, settleDuration),
CreateScaleTransition(ScaleTransform.ScaleYProperty, settleDuration)
};
_candidateOutline.Transitions = new Transitions
{
CreateOpacityTransition(settleDuration)
};
var targetScale = _ghostView.HasPreviewImage
? 1.00
: isCancel ? 0.96 : 1.04;
_candidateOutline.Opacity = 0;
_ghostView.Opacity = 0;
_root.Opacity = 0;
_ghostView.AnimateToScale(targetScale);
_candidateScale.ScaleX = targetScale;
_candidateScale.ScaleY = targetScale;
DispatcherTimer.RunOnce(
() =>
{
if (version != _dismissVersion)
{
return;
}
_root.IsVisible = false;
},
FastDuration);
}
private void ApplyPreviewRect()
{
if (!_previewRect.HasValue)
{
return;
}
var rect = _previewRect.Value;
_ghostView.Width = Math.Max(1, rect.Width);
_ghostView.Height = Math.Max(1, rect.Height);
Canvas.SetLeft(_ghostView, rect.X);
Canvas.SetTop(_ghostView, rect.Y);
_ghostView.UpdatePreviewMetrics(rect.Width, rect.Height);
}
private void ApplyCandidateRect()
{
if (!_candidateRect.HasValue)
{
_candidateOutline.IsVisible = false;
_candidateOutline.Opacity = 0;
return;
}
var rect = _candidateRect.Value;
_candidateOutline.IsVisible = true;
_candidateOutline.Width = Math.Max(1, rect.Width);
_candidateOutline.Height = Math.Max(1, rect.Height);
Canvas.SetLeft(_candidateOutline, rect.X);
Canvas.SetTop(_candidateOutline, rect.Y);
var cornerRadius = Math.Clamp(Math.Min(rect.Width, rect.Height) * 0.11, 14, 26);
_candidateOutline.CornerRadius = new CornerRadius(cornerRadius);
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
_candidateOutline.Opacity = _isVisible ? 1 : 0;
_candidateScale.ScaleX = _isVisible ? 1 : 0.97;
_candidateScale.ScaleY = _isVisible ? 1 : 0.97;
UpdateCandidateAppearance();
}
private void UpdateCandidateAppearance()
{
if (!_candidateRect.HasValue)
{
return;
}
_candidateOutline.BorderBrush = _isInvalid ? _candidateInvalidBrush : _candidateBrush;
_candidateOutline.Background = _isInvalid ? _candidateInvalidFillBrush : _candidateFillBrush;
}
private static Rect Normalize(Rect rect)
{
var width = Math.Max(1, rect.Width);
var height = Math.Max(1, rect.Height);
return new Rect(rect.X, rect.Y, width, height);
}
private static DoubleTransition CreateScaleTransition(AvaloniaProperty property, TimeSpan duration) =>
new()
{
Property = property,
Duration = duration,
Easing = StandardEasing
};
private static DoubleTransition CreateOpacityTransition(TimeSpan duration) =>
new()
{
Property = Visual.OpacityProperty,
Duration = duration,
Easing = StandardEasing
};
}

View File

@@ -0,0 +1,205 @@
using System;
using Avalonia;
namespace LanMountainDesktop.DesktopEditing;
internal enum DesktopEditSessionMode
{
None = 0,
PendingNew,
DraggingNew,
DraggingExisting,
ResizingExisting
}
internal readonly record struct DesktopEditSession
{
public DesktopEditSessionMode Mode { get; init; }
public string? ComponentId { get; init; }
public string? PlacementId { get; init; }
public int PageIndex { get; init; }
public int WidthCells { get; init; }
public int HeightCells { get; init; }
public Point StartPointerInViewport { get; init; }
public Point CurrentPointerInViewport { get; init; }
public Point PointerOffsetInViewport { get; init; }
public Rect? ComponentLibraryBounds { get; init; }
public int TargetRow { get; init; }
public int TargetColumn { get; init; }
public bool IsActive => Mode != DesktopEditSessionMode.None;
public bool IsPendingNew => Mode == DesktopEditSessionMode.PendingNew;
public bool IsDraggingNew => Mode == DesktopEditSessionMode.DraggingNew;
public bool IsDraggingExisting => Mode == DesktopEditSessionMode.DraggingExisting;
public bool IsResizingExisting => Mode == DesktopEditSessionMode.ResizingExisting;
public bool HasTargetCell => TargetRow >= 0 && TargetColumn >= 0;
public double PointerTravelDistance => DesktopPlacementMath.Distance(StartPointerInViewport, CurrentPointerInViewport);
public bool HasExceededThreshold(double threshold)
{
return DesktopPlacementMath.HasExceededThreshold(StartPointerInViewport, CurrentPointerInViewport, threshold);
}
public bool IsPointerInsideComponentLibrary()
{
return DesktopPlacementMath.IsOccludedByComponentLibrary(CurrentPointerInViewport, ComponentLibraryBounds);
}
public bool IsPreviewOccludedByComponentLibrary(Rect previewRect)
{
return DesktopPlacementMath.IsOccludedByComponentLibrary(previewRect, ComponentLibraryBounds);
}
public bool CanCommit => IsActive && HasTargetCell;
public Rect GetPreviewRect(DesktopGridGeometry grid)
{
if (HasTargetCell)
{
return DesktopPlacementMath.GetCellRect(
grid,
TargetColumn,
TargetRow,
Math.Max(1, WidthCells),
Math.Max(1, HeightCells));
}
var freePreviewOrigin = DesktopPlacementMath.Subtract(CurrentPointerInViewport, PointerOffsetInViewport);
return new Rect(
freePreviewOrigin,
new Size(
Math.Max(1, WidthCells) * grid.CellSize + Math.Max(0, Math.Max(1, WidthCells) - 1) * grid.CellGap,
Math.Max(1, HeightCells) * grid.CellSize + Math.Max(0, Math.Max(1, HeightCells) - 1) * grid.CellGap));
}
public DesktopEditSession WithCurrentPointer(Point pointerInViewport)
{
return this with { CurrentPointerInViewport = pointerInViewport };
}
public DesktopEditSession WithComponentLibraryBounds(Rect? componentLibraryBounds)
{
return this with { ComponentLibraryBounds = componentLibraryBounds };
}
public DesktopEditSession WithTargetCell(int row, int column)
{
return this with { TargetRow = row, TargetColumn = column };
}
public DesktopEditSession PromoteToDraggingNew()
{
return this with { Mode = DesktopEditSessionMode.DraggingNew };
}
public DesktopEditSession PromoteToDraggingExisting()
{
return this with { Mode = DesktopEditSessionMode.DraggingExisting };
}
public DesktopEditSession PromoteToResizingExisting()
{
return this with { Mode = DesktopEditSessionMode.ResizingExisting };
}
public static DesktopEditSession CreatePendingNew(
string componentId,
int pageIndex,
int widthCells,
int heightCells,
Point startPointerInViewport,
Point pointerOffsetInViewport,
Rect? componentLibraryBounds)
{
return new DesktopEditSession
{
Mode = DesktopEditSessionMode.PendingNew,
ComponentId = componentId,
PageIndex = pageIndex,
WidthCells = Math.Max(1, widthCells),
HeightCells = Math.Max(1, heightCells),
StartPointerInViewport = startPointerInViewport,
CurrentPointerInViewport = startPointerInViewport,
PointerOffsetInViewport = pointerOffsetInViewport,
ComponentLibraryBounds = componentLibraryBounds,
TargetRow = -1,
TargetColumn = -1
};
}
public static DesktopEditSession CreateDraggingNew(
string componentId,
int pageIndex,
int widthCells,
int heightCells,
Point startPointerInViewport,
Point pointerOffsetInViewport,
Rect? componentLibraryBounds)
{
return CreatePendingNew(
componentId,
pageIndex,
widthCells,
heightCells,
startPointerInViewport,
pointerOffsetInViewport,
componentLibraryBounds) with
{
Mode = DesktopEditSessionMode.DraggingNew
};
}
public static DesktopEditSession CreateDraggingExisting(
string componentId,
string placementId,
int pageIndex,
int widthCells,
int heightCells,
Point startPointerInViewport,
Point pointerOffsetInViewport,
Rect? componentLibraryBounds)
{
return new DesktopEditSession
{
Mode = DesktopEditSessionMode.DraggingExisting,
ComponentId = componentId,
PlacementId = placementId,
PageIndex = pageIndex,
WidthCells = Math.Max(1, widthCells),
HeightCells = Math.Max(1, heightCells),
StartPointerInViewport = startPointerInViewport,
CurrentPointerInViewport = startPointerInViewport,
PointerOffsetInViewport = pointerOffsetInViewport,
ComponentLibraryBounds = componentLibraryBounds,
TargetRow = -1,
TargetColumn = -1
};
}
public static DesktopEditSession CreateResizingExisting(
string componentId,
string placementId,
int pageIndex,
int widthCells,
int heightCells,
Point startPointerInViewport,
Rect? componentLibraryBounds)
{
return new DesktopEditSession
{
Mode = DesktopEditSessionMode.ResizingExisting,
ComponentId = componentId,
PlacementId = placementId,
PageIndex = pageIndex,
WidthCells = Math.Max(1, widthCells),
HeightCells = Math.Max(1, heightCells),
StartPointerInViewport = startPointerInViewport,
CurrentPointerInViewport = startPointerInViewport,
PointerOffsetInViewport = default,
ComponentLibraryBounds = componentLibraryBounds,
TargetRow = -1,
TargetColumn = -1
};
}
}

View File

@@ -0,0 +1,176 @@
using System;
using Avalonia;
namespace LanMountainDesktop.DesktopEditing;
internal readonly record struct DesktopGridGeometry(
Point Origin,
double CellSize,
double CellGap,
int ColumnCount,
int RowCount)
{
public double Pitch => CellSize + CellGap;
public bool IsValid =>
CellSize > 0 &&
ColumnCount > 0 &&
RowCount > 0 &&
Pitch > 0;
}
internal static class DesktopPlacementMath
{
public static double ComputeDragStartThreshold(double cellSize)
{
return Math.Max(10d, Math.Max(0d, cellSize) * 0.18d);
}
public static double Distance(Point start, Point end)
{
return Math.Sqrt(DistanceSquared(start, end));
}
public static double DistanceSquared(Point start, Point end)
{
var deltaX = end.X - start.X;
var deltaY = end.Y - start.Y;
return deltaX * deltaX + deltaY * deltaY;
}
public static bool HasExceededThreshold(Point start, Point end, double threshold)
{
if (threshold <= 0)
{
return true;
}
return DistanceSquared(start, end) >= threshold * threshold;
}
public static Point Add(Point left, Point right)
{
return new Point(left.X + right.X, left.Y + right.Y);
}
public static Point Subtract(Point left, Point right)
{
return new Point(left.X - right.X, left.Y - right.Y);
}
public static bool ContainsPoint(Rect rect, Point point)
{
return rect.Contains(point);
}
public static bool Intersects(Rect left, Rect right)
{
return left.Intersects(right);
}
public static bool HasCellPositionChanged(int originalRow, int originalColumn, int targetRow, int targetColumn)
{
return originalRow != targetRow || originalColumn != targetColumn;
}
public static bool HasCellSpanChanged(int originalWidthCells, int originalHeightCells, int targetWidthCells, int targetHeightCells)
{
return originalWidthCells != targetWidthCells || originalHeightCells != targetHeightCells;
}
public static bool IsOccludedByComponentLibrary(Point point, Rect? componentLibraryBounds)
{
return componentLibraryBounds.HasValue && ContainsPoint(componentLibraryBounds.Value, point);
}
public static bool IsOccludedByComponentLibrary(Rect previewRect, Rect? componentLibraryBounds)
{
return componentLibraryBounds.HasValue && Intersects(previewRect, componentLibraryBounds.Value);
}
public static bool CanCommitPlacement(Rect placementRect, Rect? componentLibraryBounds)
{
return !IsOccludedByComponentLibrary(placementRect, componentLibraryBounds);
}
public static Rect GetGridBounds(DesktopGridGeometry grid)
{
if (!grid.IsValid)
{
return default;
}
var width = grid.ColumnCount * grid.CellSize + Math.Max(0, grid.ColumnCount - 1) * grid.CellGap;
var height = grid.RowCount * grid.CellSize + Math.Max(0, grid.RowCount - 1) * grid.CellGap;
return new Rect(grid.Origin, new Size(width, height));
}
public static Rect GetCellRect(
DesktopGridGeometry grid,
int column,
int row,
int widthCells = 1,
int heightCells = 1)
{
var safeWidthCells = Math.Max(1, widthCells);
var safeHeightCells = Math.Max(1, heightCells);
var safeColumn = Math.Max(0, column);
var safeRow = Math.Max(0, row);
var pitch = grid.Pitch;
var x = grid.Origin.X + safeColumn * pitch;
var y = grid.Origin.Y + safeRow * pitch;
var width = safeWidthCells * grid.CellSize + Math.Max(0, safeWidthCells - 1) * grid.CellGap;
var height = safeHeightCells * grid.CellSize + Math.Max(0, safeHeightCells - 1) * grid.CellGap;
return new Rect(x, y, width, height);
}
public static Rect GetSnappedCellRect(
DesktopGridGeometry grid,
Point pointerInViewport,
Point pointerOffset,
int widthCells,
int heightCells)
{
return TryGetSnappedCell(grid, pointerInViewport, pointerOffset, widthCells, heightCells, out var column, out var row)
? GetCellRect(grid, column, row, widthCells, heightCells)
: default;
}
public static bool TryGetSnappedCell(
DesktopGridGeometry grid,
Point pointerInViewport,
Point pointerOffset,
int widthCells,
int heightCells,
out int column,
out int row)
{
column = 0;
row = 0;
if (!grid.IsValid)
{
return false;
}
var safeWidthCells = Math.Max(1, widthCells);
var safeHeightCells = Math.Max(1, heightCells);
var maxColumn = Math.Max(0, grid.ColumnCount - safeWidthCells);
var maxRow = Math.Max(0, grid.RowCount - safeHeightCells);
var pitch = grid.Pitch;
if (pitch <= 0)
{
return false;
}
var previewOrigin = Subtract(pointerInViewport, pointerOffset);
var relativeX = previewOrigin.X - grid.Origin.X;
var relativeY = previewOrigin.Y - grid.Origin.Y;
column = (int)Math.Floor(relativeX / pitch);
row = (int)Math.Floor(relativeY / pitch);
column = Math.Clamp(column, 0, maxColumn);
row = Math.Clamp(row, 0, maxRow);
return true;
}
}

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