mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:54:26 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73cdefe296 | ||
|
|
46a8df5900 | ||
|
|
2a1c09ae39 |
28
.github/workflows/build.yml
vendored
28
.github/workflows/build.yml
vendored
@@ -113,3 +113,31 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
LanMountainDesktop/bin/Release/
|
LanMountainDesktop/bin/Release/
|
||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
|
pack-plugin-packages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Pack_Plugin_Packages
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||||
|
|
||||||
|
- name: Pack SDK and template packages
|
||||||
|
shell: pwsh
|
||||||
|
run: .\scripts\Pack-PluginPackages.ps1 -Configuration Release -OutputPath .\artifacts\nuget
|
||||||
|
|
||||||
|
- name: Upload plugin package artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: plugin-packages
|
||||||
|
path: artifacts/nuget/*.nupkg
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
|
|||||||
@@ -1,578 +0,0 @@
|
|||||||
# 移除视频壁纸功能 - 技术设计文档
|
|
||||||
|
|
||||||
## 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 控件残留
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
# 移除视频壁纸功能规格说明书
|
|
||||||
|
|
||||||
## 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** 壁纸设置功能正常工作(仅支持图片和纯色)
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
# 移除视频壁纸功能 - 编码任务清单
|
|
||||||
|
|
||||||
## 任务概览
|
|
||||||
|
|
||||||
本文档将技术设计分解为可执行的编码任务,按依赖关系排序执行。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 任务 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
|
|
||||||
@@ -5,16 +5,20 @@ namespace LanMountainDesktop.DesktopHost;
|
|||||||
|
|
||||||
public static class DesktopBootstrap
|
public static class DesktopBootstrap
|
||||||
{
|
{
|
||||||
public static void InitializeStartupServices(Action initializeDeviceId, Action initializeCrashReporting, Action initializeUserBehaviorAnalytics, Action scheduleStartupCleanup)
|
public static void InitializeStartupServices(
|
||||||
|
Action initializeTelemetryIdentity,
|
||||||
|
Action initializeCrashTelemetry,
|
||||||
|
Action initializeUsageTelemetry,
|
||||||
|
Action scheduleStartupCleanup)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(initializeDeviceId);
|
ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
|
||||||
ArgumentNullException.ThrowIfNull(initializeCrashReporting);
|
ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
|
||||||
ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
|
ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
|
||||||
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
|
||||||
|
|
||||||
initializeDeviceId();
|
initializeTelemetryIdentity();
|
||||||
initializeCrashReporting();
|
initializeCrashTelemetry();
|
||||||
initializeUserBehaviorAnalytics();
|
initializeUsageTelemetry();
|
||||||
scheduleStartupCleanup();
|
scheduleStartupCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,15 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<Version>3.0.0</Version>
|
<Version>4.0.0</Version>
|
||||||
|
<PackageId>LanMountainDesktop.PluginSdk</PackageId>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>Official plugin SDK for LanMountainDesktop, including plugin manifest contracts, runtime interfaces, and registration extensions.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;Plugin;SDK;Avalonia</PackageTags>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -15,4 +23,10 @@
|
|||||||
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
<ProjectReference Include="..\LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.props" Pack="true" PackagePath="buildTransitive\" />
|
||||||
|
<None Include="buildTransitive\LanMountainDesktop.PluginSdk.targets" Pack="true" PackagePath="buildTransitive\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
21
LanMountainDesktop.PluginSdk/README.md
Normal file
21
LanMountainDesktop.PluginSdk/README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# LanMountainDesktop.PluginSdk
|
||||||
|
|
||||||
|
Official SDK package for LanMountainDesktop plugins.
|
||||||
|
|
||||||
|
## Includes
|
||||||
|
|
||||||
|
- `IPlugin`/`PluginBase` entry abstractions
|
||||||
|
- `PluginManifest` and shared contract declarations
|
||||||
|
- desktop component registration extensions
|
||||||
|
- plugin runtime context and host service abstractions
|
||||||
|
- build-transitive packaging targets for `.laapp` output
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="4.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `plugin.json` in your plugin project root, then run `dotnet build` to produce both build output and a `.laapp` package.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<LanMountainPluginManifestFileName Condition="'$(LanMountainPluginManifestFileName)' == ''">plugin.json</LanMountainPluginManifestFileName>
|
||||||
|
<LanMountainPluginPackageExtension Condition="'$(LanMountainPluginPackageExtension)' == ''">.laapp</LanMountainPluginPackageExtension>
|
||||||
|
<LanMountainPluginPackageOutputDirectory Condition="'$(LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</LanMountainPluginPackageOutputDirectory>
|
||||||
|
|
||||||
|
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == '' and Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">true</LanMountainPluginEnablePackaging>
|
||||||
|
<LanMountainPluginEnablePackaging Condition="'$(LanMountainPluginEnablePackaging)' == ''">false</LanMountainPluginEnablePackaging>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')">
|
||||||
|
<None Update="$(LanMountainPluginManifestFileName)" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<Project>
|
||||||
|
<Target Name="ValidateLanMountainPluginManifest"
|
||||||
|
BeforeTargets="Build"
|
||||||
|
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||||
|
<Error Condition="!Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')"
|
||||||
|
Text="LanMountain plugin packaging is enabled, but '$(LanMountainPluginManifestFileName)' was not found in '$(MSBuildProjectDirectory)'." />
|
||||||
|
</Target>
|
||||||
|
|
||||||
|
<Target Name="CreateLanMountainPluginPackage"
|
||||||
|
AfterTargets="Build"
|
||||||
|
Condition="'$(LanMountainPluginEnablePackaging)' == 'true'">
|
||||||
|
<PropertyGroup>
|
||||||
|
<_LanMountainPluginBuildOutputDirectory>$(LanMountainPluginBuildOutputDirectory)</_LanMountainPluginBuildOutputDirectory>
|
||||||
|
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(TargetDir)</_LanMountainPluginBuildOutputDirectory>
|
||||||
|
<_LanMountainPluginBuildOutputDirectory Condition="'$(_LanMountainPluginBuildOutputDirectory)' == ''">$(MSBuildProjectDirectory)\$(OutputPath)</_LanMountainPluginBuildOutputDirectory>
|
||||||
|
<_LanMountainPluginAssemblyName>$(LanMountainPluginAssemblyName)</_LanMountainPluginAssemblyName>
|
||||||
|
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == '' and '$(AssemblyName)' != ''">$(AssemblyName)</_LanMountainPluginAssemblyName>
|
||||||
|
<_LanMountainPluginAssemblyName Condition="'$(_LanMountainPluginAssemblyName)' == ''">$(MSBuildProjectName)</_LanMountainPluginAssemblyName>
|
||||||
|
<_LanMountainPluginPackageVersion>$(LanMountainPluginPackageVersion)</_LanMountainPluginPackageVersion>
|
||||||
|
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == '' and '$(Version)' != ''">$(Version)</_LanMountainPluginPackageVersion>
|
||||||
|
<_LanMountainPluginPackageVersion Condition="'$(_LanMountainPluginPackageVersion)' == ''">1.0.0</_LanMountainPluginPackageVersion>
|
||||||
|
<_LanMountainPluginPackageOutputDirectory>$(LanMountainPluginPackageOutputDirectory)</_LanMountainPluginPackageOutputDirectory>
|
||||||
|
<_LanMountainPluginPackageOutputDirectory Condition="'$(_LanMountainPluginPackageOutputDirectory)' == ''">$(MSBuildProjectDirectory)\</_LanMountainPluginPackageOutputDirectory>
|
||||||
|
<_LanMountainPluginPackageFileName>$(LanMountainPluginPackageFileName)</_LanMountainPluginPackageFileName>
|
||||||
|
<_LanMountainPluginPackageFileName Condition="'$(_LanMountainPluginPackageFileName)' == ''">$(_LanMountainPluginAssemblyName).$(_LanMountainPluginPackageVersion)$(LanMountainPluginPackageExtension)</_LanMountainPluginPackageFileName>
|
||||||
|
<_LanMountainPluginPackagePath>$(LanMountainPluginPackagePath)</_LanMountainPluginPackagePath>
|
||||||
|
<_LanMountainPluginPackagePath Condition="'$(_LanMountainPluginPackagePath)' == ''">$(_LanMountainPluginPackageOutputDirectory)$(_LanMountainPluginPackageFileName)</_LanMountainPluginPackagePath>
|
||||||
|
<_LanMountainPluginManifestOutputPath>$(_LanMountainPluginBuildOutputDirectory)$(LanMountainPluginManifestFileName)</_LanMountainPluginManifestOutputPath>
|
||||||
|
<_LanMountainPluginDepsPath>$(ProjectDepsFilePath)</_LanMountainPluginDepsPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<Copy SourceFiles="$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)"
|
||||||
|
DestinationFiles="$(_LanMountainPluginManifestOutputPath)"
|
||||||
|
SkipUnchangedFiles="true"
|
||||||
|
Condition="Exists('$(MSBuildProjectDirectory)\$(LanMountainPluginManifestFileName)')" />
|
||||||
|
|
||||||
|
<Error Condition="!Exists('$(_LanMountainPluginManifestOutputPath)')"
|
||||||
|
Text="Plugin manifest '$(_LanMountainPluginManifestOutputPath)' was not found in build output. Ensure '$(LanMountainPluginManifestFileName)' is copied to output." />
|
||||||
|
<Error Condition="!Exists('$(TargetPath)')"
|
||||||
|
Text="Plugin assembly '$(TargetPath)' was not found. Build output is incomplete." />
|
||||||
|
<Error Condition="'$(_LanMountainPluginDepsPath)' != '' and !Exists('$(_LanMountainPluginDepsPath)')"
|
||||||
|
Text="Plugin deps file '$(_LanMountainPluginDepsPath)' was not found. Plugin packages must include a .deps.json file." />
|
||||||
|
|
||||||
|
<MakeDir Directories="$(_LanMountainPluginPackageOutputDirectory)" />
|
||||||
|
<Delete Files="$(_LanMountainPluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||||
|
<ZipDirectory SourceDirectory="$(_LanMountainPluginBuildOutputDirectory)"
|
||||||
|
DestinationFile="$(_LanMountainPluginPackagePath)" />
|
||||||
|
<Message Importance="High"
|
||||||
|
Text="LanMountain plugin package generated: $(_LanMountainPluginPackagePath)" />
|
||||||
|
</Target>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<NoWarn>$(NoWarn);NU5128</NoWarn>
|
||||||
|
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||||
|
<PackageId>LanMountainDesktop.PluginTemplate</PackageId>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>Official dotnet new template package for LanMountainDesktop plugins.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;Plugin;Template;dotnet-new</PackageTags>
|
||||||
|
<PackageType>Template</PackageType>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<NoDefaultExcludes>true</NoDefaultExcludes>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="content\**\*.cs" />
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
<None Include="content\**\*" Pack="true" PackagePath="content\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
17
LanMountainDesktop.PluginTemplate/README.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# LanMountainDesktop.PluginTemplate
|
||||||
|
|
||||||
|
Official `dotnet new` template package for LanMountainDesktop plugins.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet new install LanMountainDesktop.PluginTemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Create a plugin
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet new lmd-plugin -n YourPluginName
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated project references `LanMountainDesktop.PluginSdk` and produces a `.laapp` package automatically when built.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/template",
|
||||||
|
"author": "LanMountainDesktop",
|
||||||
|
"classifications": [
|
||||||
|
"LanMountainDesktop",
|
||||||
|
"Plugin",
|
||||||
|
"Desktop"
|
||||||
|
],
|
||||||
|
"name": "LanMountainDesktop Plugin",
|
||||||
|
"identity": "LanMountainDesktop.PluginTemplate.CSharp",
|
||||||
|
"shortName": "lmd-plugin",
|
||||||
|
"sourceName": "LanMountainDesktop.PluginTemplate",
|
||||||
|
"preferNameDirectory": true,
|
||||||
|
"tags": {
|
||||||
|
"type": "project",
|
||||||
|
"language": "C#"
|
||||||
|
},
|
||||||
|
"symbols": {
|
||||||
|
"pluginId": {
|
||||||
|
"type": "parameter",
|
||||||
|
"datatype": "text",
|
||||||
|
"defaultValue": "LanMountainDesktop.PluginTemplate",
|
||||||
|
"description": "Plugin manifest id.",
|
||||||
|
"replaces": "__PLUGIN_ID__"
|
||||||
|
},
|
||||||
|
"pluginAuthor": {
|
||||||
|
"type": "parameter",
|
||||||
|
"datatype": "text",
|
||||||
|
"defaultValue": "Your Name",
|
||||||
|
"description": "Plugin author.",
|
||||||
|
"replaces": "__PLUGIN_AUTHOR__"
|
||||||
|
},
|
||||||
|
"pluginName": {
|
||||||
|
"type": "parameter",
|
||||||
|
"datatype": "text",
|
||||||
|
"defaultValue": "LanMountain Plugin Template",
|
||||||
|
"description": "Display name shown in plugin manifest.",
|
||||||
|
"replaces": "__PLUGIN_NAME__"
|
||||||
|
},
|
||||||
|
"pluginDescription": {
|
||||||
|
"type": "parameter",
|
||||||
|
"datatype": "text",
|
||||||
|
"defaultValue": "Plugin generated from the official LanMountainDesktop template.",
|
||||||
|
"description": "Plugin description shown in plugin manifest.",
|
||||||
|
"replaces": "__PLUGIN_DESCRIPTION__"
|
||||||
|
},
|
||||||
|
"pluginSdkVersion": {
|
||||||
|
"type": "parameter",
|
||||||
|
"datatype": "text",
|
||||||
|
"defaultValue": "4.0.0",
|
||||||
|
"description": "LanMountainDesktop.PluginSdk package version.",
|
||||||
|
"replaces": "__PLUGIN_SDK_VERSION__"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||||
|
<LanMountainPluginPackageVersion>$(Version)</LanMountainPluginPackageVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LanMountainDesktop.PluginSdk" Version="__PLUGIN_SDK_VERSION__" ExcludeAssets="runtime" PrivateAssets="all" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
15
LanMountainDesktop.PluginTemplate/content/Plugin.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.PluginTemplate;
|
||||||
|
|
||||||
|
[PluginEntrance]
|
||||||
|
public sealed class Plugin : PluginBase
|
||||||
|
{
|
||||||
|
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||||
|
{
|
||||||
|
_ = context;
|
||||||
|
_ = services;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
24
LanMountainDesktop.PluginTemplate/content/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# __PLUGIN_NAME__
|
||||||
|
|
||||||
|
Official-style plugin scaffold generated for LanMountainDesktop.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
dotnet build -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
`LanMountainDesktop.PluginSdk` build targets will generate:
|
||||||
|
|
||||||
|
- plugin output files under `bin/<Configuration>/<TFM>/`
|
||||||
|
- a `.laapp` package in the project root
|
||||||
|
|
||||||
|
## Manifest
|
||||||
|
|
||||||
|
Update `plugin.json` fields as needed before release:
|
||||||
|
|
||||||
|
- `id`
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `author`
|
||||||
|
- `version`
|
||||||
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
10
LanMountainDesktop.PluginTemplate/content/plugin.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"id": "__PLUGIN_ID__",
|
||||||
|
"name": "__PLUGIN_NAME__",
|
||||||
|
"description": "__PLUGIN_DESCRIPTION__",
|
||||||
|
"author": "__PLUGIN_AUTHOR__",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"apiVersion": "4.0.0",
|
||||||
|
"entranceAssembly": "LanMountainDesktop.PluginTemplate.dll",
|
||||||
|
"sharedContracts": []
|
||||||
|
}
|
||||||
@@ -3,8 +3,20 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<PackageId>LanMountainDesktop.Shared.Contracts</PackageId>
|
||||||
|
<IsPackable>true</IsPackable>
|
||||||
|
<Authors>LanMountainDesktop</Authors>
|
||||||
|
<Description>Shared contracts used by LanMountainDesktop host and plugins for cross-boundary communication.</Description>
|
||||||
|
<PackageTags>LanMountainDesktop;Plugin;SharedContracts;Avalonia</PackageTags>
|
||||||
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
|
<RepositoryUrl>https://github.com/wwiinnddyy/LanMountainDesktop</RepositoryUrl>
|
||||||
|
<RepositoryType>git</RepositoryType>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<None Include="README.md" Pack="true" PackagePath="\" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
16
LanMountainDesktop.Shared.Contracts/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# LanMountainDesktop.Shared.Contracts
|
||||||
|
|
||||||
|
Shared contracts package for LanMountainDesktop host and plugin ecosystems.
|
||||||
|
|
||||||
|
## Includes
|
||||||
|
|
||||||
|
- cross-boundary records used by host/runtime and plugins
|
||||||
|
- contract types intended for stable shared communication
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="LanMountainDesktop.Shared.Contracts" Version="1.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
```
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
|
||||||
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||||
|
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
|
||||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||||
|
|||||||
@@ -71,4 +71,5 @@
|
|||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
</Style>
|
</Style>
|
||||||
</Application.Styles>
|
</Application.Styles>
|
||||||
|
|
||||||
</Application>
|
</Application>
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ public partial class App : Application
|
|||||||
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
|
||||||
private ShutdownIntent _shutdownIntent;
|
private ShutdownIntent _shutdownIntent;
|
||||||
|
|
||||||
private TrayIcons? _trayIcons;
|
private TrayIcon? _trayIcon;
|
||||||
|
private NativeMenuItem? _trayShowDesktopMenuItem;
|
||||||
|
private NativeMenuItem? _traySettingsMenuItem;
|
||||||
|
private NativeMenuItem? _trayComponentLibraryMenuItem;
|
||||||
|
private NativeMenuItem? _trayRestartMenuItem;
|
||||||
|
private NativeMenuItem? _trayExitMenuItem;
|
||||||
private PluginRuntimeService? _pluginRuntimeService;
|
private PluginRuntimeService? _pluginRuntimeService;
|
||||||
private MainWindow? _mainWindow;
|
private MainWindow? _mainWindow;
|
||||||
private bool _mainWindowClosed;
|
private bool _mainWindowClosed;
|
||||||
@@ -65,7 +70,6 @@ public partial class App : Application
|
|||||||
private DesktopShellHost? _desktopShellHost;
|
private DesktopShellHost? _desktopShellHost;
|
||||||
|
|
||||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
|
||||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||||
(Current as App)?._hostApplicationLifecycle;
|
(Current as App)?._hostApplicationLifecycle;
|
||||||
|
|
||||||
@@ -244,18 +248,43 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DisposeTrayIcon();
|
if (_trayIcon is null)
|
||||||
|
|
||||||
var trayIcon = new TrayIcon
|
|
||||||
{
|
{
|
||||||
Icon = _appLogoService.CreateTrayIcon(),
|
_trayShowDesktopMenuItem = new NativeMenuItem();
|
||||||
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
|
||||||
Menu = BuildTrayMenu(),
|
|
||||||
IsVisible = true
|
|
||||||
};
|
|
||||||
|
|
||||||
_trayIcons = [trayIcon];
|
_traySettingsMenuItem = new NativeMenuItem();
|
||||||
TrayIcon.SetIcons(this, _trayIcons);
|
_traySettingsMenuItem.Click += OnTraySettingsClick;
|
||||||
|
|
||||||
|
_trayComponentLibraryMenuItem = new NativeMenuItem();
|
||||||
|
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
|
||||||
|
|
||||||
|
_trayRestartMenuItem = new NativeMenuItem();
|
||||||
|
_trayRestartMenuItem.Click += OnTrayRestartClick;
|
||||||
|
|
||||||
|
_trayExitMenuItem = new NativeMenuItem();
|
||||||
|
_trayExitMenuItem.Click += OnTrayExitClick;
|
||||||
|
|
||||||
|
var trayMenu = new NativeMenu();
|
||||||
|
trayMenu.Items.Add(_trayShowDesktopMenuItem);
|
||||||
|
trayMenu.Items.Add(_traySettingsMenuItem);
|
||||||
|
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
|
||||||
|
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
trayMenu.Items.Add(_trayRestartMenuItem);
|
||||||
|
trayMenu.Items.Add(new NativeMenuItemSeparator());
|
||||||
|
trayMenu.Items.Add(_trayExitMenuItem);
|
||||||
|
|
||||||
|
_trayIcon = new TrayIcon
|
||||||
|
{
|
||||||
|
Icon = _appLogoService.CreateTrayIcon(),
|
||||||
|
Menu = trayMenu,
|
||||||
|
IsVisible = true
|
||||||
|
};
|
||||||
|
|
||||||
|
TrayIcon.SetIcons(this, [_trayIcon]);
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshTrayIconContent();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -263,51 +292,58 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private NativeMenu BuildTrayMenu()
|
private void RefreshTrayIconContent()
|
||||||
{
|
{
|
||||||
var menu = new NativeMenu();
|
if (_trayIcon is not null)
|
||||||
|
{
|
||||||
|
_trayIcon.IsVisible = true;
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
|
if (_trayShowDesktopMenuItem is not null)
|
||||||
showDesktopItem.Click += OnTrayShowDesktopClick;
|
{
|
||||||
menu.Items.Add(showDesktopItem);
|
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
|
||||||
|
}
|
||||||
|
|
||||||
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
|
if (_traySettingsMenuItem is not null)
|
||||||
settingsItem.Click += OnTraySettingsClick;
|
{
|
||||||
menu.Items.Add(settingsItem);
|
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
|
||||||
|
}
|
||||||
|
|
||||||
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
|
if (_trayComponentLibraryMenuItem is not null)
|
||||||
componentLibraryItem.Click += OnTrayComponentLibraryClick;
|
{
|
||||||
menu.Items.Add(componentLibraryItem);
|
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
|
||||||
|
}
|
||||||
|
|
||||||
menu.Items.Add(new NativeMenuItemSeparator());
|
if (_trayRestartMenuItem is not null)
|
||||||
|
{
|
||||||
|
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
|
||||||
|
}
|
||||||
|
|
||||||
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
|
if (_trayExitMenuItem is not null)
|
||||||
restartItem.Click += OnTrayRestartClick;
|
{
|
||||||
menu.Items.Add(restartItem);
|
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
|
||||||
|
}
|
||||||
menu.Items.Add(new NativeMenuItemSeparator());
|
|
||||||
|
|
||||||
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
|
|
||||||
exitItem.Click += OnTrayExitClick;
|
|
||||||
menu.Items.Add(exitItem);
|
|
||||||
|
|
||||||
return menu;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void DisposeTrayIcon()
|
private void DisposeTrayIcon()
|
||||||
{
|
{
|
||||||
if (_trayIcons is null)
|
if (_trayIcon is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
TrayIcon.SetIcons(this, null);
|
try
|
||||||
foreach (var trayIcon in _trayIcons)
|
|
||||||
{
|
{
|
||||||
trayIcon.Dispose();
|
_trayIcon.IsVisible = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
_trayIcons = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureSettingsWindowService()
|
private void EnsureSettingsWindowService()
|
||||||
@@ -520,10 +556,7 @@ public partial class App : Application
|
|||||||
// 清除本地化缓存,强制重新加载语言文件
|
// 清除本地化缓存,强制重新加载语言文件
|
||||||
_localizationService.ClearCache();
|
_localizationService.ClearCache();
|
||||||
ApplyCurrentCultureFromSettings();
|
ApplyCurrentCultureFromSettings();
|
||||||
if (_trayIcons is not null)
|
RefreshTrayIconContent();
|
||||||
{
|
|
||||||
InitializeTrayIcon();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, DispatcherPriority.Background);
|
}, DispatcherPriority.Background);
|
||||||
}
|
}
|
||||||
@@ -591,13 +624,13 @@ public partial class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var (analytics, crashReport) = App.AnalyticsServices;
|
TelemetryServices.Usage?.Shutdown(
|
||||||
analytics?.SendShutdownEvent();
|
_shutdownIntent == ShutdownIntent.RestartRequested,
|
||||||
crashReport?.SendShutdownEvent();
|
"App.PerformExitCleanup");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -631,6 +664,27 @@ public partial class App : Application
|
|||||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||||
DisposeTrayIcon();
|
DisposeTrayIcon();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TelemetryServices.Crash?.CaptureShutdown(
|
||||||
|
_shutdownIntent == ShutdownIntent.RestartRequested,
|
||||||
|
"App.PerformExitCleanup");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TelemetryServices.Crash?.Dispose();
|
||||||
|
TelemetryServices.Usage?.Dispose();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private MainWindow CreateAndAssignMainWindow(
|
private MainWindow CreateAndAssignMainWindow(
|
||||||
|
|||||||
@@ -1,326 +1,54 @@
|
|||||||
# LanMountainDesktop 隐私政策
|
# 隐私与遥测说明
|
||||||
|
|
||||||
**最后更新日期:2026年3月17日**
|
LanMountainDesktop 提供两类可选遥测能力:
|
||||||
|
|
||||||
---
|
- 崩溃数据上传
|
||||||
|
- 行为数据分析
|
||||||
|
|
||||||
## 引言
|
这两个开关默认关闭。即使两项都关闭,应用仍会在首次启动时向 PostHog 发送一次最小化的启动基线事件,用于统计用户量。
|
||||||
|
|
||||||
欢迎使用 LanMountainDesktop!我们非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的数据。
|
## 默认行为
|
||||||
|
|
||||||
**请在使用本应用前仔细阅读本隐私政策。使用本应用即表示您同意本政策的条款。**
|
当“崩溃数据上传”和“行为数据分析”都关闭时:
|
||||||
|
|
||||||
---
|
- 仅首次启动会发送一次 `app_first_launch` 事件
|
||||||
|
- 该事件只用于统计用户量
|
||||||
|
- 事件时间由 PostHog 接入侧记录的请求时间和启动时间决定
|
||||||
|
- 不会主动上传设备型号、操作系统细节、组件操作轨迹等详细信息
|
||||||
|
|
||||||
## 1. 数据收集范围
|
## 崩溃数据上传
|
||||||
|
|
||||||
### 1.1 我们收集的数据
|
当开启“崩溃数据上传”时,应用会把崩溃与未处理异常发送到 Sentry,用于分析稳定性问题。
|
||||||
|
|
||||||
当您启用匿名数据收集功能时,我们会收集以下数据:
|
上报内容可能包括:
|
||||||
|
|
||||||
#### 匿名崩溃数据
|
- 异常堆栈和错误上下文
|
||||||
- **崩溃报告**:应用崩溃时的错误日志和堆栈跟踪
|
- 应用版本与运行环境
|
||||||
- **设备信息**:操作系统版本、设备型号、架构(x64/x86)
|
- 操作系统信息
|
||||||
- **应用版本**:当前使用的应用版本号
|
- 设备基础信息
|
||||||
- **设备标识符**:匿名生成的唯一设备ID(不包含个人信息)
|
- 最近的日志尾部内容
|
||||||
|
|
||||||
#### 匿名使用数据
|
应用退出或崩溃时,会尽量补充最后一次会话和日志信息,方便定位问题。
|
||||||
- **应用启动和关闭事件**:记录应用何时启动和关闭
|
|
||||||
- **功能使用统计**:哪些功能被使用、使用频率
|
|
||||||
- **设置变更**:用户更改了哪些设置(不包含具体设置值)
|
|
||||||
- **界面交互**:点击了哪些按钮、访问了哪些页面
|
|
||||||
- **设备信息**:操作系统、应用版本、设备类型
|
|
||||||
|
|
||||||
### 1.2 始终收集的基础数据
|
## 行为数据分析
|
||||||
|
|
||||||
**重要说明:** 为了统计应用的用户数量和日活跃用户,即使您关闭了匿名数据收集开关,我们仍会收集以下基础数据:
|
当开启“行为数据分析”时,应用会把关键行为事件发送到 PostHog,用于分析功能使用情况和会话路径。
|
||||||
|
|
||||||
- ✅ **应用启动事件**:用于统计日活跃用户
|
上报内容可能包括:
|
||||||
- ✅ **设备标识符**:用于区分不同用户(不包含个人信息)
|
|
||||||
- ✅ **应用版本**:用于统计版本分布
|
|
||||||
|
|
||||||
**这些基础数据不包含任何个人身份信息,仅用于统计用户数量和应用使用情况。**
|
- 应用启动和退出时间
|
||||||
|
- 会话开始与结束时间
|
||||||
|
- 设置页打开、关闭和导航
|
||||||
|
- 抽屉打开和关闭
|
||||||
|
- 桌面组件的放置、移动、缩放、删除和编辑入口
|
||||||
|
|
||||||
### 1.3 我们不收集的数据
|
这些事件会被转换成 PostHog 可以直接接收和分析的事件格式,方便在 PostHog 中按事件流查看用户行为。桌面端的“回放”能力通过事件时间线重建,而不是浏览器式 Session Replay。
|
||||||
|
|
||||||
我们**明确承诺不收集**以下数据:
|
## 身份与隐私控制
|
||||||
|
|
||||||
- ❌ 个人身份信息(姓名、邮箱、电话等)
|
应用会使用随机生成的匿名 install ID 和可刷新 telemetry ID 来区分安装与运行会话。
|
||||||
- ❌ 真实姓名或用户名
|
|
||||||
- ❌ 地理位置信息(精确位置)
|
|
||||||
- ❌ 文件内容或文档数据
|
|
||||||
- ❌ 密码或凭据信息
|
|
||||||
- ❌ 网络浏览历史
|
|
||||||
- ❌ 联系人信息
|
|
||||||
- ❌ 照片、视频或音频文件
|
|
||||||
|
|
||||||
---
|
- 刷新 telemetry ID 只会影响后续详细遥测
|
||||||
|
- 关闭开关后,不会继续发送对应类别的详细遥测
|
||||||
|
- IP 只会通过 Sentry / PostHog 的服务端接入侧自然记录,不会作为自定义字段重复上报
|
||||||
|
|
||||||
## 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!**
|
|
||||||
|
|
||||||
我们承诺保护您的隐私,并持续改进我们的隐私保护措施。
|
|
||||||
|
|||||||
@@ -71,9 +71,11 @@ public sealed class AppSettingsSnapshot
|
|||||||
|
|
||||||
public bool UploadAnonymousUsageData { get; set; }
|
public bool UploadAnonymousUsageData { get; set; }
|
||||||
|
|
||||||
public string? DeviceId { get; set; }
|
public string? TelemetryInstallId { get; set; }
|
||||||
|
|
||||||
public string? PersistentUserId { get; set; }
|
public string? TelemetryId { get; set; }
|
||||||
|
|
||||||
|
public bool HasReportedTelemetryBaseline { get; set; }
|
||||||
|
|
||||||
public string UpdateChannel { get; set; } = "stable";
|
public string UpdateChannel { get; set; } = "stable";
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using LanMountainDesktop.DesktopHost;
|
|||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using Sentry;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop;
|
namespace LanMountainDesktop;
|
||||||
|
|
||||||
@@ -21,11 +20,6 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
AppLogger.Initialize();
|
AppLogger.Initialize();
|
||||||
RegisterGlobalExceptionLogging();
|
RegisterGlobalExceptionLogging();
|
||||||
DesktopBootstrap.InitializeStartupServices(
|
|
||||||
InitializeDeviceId,
|
|
||||||
InitializeCrashReporting,
|
|
||||||
InitializeUserBehaviorAnalytics,
|
|
||||||
ScheduleWhiteboardNoteStartupCleanup);
|
|
||||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||||
|
|
||||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||||
@@ -44,6 +38,12 @@ sealed class Program
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DesktopBootstrap.InitializeStartupServices(
|
||||||
|
InitializeTelemetryIdentity,
|
||||||
|
InitializeCrashTelemetry,
|
||||||
|
InitializeUsageTelemetry,
|
||||||
|
ScheduleWhiteboardNoteStartupCleanup);
|
||||||
|
|
||||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||||
|
|
||||||
@@ -53,7 +53,6 @@ sealed class Program
|
|||||||
StartupRenderMode = renderMode;
|
StartupRenderMode = renderMode;
|
||||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||||
App.CurrentSingleInstanceService = singleInstance;
|
App.CurrentSingleInstanceService = singleInstance;
|
||||||
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
|
|
||||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||||
AppLogger.Info("Startup", "Application exited normally.");
|
AppLogger.Info("Startup", "Application exited normally.");
|
||||||
}
|
}
|
||||||
@@ -185,204 +184,90 @@ sealed class Program
|
|||||||
{
|
{
|
||||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||||
{
|
{
|
||||||
|
var exception = eventArgs.ExceptionObject as Exception
|
||||||
|
?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception.");
|
||||||
|
|
||||||
AppLogger.Critical(
|
AppLogger.Critical(
|
||||||
"UnhandledException",
|
"UnhandledException",
|
||||||
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
||||||
eventArgs.ExceptionObject as Exception);
|
exception);
|
||||||
|
|
||||||
if (eventArgs.IsTerminating)
|
try
|
||||||
{
|
{
|
||||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
TelemetryServices.Crash?.CaptureUnhandledException(
|
||||||
|
exception,
|
||||||
|
"AppDomain.UnhandledException",
|
||||||
|
eventArgs.IsTerminating);
|
||||||
|
}
|
||||||
|
catch (Exception telemetryException)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("UnhandledException", "Failed to forward unhandled exception to crash telemetry.", telemetryException);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||||
{
|
{
|
||||||
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TelemetryServices.Crash?.CaptureTaskException(
|
||||||
|
eventArgs.Exception,
|
||||||
|
"TaskScheduler.UnobservedTaskException");
|
||||||
|
}
|
||||||
|
catch (Exception telemetryException)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("TaskScheduler", "Failed to forward task exception to crash telemetry.", telemetryException);
|
||||||
|
}
|
||||||
|
|
||||||
eventArgs.SetObserved();
|
eventArgs.SetObserved();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InitializeDeviceId()
|
private static void InitializeTelemetryIdentity()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
TelemetryIdentityService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
||||||
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
|
AppLogger.Info(
|
||||||
|
"Startup",
|
||||||
|
$"Telemetry identity initialized. InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
|
AppLogger.Warn("Startup", "Failed to initialize telemetry identity service.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InitializeSentryForAnalytics()
|
private static void InitializeCrashTelemetry()
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deviceId = DeviceIdService.Instance.DeviceId;
|
|
||||||
|
|
||||||
SentrySdk.Init(options =>
|
|
||||||
{
|
|
||||||
options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
|
||||||
options.AutoSessionTracking = true;
|
|
||||||
options.Release = GetAppVersion();
|
|
||||||
options.Environment = GetEnvironment();
|
|
||||||
});
|
|
||||||
|
|
||||||
SentrySdk.ConfigureScope(scope =>
|
|
||||||
{
|
|
||||||
scope.User = new SentryUser
|
|
||||||
{
|
|
||||||
Id = deviceId
|
|
||||||
};
|
|
||||||
|
|
||||||
scope.SetTag("data_type", "analytics");
|
|
||||||
scope.SetTag("device_id", deviceId);
|
|
||||||
scope.SetTag("app_version", GetAppVersion());
|
|
||||||
scope.SetTag("os_name", GetOsName());
|
|
||||||
scope.SetTag("os_version", GetOsVersion());
|
|
||||||
scope.SetTag("os_build", GetOsBuild());
|
|
||||||
scope.SetTag("device_model", GetDeviceModel());
|
|
||||||
scope.SetTag("device_arch", GetDeviceArchitecture());
|
|
||||||
scope.SetTag("processor_count", GetProcessorCount().ToString());
|
|
||||||
scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
|
|
||||||
scope.SetTag("runtime_version", GetRuntimeVersion());
|
|
||||||
scope.SetTag("language", GetSystemLanguage());
|
|
||||||
scope.SetTag("clr_version", GetClrVersion());
|
|
||||||
scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
|
|
||||||
});
|
|
||||||
|
|
||||||
SentrySdk.CaptureMessage("user_active");
|
|
||||||
|
|
||||||
AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetAppVersion()
|
|
||||||
{
|
|
||||||
var version = typeof(Program).Assembly.GetName().Version;
|
|
||||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetOsName()
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows()) return "Windows";
|
|
||||||
if (OperatingSystem.IsLinux()) return "Linux";
|
|
||||||
if (OperatingSystem.IsMacOS()) return "macOS";
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetOsVersion()
|
|
||||||
{
|
|
||||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
|
||||||
catch { return "Unknown"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetOsBuild()
|
|
||||||
{
|
|
||||||
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
|
||||||
catch { return "Unknown"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetDeviceName()
|
|
||||||
{
|
|
||||||
try { return Environment.MachineName ?? "Unknown"; }
|
|
||||||
catch { return "Unknown"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetDeviceModel()
|
|
||||||
{
|
|
||||||
if (OperatingSystem.IsWindows()) return "Windows PC";
|
|
||||||
if (OperatingSystem.IsLinux()) return "Linux PC";
|
|
||||||
if (OperatingSystem.IsMacOS()) return "Mac";
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetDeviceArchitecture()
|
|
||||||
{
|
|
||||||
return Environment.Is64BitOperatingSystem ? "x64" : "x86";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int GetProcessorCount()
|
|
||||||
{
|
|
||||||
return Environment.ProcessorCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long GetTotalMemoryMB()
|
|
||||||
{
|
|
||||||
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
|
||||||
catch { return 0; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetRuntimeVersion()
|
|
||||||
{
|
|
||||||
return Environment.Version.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetSystemLanguage()
|
|
||||||
{
|
|
||||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
|
||||||
catch { return "en-US"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetClrVersion()
|
|
||||||
{
|
|
||||||
return Environment.Version.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static CrashReportService? _crashReportService;
|
|
||||||
private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
|
|
||||||
|
|
||||||
private static void InitializeCrashReporting()
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
|
var crashTelemetry = new SentryCrashTelemetryService(settingsFacade);
|
||||||
_crashReportService.RefreshEnabledState();
|
TelemetryServices.Crash = crashTelemetry;
|
||||||
|
crashTelemetry.Initialize();
|
||||||
|
AppLogger.Info("Startup", $"Crash telemetry initialized. Enabled={crashTelemetry.IsEnabled}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
|
AppLogger.Warn("Startup", "Failed to initialize crash telemetry service.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InitializeUserBehaviorAnalytics()
|
private static void InitializeUsageTelemetry()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||||
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
|
var usageTelemetry = new PostHogUsageTelemetryService(settingsFacade);
|
||||||
_userBehaviorAnalyticsService.Initialize();
|
TelemetryServices.Usage = usageTelemetry;
|
||||||
|
usageTelemetry.Initialize();
|
||||||
|
AppLogger.Info("Startup", $"Usage telemetry initialized. Enabled={usageTelemetry.IsUsageEnabled}.");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
|
AppLogger.Warn("Startup", "Failed to initialize usage telemetry service.", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetReleaseVersion()
|
|
||||||
{
|
|
||||||
var assembly = typeof(Program).Assembly;
|
|
||||||
var version = assembly.GetName().Version;
|
|
||||||
if (version is null)
|
|
||||||
{
|
|
||||||
return "1.0.0";
|
|
||||||
}
|
|
||||||
return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GetEnvironment()
|
|
||||||
{
|
|
||||||
#if DEBUG
|
|
||||||
return "development";
|
|
||||||
#else
|
|
||||||
return "production";
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
637
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
637
LanMountainDesktop/Services/PostHogUsageTelemetryService.cs
Normal file
@@ -0,0 +1,637 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class PostHogUsageTelemetryService : IDisposable
|
||||||
|
{
|
||||||
|
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||||
|
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||||
|
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly HttpClient _httpClient = new()
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(10)
|
||||||
|
};
|
||||||
|
private readonly Queue<TelemetryEvent> _eventQueue = new();
|
||||||
|
private readonly object _queueLock = new();
|
||||||
|
|
||||||
|
private Timer? _flushTimer;
|
||||||
|
private bool _isInitialized;
|
||||||
|
private bool _isUsageEnabled;
|
||||||
|
private bool _sessionActive;
|
||||||
|
private string _sessionId = string.Empty;
|
||||||
|
private DateTimeOffset _sessionStartUtc;
|
||||||
|
private long _sequence;
|
||||||
|
private readonly string _launchId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
public PostHogUsageTelemetryService(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_settingsService = settingsFacade.Settings;
|
||||||
|
_settingsService.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsUsageEnabled => _isUsageEnabled;
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
|
EnsureBaselineEventSent();
|
||||||
|
RefreshEnabledState(forceSessionStart: true);
|
||||||
|
|
||||||
|
_flushTimer = new Timer(
|
||||||
|
_ => FlushEvents(),
|
||||||
|
null,
|
||||||
|
TimeSpan.FromSeconds(10),
|
||||||
|
TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"PostHogUsage",
|
||||||
|
$"Usage telemetry initialized. Enabled={_isUsageEnabled}; InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshEnabledState(bool forceSessionStart = false)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var enabled = snapshot.UploadAnonymousUsageData;
|
||||||
|
|
||||||
|
if (_isUsageEnabled == enabled && !forceSessionStart)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previous = _isUsageEnabled;
|
||||||
|
_isUsageEnabled = enabled;
|
||||||
|
AppLogger.Info("PostHogUsage", $"Usage analytics enabled state changed from '{previous}' to '{_isUsageEnabled}'.");
|
||||||
|
|
||||||
|
if (_isUsageEnabled)
|
||||||
|
{
|
||||||
|
StartSession("usage_enabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ClearQueuedEvents();
|
||||||
|
StopSessionWithoutSending();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", "Failed to refresh usage analytics enabled state.", ex);
|
||||||
|
_isUsageEnabled = false;
|
||||||
|
ClearQueuedEvents();
|
||||||
|
StopSessionWithoutSending();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackMainWindowOpened(string source, bool isVisible, string windowState)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"main_window_opened",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["is_visible"] = isVisible,
|
||||||
|
["window_state"] = windowState
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackMainWindowClosed(string source, bool wasVisible, string windowState)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"main_window_closed",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["was_visible"] = wasVisible,
|
||||||
|
["window_state"] = windowState
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsWindowOpened(string source, string? currentPageId)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"settings_window_opened",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["current_page_id"] = currentPageId
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsWindowClosed(string source, string? currentPageId)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"settings_window_closed",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["current_page_id"] = currentPageId
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsNavigation(string? fromPageId, string? toPageId, string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"settings_navigation",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["from_page_id"] = fromPageId,
|
||||||
|
["to_page_id"] = toPageId
|
||||||
|
},
|
||||||
|
stateBefore: CreatePageState(fromPageId),
|
||||||
|
stateAfter: CreatePageState(toPageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsDrawerOpened(string? pageId, string? drawerTitle)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"settings_drawer_opened",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["page_id"] = pageId,
|
||||||
|
["drawer_title"] = drawerTitle
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSettingsDrawerClosed(string? pageId, string? drawerTitle)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"settings_drawer_closed",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["page_id"] = pageId,
|
||||||
|
["drawer_title"] = drawerTitle
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackDesktopComponentPlaced(DesktopComponentPlacementSnapshot placement, string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"desktop_component_placed",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source
|
||||||
|
},
|
||||||
|
stateAfter: DescribePlacement(placement),
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackDesktopComponentMoved(
|
||||||
|
DesktopComponentPlacementSnapshot before,
|
||||||
|
DesktopComponentPlacementSnapshot after,
|
||||||
|
string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"desktop_component_moved",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source
|
||||||
|
},
|
||||||
|
stateBefore: DescribePlacement(before),
|
||||||
|
stateAfter: DescribePlacement(after),
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackDesktopComponentResized(
|
||||||
|
DesktopComponentPlacementSnapshot before,
|
||||||
|
DesktopComponentPlacementSnapshot after,
|
||||||
|
string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"desktop_component_resized",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source
|
||||||
|
},
|
||||||
|
stateBefore: DescribePlacement(before),
|
||||||
|
stateAfter: DescribePlacement(after),
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackDesktopComponentDeleted(DesktopComponentPlacementSnapshot before, string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"desktop_component_deleted",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source
|
||||||
|
},
|
||||||
|
stateBefore: DescribePlacement(before),
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackDesktopComponentEditorOpened(DesktopComponentPlacementSnapshot placement, string source)
|
||||||
|
{
|
||||||
|
CaptureEvent(
|
||||||
|
"desktop_component_editor_opened",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source
|
||||||
|
},
|
||||||
|
stateBefore: DescribePlacement(placement),
|
||||||
|
forceFlush: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSessionStarted(string source)
|
||||||
|
{
|
||||||
|
StartSession(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void TrackSessionEnded(string source)
|
||||||
|
{
|
||||||
|
EndSession(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Shutdown(bool isRestart, string source)
|
||||||
|
{
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isUsageEnabled && _sessionActive)
|
||||||
|
{
|
||||||
|
EndSession(source, isRestart);
|
||||||
|
}
|
||||||
|
|
||||||
|
FlushEvents();
|
||||||
|
AppLogger.Info(
|
||||||
|
"PostHogUsage",
|
||||||
|
$"Usage telemetry shutdown complete. Source='{source}'; Restart='{isRestart}'; Enabled={_isUsageEnabled}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_flushTimer?.Dispose();
|
||||||
|
_settingsService.Changed -= OnSettingsChanged;
|
||||||
|
Shutdown(isRestart: false, source: "Dispose");
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", "Error disposing usage telemetry service.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_httpClient.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureBaselineEventSent()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identity = TelemetryIdentityService.Instance;
|
||||||
|
if (identity.HasReportedBaseline)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (SendBaselineEventToPostHog(identity.InstallId, now))
|
||||||
|
{
|
||||||
|
identity.MarkBaselineReported();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SendBaselineEventToPostHog(string installId, DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestBody = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["api_key"] = PostHogApiKey,
|
||||||
|
["event"] = "app_first_launch",
|
||||||
|
["distinct_id"] = installId,
|
||||||
|
["timestamp"] = timestamp.ToString("o"),
|
||||||
|
["properties"] = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["install_id"] = installId,
|
||||||
|
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||||
|
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||||
|
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||||
|
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||||
|
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||||
|
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||||
|
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||||
|
["launch_time_utc"] = timestamp.ToString("o")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(requestBody);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
using var content = new ByteArrayContent(bytes);
|
||||||
|
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||||
|
|
||||||
|
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||||
|
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PostHogUsage",
|
||||||
|
$"PostHog baseline event failed: {response.StatusCode} - {responseBody}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("PostHogUsage", "Sent first-launch baseline event.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", "Failed to send baseline launch event.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartSession(string source)
|
||||||
|
{
|
||||||
|
if (!_isInitialized || !_isUsageEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_sessionActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sessionActive = true;
|
||||||
|
_sessionId = Guid.NewGuid().ToString("N");
|
||||||
|
_sessionStartUtc = DateTimeOffset.UtcNow;
|
||||||
|
_sequence = 0;
|
||||||
|
|
||||||
|
CaptureEvent(
|
||||||
|
"app_session_start",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["launch_id"] = _launchId,
|
||||||
|
["session_start_utc"] = _sessionStartUtc.ToString("o"),
|
||||||
|
["local_hour"] = _sessionStartUtc.ToLocalTime().Hour,
|
||||||
|
["day_part"] = TelemetryEnvironmentInfo.GetLocalDayPart(_sessionStartUtc),
|
||||||
|
["timezone"] = TimeZoneInfo.Local.Id,
|
||||||
|
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||||
|
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||||
|
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||||
|
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||||
|
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture()
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
|
||||||
|
AppLogger.Info("PostHogUsage", $"Session started. SessionId={_sessionId}; Source='{source}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndSession(string source, bool isRestart = false)
|
||||||
|
{
|
||||||
|
if (!_isInitialized || !_sessionActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endUtc = DateTimeOffset.UtcNow;
|
||||||
|
var durationMs = Math.Max(0, (long)(endUtc - _sessionStartUtc).TotalMilliseconds);
|
||||||
|
|
||||||
|
CaptureEvent(
|
||||||
|
"app_session_end",
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["source"] = source,
|
||||||
|
["launch_id"] = _launchId,
|
||||||
|
["session_start_utc"] = _sessionStartUtc.ToString("o"),
|
||||||
|
["session_end_utc"] = endUtc.ToString("o"),
|
||||||
|
["duration_ms"] = durationMs,
|
||||||
|
["is_restart"] = isRestart
|
||||||
|
},
|
||||||
|
forceFlush: true);
|
||||||
|
|
||||||
|
_sessionActive = false;
|
||||||
|
_sessionId = string.Empty;
|
||||||
|
_sessionStartUtc = default;
|
||||||
|
_sequence = 0;
|
||||||
|
AppLogger.Info("PostHogUsage", $"Session ended. Source='{source}'; DurationMs={durationMs}; Restart={isRestart}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StopSessionWithoutSending()
|
||||||
|
{
|
||||||
|
_sessionActive = false;
|
||||||
|
_sessionId = string.Empty;
|
||||||
|
_sessionStartUtc = default;
|
||||||
|
_sequence = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
|
||||||
|
if (e.Scope != SettingsScope.App ||
|
||||||
|
e.ChangedKeys is null ||
|
||||||
|
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousUsageData), StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("PostHogUsage", "Usage analytics settings changed. Refreshing enabled state.");
|
||||||
|
RefreshEnabledState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CaptureEvent(
|
||||||
|
string eventName,
|
||||||
|
IReadOnlyDictionary<string, object?>? payload = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? stateBefore = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? stateAfter = null,
|
||||||
|
bool forceFlush = false)
|
||||||
|
{
|
||||||
|
if (!_isInitialized || !_isUsageEnabled || !_sessionActive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventData = new TelemetryEvent(
|
||||||
|
eventName,
|
||||||
|
TelemetryIdentityService.Instance.TelemetryId,
|
||||||
|
TelemetryIdentityService.Instance.InstallId,
|
||||||
|
TelemetryIdentityService.Instance.TelemetryId,
|
||||||
|
_sessionId,
|
||||||
|
Interlocked.Increment(ref _sequence),
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
payload ?? new Dictionary<string, object?>(),
|
||||||
|
stateBefore,
|
||||||
|
stateAfter);
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
_eventQueue.Enqueue(eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forceFlush)
|
||||||
|
{
|
||||||
|
FlushEvents();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldFlush = false;
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
shouldFlush = _eventQueue.Count >= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldFlush)
|
||||||
|
{
|
||||||
|
FlushEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FlushEvents()
|
||||||
|
{
|
||||||
|
List<TelemetryEvent> eventsToSend;
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
if (_eventQueue.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
eventsToSend = new List<TelemetryEvent>();
|
||||||
|
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||||
|
{
|
||||||
|
eventsToSend.Add(_eventQueue.Dequeue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var telemetryEvent in eventsToSend)
|
||||||
|
{
|
||||||
|
if (!SendEventToPostHog(telemetryEvent, flushImmediately: false))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Failed to send PostHog event '{telemetryEvent.EventName}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", "Failed to send queued events to PostHog.", ex);
|
||||||
|
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
foreach (var evt in eventsToSend)
|
||||||
|
{
|
||||||
|
if (_eventQueue.Count >= 100)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_eventQueue.Enqueue(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool SendEventToPostHog(TelemetryEvent telemetryEvent, bool flushImmediately)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var requestBody = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["api_key"] = PostHogApiKey,
|
||||||
|
["event"] = telemetryEvent.EventName,
|
||||||
|
["distinct_id"] = telemetryEvent.DistinctId,
|
||||||
|
["timestamp"] = telemetryEvent.Timestamp.ToString("o"),
|
||||||
|
["properties"] = telemetryEvent.ToPostHogProperties()
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(requestBody);
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
|
using var content = new ByteArrayContent(bytes);
|
||||||
|
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||||
|
|
||||||
|
var response = _httpClient.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||||
|
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
AppLogger.Warn(
|
||||||
|
"PostHogUsage",
|
||||||
|
$"PostHog event '{telemetryEvent.EventName}' failed: {response.StatusCode} - {responseBody}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flushImmediately)
|
||||||
|
{
|
||||||
|
AppLogger.Info("PostHogUsage", $"Sent event '{telemetryEvent.EventName}' immediately.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("PostHogUsage", $"Failed to send PostHog event '{telemetryEvent.EventName}'.", ex);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearQueuedEvents()
|
||||||
|
{
|
||||||
|
lock (_queueLock)
|
||||||
|
{
|
||||||
|
_eventQueue.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?> CreatePageState(string? pageId)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["page_id"] = pageId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyDictionary<string, object?> DescribePlacement(DesktopComponentPlacementSnapshot placement)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["placement_id"] = placement.PlacementId,
|
||||||
|
["component_id"] = placement.ComponentId,
|
||||||
|
["page_index"] = placement.PageIndex,
|
||||||
|
["row"] = placement.Row,
|
||||||
|
["column"] = placement.Column,
|
||||||
|
["width_cells"] = placement.WidthCells,
|
||||||
|
["height_cells"] = placement.HeightCells
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
410
LanMountainDesktop/Services/SentryCrashTelemetryService.cs
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
using Sentry;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class SentryCrashTelemetryService : IDisposable
|
||||||
|
{
|
||||||
|
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||||
|
private const string AutoIpAddress = "{{auto}}";
|
||||||
|
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
|
||||||
|
private IDisposable? _sentryHandle;
|
||||||
|
private bool _isInitialized;
|
||||||
|
private bool _isEnabled;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public SentryCrashTelemetryService(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
_settingsService = settingsFacade.Settings;
|
||||||
|
_settingsService.Changed += OnSettingsChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
return _isInitialized && _isEnabled && SentrySdk.IsEnabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Initialize()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
if (_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RefreshEnabledState(force: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshEnabledState(bool force = false)
|
||||||
|
{
|
||||||
|
bool shouldEnable;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureNotDisposed();
|
||||||
|
if (!_isInitialized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
shouldEnable = snapshot.UploadAnonymousCrashData;
|
||||||
|
|
||||||
|
if (!force && _isEnabled == shouldEnable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldEnable)
|
||||||
|
{
|
||||||
|
EnableSentry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DisableSentry();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureUnhandledException(Exception exception, string source, bool isTerminating)
|
||||||
|
{
|
||||||
|
if (exception is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!CanCapture())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventId = SentrySdk.CaptureException(exception, scope =>
|
||||||
|
{
|
||||||
|
ApplyCommonScope(scope, source, "unhandled_exception", includeLogTail: true);
|
||||||
|
scope.Level = isTerminating ? SentryLevel.Fatal : SentryLevel.Error;
|
||||||
|
scope.SetTag("exception_source", source);
|
||||||
|
scope.SetTag("is_terminating", isTerminating.ToString());
|
||||||
|
});
|
||||||
|
|
||||||
|
AppLogger.Info("SentryCrash", $"Captured unhandled exception from '{source}'. EventId={eventId}.");
|
||||||
|
|
||||||
|
if (isTerminating)
|
||||||
|
{
|
||||||
|
EndCrashSession();
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureTaskException(Exception exception, string source)
|
||||||
|
{
|
||||||
|
if (exception is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!CanCapture())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventId = SentrySdk.CaptureException(exception, scope =>
|
||||||
|
{
|
||||||
|
ApplyCommonScope(scope, source, "task_exception", includeLogTail: true);
|
||||||
|
scope.Level = SentryLevel.Error;
|
||||||
|
scope.SetTag("exception_source", source);
|
||||||
|
});
|
||||||
|
|
||||||
|
AppLogger.Info("SentryCrash", $"Captured task exception from '{source}'. EventId={eventId}.");
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CaptureShutdown(bool isRestart, string source)
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!CanCapture())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var eventId = SentrySdk.CaptureMessage("application_shutdown", scope =>
|
||||||
|
{
|
||||||
|
ApplyCommonScope(scope, source, "shutdown", includeLogTail: true);
|
||||||
|
scope.Level = SentryLevel.Info;
|
||||||
|
scope.SetTag("shutdown_intent", isRestart ? "restart" : "exit");
|
||||||
|
scope.SetExtra("shutdown_intent", isRestart ? "restart" : "exit");
|
||||||
|
}, SentryLevel.Info);
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"SentryCrash",
|
||||||
|
$"Captured application shutdown. Source='{source}'; Restart={isRestart}; EventId={eventId}.");
|
||||||
|
|
||||||
|
EndCrashSession();
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_settingsService.Changed -= OnSettingsChanged;
|
||||||
|
DisableSentry();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SentryCrash", "Failed to dispose crash telemetry service.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnableSentry()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_isEnabled && _sentryHandle is not null && SentrySdk.IsEnabled)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var handle = SentrySdk.Init(options =>
|
||||||
|
{
|
||||||
|
options.Dsn = SentryDsn;
|
||||||
|
options.AutoSessionTracking = true;
|
||||||
|
options.AttachStacktrace = true;
|
||||||
|
options.SendDefaultPii = true;
|
||||||
|
options.MaxBreadcrumbs = 100;
|
||||||
|
options.Release = TelemetryEnvironmentInfo.GetAppVersion();
|
||||||
|
options.Environment = TelemetryEnvironmentInfo.GetEnvironment();
|
||||||
|
options.DisableAppDomainUnhandledExceptionCapture();
|
||||||
|
options.DisableUnobservedTaskExceptionCapture();
|
||||||
|
});
|
||||||
|
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
handle.Dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_sentryHandle?.Dispose();
|
||||||
|
_sentryHandle = handle;
|
||||||
|
_isEnabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
SentrySdk.ConfigureScope(scope => ApplyCommonScope(scope, "startup", "startup", includeLogTail: false));
|
||||||
|
AppLogger.Info("SentryCrash", "Crash telemetry enabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DisableSentry()
|
||||||
|
{
|
||||||
|
IDisposable? handle;
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
if (!_isEnabled && _sentryHandle is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isEnabled = false;
|
||||||
|
handle = _sentryHandle;
|
||||||
|
_sentryHandle = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
EndCrashSession();
|
||||||
|
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SentryCrash", "Failed to flush Sentry while disabling crash telemetry.", ex);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
handle?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("SentryCrash", "Crash telemetry disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EndCrashSession()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (SentrySdk.IsEnabled)
|
||||||
|
{
|
||||||
|
SentrySdk.EndSession(SessionEndStatus.Exited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SentryCrash", "Failed to end Sentry session.", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanCapture()
|
||||||
|
{
|
||||||
|
return !_disposed && _isInitialized && _isEnabled && SentrySdk.IsEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyCommonScope(Scope scope, string source, string eventType, bool includeLogTail)
|
||||||
|
{
|
||||||
|
var installId = TelemetryIdentityService.Instance.InstallId;
|
||||||
|
var telemetryId = TelemetryIdentityService.Instance.TelemetryId;
|
||||||
|
|
||||||
|
scope.User = new SentryUser
|
||||||
|
{
|
||||||
|
Id = telemetryId,
|
||||||
|
IpAddress = AutoIpAddress
|
||||||
|
};
|
||||||
|
|
||||||
|
scope.SetTag("telemetry_channel", "sentry");
|
||||||
|
scope.SetTag("event_type", eventType);
|
||||||
|
scope.SetTag("source", source);
|
||||||
|
scope.SetTag("install_id", installId);
|
||||||
|
scope.SetTag("telemetry_id", telemetryId);
|
||||||
|
scope.SetTag("app_version", TelemetryEnvironmentInfo.GetAppVersion());
|
||||||
|
scope.SetTag("environment", TelemetryEnvironmentInfo.GetEnvironment());
|
||||||
|
scope.SetTag("os_name", TelemetryEnvironmentInfo.GetOsName());
|
||||||
|
scope.SetTag("os_version", TelemetryEnvironmentInfo.GetOsVersion());
|
||||||
|
scope.SetTag("os_build", TelemetryEnvironmentInfo.GetOsBuild());
|
||||||
|
scope.SetTag("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
|
||||||
|
scope.SetTag("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
|
||||||
|
scope.SetTag("processor_count", TelemetryEnvironmentInfo.GetProcessorCount().ToString());
|
||||||
|
scope.SetTag("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB().ToString());
|
||||||
|
scope.SetTag("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
|
||||||
|
scope.SetTag("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
|
||||||
|
scope.SetTag("language", TelemetryEnvironmentInfo.GetSystemLanguage());
|
||||||
|
scope.SetExtra("install_id", installId);
|
||||||
|
scope.SetExtra("telemetry_id", telemetryId);
|
||||||
|
scope.SetExtra("app_version", TelemetryEnvironmentInfo.GetAppVersion());
|
||||||
|
scope.SetExtra("environment", TelemetryEnvironmentInfo.GetEnvironment());
|
||||||
|
scope.SetExtra("os_name", TelemetryEnvironmentInfo.GetOsName());
|
||||||
|
scope.SetExtra("os_version", TelemetryEnvironmentInfo.GetOsVersion());
|
||||||
|
scope.SetExtra("os_build", TelemetryEnvironmentInfo.GetOsBuild());
|
||||||
|
scope.SetExtra("device_model", TelemetryEnvironmentInfo.GetDeviceModel());
|
||||||
|
scope.SetExtra("device_arch", TelemetryEnvironmentInfo.GetDeviceArchitecture());
|
||||||
|
scope.SetExtra("processor_count", TelemetryEnvironmentInfo.GetProcessorCount());
|
||||||
|
scope.SetExtra("total_memory_mb", TelemetryEnvironmentInfo.GetTotalMemoryMB());
|
||||||
|
scope.SetExtra("runtime_version", TelemetryEnvironmentInfo.GetRuntimeVersion());
|
||||||
|
scope.SetExtra("clr_version", TelemetryEnvironmentInfo.GetClrVersion());
|
||||||
|
scope.SetExtra("language", TelemetryEnvironmentInfo.GetSystemLanguage());
|
||||||
|
scope.SetExtra("log_file_path", AppLogger.LogFilePath);
|
||||||
|
|
||||||
|
if (includeLogTail)
|
||||||
|
{
|
||||||
|
var logTail = ReadLogTail(maxLines: 200, maxCharacters: 32_768);
|
||||||
|
if (!string.IsNullOrWhiteSpace(logTail))
|
||||||
|
{
|
||||||
|
scope.SetExtra("log_tail", logTail);
|
||||||
|
scope.SetExtra("log_tail_line_count", logTail.Count(character => character == '\n') + 1);
|
||||||
|
var attachment = new Attachment(
|
||||||
|
AttachmentType.Default,
|
||||||
|
new ByteAttachmentContent(Encoding.UTF8.GetBytes(logTail)),
|
||||||
|
"log-tail.txt",
|
||||||
|
"text/plain");
|
||||||
|
scope.AddAttachment(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
|
||||||
|
{
|
||||||
|
_ = sender;
|
||||||
|
|
||||||
|
if (e.Scope != SettingsScope.App ||
|
||||||
|
e.ChangedKeys is null ||
|
||||||
|
!e.ChangedKeys.Contains(nameof(AppSettingsSnapshot.UploadAnonymousCrashData), StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info("SentryCrash", "Crash telemetry setting changed. Refreshing enabled state.");
|
||||||
|
RefreshEnabledState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ReadLogTail(int maxLines, int maxCharacters)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var logFilePath = AppLogger.LogFilePath;
|
||||||
|
if (string.IsNullOrWhiteSpace(logFilePath) || !File.Exists(logFilePath))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new Queue<string>(Math.Min(maxLines, 256));
|
||||||
|
using var reader = File.OpenText(logFilePath);
|
||||||
|
string? line;
|
||||||
|
while ((line = reader.ReadLine()) is not null)
|
||||||
|
{
|
||||||
|
if (lines.Count >= maxLines)
|
||||||
|
{
|
||||||
|
lines.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.Enqueue(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
var tail = string.Join(Environment.NewLine, lines);
|
||||||
|
if (tail.Length <= maxCharacters)
|
||||||
|
{
|
||||||
|
return tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tail[^maxCharacters..];
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLogger.Warn("SentryCrash", "Failed to read log tail for crash telemetry.", ex);
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureNotDisposed()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
throw new ObjectDisposedException(nameof(SentryCrashTelemetryService));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -609,17 +609,32 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
|||||||
public void Save(PrivacySettingsState state)
|
public void Save(PrivacySettingsState state)
|
||||||
{
|
{
|
||||||
var snapshot = _settingsService.Load();
|
var snapshot = _settingsService.Load();
|
||||||
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
var changedKeys = new List<string>();
|
||||||
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
|
||||||
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
if (snapshot.UploadAnonymousCrashData != state.UploadAnonymousCrashData)
|
||||||
|
{
|
||||||
|
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||||
|
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousCrashData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.UploadAnonymousUsageData != state.UploadAnonymousUsageData)
|
||||||
|
{
|
||||||
|
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||||
|
changedKeys.Add(nameof(AppSettingsSnapshot.UploadAnonymousUsageData));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedKeys.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"PrivacySettings",
|
||||||
|
$"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
||||||
_settingsService.SaveSnapshot(
|
_settingsService.SaveSnapshot(
|
||||||
SettingsScope.App,
|
SettingsScope.App,
|
||||||
snapshot,
|
snapshot,
|
||||||
changedKeys:
|
changedKeys: changedKeys);
|
||||||
[
|
|
||||||
nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
|
|
||||||
nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
144
LanMountainDesktop/Services/TelemetryEnvironmentInfo.cs
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal static class TelemetryEnvironmentInfo
|
||||||
|
{
|
||||||
|
public static string GetAppVersion()
|
||||||
|
{
|
||||||
|
var assembly = typeof(TelemetryEnvironmentInfo).Assembly;
|
||||||
|
var version = assembly.GetName().Version;
|
||||||
|
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetEnvironment()
|
||||||
|
{
|
||||||
|
#if DEBUG
|
||||||
|
return "development";
|
||||||
|
#else
|
||||||
|
return "production";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetOsName()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
return "Windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
return "Linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
return "macOS";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetOsVersion()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Environment.OSVersion.VersionString ?? "Unknown";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetOsBuild()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Environment.OSVersion.Version.Build.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDeviceModel()
|
||||||
|
{
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||||
|
{
|
||||||
|
return "Windows PC";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||||
|
{
|
||||||
|
return "Linux PC";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||||
|
{
|
||||||
|
return "Mac";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetDeviceArchitecture()
|
||||||
|
{
|
||||||
|
return RuntimeInformation.OSArchitecture.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetSystemLanguage()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return CultureInfo.CurrentUICulture.Name ?? "en-US";
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return "en-US";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int GetProcessorCount()
|
||||||
|
{
|
||||||
|
return Environment.ProcessorCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long GetTotalMemoryMB()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetRuntimeVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetClrVersion()
|
||||||
|
{
|
||||||
|
return Environment.Version.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetLocalDayPart(DateTimeOffset timestamp)
|
||||||
|
{
|
||||||
|
var hour = timestamp.ToLocalTime().Hour;
|
||||||
|
return hour switch
|
||||||
|
{
|
||||||
|
< 6 => "late_night",
|
||||||
|
< 12 => "morning",
|
||||||
|
< 18 => "afternoon",
|
||||||
|
_ => "evening"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
55
LanMountainDesktop/Services/TelemetryEvent.cs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
internal sealed record TelemetryEvent(
|
||||||
|
string EventName,
|
||||||
|
string DistinctId,
|
||||||
|
string InstallId,
|
||||||
|
string TelemetryId,
|
||||||
|
string SessionId,
|
||||||
|
long Sequence,
|
||||||
|
DateTimeOffset Timestamp,
|
||||||
|
IReadOnlyDictionary<string, object?> Payload,
|
||||||
|
IReadOnlyDictionary<string, object?>? StateBefore = null,
|
||||||
|
IReadOnlyDictionary<string, object?>? StateAfter = null)
|
||||||
|
{
|
||||||
|
public Dictionary<string, object?> ToPostHogProperties()
|
||||||
|
{
|
||||||
|
var properties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["install_id"] = InstallId,
|
||||||
|
["telemetry_id"] = TelemetryId,
|
||||||
|
["session_id"] = SessionId,
|
||||||
|
["sequence"] = Sequence,
|
||||||
|
["timestamp_utc"] = Timestamp.ToString("o"),
|
||||||
|
["app_version"] = TelemetryEnvironmentInfo.GetAppVersion(),
|
||||||
|
["os_name"] = TelemetryEnvironmentInfo.GetOsName(),
|
||||||
|
["os_version"] = TelemetryEnvironmentInfo.GetOsVersion(),
|
||||||
|
["device_model"] = TelemetryEnvironmentInfo.GetDeviceModel(),
|
||||||
|
["device_arch"] = TelemetryEnvironmentInfo.GetDeviceArchitecture(),
|
||||||
|
["runtime_version"] = TelemetryEnvironmentInfo.GetRuntimeVersion(),
|
||||||
|
["language"] = TelemetryEnvironmentInfo.GetSystemLanguage(),
|
||||||
|
["payload"] = Copy(Payload)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (StateBefore is not null && StateBefore.Count > 0)
|
||||||
|
{
|
||||||
|
properties["state_before"] = Copy(StateBefore);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StateAfter is not null && StateAfter.Count > 0)
|
||||||
|
{
|
||||||
|
properties["state_after"] = Copy(StateAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, object?> Copy(IReadOnlyDictionary<string, object?> source)
|
||||||
|
{
|
||||||
|
return source.ToDictionary(entry => entry.Key, entry => entry.Value, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
158
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
158
LanMountainDesktop/Services/TelemetryIdentityService.cs
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
|
using LanMountainDesktop.Services.Settings;
|
||||||
|
|
||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public sealed class TelemetryIdentityService
|
||||||
|
{
|
||||||
|
private static TelemetryIdentityService? _instance;
|
||||||
|
|
||||||
|
private readonly ISettingsFacadeService _settingsFacade;
|
||||||
|
private readonly object _syncRoot = new();
|
||||||
|
|
||||||
|
private string _installId = string.Empty;
|
||||||
|
private string _telemetryId = string.Empty;
|
||||||
|
private bool _hasReportedBaseline;
|
||||||
|
|
||||||
|
public static TelemetryIdentityService Instance =>
|
||||||
|
_instance ?? throw new InvalidOperationException("TelemetryIdentityService not initialized.");
|
||||||
|
|
||||||
|
private TelemetryIdentityService(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Initialize(ISettingsFacadeService settingsFacade)
|
||||||
|
{
|
||||||
|
if (_instance is not null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var instance = new TelemetryIdentityService(settingsFacade);
|
||||||
|
instance.LoadOrCreateIdentity();
|
||||||
|
_instance = instance;
|
||||||
|
TelemetryServices.Identity = instance;
|
||||||
|
|
||||||
|
AppLogger.Info(
|
||||||
|
"TelemetryIdentity",
|
||||||
|
$"Initialized. InstallId={instance.InstallId}; TelemetryId={instance.TelemetryId}; BaselineReported={instance.HasReportedBaseline}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public string InstallId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
return _installId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string TelemetryId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
return _telemetryId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasReportedBaseline
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
return _hasReportedBaseline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MarkBaselineReported()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
EnsureInitialized();
|
||||||
|
|
||||||
|
if (_hasReportedBaseline)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
if (snapshot.HasReportedTelemetryBaseline)
|
||||||
|
{
|
||||||
|
_hasReportedBaseline = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshot.HasReportedTelemetryBaseline = true;
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
snapshot,
|
||||||
|
changedKeys: [nameof(AppSettingsSnapshot.HasReportedTelemetryBaseline)]);
|
||||||
|
|
||||||
|
_hasReportedBaseline = true;
|
||||||
|
AppLogger.Info("TelemetryIdentity", "Marked baseline telemetry as reported.");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadOrCreateIdentity()
|
||||||
|
{
|
||||||
|
lock (_syncRoot)
|
||||||
|
{
|
||||||
|
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||||
|
var changedKeys = new List<string>();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(snapshot.TelemetryInstallId))
|
||||||
|
{
|
||||||
|
snapshot.TelemetryInstallId = GenerateId();
|
||||||
|
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryInstallId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(snapshot.TelemetryId))
|
||||||
|
{
|
||||||
|
snapshot.TelemetryId = GenerateId();
|
||||||
|
changedKeys.Add(nameof(AppSettingsSnapshot.TelemetryId));
|
||||||
|
}
|
||||||
|
|
||||||
|
_installId = snapshot.TelemetryInstallId ?? GenerateId();
|
||||||
|
_telemetryId = snapshot.TelemetryId ?? GenerateId();
|
||||||
|
_hasReportedBaseline = snapshot.HasReportedTelemetryBaseline;
|
||||||
|
|
||||||
|
if (changedKeys.Count > 0)
|
||||||
|
{
|
||||||
|
_settingsFacade.Settings.SaveSnapshot(
|
||||||
|
SettingsScope.App,
|
||||||
|
snapshot,
|
||||||
|
changedKeys: changedKeys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureInitialized()
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(_installId) && !string.IsNullOrWhiteSpace(_telemetryId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LoadOrCreateIdentity();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateId()
|
||||||
|
{
|
||||||
|
return Guid.NewGuid().ToString("N");
|
||||||
|
}
|
||||||
|
}
|
||||||
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
10
LanMountainDesktop/Services/TelemetryServices.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace LanMountainDesktop.Services;
|
||||||
|
|
||||||
|
public static class TelemetryServices
|
||||||
|
{
|
||||||
|
public static TelemetryIdentityService? Identity { get; set; }
|
||||||
|
|
||||||
|
public static PostHogUsageTelemetryService? Usage { get; set; }
|
||||||
|
|
||||||
|
public static SentryCrashTelemetryService? Crash { get; set; }
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ using System;
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using LanMountainDesktop.Models;
|
using LanMountainDesktop.Models;
|
||||||
|
using LanMountainDesktop.PluginSdk;
|
||||||
using LanMountainDesktop.Services;
|
using LanMountainDesktop.Services;
|
||||||
using LanMountainDesktop.Services.Settings;
|
using LanMountainDesktop.Services.Settings;
|
||||||
using LanMountainDesktop.PluginSdk;
|
|
||||||
|
|
||||||
namespace LanMountainDesktop.ViewModels;
|
namespace LanMountainDesktop.ViewModels;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
|
|
||||||
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
|
||||||
{
|
{
|
||||||
_settingsFacade = settingsFacade;
|
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||||
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
|
||||||
RefreshLocalizedText();
|
RefreshLocalizedText();
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
private bool _uploadAnonymousUsageData;
|
private bool _uploadAnonymousUsageData;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _deviceId = string.Empty;
|
private string _telemetryId = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _privacyHeader = string.Empty;
|
private string _privacyHeader = string.Empty;
|
||||||
@@ -53,13 +53,10 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
private string _usageUploadDescription = string.Empty;
|
private string _usageUploadDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _deviceIdHeader = string.Empty;
|
private string _telemetryIdHeader = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _deviceIdDescription = string.Empty;
|
private string _telemetryIdDescription = string.Empty;
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
private string _refreshDeviceIdText = string.Empty;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _viewPrivacyPolicyText = string.Empty;
|
private string _viewPrivacyPolicyText = string.Empty;
|
||||||
@@ -72,33 +69,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
var state = _settingsFacade.Privacy.Get();
|
var state = _settingsFacade.Privacy.Get();
|
||||||
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||||
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||||
DeviceId = DeviceIdService.Instance.DeviceId;
|
TelemetryId = TelemetryServices.Identity?.TelemetryId ?? string.Empty;
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void RefreshDeviceId()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
|
||||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
|
||||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
|
||||||
var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
|
|
||||||
|
|
||||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<Models.AppSettingsSnapshot>(SettingsScope.App);
|
|
||||||
snapshot.DeviceId = newDeviceId;
|
|
||||||
_settingsFacade.Settings.SaveSnapshot(
|
|
||||||
SettingsScope.App,
|
|
||||||
snapshot,
|
|
||||||
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
|
|
||||||
|
|
||||||
DeviceId = newDeviceId;
|
|
||||||
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnUploadAnonymousCrashDataChanged(bool value)
|
partial void OnUploadAnonymousCrashDataChanged(bool value)
|
||||||
@@ -132,12 +103,17 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
PrivacyHeader = L("settings.privacy.title", "Privacy");
|
PrivacyHeader = L("settings.privacy.title", "Privacy");
|
||||||
CrashUploadHeader = L("settings.privacy.crash_upload_title", "Anonymous crash data uploads");
|
CrashUploadHeader = L("settings.privacy.crash_upload_title", "Anonymous crash data uploads");
|
||||||
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
|
CrashUploadDescription = L(
|
||||||
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
|
"settings.privacy.crash_upload_description",
|
||||||
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
|
"Send crash reports to help us improve stability.");
|
||||||
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
|
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage analytics");
|
||||||
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
|
UsageUploadDescription = L(
|
||||||
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
|
"settings.privacy.usage_upload_description",
|
||||||
|
"Send usage events to help us understand feature usage and session flow.");
|
||||||
|
TelemetryIdHeader = L("settings.privacy.telemetry_id_title", "Telemetry ID");
|
||||||
|
TelemetryIdDescription = L(
|
||||||
|
"settings.privacy.telemetry_id_description",
|
||||||
|
"An anonymous identifier used for detailed telemetry sessions.");
|
||||||
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
|
PrivacyPolicyHintPrefix = L("settings.privacy.policy_hint_prefix", "For more details, please ");
|
||||||
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
|
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
|
||||||
}
|
}
|
||||||
@@ -147,10 +123,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 触发隐私政策查看事件
|
|
||||||
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
|
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
|
||||||
|
|
||||||
// 发送事件通知显示隐私政策
|
|
||||||
ViewPrivacyPolicyRequested?.Invoke();
|
ViewPrivacyPolicyRequested?.Invoke();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -634,6 +634,20 @@ public partial class MainWindow
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DesktopComponentPlacementSnapshot ClonePlacementSnapshot(DesktopComponentPlacementSnapshot placement)
|
||||||
|
{
|
||||||
|
return new DesktopComponentPlacementSnapshot
|
||||||
|
{
|
||||||
|
PlacementId = placement.PlacementId,
|
||||||
|
PageIndex = placement.PageIndex,
|
||||||
|
ComponentId = placement.ComponentId,
|
||||||
|
Row = placement.Row,
|
||||||
|
Column = placement.Column,
|
||||||
|
WidthCells = placement.WidthCells,
|
||||||
|
HeightCells = placement.HeightCells
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private void OnSettingsWindowStateChanged(object? sender, EventArgs e)
|
private void OnSettingsWindowStateChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
_ = sender;
|
_ = sender;
|
||||||
@@ -875,6 +889,8 @@ public partial class MainWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var before = ClonePlacementSnapshot(placement);
|
||||||
|
|
||||||
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(_componentEditorWindowService.CurrentPlacementId, placement.PlacementId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_componentEditorWindowService.Close();
|
_componentEditorWindowService.Close();
|
||||||
@@ -896,6 +912,7 @@ public partial class MainWindow
|
|||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
|
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentDeleted(before, "component.delete");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OpenSelectedComponentEditor()
|
private void OpenSelectedComponentEditor()
|
||||||
@@ -912,6 +929,8 @@ public partial class MainWindow
|
|||||||
ComponentId: placement.ComponentId,
|
ComponentId: placement.ComponentId,
|
||||||
PlacementId: placement.PlacementId,
|
PlacementId: placement.PlacementId,
|
||||||
RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId)));
|
RefreshAction: () => RefreshDesktopComponentPlacement(placement.PlacementId)));
|
||||||
|
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentEditorOpened(ClonePlacementSnapshot(placement), "component.edit");
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement)
|
private bool TryGetSelectedDesktopPlacement(out DesktopComponentPlacementSnapshot placement)
|
||||||
@@ -1220,6 +1239,7 @@ public partial class MainWindow
|
|||||||
InvalidateDesktopPageAwareComponentContextCache();
|
InvalidateDesktopPageAwareComponentContextCache();
|
||||||
UpdateDesktopPageAwareComponentContext();
|
UpdateDesktopPageAwareComponentContext();
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentPlaced(ClonePlacementSnapshot(placement), "component.create");
|
||||||
|
|
||||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||||
}
|
}
|
||||||
@@ -2350,6 +2370,8 @@ public partial class MainWindow
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var before = ClonePlacementSnapshot(placement);
|
||||||
|
|
||||||
var widthCells = Math.Max(1, _desktopComponentResize.CurrentWidthCells);
|
var widthCells = Math.Max(1, _desktopComponentResize.CurrentWidthCells);
|
||||||
var heightCells = Math.Max(1, _desktopComponentResize.CurrentHeightCells);
|
var heightCells = Math.Max(1, _desktopComponentResize.CurrentHeightCells);
|
||||||
var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells;
|
var changed = placement.WidthCells != widthCells || placement.HeightCells != heightCells;
|
||||||
@@ -2360,6 +2382,7 @@ public partial class MainWindow
|
|||||||
if (changed)
|
if (changed)
|
||||||
{
|
{
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentResized(before, ClonePlacementSnapshot(placement), "component.resize");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -2569,6 +2592,8 @@ public partial class MainWindow
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var before = ClonePlacementSnapshot(placement);
|
||||||
|
|
||||||
placement.Row = Math.Max(0, row);
|
placement.Row = Math.Max(0, row);
|
||||||
placement.Column = Math.Max(0, column);
|
placement.Column = Math.Max(0, column);
|
||||||
|
|
||||||
@@ -2578,6 +2603,7 @@ public partial class MainWindow
|
|||||||
_desktopComponentDrag.SourceHost.Opacity = 1;
|
_desktopComponentDrag.SourceHost.Opacity = 1;
|
||||||
ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen);
|
ApplyDesktopEditStateToHost(_desktopComponentDrag.SourceHost, _isComponentLibraryOpen);
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
|
TelemetryServices.Usage?.TrackDesktopComponentMoved(before, ClonePlacementSnapshot(placement), "component.move");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
|
|
||||||
ApplyNightModeState(_isNightMode, refreshPalettes: true);
|
ApplyNightModeState(_isNightMode, refreshPalettes: true);
|
||||||
ApplyLocalization();
|
ApplyLocalization();
|
||||||
|
TelemetryServices.Usage?.TrackMainWindowOpened(
|
||||||
|
"MainWindow.OnOpened",
|
||||||
|
IsVisible,
|
||||||
|
WindowState.ToString());
|
||||||
DesktopHost.SizeChanged += OnDesktopHostSizeChanged;
|
DesktopHost.SizeChanged += OnDesktopHostSizeChanged;
|
||||||
RebuildDesktopGrid();
|
RebuildDesktopGrid();
|
||||||
LoadLauncherEntriesAsync();
|
LoadLauncherEntriesAsync();
|
||||||
@@ -303,6 +307,9 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
protected override void OnClosed(EventArgs e)
|
||||||
{
|
{
|
||||||
|
var wasVisible = IsVisible;
|
||||||
|
var windowState = WindowState.ToString();
|
||||||
|
|
||||||
PersistSettings();
|
PersistSettings();
|
||||||
_componentEditorWindowService.Close();
|
_componentEditorWindowService.Close();
|
||||||
if (_detachedComponentLibraryWindow is not null)
|
if (_detachedComponentLibraryWindow is not null)
|
||||||
@@ -329,6 +336,10 @@ public partial class MainWindow : Window, ISettingsWindowAnchorProvider
|
|||||||
{
|
{
|
||||||
settingsWindowService.StateChanged -= OnSettingsWindowStateChanged;
|
settingsWindowService.StateChanged -= OnSettingsWindowStateChanged;
|
||||||
}
|
}
|
||||||
|
TelemetryServices.Usage?.TrackMainWindowClosed(
|
||||||
|
"MainWindow.OnClosed",
|
||||||
|
wasVisible,
|
||||||
|
windowState);
|
||||||
base.OnClosed(e);
|
base.OnClosed(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,34 @@
|
|||||||
</ui:InfoBar.IconSource>
|
</ui:InfoBar.IconSource>
|
||||||
</ui:InfoBar>
|
</ui:InfoBar>
|
||||||
|
|
||||||
<Border Classes="settings-section-card">
|
<!-- 版权声明 - 放在渲染显示前面 -->
|
||||||
<StackPanel Spacing="12">
|
<ui:SettingsExpander Header="版权声明"
|
||||||
<controls:IconText Icon="WindowConsole"
|
IsExpanded="True">
|
||||||
Text="{Binding RenderBackendLabel}" />
|
<ui:SettingsExpander.IconSource>
|
||||||
<TextBlock Classes="settings-item-description"
|
<fi:SymbolIconSource Symbol="Document" />
|
||||||
Text="{Binding RenderBackendText}"
|
</ui:SettingsExpander.IconSource>
|
||||||
TextWrapping="Wrap" />
|
<ui:SettingsExpanderItem>
|
||||||
</StackPanel>
|
<ui:SettingsExpanderItem.Footer>
|
||||||
</Border>
|
<WrapPanel>
|
||||||
|
<WrapPanel.Styles>
|
||||||
|
<Style Selector="HyperlinkButton">
|
||||||
|
<Setter Property="Padding" Value="4" />
|
||||||
|
<Setter Property="Margin" Value="2" />
|
||||||
|
</Style>
|
||||||
|
</WrapPanel.Styles>
|
||||||
|
<HyperlinkButton NavigateUri="https://github.com/Lincube/LanMountainDesktop">
|
||||||
|
<TextBlock Text="GitHub 仓库" />
|
||||||
|
</HyperlinkButton>
|
||||||
|
<HyperlinkButton NavigateUri="https://github.com/Lincube/LanMountainDesktop/issues">
|
||||||
|
<TextBlock Text="问题反馈" />
|
||||||
|
</HyperlinkButton>
|
||||||
|
</WrapPanel>
|
||||||
|
</ui:SettingsExpanderItem.Footer>
|
||||||
|
<TextBlock>
|
||||||
|
<Run Text="Copyright (c) 2024-" /><Run Text="2025" /> Lincube
|
||||||
|
</TextBlock>
|
||||||
|
</ui:SettingsExpanderItem>
|
||||||
|
</ui:SettingsExpander>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -38,27 +38,22 @@
|
|||||||
Margin="0,16,0,0">
|
Margin="0,16,0,0">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0">
|
<StackPanel Grid.Column="0">
|
||||||
<TextBlock Text="{Binding DeviceIdHeader}"
|
<TextBlock Text="{Binding TelemetryIdHeader}"
|
||||||
FontWeight="SemiBold"
|
FontWeight="SemiBold"
|
||||||
FontSize="14" />
|
FontSize="14" />
|
||||||
<TextBlock Text="{Binding DeviceIdDescription}"
|
<TextBlock Text="{Binding TelemetryIdDescription}"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Opacity="0.7"
|
Opacity="0.7"
|
||||||
Margin="0,4,0,8" />
|
Margin="0,4,0,8" />
|
||||||
<TextBox x:Name="DeviceIdTextBox"
|
<TextBox x:Name="TelemetryIdTextBox"
|
||||||
Text="{Binding DeviceId}"
|
Text="{Binding TelemetryId}"
|
||||||
IsReadOnly="True"
|
IsReadOnly="True"
|
||||||
FontFamily="Consolas"
|
FontFamily="Consolas"
|
||||||
FontSize="12"
|
FontSize="12"
|
||||||
Focusable="False"
|
Focusable="False"
|
||||||
IsTabStop="False" />
|
IsTabStop="False"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1"
|
|
||||||
Content="{Binding RefreshDeviceIdText}"
|
|
||||||
Command="{Binding RefreshDeviceIdCommand}"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Margin="16,0,0,0"
|
|
||||||
Classes="accent-button" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -104,16 +104,26 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var wasOpen = ViewModel.IsDrawerOpen;
|
||||||
|
var previousTitle = ViewModel.DrawerTitle;
|
||||||
DrawerContentHost.Content = content;
|
DrawerContentHost.Content = content;
|
||||||
ViewModel.DrawerTitle = title ?? ViewModel.DrawerFallbackTitle;
|
ViewModel.DrawerTitle = title ?? ViewModel.DrawerFallbackTitle;
|
||||||
ViewModel.IsDrawerOpen = true;
|
ViewModel.IsDrawerOpen = true;
|
||||||
SyncTitleText();
|
SyncTitleText();
|
||||||
UpdateResponsiveLayout();
|
UpdateResponsiveLayout();
|
||||||
RequestResponsiveLayoutRefresh();
|
RequestResponsiveLayoutRefresh();
|
||||||
|
if (!wasOpen || !string.Equals(previousTitle, ViewModel.DrawerTitle, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
TelemetryServices.Usage?.TrackSettingsDrawerOpened(ViewModel.CurrentPageId, ViewModel.DrawerTitle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void CloseDrawer()
|
public void CloseDrawer()
|
||||||
{
|
{
|
||||||
|
var wasOpen = ViewModel.IsDrawerOpen || DrawerContentHost?.Content is not null;
|
||||||
|
var currentPageId = ViewModel.CurrentPageId;
|
||||||
|
var drawerTitle = ViewModel.DrawerTitle;
|
||||||
|
|
||||||
if (DrawerContentHost is not null)
|
if (DrawerContentHost is not null)
|
||||||
{
|
{
|
||||||
DrawerContentHost.Content = null;
|
DrawerContentHost.Content = null;
|
||||||
@@ -124,6 +134,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
SyncTitleText();
|
SyncTitleText();
|
||||||
UpdateResponsiveLayout();
|
UpdateResponsiveLayout();
|
||||||
RequestResponsiveLayoutRefresh();
|
RequestResponsiveLayoutRefresh();
|
||||||
|
if (wasOpen)
|
||||||
|
{
|
||||||
|
TelemetryServices.Usage?.TrackSettingsDrawerClosed(currentPageId, drawerTitle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void RequestRestart(string? reason = null)
|
public void RequestRestart(string? reason = null)
|
||||||
@@ -199,6 +213,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
|
|
||||||
private void NavigateTo(string? pageId)
|
private void NavigateTo(string? pageId)
|
||||||
{
|
{
|
||||||
|
var previousPageId = ViewModel.CurrentPageId;
|
||||||
var descriptor = ResolveDescriptor(pageId);
|
var descriptor = ResolveDescriptor(pageId);
|
||||||
if (descriptor is null)
|
if (descriptor is null)
|
||||||
{
|
{
|
||||||
@@ -226,6 +241,10 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
SyncTitleText();
|
SyncTitleText();
|
||||||
UpdateResponsiveLayout();
|
UpdateResponsiveLayout();
|
||||||
RequestResponsiveLayoutRefresh();
|
RequestResponsiveLayoutRefresh();
|
||||||
|
if (!string.Equals(previousPageId, descriptor.PageId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
TelemetryServices.Usage?.TrackSettingsNavigation(previousPageId, descriptor.PageId, "navigation");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private SettingsPageDescriptor? ResolveDescriptor(string? pageId)
|
private SettingsPageDescriptor? ResolveDescriptor(string? pageId)
|
||||||
@@ -367,6 +386,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
UpdateChromeMetrics();
|
UpdateChromeMetrics();
|
||||||
UpdateResponsiveLayout();
|
UpdateResponsiveLayout();
|
||||||
RequestResponsiveLayoutRefresh();
|
RequestResponsiveLayoutRefresh();
|
||||||
|
TelemetryServices.Usage?.TrackSettingsWindowOpened("SettingsWindow.OnOpened", ViewModel.CurrentPageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e)
|
private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||||
@@ -461,6 +481,7 @@ public partial class SettingsWindow : Window, ISettingsPageHostContext
|
|||||||
}
|
}
|
||||||
Opened -= OnOpened;
|
Opened -= OnOpened;
|
||||||
SizeChanged -= OnWindowSizeChanged;
|
SizeChanged -= OnWindowSizeChanged;
|
||||||
|
TelemetryServices.Usage?.TrackSettingsWindowClosed("SettingsWindow.OnClosed", ViewModel.CurrentPageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
private void OnWindowTitleBarPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ public sealed class PluginRuntimeService : IDisposable
|
|||||||
|
|
||||||
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
public PluginRuntimeService(ISettingsFacadeService? settingsFacade = null)
|
||||||
{
|
{
|
||||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
PluginsDirectory = Path.Combine(GetUserDataRootDirectory(), "Extensions", "Plugins");
|
||||||
_sharedContractManager = new PluginSharedContractManager(
|
_sharedContractManager = new PluginSharedContractManager(
|
||||||
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
Path.Combine(GetUserDataRootDirectory(), "PluginMarket"));
|
||||||
_packageManager = new PluginRuntimePackageManager(this);
|
_packageManager = new PluginRuntimePackageManager(this);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- **学生群体**:大学生、研究生、备考人员
|
- **学生群体**:大学生、研究生、备考人员
|
||||||
- **办公用户**:白领、远程工作者
|
- **办公用户**:白领、远程工作者
|
||||||
- **效率爱好者**:工具控、桌面美化爱好者
|
- **桌面美化爱好者**:追求个性化桌面布局与视觉体验的用户
|
||||||
|
|
||||||
## 2. 使用场景
|
## 2. 使用场景
|
||||||
|
|
||||||
@@ -32,60 +32,15 @@
|
|||||||
| 信息分散,需打开多个应用 | 桌面聚合展示天气、日历、新闻等信息 |
|
| 信息分散,需打开多个应用 | 桌面聚合展示天气、日历、新闻等信息 |
|
||||||
| 桌面单调,缺乏个性化 | 丰富的组件和主题自由定制 |
|
| 桌面单调,缺乏个性化 | 丰富的组件和主题自由定制 |
|
||||||
| 学习管理不便 | 课程表、自习监测专为学生设计 |
|
| 学习管理不便 | 课程表、自习监测专为学生设计 |
|
||||||
|
| 功能单一,需安装多个独立应用 | 一个应用整合考试看板、噪音监测等多种功能 |
|
||||||
| 功能无法满足个性需求 | 插件系统支持无限扩展 |
|
| 功能无法满足个性需求 | 插件系统支持无限扩展 |
|
||||||
|
|
||||||
## 5. 竞品对比分析
|
## 5. 产品进度
|
||||||
|
|
||||||
### 5.1 产品定位差异
|
- **当前版本**:v0.7.0(插件 API 3.0.0)
|
||||||
|
- **开发状态**:核心功能开发中,预计 v1.0 正式发布
|
||||||
| 产品 | 定位 | 主要场景 | 目标用户 |
|
|
||||||
|-----|------|---------|---------|
|
|
||||||
| **阑山桌面** | 个人桌面信息聚合与效率工具 | 个人学习、办公、信息获取 | 学生、办公人员、个人用户 |
|
|
||||||
| **希沃桌面** | 教室大屏教学系统 | 课堂教学、多媒体展示 | 中小学教师、学校 |
|
|
||||||
| **鸿合鸿U** | 交互式教学系统 | 课堂授课、教学管理 | 教师、教育机构 |
|
|
||||||
| **鸿合 Lesson+** | AI 备授课软件 | 备课、授课、互动、评价 | 教师 |
|
|
||||||
| **Classworks** | 教学资源与课堂管理 | 课堂互动、学情分析 | 教师、学校 |
|
|
||||||
|
|
||||||
### 5.2 功能对比
|
|
||||||
|
|
||||||
| 功能维度 | 阑山桌面 | 希沃/鸿合系列 |
|
|
||||||
|---------|---------|--------------|
|
|
||||||
| **核心功能** | 桌面组件、信息展示、效率工具 | 教学白板、课件展示、课堂互动 |
|
|
||||||
| **组件/工具** | 时钟、天气、日历、新闻、课程表 | 学科工具、白板批注、思维导图 |
|
|
||||||
| **插件扩展** | ✅ 支持第三方插件 | ❌ 封闭系统 |
|
|
||||||
| **跨平台** | ✅ Windows/Linux/macOS | ❌ 主要 Windows |
|
|
||||||
| **硬件依赖** | 无,纯软件 | 需配合交互大屏/白板 |
|
|
||||||
| **AI 功能** | 暂无 | 鸿合 Lesson+ 集成教学大模型 |
|
|
||||||
| **课堂互动** | 不支持 | 多屏互动、学生端连接 |
|
|
||||||
| **教学资源** | 无内置 | 丰富的学科资源库 |
|
|
||||||
| **使用场景** | 个人电脑桌面 | 教室大屏教学 |
|
|
||||||
| **部署方式** | 个人安装 | 学校/机构批量部署 |
|
|
||||||
|
|
||||||
### 5.3 竞争优势
|
|
||||||
|
|
||||||
| 优势 | 说明 |
|
|
||||||
|-----|------|
|
|
||||||
| **个人用户导向** | 专注个人效率,无需专用硬件 |
|
|
||||||
| **开放生态** | 插件系统支持功能无限扩展 |
|
|
||||||
| **跨平台支持** | 支持三大主流操作系统 |
|
|
||||||
| **轻量灵活** | 纯软件方案,部署成本低 |
|
|
||||||
| **隐私保护** | 本地数据存储,不上传个人信息 |
|
|
||||||
|
|
||||||
### 5.4 竞争劣势
|
|
||||||
|
|
||||||
| 劣势 | 说明 |
|
|
||||||
|-----|------|
|
|
||||||
| **非教学专用** | 缺乏专业教学工具和资源 |
|
|
||||||
| **无课堂互动** | 不支持学生端连接和课堂互动 |
|
|
||||||
| **无 AI 功能** | 暂不具备 AI 辅助教学能力 |
|
|
||||||
| **品牌认知** | 教育市场知名度低于希沃/鸿合 |
|
|
||||||
|
|
||||||
## 6. 产品进度
|
|
||||||
|
|
||||||
- **当前版本**:v1.0.0(插件 API 3.0.0)
|
|
||||||
- **开发状态**:核心功能已完成,进入优化迭代阶段
|
|
||||||
- **用户统计**:通过 PostHog 收集匿名数据(具体数据需后台查看)
|
- **用户统计**:通过 PostHog 收集匿名数据(具体数据需后台查看)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**一句话总结**:阑山桌面是一款面向个人用户的可定制桌面工具,与希沃、鸿合等教育大屏系统不同,专注个人学习办公场景,通过组件化设计和插件生态提供轻量、开放、跨平台的桌面信息聚合方案。
|
**一句话总结**:阑山桌面是一款面向个人用户的可定制桌面工具,专注个人学习办公场景,通过组件化设计和插件生态提供轻量、开放、跨平台的桌面信息聚合方案。
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ This repository does not own:
|
|||||||
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
|
- Appearance model: `IPluginAppearanceContext`, `PluginAppearanceSnapshot`, `PluginCornerRadiusTokens`, `PluginCornerRadiusPreset`
|
||||||
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
|
- Component registration model: `AddPluginDesktopComponent<TControl>(PluginDesktopComponentOptions options)`
|
||||||
|
|
||||||
|
## Plugin Package Surfaces
|
||||||
|
|
||||||
|
- `LanMountainDesktop.PluginSdk`: official plugin SDK package (includes `buildTransitive` default `.laapp` packaging targets)
|
||||||
|
- `LanMountainDesktop.Shared.Contracts`: shared contract package for host/plugin boundaries
|
||||||
|
- `LanMountainDesktop.PluginTemplate`: official `dotnet new` template package (`shortName`: `lmd-plugin`)
|
||||||
|
|
||||||
|
Use `scripts/Pack-PluginPackages.ps1` to generate local-feed packages for CI or workspace integration tests.
|
||||||
|
|
||||||
## Workspace Market Resolution
|
## Workspace Market Resolution
|
||||||
|
|
||||||
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder.
|
For local market debugging, the host resolves workspace files from the sibling repository path (`..\\LanAirApp`) instead of reading the in-repo mirror folder.
|
||||||
|
|||||||
57
scripts/Pack-PluginPackages.ps1
Normal file
57
scripts/Pack-PluginPackages.ps1
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$OutputPath,
|
||||||
|
[string]$Configuration = "Release",
|
||||||
|
[string]$Version,
|
||||||
|
[string]$NuGetPackagesPath
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||||
|
$OutputPath = Join-Path $repoRoot "artifacts\nuget"
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrWhiteSpace($NuGetPackagesPath)) {
|
||||||
|
$NuGetPackagesPath = Join-Path $repoRoot ".nuget\packages"
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolvedOutputPath = [System.IO.Path]::GetFullPath($OutputPath)
|
||||||
|
New-Item -ItemType Directory -Force -Path $resolvedOutputPath | Out-Null
|
||||||
|
$resolvedNuGetPackagesPath = [System.IO.Path]::GetFullPath($NuGetPackagesPath)
|
||||||
|
New-Item -ItemType Directory -Force -Path $resolvedNuGetPackagesPath | Out-Null
|
||||||
|
$env:NUGET_PACKAGES = $resolvedNuGetPackagesPath
|
||||||
|
|
||||||
|
$projects = @(
|
||||||
|
"LanMountainDesktop.Shared.Contracts\LanMountainDesktop.Shared.Contracts.csproj",
|
||||||
|
"LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj",
|
||||||
|
"LanMountainDesktop.PluginTemplate\LanMountainDesktop.PluginTemplate.csproj"
|
||||||
|
)
|
||||||
|
|
||||||
|
$versionArgs = @()
|
||||||
|
if (-not [string]::IsNullOrWhiteSpace($Version)) {
|
||||||
|
$versionArgs += "-p:Version=$Version"
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($project in $projects) {
|
||||||
|
$projectPath = Join-Path $repoRoot $project
|
||||||
|
if (-not (Test-Path -Path $projectPath)) {
|
||||||
|
throw "Project '$projectPath' was not found."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Packing $project..."
|
||||||
|
$args = @(
|
||||||
|
"pack",
|
||||||
|
$projectPath,
|
||||||
|
"-c", $Configuration,
|
||||||
|
"-o", $resolvedOutputPath
|
||||||
|
) + $versionArgs
|
||||||
|
|
||||||
|
& dotnet @args
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "dotnet pack failed for '$projectPath' with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Plugin packages generated to '$resolvedOutputPath'."
|
||||||
Reference in New Issue
Block a user