Compare commits

..

13 Commits

Author SHA1 Message Date
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
lincube
6c9f6be1b1 0.6.0.1
应用遥测,插件市场
2026-03-16 09:50:48 +08:00
161 changed files with 9270 additions and 935 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"

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 完成后进行

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,21 +1,33 @@
# LanAirApp
# LanAirApp (Mirror)
## 中文
`LanAirApp`阑山桌面插件生态的对外工作区。这个目录是宿主仓库的镜像副本,权威版本以独立 `LanAirApp` 仓库为准
这里的 `LanAirApp/`放在宿主仓库的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源
### 目录说明
### 这份镜像的角色
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目。
- `standards/`:插件清单和目录结构约定。
- `tools/`:插件打包与辅助工具。
- 提供本地工作区里的 `airappmarket` 索引副本
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
- 不承担宿主运行时职责
### 与宿主的关系
### 权威来源
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
- 插件市场与开发文档:独立 `LanAirApp` 仓库
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
## English
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
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

@@ -0,0 +1,578 @@
# 移除视频壁纸功能 - 技术设计文档
## 1. 概述
### 1.1 设计目标
本设计文档描述如何从 LanMountainDesktop 项目中完全移除视频壁纸功能,包括:
- 移除 LibVLC 相关依赖
- 清理主窗口中的视频壁纸代码
- 简化壁纸设置页面
- 清理本地化资源
### 1.2 技术约束
- 保持现有图片壁纸和纯色壁纸功能完整
- 确保应用构建和运行正常
- 不引入新的外部依赖
---
## 2. 架构变更
### 2.1 变更概览图
```
┌─────────────────────────────────────────────────────────────────┐
│ 变更前架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ ├── DesktopWallpaperImageLayer (图片层) │
│ │ ├── DesktopVideoWallpaperImage (视频海报层) │
│ │ └── DesktopVideoWallpaperView (VLC视频播放层) │
│ ├── _libVlc, _videoWallpaperPlayer, _videoWallpaperMedia │
│ └── StartVideoWallpaper(), StopVideoWallpaper() │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | Video | SolidColor │
│ └── 视频预览区域 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 变更后架构 │
├─────────────────────────────────────────────────────────────────┤
│ MainWindow │
│ ├── DesktopWallpaperLayer (背景层) │
│ │ └── DesktopWallpaperImageLayer (图片层) │
│ └── (移除所有视频相关字段和方法) │
│ │
│ WallpaperSettingsPage │
│ ├── 类型选择: Image | SolidColor │
│ └── (移除视频预览区域) │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 组件变更清单
| 组件 | 变更类型 | 说明 |
|------|----------|------|
| LanMountainDesktop.csproj | 修改 | 移除 LibVLC 包引用 |
| MainWindow.axaml | 修改 | 移除视频控件和命名空间 |
| MainWindow.axaml.cs | 修改 | 移除视频相关字段和清理代码 |
| MainWindow.SettingsHardCut.Stubs.cs | 修改 | 移除视频壁纸方法 |
| AppearanceThemeService.cs | 修改 | 移除视频种子提取器 |
| WallpaperSettingsPage.axaml | 修改 | 移除视频类型UI |
| WallpaperSettingsPageViewModel.cs | 修改 | 移除视频相关属性 |
| SettingsContracts.cs | 修改 | 移除 Video 枚举值 |
| SettingsDomainServices.cs | 修改 | 移除视频扩展名检测 |
| zh-CN.json | 修改 | 移除视频相关本地化文本 |
---
## 3. 详细设计
### 3.1 项目依赖变更 (LanMountainDesktop.csproj)
#### 3.1.1 移除的包引用
```xml
<!-- 移除以下包引用 -->
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="..." />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="..." />
```
#### 3.1.2 变更影响
- 减少约 100MB+ 的依赖包大小
- 简化构建和发布流程
- 移除平台特定的原生库依赖
---
### 3.2 主窗口 XAML 变更 (MainWindow.axaml)
#### 3.2.1 移除命名空间声明
```xml
<!-- 移除此行 -->
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
```
#### 3.2.2 移除视频壁纸控件
移除以下控件约第126-137行
```xml
<!-- 移除 DesktopVideoWallpaperImage -->
<Image x:Name="DesktopVideoWallpaperImage"
IsVisible="False"
IsHitTestVisible="False"
Stretch="UniformToFill"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<!-- 移除 DesktopVideoWallpaperView -->
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
IsVisible="False"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
```
---
### 3.3 主窗口代码变更 (MainWindow.axaml.cs)
#### 3.3.1 移除 using 声明
```csharp
// 移除以下 using如果存在
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.3.2 移除静态字段
```csharp
// 移除以下字段约第68-71行
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.3.3 移除实例字段
```csharp
// 移除以下字段约第123-146行
private Bitmap? _videoWallpaperPosterBitmap;
private string? _videoWallpaperPosterPath;
private string? _wallpaperVideoPath;
private LibVLC? _libVlc;
private MediaPlayer? _videoWallpaperPlayer;
private Media? _videoWallpaperMedia;
private readonly object _desktopVideoFrameSync = new();
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
private IntPtr _desktopVideoFrameBufferPtr;
private byte[]? _desktopVideoStagingBuffer;
private WriteableBitmap? _desktopVideoBitmap;
private int _desktopVideoFrameWidth;
private int _desktopVideoFrameHeight;
private int _desktopVideoFramePitch;
private int _desktopVideoFrameBufferSize;
private int _desktopVideoFrameDirtyFlag;
```
#### 3.3.4 修改 OnClosed 方法
移除视频相关清理代码约第336-350行
```csharp
// 移除以下代码行
StopVideoWallpaper();
_videoWallpaperMedia?.Dispose();
_videoWallpaperMedia = null;
_videoWallpaperPlayer?.Dispose();
_videoWallpaperPlayer = null;
_desktopVideoFrameRefreshTimer?.Stop();
_desktopVideoFrameRefreshTimer = null;
_videoWallpaperPosterBitmap?.Dispose();
_videoWallpaperPosterBitmap = null;
_videoWallpaperPosterPath = null;
_libVlc?.Dispose();
_libVlc = null;
```
---
### 3.4 主窗口 Stub 方法变更 (MainWindow.SettingsHardCut.Stubs.cs)
#### 3.4.1 移除 using 声明
```csharp
// 移除以下 using第19-20行
using LibVLCSharp.Shared;
using LibVLCSharp.Avalonia;
```
#### 3.4.2 移除方法
移除以下完整方法:
| 方法名 | 行号范围 | 说明 |
|--------|----------|------|
| `StartVideoWallpaper` | 337-383 | 启动视频壁纸播放 |
| `StopVideoWallpaper` | 385-395 | 停止视频壁纸播放 |
| `TryCaptureVideoWallpaperPosterFrame` | 666-751 | 捕获视频海报帧 |
| `ApplyVideoWallpaperPosterVisibility` | 647-664 | 控制视频海报可见性 |
#### 3.4.3 修改 UpdateWallpaperDisplay 方法
简化为仅处理图片壁纸:
```csharp
private void UpdateWallpaperDisplay()
{
// 移除视频分支,仅保留图片处理
StopVideoWallpaper(); // 移除此调用
ApplyWallpaperBrush();
}
```
修改后:
```csharp
private void UpdateWallpaperDisplay()
{
ApplyWallpaperBrush();
}
```
#### 3.4.4 修改 ApplyWallpaperBrush 方法
移除所有 `ApplyVideoWallpaperPosterVisibility` 调用:
```csharp
// 移除以下调用
ApplyVideoWallpaperPosterVisibility(showPoster: false);
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
```
#### 3.4.5 修改 SetWallpaperState 方法
移除视频类型处理分支约第238-247行
```csharp
// 移除以下代码块
var requestedTypeIsVideo = string.Equals(_wallpaperType, "Video", StringComparison.OrdinalIgnoreCase);
if (SupportedVideoExtensions.Contains(extension) || requestedTypeIsVideo)
{
_wallpaperMediaType = WallpaperMediaType.Video;
_wallpaperVideoPath = _wallpaperPath;
_wallpaperDisplayState = File.Exists(_wallpaperPath)
? WallpaperDisplayState.CurrentValidWallpaper
: WallpaperDisplayState.TemporarilyUnavailable;
return;
}
```
---
### 3.5 外观主题服务变更 (AppearanceThemeService.cs)
#### 3.5.1 移除接口和类
移除以下代码约第92-184行
```csharp
// 移除接口
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
// 移除实现类
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
// ... 整个类实现
}
```
---
### 3.6 壁纸设置页面 XAML 变更 (WallpaperSettingsPage.axaml)
#### 3.6.1 移除视频预览区域
移除以下代码约第29-44行
```xml
<Border Background="#FFF6F7F9"
IsVisible="{Binding IsVideo}">
<StackPanel HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="12">
<fi:FluentIcon Icon="Video"
Width="72"
Height="72"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<TextBlock Text="{Binding VideoModeHintText}"
Width="300"
TextAlignment="Center"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
</Border>
```
#### 3.6.2 移除视频模式提示文本
移除以下代码约第150-154行
```xml
<TextBlock Margin="0,8,0,0"
IsVisible="{Binding IsVideo}"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="{Binding VideoModeHintText}"
TextWrapping="Wrap" />
```
#### 3.6.3 修改填充方式设置可见性绑定
```xml
<!-- 修改前 -->
IsVisible="{Binding IsImageOrVideo}"
<!-- 修改后 -->
IsVisible="{Binding IsImage}"
```
---
### 3.7 壁纸设置 ViewModel 变更 (WallpaperSettingsPageViewModel.cs)
#### 3.7.1 移除属性
```csharp
// 移除以下属性
[ObservableProperty]
private bool _isImageOrVideo;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private string _videoModeHintText = string.Empty;
```
#### 3.7.2 修改 CreateWallpaperTypes 方法
```csharp
// 修改前
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
// 修改后
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
```
#### 3.7.3 修改 UpdateVisibility 方法
移除 IsVideo 和 IsImageOrVideo 的赋值:
```csharp
// 移除以下行
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
```
#### 3.7.4 修改 RefreshLocalizedText 方法
```csharp
// 移除以下行
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
```
---
### 3.8 设置契约变更 (SettingsContracts.cs)
#### 3.8.1 修改 WallpaperMediaType 枚举
```csharp
// 修改前
public enum WallpaperMediaType
{
None,
Image,
Video
}
// 修改后
public enum WallpaperMediaType
{
None,
Image
}
```
---
### 3.9 设置域服务变更 (SettingsDomainServices.cs)
#### 3.9.1 移除视频扩展名集合
```csharp
// 移除以下字段约第150-153行
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
```
#### 3.9.2 修改 DetectMediaType 方法
```csharp
// 修改前
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
// 修改后
public WallpaperMediaType DetectMediaType(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return WallpaperMediaType.None;
}
var extension = Path.GetExtension(path.Trim());
if (string.IsNullOrWhiteSpace(extension))
{
return WallpaperMediaType.None;
}
if (ImageExtensions.Contains(extension))
{
return WallpaperMediaType.Image;
}
return WallpaperMediaType.None;
}
```
---
### 3.10 本地化文件变更 (zh-CN.json)
#### 3.10.1 移除的本地化键
```json
// 移除以下键值对
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}"
```
#### 3.10.2 修改描述文本
```json
// 修改前
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
// 修改后
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
```
---
## 4. 数据模型变更
### 4.1 WallpaperMediaType 枚举简化
```
变更前: None | Image | Video
变更后: None | Image
```
### 4.2 设置存储兼容性
现有用户设置中如果包含 `Type: "Video"` 的壁纸配置:
- 应用将无法识别该类型
- 将回退到纯色背景
- 用户需要重新选择图片壁纸
---
## 5. 风险评估
### 5.1 潜在风险
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 现有视频壁纸用户设置失效 | 中 | 应用会自动回退到纯色背景 |
| 遗漏的视频相关代码引用 | 低 | 编译器会报告未定义类型错误 |
| 本地化键遗漏 | 低 | 运行时会显示键名而非翻译文本 |
### 5.2 回滚策略
如需回滚,可通过 Git 恢复以下文件:
- LanMountainDesktop.csproj
- MainWindow.axaml / .axaml.cs
- MainWindow.SettingsHardCut.Stubs.cs
- AppearanceThemeService.cs
- WallpaperSettingsPage.axaml
- WallpaperSettingsPageViewModel.cs
- SettingsContracts.cs
- SettingsDomainServices.cs
- zh-CN.json
---
## 6. 验证清单
### 6.1 编译验证
- [ ] 项目编译无错误
- [ ] 无 LibVLC 相关类型引用警告
- [ ] 无未使用变量警告
### 6.2 功能验证
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"
- [ ] 壁纸导入功能正常工作
### 6.3 清理验证
- [ ] 无 LibVLC 相关 DLL 在输出目录
- [ ] 无视频相关本地化文本残留
- [ ] 无视频相关 UI 控件残留

View File

@@ -0,0 +1,206 @@
# 移除视频壁纸功能规格说明书
## Why
当前 LanMountainDesktop 项目包含视频壁纸功能,该功能引入了以下复杂性和依赖:
1. 引入了 LibVLCSharp.Avalonia、VideoLAN.LibVLC.Windows、VideoLAN.LibVLC.Mac 等重型依赖
2. 在主窗口中残留大量视频壁纸相关代码和字段
3. 在设置页面中保留了视频类型选择器和相关 UI 元素
4. 在本地化文件中保留了视频壁纸相关文本
5. 增加了应用复杂度和维护成本
用户决定移除该功能以简化代码库。
## What Changes
- 移除 LibVLCSharp.Avalonia 及 VideoLAN.LibVLC.* NuGet 依赖
- 移除 AppearanceThemeService.cs 中的 LibVlcVideoWallpaperSeedExtractor 类和 IVideoWallpaperSeedExtractor 接口
- 移除 MainWindow.axaml.cs 中的视频壁纸相关字段和清理代码
- 移除 MainWindow.SettingsHardCut.Stubs.cs 中的视频壁纸相关方法
- 移除 MainWindow.axaml 中的 DesktopVideoWallpaperImage 和 DesktopVideoWallpaperView 控件
- 移除 WallpaperSettingsPage.axaml 中的视频类型选择器和视频模式提示
- 移除 WallpaperSettingsPageViewModel.cs 中的 IsVideo、VideoModeHintText 等属性
- 移除 SettingsContracts.cs 中 WallpaperMediaType 枚举的 Video 值
- 移除 SettingsDomainServices.cs 中 WallpaperMediaService 类的视频扩展名检测逻辑
- 移除本地化文件中的视频壁纸相关文本
## Impact
### Affected specs
- 壁纸设置功能规格
- 主窗口桌面层规格
### Affected code
- `LanMountainDesktop.csproj` - NuGet 依赖配置
- `Services/AppearanceThemeService.cs` - 视频壁纸种子提取器
- `Views/MainWindow.axaml.cs` - 主窗口字段和清理逻辑
- `Views/MainWindow.SettingsHardCut.Stubs.cs` - 视频壁纸控制方法
- `Views/MainWindow.axaml` - 视频壁纸 UI 控件
- `Views/SettingsPages/WallpaperSettingsPage.axaml` - 壁纸设置页面 UI
- `ViewModels/WallpaperSettingsPageViewModel.cs` - 壁纸设置 ViewModel
- `Services/Settings/SettingsContracts.cs` - 壁纸媒体类型枚举
- `Services/Settings/SettingsDomainServices.cs` - 壁纸媒体服务
- `Localization/zh-CN.json` - 本地化文本
---
## REMOVED Requirements
### Requirement: 视频壁纸播放功能
**Reason**: 用户决定移除视频壁纸功能以简化代码库,减少重型依赖
**Migration**:
- 用户如需动态壁纸,可使用静态图片壁纸替代
- 现有视频壁纸设置将被重置为纯色背景
#### Scenario: 视频壁纸播放
- **GIVEN** 用户选择了视频文件作为壁纸
- **WHEN** 系统检测到视频格式
- **THEN** 系统不再支持视频壁纸播放
- **AND THEN** 系统提示用户该文件类型不受支持
### Requirement: LibVLC 依赖
**Reason**: 移除视频壁纸功能后不再需要 LibVLC 库
**Migration**: 从项目依赖中移除以下包:
- LibVLCSharp.Avalonia
- VideoLAN.LibVLC.Windows
- VideoLAN.LibVLC.Mac
### Requirement: 视频壁纸种子提取
**Reason**: 移除视频壁纸功能后不再需要从视频中提取颜色种子
**Migration**: 移除 `LibVlcVideoWallpaperSeedExtractor` 类和 `IVideoWallpaperSeedExtractor` 接口
### Requirement: 视频壁纸 UI 控件
**Reason**: 移除视频壁纸功能后不再需要视频显示控件
**Migration**: 移除 `DesktopVideoWallpaperImage``DesktopVideoWallpaperView` 控件
### Requirement: 视频类型选择器
**Reason**: 移除视频壁纸功能后不再需要视频类型选项
**Migration**: 从壁纸类型选择器中移除"视频"选项
---
## MODIFIED Requirements
### Requirement: 壁纸媒体类型检测
**当前**: 支持检测 None、Image、Video 三种类型
**修改后**: 仅支持检测 None、Image 两种类型
#### Scenario: 检测媒体类型
- **WHEN** 用户选择壁纸文件
- **THEN** 系统仅检测图片格式(.png, .jpg, .jpeg, .bmp, .gif, .webp
- **AND THEN** 视频格式文件将被识别为不受支持的类型
### Requirement: 壁纸类型选项
**当前**: 提供图片、视频、纯色三种类型选项
**修改后**: 仅提供图片、纯色两种类型选项
#### Scenario: 壁纸类型选择
- **WHEN** 用户打开壁纸设置页面
- **THEN** 类型选择器仅显示"图片"和"纯色"选项
- **AND THEN** "视频"选项不再显示
### Requirement: 壁纸设置页面预览
**当前**: 根据类型显示图片预览、视频预览或纯色预览
**修改后**: 根据类型显示图片预览或纯色预览
#### Scenario: 预览显示
- **WHEN** 用户选择壁纸类型
- **THEN** 系统仅显示图片预览或纯色预览
- **AND THEN** 视频预览区域不再显示
### Requirement: 主窗口壁纸显示
**当前**: 支持显示静态图片壁纸和视频壁纸
**修改后**: 仅支持显示静态图片壁纸
#### Scenario: 壁纸显示更新
- **WHEN** 用户应用新壁纸
- **THEN** 系统仅处理静态图片壁纸显示
- **AND THEN** 视频壁纸播放逻辑不再执行
---
## ADDED Requirements
### Requirement: 清理残留代码
系统 SHALL 完全移除视频壁纸功能相关的所有代码和资源。
#### Scenario: 主窗口字段清理
- **WHEN** 执行代码清理
- **THEN** 移除以下字段:
- `_videoWallpaperPosterBitmap`
- `_videoWallpaperPosterPath`
- `_libVlc`
- `_videoWallpaperPlayer`
- `_videoWallpaperMedia`
- `_wallpaperVideoPath`
#### Scenario: 主窗口方法清理
- **WHEN** 执行代码清理
- **THEN** 移除以下方法:
- `StartVideoWallpaper`
- `StopVideoWallpaper`
- `TryCaptureVideoWallpaperPosterFrame`
- `ApplyVideoWallpaperPosterVisibility`
- `UpdateWallpaperDisplay` 中的视频处理分支
#### Scenario: ViewModel 属性清理
- **WHEN** 执行代码清理
- **THEN** 移除以下属性:
- `IsVideo`
- `VideoModeHintText`
- `IsImageOrVideo`(改为 `IsImage`
#### Scenario: 本地化文本清理
- **WHEN** 执行代码清理
- **THEN** 移除以下本地化键:
- `settings.wallpaper.type.video`
- `settings.wallpaper.video_applied`
- `settings.wallpaper.video_mode`
- `settings.wallpaper.video_restored`
- `settings.wallpaper.video_not_found`
- `settings.wallpaper.video_player_unavailable`
- `settings.wallpaper.video_play_failed_format`
### Requirement: 依赖项清理
系统 SHALL 从项目文件中移除 LibVLC 相关 NuGet 包引用。
#### Scenario: NuGet 包移除
- **WHEN** 执行依赖清理
- **THEN** 移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### Requirement: 构建验证
系统 SHALL 在移除视频壁纸功能后保持正常构建和运行。
#### Scenario: 构建成功
- **WHEN** 执行项目构建
- **THEN** 构建成功无错误
- **AND THEN** 所有现有测试通过
#### Scenario: 应用启动
- **WHEN** 启动应用程序
- **THEN** 应用正常启动
- **AND THEN** 壁纸设置功能正常工作(仅支持图片和纯色)

View File

@@ -0,0 +1,600 @@
# 移除视频壁纸功能 - 编码任务清单
## 任务概览
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
---
## 任务 1: 移除项目依赖
**优先级**: P0 (最高)
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从项目文件中移除 LibVLC 相关的 NuGet 包引用。
### 输入
- `LanMountainDesktop/LanMountainDesktop.csproj`
### 输出
- 修改后的 `LanMountainDesktop.csproj`,移除以下包引用:
- `LibVLCSharp.Avalonia`
- `VideoLAN.LibVLC.Windows`
- `VideoLAN.LibVLC.Mac`
### 验收标准
- [ ] 项目文件中不再包含 LibVLC 相关包引用
- [ ] 执行 `dotnet restore` 成功
### 执行提示
```
编辑 LanMountainDesktop.csproj移除以下 PackageReference 节点:
1. <PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
2. <PackageReference Include="VideoLAN.LibVLC.Windows" ... />
3. <PackageReference Include="VideoLAN.LibVLC.Mac" ... />
```
---
## 任务 2: 移除主窗口 XAML 视频控件
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 MainWindow.axaml 中移除视频壁纸相关的 XAML 控件和命名空间声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml`
### 输出
- 移除 LibVLC 命名空间声明
- 移除 `DesktopVideoWallpaperImage` 控件
- 移除 `DesktopVideoWallpaperView` 控件
### 验收标准
- [ ] XAML 中无 `xmlns:vlc` 命名空间
- [ ] XAML 中无 `DesktopVideoWallpaperImage` 元素
- [ ] XAML 中无 `DesktopVideoWallpaperView` 元素
### 执行提示
```
编辑 MainWindow.axaml
1. 移除第 9 行: xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
2. 移除第 126-131 行: <Image x:Name="DesktopVideoWallpaperImage" ... />
3. 移除第 133-137 行: <vlc:VideoView x:Name="DesktopVideoWallpaperView" ... />
```
---
## 任务 3: 移除主窗口代码视频字段
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 15 分钟
### 描述
从 MainWindow.axaml.cs 中移除视频壁纸相关的字段声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs`
### 输出
- 移除 `SupportedVideoExtensions` 静态字段
- 移除所有视频相关实例字段
### 验收标准
- [ ]`SupportedVideoExtensions` 字段
- [ ]`_videoWallpaperPosterBitmap` 字段
- [ ]`_videoWallpaperPosterPath` 字段
- [ ]`_wallpaperVideoPath` 字段
- [ ]`_libVlc` 字段
- [ ]`_videoWallpaperPlayer` 字段
- [ ]`_videoWallpaperMedia` 字段
- [ ]`_desktopVideoFrameSync` 及相关视频帧处理字段
### 执行提示
```
编辑 MainWindow.axaml.cs
1. 移除第 68-71 行的 SupportedVideoExtensions 定义
2. 移除第 123-146 行的所有视频相关字段
```
---
## 任务 4: 移除主窗口 OnClosed 清理代码
**优先级**: P0
**依赖**: 任务 3
**预估工作量**: 5 分钟
### 描述
从 MainWindow.axaml.cs 的 OnClosed 方法中移除视频相关清理代码。
### 输入
- `LanMountainDesktop/Views/MainWindow.axaml.cs` (OnClosed 方法)
### 输出
- 简化的 OnClosed 方法,无视频清理逻辑
### 验收标准
- [ ] OnClosed 方法中无 `StopVideoWallpaper()` 调用
- [ ] OnClosed 方法中无 `_videoWallpaperMedia` 相关清理
- [ ] OnClosed 方法中无 `_videoWallpaperPlayer` 相关清理
- [ ] OnClosed 方法中无 `_libVlc` 相关清理
### 执行提示
```
编辑 MainWindow.axaml.cs 的 OnClosed 方法,移除以下代码行:
- StopVideoWallpaper();
- _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null;
- _videoWallpaperPlayer?.Dispose(); _videoWallpaperPlayer = null;
- _desktopVideoFrameRefreshTimer?.Stop(); _desktopVideoFrameRefreshTimer = null;
- _videoWallpaperPosterBitmap?.Dispose(); _videoWallpaperPosterBitmap = null;
- _videoWallpaperPosterPath = null;
- _libVlc?.Dispose(); _libVlc = null;
```
---
## 任务 5: 移除主窗口 Stub 方法
**优先级**: P0
**依赖**: 任务 1
**预估工作量**: 20 分钟
### 描述
从 MainWindow.SettingsHardCut.Stubs.cs 中移除视频壁纸相关方法和 using 声明。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 移除 LibVLC using 声明
- 移除 `StartVideoWallpaper` 方法
- 移除 `StopVideoWallpaper` 方法
- 移除 `TryCaptureVideoWallpaperPosterFrame` 方法
- 移除 `ApplyVideoWallpaperPosterVisibility` 方法
### 验收标准
- [ ]`using LibVLCSharp.Shared;`
- [ ]`using LibVLCSharp.Avalonia;`
- [ ]`StartVideoWallpaper` 方法定义
- [ ]`StopVideoWallpaper` 方法定义
- [ ]`TryCaptureVideoWallpaperPosterFrame` 方法定义
- [ ]`ApplyVideoWallpaperPosterVisibility` 方法定义
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. 移除第 19-20 行的 using 声明
2. 移除 StartVideoWallpaper 方法(第 337-383 行)
3. 移除 StopVideoWallpaper 方法(第 385-395 行)
4. 移除 ApplyVideoWallpaperPosterVisibility 方法(第 647-664 行)
5. 移除 TryCaptureVideoWallpaperPosterFrame 方法(第 666-751 行)
```
---
## 任务 6: 简化壁纸状态处理逻辑
**优先级**: P0
**依赖**: 任务 5
**预估工作量**: 15 分钟
### 描述
修改 MainWindow.SettingsHardCut.Stubs.cs 中的壁纸状态处理方法,移除视频类型分支。
### 输入
- `LanMountainDesktop/Views/MainWindow.SettingsHardCut.Stubs.cs`
### 输出
- 简化的 `SetWallpaperState` 方法
- 简化的 `UpdateWallpaperDisplay` 方法
- 简化的 `ApplyWallpaperBrush` 方法
### 验收标准
- [ ] `SetWallpaperState` 中无视频类型检测分支
- [ ] `SetWallpaperState` 中无 `_wallpaperVideoPath` 赋值
- [ ] `UpdateWallpaperDisplay` 中无 `StopVideoWallpaper()` 调用
- [ ] `ApplyWallpaperBrush` 中无 `ApplyVideoWallpaperPosterVisibility` 调用
### 执行提示
```
编辑 MainWindow.SettingsHardCut.Stubs.cs
1. SetWallpaperState 方法:
- 移除 requestedTypeIsVideo 变量定义
- 移除视频类型检测 if 块SupportedVideoExtensions.Contains 检查)
2. UpdateWallpaperDisplay 方法:
- 移除视频类型分支,仅保留 ApplyWallpaperBrush() 调用
3. ApplyWallpaperBrush 方法:
- 移除所有 ApplyVideoWallpaperPosterVisibility 调用
```
---
## 任务 7: 移除外观主题服务视频提取器
**优先级**: P1
**依赖**: 任务 1
**预估工作量**: 10 分钟
### 描述
从 AppearanceThemeService.cs 中移除视频壁纸种子提取器接口和实现类。
### 输入
- `LanMountainDesktop/Services/AppearanceThemeService.cs`
### 输出
- 移除 `IVideoWallpaperSeedExtractor` 接口
- 移除 `LibVlcVideoWallpaperSeedExtractor`
### 验收标准
- [ ]`IVideoWallpaperSeedExtractor` 接口定义
- [ ]`LibVlcVideoWallpaperSeedExtractor` 类定义
### 执行提示
```
编辑 AppearanceThemeService.cs
移除第 92-184 行的接口和类定义:
- IVideoWallpaperSeedExtractor 接口
- LibVlcVideoWallpaperSeedExtractor 类
```
---
## 任务 8: 简化壁纸设置页面 XAML
**优先级**: P1
**依赖**: 无
**预估工作量**: 10 分钟
### 描述
从 WallpaperSettingsPage.axaml 中移除视频预览区域和相关 UI 元素。
### 输入
- `LanMountainDesktop/Views/SettingsPages/WallpaperSettingsPage.axaml`
### 输出
- 移除视频预览 Border 区域
- 移除视频模式提示 TextBlock
- 修改填充方式可见性绑定
### 验收标准
- [ ] 无视频预览 BorderIsVisible="{Binding IsVideo}"
- [ ] 无 VideoModeHintText 绑定的 TextBlock
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
### 执行提示
```
编辑 WallpaperSettingsPage.axaml
1. 移除第 29-44 行的视频预览 Border
2. 移除第 150-154 行的视频模式提示 TextBlock
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
```
---
## 任务 9: 简化壁纸设置 ViewModel
**优先级**: P1
**依赖**: 任务 8
**预估工作量**: 15 分钟
### 描述
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
### 输入
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
### 输出
- 移除 `_isImageOrVideo``_isVideo``_videoModeHintText` 属性
- 修改 `CreateWallpaperTypes` 方法
- 修改 `UpdateVisibility` 方法
- 修改 `RefreshLocalizedText` 方法
### 验收标准
- [ ]`IsImageOrVideo` 属性
- [ ]`IsVideo` 属性
- [ ]`VideoModeHintText` 属性
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
### 执行提示
```
编辑 WallpaperSettingsPageViewModel.cs
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
2. 移除第 85-86 行的 _isVideo 字段和属性
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
```
---
## 任务 10: 简化壁纸媒体类型枚举
**优先级**: P1
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
### 输出
- 简化的 `WallpaperMediaType` 枚举
### 验收标准
- [ ] `WallpaperMediaType` 枚举仅包含 `None``Image`
### 执行提示
```
编辑 SettingsContracts.cs
修改第 11-16 行的枚举定义:
public enum WallpaperMediaType
{
None,
Image
}
```
---
## 任务 11: 简化壁纸媒体服务
**优先级**: P1
**依赖**: 任务 10
**预估工作量**: 10 分钟
### 描述
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
### 输出
- 移除 `VideoExtensions` 字段
- 简化 `DetectMediaType` 方法
### 验收标准
- [ ]`VideoExtensions` 字段定义
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
### 执行提示
```
编辑 SettingsDomainServices.cs
1. 移除第 150-153 行的 VideoExtensions 字段定义
2. 修改 DetectMediaType 方法,移除视频检测分支
```
---
## 任务 12: 清理本地化文件
**优先级**: P2
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
### 输入
- `LanMountainDesktop/Localization/zh-CN.json`
### 输出
- 移除视频相关本地化键
- 修改壁纸描述文本
### 验收标准
- [ ]`settings.wallpaper.type.video`
- [ ]`settings.wallpaper.video_applied`
- [ ]`settings.wallpaper.video_mode`
- [ ]`settings.wallpaper.video_restored`
- [ ]`settings.wallpaper.video_not_found`
- [ ]`settings.wallpaper.video_player_unavailable`
- [ ]`settings.wallpaper.video_play_failed_format`
- [ ] `settings.wallpaper.description` 文本已更新
### 执行提示
```
编辑 zh-CN.json
1. 移除以下键值对:
- "settings.wallpaper.type.video"
- "settings.wallpaper.video_applied"
- "settings.wallpaper.video_mode"
- "settings.wallpaper.video_restored"
- "settings.wallpaper.video_not_found"
- "settings.wallpaper.video_player_unavailable"
- "settings.wallpaper.video_play_failed_format"
2. 修改描述文本:
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
```
---
## 任务 13: 构建验证
**优先级**: P0
**依赖**: 任务 1-12 全部完成
**预估工作量**: 10 分钟
### 描述
验证项目在移除视频壁纸功能后能够正常构建。
### 输入
- 整个项目
### 输出
- 构建成功确认
### 验收标准
- [ ] `dotnet build` 执行成功,无编译错误
- [ ] 无 LibVLC 相关类型未定义错误
- [ ] 无未使用变量警告(或已处理)
### 执行提示
```
在项目根目录执行:
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
检查输出:
- 确认无编译错误
- 确认无 LibVLC 相关类型引用错误
```
---
## 任务 14: 功能验证
**优先级**: P0
**依赖**: 任务 13
**预估工作量**: 15 分钟
### 描述
验证应用在移除视频壁纸功能后核心功能正常工作。
### 输入
- 构建后的应用
### 输出
- 功能验证报告
### 验收标准
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"选项
- [ ] 壁纸导入功能正常工作
### 执行提示
```
运行应用:
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
手动验证:
1. 应用启动无崩溃
2. 打开设置 -> 壁纸页面
3. 确认类型选择器仅有"图片"和"纯色"
4. 测试选择图片壁纸
5. 测试选择纯色壁纸
```
---
## 任务依赖关系图
```
任务 1 (移除依赖)
├── 任务 2 (XAML控件)
├── 任务 3 (代码字段)
│ └── 任务 4 (OnClosed清理)
├── 任务 5 (Stub方法)
│ └── 任务 6 (状态处理逻辑)
└── 任务 7 (主题服务)
任务 8 (设置页面XAML)
└── 任务 9 (设置ViewModel)
任务 10 (枚举简化)
└── 任务 11 (媒体服务)
任务 12 (本地化) - 独立
任务 13 (构建验证) - 依赖所有任务
└── 任务 14 (功能验证)
```
---
## 执行顺序建议
按以下顺序执行可确保依赖关系正确:
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
3. **第三批** (可并行): 任务 4, 任务 6
4. **第四批**: 任务 13
5. **第五批**: 任务 14

View File

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

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,27 @@
using System;
using Avalonia;
namespace LanMountainDesktop.DesktopHost;
public static class DesktopBootstrap
{
public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup)
{
ArgumentNullException.ThrowIfNull(initializeDeviceId);
ArgumentNullException.ThrowIfNull(initializeCrashReporting);
ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
initializeDeviceId();
initializeCrashReporting();
initializeUserBehaviorAnalytics();
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

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

View File

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

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Version>3.0.0</Version>
</PropertyGroup>
<ItemGroup>
@@ -12,6 +12,7 @@
<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>
</Project>

View File

@@ -1,3 +1,5 @@
using LanMountainDesktop.Shared.Contracts;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentContext
@@ -11,6 +13,8 @@ public sealed class PluginDesktopComponentContext
string componentId,
string? placementId,
double cellSize,
double globalCornerRadiusScale,
AppearanceCornerRadiusTokens cornerRadiusTokens,
IPluginSettingsService? pluginSettings = null)
{
ArgumentNullException.ThrowIfNull(manifest);
@@ -19,6 +23,7 @@ public sealed class PluginDesktopComponentContext
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(cornerRadiusTokens);
Manifest = manifest;
PluginDirectory = pluginDirectory;
@@ -28,6 +33,8 @@ public sealed class PluginDesktopComponentContext
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
CellSize = Math.Max(1, cellSize);
GlobalCornerRadiusScale = Math.Max(0.1d, globalCornerRadiusScale);
CornerRadiusTokens = cornerRadiusTokens;
PluginSettings = pluginSettings;
}
@@ -47,8 +54,22 @@ public sealed class PluginDesktopComponentContext
public double CellSize { get; }
public double GlobalCornerRadiusScale { get; }
public AppearanceCornerRadiusTokens CornerRadiusTokens { get; }
public IPluginSettingsService? PluginSettings { get; }
public double ResolveScaledCornerRadius(double baseRadius, double? minimum = null, double? maximum = null)
{
var scaled = Math.Max(0d, baseRadius) * GlobalCornerRadiusScale;
var scaledMin = minimum.HasValue ? minimum.Value * GlobalCornerRadiusScale : scaled;
var scaledMax = maximum.HasValue ? maximum.Value * GlobalCornerRadiusScale : scaled;
return minimum.HasValue || maximum.HasValue
? Math.Clamp(scaled, scaledMin, scaledMax)
: scaled;
}
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));

View File

@@ -0,0 +1,20 @@
namespace LanMountainDesktop.Settings.Core;
public static class GlobalAppearanceSettings
{
public const double DefaultCornerRadiusScale = 1.0;
public const double MinimumCornerRadiusScale = 0.70;
public const double MaximumCornerRadiusScale = 1.40;
public const double CornerRadiusScaleStep = 0.05;
public static double NormalizeCornerRadiusScale(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return DefaultCornerRadiusScale;
}
var clamped = Math.Clamp(value, MinimumCornerRadiusScale, MaximumCornerRadiusScale);
return Math.Round(clamped / CornerRadiusScaleStep, MidpointRounding.AwayFromZero) * CornerRadiusScaleStep;
}
}

View File

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

View File

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

View File

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

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,5 +1,11 @@
<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.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />

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;
@@ -61,11 +62,22 @@ public partial class App : Application
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;
@@ -106,28 +118,32 @@ public partial class App : Application
AppLogger.Info("App", "Framework initialization completed.");
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
InitializeTrayIcon();
DesktopBootstrap.InitializeApplication(this, InitializeDesktopShell);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) =>
base.OnFrameworkInitializationCompleted();
}
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)
@@ -483,6 +499,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) &&
@@ -500,6 +517,8 @@ public partial class App : Application
if (languageChanged)
{
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{
@@ -569,6 +588,18 @@ public partial class App : Application
_exitCleanupCompleted = true;
_settingsFacade.Settings.Changed -= OnSettingsChanged;
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
try
{
var (analytics, crashReport) = App.AnalyticsServices;
analytics?.SendShutdownEvent();
crashReport?.SendShutdownEvent();
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
}
try
{
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();

View File

@@ -0,0 +1,326 @@
# LanMountainDesktop 隐私政策
**最后更新日期2026年3月17日**
---
## 引言
欢迎使用 LanMountainDesktop我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
---
## 1. 数据收集范围
### 1.1 我们收集的数据
当您启用匿名数据收集功能时,我们会收集以下数据:
#### 匿名崩溃数据
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
- **设备信息**操作系统版本、设备型号、架构x64/x86
- **应用版本**:当前使用的应用版本号
- **设备标识符**匿名生成的唯一设备ID不包含个人信息
#### 匿名使用数据
- **应用启动和关闭事件**:记录应用何时启动和关闭
- **功能使用统计**:哪些功能被使用、使用频率
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
- **界面交互**:点击了哪些按钮、访问了哪些页面
- **设备信息**:操作系统、应用版本、设备类型
### 1.2 始终收集的基础数据
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
-**应用启动事件**:用于统计日活跃用户
-**设备标识符**:用于区分不同用户(不包含个人信息)
-**应用版本**:用于统计版本分布
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
### 1.3 我们不收集的数据
我们**明确承诺不收集**以下数据:
- ❌ 个人身份信息(姓名、邮箱、电话等)
- ❌ 真实姓名或用户名
- ❌ 地理位置信息(精确位置)
- ❌ 文件内容或文档数据
- ❌ 密码或凭据信息
- ❌ 网络浏览历史
- ❌ 联系人信息
- ❌ 照片、视频或音频文件
---
## 2. 数据收集目的
我们收集数据的目的如下:
### 2.1 基础数据用途(始终收集)
- **统计用户数量**:了解应用的用户规模
- **统计日活跃用户**:了解应用的活跃程度
- **版本分布统计**:了解用户使用的版本情况
### 2.2 崩溃数据用途
- **提高应用稳定性**:识别和修复崩溃问题
- **优化性能**:分析性能瓶颈
- **改进用户体验**:了解应用在不同设备上的表现
### 2.3 使用数据用途
- **功能优化**:了解哪些功能最受欢迎,优先改进
- **用户体验改进**:优化界面设计和交互流程
- **统计分析**:了解用户规模和使用趋势
- **产品决策**:基于数据做出产品发展方向决策
---
## 3. 数据存储和处理
### 3.1 数据存储位置
我们使用以下第三方服务存储和处理数据:
#### Sentry崩溃报告
- **用途**:崩溃数据收集和分析
- **位置**:美国
- **官网**https://sentry.io
- **隐私政策**https://sentry.io/privacy/
#### PostHog使用分析
- **用途**:用户行为分析和统计
- **位置**:美国
- **官网**https://posthog.com
- **隐私政策**https://posthog.com/privacy
### 3.2 数据保留期限
- **崩溃数据**保留90天后自动删除
- **使用数据**保留12个月后自动删除
- **设备标识符**:永久保留(用于统计日活用户)
### 3.3 数据安全措施
我们采取以下安全措施保护您的数据:
- ✅ 数据传输使用HTTPS加密
- ✅ 数据存储使用加密技术
- ✅ 访问权限严格控制
- ✅ 定期安全审计
---
## 4. 数据共享
### 4.1 我们不会出售您的数据
我们**明确承诺**
- ❌ 不会出售您的个人数据
- ❌ 不会将您的数据用于广告目的
- ❌ 不会与第三方共享可识别个人的数据
### 4.2 必要的共享
我们仅在以下情况下共享数据:
- **服务提供商**与Sentry和PostHog共享数据以提供服务
- **法律要求**:在法律要求或政府机构合法要求时
---
## 5. 您的权利
### 5.1 选择权
您完全控制详细数据收集:
- **匿名崩溃数据**:可在设置中开启或关闭
- **匿名使用数据**:可在设置中开启或关闭
- **基础数据**:始终收集(用于统计用户数量)
**注意:** 即使关闭所有开关,我们仍会收集基础数据(应用启动事件和设备标识符)以统计用户数量。
### 5.2 数据访问权
您可以:
- 查看我们收集的数据类型
- 了解数据的使用目的
- 了解数据的存储位置
### 5.3 数据删除权
您可以:
- 随时关闭详细数据收集功能
- 删除本地存储的设备标识符
- 联系我们删除已收集的数据
---
## 6. 设备标识符
### 6.1 什么是设备标识符?
设备标识符是一个随机生成的唯一字符串,用于:
- 统计日活用户数量
- 区分不同设备
- 分析用户使用趋势
### 6.2 设备标识符的特点
- **匿名性**:不包含任何个人信息
- **随机性**通过SHA256算法生成
- **唯一性**:每个设备有唯一标识符
- **持久性**即使刷新设备ID仍能关联到同一用户
- **可重置**:您可以在设置中刷新设备标识符
### 6.3 设备标识符刷新
当您刷新设备标识符时:
- 生成新的设备ID
- **持久用户ID保持不变**,确保仍能关联到同一用户
- 统计数据不会丢失
### 6.4 设备标识符示例
```
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
```
---
## 7. 儿童隐私保护
本应用不面向13岁以下儿童。我们不会故意收集儿童的个人信息。如果您发现我们无意中收集了儿童的数据请联系我们我们将立即删除相关数据。
---
## 8. 国际数据传输
由于我们的服务提供商位于美国,您的数据可能会被传输到美国。我们确保:
- 数据传输符合相关法律法规
- 服务提供商遵守GDPR等隐私法规
- 采取适当的安全措施保护数据
---
## 9. 隐私政策更新
我们可能会不时更新本隐私政策。更新时,我们将:
- 在本页面更新"最后更新日期"
- 在应用内通知您重大变更
- 继续使用应用即表示您同意更新后的政策
---
## 10. 联系我们
如果您对本隐私政策有任何疑问或建议,请通过以下方式联系我们:
- **GitHub Issues**https://github.com/wwiinnddyy/LanMountainDesktop/issues
- **电子邮件**[您的邮箱地址]
---
## 11. 法律依据
### 11.1 GDPR合规
如果您位于欧洲经济区EEA我们的数据处理基于
- **同意**:您明确同意数据收集
- **合法利益**:改进应用性能和用户体验
### 11.2 CCPA合规
如果您是加州居民,您有权:
- 知道我们收集了哪些数据
- 要求删除您的数据
- 选择退出数据销售(我们不销售数据)
---
## 12. 第三方链接
本应用可能包含第三方网站链接。我们不对这些网站的隐私政策负责。请阅读这些网站的隐私政策。
---
## 13. 数据收集示例
### 13.1 崩溃报告示例
```json
{
"event_id": "abc123",
"timestamp": "2024-01-01T12:00:00Z",
"device_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"app_version": "1.0.0",
"os_name": "Windows",
"os_version": "10.0.19041",
"error_message": "NullReferenceException",
"stack_trace": "..."
}
```
### 13.2 使用数据示例
```json
{
"event": "app_online",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"app_version": "1.0.0",
"os_name": "Windows",
"event_type": "app_start",
"analytics_enabled": true
}
}
```
### 13.3 基础数据示例(始终收集)
```json
{
"event": "$pageview",
"timestamp": "2024-01-01T12:00:00Z",
"distinct_id": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"properties": {
"$current_url": "app://main",
"$title": "LanMountainDesktop"
}
}
```
---
## 14. 您的同意
使用本应用即表示您:
- ✅ 已阅读并理解本隐私政策
- ✅ 同意我们按照本政策收集和使用数据
- ✅ 了解您可以随时撤回同意(详细数据收集)
- ✅ 了解基础数据将始终收集以统计用户数量
---
## 15. 免责声明
本隐私政策仅适用于 LanMountainDesktop 应用。我们不对以下情况负责:
- 第三方服务的隐私政策
- 您自行分享的数据
- 不可抗力导致的数据泄露
---
**感谢您信任阑山桌面LanMountainDesktop**
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。

View File

@@ -1,4 +1,4 @@
namespace LanMountainDesktop.ComponentSystem;
namespace LanMountainDesktop.ComponentSystem;
public static class BuiltInComponentIds
{
@@ -40,4 +40,6 @@ 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";
}

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;
@@ -327,6 +327,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",

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

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
@@ -21,14 +21,22 @@
<ItemGroup>
<Folder Include="Models\" />
<AvaloniaResource Include="Assets\**" />
<AvaloniaResource Include="Localization\**" />
<EmbeddedResource Include="Assets\Documents\Privacy.md" />
<EmbeddedResource Include="Localization\*.json" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.Host.Abstractions\LanMountainDesktop.Host.Abstractions.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.DesktopHost\LanMountainDesktop.DesktopHost.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
ReferenceOutputAssembly="false" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
@@ -53,13 +61,18 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="MudTools.OfficeInterop" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Word" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.Excel" Version="2.0.8" />
<PackageReference Include="MudTools.OfficeInterop.PowerPoint" Version="2.0.8" />
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
<PackageReference Include="PostHog" Version="2.4.0" />
<PackageReference Include="Sentry" Version="4.0.0" />
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; or '$(RuntimeIdentifier)' == 'win-x86'" />
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))&#xA; or '$(RuntimeIdentifier)' == 'osx-x64'" />
<PackageReference Include="WebView.Avalonia" Version="11.0.0.1" />
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
@@ -69,17 +82,13 @@
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
<ItemGroup>
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true" />
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
"button.back_to_platform": "Back to {0}",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.back_to_platform": "Back to {0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.shell.title": "Settings",
@@ -20,7 +25,7 @@
"settings.nav.group_system": "System",
"settings.nav.group_extensions": "Extensions",
"settings.nav.wallpaper": "Wallpaper",
"settings.nav.grid": "Grid",
"settings.nav.grid": "Components",
"settings.nav.color": "Color",
"settings.nav.status_bar": "Status Bar",
"settings.nav.weather": "Weather",
@@ -86,6 +91,8 @@
"settings.status_bar.description": "Choose which components appear on the top status bar.",
"settings.status_bar.clock_header": "Clock Component",
"settings.status_bar.clock_description": "Display a clock on the top status bar.",
"settings.status_bar.clock_transparent_background_label": "Transparent background",
"settings.status_bar.clock_transparent_background_desc": "Remove the capsule background and keep only the clock text.",
"settings.status_bar.spacing_header": "Component Spacing",
"settings.status_bar.spacing_desc": "Adjust spacing between status bar components.",
"settings.status_bar.spacing_mode_compact": "Compact",
@@ -99,6 +106,11 @@
"settings.privacy.crash_upload_description": "Help us improve application stability.",
"settings.privacy.usage_upload_title": "Anonymous usage data uploads",
"settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.privacy.device_id_title": "Device ID",
"settings.privacy.device_id_description": "Unique identifier for this device. Click refresh to regenerate.",
"settings.privacy.refresh_device_id": "Refresh",
"settings.privacy.policy_hint_prefix": "For more details, please ",
"settings.privacy.view_policy": "view our privacy policy",
"settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
@@ -255,7 +267,6 @@
"settings.color.use_system_chrome_toggle": "Use system window chrome",
"settings.color.theme_color_label": "Theme accent color",
"settings.appearance.theme_color_mode_label": "Theme color source",
"settings.appearance.system_material_label": "System material",
"settings.appearance.theme_color_mode.neutral": "Default neutral",
"settings.appearance.theme_color_mode.user": "User theme color Monet",
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
@@ -265,6 +276,8 @@
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
"component.color_scheme.follow_system": "Follow system color scheme",
"component.color_scheme.native": "Use component custom color scheme",
"settings.appearance.system_material.none": "None",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
@@ -290,8 +303,17 @@
"settings.status_bar.clock_format.hm": "Hour:Minute",
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
"settings.components.title": "Components",
"settings.components.description": "Adjust desktop grid density and widget placement.",
"settings.components.grid_header": "Grid Layout",
"settings.components.description": "Adjust component layout and corner design.",
"settings.components.grid_header": "Grid Settings",
"settings.components.header": "Grid Settings",
"settings.components.short_side_label": "Short Side Cells",
"settings.components.edge_inset_label": "Screen Inset",
"settings.components.spacing_label": "Component Spacing",
"settings.components.spacing_compact": "Compact",
"settings.components.spacing_relaxed": "Relaxed",
"settings.components.corner_radius.header": "Corner Design",
"settings.components.corner_radius.label": "Component Corner Radius",
"settings.components.corner_radius.description": "Adjust the shared corner radius used by component containers, and expand the internal safe area with it.",
"settings.update.title": "Update",
"settings.update.current_version_label": "Current Version",
"settings.update.latest_version_label": "Latest Release",
@@ -397,6 +419,7 @@
"common.monet": "Monet",
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.folder": "Folder",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
@@ -553,6 +576,7 @@
"component_category.info": "Info",
"component_category.calculator": "Calculator",
"component_category.study": "Study",
"component_category.file": "File",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
"component.lunar_calendar": "Lunar Calendar",
@@ -580,6 +604,20 @@
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
"component.office_recent_documents": "Recent Documents",
"whiteboard.settings.desc": "Each blackboard keeps its own note history and saves it independently.",
"whiteboard.settings.retention.title": "Note retention",
"whiteboard.settings.retention.desc": "Choose how long this blackboard should keep saved notes before expired data is removed automatically.",
"whiteboard.settings.retention.option": "{0} days",
"whiteboard.settings.instance_scope": "This retention setting is stored per blackboard component instance.",
"office_recent_documents.settings.desc": "Choose which Windows and Office sources this widget should scan for recent documents.",
"office_recent_documents.settings.sources_title": "Recent document sources",
"office_recent_documents.settings.sources_desc": "You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.",
"office_recent_documents.settings.source.registry": "Office registry MRU",
"office_recent_documents.settings.source.recent_folders": "Windows Recent folders",
"office_recent_documents.settings.source.jump_lists": "Windows Jump Lists",
"office_recent_documents.settings.hint": "If you disable all sources, this widget will stay empty until at least one source is enabled again.",
"component.removable_storage": "Removable Storage",
"component.holiday_calendar": "Holiday Calendar",
"component.study_environment": "Environment",
"component.study_session_control": "Study Session Control",
@@ -781,6 +819,20 @@
"study.environment.settings.show_display_db": "Show display dB",
"study.environment.settings.show_dbfs": "Show dBFS",
"study.environment.settings.hint": "At least one display mode must stay enabled.",
"removable_storage.settings.desc": "Show a connected USB drive with quick open and eject actions.",
"removable_storage.settings.behavior_title": "Behavior",
"removable_storage.settings.behavior_desc": "The widget automatically watches for removable drives and switches to the newest inserted USB drive.",
"removable_storage.action.open": "Open",
"removable_storage.action.eject": "Eject",
"removable_storage.widget.default_name": "Removable Drive",
"removable_storage.widget.empty_title": "No device inserted",
"removable_storage.widget.empty_subtitle": "Insert a USB drive to show it here.",
"removable_storage.widget.empty_hint": "Buttons stay disabled until a removable device is inserted.",
"removable_storage.widget.ready": "Ready to open or eject.",
"removable_storage.widget.ejecting": "Ejecting drive...",
"removable_storage.widget.eject_failed": "Could not eject this drive. Close any files on it and try again.",
"removable_storage.widget.open_failed": "Failed to open this drive.",
"removable_storage.widget.refresh_failed": "Drive list refresh failed.",
"study.session_control.action.start": "Start Study Session",
"study.session_control.action.stop": "Stop Study Session",
"study.session_control.idle_hint": "Tap the right button to start",
@@ -885,5 +937,7 @@
"placement.tile": "Tile",
"single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK"
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
}

View File

@@ -7,7 +7,12 @@
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"button.back_to_platform": "回到{0}",
"tooltip.back_to_windows": "回到Windows",
"tooltip.back_to_platform": "回到{0}",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "设置",
"settings.title": "设置",
"settings.shell.title": "设置",
@@ -20,7 +25,7 @@
"settings.nav.group_system": "系统",
"settings.nav.group_extensions": "扩展",
"settings.nav.wallpaper": "壁纸",
"settings.nav.grid": "网格",
"settings.nav.grid": "组件",
"settings.nav.color": "颜色",
"settings.nav.status_bar": "状态栏",
"settings.nav.weather": "天气",
@@ -31,13 +36,14 @@
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.type_label": "壁纸类型",
"settings.wallpaper.type.image": "图片",
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.type.solid_color": "纯色",
"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
"settings.wallpaper.custom_color_apply": "应用",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
"settings.wallpaper.pick_button": "选择文件",
@@ -46,20 +52,14 @@
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
"settings.wallpaper.import_failed": "导入壁纸文件失败。",
"settings.wallpaper.image_applied": "图片壁纸已应用。",
"settings.wallpaper.video_applied": "视频壁纸已应用。",
"settings.wallpaper.unsupported_file": "所选文件类型不受支持。",
"settings.wallpaper.apply_failed_format": "应用壁纸失败:{0}",
"settings.wallpaper.mode_format": "壁纸模式:{0}。",
"settings.wallpaper.video_mode": "视频壁纸使用自动填充模式。",
"settings.wallpaper.cleared": "背景已恢复为纯色。",
"settings.wallpaper.default_status": "当前使用纯色背景。",
"settings.wallpaper.saved_not_found": "未找到已保存的壁纸文件,已使用纯色背景。",
"settings.wallpaper.restored": "已恢复保存的壁纸。",
"settings.wallpaper.video_restored": "已恢复保存的视频壁纸。",
"settings.wallpaper.restore_failed": "恢复已保存壁纸失败,已使用纯色背景。",
"settings.wallpaper.video_not_found": "未找到视频壁纸文件。",
"settings.wallpaper.video_player_unavailable": "视频播放器不可用。",
"settings.wallpaper.video_play_failed_format": "播放视频壁纸失败:{0}",
"settings.grid.title": "网格布局",
"settings.grid.description": "每个组件至少占用一个格子(最小 1x1。",
"settings.grid.short_side_label": "短边格数",
@@ -85,12 +85,13 @@
"settings.color.theme_ready_format": "主题色已就绪:{0}。",
"settings.color.theme_applied_format": "{0}主题色已应用:{1}。",
"settings.color.theme_updated_wallpaper": "壁纸已更新,莫奈色已刷新。",
"settings.color.theme_updated_video": "视频壁纸已更新,主题色已刷新。",
"settings.color.theme_cleared_wallpaper": "壁纸已清除,莫奈色已刷新。",
"settings.status_bar.title": "状态栏",
"settings.status_bar.description": "选择顶部状态栏显示的组件。",
"settings.status_bar.clock_header": "时间组件",
"settings.status_bar.clock_description": "在顶部状态栏显示时钟。",
"settings.status_bar.clock_transparent_background_label": "透明背景",
"settings.status_bar.clock_transparent_background_desc": "移除胶囊背景,仅保留时钟文字。",
"settings.status_bar.spacing_header": "组件间距",
"settings.status_bar.spacing_desc": "调整状态栏组件之间的间距。",
"settings.status_bar.spacing_mode_compact": "紧凑",
@@ -104,6 +105,11 @@
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
"settings.privacy.usage_upload_title": "匿名上传使用数据",
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.privacy.device_id_title": "设备标识符",
"settings.privacy.device_id_description": "此设备的唯一标识符。点击刷新以重新生成。",
"settings.privacy.refresh_device_id": "刷新",
"settings.privacy.policy_hint_prefix": "了解更多详情,请",
"settings.privacy.view_policy": "查看我们的隐私政策",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
@@ -260,7 +266,6 @@
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
"settings.color.theme_color_label": "主题强调色",
"settings.appearance.theme_color_mode_label": "主题色来源",
"settings.appearance.system_material_label": "系统材质",
"settings.appearance.theme_color_mode.neutral": "默认中性",
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
@@ -270,6 +275,8 @@
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
"component.color_scheme.follow_system": "跟随系统配色",
"component.color_scheme.native": "使用组件自定义配色",
"settings.appearance.system_material.none": "无",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
@@ -294,9 +301,18 @@
"settings.status_bar.clock_format_label": "时钟格式",
"settings.status_bar.clock_format.hm": "时:分",
"settings.status_bar.clock_format.hms": "时:分:秒",
"settings.components.title": "网格",
"settings.components.description": "调整桌面网格与布局。",
"settings.components.grid_header": "网格布局",
"settings.components.title": "组件",
"settings.components.description": "调整组件布局与圆角设计。",
"settings.components.grid_header": "网格设置",
"settings.components.header": "网格设置",
"settings.components.short_side_label": "短边格数",
"settings.components.edge_inset_label": "屏幕边距",
"settings.components.spacing_label": "组件间距",
"settings.components.spacing_compact": "紧凑",
"settings.components.spacing_relaxed": "宽松",
"settings.components.corner_radius.header": "圆角设计",
"settings.components.corner_radius.label": "组件圆角",
"settings.components.corner_radius.description": "统一调整组件容器圆角,并随圆角增大同步扩展内部安全区。",
"settings.update.title": "更新",
"settings.update.current_version_label": "当前版本",
"settings.update.latest_version_label": "最新发布",
@@ -392,7 +408,6 @@
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
"filepicker.video_files": "视频文件",
"common.day": "日间",
"common.night": "夜间",
"common.back": "返回",
@@ -402,6 +417,7 @@
"common.monet": "莫奈",
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.folder": "文件夹",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
@@ -558,6 +574,7 @@
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component_category.file": "文件",
"component.date": "日历",
"component.month_calendar": "月历",
"component.lunar_calendar": "农历",
@@ -585,6 +602,19 @@
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
"component.office_recent_documents": "最近文档",
"whiteboard.settings.desc": "每个小黑板都会独立保存自己的笔记历史。",
"whiteboard.settings.retention.title": "笔记保留时间",
"whiteboard.settings.retention.desc": "选择这个小黑板在过期笔记被自动删除前,应当保留已保存笔记多久。",
"whiteboard.settings.retention.option": "{0} 天",
"whiteboard.settings.instance_scope": "这个保留时间设置会按每个小黑板组件实例单独存储。",
"office_recent_documents.settings.desc": "选择此小组件需要扫描的 Windows 和 Office 最近文档来源。",
"office_recent_documents.settings.sources_title": "最近文档来源",
"office_recent_documents.settings.sources_desc": "可以同时选择多个来源。勾选注册表来源时,还会保留 Office interop 的 MRU 回退。",
"office_recent_documents.settings.source.registry": "Office 注册表 MRU",
"office_recent_documents.settings.source.recent_folders": "Windows 最近文件夹",
"office_recent_documents.settings.source.jump_lists": "Windows 跳转列表",
"office_recent_documents.settings.hint": "如果关闭全部来源,此小组件会保持空白,直到再次至少启用一个来源。",
"component.holiday_calendar": "节假日日历",
"component.study_environment": "环境",
"component.study_session_control": "自习时段控制",
@@ -781,6 +811,21 @@
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"component.removable_storage": "可移动存储",
"removable_storage.settings.desc": "在桌面上显示已连接的 U 盘,并提供打开与弹出操作。",
"removable_storage.settings.behavior_title": "行为",
"removable_storage.settings.behavior_desc": "组件会自动监听可移动存储设备,并优先显示最新插入的 U 盘。",
"removable_storage.action.open": "打开",
"removable_storage.action.eject": "弹出",
"removable_storage.widget.default_name": "可移动磁盘",
"removable_storage.widget.empty_title": "未插入设备",
"removable_storage.widget.empty_subtitle": "插入 U 盘后会自动显示在这里。",
"removable_storage.widget.empty_hint": "在插入可移动设备之前,底部按钮会保持置灰不可点击。",
"removable_storage.widget.ready": "已准备好,可直接打开或弹出。",
"removable_storage.widget.ejecting": "正在弹出设备...",
"removable_storage.widget.eject_failed": "无法弹出该设备,请先关闭正在占用它的文件后再试。",
"removable_storage.widget.open_failed": "打开该设备失败。",
"removable_storage.widget.refresh_failed": "刷新可移动存储列表失败。",
"study.environment.settings.title": "环境组件设置",
"study.environment.settings.desc": "配置右侧实时噪音值显示内容。",
"study.environment.settings.show_display_db": "显示 display dB",
@@ -890,5 +935,7 @@
"placement.tile": "平铺",
"single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定"
"single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启"
}

View File

@@ -1,4 +1,5 @@
using System.Collections.Generic;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Models;
@@ -16,6 +17,8 @@ public sealed class AppSettingsSnapshot
public bool UseSystemChrome { get; set; }
public double GlobalCornerRadiusScale { get; set; } = GlobalAppearanceSettings.DefaultCornerRadiusScale;
public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none";
@@ -62,14 +65,16 @@ public sealed class AppSettingsSnapshot
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
public bool UploadAnonymousCrashData { get; set; }
public bool UploadAnonymousUsageData { get; set; }
public string? DeviceId { get; set; }
public string? PersistentUserId { get; set; }
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
@@ -99,6 +104,8 @@ public sealed class AppSettingsSnapshot
public string ClockDisplayFormat { get; set; } = "HourMinuteSecond";
public bool StatusBarClockTransparentBackground { get; set; }
public string StatusBarSpacingMode { get; set; } = "Relaxed";
public int StatusBarCustomSpacingPercent { get; set; } = 12;

View File

@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public string? ColorSchemeSource { get; set; }
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
@@ -56,12 +58,16 @@ public sealed class ComponentSettingsSnapshot
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public int WhiteboardNoteRetentionDays { get; set; } = 15;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -89,6 +95,9 @@ public sealed class ComponentSettingsSnapshot
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
: [];
clone.OfficeRecentDocumentsEnabledSources = OfficeRecentDocumentsEnabledSources is not null
? new List<string>(OfficeRecentDocumentsEnabledSources)
: null;
return clone;
}

View File

@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Models;
public static class OfficeRecentDocumentSourceTypes
{
public const string Registry = "registry";
public const string RecentFolders = "recent_folders";
public const string JumpLists = "jump_lists";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Registry,
RecentFolders,
JumpLists
];
public static IReadOnlyList<string> DefaultValues => SupportedValues;
public static IReadOnlyList<string> NormalizeValues(IEnumerable<string>? values, bool useDefaultWhenEmpty)
{
if (values is null)
{
return useDefaultWhenEmpty ? DefaultValues : Array.Empty<string>();
}
var normalized = values
.Select(NormalizeValue)
.OfType<string>()
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
if (normalized.Length == 0 && useDefaultWhenEmpty)
{
return DefaultValues;
}
return normalized;
}
private static string? NormalizeValue(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
Registry => Registry,
RecentFolders => RecentFolders,
JumpLists => JumpLists,
_ => null
};
}
}

View File

@@ -0,0 +1,23 @@
namespace LanMountainDesktop.Models;
public static class WhiteboardNoteRetentionPolicy
{
public const int MinimumDays = 7;
public const int MaximumDays = 15;
public const int DefaultDays = MaximumDays;
public static int NormalizeDays(int days)
{
if (days < MinimumDays)
{
return MinimumDays;
}
if (days > MaximumDays)
{
return MaximumDays;
}
return days;
}
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class WhiteboardNoteSnapshot
{
public int Version { get; set; } = 1;
public DateTimeOffset SavedUtc { get; set; }
public List<WhiteboardStrokeSnapshot> Strokes { get; set; } = [];
public WhiteboardNoteSnapshot Clone()
{
var clone = (WhiteboardNoteSnapshot)MemberwiseClone();
clone.Strokes = Strokes is { Count: > 0 }
? new List<WhiteboardStrokeSnapshot>(Strokes.ConvertAll(stroke => stroke?.Clone() ?? new WhiteboardStrokeSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStrokeSnapshot
{
public string Color { get; set; } = "#FF000000";
public double InkThickness { get; set; } = 2.5d;
public bool IgnorePressure { get; set; } = true;
public List<WhiteboardStylusPointSnapshot> Points { get; set; } = [];
public WhiteboardStrokeSnapshot Clone()
{
var clone = (WhiteboardStrokeSnapshot)MemberwiseClone();
clone.Points = Points is { Count: > 0 }
? new List<WhiteboardStylusPointSnapshot>(Points.ConvertAll(point => point?.Clone() ?? new WhiteboardStylusPointSnapshot()))
: [];
return clone;
}
}
public sealed class WhiteboardStylusPointSnapshot
{
public double X { get; set; }
public double Y { get; set; }
public double Pressure { get; set; } = 0.5d;
public double Width { get; set; }
public double Height { get; set; }
public WhiteboardStylusPointSnapshot Clone()
{
return (WhiteboardStylusPointSnapshot)MemberwiseClone();
}
}

View File

@@ -4,9 +4,11 @@ using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using Sentry;
namespace LanMountainDesktop;
@@ -14,14 +16,16 @@ sealed class Program
{
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args)
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
DesktopBootstrap.InitializeStartupServices(
InitializeDeviceId,
InitializeCrashReporting,
InitializeUserBehaviorAnalytics,
ScheduleWhiteboardNoteStartupCleanup);
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
@@ -49,6 +53,7 @@ sealed class Program
StartupRenderMode = renderMode;
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -63,7 +68,6 @@ sealed class Program
}
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
{
var builder = AppBuilder.Configure<App>()
@@ -87,6 +91,25 @@ sealed class Program
return builder;
}
private static void ScheduleWhiteboardNoteStartupCleanup()
{
_ = Task.Run(() =>
{
try
{
var deletedCount = new WhiteboardNotePersistenceService().DeleteExpiredNotesBatch(batchSize: 512);
if (deletedCount > 0)
{
AppLogger.Info("Startup", $"Deleted {deletedCount} expired whiteboard notes during startup maintenance.");
}
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to run whiteboard note startup maintenance.", ex);
}
});
}
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
{
var singleInstance = SingleInstanceService.CreateDefault();
@@ -151,7 +174,6 @@ sealed class Program
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
@@ -167,6 +189,11 @@ sealed class Program
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
if (eventArgs.IsTerminating)
{
SentrySdk.Flush(TimeSpan.FromSeconds(5));
}
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
@@ -175,4 +202,187 @@ sealed class Program
eventArgs.SetObserved();
};
}
private static void InitializeDeviceId()
{
try
{
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
}
}
private static void InitializeSentryForAnalytics()
{
try
{
var deviceId = DeviceIdService.Instance.DeviceId;
SentrySdk.Init(options =>
{
options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
options.AutoSessionTracking = true;
options.Release = GetAppVersion();
options.Environment = GetEnvironment();
});
SentrySdk.ConfigureScope(scope =>
{
scope.User = new SentryUser
{
Id = deviceId
};
scope.SetTag("data_type", "analytics");
scope.SetTag("device_id", deviceId);
scope.SetTag("app_version", GetAppVersion());
scope.SetTag("os_name", GetOsName());
scope.SetTag("os_version", GetOsVersion());
scope.SetTag("os_build", GetOsBuild());
scope.SetTag("device_model", GetDeviceModel());
scope.SetTag("device_arch", GetDeviceArchitecture());
scope.SetTag("processor_count", GetProcessorCount().ToString());
scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
scope.SetTag("runtime_version", GetRuntimeVersion());
scope.SetTag("language", GetSystemLanguage());
scope.SetTag("clr_version", GetClrVersion());
scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
});
SentrySdk.CaptureMessage("user_active");
AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
}
}
private static string GetAppVersion()
{
var version = typeof(Program).Assembly.GetName().Version;
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
}
private static string GetOsName()
{
if (OperatingSystem.IsWindows()) return "Windows";
if (OperatingSystem.IsLinux()) return "Linux";
if (OperatingSystem.IsMacOS()) return "macOS";
return "Unknown";
}
private static string GetOsVersion()
{
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetOsBuild()
{
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetDeviceName()
{
try { return Environment.MachineName ?? "Unknown"; }
catch { return "Unknown"; }
}
private static string GetDeviceModel()
{
if (OperatingSystem.IsWindows()) return "Windows PC";
if (OperatingSystem.IsLinux()) return "Linux PC";
if (OperatingSystem.IsMacOS()) return "Mac";
return "Unknown";
}
private static string GetDeviceArchitecture()
{
return Environment.Is64BitOperatingSystem ? "x64" : "x86";
}
private static int GetProcessorCount()
{
return Environment.ProcessorCount;
}
private static long GetTotalMemoryMB()
{
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
catch { return 0; }
}
private static string GetRuntimeVersion()
{
return Environment.Version.ToString();
}
private static string GetSystemLanguage()
{
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
catch { return "en-US"; }
}
private static string GetClrVersion()
{
return Environment.Version.ToString();
}
private static CrashReportService? _crashReportService;
private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
private static void InitializeCrashReporting()
{
try
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
_crashReportService.RefreshEnabledState();
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
}
}
private static void InitializeUserBehaviorAnalytics()
{
try
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
_userBehaviorAnalyticsService.Initialize();
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
}
}
private static string GetReleaseVersion()
{
var assembly = typeof(Program).Assembly;
var version = assembly.GetName().Version;
if (version is null)
{
return "1.0.0";
}
return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
}
private static string GetEnvironment()
{
#if DEBUG
return "development";
#else
return "production";
#endif
}
}

View File

@@ -29,6 +29,16 @@ public sealed class AppDatabaseService
_databasePath = Path.Combine(dataDirectory, "app.db");
}
public AppDatabaseService(string databasePath)
{
if (string.IsNullOrWhiteSpace(databasePath))
{
throw new ArgumentException("Database path cannot be null or whitespace.", nameof(databasePath));
}
_databasePath = databasePath;
}
public SqliteConnection OpenConnection()
{
var directory = Path.GetDirectoryName(_databasePath);

View File

@@ -11,11 +11,13 @@ using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.Media.Imaging;
using LanMountainDesktop.Appearance;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Shared.Contracts;
using LanMountainDesktop.Theme;
using LibVLCSharp.Shared;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
@@ -42,6 +44,8 @@ public sealed record AppearanceThemeSnapshot(
string ThemeColorMode,
string? UserThemeColor,
string? SelectedWallpaperSeed,
double GlobalCornerRadiusScale,
AppearanceCornerRadiusTokens CornerRadiusTokens,
string ResolvedSeedSource,
MonetPalette MonetPalette,
Color AccentColor,
@@ -89,11 +93,6 @@ internal interface IMaterialSurfaceService
AppearanceMaterialSurface GetSurface(ThemeColorContext context, MaterialSurfaceRole role);
}
internal interface IVideoWallpaperSeedExtractor
{
IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService);
}
internal readonly record struct WallpaperSeedSourceDescriptor(
string SourceKind,
string SourceKey,
@@ -114,75 +113,6 @@ internal readonly record struct WallpaperPaletteResolution(
Color EffectiveSeedColor,
string? ResolvedWallpaperPath);
internal sealed class LibVlcVideoWallpaperSeedExtractor : IVideoWallpaperSeedExtractor
{
public IReadOnlyList<Color> ExtractSeedCandidates(string videoPath, MonetColorService monetColorService)
{
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
{
return [];
}
var snapshotPath = Path.Combine(
Path.GetTempPath(),
$"lanmountaindesktop-video-seed-{Guid.NewGuid():N}.png");
try
{
using var libVlc = new LibVLC("--no-audio", "--intf=dummy", "--no-video-title-show");
using var media = new Media(libVlc, new Uri(videoPath));
using var mediaPlayer = new MediaPlayer(libVlc)
{
Media = media
};
mediaPlayer.Play();
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < TimeSpan.FromSeconds(5))
{
Thread.Sleep(180);
if (!mediaPlayer.TakeSnapshot(0, snapshotPath, 320, 180))
{
continue;
}
var fileInfo = new FileInfo(snapshotPath);
if (!fileInfo.Exists || fileInfo.Length <= 0)
{
continue;
}
using var bitmap = new Bitmap(snapshotPath);
return monetColorService.ExtractSeedCandidates(bitmap);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"Appearance.VideoWallpaperPalette",
$"Failed to extract wallpaper seed candidates from video '{videoPath}'.",
ex);
}
finally
{
try
{
if (File.Exists(snapshotPath))
{
File.Delete(snapshotPath);
}
}
catch
{
// Best effort cleanup only.
}
}
return [];
}
}
internal sealed class SystemWallpaperService : ISystemWallpaperService
{
public bool IsSupported => OperatingSystem.IsWindows();
@@ -248,6 +178,15 @@ internal sealed class WindowMaterialService : IWindowMaterialService
{
ArgumentNullException.ThrowIfNull(window);
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
if (normalizedMode == ThemeAppearanceValues.MaterialNone)
{
window.Background = Brushes.White;
window.TransparencyLevelHint = [WindowTransparencyLevel.None];
return;
}
window.Background = Brushes.Transparent;
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
@@ -259,7 +198,6 @@ internal sealed class WindowMaterialService : IWindowMaterialService
return;
}
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
window.TransparencyLevelHint = normalizedMode switch
{
ThemeAppearanceValues.MaterialMica =>
@@ -469,7 +407,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
private readonly ISystemWallpaperService _systemWallpaperService;
private readonly IWindowMaterialService _windowMaterialService;
private readonly IMaterialSurfaceService _materialSurfaceService;
private readonly IVideoWallpaperSeedExtractor _videoWallpaperSeedExtractor;
private readonly MonetColorService _monetColorService = new();
private readonly string _liveThemeColorMode;
private readonly string _liveSystemMaterialMode;
@@ -482,14 +419,12 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
ISettingsFacadeService settingsFacade,
ISystemWallpaperService systemWallpaperService,
IWindowMaterialService windowMaterialService,
IMaterialSurfaceService materialSurfaceService,
IVideoWallpaperSeedExtractor? videoWallpaperSeedExtractor = null)
IMaterialSurfaceService materialSurfaceService)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_systemWallpaperService = systemWallpaperService ?? throw new ArgumentNullException(nameof(systemWallpaperService));
_windowMaterialService = windowMaterialService ?? throw new ArgumentNullException(nameof(windowMaterialService));
_materialSurfaceService = materialSurfaceService ?? throw new ArgumentNullException(nameof(materialSurfaceService));
_videoWallpaperSeedExtractor = videoWallpaperSeedExtractor ?? new LibVlcVideoWallpaperSeedExtractor();
var initialThemeState = _settingsFacade.Theme.Get();
_liveThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(
initialThemeState.ThemeColorMode,
@@ -534,6 +469,13 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
var context = CreateThemeContext(snapshot);
ThemeColorSystemService.ApplyThemeResources(resources, context);
GlassEffectService.ApplyGlassResources(resources, context);
resources["DesignCornerRadiusMicro"] = snapshot.CornerRadiusTokens.Micro;
resources["DesignCornerRadiusXs"] = snapshot.CornerRadiusTokens.Xs;
resources["DesignCornerRadiusSm"] = snapshot.CornerRadiusTokens.Sm;
resources["DesignCornerRadiusMd"] = snapshot.CornerRadiusTokens.Md;
resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
}
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
@@ -608,6 +550,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
if (!refreshAll &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale), StringComparer.OrdinalIgnoreCase) &&
!(respondsToThemeColor &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
!(respondsToWallpaper &&
@@ -629,6 +572,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
bool queueWallpaperPaletteBuild)
{
var availableModes = _windowMaterialService.GetAvailableModes();
var globalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(themeState.GlobalCornerRadiusScale);
var cornerRadiusTokens = AppearanceCornerRadiusTokenFactory.Create(globalCornerRadiusScale);
MonetPalette palette;
IReadOnlyList<Color> wallpaperSeedCandidates;
Color effectiveSeedColor;
@@ -668,6 +613,8 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
themeColorMode,
themeState.ThemeColor,
selectedWallpaperSeed,
globalCornerRadiusScale,
cornerRadiusTokens,
resolvedSeedSource,
palette,
ResolveAccentColor(themeColorMode, themeState.ThemeColor, palette),
@@ -878,7 +825,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
IReadOnlyList<Color> seedCandidates = source.SourceKind switch
{
"app_wallpaper" or "system_wallpaper" => ExtractImageSeedCandidates(source.FilePath),
"app_video" => ExtractVideoSeedCandidates(source.FilePath),
"app_solid" when source.SolidColor is { } solidColor => new[] { solidColor },
_ => []
};
@@ -912,16 +858,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
}
}
private IReadOnlyList<Color> ExtractVideoSeedCandidates(string? wallpaperPath)
{
if (string.IsNullOrWhiteSpace(wallpaperPath) || !File.Exists(wallpaperPath))
{
return [];
}
return _videoWallpaperSeedExtractor.ExtractSeedCandidates(wallpaperPath, _monetColorService);
}
private WallpaperSeedSourceDescriptor ResolveWallpaperSeedSource(WallpaperSettingsState wallpaperState)
{
if (string.Equals(wallpaperState.Type, "SolidColor", StringComparison.OrdinalIgnoreCase) &&
@@ -952,16 +888,6 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
wallpaperPath,
null);
}
if (appWallpaperMediaType == WallpaperMediaType.Video)
{
return new WallpaperSeedSourceDescriptor(
"app_video",
CreateWallpaperSourceKey("app_video", wallpaperPath),
wallpaperPath,
wallpaperPath,
null);
}
}
var systemWallpaper = _systemWallpaperService.GetWallpaperPath();

View File

@@ -163,7 +163,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
var totalElapsedWeeks = (int)Math.Floor(
(referenceDate.ToDateTime(TimeOnly.MinValue) - cycleRule.SingleWeekStartDate.Value.ToDateTime(TimeOnly.MinValue)).TotalDays / 7d);
for (var cycleLength = 2; cycleLength <= maxCycle; cycleLength++)
for (var cycleLength = 1; cycleLength <= maxCycle; cycleLength++)
{
var cycleOffset = cycleLength < cycleRule.MultiWeekRotationOffset.Count
? cycleRule.MultiWeekRotationOffset[cycleLength]
@@ -668,7 +668,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return true;
}
if (weekCountDivTotal <= 1 || weekCountDivTotal >= cyclePositions.Count)
if (weekCountDivTotal <= 0 || weekCountDivTotal >= cyclePositions.Count)
{
return false;
}

View File

@@ -106,6 +106,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
public void DeleteForComponent(string componentId, string? placementId)
{
_ = new WhiteboardNotePersistenceService().DeleteNote(componentId, placementId);
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,18 @@ public static class DesktopComponentEditorRegistryFactory
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopRemovableStorage] = new(
BuiltInComponentIds.DesktopRemovableStorage,
context => new RemovableStorageComponentEditor(context)),
[BuiltInComponentIds.DesktopWhiteboard] = new(
BuiltInComponentIds.DesktopWhiteboard,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopBlackboardLandscape] = new(
BuiltInComponentIds.DesktopBlackboardLandscape,
context => new WhiteboardComponentEditor(context)),
[BuiltInComponentIds.DesktopOfficeRecentDocuments] = new(
BuiltInComponentIds.DesktopOfficeRecentDocuments,
context => new OfficeRecentDocumentsComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),

View File

@@ -122,6 +122,7 @@ public static class DesktopComponentRegistryFactory
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
var appearanceSnapshot = HostAppearanceThemeProvider.GetOrCreate().GetCurrent();
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
@@ -131,6 +132,8 @@ public static class DesktopComponentRegistryFactory
contribution.Registration.ComponentId,
context.PlacementId,
context.CellSize,
appearanceSnapshot.GlobalCornerRadiusScale,
appearanceSnapshot.CornerRadiusTokens,
pluginSettings);
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);

View File

@@ -20,6 +20,7 @@ public sealed record ComponentLibraryCategoryEntry(
public sealed record ComponentLibraryCreateContext(
double CellSize,
double GlobalCornerRadiusScale,
TimeZoneService TimeZoneService,
IWeatherInfoService WeatherInfoService,
IRecommendationInfoService RecommendationInfoService,

View File

@@ -0,0 +1,19 @@
using System;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public interface IWhiteboardNotePersistenceService
{
WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays);
void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays);
bool DeleteNote(string componentId, string? placementId);
bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays);
bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null);
DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays);
}

View File

@@ -1,6 +1,7 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text.Json;
namespace LanMountainDesktop.Services;
@@ -16,6 +17,23 @@ public sealed class LocalizationService
private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// 清除指定语言代码的缓存,强制下次重新加载。
/// 在语言切换时调用此方法以确保加载最新的语言文件。
/// </summary>
public void ClearCache(string? languageCode = null)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
_cache.Clear();
}
else
{
var normalizedCode = NormalizeLanguageCode(languageCode);
_cache.Remove(normalizedCode);
}
}
public string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
@@ -42,14 +60,17 @@ public sealed class LocalizationService
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
var json = TryLoadFromFileSystem(languageCode);
if (string.IsNullOrEmpty(json))
{
json = TryLoadFromEmbeddedResource(languageCode);
}
if (!string.IsNullOrEmpty(json))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
if (data is not null && data.Count > 0)
{
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
}
@@ -60,7 +81,48 @@ public sealed class LocalizationService
// Keep empty table for resilience.
}
_cache[languageCode] = result;
// 只有当语言表非空时才缓存,这样如果加载失败可以下次重试
if (result.Count > 0)
{
_cache[languageCode] = result;
}
return result;
}
private string? TryLoadFromFileSystem(string languageCode)
{
try
{
var filePath = Path.Combine(AppContext.BaseDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
return File.ReadAllText(filePath);
}
}
catch
{
// Continue to next method
}
return null;
}
private string? TryLoadFromEmbeddedResource(string languageCode)
{
try
{
var assembly = Assembly.GetExecutingAssembly();
var resourceName = $"LanMountainDesktop.Localization.{languageCode}.json";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
using var reader = new StreamReader(stream);
return reader.ReadToEnd();
}
}
catch
{
// Continue to next method
}
return null;
}
}

View File

@@ -0,0 +1,742 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using LanMountainDesktop.Models;
using Microsoft.Win32;
using MudTools.OfficeInterop;
using MudTools.OfficeInterop.Excel;
using MudTools.OfficeInterop.Word;
namespace LanMountainDesktop.Services;
public interface IOfficeRecentDocumentsService
{
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
void OpenDocument(string filePath);
}
public sealed class OfficeRecentDocument
{
public string FileName { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string Extension { get; set; } = string.Empty;
public DateTime LastModifiedTime { get; set; }
public long FileSizeBytes { get; set; }
public string IconGlyph { get; set; } = string.Empty;
internal DateTime? RecentAccessTime { get; set; }
internal int SourcePriority { get; set; }
internal int SourceOrder { get; set; }
}
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
{
private const string LogCategory = "OfficeRecentDocs";
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
private static readonly Regex OfficeFilePathRegex = new(
@"(?:[A-Z]:\\|\\\\)[^\x00-\x1F""<>|]+?\.(?:docx?|dotx?|rtf|xlsx?|xlsm|xlsb|csv|pptx?|pptm|ppsx?)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex OfficeMruTimestampRegex = new(
@"\[T(?<filetime>[0-9A-F]+)\]",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
{
var documents = new List<OfficeRecentDocument>();
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
enabledSources,
useDefaultWhenEmpty: enabledSources is null);
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
{
return documents;
}
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
if (useRegistry)
{
TryGetFromRegistry(documents);
}
if (useRecentFolders)
{
TryGetFromRecentFolders(documents);
}
if (useJumpLists)
{
TryGetFromJumpLists(documents);
}
if (useRegistry && documents.Count < maxCount)
{
TryGetFromMudToolsInterop(documents);
}
return documents
.GroupBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
.Select(MergeDocuments)
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.Take(maxCount)
.ToList();
}
public void OpenDocument(string filePath)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = filePath,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, $"Failed to open Office document '{filePath}'.", ex);
}
}
private static OfficeRecentDocument MergeDocuments(IGrouping<string, OfficeRecentDocument> group)
{
var preferred = group
.OrderByDescending(d => d.RecentAccessTime ?? DateTime.MinValue)
.ThenBy(d => d.SourcePriority)
.ThenBy(d => d.SourceOrder)
.ThenByDescending(d => d.LastModifiedTime)
.First();
return new OfficeRecentDocument
{
FileName = preferred.FileName,
FilePath = preferred.FilePath,
Extension = preferred.Extension,
LastModifiedTime = group.Max(d => d.LastModifiedTime),
FileSizeBytes = preferred.FileSizeBytes,
IconGlyph = preferred.IconGlyph,
RecentAccessTime = group
.Where(d => d.RecentAccessTime.HasValue)
.Select(d => d.RecentAccessTime)
.Max(),
SourcePriority = preferred.SourcePriority,
SourceOrder = preferred.SourceOrder
};
}
[SupportedOSPlatform("windows")]
private void TryGetFromMudToolsInterop(List<OfficeRecentDocument> documents)
{
try
{
RunOnStaThread(() =>
{
var sourceOrder = 0;
TryGetFromWordInterop(documents, ref sourceOrder);
TryGetFromExcelInterop(documents, ref sourceOrder);
});
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "MudTools.OfficeInterop recent-document read failed.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromWordInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Word.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = WordFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", WdAlertLevel.wdAlertsNone);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Word recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromExcelInterop(List<OfficeRecentDocument> documents, ref int sourceOrder)
{
if (!TryGetOfficeApplication("Excel.Application", out var comObject, out var createdNew))
{
return;
}
object? application = null;
try
{
application = ExcelFactory.Connection(comObject!);
if (createdNew)
{
TrySetProperty(comObject, "Visible", false);
TrySetProperty(application, "DisplayAlerts", false);
}
AddInteropRecentFiles(documents, GetPropertyValue(application, "RecentFiles"), 0, ref sourceOrder);
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Excel recent files via MudTools.OfficeInterop.", ex);
}
finally
{
CleanupOfficeApplication(application, comObject, createdNew);
}
}
private void AddInteropRecentFiles(
List<OfficeRecentDocument> documents,
object? recentFiles,
int sourcePriority,
ref int sourceOrder)
{
if (recentFiles == null)
{
return;
}
var count = GetIntProperty(recentFiles, "Count");
var itemProperty = recentFiles.GetType().GetProperty("Item");
if (count <= 0 || itemProperty == null)
{
return;
}
for (var index = 1; index <= count; index++)
{
try
{
var recentFile = itemProperty.GetValue(recentFiles, new object[] { index });
var filePath = GetStringProperty(recentFile, "Path");
AddDocumentIfExists(documents, filePath, sourcePriority, sourceOrder++, null);
}
catch
{
// Ignore a single malformed MRU entry and keep processing the rest.
}
}
}
[SupportedOSPlatform("windows")]
private static bool TryGetOfficeApplication(string progId, out object? comObject, out bool createdNew)
{
comObject = null;
createdNew = false;
var applicationType = Type.GetTypeFromProgID(progId, throwOnError: false);
if (applicationType == null)
{
return false;
}
try
{
comObject = Activator.CreateInstance(applicationType);
createdNew = comObject != null;
return comObject != null;
}
catch
{
return false;
}
}
[SupportedOSPlatform("windows")]
private static void CleanupOfficeApplication(object? application, object? comObject, bool createdNew)
{
try
{
if (createdNew && application != null)
{
InvokeParameterlessMethod(application, "Quit");
}
}
catch
{
}
try
{
if (application is IDisposable disposable)
{
disposable.Dispose();
}
}
catch
{
}
ReleaseComObject(application);
if (!ReferenceEquals(application, comObject))
{
ReleaseComObject(comObject);
}
}
[SupportedOSPlatform("windows")]
private static void ReleaseComObject(object? instance)
{
if (instance == null || !Marshal.IsComObject(instance))
{
return;
}
try
{
Marshal.FinalReleaseComObject(instance);
}
catch
{
}
}
[SupportedOSPlatform("windows")]
private static void RunOnStaThread(Action action)
{
Exception? exception = null;
using var finished = new ManualResetEventSlim();
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
exception = ex;
}
finally
{
finished.Set();
}
});
thread.IsBackground = true;
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
finished.Wait();
if (exception != null)
{
throw new InvalidOperationException("Failed to run Office interop on STA thread.", exception);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistry(List<OfficeRecentDocument> documents)
{
try
{
using var officeRoot = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Office");
if (officeRoot == null)
{
return;
}
var versions = officeRoot
.GetSubKeyNames()
.Where(IsOfficeVersionKey)
.OrderByDescending(ParseVersionKey)
.ToList();
var sourceOrder = 0;
foreach (var version in versions)
{
TryGetFromRegistryApp(documents, version, "Word", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "Excel", ref sourceOrder);
TryGetFromRegistryApp(documents, version, "PowerPoint", ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Office MRU entries from the registry.", ex);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryApp(List<OfficeRecentDocument> documents, string version, string appName, ref int sourceOrder)
{
TryGetFromRegistryMruKey(documents, $@"Software\Microsoft\Office\{version}\{appName}\File MRU", ref sourceOrder);
using var userMruRoot = Registry.CurrentUser.OpenSubKey($@"Software\Microsoft\Office\{version}\{appName}\User MRU");
if (userMruRoot == null)
{
return;
}
foreach (var identityKey in userMruRoot.GetSubKeyNames())
{
TryGetFromRegistryMruKey(
documents,
$@"Software\Microsoft\Office\{version}\{appName}\User MRU\{identityKey}\File MRU",
ref sourceOrder);
}
}
[SupportedOSPlatform("windows")]
private void TryGetFromRegistryMruKey(List<OfficeRecentDocument> documents, string registryPath, ref int sourceOrder)
{
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
if (key == null)
{
return;
}
var entries = key
.GetValueNames()
.Where(name => name.StartsWith("Item ", StringComparison.OrdinalIgnoreCase))
.Select(name => new
{
Name = name,
Order = ParseMruItemOrder(name),
Value = key.GetValue(name) as string
})
.Where(entry => !string.IsNullOrWhiteSpace(entry.Value))
.OrderBy(entry => entry.Order);
foreach (var entry in entries)
{
var (filePath, recentAccessTime) = ParseOfficeMruValue(entry.Value!);
AddDocumentIfExists(documents, filePath, 1, sourceOrder++, recentAccessTime);
}
}
private void TryGetFromRecentFolders(List<OfficeRecentDocument> documents)
{
try
{
var linkFiles = GetRecentFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.lnk"))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var linkFile in linkFiles)
{
var targetPath = GetShortcutTarget(linkFile.FullName);
AddDocumentIfExists(documents, targetPath, 2, sourceOrder++, linkFile.LastWriteTime);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Recent shortcut folders.", ex);
}
}
private void TryGetFromJumpLists(List<OfficeRecentDocument> documents)
{
try
{
var jumpListFiles = GetJumpListFolders()
.Where(Directory.Exists)
.SelectMany(path => Directory.EnumerateFiles(path, "*.automaticDestinations-ms")
.Concat(Directory.EnumerateFiles(path, "*.customDestinations-ms")))
.Select(path => new FileInfo(path))
.OrderByDescending(info => info.LastWriteTimeUtc)
.ToList();
var sourceOrder = 0;
foreach (var jumpListFile in jumpListFiles)
{
TryParseJumpListFile(jumpListFile, documents, ref sourceOrder);
}
}
catch (Exception ex)
{
AppLogger.Warn(LogCategory, "Failed to read Windows Jump Lists for Office documents.", ex);
}
}
private void TryParseJumpListFile(FileInfo jumpListFile, List<OfficeRecentDocument> documents, ref int sourceOrder)
{
try
{
var bytes = File.ReadAllBytes(jumpListFile.FullName);
foreach (var filePath in ExtractPossiblePaths(bytes))
{
AddDocumentIfExists(documents, filePath, 3, sourceOrder++, jumpListFile.LastWriteTime);
}
}
catch
{
// Ignore a single Jump List file and keep scanning the rest.
}
}
private static IEnumerable<string> ExtractPossiblePaths(byte[] bytes)
{
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var text in new[]
{
Encoding.Unicode.GetString(bytes),
Encoding.Latin1.GetString(bytes)
})
{
foreach (Match match in OfficeFilePathRegex.Matches(text))
{
var normalizedPath = NormalizeFilePath(match.Value);
if (!string.IsNullOrWhiteSpace(normalizedPath))
{
paths.Add(normalizedPath);
}
}
}
return paths;
}
private void AddDocumentIfExists(
List<OfficeRecentDocument> documents,
string? filePath,
int sourcePriority,
int sourceOrder,
DateTime? recentAccessTime)
{
try
{
var normalizedPath = NormalizeFilePath(filePath);
if (string.IsNullOrWhiteSpace(normalizedPath))
{
return;
}
var extension = Path.GetExtension(normalizedPath).ToLowerInvariant();
if (!IsOfficeFile(extension) || !File.Exists(normalizedPath))
{
return;
}
var fileInfo = new FileInfo(normalizedPath);
documents.Add(new OfficeRecentDocument
{
FileName = Path.GetFileNameWithoutExtension(normalizedPath),
FilePath = normalizedPath,
Extension = extension,
LastModifiedTime = fileInfo.LastWriteTime,
FileSizeBytes = fileInfo.Length,
IconGlyph = GetIconGlyph(extension),
RecentAccessTime = recentAccessTime,
SourcePriority = sourcePriority,
SourceOrder = sourceOrder
});
}
catch
{
// Ignore a single file and keep processing the rest of the MRU list.
}
}
private static IEnumerable<string> GetRecentFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent"),
Path.Combine(appData, "Microsoft", "Word", "Recent"),
Path.Combine(appData, "Microsoft", "Excel", "Recent"),
Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Word", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "Excel", "Recent"),
Path.Combine(localAppData, "Microsoft", "Office", "PowerPoint", "Recent")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> GetJumpListFolders()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return new[]
{
Path.Combine(appData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(appData, "Microsoft", "Windows", "Recent", "CustomDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "AutomaticDestinations"),
Path.Combine(localAppData, "Microsoft", "Windows", "Recent", "CustomDestinations")
}.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static bool IsOfficeVersionKey(string keyName)
{
return Version.TryParse(keyName, out _);
}
private static Version ParseVersionKey(string keyName)
{
return Version.TryParse(keyName, out var version) ? version : new Version(0, 0);
}
private static int ParseMruItemOrder(string valueName)
{
var numberText = valueName["Item ".Length..];
return int.TryParse(numberText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number)
? number
: int.MaxValue;
}
private static (string? FilePath, DateTime? RecentAccessTime) ParseOfficeMruValue(string rawValue)
{
var filePath = ExtractOfficeFilePath(rawValue);
DateTime? recentAccessTime = null;
var timestampMatch = OfficeMruTimestampRegex.Match(rawValue);
if (timestampMatch.Success &&
long.TryParse(timestampMatch.Groups["filetime"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var fileTime) &&
fileTime > 0)
{
try
{
recentAccessTime = DateTime.FromFileTimeUtc(fileTime).ToLocalTime();
}
catch
{
recentAccessTime = null;
}
}
return (filePath, recentAccessTime);
}
private static string? ExtractOfficeFilePath(string rawValue)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var markerIndex = rawValue.LastIndexOf('*');
var candidate = markerIndex >= 0
? rawValue[(markerIndex + 1)..]
: rawValue;
var normalizedCandidate = NormalizeFilePath(candidate);
if (!string.IsNullOrWhiteSpace(normalizedCandidate) && IsOfficeFile(Path.GetExtension(normalizedCandidate)))
{
return normalizedCandidate;
}
var match = OfficeFilePathRegex.Match(rawValue);
return match.Success ? NormalizeFilePath(match.Value) : null;
}
private static string? NormalizeFilePath(string? rawPath)
{
if (string.IsNullOrWhiteSpace(rawPath))
{
return null;
}
var candidate = rawPath.Trim('\0', ' ', '"');
candidate = Environment.ExpandEnvironmentVariables(candidate);
if (Uri.TryCreate(candidate, UriKind.Absolute, out var uri) && uri.IsFile)
{
candidate = uri.LocalPath;
}
candidate = candidate.Replace('/', '\\');
return string.IsNullOrWhiteSpace(candidate) ? null : candidate;
}
private static bool IsOfficeFile(string extension)
{
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
private static string GetIconGlyph(string extension)
{
return extension switch
{
".doc" or ".docx" or ".dot" or ".dotx" or ".rtf" => "\uE8A5",
".xls" or ".xlsx" or ".xlsm" or ".xlsb" or ".csv" => "\uE9F9",
".ppt" or ".pptx" or ".pptm" or ".pps" or ".ppsx" => "\uE8A1",
_ => "\uE8A5"
};
}
private static string? GetShortcutTarget(string lnkPath)
{
return ShortcutHelper.GetShortcutTarget(lnkPath);
}
private static object? GetPropertyValue(object? instance, string propertyName)
{
return instance?.GetType().GetProperty(propertyName)?.GetValue(instance);
}
private static string? GetStringProperty(object? instance, string propertyName)
{
return GetPropertyValue(instance, propertyName) as string;
}
private static int GetIntProperty(object instance, string propertyName)
{
var value = GetPropertyValue(instance, propertyName);
return value switch
{
int intValue => intValue,
short shortValue => shortValue,
long longValue => (int)longValue,
_ => 0
};
}
private static void TrySetProperty(object? instance, string propertyName, object value)
{
var property = instance?.GetType().GetProperty(propertyName);
if (property?.CanWrite == true)
{
property.SetValue(instance, value);
}
}
private static void InvokeParameterlessMethod(object instance, string methodName)
{
instance.GetType().GetMethod(methodName, Type.EmptyTypes)?.Invoke(instance, null);
}
}

View File

@@ -0,0 +1,310 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
public sealed record RemovableStorageDrive(
string RootPath,
string DriveLetter,
string? VolumeLabel);
public interface IRemovableStorageService
{
IReadOnlyList<RemovableStorageDrive> GetConnectedDrives();
bool OpenDrive(string rootPath);
bool EjectDrive(string rootPath);
}
public sealed class RemovableStorageService : IRemovableStorageService
{
public IReadOnlyList<RemovableStorageDrive> GetConnectedDrives()
{
var drives = new List<RemovableStorageDrive>();
foreach (var drive in DriveInfo.GetDrives())
{
try
{
if (drive.DriveType != DriveType.Removable || !drive.IsReady)
{
continue;
}
var rootPath = NormalizeRootPath(drive.Name);
if (string.IsNullOrWhiteSpace(rootPath))
{
continue;
}
var driveLetter = rootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var volumeLabel = string.IsNullOrWhiteSpace(drive.VolumeLabel)
? null
: drive.VolumeLabel.Trim();
drives.Add(new RemovableStorageDrive(rootPath, driveLetter, volumeLabel));
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to inspect drive '{drive.Name}'.", ex);
}
}
return drives
.OrderBy(drive => drive.DriveLetter, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
public bool OpenDrive(string rootPath)
{
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = normalizedRootPath,
UseShellExecute = true
});
return true;
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to open drive '{normalizedRootPath}'.", ex);
return false;
}
}
public bool EjectDrive(string rootPath)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
var normalizedRootPath = NormalizeRootPath(rootPath);
if (string.IsNullOrWhiteSpace(normalizedRootPath))
{
return false;
}
object? shellApplication = null;
object? computerFolder = null;
object? driveItem = null;
try
{
var shellType = Type.GetTypeFromProgID("Shell.Application");
if (shellType is null)
{
return false;
}
shellApplication = Activator.CreateInstance(shellType);
if (shellApplication is null)
{
return false;
}
computerFolder = shellType.InvokeMember(
"NameSpace",
BindingFlags.InvokeMethod,
binder: null,
target: shellApplication,
args: [17]);
if (computerFolder is null)
{
return false;
}
var driveToken = normalizedRootPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
driveItem = computerFolder.GetType().InvokeMember(
"ParseName",
BindingFlags.InvokeMethod,
binder: null,
target: computerFolder,
args: [driveToken]);
if (driveItem is null)
{
return false;
}
if (TryInvokeVerb(driveItem, "Eject"))
{
return true;
}
return TryInvokeLocalizedEjectVerb(driveItem);
}
catch (Exception ex)
{
AppLogger.Warn("RemovableStorage", $"Failed to eject drive '{normalizedRootPath}'.", ex);
return false;
}
finally
{
ReleaseComObject(driveItem);
ReleaseComObject(computerFolder);
ReleaseComObject(shellApplication);
}
}
private static bool TryInvokeLocalizedEjectVerb(object driveItem)
{
object? verbs = null;
try
{
verbs = driveItem.GetType().InvokeMember(
"Verbs",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: null);
if (verbs is null)
{
return false;
}
var verbsType = verbs.GetType();
var countObject = verbsType.InvokeMember(
"Count",
BindingFlags.GetProperty,
binder: null,
target: verbs,
args: null);
var count = countObject is null
? 0
: Convert.ToInt32(countObject, CultureInfo.InvariantCulture);
for (var index = 0; index < count; index++)
{
object? verb = null;
try
{
verb = verbsType.InvokeMember(
"Item",
BindingFlags.InvokeMethod,
binder: null,
target: verbs,
args: [index]);
if (verb is null)
{
continue;
}
var verbNameObject = verb.GetType().InvokeMember(
"Name",
BindingFlags.GetProperty,
binder: null,
target: verb,
args: null);
var verbName = Convert.ToString(verbNameObject, CultureInfo.InvariantCulture);
if (!IsEjectVerbName(verbName))
{
continue;
}
verb.GetType().InvokeMember(
"DoIt",
BindingFlags.InvokeMethod,
binder: null,
target: verb,
args: null);
return true;
}
finally
{
ReleaseComObject(verb);
}
}
return false;
}
finally
{
ReleaseComObject(verbs);
}
}
private static bool TryInvokeVerb(object driveItem, string verbName)
{
try
{
driveItem.GetType().InvokeMember(
"InvokeVerb",
BindingFlags.InvokeMethod,
binder: null,
target: driveItem,
args: [verbName]);
return true;
}
catch
{
return false;
}
}
private static bool IsEjectVerbName(string? verbName)
{
if (string.IsNullOrWhiteSpace(verbName))
{
return false;
}
var normalized = string.Concat(
verbName
.Where(character => !char.IsWhiteSpace(character) && character != '&'))
.Trim();
return normalized.Contains("Eject", StringComparison.OrdinalIgnoreCase) ||
normalized.Contains("弹出", StringComparison.Ordinal) ||
normalized.Contains("安全删除", StringComparison.Ordinal) ||
normalized.Contains("卸载", StringComparison.Ordinal);
}
private static string NormalizeRootPath(string? rootPath)
{
if (string.IsNullOrWhiteSpace(rootPath))
{
return string.Empty;
}
var trimmed = rootPath.Trim();
if (trimmed.Length == 1 && char.IsLetter(trimmed[0]))
{
return string.Create(CultureInfo.InvariantCulture, $"{trimmed}:{Path.DirectorySeparatorChar}");
}
if (trimmed.Length == 2 && char.IsLetter(trimmed[0]) && trimmed[1] == ':')
{
return trimmed + Path.DirectorySeparatorChar;
}
var normalized = trimmed.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var resolvedRoot = Path.GetPathRoot(normalized);
return string.IsNullOrWhiteSpace(resolvedRoot)
? normalized
: resolvedRoot;
}
private static void ReleaseComObject(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.FinalReleaseComObject(value);
}
}
}

View File

@@ -17,7 +17,7 @@ internal sealed class SettingsCatalogService : ISettingsCatalog
[
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "GridDots", sortOrder: 20),
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "Apps", sortOrder: 20),
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
]);

View File

@@ -5,22 +5,23 @@ using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings;
public enum WallpaperMediaType
{
None,
Image,
Video
Image
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement, string? CustomColor = null);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
string? ThemeColor,
bool UseSystemChrome,
double GlobalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale,
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
string? SelectedWallpaperSeed = null);
@@ -30,6 +31,7 @@ public sealed record StatusBarSettingsState(
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
bool ClockTransparentBackground,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
@@ -48,7 +50,6 @@ public sealed record PrivacySettingsState(
bool UploadAnonymousCrashData,
bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates,
string UpdateChannel,
string UpdateMode,

View File

@@ -10,6 +10,7 @@ using Avalonia.Media.Imaging;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Settings.Core;
using LanMountainDesktop.Services.PluginMarket;
namespace LanMountainDesktop.Services.Settings;
@@ -147,11 +148,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
};
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
};
private readonly string _wallpapersDirectory;
public WallpaperMediaService()
@@ -180,11 +176,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
return WallpaperMediaType.Image;
}
if (VideoExtensions.Contains(extension))
{
return WallpaperMediaType.Video;
}
return WallpaperMediaType.None;
}
@@ -252,6 +243,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
snapshot.IsNightMode ?? false,
snapshot.ThemeColor,
snapshot.UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale),
ThemeAppearanceValues.NormalizeThemeColorMode(snapshot.ThemeColorMode, snapshot.ThemeColor),
ThemeAppearanceValues.NormalizeSystemMaterialMode(snapshot.SystemMaterialMode),
snapshot.SelectedWallpaperSeed);
@@ -262,6 +254,7 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
var snapshot = _settingsService.Load();
var changedKeys = new List<string>();
var normalizedThemeColor = string.IsNullOrWhiteSpace(state.ThemeColor) ? null : state.ThemeColor;
var normalizedCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(state.GlobalCornerRadiusScale);
var normalizedThemeColorMode = ThemeAppearanceValues.NormalizeThemeColorMode(state.ThemeColorMode, state.ThemeColor);
var normalizedSystemMaterialMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(state.SystemMaterialMode);
var normalizedSelectedWallpaperSeed = string.IsNullOrWhiteSpace(state.SelectedWallpaperSeed)
@@ -286,6 +279,12 @@ internal sealed class ThemeAppearanceService : IThemeAppearanceService
changedKeys.Add(nameof(AppSettingsSnapshot.UseSystemChrome));
}
if (Math.Abs(GlobalAppearanceSettings.NormalizeCornerRadiusScale(snapshot.GlobalCornerRadiusScale) - normalizedCornerRadiusScale) > 0.0001d)
{
snapshot.GlobalCornerRadiusScale = normalizedCornerRadiusScale;
changedKeys.Add(nameof(AppSettingsSnapshot.GlobalCornerRadiusScale));
}
if (!string.Equals(snapshot.ThemeColorMode, normalizedThemeColorMode, StringComparison.OrdinalIgnoreCase))
{
snapshot.ThemeColorMode = normalizedThemeColorMode;
@@ -371,6 +370,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions,
snapshot.TaskbarLayoutMode,
snapshot.ClockDisplayFormat,
snapshot.StatusBarClockTransparentBackground,
snapshot.StatusBarSpacingMode,
snapshot.StatusBarCustomSpacingPercent);
}
@@ -383,6 +383,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
snapshot.EnableDynamicTaskbarActions = state.EnableDynamicTaskbarActions;
snapshot.TaskbarLayoutMode = state.TaskbarLayoutMode;
snapshot.ClockDisplayFormat = state.ClockDisplayFormat;
snapshot.StatusBarClockTransparentBackground = state.ClockTransparentBackground;
snapshot.StatusBarSpacingMode = state.SpacingMode;
snapshot.StatusBarCustomSpacingPercent = state.CustomSpacingPercent;
_settingsService.SaveSnapshot(
@@ -395,6 +396,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
nameof(AppSettingsSnapshot.ClockDisplayFormat),
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
]);
@@ -609,6 +611,7 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
var snapshot = _settingsService.Load();
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
@@ -637,7 +640,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.UpdateChannel,
snapshot.IncludePrereleaseUpdates);
return new UpdateSettingsState(
snapshot.AutoCheckUpdates,
string.Equals(normalizedChannel, UpdateSettingsValues.ChannelPreview, StringComparison.OrdinalIgnoreCase),
normalizedChannel,
UpdateSettingsValues.NormalizeMode(snapshot.UpdateMode),
@@ -655,7 +657,6 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
var normalizedChannel = UpdateSettingsValues.NormalizeChannel(
state.UpdateChannel,
state.IncludePrereleaseUpdates);
snapshot.AutoCheckUpdates = state.AutoCheckUpdates;
snapshot.IncludePrereleaseUpdates = string.Equals(
normalizedChannel,
UpdateSettingsValues.ChannelPreview,
@@ -681,7 +682,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.AutoCheckUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
nameof(AppSettingsSnapshot.UpdateChannel),
nameof(AppSettingsSnapshot.UpdateMode),

View File

@@ -294,6 +294,8 @@ internal sealed class SettingsWindowService : ISettingsWindowService
if (languageChanged)
{
var regionState = _settingsFacade.Region.Get();
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);

View File

@@ -0,0 +1,32 @@
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
internal static class ShortcutHelper
{
[ComImport]
[Guid("72C24DD5-D70A-438B-8A42-98424B88AFB8")]
internal class WshShell { }
[ComImport]
[Guid("F935DC21-1CF0-11D0-ADB9-00C04FD58A0B")]
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
internal interface IWshShortcut
{
string TargetPath { get; set; }
}
public static string? GetShortcutTarget(string lnkPath)
{
try
{
dynamic shell = new WshShell();
dynamic shortcut = shell.CreateShortcut(lnkPath);
return shortcut.TargetPath;
}
catch
{
return null;
}
}
}

View File

@@ -10,6 +10,9 @@ public static class ThemeAppearanceValues
public const string ColorModeSeedMonet = "seed_monet";
public const string ColorModeWallpaperMonet = "wallpaper_monet";
public const string ColorSchemeFollowSystem = "follow_system";
public const string ColorSchemeNative = "native";
public const string MaterialNone = "none";
public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic";

View File

@@ -131,13 +131,10 @@ public sealed class UpdateWorkflowService
CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Update.Get();
if (!state.AutoCheckUpdates)
{
return;
}
try
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{
@@ -145,12 +142,14 @@ public sealed class UpdateWorkflowService
}
var normalizedMode = UpdateSettingsValues.NormalizeMode(state.UpdateMode);
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeManual, StringComparison.OrdinalIgnoreCase))
// For "Silent Download" and "Silent Install" modes, automatically download the update
if (string.Equals(normalizedMode, UpdateSettingsValues.ModeDownloadThenConfirm, StringComparison.OrdinalIgnoreCase) ||
string.Equals(normalizedMode, UpdateSettingsValues.ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
return;
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
}
await DownloadReleaseAsync(result, cancellationToken: cancellationToken);
// For "Manual" mode, just check but don't download
}
catch (OperationCanceledException)
{

View File

@@ -0,0 +1,338 @@
using System;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services;
public sealed class WhiteboardNotePersistenceService : IWhiteboardNotePersistenceService
{
private const int DefaultCleanupBatchSize = 256;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
private readonly object _schemaSyncRoot = new();
private readonly AppDatabaseService _databaseService;
private bool _schemaInitialized;
public WhiteboardNotePersistenceService(AppDatabaseService? databaseService = null)
{
_databaseService = databaseService ?? AppDatabaseServiceFactory.CreateDefault();
}
public WhiteboardNoteSnapshot LoadNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return new WhiteboardNoteSnapshot();
}
try
{
using var connection = OpenConnection();
DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
using var command = connection.CreateCommand();
command.CommandText = """
SELECT note_json, saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
using var reader = command.ExecuteReader();
if (!reader.Read() || reader.IsDBNull(0))
{
return new WhiteboardNoteSnapshot();
}
var json = reader.GetString(0);
if (string.IsNullOrWhiteSpace(json))
{
return new WhiteboardNoteSnapshot();
}
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(json, JsonOptions) ?? new WhiteboardNoteSnapshot();
if (!reader.IsDBNull(1))
{
snapshot.SavedUtc = DateTimeOffset.FromUnixTimeMilliseconds(reader.GetInt64(1));
}
if (IsExpired(snapshot, retentionDays))
{
DeleteNote(normalizedComponentId, normalizedPlacementId);
return new WhiteboardNoteSnapshot();
}
return snapshot.Clone();
}
catch
{
return new WhiteboardNoteSnapshot();
}
}
public void SaveNote(string componentId, string? placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return;
}
try
{
var nowUtc = DateTimeOffset.UtcNow;
var persistedSnapshot = snapshot?.Clone() ?? new WhiteboardNoteSnapshot();
persistedSnapshot.SavedUtc = nowUtc;
var expiresUtc = GetExpirationUtc(persistedSnapshot, retentionDays) ?? nowUtc.AddDays(WhiteboardNoteRetentionPolicy.DefaultDays);
var json = JsonSerializer.Serialize(persistedSnapshot, JsonOptions);
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO whiteboard_notes(
component_id,
placement_id,
note_json,
saved_at_utc_ms,
expires_at_utc_ms,
updated_at_utc_ms)
VALUES(
$componentId,
$placementId,
$noteJson,
$savedAtUtcMs,
$expiresAtUtcMs,
$updatedAtUtcMs)
ON CONFLICT(component_id, placement_id) DO UPDATE SET
note_json = excluded.note_json,
saved_at_utc_ms = excluded.saved_at_utc_ms,
expires_at_utc_ms = excluded.expires_at_utc_ms,
updated_at_utc_ms = excluded.updated_at_utc_ms;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$noteJson", json);
command.Parameters.AddWithValue("$savedAtUtcMs", persistedSnapshot.SavedUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
command.ExecuteNonQuery();
}
catch
{
// Keep whiteboard usable even when persistence is unavailable.
}
}
public bool DeleteNote(string componentId, string? placementId)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
return command.ExecuteNonQuery() > 0;
}
catch
{
return false;
}
}
public bool TryDeleteExpiredNote(string componentId, string? placementId, int retentionDays)
{
if (!TryNormalizeKeys(componentId, placementId, out var normalizedComponentId, out var normalizedPlacementId))
{
return false;
}
try
{
using var connection = OpenConnection();
return DeleteExpiredInternal(
connection,
normalizedComponentId,
normalizedPlacementId,
WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays),
DateTimeOffset.UtcNow);
}
catch
{
return false;
}
}
public int DeleteExpiredNotesBatch(int batchSize = DefaultCleanupBatchSize, DateTimeOffset? now = null)
{
try
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM whiteboard_notes
WHERE rowid IN (
SELECT rowid
FROM whiteboard_notes
WHERE expires_at_utc_ms <= $nowUtcMs
ORDER BY expires_at_utc_ms ASC
LIMIT $batchSize
);
""";
command.Parameters.AddWithValue("$nowUtcMs", (now ?? DateTimeOffset.UtcNow).ToUnixTimeMilliseconds());
command.Parameters.AddWithValue("$batchSize", NormalizeBatchSize(batchSize));
return command.ExecuteNonQuery();
}
catch
{
return 0;
}
}
public bool IsExpired(WhiteboardNoteSnapshot snapshot, int retentionDays, DateTimeOffset? now = null)
{
if (snapshot is null)
{
return false;
}
var expirationUtc = GetExpirationUtc(snapshot, retentionDays);
if (!expirationUtc.HasValue)
{
return false;
}
return expirationUtc.Value <= (now ?? DateTimeOffset.UtcNow);
}
public DateTimeOffset? GetExpirationUtc(WhiteboardNoteSnapshot snapshot, int retentionDays)
{
if (snapshot is null || snapshot.SavedUtc == default)
{
return null;
}
return snapshot.SavedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
}
private SqliteConnection OpenConnection()
{
var connection = _databaseService.OpenConnection();
EnsureSchema(connection);
return connection;
}
private void EnsureSchema(SqliteConnection connection)
{
if (_schemaInitialized)
{
return;
}
lock (_schemaSyncRoot)
{
if (_schemaInitialized)
{
return;
}
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS whiteboard_notes (
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
note_json TEXT NOT NULL,
saved_at_utc_ms INTEGER NOT NULL,
expires_at_utc_ms INTEGER NOT NULL,
updated_at_utc_ms INTEGER NOT NULL,
PRIMARY KEY (component_id, placement_id)
);
CREATE INDEX IF NOT EXISTS idx_whiteboard_notes_expires_at
ON whiteboard_notes(expires_at_utc_ms);
""";
command.ExecuteNonQuery();
_schemaInitialized = true;
}
}
private static bool DeleteExpiredInternal(
SqliteConnection connection,
string componentId,
string placementId,
int retentionDays,
DateTimeOffset nowUtc)
{
using var selectCommand = connection.CreateCommand();
selectCommand.CommandText = """
SELECT saved_at_utc_ms
FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId
LIMIT 1;
""";
selectCommand.Parameters.AddWithValue("$componentId", componentId);
selectCommand.Parameters.AddWithValue("$placementId", placementId);
var scalar = selectCommand.ExecuteScalar();
if (scalar is not long savedAtUtcMs)
{
return false;
}
var savedUtc = DateTimeOffset.FromUnixTimeMilliseconds(savedAtUtcMs);
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
if (expiresUtc > nowUtc)
{
return false;
}
using var deleteCommand = connection.CreateCommand();
deleteCommand.CommandText = """
DELETE FROM whiteboard_notes
WHERE component_id = $componentId
AND placement_id = $placementId;
""";
deleteCommand.Parameters.AddWithValue("$componentId", componentId);
deleteCommand.Parameters.AddWithValue("$placementId", placementId);
return deleteCommand.ExecuteNonQuery() > 0;
}
private static bool TryNormalizeKeys(
string componentId,
string? placementId,
out string normalizedComponentId,
out string normalizedPlacementId)
{
normalizedComponentId = componentId?.Trim() ?? string.Empty;
normalizedPlacementId = placementId?.Trim() ?? string.Empty;
return !string.IsNullOrWhiteSpace(normalizedComponentId);
}
private static int NormalizeBatchSize(int batchSize)
{
return batchSize <= 0
? DefaultCleanupBatchSize
: Math.Clamp(batchSize, 1, 4096);
}
}

View File

@@ -1,5 +1,6 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:assists="clr-namespace:Material.Styles.Assists;assembly=Material.Styles">
<Style Selector="Window.component-editor-window">
<Setter Property="Background" Value="{DynamicResource EditorWindowBackgroundBrush}" />
</Style>
@@ -17,7 +18,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="FontSize" Value="14" />
@@ -39,7 +40,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="18" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
@@ -60,7 +61,7 @@
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="16,12" />
<Setter Property="Margin" Value="6,4" />
<Setter Property="CornerRadius" Value="14" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="MinHeight" Value="44" />
</Style>
@@ -74,7 +75,21 @@
</Style>
<Style Selector="Window.component-editor-window RadioButton">
<Setter Property="Theme" Value="{StaticResource MaterialRadioButton}" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />
<Setter Property="Margin" Value="0,2" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="assists:SelectionControlAssist.Size" Value="20" />
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource ComponentEditorSecondaryTextBrush}" />
<Setter Property="assists:SelectionControlAssist.InnerForeground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:pointerover">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorSelectOutlineStrongBrush}" />
</Style>
<Style Selector="Window.component-editor-window RadioButton:checked">
<Setter Property="assists:SelectionControlAssist.Foreground" Value="{DynamicResource EditorPrimaryBrush}" />
</Style>
<Style Selector="Window.component-editor-window ToggleSwitch">
@@ -85,7 +100,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSurfaceContainerHighBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Padding" Value="4" />
</Style>
@@ -93,7 +108,7 @@
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="18,12" />
<Setter Property="MinHeight" Value="48" />
<Setter Property="FontSize" Value="14" />
@@ -124,14 +139,14 @@
<Setter Property="Background" Value="{DynamicResource ComponentEditorHeroBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="28" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
</Style>
<Style Selector="Border.component-editor-card">
<Setter Property="Background" Value="{DynamicResource ComponentEditorCardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource ComponentEditorCardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="24" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
</Style>
<Style Selector="TextBlock.component-editor-headline">

View File

@@ -4,11 +4,13 @@
<Styles.Resources>
<!-- Unified corner radius tokens used across settings and widget panels -->
<CornerRadius x:Key="DesignCornerRadiusMicro">6</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXl">32</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusLg">28</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusMd">20</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusIsland">36</CornerRadius>
</Styles.Resources>
<Style Selector="TextBlock">
@@ -19,7 +21,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
<Setter Property="CornerRadius" Value="20" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Padding" Value="16,10" />
@@ -155,7 +157,7 @@
<Style Selector="Button.swatch-button">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="16" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="Opacity" Value="0.88" />
</Style>
@@ -165,29 +167,35 @@
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
<Style Selector="Border.glass-panel">
<!--
半透明表面样式类
注意:这些样式使用纯色半透明画刷模拟玻璃效果,并非真正的 Mica/Acrylic 模糊材质。
真正的 Mica/Acrylic 效果仅通过 WindowTransparencyLevel 在独立窗口上应用。
-->
<Style Selector="Border.surface-translucent-panel">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1.2" />
<Setter Property="CornerRadius" Value="28" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
<Setter Property="BoxShadow" Value="0 4 12 #1A000000" />
</Style>
<Style Selector="Border.glass-strong">
<Style Selector="Border.surface-translucent-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassStrongBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="CornerRadius" Value="32" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXl}" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 8 24 #26000000" />
</Style>
<Style Selector="Border.glass-island">
<Style Selector="Border.surface-translucent-island">
<Setter Property="Background" Value="{DynamicResource AdaptiveDockGlassBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveDockGlassBorderBrush}" />
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="CornerRadius" Value="36" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 12 32 #33000000" />
<Setter Property="Transitions">
@@ -197,19 +205,26 @@
</Setter>
</Style>
<Style Selector="Border.mica-strong">
<Style Selector="Border.surface-solid-strong">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassStrongBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="36" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusIsland}" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassStrongOpacity}" />
<Setter Property="BoxShadow" Value="0 8 22 #2A000000" />
</Style>
<Style Selector="Border.glass-overlay">
<Style Selector="Border.surface-translucent-overlay">
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassOverlayBackgroundBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassOverlayOpacity}" />
</Style>
<!-- 向后兼容的旧样式类(已弃用) -->
<Style Selector="Border.glass-panel" />
<Style Selector="Border.glass-strong" />
<Style Selector="Border.glass-island" />
<Style Selector="Border.mica-strong" />
<Style Selector="Border.glass-overlay" />
</Styles>

View File

@@ -48,21 +48,21 @@
</Style>
<Style Selector="Border.settings-section-card">
<Setter Property="CornerRadius" Value="18" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusLg}" />
<Setter Property="Padding" Value="20" />
<Setter Property="Margin" Value="0,0,0,14" />
<Setter Property="BoxShadow" Value="0 2 8 #12000000" />
</Style>
<Style Selector="Border.settings-option-card">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="16" />
<Setter Property="Margin" Value="0,0,0,12" />
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
</Style>
<Style Selector="Border.settings-list-item">
<Setter Property="CornerRadius" Value="14" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
<Setter Property="Padding" Value="16" />
<Setter Property="Margin" Value="0,0,0,10" />
<Setter Property="BoxShadow" Value="0 1 4 #0F000000" />
@@ -77,7 +77,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="HorizontalAlignment" Value="Left" />
<Setter Property="VerticalAlignment" Value="Top" />
</Style>
@@ -201,7 +201,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="Padding" Value="14,12" />
<Setter Property="Margin" Value="0,0,0,8" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
@@ -229,7 +229,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
<Setter Property="Foreground" Value="{DynamicResource AdaptiveOnAccentBrush}" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="Padding" Value="16,10" />
<Setter Property="MinHeight" Value="36" />
</Style>
@@ -254,7 +254,7 @@
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="10" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusXs}" />
<Setter Property="MinHeight" Value="36" />
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />

View File

@@ -564,11 +564,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
RefreshInstalledSnapshot();
RefreshItemStates();
// 设置更明显的状态消息
var pluginName = result.PluginName ?? item.Name;
StatusMessage = string.Format(
CultureInfo.CurrentCulture,
L("market.status.install_success_format", "Plugin '{0}' has been staged. Restart the app to apply it."),
result.PluginName ?? item.Name);
RestartRequested?.Invoke(RestartRequiredMessage);
L("market.status.install_success_restart_format", "Plugin '{0}' installed successfully! Please restart the application to activate it."),
pluginName);
// 触发重启提醒
RestartRequested?.Invoke(string.Format(
CultureInfo.CurrentCulture,
L("market.dialog.restart_message_format", "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"),
pluginName));
return;
}

View File

@@ -0,0 +1,92 @@
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ViewModels;
public sealed partial class PrivacyPolicyViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
[ObservableProperty]
private string _title = string.Empty;
[ObservableProperty]
private string _description = string.Empty;
[ObservableProperty]
private string _loadingText = string.Empty;
[ObservableProperty]
private string _errorText = string.Empty;
[ObservableProperty]
private string _markdownContent = string.Empty;
[ObservableProperty]
private bool _isLoading = true;
[ObservableProperty]
private bool _hasError;
[ObservableProperty]
private bool _hasContent;
public PrivacyPolicyViewModel()
{
_languageCode = "zh-CN";
RefreshLocalizedText();
LoadPrivacyPolicy();
}
private void RefreshLocalizedText()
{
Title = L("settings.privacy.policy_title", "Privacy Policy");
Description = L("settings.privacy.policy_description", "Learn how we collect, use, and protect your data.");
LoadingText = L("settings.privacy.policy_loading", "Loading privacy policy...");
}
private async void LoadPrivacyPolicy()
{
try
{
IsLoading = true;
HasError = false;
HasContent = false;
// 从嵌入资源加载隐私政策Markdown文件
var assembly = Assembly.GetExecutingAssembly();
var resourceName = "LanMountainDesktop.Assets.Documents.Privacy.md";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream is null)
{
throw new FileNotFoundException($"Privacy policy resource not found: {resourceName}");
}
using var reader = new StreamReader(stream);
var markdown = await reader.ReadToEndAsync();
MarkdownContent = markdown;
IsLoading = false;
HasContent = true;
AppLogger.Info("PrivacyPolicy", "Privacy policy loaded successfully.");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacyPolicy", "Failed to load privacy policy.", ex);
IsLoading = false;
HasError = true;
ErrorText = L("settings.privacy.policy_error", "Failed to load privacy policy. Please try again later.");
}
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}

View File

@@ -1,6 +1,10 @@
using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.ViewModels;
@@ -11,6 +15,8 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private readonly string _languageCode;
private bool _isInitializing;
public event Action? ViewPrivacyPolicyRequested;
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
@@ -28,6 +34,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _uploadAnonymousUsageData;
[ObservableProperty]
private string _deviceId = string.Empty;
[ObservableProperty]
private string _privacyHeader = string.Empty;
@@ -43,11 +52,53 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _usageUploadDescription = string.Empty;
[ObservableProperty]
private string _deviceIdHeader = string.Empty;
[ObservableProperty]
private string _deviceIdDescription = string.Empty;
[ObservableProperty]
private string _refreshDeviceIdText = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
[ObservableProperty]
private string _privacyPolicyHintPrefix = string.Empty;
public void Load()
{
var state = _settingsFacade.Privacy.Get();
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
DeviceId = DeviceIdService.Instance.DeviceId;
}
[RelayCommand]
private void RefreshDeviceId()
{
try
{
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
var snapshot = _settingsFacade.Settings.LoadSnapshot<Models.AppSettingsSnapshot>(SettingsScope.App);
snapshot.DeviceId = newDeviceId;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
DeviceId = newDeviceId;
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
}
}
partial void OnUploadAnonymousCrashDataChanged(bool value)
@@ -84,6 +135,28 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
[RelayCommand]
private void ViewPrivacyPolicy()
{
try
{
// 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
// 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to view privacy policy.", ex);
}
}
private string L(string key, string fallback)

View File

@@ -12,6 +12,7 @@ using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.ViewModels;
@@ -268,12 +269,17 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
partial void OnSelectedLanguageChanged(SelectionOption value)
{
RefreshPreview();
if (_isInitializing || value is null)
{
return;
}
// 更新语言代码并刷新UI文本
_languageCode = _localizationService.NormalizeLanguageCode(value.Value);
RefreshLocalizedText();
RefreshPreview();
// 保存设置
_settingsFacade.Region.Save(new RegionSettingsState(
value.Value,
NormalizeTimeZoneId(SelectedTimeZone?.Id)));
@@ -476,6 +482,9 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _useSystemChrome;
[ObservableProperty]
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
[ObservableProperty]
private SelectionOption _selectedThemeColorMode = new(ThemeAppearanceValues.ColorModeSeedMonet, "User theme color Monet");
@@ -542,6 +551,12 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _systemMaterialLabel = string.Empty;
[ObservableProperty]
private string _globalCornerRadiusLabel = string.Empty;
[ObservableProperty]
private string _globalCornerRadiusDescription = string.Empty;
[ObservableProperty]
private string _themeHeader = string.Empty;
@@ -663,6 +678,32 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
PersistCurrentState(restartRequired: false);
}
partial void OnGlobalCornerRadiusScaleChanged(double value)
{
if (_isInitializing)
{
return;
}
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
if (Math.Abs(normalized - value) > 0.0001d)
{
_isInitializing = true;
try
{
GlobalCornerRadiusScale = normalized;
}
finally
{
_isInitializing = false;
}
return;
}
PersistCurrentState(restartRequired: false);
}
partial void OnSelectedThemeColorModeChanged(SelectionOption value)
{
if (_isInitializing || value is null)
@@ -727,6 +768,8 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
ThemeColorLabel = L("settings.color.theme_color_label", "Theme Accent Color");
ThemeColorModeLabel = L("settings.appearance.theme_color_mode_label", "Theme color source");
SystemMaterialLabel = L("settings.appearance.system_material_label", "System material");
GlobalCornerRadiusLabel = L("settings.appearance.corner_radius.label", "Global corner radius");
GlobalCornerRadiusDescription = L("settings.appearance.corner_radius.description", "Adjust the shared radius scale used by cards, panels, and component containers.");
ThemeSourceNeutralText = L("settings.appearance.theme_color_mode.neutral", "Default neutral");
ThemeSourceUserColorText = L("settings.appearance.theme_color_mode.user", "User theme color Monet");
ThemeSourceWallpaperText = L("settings.appearance.theme_color_mode.wallpaper", "Wallpaper Monet");
@@ -771,6 +814,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
IsNightMode = theme.IsNightMode;
ThemeColor = theme.ThemeColor ?? string.Empty;
UseSystemChrome = theme.UseSystemChrome;
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
_selectedWallpaperSeed = theme.SelectedWallpaperSeed;
SyncCustomSeedPickerWithSavedThemeColor();
@@ -820,6 +864,7 @@ public sealed partial class AppearanceSettingsPageViewModel : ViewModelBase
IsNightMode,
themeColor,
UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
themeColorMode,
ThemeAppearanceValues.NormalizeSystemMaterialMode(SelectedSystemMaterialMode?.Value),
_selectedWallpaperSeed);
@@ -951,7 +996,7 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
private string _pageDescription = string.Empty;
[ObservableProperty]
private string _gridHeader = string.Empty;
private string _componentsHeader = string.Empty;
[ObservableProperty]
private string _shortSideCellsLabel = string.Empty;
@@ -962,6 +1007,18 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _spacingPresetLabel = string.Empty;
[ObservableProperty]
private double _globalCornerRadiusScale = GlobalAppearanceSettings.DefaultCornerRadiusScale;
[ObservableProperty]
private string _componentRadiusHeader = string.Empty;
[ObservableProperty]
private string _globalCornerRadiusLabel = string.Empty;
[ObservableProperty]
private string _globalCornerRadiusDescription = string.Empty;
public void Load()
{
var state = _settingsFacade.Grid.Get();
@@ -971,6 +1028,9 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
SelectedSpacingPreset = SpacingPresets.FirstOrDefault(option =>
string.Equals(option.Value, spacingPreset, StringComparison.OrdinalIgnoreCase))
?? SpacingPresets[1];
var theme = _settingsFacade.Theme.Get();
GlobalCornerRadiusScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(theme.GlobalCornerRadiusScale);
}
partial void OnShortSideCellsChanged(int value)
@@ -1003,6 +1063,32 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
SaveGrid();
}
partial void OnGlobalCornerRadiusScaleChanged(double value)
{
if (_isInitializing)
{
return;
}
var normalized = GlobalAppearanceSettings.NormalizeCornerRadiusScale(value);
if (Math.Abs(normalized - value) > 0.0001d)
{
_isInitializing = true;
try
{
GlobalCornerRadiusScale = normalized;
}
finally
{
_isInitializing = false;
}
return;
}
SaveComponentCornerRadius();
}
private void SaveGrid()
{
_settingsFacade.Grid.Save(new GridSettingsState(
@@ -1011,23 +1097,39 @@ public sealed partial class ComponentsSettingsPageViewModel : ViewModelBase
Math.Clamp(EdgeInsetPercent, 0, 30)));
}
private void SaveComponentCornerRadius()
{
var theme = _settingsFacade.Theme.Get();
_settingsFacade.Theme.Save(new ThemeAppearanceSettingsState(
theme.IsNightMode,
theme.ThemeColor,
theme.UseSystemChrome,
GlobalAppearanceSettings.NormalizeCornerRadiusScale(GlobalCornerRadiusScale),
theme.ThemeColorMode,
theme.SystemMaterialMode,
theme.SelectedWallpaperSeed));
}
private IReadOnlyList<SelectionOption> CreateSpacingPresets()
{
return
[
new SelectionOption("Compact", L("settings.grid.spacing_compact", "Compact")),
new SelectionOption("Relaxed", L("settings.grid.spacing_relaxed", "Relaxed"))
new SelectionOption("Compact", L("settings.components.spacing_compact", "Compact")),
new SelectionOption("Relaxed", L("settings.components.spacing_relaxed", "Relaxed"))
];
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.components.title", "Components");
PageDescription = L("settings.components.description", "Desktop grid and widget placement density.");
GridHeader = L("settings.components.grid_header", "Grid Layout");
ShortSideCellsLabel = L("settings.grid.short_side_label", "Short Side Cells");
EdgeInsetPercentLabel = L("settings.grid.edge_inset_label", "Screen Inset");
SpacingPresetLabel = L("settings.grid.spacing_label", "Grid Spacing");
PageDescription = L("settings.components.description", "Adjust component layout and corner design.");
ComponentsHeader = L("settings.components.header", "Grid Settings");
ShortSideCellsLabel = L("settings.components.short_side_label", "Short Side Cells");
EdgeInsetPercentLabel = L("settings.components.edge_inset_label", "Screen Inset");
SpacingPresetLabel = L("settings.components.spacing_label", "Component Spacing");
ComponentRadiusHeader = L("settings.components.corner_radius.header", "Corner Design");
GlobalCornerRadiusLabel = L("settings.components.corner_radius.label", "Component Corner Radius");
GlobalCornerRadiusDescription = L("settings.components.corner_radius.description", "Adjust the shared corner radius used by component containers, and expand the internal safe area with it.");
}
private string L(string key, string fallback)
@@ -1329,9 +1431,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
LoadStateFromSettings();
}
[ObservableProperty]
private bool _autoCheckUpdates;
[ObservableProperty]
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
@@ -1380,9 +1479,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _preferencesDescription = string.Empty;
[ObservableProperty]
private string _autoCheckUpdatesLabel = string.Empty;
[ObservableProperty]
private string _updateChannelLabel = string.Empty;
@@ -1520,16 +1616,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
private bool IsBusy => IsCheckingForUpdates || IsDownloading;
partial void OnAutoCheckUpdatesChanged(bool value)
{
if (_isInitializing)
{
return;
}
SaveUpdateSettings();
}
partial void OnSelectedUpdateChannelOptionChanged(SelectionOption? value)
{
if (value is not null &&
@@ -1729,7 +1815,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
var current = _settingsFacade.Update.Get();
_settingsFacade.Update.Save(current with
{
AutoCheckUpdates = AutoCheckUpdates,
IncludePrereleaseUpdates = string.Equals(
SelectedUpdateChannelValue,
UpdateSettingsValues.ChannelPreview,
@@ -1841,7 +1926,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
StatusCardDescription = L("settings.update.status_card_description", "Check for updates and review the latest release information.");
PreferencesHeader = L("settings.update.preferences_header", "Update Preferences");
PreferencesDescription = L("settings.update.preferences_description", "Choose your release channel, download source, behavior, and download speed.");
AutoCheckUpdatesLabel = L("settings.update.auto_check_toggle", "Automatically check for updates on startup");
UpdateChannelLabel = L("settings.update.channel_label", "Update Channel");
UpdateSourceLabel = L("settings.update.source_label", "Download Source");
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
@@ -1870,7 +1954,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
var update = _settingsFacade.Update.Get();
_isInitializing = true;
AutoCheckUpdates = update.AutoCheckUpdates;
SelectedUpdateChannelValue = UpdateSettingsValues.NormalizeChannel(update.UpdateChannel, update.IncludePrereleaseUpdates);
SelectedUpdateSourceValue = UpdateSettingsValues.NormalizeDownloadSource(update.UpdateDownloadSource);
SelectedUpdateModeValue = UpdateSettingsValues.NormalizeMode(update.UpdateMode);

View File

@@ -39,6 +39,9 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private SelectionOption _selectedClockFormat = new("HourMinuteSecond", "Hour:Minute:Second");
[ObservableProperty]
private bool _clockTransparentBackground;
[ObservableProperty]
private SelectionOption _selectedSpacingMode = new("Relaxed", "Relaxed");
@@ -66,6 +69,12 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _clockFormatLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundLabel = string.Empty;
[ObservableProperty]
private string _clockTransparentBackgroundDescription = string.Empty;
[ObservableProperty]
private string _spacingHeader = string.Empty;
@@ -88,6 +97,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
SelectedClockFormat = ClockFormats.FirstOrDefault(option =>
string.Equals(option.Value, clockFormat, StringComparison.OrdinalIgnoreCase))
?? ClockFormats[1];
ClockTransparentBackground = state.ClockTransparentBackground;
var spacingMode = NormalizeSpacingMode(state.SpacingMode);
SelectedSpacingMode = SpacingModes.FirstOrDefault(option =>
@@ -117,6 +127,16 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
Save();
}
partial void OnClockTransparentBackgroundChanged(bool value)
{
if (_isInitializing)
{
return;
}
Save();
}
partial void OnSelectedSpacingModeChanged(SelectionOption value)
{
IsCustomSpacingVisible = string.Equals(value?.Value, "Custom", StringComparison.OrdinalIgnoreCase);
@@ -163,6 +183,7 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
state.EnableDynamicTaskbarActions,
state.TaskbarLayoutMode,
SelectedClockFormat.Value,
ClockTransparentBackground,
NormalizeSpacingMode(SelectedSpacingMode.Value),
Math.Clamp(CustomSpacingPercent, 0, 30)));
}
@@ -194,6 +215,8 @@ public sealed partial class StatusBarSettingsPageViewModel : ViewModelBase
ClockHeader = L("settings.status_bar.clock_header", "Clock Component");
ClockDescription = L("settings.status_bar.clock_description", "Display a clock on the top status bar.");
ClockFormatLabel = L("settings.status_bar.clock_format_label", "Clock format");
ClockTransparentBackgroundLabel = L("settings.status_bar.clock_transparent_background_label", "Transparent background");
ClockTransparentBackgroundDescription = L("settings.status_bar.clock_transparent_background_desc", "Remove the capsule background and keep only the clock text.");
SpacingHeader = L("settings.status_bar.spacing_header", "Component Spacing");
SpacingDescription = L("settings.status_bar.spacing_desc", "Adjust spacing between status bar components.");
CustomSpacingLabel = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");

View File

@@ -82,23 +82,24 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _isImage;
[ObservableProperty]
private bool _isVideo;
[ObservableProperty]
private Bitmap? _previewImage;
[ObservableProperty]
private IBrush? _previewBrush;
// 自定义颜色持久化
[ObservableProperty]
private string _videoModeHintText = string.Empty;
private Color _customColor = Colors.White;
[ObservableProperty]
private IBrush _customColorBrush = new SolidColorBrush(Colors.White);
public void Load()
{
var wallpaper = _settingsFacade.Wallpaper.Get();
WallpaperPath = wallpaper.WallpaperPath ?? string.Empty;
SelectedWallpaperType = WallpaperTypes.FirstOrDefault(t => t.Value == wallpaper.Type) ?? WallpaperTypes[0];
SelectedColor = wallpaper.Color ?? PresetColors[0];
@@ -108,7 +109,14 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SelectedWallpaperPlacement = WallpaperPlacements.FirstOrDefault(option =>
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
?? WallpaperPlacements[0];
// 加载自定义颜色
if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor))
{
CustomColor = customColor;
CustomColorBrush = new SolidColorBrush(customColor);
}
UpdateVisibility();
UpdatePreviewFromCurrentSelection();
}
@@ -124,8 +132,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private void UpdateVisibility()
{
IsImage = SelectedWallpaperType?.Value == "Image";
IsVideo = SelectedWallpaperType?.Value == "Video";
IsImageOrVideo = SelectedWallpaperType?.Value is "Image" or "Video";
IsImageOrVideo = IsImage;
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
}
@@ -135,6 +142,16 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
SaveWallpaper();
}
partial void OnCustomColorChanged(Color value)
{
CustomColorBrush = new SolidColorBrush(value);
// 将自定义颜色应用到壁纸
var colorHex = $"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}";
SelectedColor = colorHex;
if (_isInitializing) return;
SaveWallpaper();
}
public async Task ImportWallpaperAsync(string sourcePath)
{
var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath);
@@ -222,11 +239,13 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
var normalizedPath = SelectedWallpaperType?.Value == "SolidColor" || string.IsNullOrWhiteSpace(WallpaperPath)
? null
: WallpaperPath;
var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}";
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
normalizedPath,
selectedType,
SelectedColor,
selectedPlacement));
selectedPlacement,
customColorHex));
}
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
@@ -246,7 +265,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
return
[
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("Video", L("settings.wallpaper.type.video", "Video")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
];
}
@@ -257,7 +275,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[
"#D8A7B1", "#B6C9BB", "#A2B5BB", "#E6E2D3",
"#B5A397", "#C5C1C0", "#D4BE8D", "#C08261",
"#8E9775", "#9FBAD3", "#E5BAA2", "#4E596F"
"#8E9775", "#9FBAD3", "#E5BAA2"
];
}
@@ -271,7 +289,6 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop.");
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
FilePickerTitle = L("filepicker.title", "Select wallpaper");
VideoModeHintText = L("settings.wallpaper.video_mode", "Video wallpaper uses automatic fill mode.");
}
private string L(string key, string fallback)

View File

@@ -53,7 +53,7 @@
<!-- MD3 Button Styles -->
<Style Selector="Button.component-editor-footer-button">
<Setter Property="CornerRadius" Value="20" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
<Setter Property="Height" Value="40" />
@@ -118,7 +118,7 @@
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="18"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>

View File

@@ -241,6 +241,7 @@ public partial class ComponentEditorWindow : Window
"DataLine" => MaterialIconKind.ChartLine,
"Edit" => MaterialIconKind.Pencil,
"Calculator" => MaterialIconKind.Calculator,
"Storage" => MaterialIconKind.UsbFlashDrive,
"Globe" => MaterialIconKind.Web,
"Play" => MaterialIconKind.Play,
_ => MaterialIconKind.Settings

View File

@@ -17,6 +17,25 @@
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">

View File

@@ -11,13 +11,15 @@ using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
private string? _activeScheduleId;
private bool _suppressEvents;
public ClassScheduleComponentEditor()
: this(null)
@@ -62,10 +64,46 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
DescriptionTextBlock.Text = L(
"schedule.settings.desc",
"Import a ClassIsland CSES schedule file and choose which one to use.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
AddScheduleButton.Content = L("schedule.settings.add", "Add Schedule");
EmptyStateTextBlock.Text = L("schedule.settings.empty", "No imported schedules yet.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = colorSchemeSource;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
@@ -80,11 +118,11 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("schedule.settings.picker_title", "选择 ClassIsland 课表文件"),
Title = L("schedule.settings.picker_title", "Choose ClassIsland schedule file"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES 课表"))
new FilePickerFileType(L("schedule.settings.picker_file_type", "ClassIsland CSES Schedule"))
{
Patterns = ["*.cses", "*.yaml", "*.yml"]
}
@@ -114,7 +152,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
Id = Guid.NewGuid().ToString("N"),
DisplayName = Path.GetFileNameWithoutExtension(importedPath)?.Trim()
?? L("schedule.settings.unnamed", "未命名课表"),
?? L("schedule.settings.unnamed", "Untitled Schedule"),
FilePath = importedPath
});
_activeScheduleId = _importedSchedules[^1].Id;
@@ -178,7 +216,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var title = new TextBlock
{
Text = string.IsNullOrWhiteSpace(item.DisplayName)
? L("schedule.settings.unnamed", "未命名课表")
? L("schedule.settings.unnamed", "Untitled Schedule")
: item.DisplayName,
FontWeight = FontWeight.SemiBold
};
@@ -193,7 +231,7 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
var deleteButton = new Button
{
Content = L("schedule.settings.delete", "删除"),
Content = L("schedule.settings.delete", "Delete"),
Tag = item.Id,
Padding = new Thickness(12, 8),
HorizontalAlignment = HorizontalAlignment.Right

View File

@@ -0,0 +1,42 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.OfficeRecentDocumentsComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="SourcesHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="SourcesDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<CheckBox x:Name="RegistryCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="RecentFoldersCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
<CheckBox x:Name="JumpListsCheckBox"
IsCheckedChanged="OnSourceSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="HintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,87 @@
using System;
using System.Linq;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class OfficeRecentDocumentsComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public OfficeRecentDocumentsComponentEditor()
: this(null)
{
}
public OfficeRecentDocumentsComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
snapshot.OfficeRecentDocumentsEnabledSources,
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? L(
"component.office_recent_documents",
"Recent Documents");
DescriptionTextBlock.Text = L(
"office_recent_documents.settings.desc",
"Choose which Windows and Office sources this widget should scan for recent documents.");
SourcesHeaderTextBlock.Text = L(
"office_recent_documents.settings.sources_title",
"Recent document sources");
SourcesDescriptionTextBlock.Text = L(
"office_recent_documents.settings.sources_desc",
"You can combine multiple sources. Registry selection also keeps the Office interop MRU fallback available.");
RegistryCheckBox.Content = L(
"office_recent_documents.settings.source.registry",
"Office registry MRU");
RecentFoldersCheckBox.Content = L(
"office_recent_documents.settings.source.recent_folders",
"Windows Recent folders");
JumpListsCheckBox.Content = L(
"office_recent_documents.settings.source.jump_lists",
"Windows Jump Lists");
HintTextBlock.Text = L(
"office_recent_documents.settings.hint",
"If you disable all sources, this widget will stay empty until at least one source is enabled again.");
_suppressEvents = true;
RegistryCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
RecentFoldersCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
JumpListsCheckBox.IsChecked = enabledSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
_suppressEvents = false;
}
private void OnSourceSelectionChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var selectedSources = new[]
{
RegistryCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.Registry : null,
RecentFoldersCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.RecentFolders : null,
JumpListsCheckBox.IsChecked == true ? OfficeRecentDocumentSourceTypes.JumpLists : null
}
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Cast<string>()
.ToList();
var snapshot = LoadSnapshot();
snapshot.OfficeRecentDocumentsEnabledSources = selectedSources;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.OfficeRecentDocumentsEnabledSources));
}
}

View File

@@ -0,0 +1,50 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.RemovableStorageComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="10">
<TextBlock x:Name="BehaviorHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="BehaviorTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,67 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class RemovableStorageComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public RemovableStorageComponentEditor()
: this(null)
{
}
public RemovableStorageComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
ApplyState();
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var colorSchemeSource = snapshot.ColorSchemeSource;
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Removable Storage";
DescriptionTextBlock.Text = L(
"removable_storage.settings.desc",
"Show a connected USB drive with quick open and eject actions.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
BehaviorHeaderTextBlock.Text = L("removable_storage.settings.behavior_title", "Behavior");
BehaviorTextBlock.Text = L(
"removable_storage.settings.behavior_desc",
"The widget automatically watches for removable drives and switches to the newest inserted USB drive.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrWhiteSpace(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
}

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:FluentAvalonia.UI.Controls"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
<StackPanel Spacing="16">
@@ -17,6 +18,25 @@
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
Classes="component-editor-section-title" />
<ComboBox x:Name="ColorSchemeComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnColorSchemeSelectionChanged">
<ComboBoxItem x:Name="FollowSystemColorSchemeItem"
Classes="component-editor-select-item"
Tag="follow_system" />
<ComboBoxItem x:Name="UseNativeColorSchemeItem"
Classes="component-editor-select-item"
Tag="native" />
</ComboBox>
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">

View File

@@ -1,6 +1,9 @@
using System;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
@@ -25,23 +28,55 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
var snapshot = LoadSnapshot();
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
var colorSchemeSource = snapshot.ColorSchemeSource;
if (!showDisplayDb && !showDbfs)
{
showDisplayDb = true;
}
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
DescriptionTextBlock.Text = L(
"study.environment.settings.desc",
"Configure the realtime audio level information shown on the right side.");
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "Color Scheme");
FollowSystemColorSchemeItem.Content = L("component.color_scheme.follow_system", "Follow system color scheme");
UseNativeColorSchemeItem.Content = L("component.color_scheme.native", "Use component custom color scheme");
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "Show display dB");
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "Show dBFS");
HintTextBlock.Text = L("study.environment.settings.hint", "At least one display mode must stay enabled.");
_suppressEvents = true;
ColorSchemeComboBox.SelectedItem =
string.IsNullOrEmpty(colorSchemeSource) ||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
? FollowSystemColorSchemeItem
: UseNativeColorSchemeItem;
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
DbfsToggleSwitch.IsChecked = showDbfs;
_suppressEvents = false;
}
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
? tag
: ThemeAppearanceValues.ColorSchemeFollowSystem;
var snapshot = LoadSnapshot();
snapshot.ColorSchemeSource = colorSchemeSource;
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
}
private void OnToggleChanged(object? sender, RoutedEventArgs e)
{
_ = sender;

View File

@@ -0,0 +1,40 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
x:Class="LanMountainDesktop.Views.ComponentEditors.WhiteboardComponentEditor">
<StackPanel Spacing="16">
<Border Classes="component-editor-hero-card"
Padding="24">
<StackPanel Spacing="8">
<TextBlock x:Name="HeadlineTextBlock"
Classes="component-editor-headline"
TextWrapping="Wrap" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
</StackPanel>
</Border>
<Border Classes="component-editor-card"
Padding="20">
<StackPanel Spacing="12">
<TextBlock x:Name="RetentionHeaderTextBlock"
Classes="component-editor-section-title" />
<TextBlock x:Name="RetentionDescriptionTextBlock"
Classes="component-editor-secondary-text"
TextWrapping="Wrap" />
<ComboBox x:Name="RetentionComboBox"
Classes="component-editor-select"
HorizontalAlignment="Stretch"
SelectionChanged="OnRetentionSelectionChanged" />
</StackPanel>
</Border>
<TextBlock x:Name="InstanceHintTextBlock"
Classes="component-editor-secondary-text"
Margin="12,0"
TextWrapping="Wrap" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,106 @@
using System;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class WhiteboardComponentEditor : ComponentEditorViewBase
{
private bool _suppressEvents;
public WhiteboardComponentEditor()
: this(null)
{
}
public WhiteboardComponentEditor(DesktopComponentEditorContext? context)
: base(context)
{
InitializeComponent();
BuildRetentionOptions();
ApplyState();
}
private void BuildRetentionOptions()
{
RetentionComboBox.Items.Clear();
for (var days = WhiteboardNoteRetentionPolicy.MinimumDays; days <= WhiteboardNoteRetentionPolicy.MaximumDays; days++)
{
var item = new ComboBoxItem
{
Tag = days.ToString(),
Content = L(
"whiteboard.settings.retention.option",
"{0} days").Replace("{0}", days.ToString())
};
item.Classes.Add("component-editor-select-item");
RetentionComboBox.Items.Add(item);
}
}
private void ApplyState()
{
var snapshot = LoadSnapshot();
var retentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Blackboard";
DescriptionTextBlock.Text = L(
"whiteboard.settings.desc",
"Each blackboard keeps its own note history and saves it independently.");
RetentionHeaderTextBlock.Text = L(
"whiteboard.settings.retention.title",
"Note retention");
RetentionDescriptionTextBlock.Text = L(
"whiteboard.settings.retention.desc",
"Choose how long this blackboard should keep saved notes before expired data is removed automatically.");
InstanceHintTextBlock.Text = L(
"whiteboard.settings.instance_scope",
"This retention setting is stored per blackboard component instance.");
_suppressEvents = true;
RetentionComboBox.SelectedItem = RetentionComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tag &&
int.TryParse(tag, out var days) &&
days == retentionDays);
_suppressEvents = false;
}
private void OnRetentionSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var snapshot = LoadSnapshot();
snapshot.WhiteboardNoteRetentionDays = GetSelectedRetentionDays();
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.WhiteboardNoteRetentionDays));
}
private int GetSelectedRetentionDays()
{
if (RetentionComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tag &&
int.TryParse(tag, out var days))
{
return NormalizeRetentionDays(days);
}
return WhiteboardNoteRetentionPolicy.DefaultDays;
}
private static int NormalizeRetentionDays(int days)
{
return WhiteboardNoteRetentionPolicy.NormalizeDays(
days <= 0
? WhiteboardNoteRetentionPolicy.DefaultDays
: days);
}
}

View File

@@ -38,8 +38,8 @@
<Grid Grid.Row="1"
ColumnDefinitions="240,*"
ColumnSpacing="12">
<Border Classes="glass-panel"
CornerRadius="24"
<Border Classes="surface-translucent-panel"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10">
<ListBox x:Name="CategoryListBox"
Background="Transparent"
@@ -50,7 +50,7 @@
<DataTemplate x:DataType="vm:ComponentLibraryCategoryViewModel">
<Border Padding="10"
Margin="0,0,0,6"
CornerRadius="14"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Background="{DynamicResource AdaptiveNavItemBackgroundBrush}">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="8">
@@ -70,8 +70,8 @@
</Border>
<Border Grid.Column="1"
Classes="glass-strong"
CornerRadius="24"
Classes="surface-translucent-strong"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10">
<ScrollViewer VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
@@ -87,14 +87,14 @@
<Border Width="240"
Height="220"
Margin="6"
CornerRadius="18"
CornerRadius="{DynamicResource DesignCornerRadiusLg}"
Padding="10"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1">
<Grid RowDefinitions="*,Auto,Auto"
RowSpacing="8">
<Border CornerRadius="12"
<Border CornerRadius="{DynamicResource DesignCornerRadiusXs}"
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
BorderThickness="1"
BorderBrush="{DynamicResource AdaptiveGlassPanelBorderBrush}"

View File

@@ -326,7 +326,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(42 * scale, 16, 56));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(42 * scale, 16, 56);
RootBorder.Padding = new Thickness(Math.Clamp(14 * scale, 8, 26));
ApplyModeVisualIfNeeded();
}

View File

@@ -13,6 +13,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -50,6 +51,7 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private bool _autoRefreshEnabled = true;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private string? _componentColorScheme;
private sealed record HotItemVisual(
Border Host,
@@ -180,17 +182,25 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private void ApplyNightModeVisual()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var brandColor = useMonetColor
? (_isNightVisual ? Color.Parse("#9FABFF") : Color.Parse("#4F6BEB"))
: (_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
BrandTextBlock.Foreground = new SolidColorBrush(brandColor);
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.IndexTextBlock.Foreground = new SolidColorBrush(brandColor);
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
@@ -371,12 +381,12 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
@@ -488,10 +498,11 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
_componentColorScheme = snapshot.ColorSchemeSource;
}
catch
{
// Keep fallback defaults.
_componentColorScheme = null;
}
_autoRefreshEnabled = enabled;

View File

@@ -386,12 +386,12 @@ public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWid
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * softScale, 16, 52);
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));

View File

@@ -79,11 +79,11 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget,
{
_currentCellSize = Math.Max(1, cellSize);
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.34, 12, 28);
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.20, 8, 18));
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
WebViewHostBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.24, 10, 22);
AddressBarBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.22, 10, 20);
AddressBarBorder.Padding = new Thickness(8, 6);
if (RootBorder.Child is Grid rootGrid)

View File

@@ -43,7 +43,7 @@
<Grid Grid.Row="1">
<ScrollViewer x:Name="ContentScrollViewer"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Disabled">
VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="CourseListPanel" />
</ScrollViewer>

View File

@@ -1,9 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
@@ -25,9 +26,17 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(4)
Interval = TimeSpan.FromMinutes(1)
};
private int _lastCurrentCourseIndex = -1;
private DateOnly _lastRefreshDate = DateOnly.MinValue;
private bool _isUserScrolling;
private Vector _lastScrollOffset;
private Point _dragStartPoint;
private Point _lastDragPoint;
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
private readonly LocalizationService _localizationService = new();
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
@@ -39,6 +48,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
private string _placementId = string.Empty;
private string? _componentColorScheme;
public ClassScheduleWidget()
{
@@ -50,6 +60,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ContentScrollViewer.PointerPressed += OnScrollViewerPointerPressed;
ContentScrollViewer.PointerMoved += OnScrollViewerPointerMoved;
ContentScrollViewer.PointerReleased += OnScrollViewerPointerReleased;
ApplyCellSize(_currentCellSize);
RefreshSchedule();
}
@@ -107,9 +121,109 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
RefreshSchedule();
}
private void OnScrollViewerPointerPressed(object? sender, PointerPressedEventArgs e)
{
_isUserScrolling = true;
_dragStartPoint = e.GetCurrentPoint(ContentScrollViewer).Position;
_lastDragPoint = _dragStartPoint;
_lastScrollOffset = ContentScrollViewer.Offset;
}
private void OnScrollViewerPointerMoved(object? sender, PointerEventArgs e)
{
if (!_isUserScrolling)
{
return;
}
var currentPoint = e.GetCurrentPoint(ContentScrollViewer);
var currentPosition = currentPoint.Position;
var deltaY = currentPosition.Y - _lastDragPoint.Y;
var newOffset = _lastScrollOffset;
newOffset = newOffset.WithY(newOffset.Y - deltaY);
ContentScrollViewer.Offset = newOffset;
_lastDragPoint = currentPosition;
}
private void OnScrollViewerPointerReleased(object? sender, PointerReleasedEventArgs e)
{
_lastScrollOffset = ContentScrollViewer.Offset;
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
{
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
var currentDate = DateOnly.FromDateTime(now);
var previousCourseIndex = _lastCurrentCourseIndex;
RefreshSchedule();
var newCurrentCourseIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = newCurrentCourseIndex;
if (previousCourseIndex != newCurrentCourseIndex && newCurrentCourseIndex >= 0)
{
if (_isUserScrolling)
{
_isUserScrolling = false;
}
ScrollToCurrentCourse(newCurrentCourseIndex);
}
if (_lastRefreshDate != currentDate && currentDate > _lastRefreshDate)
{
_lastRefreshDate = currentDate;
}
}
private int FindCurrentCourseIndex()
{
for (var i = 0; i < _courseItems.Count; i++)
{
if (_courseItems[i].IsCurrent)
{
return i;
}
}
return -1;
}
private void ScrollToCurrentCourse(int courseIndex)
{
if (courseIndex < 0 || courseIndex >= _courseItems.Count)
{
return;
}
// 确保在UI线程执行
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
if (courseIndex >= CourseListPanel.Children.Count)
{
return;
}
var targetChild = CourseListPanel.Children[courseIndex];
if (targetChild == null || !targetChild.IsArrangeValid)
{
return;
}
var bounds = targetChild.Bounds;
var scrollViewerHeight = ContentScrollViewer.Bounds.Height;
var contentHeight = CourseListPanel.Bounds.Height;
// 计算滚动位置,使当前课程居中显示
var targetOffset = bounds.Position.Y - (scrollViewerHeight / 2) + (bounds.Height / 2);
// 确保不超出边界
targetOffset = Math.Max(0, Math.Min(targetOffset, contentHeight - scrollViewerHeight));
ContentScrollViewer.Offset = new Vector(0, targetOffset);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
public void RefreshFromSettings()
@@ -134,45 +248,85 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
_componentId,
_placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
_componentColorScheme = componentSettings.ColorSchemeSource;
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
UpdateHeader(now);
var today = DateOnly.FromDateTime(now);
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
var readResult = _scheduleService.Load(importedSchedulePath);
if (!readResult.Success || readResult.Snapshot is null)
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
RenderScheduleItems();
return;
}
var snapshot = readResult.Snapshot;
var today = DateOnly.FromDateTime(now);
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
{
_courseItems = Array.Empty<CourseItemViewModel>();
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
RenderScheduleItems();
return;
var nextDay = today.AddDays(1);
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan))
{
resolvedClassPlan = nextDayClassPlan;
today = nextDay;
}
else
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
RenderScheduleItems();
return;
}
}
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
{
_courseItems = Array.Empty<CourseItemViewModel>();
UpdateHeader(now);
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
RenderScheduleItems();
return;
}
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, now);
var adjustedNow = today == DateOnly.FromDateTime(now) ? now : DateTime.Today.AddHours(8);
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, adjustedNow);
if (_courseItems.Count == 0)
{
var nextDay = today.AddDays(1);
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan) &&
snapshot.TimeLayouts.TryGetValue(nextDayClassPlan.ClassPlan.TimeLayoutId, out var nextLayout))
{
today = nextDay;
adjustedNow = DateTime.Today.AddHours(8);
_courseItems = BuildCourseItemViewModels(snapshot, nextDayClassPlan.ClassPlan, nextLayout, adjustedNow);
}
}
UpdateHeader(today.ToDateTime(TimeOnly.MinValue));
if (_courseItems.Count == 0)
{
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
}
else
{
var currentIndex = FindCurrentCourseIndex();
_lastCurrentCourseIndex = currentIndex;
HideStatus();
// 初始化时自动跳转到当前课程
if (currentIndex >= 0)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
{
ScrollToCurrentCourse(currentIndex);
}, Avalonia.Threading.DispatcherPriority.Loaded);
}
}
RenderScheduleItems();
@@ -336,6 +490,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return;
}
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var scale = ResolveScale();
var bulletSize = Math.Clamp(10 * scale, 5, 12);
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
@@ -350,13 +508,14 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
var currentBrush = CreateBrush("#FF4D5A");
var currentBrush = useMonetColor
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF4D5A");
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
for (var i = 0; i < visibleItems.Count; i++)
for (var i = 0; i < _courseItems.Count; i++)
{
var item = visibleItems[i];
var item = _courseItems[i];
var bulletBrush = item.IsCurrent ? currentBrush : normalBulletBrush;
var bullet = new Border
@@ -438,21 +597,33 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private void ApplyAdaptiveLayout()
{
if (Bounds.Width <= 0 || Bounds.Height <= 0)
{
return;
}
var scale = ResolveScale();
_isNightVisual = ResolveNightMode();
var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44);
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var slashBrush = useMonetColor
? CreateBrush("#FF4FC3F7")
: CreateBrush("#FF3250");
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(_currentCellSize * 0.45, 24, 44);
RootBorder.Background = _isNightVisual
? CreateGradientBrush("#171A21", "#0C0E14")
: CreateGradientBrush("#F7F8FC", "#ECEFF6");
RootBorder.BorderBrush = CreateBrush(_isNightVisual ? "#24FFFFFF" : "#15000000");
var rootPadding = new Thickness(
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(14 * scale, 9, 20),
Math.Clamp(16 * scale, 10, 24),
Math.Clamp(14 * scale, 8, 20));
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 9, 20),
ComponentChromeCornerRadiusHelper.SafeValue(16 * scale, 10, 24),
ComponentChromeCornerRadiusHelper.SafeValue(14 * scale, 8, 20));
RootBorder.Padding = rootPadding;
LayoutGrid.RowSpacing = Math.Clamp(14 * scale, 6, 20);
@@ -468,7 +639,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
SlashTextBlock.Foreground = CreateBrush("#FF3250");
SlashTextBlock.Foreground = slashBrush;
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -8,7 +8,7 @@
x:Class="LanMountainDesktop.Views.Components.ClockWidget">
<Border x:Name="RootBorder"
Classes="glass-panel"
Classes="surface-translucent-panel"
Padding="0"
CornerRadius="24">
<StackPanel Orientation="Horizontal"

View File

@@ -23,6 +23,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
private TimeZoneService? _timeZoneService;
private ClockDisplayFormat _displayFormat = ClockDisplayFormat.HourMinuteSecond;
private bool _transparentBackground;
private double _lastAppliedCellSize = 100;
public ClockWidget()
{
@@ -44,11 +46,32 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
}
}
public bool TransparentBackground
{
get => _transparentBackground;
set
{
if (_transparentBackground == value)
{
return;
}
_transparentBackground = value;
ApplyChrome();
ApplyCellSize(_lastAppliedCellSize);
}
}
public void SetDisplayFormat(ClockDisplayFormat format)
{
DisplayFormat = format;
}
public void SetTransparentBackground(bool transparentBackground)
{
TransparentBackground = transparentBackground;
}
public void SetTimeZoneService(TimeZoneService timeZoneService)
{
ClearTimeZoneService();
@@ -101,6 +124,8 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
public void ApplyCellSize(double cellSize)
{
_lastAppliedCellSize = cellSize;
// --- Class Island “满盈”风格算法 ---
// 1. 计算组件高度:保持与任务栏核心比例一致 (0.74x)
@@ -129,8 +154,39 @@ public partial class ClockWidget : UserControl, IDesktopComponentWidget, ITimeZo
{
panel.Spacing = Math.Clamp(cellSize * 0.06, 2, 8);
}
if (_transparentBackground)
{
RootBorder.MinWidth = 0;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.06, 4, 10), 0);
return;
}
// 确保清除可能存在的固定 Padding由代码控制“紧密感”
RootBorder.MinWidth = cellSize * 2.2;
RootBorder.Padding = new Thickness(Math.Clamp(cellSize * 0.15, 12, 24), 0);
}
private void ApplyChrome()
{
if (_transparentBackground)
{
RootBorder.Classes.Remove("glass-panel");
RootBorder.Background = Brushes.Transparent;
RootBorder.BorderBrush = Brushes.Transparent;
RootBorder.BorderThickness = new Thickness(0);
RootBorder.BoxShadow = default;
return;
}
if (!RootBorder.Classes.Contains("glass-panel"))
{
RootBorder.Classes.Add("glass-panel");
}
RootBorder.ClearValue(Border.BackgroundProperty);
RootBorder.ClearValue(Border.BorderBrushProperty);
RootBorder.ClearValue(Border.BorderThicknessProperty);
RootBorder.ClearValue(Border.BoxShadowProperty);
}
}

View File

@@ -480,7 +480,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
{
Width = 160,
Height = 90,
CornerRadius = new CornerRadius(16),
CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16, 8, 22),
ClipToBounds = true,
Background = new SolidColorBrush(Color.Parse("#E6E6E6")),
IsHitTestVisible = false
@@ -545,10 +545,10 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
var scale = ResolveScale();
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
RootBorder.Padding = new Thickness(0);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
CardBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
CardBorder.Padding = new Thickness(
Math.Clamp(16 * scale, 8, 24),
Math.Clamp(14 * scale, 7, 22),
@@ -573,8 +573,8 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
News1ImageHost.Height = imageHeight;
News2ImageHost.Width = imageWidth;
News2ImageHost.Height = imageHeight;
News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
News1ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
News2ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
var columnGap = Math.Clamp(12 * scale, 6, 18);
NewsItem1Grid.ColumnSpacing = columnGap;
@@ -611,7 +611,7 @@ public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget,
row.ImageHost.Width = imageWidth;
row.ImageHost.Height = imageHeight;
row.ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22));
row.ImageHost.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(16 * scale, 8, 22);
row.TitleTextBlock.MaxWidth = availableTextWidth;
row.TitleTextBlock.FontSize = Math.Clamp(19 * scale, 10, 25);

View File

@@ -0,0 +1,72 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Host.Abstractions;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
internal static class ComponentChromeCornerRadiusHelper
{
public static double ResolveScale(ComponentChromeContext? chromeContext = null)
{
if (chromeContext is not null)
{
return Math.Max(0.1d, chromeContext.GlobalCornerRadiusScale);
}
return Math.Max(0.1d, HostAppearanceThemeProvider.GetOrCreate().GetCurrent().GlobalCornerRadiusScale);
}
public static CornerRadius Scale(double baseRadius, double min, double max, ComponentChromeContext? chromeContext = null)
{
var scale = ResolveScale(chromeContext);
return new CornerRadius(Math.Clamp(baseRadius * scale, min * scale, max * scale));
}
public static void Apply(CornerRadius radius, params Border?[] chromeLayers)
{
foreach (var chromeLayer in chromeLayers)
{
if (chromeLayer is not null)
{
chromeLayer.CornerRadius = radius;
}
}
}
public static CornerRadius ResolveToken(string key, double fallback)
{
var application = Application.Current;
return application is not null &&
application.Resources.TryGetResource(key, application.ActualThemeVariant, out var resource) &&
resource is CornerRadius radius
? radius
: new CornerRadius(fallback);
}
public static double ScaleValue(double value, ComponentChromeContext? chromeContext = null)
{
return value * ResolveScale(chromeContext);
}
public static double ResolveContentSafetyScale(
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
{
var scale = ResolveScale(chromeContext);
var normalizedResponsiveness = Math.Clamp(responsiveness, 0d, 1d);
return 1d + ((scale - 1d) * normalizedResponsiveness);
}
public static double SafeValue(
double baseValue,
double min,
double max,
ComponentChromeContext? chromeContext = null,
double responsiveness = 0.45d)
{
var safetyScale = ResolveContentSafetyScale(chromeContext, responsiveness);
return Math.Clamp(baseValue * safetyScale, min * safetyScale, max * safetyScale);
}
}

View File

@@ -101,7 +101,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
_currentCellSize = Math.Max(1, cellSize);
var scale = ResolveScale();
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52));
RootBorder.CornerRadius = ComponentChromeCornerRadiusHelper.Scale(34 * scale, 16, 52);
InfoPanel.Padding = new Thickness(
Math.Clamp(18 * scale, 10, 28),

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