mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb86ca10e7 | ||
|
|
b3a74aa072 | ||
|
|
b436bfa884 | ||
|
|
081abeb688 | ||
|
|
594a62132f | ||
|
|
15e589aedd | ||
|
|
ac4617f5cf | ||
|
|
0645598753 | ||
|
|
dadd132b4f | ||
|
|
298defb829 |
16
.codex/environments/environment.toml
Normal file
16
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "LanMountainDesktop"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = "dotnet run --project 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop\\LanMountainDesktop.csproj"
|
||||
|
||||
[[actions]]
|
||||
name = "构建"
|
||||
icon = "tool"
|
||||
command = "dotnet build 'C:\\Users\\USER693091\\Documents\\GitHub\\LanMountainDesktop\\LanMountainDesktop.slnx"
|
||||
@@ -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 控件残留
|
||||
@@ -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** 壁纸设置功能正常工作(仅支持图片和纯色)
|
||||
@@ -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
|
||||
- 修改填充方式可见性绑定
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] 无视频预览 Border(IsVisible="{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
|
||||
@@ -4,6 +4,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,15 @@ public partial class App : Application
|
||||
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;
|
||||
@@ -501,6 +510,8 @@ public partial class App : Application
|
||||
|
||||
if (languageChanged)
|
||||
{
|
||||
// 清除本地化缓存,强制重新加载语言文件
|
||||
_localizationService.ClearCache();
|
||||
ApplyCurrentCultureFromSettings();
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
|
||||
326
LanMountainDesktop/Assets/Documents/Privacy.md
Normal file
326
LanMountainDesktop/Assets/Documents/Privacy.md
Normal 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!**
|
||||
|
||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
||||
@@ -41,4 +41,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
|
||||
}
|
||||
|
||||
@@ -336,6 +336,15 @@ public sealed class ComponentRegistry
|
||||
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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -21,6 +21,9 @@
|
||||
<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>
|
||||
@@ -52,15 +55,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')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Mac" Version="3.1.3.1" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('OSX')))
 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" />
|
||||
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
@@ -398,6 +410,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.",
|
||||
@@ -554,6 +567,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",
|
||||
@@ -582,6 +596,19 @@
|
||||
"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",
|
||||
@@ -783,6 +810,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",
|
||||
@@ -887,5 +928,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?"
|
||||
}
|
||||
|
||||
@@ -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": "设置",
|
||||
@@ -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": "位置来源",
|
||||
@@ -393,7 +399,6 @@
|
||||
"settings.footer": "LanMountainDesktop 设置",
|
||||
"filepicker.title": "选择壁纸",
|
||||
"filepicker.image_files": "图片文件",
|
||||
"filepicker.video_files": "视频文件",
|
||||
"common.day": "日间",
|
||||
"common.night": "夜间",
|
||||
"common.back": "返回",
|
||||
@@ -403,6 +408,7 @@
|
||||
"common.monet": "莫奈",
|
||||
"desktop.page_index_format": "桌面 {0}",
|
||||
"launcher.title": "应用启动台",
|
||||
"launcher.folder": "文件夹",
|
||||
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
|
||||
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
|
||||
"launcher.empty": "未找到开始菜单条目。",
|
||||
@@ -559,6 +565,7 @@
|
||||
"component_category.info": "信息推荐",
|
||||
"component_category.calculator": "计算器",
|
||||
"component_category.study": "自习",
|
||||
"component_category.file": "文件",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
"component.lunar_calendar": "农历",
|
||||
@@ -587,6 +594,18 @@
|
||||
"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": "自习时段控制",
|
||||
@@ -783,6 +802,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",
|
||||
@@ -892,5 +926,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是否立即重启?"
|
||||
}
|
||||
|
||||
@@ -62,8 +62,6 @@ 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; }
|
||||
@@ -72,6 +70,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
public string? PersistentUserId { get; set; }
|
||||
|
||||
public string UpdateChannel { get; set; } = "stable";
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
@@ -101,6 +101,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;
|
||||
|
||||
@@ -58,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();
|
||||
@@ -91,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;
|
||||
}
|
||||
|
||||
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal file
53
LanMountainDesktop/Models/OfficeRecentDocumentSourceTypes.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal file
23
LanMountainDesktop/Models/WhiteboardNoteRetentionPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal file
60
LanMountainDesktop/Models/WhiteboardNoteSnapshot.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ sealed class Program
|
||||
|
||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||
ScheduleWhiteboardNoteStartupCleanup();
|
||||
|
||||
try
|
||||
{
|
||||
@@ -88,6 +89,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,7 +15,6 @@ using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LibVLCSharp.Shared;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -89,11 +88,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 +108,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();
|
||||
@@ -477,7 +402,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;
|
||||
@@ -490,14 +414,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,
|
||||
@@ -886,7 +808,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 },
|
||||
_ => []
|
||||
};
|
||||
@@ -920,16 +841,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) &&
|
||||
@@ -960,16 +871,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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class DeviceIdService
|
||||
{
|
||||
private static DeviceIdService? _instance;
|
||||
private string? _deviceId;
|
||||
private string? _persistentUserId; // 持久化的用户ID,用于关联设备
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private bool _isInitialized;
|
||||
|
||||
@@ -43,6 +44,19 @@ public sealed class DeviceIdService
|
||||
}
|
||||
}
|
||||
|
||||
// 持久化的用户ID,用于跨设备关联用户
|
||||
public string PersistentUserId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_persistentUserId is null)
|
||||
{
|
||||
throw new InvalidOperationException("PersistentUserId not initialized");
|
||||
}
|
||||
return _persistentUserId;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDeviceId()
|
||||
{
|
||||
if (_isInitialized)
|
||||
@@ -56,13 +70,22 @@ public sealed class DeviceIdService
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
// 初始化或生成持久化用户ID(只生成一次,永不改变)
|
||||
if (string.IsNullOrEmpty(snapshot.PersistentUserId))
|
||||
{
|
||||
snapshot.PersistentUserId = GeneratePersistentUserId();
|
||||
AppLogger.Info("DeviceId", $"Generated new persistent user ID: {snapshot.PersistentUserId}");
|
||||
}
|
||||
_persistentUserId = snapshot.PersistentUserId;
|
||||
|
||||
// 初始化或生成设备ID(可以刷新)
|
||||
if (string.IsNullOrEmpty(snapshot.DeviceId))
|
||||
{
|
||||
snapshot.DeviceId = GenerateDeviceId();
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
|
||||
changedKeys: [nameof(AppSettingsSnapshot.DeviceId), nameof(AppSettingsSnapshot.PersistentUserId)]);
|
||||
_deviceId = snapshot.DeviceId;
|
||||
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
|
||||
}
|
||||
@@ -75,6 +98,7 @@ public sealed class DeviceIdService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_deviceId = GenerateDeviceId();
|
||||
_persistentUserId = GeneratePersistentUserId();
|
||||
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
|
||||
}
|
||||
}
|
||||
@@ -87,6 +111,15 @@ public sealed class DeviceIdService
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||
return Convert.ToHexString(hash)[..32].ToLower();
|
||||
}
|
||||
|
||||
private static string GeneratePersistentUserId()
|
||||
{
|
||||
// 生成一个永久性的用户ID,基于机器名和用户名的哈希
|
||||
var userInfo = $"{Environment.MachineName}|{Environment.UserName}|LanMountainDesktop";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(userInfo));
|
||||
return Convert.ToHexString(hash)[..32].ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
@@ -140,9 +173,18 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
// 发送PostHog标准的$pageview事件用于统计日活(始终发送,不受开关影响)
|
||||
CaptureEvent("$pageview", new Dictionary<string, object>
|
||||
{
|
||||
{ "$current_url", "app://main" },
|
||||
{ "$title", "LanMountainDesktop" }
|
||||
});
|
||||
|
||||
// 发送应用启动事件(始终发送,用于统计用户数量)
|
||||
CaptureEvent("app_online", new Dictionary<string, object>
|
||||
{
|
||||
{ "event_type", "app_start" }
|
||||
{ "event_type", "app_start" },
|
||||
{ "analytics_enabled", _isEnabled }
|
||||
});
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
|
||||
@@ -382,10 +424,22 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
|
||||
try
|
||||
{
|
||||
// 基础事件($pageview, app_online, app_shutdown等)始终发送,用于统计用户数量
|
||||
bool isBasicEvent = eventName.StartsWith("$") ||
|
||||
eventName == "app_online" ||
|
||||
eventName == "app_shutdown" ||
|
||||
eventName == "$identify";
|
||||
|
||||
// 非基础事件只有在启用时才发送
|
||||
if (!isBasicEvent && !_isEnabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var eventData = new UserBehaviorEvent
|
||||
{
|
||||
Event = eventName,
|
||||
DistinctId = _deviceIdService.DeviceId,
|
||||
DistinctId = _deviceIdService.PersistentUserId, // 使用持久化用户ID
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Properties = properties ?? new Dictionary<string, object>(),
|
||||
IncludeDetailedData = _isEnabled
|
||||
@@ -542,21 +596,29 @@ public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
{
|
||||
var userProperties = new Dictionary<string, object>
|
||||
{
|
||||
{ "$device_id", distinctId },
|
||||
{ "$app_version", GetAppVersion() },
|
||||
{ "$os", GetOsName() },
|
||||
{ "$os_version", GetOsVersion() }
|
||||
{ "$os_version", GetOsVersion() },
|
||||
{ "$device_type", GetDeviceModel() },
|
||||
{ "$device_id", _deviceIdService.DeviceId } // 当前设备ID
|
||||
};
|
||||
|
||||
// PostHog正确的$identify格式
|
||||
// 使用PersistentUserId作为distinct_id,确保设备ID刷新后仍能关联到同一用户
|
||||
var requestBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "api_key", PostHogApiKey },
|
||||
{ "event", "$identify" },
|
||||
{ "distinct_id", _deviceIdService.PersistentUserId }, // 使用持久化用户ID
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "properties", new Dictionary<string, object>
|
||||
{
|
||||
{ "distinct_id", distinctId },
|
||||
{ "$set", userProperties }
|
||||
{ "$set", userProperties },
|
||||
{ "$set_once", new Dictionary<string, object>
|
||||
{
|
||||
{ "first_app_open", DateTimeOffset.UtcNow.ToString("o") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -796,6 +858,9 @@ public sealed class CrashReportService
|
||||
});
|
||||
|
||||
ConfigureCrashReportingScope();
|
||||
|
||||
// 显式开始会话跟踪
|
||||
SentrySdk.StartSession();
|
||||
|
||||
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
|
||||
|
||||
@@ -849,7 +914,10 @@ public sealed class CrashReportService
|
||||
{
|
||||
if (_isEnabled && _isInitialized)
|
||||
{
|
||||
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
|
||||
// 结束Sentry会话
|
||||
SentrySdk.EndSession();
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||
AppLogger.Info("CrashReport", $"Shutdown event sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
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.Json;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
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);
|
||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null);
|
||||
void OpenDocument(string filePath);
|
||||
}
|
||||
|
||||
@@ -24,78 +31,67 @@ public sealed class OfficeRecentDocument
|
||||
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)
|
||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20, IReadOnlyCollection<string>? enabledSources = null)
|
||||
{
|
||||
var documents = new List<OfficeRecentDocument>();
|
||||
var recentPaths = GetRecentFolders();
|
||||
var normalizedSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||
enabledSources,
|
||||
useDefaultWhenEmpty: enabledSources is null);
|
||||
|
||||
foreach (var recentPath in recentPaths)
|
||||
if (!OperatingSystem.IsWindows() || normalizedSources.Count == 0)
|
||||
{
|
||||
if (!Directory.Exists(recentPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return documents;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
||||
foreach (var lnkPath in files)
|
||||
{
|
||||
var targetPath = GetShortcutTarget(lnkPath);
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var useRegistry = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.Registry, StringComparer.OrdinalIgnoreCase);
|
||||
var useRecentFolders = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.RecentFolders, StringComparer.OrdinalIgnoreCase);
|
||||
var useJumpLists = normalizedSources.Contains(OfficeRecentDocumentSourceTypes.JumpLists, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
|
||||
if (!IsOfficeFile(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (useRegistry)
|
||||
{
|
||||
TryGetFromRegistry(documents);
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (useRecentFolders)
|
||||
{
|
||||
TryGetFromRecentFolders(documents);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(targetPath);
|
||||
var doc = new OfficeRecentDocument
|
||||
{
|
||||
FileName = Path.GetFileNameWithoutExtension(targetPath),
|
||||
FilePath = targetPath,
|
||||
Extension = extension,
|
||||
LastModifiedTime = fileInfo.LastWriteTime,
|
||||
FileSizeBytes = fileInfo.Length,
|
||||
IconGlyph = GetIconGlyph(extension)
|
||||
};
|
||||
if (useJumpLists)
|
||||
{
|
||||
TryGetFromJumpLists(documents);
|
||||
}
|
||||
|
||||
if (!documents.Any(d => d.FilePath == targetPath))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
if (useRegistry && documents.Count < maxCount)
|
||||
{
|
||||
TryGetFromMudToolsInterop(documents);
|
||||
}
|
||||
|
||||
return documents
|
||||
.OrderByDescending(d => d.LastModifiedTime)
|
||||
.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();
|
||||
}
|
||||
@@ -109,30 +105,587 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetRecentFolders()
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static void RunOnStaThread(Action action)
|
||||
{
|
||||
var folders = new List<string>();
|
||||
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);
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
|
||||
return folders;
|
||||
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) ||
|
||||
ExcelExtensions.Contains(extension) ||
|
||||
PowerPointExtensions.Contains(extension);
|
||||
return OfficeExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
ExcelExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
|
||||
PowerPointExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GetIconGlyph(string extension)
|
||||
@@ -150,4 +703,40 @@ public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal file
310
LanMountainDesktop/Services/RemovableStorageService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,11 @@ 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,
|
||||
@@ -30,6 +29,7 @@ public sealed record StatusBarSettingsState(
|
||||
bool EnableDynamicTaskbarActions,
|
||||
string TaskbarLayoutMode,
|
||||
string ClockDisplayFormat,
|
||||
bool ClockTransparentBackground,
|
||||
string SpacingMode,
|
||||
int CustomSpacingPercent);
|
||||
public sealed record WeatherSettingsState(
|
||||
@@ -48,7 +48,6 @@ public sealed record PrivacySettingsState(
|
||||
bool UploadAnonymousCrashData,
|
||||
bool UploadAnonymousUsageData);
|
||||
public sealed record UpdateSettingsState(
|
||||
bool AutoCheckUpdates,
|
||||
bool IncludePrereleaseUpdates,
|
||||
string UpdateChannel,
|
||||
string UpdateMode,
|
||||
|
||||
@@ -147,11 +147,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 +175,6 @@ internal sealed class WallpaperMediaService : IWallpaperMediaService
|
||||
return WallpaperMediaType.Image;
|
||||
}
|
||||
|
||||
if (VideoExtensions.Contains(extension))
|
||||
{
|
||||
return WallpaperMediaType.Video;
|
||||
}
|
||||
|
||||
return WallpaperMediaType.None;
|
||||
}
|
||||
|
||||
@@ -371,6 +361,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
snapshot.EnableDynamicTaskbarActions,
|
||||
snapshot.TaskbarLayoutMode,
|
||||
snapshot.ClockDisplayFormat,
|
||||
snapshot.StatusBarClockTransparentBackground,
|
||||
snapshot.StatusBarSpacingMode,
|
||||
snapshot.StatusBarCustomSpacingPercent);
|
||||
}
|
||||
@@ -383,6 +374,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 +387,7 @@ internal sealed class StatusBarSettingsService : IStatusBarSettingsService
|
||||
nameof(AppSettingsSnapshot.EnableDynamicTaskbarActions),
|
||||
nameof(AppSettingsSnapshot.TaskbarLayoutMode),
|
||||
nameof(AppSettingsSnapshot.ClockDisplayFormat),
|
||||
nameof(AppSettingsSnapshot.StatusBarClockTransparentBackground),
|
||||
nameof(AppSettingsSnapshot.StatusBarSpacingMode),
|
||||
nameof(AppSettingsSnapshot.StatusBarCustomSpacingPercent)
|
||||
]);
|
||||
@@ -638,7 +631,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),
|
||||
@@ -656,7 +648,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,
|
||||
@@ -682,7 +673,7 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
|
||||
snapshot,
|
||||
changedKeys:
|
||||
[
|
||||
nameof(AppSettingsSnapshot.AutoCheckUpdates),
|
||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
||||
nameof(AppSettingsSnapshot.IncludePrereleaseUpdates),
|
||||
nameof(AppSettingsSnapshot.UpdateChannel),
|
||||
nameof(AppSettingsSnapshot.UpdateMode),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal file
338
LanMountainDesktop/Services/WhiteboardNotePersistenceService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -165,7 +165,13 @@
|
||||
<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" />
|
||||
@@ -174,7 +180,7 @@
|
||||
<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" />
|
||||
@@ -183,7 +189,7 @@
|
||||
<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" />
|
||||
@@ -197,7 +203,7 @@
|
||||
</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" />
|
||||
@@ -205,11 +211,18 @@
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
92
LanMountainDesktop/ViewModels/PrivacyPolicyViewModel.cs
Normal file
92
LanMountainDesktop/ViewModels/PrivacyPolicyViewModel.cs
Normal 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);
|
||||
}
|
||||
@@ -15,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;
|
||||
@@ -59,6 +61,12 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
||||
[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();
|
||||
@@ -130,6 +138,25 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
||||
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)
|
||||
|
||||
@@ -268,12 +268,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)));
|
||||
@@ -1329,9 +1334,6 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
|
||||
LoadStateFromSettings();
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
private bool _autoCheckUpdates;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _selectedUpdateChannelValue = UpdateSettingsValues.ChannelStable;
|
||||
|
||||
@@ -1380,9 +1382,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 +1519,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 +1718,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 +1829,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 +1857,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);
|
||||
|
||||
@@ -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 (%)");
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,14 +22,17 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -68,40 +68,37 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
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;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = true;
|
||||
}
|
||||
|
||||
ColorSchemeComboBox.SelectedItem =
|
||||
string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
string.Equals(colorSchemeSource, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase)
|
||||
? FollowSystemColorSchemeItem
|
||||
: UseNativeColorSchemeItem;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? tag
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
@@ -121,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"]
|
||||
}
|
||||
@@ -155,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;
|
||||
@@ -219,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
|
||||
};
|
||||
@@ -234,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
|
||||
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||
<StackPanel Spacing="16">
|
||||
<Border Classes="component-editor-hero_card"
|
||||
<Border Classes="component-editor-hero-card"
|
||||
Padding="24">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="HeadlineTextBlock"
|
||||
@@ -23,14 +23,17 @@
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
<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>
|
||||
|
||||
@@ -44,7 +47,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor_card"
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
@@ -34,43 +36,40 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
||||
}
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
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;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = 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 OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
private void OnColorSchemeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
var colorSchemeSource = ColorSchemeComboBox.SelectedItem is ComboBoxItem item && item.Tag is string tag
|
||||
? tag
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="240,*"
|
||||
ColumnSpacing="12">
|
||||
<Border Classes="glass-panel"
|
||||
<Border Classes="surface-translucent-panel"
|
||||
CornerRadius="24"
|
||||
Padding="10">
|
||||
<ListBox x:Name="CategoryListBox"
|
||||
@@ -70,7 +70,7 @@
|
||||
</Border>
|
||||
|
||||
<Border Grid.Column="1"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="24"
|
||||
Padding="10">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<Grid Grid.Row="1">
|
||||
<ScrollViewer x:Name="ContentScrollViewer"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="CourseListPanel" />
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -198,12 +198,32 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return;
|
||||
}
|
||||
|
||||
if (courseIndex < CourseListPanel.Children.Count)
|
||||
// 确保在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;
|
||||
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y);
|
||||
}
|
||||
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()
|
||||
@@ -298,6 +318,15 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
var currentIndex = FindCurrentCourseIndex();
|
||||
_lastCurrentCourseIndex = currentIndex;
|
||||
HideStatus();
|
||||
|
||||
// 初始化时自动跳转到当前课程
|
||||
if (currentIndex >= 0)
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
ScrollToCurrentCourse(currentIndex);
|
||||
}, Avalonia.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
}
|
||||
|
||||
RenderScheduleItems();
|
||||
@@ -484,10 +513,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
: 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +444,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.office_recent_documents",
|
||||
() => new OfficeRecentDocumentsWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopRemovableStorage,
|
||||
"component.removable_storage",
|
||||
() => new RemovableStorageWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.46, 12, 26)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.HolidayCalendar,
|
||||
"component.holiday_calendar",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
Background="#2D5A8E"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
@@ -23,15 +23,15 @@
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-40,-40,0"
|
||||
CornerRadius="70"
|
||||
Background="{DynamicResource SystemAccentColorLight2Brush}"
|
||||
Opacity="0.2"
|
||||
Background="#4A90D9"
|
||||
Opacity="0.3"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Foreground="#D8FFFFFF"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
@@ -48,7 +48,7 @@
|
||||
PointerPressed="OnRefreshPointerPressed">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
Foreground="#B8FFFFFF" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
@@ -68,14 +68,14 @@
|
||||
Width="130"
|
||||
Height="90"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
Background="#3AFFFFFF"
|
||||
Padding="10"
|
||||
Cursor="Hand"
|
||||
PointerPressed="OnDocumentCardPointerPressed">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding FileName}"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Foreground="#D8FFFFFF"
|
||||
FontSize="12"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
@@ -84,7 +84,7 @@
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding TimeAgo}"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
Foreground="#9AFFFFFF"
|
||||
FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
@@ -99,7 +99,7 @@
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="暂无最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
Foreground="#9AFFFFFF"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware
|
||||
{
|
||||
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
|
||||
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private List<OfficeRecentDocument> _documents = new();
|
||||
private string _componentId = BuiltInComponentIds.DesktopOfficeRecentDocuments;
|
||||
private string _placementId = string.Empty;
|
||||
private IReadOnlyList<string> _enabledSources = OfficeRecentDocumentSourceTypes.DefaultValues;
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isLoading;
|
||||
@@ -20,6 +26,7 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
{
|
||||
InitializeComponent();
|
||||
_recentDocumentsService = new OfficeRecentDocumentsService();
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -44,27 +51,45 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDocuments()
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopOfficeRecentDocuments
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
private async void LoadDocuments()
|
||||
{
|
||||
if (_isLoading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
ReloadSettings();
|
||||
StatusTextBlock.IsVisible = false;
|
||||
DocumentsItemsControl.ItemsSource = null;
|
||||
|
||||
_documents = _recentDocumentsService.GetRecentDocuments(20);
|
||||
var enabledSources = _enabledSources.ToArray();
|
||||
_documents = await Task.Run(() => _recentDocumentsService.GetRecentDocuments(20, enabledSources));
|
||||
|
||||
if (_documents.Count == 0)
|
||||
{
|
||||
StatusTextBlock.Text = "暂无最近文档";
|
||||
StatusTextBlock.Text = "\u6682\u65e0\u6700\u8fd1\u6587\u6863";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateDisplay();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusTextBlock.Text = "加载失败";
|
||||
AppLogger.Warn("OfficeRecentDocsWidget", "Failed to load recent Office documents.", ex);
|
||||
StatusTextBlock.Text = "\u52a0\u8f7d\u5931\u8d25";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
}
|
||||
finally
|
||||
@@ -73,6 +98,14 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadSettings()
|
||||
{
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_enabledSources = OfficeRecentDocumentSourceTypes.NormalizeValues(
|
||||
snapshot.OfficeRecentDocumentsEnabledSources,
|
||||
useDefaultWhenEmpty: snapshot.OfficeRecentDocumentsEnabledSources is null);
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
|
||||
@@ -90,15 +123,29 @@ public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponen
|
||||
var span = DateTime.Now - dateTime;
|
||||
|
||||
if (span.TotalMinutes < 1)
|
||||
return "刚刚";
|
||||
{
|
||||
return "\u521a\u521a";
|
||||
}
|
||||
|
||||
if (span.TotalMinutes < 60)
|
||||
return $"{(int)span.TotalMinutes} 分钟前";
|
||||
{
|
||||
return $"{(int)span.TotalMinutes} \u5206\u949f\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalHours < 24)
|
||||
return $"{(int)span.TotalHours} 小时前";
|
||||
{
|
||||
return $"{(int)span.TotalHours} \u5c0f\u65f6\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalDays < 7)
|
||||
return $"{(int)span.TotalDays} 天前";
|
||||
{
|
||||
return $"{(int)span.TotalDays} \u5929\u524d";
|
||||
}
|
||||
|
||||
if (span.TotalDays < 30)
|
||||
return $"{(int)(span.TotalDays / 7)} 周前";
|
||||
{
|
||||
return $"{(int)(span.TotalDays / 7)} \u5468\u524d";
|
||||
}
|
||||
|
||||
return dateTime.ToString("MM/dd");
|
||||
}
|
||||
|
||||
119
LanMountainDesktop/Views/Components/RemovableStorageWidget.axaml
Normal file
119
LanMountainDesktop/Views/Components/RemovableStorageWidget.axaml
Normal file
@@ -0,0 +1,119 @@
|
||||
<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"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="280"
|
||||
d:DesignHeight="280"
|
||||
x:Class="LanMountainDesktop.Views.Components.RemovableStorageWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="28"
|
||||
BorderThickness="1"
|
||||
Padding="16"
|
||||
ClipToBounds="True">
|
||||
<Grid>
|
||||
<Border x:Name="AccentOrb"
|
||||
Width="132"
|
||||
Height="132"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-48,-48,0"
|
||||
CornerRadius="66"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Border x:Name="AccentGlow"
|
||||
Height="76"
|
||||
Margin="-18,0,-18,-34"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Bottom"
|
||||
CornerRadius="38"
|
||||
Opacity="0.42"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Grid x:Name="LayoutGrid"
|
||||
RowDefinitions="Auto,*,Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid x:Name="HeaderGrid"
|
||||
ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border x:Name="IconBadge"
|
||||
Width="44"
|
||||
Height="44"
|
||||
CornerRadius="22"
|
||||
VerticalAlignment="Top">
|
||||
<fi:FluentIcon x:Name="DriveIcon"
|
||||
Icon="UsbStick"
|
||||
IconVariant="Regular"
|
||||
FontSize="24"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel x:Name="HeaderTextStack"
|
||||
Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="DriveNameTextBlock"
|
||||
FontWeight="SemiBold"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock x:Name="DriveDetailTextBlock"
|
||||
TextWrapping="NoWrap"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
Grid.Row="1"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<Button x:Name="OpenButton"
|
||||
Grid.Row="2"
|
||||
Height="42"
|
||||
Padding="14,0"
|
||||
CornerRadius="999"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="OnOpenClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:FluentIcon x:Name="OpenButtonIcon"
|
||||
Icon="OpenFolder"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="OpenButtonTextBlock"
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button x:Name="EjectButton"
|
||||
Grid.Row="3"
|
||||
Height="42"
|
||||
Padding="14,0"
|
||||
CornerRadius="999"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Click="OnEjectClick">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="8">
|
||||
<fi:FluentIcon x:Name="EjectButtonIcon"
|
||||
Icon="ArrowEject"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock x:Name="EjectButtonTextBlock"
|
||||
Grid.Column="1"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class RemovableStorageWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||
{
|
||||
private readonly record struct RemovableStoragePalette(
|
||||
Color BackgroundFrom,
|
||||
Color BackgroundTo,
|
||||
Color Border,
|
||||
Color AccentOrb,
|
||||
Color AccentGlow,
|
||||
Color IconBadgeBackground,
|
||||
Color IconForeground,
|
||||
Color PrimaryText,
|
||||
Color SecondaryText,
|
||||
Color StatusText,
|
||||
Color Accent,
|
||||
Color OnAccent,
|
||||
Color SecondaryButtonBackground,
|
||||
Color SecondaryButtonBorder,
|
||||
Color SecondaryButtonForeground,
|
||||
Color DisabledButtonBackground,
|
||||
Color DisabledButtonBorder,
|
||||
Color DisabledButtonForeground);
|
||||
|
||||
private readonly DispatcherTimer _pollTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(2)
|
||||
};
|
||||
|
||||
private readonly IRemovableStorageService _removableStorageService = new RemovableStorageService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
|
||||
private IReadOnlyList<RemovableStorageDrive> _connectedDrives = Array.Empty<RemovableStorageDrive>();
|
||||
private string _componentId = BuiltInComponentIds.DesktopRemovableStorage;
|
||||
private string _placementId = string.Empty;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string? _componentColorScheme;
|
||||
private string _selectedDriveRootPath = string.Empty;
|
||||
private string? _statusOverrideText;
|
||||
private double _currentCellSize = 48;
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _isDisposed;
|
||||
|
||||
public RemovableStorageWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
_pollTimer.Tick += OnPollTimerTick;
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
ReloadSettings();
|
||||
ApplyVisualState();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
var shouldRefresh = !_isOnActivePage && isOnActivePage;
|
||||
_isOnActivePage = isOnActivePage;
|
||||
UpdatePollingState();
|
||||
|
||||
if (shouldRefresh)
|
||||
{
|
||||
_ = RefreshDriveListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopRemovableStorage
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
ReloadSettings();
|
||||
ApplyVisualState();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = true;
|
||||
UpdatePollingState();
|
||||
_ = RefreshDriveListAsync();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
_isAttached = false;
|
||||
UpdatePollingState();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
ApplyVisualState();
|
||||
}
|
||||
|
||||
private async void OnPollTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
await RefreshDriveListAsync();
|
||||
}
|
||||
|
||||
private async void OnOpenClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
var drive = GetSelectedDrive();
|
||||
if (drive is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_removableStorageService.OpenDrive(drive.RootPath))
|
||||
{
|
||||
_statusOverrideText = L("removable_storage.widget.ready", "Ready to open or eject.");
|
||||
ApplyVisualState();
|
||||
return;
|
||||
}
|
||||
|
||||
_statusOverrideText = L("removable_storage.widget.open_failed", "Failed to open this drive.");
|
||||
ApplyVisualState();
|
||||
await RefreshDriveListAsync();
|
||||
}
|
||||
|
||||
private async void OnEjectClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
var drive = GetSelectedDrive();
|
||||
if (drive is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusOverrideText = L("removable_storage.widget.ejecting", "Ejecting drive...");
|
||||
ApplyVisualState();
|
||||
|
||||
var ejected = _removableStorageService.EjectDrive(drive.RootPath);
|
||||
_statusOverrideText = ejected
|
||||
? L("removable_storage.widget.ejecting", "Ejecting drive...")
|
||||
: L("removable_storage.widget.eject_failed", "Could not eject this drive. Close any files on it and try again.");
|
||||
ApplyVisualState();
|
||||
await RefreshDriveListAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshDriveListAsync()
|
||||
{
|
||||
if (_isDisposed || _isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
|
||||
try
|
||||
{
|
||||
var previousDriveRoots = new HashSet<string>(
|
||||
_connectedDrives.Select(drive => drive.RootPath),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var latestDrives = await Task.Run(() => _removableStorageService.GetConnectedDrives());
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var newlyInsertedDrive = latestDrives.FirstOrDefault(drive => !previousDriveRoots.Contains(drive.RootPath));
|
||||
_connectedDrives = latestDrives;
|
||||
|
||||
if (newlyInsertedDrive is not null)
|
||||
{
|
||||
_selectedDriveRootPath = newlyInsertedDrive.RootPath;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(_selectedDriveRootPath) ||
|
||||
!_connectedDrives.Any(drive => string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
_selectedDriveRootPath = _connectedDrives.FirstOrDefault().RootPath ?? string.Empty;
|
||||
}
|
||||
|
||||
if (_connectedDrives.Count == 0)
|
||||
{
|
||||
_selectedDriveRootPath = string.Empty;
|
||||
_statusOverrideText = null;
|
||||
}
|
||||
else if (newlyInsertedDrive is not null)
|
||||
{
|
||||
_statusOverrideText = null;
|
||||
}
|
||||
|
||||
ReloadSettings();
|
||||
ApplyVisualState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("RemovableStorageWidget", "Failed to refresh removable storage widget.", ex);
|
||||
_statusOverrideText = L("removable_storage.widget.refresh_failed", "Drive list refresh failed.");
|
||||
ApplyVisualState();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
var appSettings = _settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||
_componentColorScheme = componentSettings.ColorSchemeSource;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(_languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyVisualState()
|
||||
{
|
||||
var drive = GetSelectedDrive();
|
||||
var hasDrive = drive is not null;
|
||||
var palette = ResolvePalette();
|
||||
|
||||
RootBorder.Background = CreateGradientBrush(palette.BackgroundFrom, palette.BackgroundTo);
|
||||
RootBorder.BorderBrush = CreateBrush(palette.Border);
|
||||
AccentOrb.Background = CreateBrush(palette.AccentOrb);
|
||||
AccentGlow.Background = CreateBrush(palette.AccentGlow);
|
||||
IconBadge.Background = CreateBrush(palette.IconBadgeBackground);
|
||||
DriveIcon.Foreground = CreateBrush(palette.IconForeground);
|
||||
DriveNameTextBlock.Foreground = CreateBrush(palette.PrimaryText);
|
||||
DriveDetailTextBlock.Foreground = CreateBrush(palette.SecondaryText);
|
||||
StatusTextBlock.Foreground = CreateBrush(palette.StatusText);
|
||||
|
||||
if (hasDrive)
|
||||
{
|
||||
ApplyButtonPalette(
|
||||
OpenButton,
|
||||
OpenButtonIcon,
|
||||
OpenButtonTextBlock,
|
||||
palette.Accent,
|
||||
palette.OnAccent,
|
||||
palette.Accent);
|
||||
ApplyButtonPalette(
|
||||
EjectButton,
|
||||
EjectButtonIcon,
|
||||
EjectButtonTextBlock,
|
||||
palette.SecondaryButtonBackground,
|
||||
palette.SecondaryButtonForeground,
|
||||
palette.SecondaryButtonBorder);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyButtonPalette(
|
||||
OpenButton,
|
||||
OpenButtonIcon,
|
||||
OpenButtonTextBlock,
|
||||
palette.DisabledButtonBackground,
|
||||
palette.DisabledButtonForeground,
|
||||
palette.DisabledButtonBorder);
|
||||
ApplyButtonPalette(
|
||||
EjectButton,
|
||||
EjectButtonIcon,
|
||||
EjectButtonTextBlock,
|
||||
palette.DisabledButtonBackground,
|
||||
palette.DisabledButtonForeground,
|
||||
palette.DisabledButtonBorder);
|
||||
}
|
||||
|
||||
OpenButton.IsEnabled = hasDrive;
|
||||
EjectButton.IsEnabled = hasDrive;
|
||||
|
||||
OpenButtonTextBlock.Text = L("removable_storage.action.open", "Open");
|
||||
EjectButtonTextBlock.Text = L("removable_storage.action.eject", "Eject");
|
||||
|
||||
if (hasDrive)
|
||||
{
|
||||
var selectedDrive = drive!;
|
||||
DriveNameTextBlock.Text = ResolveDriveName(selectedDrive);
|
||||
DriveDetailTextBlock.Text = selectedDrive.DriveLetter;
|
||||
StatusTextBlock.Text = _statusOverrideText ??
|
||||
L("removable_storage.widget.ready", "Ready to open or eject.");
|
||||
}
|
||||
else
|
||||
{
|
||||
DriveNameTextBlock.Text = L("removable_storage.widget.empty_title", "No device inserted");
|
||||
DriveDetailTextBlock.Text = L("removable_storage.widget.empty_subtitle", "Insert a USB drive to show it here.");
|
||||
StatusTextBlock.Text = L("removable_storage.widget.empty_hint", "Buttons stay disabled until a removable device is inserted.");
|
||||
}
|
||||
|
||||
ApplyLayoutMetrics();
|
||||
}
|
||||
|
||||
private void ApplyLayoutMetrics()
|
||||
{
|
||||
var scale = ResolveScale();
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * 2;
|
||||
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * 0.44, 18, 34);
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(16 * scale, 10, 24),
|
||||
Math.Clamp(15 * scale, 10, 22),
|
||||
Math.Clamp(16 * scale, 10, 24),
|
||||
Math.Clamp(15 * scale, 10, 22));
|
||||
|
||||
LayoutGrid.RowSpacing = Math.Clamp(10 * scale, 8, 16);
|
||||
HeaderGrid.ColumnSpacing = Math.Clamp(12 * scale, 8, 16);
|
||||
HeaderTextStack.Spacing = Math.Clamp(2 * scale, 1, 4);
|
||||
|
||||
var badgeSize = Math.Clamp(44 * scale, 38, 60);
|
||||
IconBadge.Width = badgeSize;
|
||||
IconBadge.Height = badgeSize;
|
||||
IconBadge.CornerRadius = new CornerRadius(badgeSize * 0.5);
|
||||
DriveIcon.FontSize = Math.Clamp(24 * scale, 20, 32);
|
||||
|
||||
DriveNameTextBlock.FontSize = Math.Clamp(16 * scale, 13, 24);
|
||||
DriveDetailTextBlock.FontSize = Math.Clamp(11.5 * scale, 10, 16);
|
||||
StatusTextBlock.FontSize = Math.Clamp(12 * scale, 10, 17);
|
||||
StatusTextBlock.MaxWidth = Math.Max(96, width - (RootBorder.Padding.Left + RootBorder.Padding.Right));
|
||||
|
||||
var buttonHeight = Math.Clamp(42 * scale, 38, 54);
|
||||
var buttonPadding = Math.Clamp(14 * scale, 10, 20);
|
||||
var buttonCornerRadius = Math.Clamp(buttonHeight * 0.5, 18, 999);
|
||||
|
||||
OpenButton.Height = buttonHeight;
|
||||
OpenButton.Padding = new Thickness(buttonPadding, 0);
|
||||
OpenButton.CornerRadius = new CornerRadius(buttonCornerRadius);
|
||||
|
||||
EjectButton.Height = buttonHeight;
|
||||
EjectButton.Padding = new Thickness(buttonPadding, 0);
|
||||
EjectButton.CornerRadius = new CornerRadius(buttonCornerRadius);
|
||||
|
||||
OpenButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
|
||||
EjectButtonIcon.FontSize = Math.Clamp(16 * scale, 14, 20);
|
||||
OpenButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
|
||||
EjectButtonTextBlock.FontSize = Math.Clamp(13 * scale, 11.5, 18);
|
||||
|
||||
AccentOrb.Width = Math.Clamp(width * 0.44, 96, 176);
|
||||
AccentOrb.Height = AccentOrb.Width;
|
||||
AccentOrb.CornerRadius = new CornerRadius(AccentOrb.Width * 0.5);
|
||||
AccentGlow.Height = Math.Clamp(76 * scale, 52, 110);
|
||||
AccentGlow.CornerRadius = new CornerRadius(AccentGlow.Height * 0.5);
|
||||
}
|
||||
|
||||
private RemovableStorageDrive? GetSelectedDrive()
|
||||
{
|
||||
if (_connectedDrives.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_selectedDriveRootPath))
|
||||
{
|
||||
var selected = _connectedDrives.FirstOrDefault(drive =>
|
||||
string.Equals(drive.RootPath, _selectedDriveRootPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (selected is not null)
|
||||
{
|
||||
return selected;
|
||||
}
|
||||
}
|
||||
|
||||
return _connectedDrives[0];
|
||||
}
|
||||
|
||||
private string ResolveDriveName(RemovableStorageDrive drive)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(drive.VolumeLabel)
|
||||
? L("removable_storage.widget.default_name", "Removable Drive")
|
||||
: drive.VolumeLabel.Trim();
|
||||
}
|
||||
|
||||
private RemovableStoragePalette ResolvePalette()
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
if (!useMonetColor)
|
||||
{
|
||||
var nativeAccent = Color.Parse("#FF65A8FF");
|
||||
var nativeBackgroundFrom = Color.Parse("#FF10345F");
|
||||
var nativeBackgroundTo = Color.Parse("#FF0D213E");
|
||||
var nativePrimaryText = Color.Parse("#FFF4F8FF");
|
||||
var nativeSecondaryText = Color.Parse("#C8D9F5FF");
|
||||
var nativeDisabled = Color.Parse("#30465D7A");
|
||||
|
||||
return new RemovableStoragePalette(
|
||||
nativeBackgroundFrom,
|
||||
nativeBackgroundTo,
|
||||
Color.Parse("#6A97D6FF"),
|
||||
Color.Parse("#2F8BC5FF"),
|
||||
Color.Parse("#4C79BFFF"),
|
||||
Color.Parse("#335BAAFF"),
|
||||
Color.Parse("#FFF5FAFF"),
|
||||
nativePrimaryText,
|
||||
nativeSecondaryText,
|
||||
Color.Parse("#D8E7FFFF"),
|
||||
nativeAccent,
|
||||
ColorMath.EnsureContrast(Color.Parse("#FF071420"), nativeAccent, 4.5),
|
||||
Color.Parse("#24FFFFFF"),
|
||||
Color.Parse("#5A9ACDFF"),
|
||||
nativePrimaryText,
|
||||
nativeDisabled,
|
||||
Color.Parse("#4D6782A0"),
|
||||
Color.Parse("#8FA8BDD1"));
|
||||
}
|
||||
|
||||
var surfaceRaised = ResolveThemeColor("AdaptiveSurfaceRaisedBrush", "#FF1A2332");
|
||||
var surfaceOverlay = ResolveThemeColor("AdaptiveSurfaceOverlayBrush", "#FF111827");
|
||||
var accent = ResolveThemeColor("AdaptiveAccentBrush", "#FF61A8FF");
|
||||
var onAccent = ResolveThemeColor("AdaptiveOnAccentBrush", "#FFFFFFFF");
|
||||
var primaryText = ResolveThemeColor("AdaptiveTextPrimaryBrush", "#FFF8FAFC");
|
||||
var secondaryText = ResolveThemeColor("AdaptiveTextSecondaryBrush", "#FFD0D7E3");
|
||||
var mutedText = ResolveThemeColor("AdaptiveTextMutedBrush", "#FFAFB8C7");
|
||||
var disabledButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, surfaceOverlay, 0.35), 0xD8);
|
||||
var disabledButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.18), 0x88);
|
||||
var disabledButtonForeground = ColorMath.WithAlpha(primaryText, 0x88);
|
||||
|
||||
var backgroundFrom = ColorMath.Blend(surfaceRaised, accent, 0.18);
|
||||
var backgroundTo = ColorMath.Blend(surfaceOverlay, surfaceRaised, 0.46);
|
||||
var border = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.38), 0xB8);
|
||||
var iconBadgeBackground = ColorMath.Blend(surfaceRaised, accent, 0.28);
|
||||
var iconForeground = ColorMath.EnsureContrast(accent, iconBadgeBackground, 3.0);
|
||||
var secondaryButtonBackground = ColorMath.WithAlpha(ColorMath.Blend(surfaceRaised, accent, 0.10), 0xE6);
|
||||
var secondaryButtonBorder = ColorMath.WithAlpha(ColorMath.Blend(accent, surfaceRaised, 0.46), 0xC6);
|
||||
|
||||
return new RemovableStoragePalette(
|
||||
backgroundFrom,
|
||||
backgroundTo,
|
||||
border,
|
||||
ColorMath.WithAlpha(accent, 0x28),
|
||||
ColorMath.WithAlpha(ColorMath.Blend(accent, backgroundFrom, 0.26), 0x74),
|
||||
iconBadgeBackground,
|
||||
iconForeground,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
mutedText,
|
||||
accent,
|
||||
onAccent,
|
||||
secondaryButtonBackground,
|
||||
secondaryButtonBorder,
|
||||
primaryText,
|
||||
disabledButtonBackground,
|
||||
disabledButtonBorder,
|
||||
disabledButtonForeground);
|
||||
}
|
||||
|
||||
private Color ResolveThemeColor(string resourceKey, string fallbackHex)
|
||||
{
|
||||
if (this.TryFindResource(resourceKey, out var resource))
|
||||
{
|
||||
if (resource is ISolidColorBrush solidBrush)
|
||||
{
|
||||
return solidBrush.Color;
|
||||
}
|
||||
|
||||
if (resource is SolidColorBrush directSolidBrush)
|
||||
{
|
||||
return directSolidBrush.Color;
|
||||
}
|
||||
}
|
||||
|
||||
return Color.Parse(fallbackHex);
|
||||
}
|
||||
|
||||
private double ResolveScale()
|
||||
{
|
||||
var cellScale = Math.Clamp(_currentCellSize / 48d, 0.72, 2.2);
|
||||
var widthScale = Bounds.Width > 1 ? Math.Clamp(Bounds.Width / 220d, 0.72, 2.4) : 1;
|
||||
var heightScale = Bounds.Height > 1 ? Math.Clamp(Bounds.Height / 220d, 0.72, 2.4) : 1;
|
||||
return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.72, 2.2);
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
return _localizationService.GetString(_languageCode, key, fallback);
|
||||
}
|
||||
|
||||
private static void ApplyButtonPalette(
|
||||
Button button,
|
||||
FluentIcon icon,
|
||||
TextBlock textBlock,
|
||||
Color background,
|
||||
Color foreground,
|
||||
Color border)
|
||||
{
|
||||
button.Background = CreateBrush(background);
|
||||
button.BorderBrush = CreateBrush(border);
|
||||
button.BorderThickness = new Thickness(1);
|
||||
button.Foreground = CreateBrush(foreground);
|
||||
icon.Foreground = CreateBrush(foreground);
|
||||
textBlock.Foreground = CreateBrush(foreground);
|
||||
}
|
||||
|
||||
private static IBrush CreateGradientBrush(Color from, Color to)
|
||||
{
|
||||
return new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops = new GradientStops
|
||||
{
|
||||
new GradientStop(from, 0),
|
||||
new GradientStop(to, 1)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static SolidColorBrush CreateBrush(Color color)
|
||||
{
|
||||
return new(color);
|
||||
}
|
||||
|
||||
private void UpdatePollingState()
|
||||
{
|
||||
if (_isAttached && _isOnActivePage)
|
||||
{
|
||||
if (!_pollTimer.IsEnabled)
|
||||
{
|
||||
_pollTimer.Start();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_pollTimer.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
_pollTimer.Stop();
|
||||
_pollTimer.Tick -= OnPollTimerTick;
|
||||
AttachedToVisualTree -= OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree -= OnDetachedFromVisualTree;
|
||||
SizeChanged -= OnSizeChanged;
|
||||
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyDeductionReasonsWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="22"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyInterruptDensityWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="22"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -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 @@
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyNoiseCurveWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="24"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyNoiseDistributionWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="24"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
d:DesignHeight="360"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudyScoreOverviewWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="24"
|
||||
Padding="16,14"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
d:DesignHeight="150"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudySessionControlWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="18"
|
||||
Padding="14,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
d:DesignHeight="220"
|
||||
x:Class="LanMountainDesktop.Views.Components.StudySessionHistoryWidget">
|
||||
<Border x:Name="RootBorder"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
CornerRadius="22"
|
||||
Padding="12,10"
|
||||
ClipToBounds="True">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -9,13 +10,18 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using DotNetCampus.Inking;
|
||||
using DotNetCampus.Inking.Primitive;
|
||||
using FluentIcons.Avalonia;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||
{
|
||||
private enum WhiteboardToolMode
|
||||
{
|
||||
@@ -24,11 +30,22 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
}
|
||||
|
||||
private static readonly PropertyInfo? StrokeColorProperty = typeof(SkiaStroke).GetProperty(nameof(SkiaStroke.Color));
|
||||
private static readonly PropertyInfo? StrokePointListProperty = typeof(SkiaStroke).GetProperty("PointList");
|
||||
private readonly int _baseWidthCells;
|
||||
private readonly IComponentInstanceSettingsStore _componentSettingsStore = HostComponentSettingsStoreProvider.GetOrCreate();
|
||||
private readonly IWhiteboardNotePersistenceService _notePersistenceService = new WhiteboardNotePersistenceService();
|
||||
private readonly DispatcherTimer _noteSaveTimer = new() { Interval = TimeSpan.FromMinutes(5) };
|
||||
private double _currentCellSize = 48;
|
||||
private WhiteboardToolMode _toolMode = WhiteboardToolMode.Pen;
|
||||
private bool? _isNightModeApplied;
|
||||
private SKColor _currentInkColor = SKColors.Black;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWhiteboard;
|
||||
private string _placementId = string.Empty;
|
||||
private int _noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
private bool _isApplyingPersistedSnapshot;
|
||||
private bool _noteDirty;
|
||||
private int _noteLoadRevision;
|
||||
private bool _disposed;
|
||||
|
||||
public WhiteboardWidget()
|
||||
: this(baseWidthCells: 2)
|
||||
@@ -43,21 +60,26 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
_noteSaveTimer.Tick += OnNoteSaveTimerTick;
|
||||
|
||||
ConfigureInkCanvas();
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshFromSettings();
|
||||
ApplyThemeVisual(force: true);
|
||||
SetToolMode(WhiteboardToolMode.Pen);
|
||||
}
|
||||
|
||||
public int NoteRetentionDays => _noteRetentionDays;
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
ApplyThemeVisual(force: true);
|
||||
SchedulePersistedNoteLoad();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
// Keep all state in-memory for lightweight re-attach scenarios.
|
||||
PersistNoteImmediately();
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -79,6 +101,9 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
settings.EraserSize = new Size(20, 20);
|
||||
settings.IsBitmapCacheEnabled = true;
|
||||
settings.MaxBitmapCacheSize = 2048;
|
||||
InkCanvas.StrokeCollected += OnInkCanvasStrokeCollected;
|
||||
InkCanvas.PointerReleased += OnInkCanvasPointerReleased;
|
||||
InkCanvas.PointerCaptureLost += OnInkCanvasPointerCaptureLost;
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
@@ -134,6 +159,63 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
RefreshToolButtonVisuals();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
var nextComponentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWhiteboard
|
||||
: componentId.Trim();
|
||||
var nextPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
|
||||
if (_noteDirty &&
|
||||
HasValidPersistenceContext() &&
|
||||
(string.Compare(_componentId, nextComponentId, StringComparison.OrdinalIgnoreCase) != 0 ||
|
||||
string.Compare(_placementId, nextPlacementId, StringComparison.OrdinalIgnoreCase) != 0))
|
||||
{
|
||||
PersistNoteImmediately();
|
||||
}
|
||||
|
||||
_componentId = nextComponentId;
|
||||
_placementId = nextPlacementId;
|
||||
RefreshFromSettings();
|
||||
ClearAllStrokes();
|
||||
SchedulePersistedNoteLoad();
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!HasValidPersistenceContext())
|
||||
{
|
||||
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_noteRetentionDays = NormalizeRetentionDays(snapshot.WhiteboardNoteRetentionDays);
|
||||
_notePersistenceService.TryDeleteExpiredNote(_componentId, _placementId, _noteRetentionDays);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_noteRetentionDays = WhiteboardNoteRetentionPolicy.DefaultDays;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_noteSaveTimer.Stop();
|
||||
_noteSaveTimer.Tick -= OnNoteSaveTimerTick;
|
||||
InkCanvas.StrokeCollected -= OnInkCanvasStrokeCollected;
|
||||
InkCanvas.PointerReleased -= OnInkCanvasPointerReleased;
|
||||
InkCanvas.PointerCaptureLost -= OnInkCanvasPointerCaptureLost;
|
||||
}
|
||||
|
||||
private void RecolorAllStrokes(SKColor targetColor)
|
||||
{
|
||||
for (var i = 0; i < InkCanvas.Strokes.Count; i++)
|
||||
@@ -183,6 +265,14 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int NormalizeRetentionDays(int days)
|
||||
{
|
||||
return WhiteboardNoteRetentionPolicy.NormalizeDays(
|
||||
days <= 0
|
||||
? WhiteboardNoteRetentionPolicy.DefaultDays
|
||||
: days);
|
||||
}
|
||||
|
||||
private static double CalculateRelativeLuminance(Color color)
|
||||
{
|
||||
static double ToLinear(double channel)
|
||||
@@ -267,25 +357,8 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnClearButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
var strokeList = InkCanvas.Strokes.ToList();
|
||||
foreach (var stroke in strokeList)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep the widget alive even if one stroke removal fails.
|
||||
}
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
ClearAllStrokes();
|
||||
QueueNoteSave();
|
||||
}
|
||||
|
||||
private async void OnExportButtonClick(object? sender, RoutedEventArgs e)
|
||||
@@ -358,4 +431,273 @@ public partial class WhiteboardWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
svgCanvas.Flush();
|
||||
}
|
||||
|
||||
private void OnInkCanvasStrokeCollected(object? sender, DotNetCampus.Inking.Contexts.AvaloniaSkiaInkCanvasStrokeCollectedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
QueueNoteSave();
|
||||
}
|
||||
|
||||
private void OnInkCanvasPointerReleased(object? sender, Avalonia.Input.PointerReleasedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
QueueNoteSave();
|
||||
}
|
||||
|
||||
private void OnInkCanvasPointerCaptureLost(object? sender, Avalonia.Input.PointerCaptureLostEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
_ = e;
|
||||
QueueNoteSave();
|
||||
}
|
||||
|
||||
private void OnNoteSaveTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||
{
|
||||
_noteSaveTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_noteDirty)
|
||||
{
|
||||
_noteSaveTimer.Stop();
|
||||
return;
|
||||
}
|
||||
|
||||
var noteSnapshot = BuildNoteSnapshot();
|
||||
var componentId = _componentId;
|
||||
var placementId = _placementId;
|
||||
var retentionDays = _noteRetentionDays;
|
||||
_noteDirty = false;
|
||||
_noteSaveTimer.Stop();
|
||||
_ = Task.Run(() => _notePersistenceService.SaveNote(componentId, placementId, noteSnapshot, retentionDays));
|
||||
}
|
||||
|
||||
private void QueueNoteSave()
|
||||
{
|
||||
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_noteDirty = true;
|
||||
if (!_noteSaveTimer.IsEnabled)
|
||||
{
|
||||
_noteSaveTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistNoteImmediately()
|
||||
{
|
||||
if (_disposed || _isApplyingPersistedSnapshot || !HasValidPersistenceContext())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_noteDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_noteDirty = false;
|
||||
_noteSaveTimer.Stop();
|
||||
var noteSnapshot = BuildNoteSnapshot();
|
||||
var componentId = _componentId;
|
||||
var placementId = _placementId;
|
||||
var retentionDays = _noteRetentionDays;
|
||||
_ = Task.Run(() => _notePersistenceService.SaveNote(
|
||||
componentId,
|
||||
placementId,
|
||||
noteSnapshot,
|
||||
retentionDays));
|
||||
}
|
||||
|
||||
private async void SchedulePersistedNoteLoad()
|
||||
{
|
||||
if (!HasValidPersistenceContext())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var revision = ++_noteLoadRevision;
|
||||
var componentId = _componentId;
|
||||
var placementId = _placementId;
|
||||
var retentionDays = _noteRetentionDays;
|
||||
|
||||
try
|
||||
{
|
||||
var noteSnapshot = await Task.Run(() => _notePersistenceService.LoadNote(componentId, placementId, retentionDays));
|
||||
if (_disposed || revision != _noteLoadRevision ||
|
||||
!string.Equals(_componentId, componentId, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(_placementId, placementId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_disposed || revision != _noteLoadRevision)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isApplyingPersistedSnapshot = true;
|
||||
try
|
||||
{
|
||||
ClearAllStrokes();
|
||||
ApplyNoteSnapshot(noteSnapshot);
|
||||
RecolorAllStrokes(_currentInkColor);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isApplyingPersistedSnapshot = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort only. Whiteboard should stay usable if persistence is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private WhiteboardNoteSnapshot BuildNoteSnapshot()
|
||||
{
|
||||
return new WhiteboardNoteSnapshot
|
||||
{
|
||||
Strokes = InkCanvas.Strokes
|
||||
.Select(BuildStrokeSnapshot)
|
||||
.Where(static stroke => stroke.Points.Count > 0)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static WhiteboardStrokeSnapshot BuildStrokeSnapshot(SkiaStroke stroke)
|
||||
{
|
||||
var pointList = TryGetStrokePoints(stroke);
|
||||
return new WhiteboardStrokeSnapshot
|
||||
{
|
||||
Color = ToHexColor(stroke.Color),
|
||||
InkThickness = stroke.InkThickness,
|
||||
IgnorePressure = stroke.IgnorePressure,
|
||||
Points = pointList
|
||||
.Select(static point => new WhiteboardStylusPointSnapshot
|
||||
{
|
||||
X = point.X,
|
||||
Y = point.Y,
|
||||
Pressure = point.Pressure,
|
||||
Width = point.Width ?? 0,
|
||||
Height = point.Height ?? 0
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private void ApplyNoteSnapshot(WhiteboardNoteSnapshot snapshot)
|
||||
{
|
||||
if (snapshot.Strokes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var renderer = InkCanvas.AvaloniaSkiaInkCanvas.Settings.InkStrokeRenderer;
|
||||
foreach (var strokeSnapshot in snapshot.Strokes)
|
||||
{
|
||||
var stylusPoints = strokeSnapshot.Points
|
||||
.Select(ConvertStylusPoint)
|
||||
.ToList();
|
||||
if (stylusPoints.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var path = renderer.RenderInkToPath(stylusPoints, strokeSnapshot.InkThickness);
|
||||
var staticStroke = SkiaStroke.CreateStaticStroke(
|
||||
InkId.NewId(),
|
||||
path,
|
||||
new StylusPointListSpan(stylusPoints, 0, stylusPoints.Count),
|
||||
ParseStrokeColor(strokeSnapshot.Color),
|
||||
(float)strokeSnapshot.InkThickness,
|
||||
strokeSnapshot.IgnorePressure,
|
||||
renderer);
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.AddStaticStroke(staticStroke);
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UpdateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
}
|
||||
|
||||
private static InkStylusPoint ConvertStylusPoint(WhiteboardStylusPointSnapshot point)
|
||||
{
|
||||
return new InkStylusPoint(point.X, point.Y, (float)Math.Clamp(point.Pressure, 0f, 1f))
|
||||
{
|
||||
Width = point.Width > 0 ? point.Width : null,
|
||||
Height = point.Height > 0 ? point.Height : null
|
||||
};
|
||||
}
|
||||
|
||||
private static SKColor ParseStrokeColor(string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
try
|
||||
{
|
||||
var color = Color.Parse(value);
|
||||
return new SKColor(color.R, color.G, color.B, color.A);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to the default color.
|
||||
}
|
||||
}
|
||||
|
||||
return SKColors.Black;
|
||||
}
|
||||
|
||||
private static string ToHexColor(SKColor color)
|
||||
{
|
||||
return $"#{color.Alpha:X2}{color.Red:X2}{color.Green:X2}{color.Blue:X2}";
|
||||
}
|
||||
|
||||
private void ClearAllStrokes()
|
||||
{
|
||||
var strokeList = InkCanvas.Strokes.ToList();
|
||||
foreach (var stroke in strokeList)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (ReferenceEquals(stroke.InkCanvas, InkCanvas.AvaloniaSkiaInkCanvas))
|
||||
{
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.RemoveStaticStroke(stroke);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep the widget alive even if one stroke removal fails.
|
||||
}
|
||||
}
|
||||
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.UseBitmapCache(false);
|
||||
InkCanvas.AvaloniaSkiaInkCanvas.InvalidateBitmapCache();
|
||||
InkCanvas.InvalidateVisual();
|
||||
}
|
||||
|
||||
private bool HasValidPersistenceContext()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_componentId) &&
|
||||
!string.IsNullOrWhiteSpace(_placementId);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<InkStylusPoint> TryGetStrokePoints(SkiaStroke stroke)
|
||||
{
|
||||
if (StrokePointListProperty?.GetValue(stroke) is IReadOnlyList<InkStylusPoint> pointList)
|
||||
{
|
||||
return pointList;
|
||||
}
|
||||
|
||||
return Array.Empty<InkStylusPoint>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,10 +398,12 @@ public partial class MainWindow
|
||||
_clockDisplayFormat = snapshot.ClockDisplayFormat == "HourMinute"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
_statusBarClockTransparentBackground = snapshot.StatusBarClockTransparentBackground;
|
||||
|
||||
if (ClockWidget is not null)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +415,7 @@ public partial class MainWindow
|
||||
if (ClockWidget is not null)
|
||||
{
|
||||
ClockWidget.IsVisible = showClock;
|
||||
ClockWidget.SetTransparentBackground(_statusBarClockTransparentBackground);
|
||||
if (showClock)
|
||||
{
|
||||
ClockWidget.SetDisplayFormat(_clockDisplayFormat);
|
||||
@@ -964,6 +967,7 @@ public partial class MainWindow
|
||||
DisposeComponentIfNeeded(host);
|
||||
contentHost.Child = component;
|
||||
ApplyDesktopEditStateToHost(host, _isComponentLibraryOpen);
|
||||
InvalidateDesktopPageAwareComponentContextCache();
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
if (_selectedDesktopComponentHost == host)
|
||||
{
|
||||
@@ -1102,6 +1106,7 @@ public partial class MainWindow
|
||||
|
||||
ClearTimeZoneServiceBindings(pageGrid.Children.OfType<Control>().ToList());
|
||||
pageGrid.Children.Clear();
|
||||
InvalidateDesktopPageAwareComponentContextCache();
|
||||
|
||||
var maxColumns = pageGrid.ColumnDefinitions.Count;
|
||||
var maxRows = pageGrid.RowDefinitions.Count;
|
||||
@@ -1204,6 +1209,7 @@ public partial class MainWindow
|
||||
pageGrid.Children.Add(host);
|
||||
|
||||
_desktopComponentPlacements.Add(placement);
|
||||
InvalidateDesktopPageAwareComponentContextCache();
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
PersistSettings();
|
||||
|
||||
@@ -1577,14 +1583,86 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateDesktopPageAwareComponentContextCache()
|
||||
{
|
||||
_desktopPageContextInitialized = false;
|
||||
_desktopPageContextActiveMask = 0;
|
||||
}
|
||||
|
||||
private int BuildDesktopPageAwareComponentActiveMask()
|
||||
{
|
||||
if (_isSettingsOpen)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var activeMask = 0;
|
||||
if (_desktopSurfacePageWidth > 1 &&
|
||||
_desktopPagesHostTransform is not null &&
|
||||
(_isDesktopSwipeActive ||
|
||||
_desktopPageContextSettlingSourceIndex is not null ||
|
||||
_desktopPageContextSettlingTargetIndex is not null))
|
||||
{
|
||||
var viewportLeft = -_desktopPagesHostTransform.X;
|
||||
var viewportRight = viewportLeft + _desktopSurfacePageWidth;
|
||||
for (var pageIndex = 0; pageIndex < _desktopPageCount; pageIndex++)
|
||||
{
|
||||
var pageLeft = pageIndex * _desktopSurfacePageWidth;
|
||||
var pageRight = pageLeft + _desktopSurfacePageWidth;
|
||||
if (pageRight > viewportLeft + 0.5d && pageLeft < viewportRight - 0.5d)
|
||||
{
|
||||
activeMask |= 1 << pageIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentDesktopSurfaceIndex >= 0 && _currentDesktopSurfaceIndex < _desktopPageCount)
|
||||
{
|
||||
activeMask |= 1 << _currentDesktopSurfaceIndex;
|
||||
}
|
||||
|
||||
if (_desktopPageContextSettlingSourceIndex is int sourceIndex &&
|
||||
sourceIndex >= 0 &&
|
||||
sourceIndex < _desktopPageCount)
|
||||
{
|
||||
activeMask |= 1 << sourceIndex;
|
||||
}
|
||||
|
||||
if (_desktopPageContextSettlingTargetIndex is int targetIndex &&
|
||||
targetIndex >= 0 &&
|
||||
targetIndex < _desktopPageCount)
|
||||
{
|
||||
activeMask |= 1 << targetIndex;
|
||||
}
|
||||
|
||||
return activeMask;
|
||||
}
|
||||
|
||||
private void UpdateDesktopPageAwareComponentContext()
|
||||
{
|
||||
var activeDesktopPageIndex = _isSettingsOpen ? -1 : _currentDesktopSurfaceIndex;
|
||||
var isEditMode = _isComponentLibraryOpen || _isSettingsOpen;
|
||||
var activeMask = BuildDesktopPageAwareComponentActiveMask();
|
||||
var pageUpdateMask = !_desktopPageContextInitialized || isEditMode != _desktopPageContextEditMode
|
||||
? _desktopPageComponentGrids.Keys.Aggregate(0, (mask, pageIndex) => mask | (1 << pageIndex))
|
||||
: activeMask ^ _desktopPageContextActiveMask;
|
||||
|
||||
if (_desktopPageContextInitialized &&
|
||||
pageUpdateMask == 0 &&
|
||||
isEditMode == _desktopPageContextEditMode &&
|
||||
activeMask == _desktopPageContextActiveMask)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in _desktopPageComponentGrids)
|
||||
{
|
||||
var isOnActivePage = pair.Key == activeDesktopPageIndex;
|
||||
var pageBit = 1 << pair.Key;
|
||||
if ((pageUpdateMask & pageBit) == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var isOnActivePage = (activeMask & pageBit) != 0;
|
||||
foreach (var host in pair.Value.Children.OfType<Border>())
|
||||
{
|
||||
if (!host.Classes.Contains(DesktopComponentHostClass))
|
||||
@@ -1598,6 +1676,10 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_desktopPageContextInitialized = true;
|
||||
_desktopPageContextEditMode = isEditMode;
|
||||
_desktopPageContextActiveMask = activeMask;
|
||||
}
|
||||
|
||||
private static void ApplyDesktopPageContext(Control root, bool isOnActivePage, bool isEditMode)
|
||||
@@ -2702,6 +2784,11 @@ public partial class MainWindow
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Symbol.Folder;
|
||||
}
|
||||
|
||||
return Symbol.Apps;
|
||||
}
|
||||
|
||||
@@ -2747,6 +2834,11 @@ public partial class MainWindow
|
||||
return L("component_category.study", "Study");
|
||||
}
|
||||
|
||||
if (string.Equals(categoryId, "File", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return L("component_category.file", "File");
|
||||
}
|
||||
|
||||
return categoryId;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Animation;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
@@ -16,6 +17,7 @@ using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -54,6 +56,8 @@ public partial class MainWindow
|
||||
private int _currentDesktopSurfaceIndex;
|
||||
private double _desktopSurfacePageWidth;
|
||||
private TranslateTransform? _desktopPagesHostTransform;
|
||||
private Transitions? _desktopPagesHostSnapTransitions;
|
||||
private bool _desktopPagesHostTransitionsSuspended;
|
||||
private bool _isDesktopSwipeActive;
|
||||
private bool _isDesktopSwipeDirectionLocked;
|
||||
private Point _desktopSwipeStartPoint;
|
||||
@@ -62,6 +66,12 @@ public partial class MainWindow
|
||||
private long _desktopSwipeLastTimestamp;
|
||||
private double _desktopSwipeVelocityX;
|
||||
private double _desktopSwipeBaseOffset;
|
||||
private bool _desktopPageContextInitialized;
|
||||
private bool _desktopPageContextEditMode;
|
||||
private int _desktopPageContextActiveMask;
|
||||
private int? _desktopPageContextSettlingSourceIndex;
|
||||
private int? _desktopPageContextSettlingTargetIndex;
|
||||
private int _desktopPageContextSettleRevision;
|
||||
|
||||
private int LauncherSurfaceIndex => Math.Max(MinDesktopPageCount, _desktopPageCount);
|
||||
|
||||
@@ -164,6 +174,15 @@ public partial class MainWindow
|
||||
DesktopPagesHost.RenderTransform = _desktopPagesHostTransform;
|
||||
}
|
||||
|
||||
if (_desktopPagesHostTransitionsSuspended)
|
||||
{
|
||||
_desktopPagesHostTransform.Transitions = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
|
||||
}
|
||||
|
||||
var viewportRow = gridMetrics.RowCount > 2 ? 1 : 0;
|
||||
var viewportRowSpan = gridMetrics.RowCount > 2 ? gridMetrics.RowCount - 2 : 1;
|
||||
var pageWidth = Math.Max(1, gridMetrics.GridWidthPx);
|
||||
@@ -200,6 +219,7 @@ public partial class MainWindow
|
||||
DesktopPagesContainer.Width = pageWidth * _desktopPageCount;
|
||||
DesktopPagesContainer.Height = pageHeight;
|
||||
_desktopPageComponentGrids.Clear();
|
||||
InvalidateDesktopPageAwareComponentContextCache();
|
||||
for (var index = 0; index < _desktopPageCount; index++)
|
||||
{
|
||||
DesktopPagesContainer.ColumnDefinitions.Add(new ColumnDefinition(new GridLength(pageWidth, GridUnitType.Pixel)));
|
||||
@@ -354,6 +374,88 @@ public partial class MainWindow
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
}
|
||||
|
||||
private void SetDesktopPagesHostSnapAnimationEnabled(bool enabled)
|
||||
{
|
||||
if (_desktopPagesHostTransform is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled)
|
||||
{
|
||||
if (!_desktopPagesHostTransitionsSuspended)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopPagesHostTransform.Transitions = _desktopPagesHostSnapTransitions;
|
||||
_desktopPagesHostTransitionsSuspended = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_desktopPagesHostTransitionsSuspended)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopPagesHostSnapTransitions ??= _desktopPagesHostTransform.Transitions;
|
||||
_desktopPagesHostTransform.Transitions = null;
|
||||
_desktopPagesHostTransitionsSuspended = true;
|
||||
}
|
||||
|
||||
private void ClearDesktopPageContextSettle(bool refreshContext)
|
||||
{
|
||||
_desktopPageContextSettleRevision++;
|
||||
_desktopPageContextSettlingSourceIndex = null;
|
||||
_desktopPageContextSettlingTargetIndex = null;
|
||||
|
||||
if (refreshContext)
|
||||
{
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
}
|
||||
}
|
||||
|
||||
private void BeginDesktopPageContextSettle(int previousIndex, int targetIndex)
|
||||
{
|
||||
var sourceIndex = previousIndex >= 0 && previousIndex < _desktopPageCount
|
||||
? previousIndex
|
||||
: (int?)null;
|
||||
var destinationIndex = targetIndex >= 0 && targetIndex < _desktopPageCount
|
||||
? targetIndex
|
||||
: (int?)null;
|
||||
|
||||
if (sourceIndex == destinationIndex && destinationIndex is not null)
|
||||
{
|
||||
ClearDesktopPageContextSettle(refreshContext: false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sourceIndex is null && destinationIndex is null)
|
||||
{
|
||||
ClearDesktopPageContextSettle(refreshContext: false);
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopPageContextSettleRevision++;
|
||||
var settleRevision = _desktopPageContextSettleRevision;
|
||||
_desktopPageContextSettlingSourceIndex = sourceIndex;
|
||||
_desktopPageContextSettlingTargetIndex = destinationIndex;
|
||||
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (settleRevision != _desktopPageContextSettleRevision)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopPageContextSettlingSourceIndex = null;
|
||||
_desktopPageContextSettlingTargetIndex = null;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
},
|
||||
FluttermotionToken.Page + TimeSpan.FromMilliseconds(36));
|
||||
}
|
||||
|
||||
private void MoveSurfaceBy(int delta)
|
||||
{
|
||||
if (delta == 0)
|
||||
@@ -373,9 +475,11 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
var previousIndex = _currentDesktopSurfaceIndex;
|
||||
_currentDesktopSurfaceIndex = target;
|
||||
BeginDesktopPageContextSettle(previousIndex, target);
|
||||
ApplyDesktopSurfaceOffset();
|
||||
PersistSettings();
|
||||
SchedulePersistSettings(delayMs: Math.Max(280, (int)FluttermotionToken.Page.TotalMilliseconds + 80));
|
||||
}
|
||||
|
||||
private bool CanSwipeDesktopSurface()
|
||||
@@ -426,6 +530,7 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
ClearDesktopPageContextSettle(refreshContext: false);
|
||||
_isDesktopSwipeActive = true;
|
||||
_isDesktopSwipeDirectionLocked = false;
|
||||
_desktopSwipeStartPoint = pointerInViewport;
|
||||
@@ -603,6 +708,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_isDesktopSwipeDirectionLocked = true;
|
||||
SetDesktopPagesHostSnapAnimationEnabled(enabled: false);
|
||||
if (e.Pointer.Captured != DesktopPagesViewport)
|
||||
{
|
||||
e.Pointer.Capture(DesktopPagesViewport);
|
||||
@@ -621,6 +727,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_desktopPagesHostTransform.X = tentative;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
@@ -656,6 +763,7 @@ public partial class MainWindow
|
||||
_desktopSwipeLastTimestamp = 0;
|
||||
if (wasDirectionLocked)
|
||||
{
|
||||
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
|
||||
ApplyDesktopSurfaceOffset();
|
||||
}
|
||||
}
|
||||
@@ -682,6 +790,8 @@ public partial class MainWindow
|
||||
return false;
|
||||
}
|
||||
|
||||
SetDesktopPagesHostSnapAnimationEnabled(enabled: true);
|
||||
|
||||
var deltaX = _desktopSwipeCurrentPoint.X - _desktopSwipeStartPoint.X;
|
||||
var deltaY = _desktopSwipeCurrentPoint.Y - _desktopSwipeStartPoint.Y;
|
||||
var absDeltaX = Math.Abs(deltaX);
|
||||
|
||||
@@ -3,8 +3,9 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
@@ -16,8 +17,6 @@ using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using LibVLCSharp.Shared;
|
||||
using LibVLCSharp.Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -34,6 +33,11 @@ public partial class MainWindow
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
if (_suppressOwnSettingsReloadCount > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.Scope == SettingsScope.App && e.ChangedKeys is { Count: > 0 })
|
||||
{
|
||||
var changedKeys = e.ChangedKeys.ToArray();
|
||||
@@ -99,7 +103,11 @@ public partial class MainWindow
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
Title = L("app.title", "LanMountainDesktop");
|
||||
BackToWindowsTextBlock.Text = L("button.back_to_windows", "Back to Windows");
|
||||
var platformName = OperatingSystem.IsWindows() ? "Windows"
|
||||
: OperatingSystem.IsMacOS() ? "macOS"
|
||||
: "Linux";
|
||||
BackToWindowsTextBlock.Text = Lf("button.back_to_platform", "Back to {0}", platformName);
|
||||
ToolTip.SetTip(BackToWindowsButton, Lf("tooltip.back_to_platform", "Back to {0}", platformName));
|
||||
ComponentLibraryTitleTextBlock.Text = L("component_library.title", "Widgets");
|
||||
LauncherTitleTextBlock.Text = L("launcher.title", "App Launcher");
|
||||
LauncherSubtitleTextBlock.Text = OperatingSystem.IsLinux()
|
||||
@@ -213,7 +221,6 @@ public partial class MainWindow
|
||||
_wallpaperType = string.IsNullOrWhiteSpace(type) ? "Image" : type.Trim();
|
||||
_wallpaperPlacement = WallpaperImageBrushFactory.NormalizePlacement(placement);
|
||||
_wallpaperSolidColor = TryParseColor(color, out var parsedColor) ? parsedColor : null;
|
||||
_wallpaperVideoPath = null;
|
||||
_wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
||||
|
||||
_wallpaperBitmap?.Dispose();
|
||||
@@ -235,17 +242,6 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(_wallpaperPath);
|
||||
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;
|
||||
}
|
||||
|
||||
if (!SupportedImageExtensions.Contains(extension))
|
||||
{
|
||||
_wallpaperMediaType = WallpaperMediaType.Image;
|
||||
@@ -285,7 +281,6 @@ public partial class MainWindow
|
||||
if (_wallpaperMediaType == WallpaperMediaType.SolidColor && _wallpaperSolidColor.HasValue)
|
||||
{
|
||||
DesktopWallpaperLayer.Background = new SolidColorBrush(_wallpaperSolidColor.Value);
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -296,7 +291,6 @@ public partial class MainWindow
|
||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_wallpaperBitmap, _wallpaperPlacement);
|
||||
DesktopWallpaperImageLayer.IsVisible = true;
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,92 +302,17 @@ public partial class MainWindow
|
||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||
DesktopWallpaperImageLayer.Background = WallpaperImageBrushFactory.Create(_lastValidWallpaperBitmap, _wallpaperPlacement);
|
||||
DesktopWallpaperImageLayer.IsVisible = true;
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
return;
|
||||
}
|
||||
|
||||
DesktopWallpaperLayer.Background = _defaultDesktopBackground ?? CreateNeutralWallpaperFallbackBrush();
|
||||
ApplyVideoWallpaperPosterVisibility(
|
||||
showPoster: _wallpaperMediaType == WallpaperMediaType.Video && _videoWallpaperPosterBitmap is not null);
|
||||
}
|
||||
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
if (_wallpaperMediaType == WallpaperMediaType.Video)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_wallpaperVideoPath))
|
||||
{
|
||||
StartVideoWallpaper(_wallpaperVideoPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
StopVideoWallpaper();
|
||||
}
|
||||
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
|
||||
private void StartVideoWallpaper(string videoPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoPath) || !File.Exists(videoPath))
|
||||
{
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_libVlc ??= new LibVLC();
|
||||
_videoWallpaperPlayer ??= new MediaPlayer(_libVlc);
|
||||
|
||||
if (_videoWallpaperMedia?.Mrl != videoPath)
|
||||
{
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||
_videoWallpaperPlayer.Media = _videoWallpaperMedia;
|
||||
}
|
||||
|
||||
if (DesktopVideoWallpaperView is { } videoView)
|
||||
{
|
||||
videoView.MediaPlayer = _videoWallpaperPlayer;
|
||||
videoView.IsVisible = true;
|
||||
}
|
||||
|
||||
if (!string.Equals(_videoWallpaperPosterPath, videoPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
|
||||
}
|
||||
|
||||
if (!_videoWallpaperPlayer.IsPlaying)
|
||||
{
|
||||
_videoWallpaperPlayer.Play();
|
||||
}
|
||||
|
||||
TryCaptureVideoWallpaperPosterFrame(videoPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: _videoWallpaperPosterBitmap is not null);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopVideoWallpaper()
|
||||
{
|
||||
if (DesktopVideoWallpaperView is { } videoView)
|
||||
{
|
||||
videoView.IsVisible = false;
|
||||
}
|
||||
|
||||
_videoWallpaperPlayer?.Stop();
|
||||
_wallpaperVideoPath = null;
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: false);
|
||||
}
|
||||
|
||||
private double CalculateCurrentBackgroundLuminance()
|
||||
{
|
||||
var brush = DesktopWallpaperLayer.Background;
|
||||
@@ -473,6 +392,7 @@ public partial class MainWindow
|
||||
|
||||
private void PersistSettings()
|
||||
{
|
||||
_persistSettingsRevision++;
|
||||
if (_suppressSettingsPersistence)
|
||||
{
|
||||
return;
|
||||
@@ -480,6 +400,8 @@ public partial class MainWindow
|
||||
|
||||
try
|
||||
{
|
||||
// Saving our own state should not trigger a full external reload cycle.
|
||||
_suppressOwnSettingsReloadCount++;
|
||||
_settingsService.SaveSnapshot(SettingsScope.App, BuildAppSettingsSnapshot());
|
||||
_componentLayoutStore.SaveLayout(BuildDesktopLayoutSettingsSnapshot());
|
||||
_settingsService.SaveSnapshot(SettingsScope.Launcher, BuildLauncherSettingsSnapshot());
|
||||
@@ -488,11 +410,29 @@ public partial class MainWindow
|
||||
{
|
||||
AppLogger.Warn("SettingsRuntime", "Failed to persist settings.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_suppressOwnSettingsReloadCount > 0)
|
||||
{
|
||||
_suppressOwnSettingsReloadCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SchedulePersistSettings(int delayMs = 200)
|
||||
{
|
||||
DispatcherTimer.RunOnce(PersistSettings, TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
|
||||
var revision = ++_persistSettingsRevision;
|
||||
DispatcherTimer.RunOnce(
|
||||
() =>
|
||||
{
|
||||
if (revision != _persistSettingsRevision)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PersistSettings();
|
||||
},
|
||||
TimeSpan.FromMilliseconds(Math.Max(0, delayMs)));
|
||||
}
|
||||
|
||||
internal void ReloadFromPersistedSettings()
|
||||
@@ -564,6 +504,7 @@ public partial class MainWindow
|
||||
var latestWeatherState = _weatherSettingsService.Get();
|
||||
var latestUpdateState = _updateSettingsService.Get();
|
||||
var latestThemeState = _themeSettingsService.Get();
|
||||
var latestPrivacyState = _settingsFacade.Privacy.Get();
|
||||
return new AppSettingsSnapshot
|
||||
{
|
||||
GridShortSideCells = _targetShortSideCells,
|
||||
@@ -595,7 +536,8 @@ public partial class MainWindow
|
||||
WeatherNoTlsRequests = latestWeatherState.NoTlsRequests,
|
||||
AutoStartWithWindows = _autoStartWithWindows,
|
||||
AppRenderMode = _selectedAppRenderMode,
|
||||
AutoCheckUpdates = latestUpdateState.AutoCheckUpdates,
|
||||
UploadAnonymousCrashData = latestPrivacyState.UploadAnonymousCrashData,
|
||||
UploadAnonymousUsageData = latestPrivacyState.UploadAnonymousUsageData,
|
||||
IncludePrereleaseUpdates = latestUpdateState.IncludePrereleaseUpdates,
|
||||
UpdateChannel = latestUpdateState.UpdateChannel,
|
||||
UpdateMode = latestUpdateState.UpdateMode,
|
||||
@@ -610,6 +552,7 @@ public partial class MainWindow
|
||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarClockTransparentBackground = _statusBarClockTransparentBackground,
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent
|
||||
};
|
||||
@@ -644,112 +587,6 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyVideoWallpaperPosterVisibility(bool showPoster)
|
||||
{
|
||||
if (DesktopVideoWallpaperImage is not { } posterImage)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!showPoster ||
|
||||
_videoWallpaperPosterBitmap is null ||
|
||||
!string.Equals(_videoWallpaperPosterPath, _wallpaperVideoPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
posterImage.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
posterImage.Source = _videoWallpaperPosterBitmap;
|
||||
posterImage.IsVisible = true;
|
||||
}
|
||||
|
||||
private void TryCaptureVideoWallpaperPosterFrame(string videoPath)
|
||||
{
|
||||
if (_videoWallpaperPlayer is null || string.IsNullOrWhiteSpace(videoPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var snapshotPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"lanmountaindesktop-wallpaper-poster-{Guid.NewGuid():N}.png");
|
||||
|
||||
try
|
||||
{
|
||||
for (var attempt = 0; attempt < 12; attempt++)
|
||||
{
|
||||
await Task.Delay(250).ConfigureAwait(false);
|
||||
|
||||
if (_wallpaperMediaType != WallpaperMediaType.Video ||
|
||||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase) ||
|
||||
_videoWallpaperPlayer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_videoWallpaperPlayer.TakeSnapshot(0, snapshotPath, 640, 360))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(snapshotPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var fileInfo = new FileInfo(snapshotPath);
|
||||
if (fileInfo.Length <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Bitmap posterBitmap;
|
||||
await using (var stream = File.OpenRead(snapshotPath))
|
||||
{
|
||||
posterBitmap = new Bitmap(stream);
|
||||
}
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
if (_wallpaperMediaType != WallpaperMediaType.Video ||
|
||||
!string.Equals(_wallpaperVideoPath, videoPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
posterBitmap.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
_videoWallpaperPosterBitmap?.Dispose();
|
||||
_videoWallpaperPosterBitmap = posterBitmap;
|
||||
_videoWallpaperPosterPath = videoPath;
|
||||
ApplyVideoWallpaperPosterVisibility(showPoster: true);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort poster capture only.
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(snapshotPath))
|
||||
{
|
||||
File.Delete(snapshotPath);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort cleanup only.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
@@ -6,7 +6,7 @@
|
||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:mi="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
|
||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
@@ -123,18 +123,7 @@
|
||||
VerticalAlignment="Stretch"
|
||||
Background="Transparent" />
|
||||
|
||||
<Image x:Name="DesktopVideoWallpaperImage"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
|
||||
<vlc:VideoView x:Name="DesktopVideoWallpaperView"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
|
||||
<Grid x:Name="DesktopGrid"
|
||||
HorizontalAlignment="Center"
|
||||
@@ -156,7 +145,9 @@
|
||||
<TranslateTransform>
|
||||
<TranslateTransform.Transitions>
|
||||
<Transitions>
|
||||
<DoubleTransition Property="X" Duration="{StaticResource FluttermotionToken.Duration.Page}" />
|
||||
<DoubleTransition Property="X"
|
||||
Duration="{StaticResource FluttermotionToken.Duration.Page}"
|
||||
Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</TranslateTransform.Transitions>
|
||||
</TranslateTransform>
|
||||
@@ -167,7 +158,7 @@
|
||||
|
||||
<Border x:Name="LauncherPagePanel"
|
||||
Grid.Column="1"
|
||||
Classes="glass-panel"
|
||||
Classes="surface-translucent-panel"
|
||||
ClipToBounds="False"
|
||||
CornerRadius="36"
|
||||
Padding="18">
|
||||
@@ -175,11 +166,9 @@
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="LauncherTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Text="App Launcher" />
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="LauncherSubtitleTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Apps and folders from Windows Start Menu." />
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
@@ -197,7 +186,7 @@
|
||||
Opacity="{DynamicResource AdaptiveGlassOverlayOpacity}"
|
||||
PointerPressed="OnLauncherFolderOverlayPointerPressed">
|
||||
<Border x:Name="LauncherFolderPanel"
|
||||
Classes="mica-strong"
|
||||
Classes="surface-solid-strong"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Margin="52"
|
||||
@@ -225,8 +214,7 @@
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="Folder" />
|
||||
FontWeight="SemiBold" />
|
||||
<Button x:Name="LauncherFolderCloseButton"
|
||||
Grid.Column="2"
|
||||
Width="38"
|
||||
@@ -275,7 +263,7 @@
|
||||
</Border>
|
||||
|
||||
<Border x:Name="BottomTaskbarContainer"
|
||||
Classes="glass-island"
|
||||
Classes="surface-translucent-island"
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="1"
|
||||
@@ -299,8 +287,7 @@
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Click="OnMinimizeClick"
|
||||
ToolTip.Tip="回到Windows">
|
||||
Click="OnMinimizeClick">
|
||||
<StackPanel Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
@@ -309,8 +296,7 @@
|
||||
Icon="Window"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="BackToWindowsTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="回到Windows" />
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
@@ -445,7 +431,7 @@
|
||||
<Border x:Name="ComponentLibraryWindow"
|
||||
IsVisible="False"
|
||||
Opacity="0"
|
||||
Classes="glass-strong"
|
||||
Classes="surface-translucent-strong"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
Width="620"
|
||||
@@ -490,7 +476,7 @@
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="glass-panel"
|
||||
Classes="surface-translucent-panel"
|
||||
CornerRadius="12"
|
||||
Padding="14">
|
||||
<Grid>
|
||||
|
||||
@@ -25,7 +25,7 @@ using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using LibVLCSharp.Shared;
|
||||
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -35,7 +35,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
{
|
||||
None,
|
||||
Image,
|
||||
Video,
|
||||
SolidColor
|
||||
}
|
||||
|
||||
@@ -65,10 +64,6 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
{
|
||||
".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"
|
||||
};
|
||||
private static readonly HashSet<string> SupportedVideoExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v"
|
||||
};
|
||||
private static readonly TaskbarActionId[] DefaultPinnedTaskbarActions =
|
||||
[
|
||||
TaskbarActionId.MinimizeToWindows
|
||||
@@ -120,30 +115,11 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private Bitmap? _wallpaperBitmap;
|
||||
private Bitmap? _lastValidWallpaperBitmap;
|
||||
private string? _lastValidWallpaperPath;
|
||||
private Bitmap? _videoWallpaperPosterBitmap;
|
||||
private string? _videoWallpaperPosterPath;
|
||||
private WallpaperMediaType _wallpaperMediaType;
|
||||
private WallpaperDisplayState _wallpaperDisplayState = WallpaperDisplayState.NoWallpaperConfigured;
|
||||
private string _wallpaperPlacement = WallpaperImageBrushFactory.Fill;
|
||||
private string? _wallpaperVideoPath;
|
||||
private string _wallpaperType = "Image";
|
||||
private Color? _wallpaperSolidColor;
|
||||
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;
|
||||
private string? _wallpaperPath;
|
||||
private string _wallpaperStatus = "Current background uses solid color.";
|
||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||
@@ -155,6 +131,7 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private string _gridSpacingPreset = "Relaxed";
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private bool _statusBarClockTransparentBackground;
|
||||
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
||||
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
||||
private string _languageCode = "zh-CN";
|
||||
@@ -177,6 +154,8 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
private bool _isWeatherPreviewInProgress;
|
||||
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _externalSettingsReloadPending;
|
||||
private int _persistSettingsRevision;
|
||||
private int _suppressOwnSettingsReloadCount;
|
||||
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
|
||||
|
||||
public MainWindow()
|
||||
@@ -333,21 +312,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
||||
_detachedComponentLibraryWindow.Close();
|
||||
}
|
||||
_detachedComponentLibraryWindow = null;
|
||||
StopVideoWallpaper();
|
||||
DisposeLauncherResources();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_videoWallpaperPlayer?.Dispose();
|
||||
_videoWallpaperPlayer = null;
|
||||
_desktopVideoFrameRefreshTimer?.Stop();
|
||||
_desktopVideoFrameRefreshTimer = null;
|
||||
_videoWallpaperPosterBitmap?.Dispose();
|
||||
_videoWallpaperPosterBitmap = null;
|
||||
_videoWallpaperPosterPath = null;
|
||||
_lastValidWallpaperBitmap?.Dispose();
|
||||
_lastValidWallpaperBitmap = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
if (_recommendationInfoService is IDisposable recommendationServiceDisposable)
|
||||
{
|
||||
recommendationServiceDisposable.Dispose();
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<TextBox Text="{Binding SearchText}"
|
||||
Watermark="{Binding SearchPlaceholder}" />
|
||||
<TextBox x:Name="SearchTextBox"
|
||||
Text="{Binding SearchText}"
|
||||
Watermark="{Binding SearchPlaceholder}"
|
||||
Focusable="True"
|
||||
IsTabStop="True" />
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding RefreshCommand}"
|
||||
Content="{Binding RefreshButtonText}" />
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:LanMountainDesktop.ViewModels"
|
||||
xmlns:mdxaml="https://github.com/whistyun/Markdown.Avalonia"
|
||||
xmlns:helpers="using:LanMountainDesktop.Helpers"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.PrivacyPolicyDrawer"
|
||||
x:DataType="vm:PrivacyPolicyViewModel">
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Classes="settings-page-container"
|
||||
Margin="0,0,0,8">
|
||||
<Border Classes="settings-section-card">
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="12">
|
||||
<Border Classes="settings-section-card-icon-host"
|
||||
Width="48"
|
||||
Height="48">
|
||||
<Viewbox Stretch="Uniform">
|
||||
<fi:SymbolIcon Symbol="Document" />
|
||||
</Viewbox>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="4"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Classes="settings-card-header"
|
||||
Margin="0"
|
||||
Text="{Binding Title}" />
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding Description}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-section-card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="settings-item-description"
|
||||
IsVisible="{Binding IsLoading}"
|
||||
Text="{Binding LoadingText}" />
|
||||
|
||||
<TextBlock Classes="settings-item-description"
|
||||
IsVisible="{Binding HasError}"
|
||||
Text="{Binding ErrorText}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<mdxaml:MarkdownScrollViewer IsVisible="{Binding HasContent}"
|
||||
Markdown="{Binding MarkdownContent}"
|
||||
Engine="{x:Static helpers:PluginMarketMarkdownHelper.Engine}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,17 @@
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PrivacyPolicyDrawer : UserControl
|
||||
{
|
||||
public PrivacyPolicyDrawer()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PrivacyPolicyDrawer(PrivacyPolicyViewModel viewModel) : this()
|
||||
{
|
||||
DataContext = viewModel;
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,13 @@
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
Margin="0,4,0,8" />
|
||||
<TextBox Text="{Binding DeviceId}"
|
||||
<TextBox x:Name="DeviceIdTextBox"
|
||||
Text="{Binding DeviceId}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12" />
|
||||
FontSize="12"
|
||||
Focusable="False"
|
||||
IsTabStop="False" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="{Binding RefreshDeviceIdText}"
|
||||
@@ -58,6 +61,28 @@
|
||||
Classes="accent-button" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
Margin="0,16,0,0"
|
||||
Spacing="4">
|
||||
<TextBlock Text="{Binding PrivacyPolicyHintPrefix}"
|
||||
FontSize="13"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Content="{Binding ViewPrivacyPolicyText}"
|
||||
Command="{Binding ViewPrivacyPolicyCommand}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource SystemAccentColor}"
|
||||
Cursor="Hand">
|
||||
<Button.Styles>
|
||||
<Style Selector="Button:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="TextBlock.Foreground" Value="{DynamicResource SystemAccentColorDark1}" />
|
||||
</Style>
|
||||
</Button.Styles>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -22,9 +22,17 @@ public partial class PrivacySettingsPage : SettingsPageBase
|
||||
public PrivacySettingsPage(PrivacySettingsPageViewModel viewModel)
|
||||
{
|
||||
ViewModel = viewModel;
|
||||
ViewModel.ViewPrivacyPolicyRequested += OnViewPrivacyPolicyRequested;
|
||||
DataContext = ViewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public PrivacySettingsPageViewModel ViewModel { get; }
|
||||
|
||||
private void OnViewPrivacyPolicyRequested()
|
||||
{
|
||||
var privacyPolicyViewModel = new PrivacyPolicyViewModel();
|
||||
var drawer = new PrivacyPolicyDrawer(privacyPolicyViewModel);
|
||||
OpenDrawer(drawer, privacyPolicyViewModel.Title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@
|
||||
</ComboBox>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="16">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding ClockTransparentBackgroundLabel}" />
|
||||
<TextBlock Text="{Binding ClockTransparentBackgroundDescription}"
|
||||
Opacity="0.75"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<ToggleSwitch Grid.Column="1"
|
||||
IsChecked="{Binding ClockTransparentBackground}"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Separator Classes="settings-separator" />
|
||||
|
||||
@@ -21,12 +21,17 @@
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Opacity" Value="0.68" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="MaxWidth" Value="200" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.update-kv-value">
|
||||
<Setter Property="FontSize" Value="14" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
<Setter Property="MaxWidth" Value="200" />
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
@@ -64,10 +69,12 @@
|
||||
Content="{Binding CheckForUpdatesButtonText}" />
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,*"
|
||||
ColumnSpacing="14"
|
||||
RowSpacing="12">
|
||||
<StackPanel Grid.Column="0"
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnSpacing="20"
|
||||
RowSpacing="16">
|
||||
<StackPanel Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Spacing="4">
|
||||
<TextBlock Classes="update-kv-label"
|
||||
Text="{Binding CurrentVersionLabel}" />
|
||||
@@ -75,7 +82,8 @@
|
||||
Text="{Binding CurrentVersionText}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
<StackPanel Grid.Row="0"
|
||||
Grid.Column="1"
|
||||
Spacing="4"
|
||||
IsVisible="{Binding IsLatestVersionVisible}">
|
||||
<TextBlock Classes="update-kv-label"
|
||||
@@ -105,18 +113,27 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<StackPanel Spacing="12"
|
||||
HorizontalAlignment="Left">
|
||||
<TextBlock Classes="settings-item-description"
|
||||
Text="{Binding UpdateStatus}" />
|
||||
Text="{Binding UpdateStatus}"
|
||||
TextWrapping="Wrap"
|
||||
HorizontalAlignment="Left"
|
||||
MaxWidth="500" />
|
||||
|
||||
<ProgressBar Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding DownloadProgressValue}"
|
||||
IsVisible="{Binding IsDownloadProgressVisible}" />
|
||||
IsVisible="{Binding IsDownloadProgressVisible}"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="0,4,0,4" />
|
||||
|
||||
<TextBlock Classes="settings-item-description"
|
||||
IsVisible="{Binding IsDownloadProgressVisible}"
|
||||
Text="{Binding DownloadProgressText}" />
|
||||
Text="{Binding DownloadProgressText}"
|
||||
TextWrapping="Wrap"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,4,0,0" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Orientation="Horizontal"
|
||||
@@ -210,15 +227,7 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<ui:SettingsExpander Classes="settings-expander-card"
|
||||
Header="{Binding AutoCheckUpdatesLabel}">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="ClockAlarm" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding AutoCheckUpdates}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -21,27 +21,11 @@
|
||||
CornerRadius="48"
|
||||
BoxShadow="0 12 32 #50000000">
|
||||
<Panel Background="{DynamicResource AdaptiveSurfaceBaseBrush}" Margin="2">
|
||||
<!-- 图片/视频预览 -->
|
||||
<!-- 图片预览 -->
|
||||
<Border Background="#FFF6F7F9"
|
||||
IsVisible="{Binding IsImage}">
|
||||
<Border Background="{Binding PreviewBrush}" />
|
||||
</Border>
|
||||
<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>
|
||||
<!-- 纯色预览 -->
|
||||
<Border Background="{Binding SelectedColor}"
|
||||
IsVisible="{Binding IsSolidColor}" />
|
||||
@@ -52,31 +36,104 @@
|
||||
|
||||
<!-- 右侧:颜色选择网格 -->
|
||||
<StackPanel Grid.Column="1" VerticalAlignment="Center" Spacing="12" IsVisible="{Binding IsSolidColor}">
|
||||
<TextBlock Text="{Binding WallpaperColorLabel}"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
<TextBlock Text="{Binding WallpaperColorLabel}"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Opacity="0.8" />
|
||||
<ItemsControl ItemsSource="{Binding PresetColors}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="4" Rows="3" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="{Binding}"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding $parent[UserControl].((vm:WallpaperSettingsPageViewModel)DataContext).SelectColorCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{Binding}">
|
||||
<!-- 简单的悬停与选中效果由 Button 默认样式提供,
|
||||
由于使用了低饱和度颜色,背景色本身就很柔和 -->
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<UniformGrid Columns="4" Rows="3">
|
||||
<!-- 预设颜色 1-11 -->
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#D8A7B1"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#D8A7B1"
|
||||
ToolTip.Tip="#D8A7B1" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#B6C9BB"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#B6C9BB"
|
||||
ToolTip.Tip="#B6C9BB" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#A2B5BB"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#A2B5BB"
|
||||
ToolTip.Tip="#A2B5BB" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#E6E2D3"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#E6E2D3"
|
||||
ToolTip.Tip="#E6E2D3" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#B5A397"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#B5A397"
|
||||
ToolTip.Tip="#B5A397" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#C5C1C0"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#C5C1C0"
|
||||
ToolTip.Tip="#C5C1C0" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#D4BE8D"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#D4BE8D"
|
||||
ToolTip.Tip="#D4BE8D" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#C08261"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#C08261"
|
||||
ToolTip.Tip="#C08261" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#8E9775"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#8E9775"
|
||||
ToolTip.Tip="#8E9775" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#9FBAD3"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#9FBAD3"
|
||||
ToolTip.Tip="#9FBAD3" />
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="#E5BAA2"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
Command="{Binding SelectColorCommand}"
|
||||
CommandParameter="#E5BAA2"
|
||||
ToolTip.Tip="#E5BAA2" />
|
||||
<!-- 第12个位置:自定义颜色选择器 -->
|
||||
<Button Width="48" Height="48" Margin="4" Padding="0"
|
||||
Background="{Binding CustomColorBrush}"
|
||||
BorderThickness="0"
|
||||
CornerRadius="6"
|
||||
ToolTip.Tip="自定义颜色">
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedLeft">
|
||||
<StackPanel Width="260" Spacing="12">
|
||||
<ColorPicker Color="{Binding CustomColor}" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</UniformGrid>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -105,7 +162,7 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<!-- 图片/视频文件选择 -->
|
||||
<!-- 图片文件选择 -->
|
||||
<ui:SettingsExpander Header="{Binding WallpaperPathLabel}"
|
||||
IsVisible="{Binding IsImage}"
|
||||
Margin="0,4,0,0">
|
||||
@@ -129,7 +186,7 @@
|
||||
<!-- 填充方式 -->
|
||||
<ui:SettingsExpander Header="{Binding WallpaperPlacementLabel}"
|
||||
Description="{Binding WallpaperPlacementDescription}"
|
||||
IsVisible="{Binding IsImageOrVideo}"
|
||||
IsVisible="{Binding IsImage}"
|
||||
Margin="0,4,0,0">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Maximize" />
|
||||
@@ -147,12 +204,6 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<TextBlock Margin="0,8,0,0"
|
||||
IsVisible="{Binding IsVideo}"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="{Binding VideoModeHintText}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -96,8 +96,11 @@
|
||||
<StackPanel Spacing="14">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<TextBox Text="{Binding SearchKeyword}"
|
||||
Watermark="{Binding SearchPlaceholder}" />
|
||||
<TextBox x:Name="SearchKeywordTextBox"
|
||||
Text="{Binding SearchKeyword}"
|
||||
Watermark="{Binding SearchPlaceholder}"
|
||||
Focusable="True"
|
||||
IsTabStop="True" />
|
||||
<Button Grid.Column="1"
|
||||
Command="{Binding SearchCommand}"
|
||||
Content="{Binding SearchButtonText}" />
|
||||
@@ -178,12 +181,18 @@
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<TextBox Text="{Binding LocationKey}"
|
||||
Watermark="{Binding LocationKeyPlaceholder}" />
|
||||
<TextBox x:Name="LocationKeyTextBox"
|
||||
Text="{Binding LocationKey}"
|
||||
Watermark="{Binding LocationKeyPlaceholder}"
|
||||
Focusable="True"
|
||||
IsTabStop="True" />
|
||||
</ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem>
|
||||
<TextBox Text="{Binding LocationName}"
|
||||
Watermark="{Binding LocationNamePlaceholder}" />
|
||||
<TextBox x:Name="LocationNameTextBox"
|
||||
Text="{Binding LocationName}"
|
||||
Watermark="{Binding LocationNamePlaceholder}"
|
||||
Focusable="True"
|
||||
IsTabStop="True" />
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
@@ -230,11 +239,14 @@
|
||||
<fi:SymbolIconSource Symbol="Warning" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<TextBox Width="360"
|
||||
<TextBox x:Name="ExcludedAlertsTextBox"
|
||||
Width="360"
|
||||
MinHeight="120"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ExcludedAlerts}" />
|
||||
Text="{Binding ExcludedAlerts}"
|
||||
Focusable="True"
|
||||
IsTabStop="True" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user