Compare commits

..

24 Commits

Author SHA1 Message Date
lincube
7a268489c9 ci.圆角
修了下新的圆角。
2026-03-30 16:34:45 +08:00
lincube
148e4c894a 0.8.0
圆角设计更新
2026-03-30 15:28:51 +08:00
lincube
f84111e837 0.7.9.2
自习设置,优化设置选项卡图标,加入智教hub组件
2026-03-30 02:40:10 +08:00
lincube
bd2313fe7e 0.7.9.1 2026-03-29 15:34:17 +08:00
lincube
372b5b7adc 0.7.9
更新功能优化、插件市场优化,反正就是优化了很多东西
2026-03-25 11:27:30 +08:00
lincube
74703582e7 0.7.8.1
凤凰网新闻组件优化、央广网新闻组件优化。
2026-03-25 07:44:55 +08:00
lincube
26ff11b16b 0.7.8 2026-03-24 23:15:32 +08:00
lincube
b83cfb47b0 0.7.7.2
笔迹粗细大小调节
2026-03-24 20:16:44 +08:00
lincube
a0bb83c743 0.7.7.1 2026-03-24 17:47:54 +08:00
lincube
af2e7b4f2f 0.7.7
橘鸦新闻
2026-03-24 09:33:56 +08:00
lincube
798124e500 0.7.6.3 2026-03-23 22:43:54 +08:00
lincube
95ecb06668 0.7.6.2
在应用启动台上,也可以正常滑动
2026-03-23 21:13:08 +08:00
lincube
ac7e8db516 0.7.6.1
修复了系统标题栏的问题。
2026-03-23 12:34:04 +08:00
lincube
8ded721f46 0.7.6
加入删除页面二次确认
2026-03-23 12:14:56 +08:00
lincube
a559325f5a 0.7.5.3
设置界面动画优化
2026-03-23 11:25:24 +08:00
lincube
b60368527f 0.7.5.2
日韩支持
2026-03-22 23:30:43 +08:00
lincube
c8c3f51bff 0.7.5.1
精致
2026-03-22 20:29:44 +08:00
lincube
685323e057 0.7.5
顺滑的组件放置与调整
2026-03-22 15:21:29 +08:00
lincube
def21c79b1 0.7.4.1
动画优化
2026-03-22 14:47:15 +08:00
lincube
c3db5af923 0.7.4
首先我加了CI课程表json的读取,然后把天气时钟这个老问题也修了。
2026-03-22 04:57:19 +08:00
lincube
1a7dde34d0 0.7.3.1 2026-03-22 02:53:31 +08:00
lincube
73cdefe296 0.7.3
修东西
2026-03-21 22:40:07 +08:00
lincube
46a8df5900 0.7.2 2026-03-21 16:16:02 +08:00
lincube
2a1c09ae39 0.7.2 2026-03-21 13:08:20 +08:00
243 changed files with 23278 additions and 5177 deletions

45
.github/README.md vendored
View File

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

133
.github/READMEmd vendored Normal file
View File

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

View File

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

93
AGENTS.md Normal file
View File

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

View File

@@ -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 控件残留

View File

@@ -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** 壁纸设置功能正常工作(仅支持图片和纯色)

View File

@@ -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
- 修改填充方式可见性绑定
### 验收标准
- [ ] 无视频预览 BorderIsVisible="{Binding IsVideo}"
- [ ] 无 VideoModeHintText 绑定的 TextBlock
- [ ] 填充方式设置绑定改为 `IsVisible="{Binding IsImage}"`
### 执行提示
```
编辑 WallpaperSettingsPage.axaml
1. 移除第 29-44 行的视频预览 Border
2. 移除第 150-154 行的视频模式提示 TextBlock
3. 修改第 132 行: IsVisible="{Binding IsImageOrVideo}" 改为 IsVisible="{Binding IsImage}"
```
---
## 任务 9: 简化壁纸设置 ViewModel
**优先级**: P1
**依赖**: 任务 8
**预估工作量**: 15 分钟
### 描述
从 WallpaperSettingsPageViewModel.cs 中移除视频相关属性和方法逻辑。
### 输入
- `LanMountainDesktop/ViewModels/WallpaperSettingsPageViewModel.cs`
### 输出
- 移除 `_isImageOrVideo``_isVideo``_videoModeHintText` 属性
- 修改 `CreateWallpaperTypes` 方法
- 修改 `UpdateVisibility` 方法
- 修改 `RefreshLocalizedText` 方法
### 验收标准
- [ ]`IsImageOrVideo` 属性
- [ ]`IsVideo` 属性
- [ ]`VideoModeHintText` 属性
- [ ] `CreateWallpaperTypes` 仅返回 Image 和 SolidColor 选项
- [ ] `UpdateVisibility` 中无 IsVideo、IsImageOrVideo 赋值
- [ ] `RefreshLocalizedText` 中无 VideoModeHintText 赋值
### 执行提示
```
编辑 WallpaperSettingsPageViewModel.cs
1. 移除第 76-77 行的 _isImageOrVideo 字段和属性
2. 移除第 85-86 行的 _isVideo 字段和属性
3. 移除第 94-95 行的 _videoModeHintText 字段和属性
4. 修改 CreateWallpaperTypes 方法,移除 Video 选项
5. 修改 UpdateVisibility 方法,移除 IsVideo 和 IsImageOrVideo 赋值
6. 修改 RefreshLocalizedText 方法,移除 VideoModeHintText 赋值
```
---
## 任务 10: 简化壁纸媒体类型枚举
**优先级**: P1
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 SettingsContracts.cs 中移除 WallpaperMediaType 枚举的 Video 值。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsContracts.cs`
### 输出
- 简化的 `WallpaperMediaType` 枚举
### 验收标准
- [ ] `WallpaperMediaType` 枚举仅包含 `None``Image`
### 执行提示
```
编辑 SettingsContracts.cs
修改第 11-16 行的枚举定义:
public enum WallpaperMediaType
{
None,
Image
}
```
---
## 任务 11: 简化壁纸媒体服务
**优先级**: P1
**依赖**: 任务 10
**预估工作量**: 10 分钟
### 描述
从 SettingsDomainServices.cs 中移除视频扩展名检测逻辑。
### 输入
- `LanMountainDesktop/Services/Settings/SettingsDomainServices.cs`
### 输出
- 移除 `VideoExtensions` 字段
- 简化 `DetectMediaType` 方法
### 验收标准
- [ ]`VideoExtensions` 字段定义
- [ ] `DetectMediaType` 方法中无视频扩展名检测逻辑
### 执行提示
```
编辑 SettingsDomainServices.cs
1. 移除第 150-153 行的 VideoExtensions 字段定义
2. 修改 DetectMediaType 方法,移除视频检测分支
```
---
## 任务 12: 清理本地化文件
**优先级**: P2
**依赖**: 无
**预估工作量**: 5 分钟
### 描述
从 zh-CN.json 中移除视频壁纸相关的本地化文本。
### 输入
- `LanMountainDesktop/Localization/zh-CN.json`
### 输出
- 移除视频相关本地化键
- 修改壁纸描述文本
### 验收标准
- [ ]`settings.wallpaper.type.video`
- [ ]`settings.wallpaper.video_applied`
- [ ]`settings.wallpaper.video_mode`
- [ ]`settings.wallpaper.video_restored`
- [ ]`settings.wallpaper.video_not_found`
- [ ]`settings.wallpaper.video_player_unavailable`
- [ ]`settings.wallpaper.video_play_failed_format`
- [ ] `settings.wallpaper.description` 文本已更新
### 执行提示
```
编辑 zh-CN.json
1. 移除以下键值对:
- "settings.wallpaper.type.video"
- "settings.wallpaper.video_applied"
- "settings.wallpaper.video_mode"
- "settings.wallpaper.video_restored"
- "settings.wallpaper.video_not_found"
- "settings.wallpaper.video_player_unavailable"
- "settings.wallpaper.video_play_failed_format"
2. 修改描述文本:
"settings.wallpaper.description": "选择图片后可立即设为应用窗口壁纸。"
```
---
## 任务 13: 构建验证
**优先级**: P0
**依赖**: 任务 1-12 全部完成
**预估工作量**: 10 分钟
### 描述
验证项目在移除视频壁纸功能后能够正常构建。
### 输入
- 整个项目
### 输出
- 构建成功确认
### 验收标准
- [ ] `dotnet build` 执行成功,无编译错误
- [ ] 无 LibVLC 相关类型未定义错误
- [ ] 无未使用变量警告(或已处理)
### 执行提示
```
在项目根目录执行:
dotnet build LanMountainDesktop/LanMountainDesktop.csproj
检查输出:
- 确认无编译错误
- 确认无 LibVLC 相关类型引用错误
```
---
## 任务 14: 功能验证
**优先级**: P0
**依赖**: 任务 13
**预估工作量**: 15 分钟
### 描述
验证应用在移除视频壁纸功能后核心功能正常工作。
### 输入
- 构建后的应用
### 输出
- 功能验证报告
### 验收标准
- [ ] 应用正常启动
- [ ] 图片壁纸正常显示
- [ ] 纯色壁纸正常显示
- [ ] 壁纸设置页面正常打开
- [ ] 类型选择器仅显示"图片"和"纯色"选项
- [ ] 壁纸导入功能正常工作
### 执行提示
```
运行应用:
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
手动验证:
1. 应用启动无崩溃
2. 打开设置 -> 壁纸页面
3. 确认类型选择器仅有"图片"和"纯色"
4. 测试选择图片壁纸
5. 测试选择纯色壁纸
```
---
## 任务依赖关系图
```
任务 1 (移除依赖)
├── 任务 2 (XAML控件)
├── 任务 3 (代码字段)
│ └── 任务 4 (OnClosed清理)
├── 任务 5 (Stub方法)
│ └── 任务 6 (状态处理逻辑)
└── 任务 7 (主题服务)
任务 8 (设置页面XAML)
└── 任务 9 (设置ViewModel)
任务 10 (枚举简化)
└── 任务 11 (媒体服务)
任务 12 (本地化) - 独立
任务 13 (构建验证) - 依赖所有任务
└── 任务 14 (功能验证)
```
---
## 执行顺序建议
按以下顺序执行可确保依赖关系正确:
1. **第一批** (可并行): 任务 1, 任务 8, 任务 10, 任务 12
2. **第二批** (可并行): 任务 2, 任务 3, 任务 5, 任务 7, 任务 9, 任务 11
3. **第三批** (可并行): 任务 4, 任务 6
4. **第四批**: 任务 13
5. **第五批**: 任务 14

View File

@@ -11,12 +11,13 @@ public static class AppearanceCornerRadiusTokenFactory
var normalizedScale = GlobalAppearanceSettings.NormalizeCornerRadiusScale(scale);
return new AppearanceCornerRadiusTokens(
Radius(6, normalizedScale),
Radius(10, normalizedScale),
Radius(12, normalizedScale),
Radius(14, normalizedScale),
Radius(18, normalizedScale),
Radius(24, normalizedScale),
Radius(30, normalizedScale),
Radius(36, normalizedScale));
Radius(20, normalizedScale),
Radius(28, normalizedScale),
Radius(32, normalizedScale),
Radius(36, normalizedScale),
Radius(18, normalizedScale));
}
private static CornerRadius Radius(double value, double scale)

View File

@@ -5,16 +5,20 @@ namespace LanMountainDesktop.DesktopHost;
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(initializeCrashReporting);
ArgumentNullException.ThrowIfNull(initializeUserBehaviorAnalytics);
ArgumentNullException.ThrowIfNull(initializeTelemetryIdentity);
ArgumentNullException.ThrowIfNull(initializeCrashTelemetry);
ArgumentNullException.ThrowIfNull(initializeUsageTelemetry);
ArgumentNullException.ThrowIfNull(scheduleStartupCleanup);
initializeDeviceId();
initializeCrashReporting();
initializeUserBehaviorAnalytics();
initializeTelemetryIdentity();
initializeCrashTelemetry();
initializeUsageTelemetry();
scheduleStartupCleanup();
}

View File

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

View File

@@ -9,5 +9,6 @@ public enum PluginCornerRadiusPreset
Md = 4,
Lg = 5,
Xl = 6,
Island = 7
Island = 7,
Component = 8
}

View File

@@ -10,13 +10,14 @@ public sealed record PluginCornerRadiusTokens(
double Md,
double Lg,
double Xl,
double Island)
double Island,
double Component)
{
public double Get(PluginCornerRadiusPreset preset)
{
return preset switch
{
PluginCornerRadiusPreset.Default => Md,
PluginCornerRadiusPreset.Default => Component,
PluginCornerRadiusPreset.Micro => Micro,
PluginCornerRadiusPreset.Xs => Xs,
PluginCornerRadiusPreset.Sm => Sm,
@@ -24,7 +25,8 @@ public sealed record PluginCornerRadiusTokens(
PluginCornerRadiusPreset.Lg => Lg,
PluginCornerRadiusPreset.Xl => Xl,
PluginCornerRadiusPreset.Island => Island,
_ => Md
PluginCornerRadiusPreset.Component => Component,
_ => Component
};
}
@@ -44,6 +46,7 @@ public sealed record PluginCornerRadiusTokens(
tokens.Md.TopLeft,
tokens.Lg.TopLeft,
tokens.Xl.TopLeft,
tokens.Island.TopLeft);
tokens.Island.TopLeft,
tokens.Component.TopLeft);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,4 +9,5 @@ public sealed record AppearanceCornerRadiusTokens(
CornerRadius Md,
CornerRadius Lg,
CornerRadius Xl,
CornerRadius Island);
CornerRadius Island,
CornerRadius Component);

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ public sealed class BuiltInDesktopHostCornerRadiusBaselineTests
var registry = new DesktopComponentRuntimeRegistry(
ComponentRegistry.CreateDefault(),
DesktopComponentRuntimeRegistry.GetDefaultRegistrations());
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Lg.TopLeft;
var expected = AppearanceCornerRadiusTokenFactory.Create(globalScale).Component.TopLeft;
foreach (var descriptor in registry.GetDesktopComponents())
{

View File

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

View File

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

View File

@@ -40,12 +40,13 @@ public sealed class CornerRadiusScaleTests
GlobalCornerRadiusScale: 0d,
CornerRadiusTokens: PluginCornerRadiusTokens.FromShared(new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(10),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(18),
new CornerRadius(24),
new CornerRadius(30),
new CornerRadius(36))),
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8))),
ThemeVariant: "Unknown"));
var context = new PluginDesktopComponentContext(
@@ -76,7 +77,8 @@ public sealed class CornerRadiusScaleTests
Md: 36d,
Lg: 48d,
Xl: 60d,
Island: 72d),
Island: 72d,
Component: 16d),
ThemeVariant: "Light"));
Assert.Equal(36d, context.ResolveCornerRadius(PluginCornerRadiusPreset.Md), 3);

View File

@@ -48,11 +48,12 @@ public sealed class DesktopComponentRuntimeRegistrationCornerRadiusTests
GlobalCornerRadiusScale: globalScale,
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
new CornerRadius(6),
new CornerRadius(10),
new CornerRadius(12),
new CornerRadius(14),
new CornerRadius(18),
new CornerRadius(24),
new CornerRadius(30),
new CornerRadius(36)));
new CornerRadius(20),
new CornerRadius(28),
new CornerRadius(32),
new CornerRadius(36),
new CornerRadius(8)));
}
}

View File

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

View File

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

View File

@@ -53,8 +53,8 @@ public sealed class InfoRecommendationHostCornerRadiusTests
var max = descriptor.ResolveCornerRadius(CreateChromeContext(componentId, cellSize, 2.5d));
Assert.Equal(0d, zero, 3);
Assert.Equal(24d, unit, 3);
Assert.Equal(60d, max, 3);
Assert.Equal(18d, unit, 3);
Assert.Equal(45d, max, 3);
Assert.True(zero <= unit && unit <= max);
}

View File

@@ -6,6 +6,7 @@
<Project Path="LanMountainDesktop.DesktopComponents.Runtime/LanMountainDesktop.DesktopComponents.Runtime.csproj" />
<Project Path="LanMountainDesktop.DesktopHost/LanMountainDesktop.DesktopHost.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginTemplate/LanMountainDesktop.PluginTemplate.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />

View File

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

View File

@@ -47,6 +47,7 @@ public partial class App : Application
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly FontFamilyService _fontFamilyService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
@@ -57,7 +58,12 @@ public partial class App : Application
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
private TrayIcons? _trayIcons;
private TrayIcon? _trayIcon;
private NativeMenuItem? _trayShowDesktopMenuItem;
private NativeMenuItem? _traySettingsMenuItem;
private NativeMenuItem? _trayComponentLibraryMenuItem;
private NativeMenuItem? _trayRestartMenuItem;
private NativeMenuItem? _trayExitMenuItem;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private bool _mainWindowClosed;
@@ -65,7 +71,6 @@ public partial class App : Application
private DesktopShellHost? _desktopShellHost;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
@@ -97,6 +102,11 @@ public partial class App : Application
public App()
{
if (Design.IsDesignMode)
{
return;
}
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
@@ -104,9 +114,16 @@ public partial class App : Application
public override void Initialize()
{
AppLogger.Info("App", "Initializing application resources.");
AvaloniaXamlLoader.Load(this);
if (Design.IsDesignMode)
{
ApplyDesignTimeTheme();
return;
}
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
ApplyThemeFromSettings();
ApplyCurrentCultureFromSettings();
EnsureSettingsWindowService();
@@ -115,6 +132,12 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode)
{
base.OnFrameworkInitializationCompleted();
return;
}
AppLogger.Info("App", "Framework initialization completed.");
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
@@ -123,6 +146,20 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private void ApplyDesignTimeTheme()
{
RequestedThemeVariant = ThemeVariant.Light;
try
{
ApplyAdaptiveThemeResources();
}
catch (Exception ex)
{
AppLogger.Warn("Previewer", "Failed to apply adaptive theme resources in design mode.", ex);
}
}
private void InitializeDesktopShell()
{
_desktopShellHost ??= new DesktopShellHost(
@@ -244,18 +281,43 @@ public partial class App : Application
{
try
{
DisposeTrayIcon();
var trayIcon = new TrayIcon
if (_trayIcon is null)
{
Icon = _appLogoService.CreateTrayIcon(),
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
Menu = BuildTrayMenu(),
IsVisible = true
};
_trayShowDesktopMenuItem = new NativeMenuItem();
_trayShowDesktopMenuItem.Click += OnTrayShowDesktopClick;
_trayIcons = [trayIcon];
TrayIcon.SetIcons(this, _trayIcons);
_traySettingsMenuItem = new NativeMenuItem();
_traySettingsMenuItem.Click += OnTraySettingsClick;
_trayComponentLibraryMenuItem = new NativeMenuItem();
_trayComponentLibraryMenuItem.Click += OnTrayComponentLibraryClick;
_trayRestartMenuItem = new NativeMenuItem();
_trayRestartMenuItem.Click += OnTrayRestartClick;
_trayExitMenuItem = new NativeMenuItem();
_trayExitMenuItem.Click += OnTrayExitClick;
var trayMenu = new NativeMenu();
trayMenu.Items.Add(_trayShowDesktopMenuItem);
trayMenu.Items.Add(_traySettingsMenuItem);
trayMenu.Items.Add(_trayComponentLibraryMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayRestartMenuItem);
trayMenu.Items.Add(new NativeMenuItemSeparator());
trayMenu.Items.Add(_trayExitMenuItem);
_trayIcon = new TrayIcon
{
Icon = _appLogoService.CreateTrayIcon(),
Menu = trayMenu,
IsVisible = true
};
TrayIcon.SetIcons(this, [_trayIcon]);
}
RefreshTrayIconContent();
}
catch (Exception ex)
{
@@ -263,51 +325,58 @@ public partial class App : Application
}
}
private NativeMenu BuildTrayMenu()
private void RefreshTrayIconContent()
{
var menu = new NativeMenu();
if (_trayIcon is not null)
{
_trayIcon.IsVisible = true;
if (!OperatingSystem.IsLinux())
{
_trayIcon.ToolTipText = L("tray.tooltip", "LanMountainDesktop");
}
}
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
showDesktopItem.Click += OnTrayShowDesktopClick;
menu.Items.Add(showDesktopItem);
if (_trayShowDesktopMenuItem is not null)
{
_trayShowDesktopMenuItem.Header = L("tray.menu.show_desktop", "Open Desktop");
}
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
if (_traySettingsMenuItem is not null)
{
_traySettingsMenuItem.Header = L("tray.menu.settings", "Settings");
}
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
componentLibraryItem.Click += OnTrayComponentLibraryClick;
menu.Items.Add(componentLibraryItem);
if (_trayComponentLibraryMenuItem is not null)
{
_trayComponentLibraryMenuItem.Header = L("tray.menu.component_library", "Component Library");
}
menu.Items.Add(new NativeMenuItemSeparator());
if (_trayRestartMenuItem is not null)
{
_trayRestartMenuItem.Header = L("tray.menu.restart", "Restart App");
}
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem);
return menu;
if (_trayExitMenuItem is not null)
{
_trayExitMenuItem.Header = L("tray.menu.exit", "Exit App");
}
}
private void DisposeTrayIcon()
{
if (_trayIcons is null)
if (_trayIcon is null)
{
return;
}
TrayIcon.SetIcons(this, null);
foreach (var trayIcon in _trayIcons)
try
{
trayIcon.Dispose();
_trayIcon.IsVisible = false;
}
catch (Exception ex)
{
AppLogger.Warn("TrayIcon", "Failed to hide tray icon during cleanup.", ex);
}
_trayIcons = null;
}
private void EnsureSettingsWindowService()
@@ -380,6 +449,21 @@ public partial class App : Application
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
ApplyLanguageSpecificFont(languageCode);
}
private void ApplyLanguageSpecificFont(string languageCode)
{
var fontFamily = _fontFamilyService.GetFontFamilyForLanguage(languageCode);
if (Resources.TryGetValue("AppFontFamily", out var currentFont) &&
currentFont is FontFamily currentFontFamily &&
currentFontFamily.Name == fontFamily.Name)
{
return;
}
Resources["AppFontFamily"] = fontFamily;
}
private void ActivateMainWindow()
@@ -520,10 +604,7 @@ public partial class App : Application
// 清除本地化缓存,强制重新加载语言文件
_localizationService.ClearCache();
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
RefreshTrayIconContent();
}
}, DispatcherPriority.Background);
}
@@ -591,13 +672,13 @@ public partial class App : Application
try
{
var (analytics, crashReport) = App.AnalyticsServices;
analytics?.SendShutdownEvent();
crashReport?.SendShutdownEvent();
TelemetryServices.Usage?.Shutdown(
_shutdownIntent == ShutdownIntent.RestartRequested,
"App.PerformExitCleanup");
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
AppLogger.Warn("Analytics", "Failed to shut down usage telemetry during exit cleanup.", ex);
}
try
@@ -631,6 +712,27 @@ public partial class App : Application
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
try
{
TelemetryServices.Crash?.CaptureShutdown(
_shutdownIntent == ShutdownIntent.RestartRequested,
"App.PerformExitCleanup");
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to capture crash shutdown telemetry during exit cleanup.", ex);
}
try
{
TelemetryServices.Crash?.Dispose();
TelemetryServices.Usage?.Dispose();
}
catch (Exception ex)
{
AppLogger.Warn("Analytics", "Failed to dispose telemetry services during exit cleanup.", ex);
}
}
private MainWindow CreateAndAssignMainWindow(

View File

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

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -33,6 +33,7 @@ public static class BuiltInComponentIds
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopJuyaNews = "DesktopJuyaNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
@@ -42,4 +43,5 @@ public static class BuiltInComponentIds
public const string DesktopBrowser = "DesktopBrowser";
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
public const string DesktopRemovableStorage = "DesktopRemovableStorage";
public const string DesktopZhiJiaoHub = "DesktopZhiJiaoHub";
}

View File

@@ -261,6 +261,16 @@ public sealed class ComponentRegistry
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopJuyaNews,
"橘鸦早报",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
@@ -380,7 +390,17 @@ public sealed class ComponentRegistry
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true)
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopZhiJiaoHub,
"智教Hub",
"Image",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true,
ResizeMode: DesktopComponentResizeMode.Free)
};
return new ComponentRegistry(builtIn);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ using Markdown.Avalonia;
namespace LanMountainDesktop.Helpers;
public static class PluginMarketMarkdownHelper
public static class PluginCatalogMarkdownHelper
{
private static Markdown.Avalonia.Markdown? _engine;

View File

@@ -53,8 +53,8 @@
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageReference Include="Downloader" Version="4.1.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.320" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.320" />
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />

View File

@@ -38,6 +38,27 @@
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
"settings.wallpaper.current_label": "Current Wallpaper",
"settings.wallpaper.type_label": "Wallpaper Type",
"settings.wallpaper.type.image": "Image",
"settings.wallpaper.type.solid_color": "Solid Color",
"settings.wallpaper.type.system": "System Wallpaper",
"settings.wallpaper.system.label": "System Wallpaper",
"settings.wallpaper.system.unavailable": "Unable to read system wallpaper",
"settings.wallpaper.refresh_interval": "Refresh Interval",
"settings.wallpaper.refresh_now": "Refresh Now",
"settings.wallpaper.refresh.30s": "30 seconds",
"settings.wallpaper.refresh.1m": "1 minute",
"settings.wallpaper.refresh.5m": "5 minutes",
"settings.wallpaper.refresh.10m": "10 minutes",
"settings.wallpaper.refresh.15m": "15 minutes",
"settings.wallpaper.refresh.30m": "30 minutes",
"settings.wallpaper.refresh.1h": "1 hour",
"settings.wallpaper.refresh.2h": "2 hours",
"settings.wallpaper.refresh.4h": "4 hours",
"settings.wallpaper.refresh.8h": "8 hours",
"settings.wallpaper.refresh.12h": "12 hours",
"settings.wallpaper.refresh.24h": "24 hours",
"settings.wallpaper.color_label": "Wallpaper Color",
"settings.wallpaper.placement_label": "Placement",
"settings.wallpaper.placement_desc": "Adjust how the image fills the desktop.",
"settings.wallpaper.pick_button": "Browse Files",
@@ -182,6 +203,52 @@
"settings.weather.alert_list_desc": "One exclusion rule per line.",
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
"settings.study.title": "Study",
"settings.study.description": "Configure study environment monitoring, focus timer, and alert settings.",
"settings.study.noise_header": "Noise Monitoring",
"settings.study.noise_description": "Configure microphone sampling rate and noise scoring sensitivity.",
"settings.study.sampling_rate_label": "Sampling Rate",
"settings.study.sampling_rate_desc": "Time interval for microphone audio sampling. Higher frequency captures noise changes more accurately but increases power consumption.",
"settings.study.sampling_rate_20ms": "20ms (High)",
"settings.study.sampling_rate_50ms": "50ms (Standard)",
"settings.study.sampling_rate_100ms": "100ms (Power Saving)",
"settings.study.sampling_rate_200ms": "200ms (Low Power)",
"settings.study.sensitivity_label": "Noise Sensitivity",
"settings.study.sensitivity_desc": "Scoring threshold determines what level of noise is considered interference. Stricter thresholds detect quieter noises.",
"settings.study.sensitivity_relaxed": "Relaxed (-45dBFS)",
"settings.study.sensitivity_standard": "Standard (-50dBFS)",
"settings.study.sensitivity_strict": "Strict (-55dBFS)",
"settings.study.sensitivity_very_strict": "Very Strict (-60dBFS)",
"settings.study.current_threshold_format": "Current scoring threshold: {0} dBFS",
"settings.study.timer_header": "Focus Timer",
"settings.study.timer_description": "Configure focus and break session durations.",
"settings.study.focus_duration_label": "Focus Duration",
"settings.study.focus_duration_desc": "Duration of a single focus session (minutes).",
"settings.study.break_duration_label": "Break Duration",
"settings.study.break_duration_desc": "Duration of a short break session (minutes).",
"settings.study.long_break_duration_label": "Long Break Duration",
"settings.study.long_break_duration_desc": "Duration of a long break session (minutes).",
"settings.study.sessions_before_long_break_label": "Long Break Interval",
"settings.study.sessions_before_long_break_desc": "Number of focus sessions before a long break.",
"settings.study.auto_start_break_label": "Auto-start Break",
"settings.study.auto_start_break_desc": "Automatically start break timer when focus session ends.",
"settings.study.auto_start_focus_label": "Auto-start Focus",
"settings.study.auto_start_focus_desc": "Automatically start focus timer when break ends.",
"settings.study.alert_header": "Alert Settings",
"settings.study.alert_description": "Configure noise interference alerts.",
"settings.study.noise_alert_enabled_label": "Enable Noise Alert",
"settings.study.noise_alert_enabled_desc": "Show an alert when noise interference exceeds tolerance threshold.",
"settings.study.max_interrupts_label": "Max Tolerated Interrupts",
"settings.study.max_interrupts_desc": "Maximum noise interference events per minute before triggering an alert.",
"settings.study.display_header": "Display Settings",
"settings.study.display_description": "Configure how noise data is displayed.",
"settings.study.show_realtime_db_label": "Show Realtime Decibel",
"settings.study.show_realtime_db_desc": "Display decibel values in real-time on components.",
"settings.study.baseline_db_label": "Baseline Display Decibel",
"settings.study.baseline_db_desc": "Calibrated baseline decibel value for converting dBFS to user-readable dB.",
"settings.study.avg_window_label": "Averaging Window",
"settings.study.avg_window_desc": "Time window for smoothing noise display. Larger values make display more stable but slower to respond.",
"settings.study.footer_hint": "These settings affect the behavior of study environment monitoring components.",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
@@ -217,7 +284,14 @@
"schedule.settings.unnamed": "Unnamed Schedule",
"schedule.settings.delete": "Delete",
"schedule.settings.picker_title": "Select ClassIsland schedule file",
"schedule.settings.picker_file_type": "ClassIsland CSES schedule",
"schedule.settings.picker_file_type.all": "ClassIsland Schedule Files",
"schedule.settings.picker_file_type.json": "ClassIsland Profile (JSON)",
"schedule.settings.picker_file_type.cses": "CSES Schedule (YAML)",
"schedule.settings.semester.title": "Semester Settings",
"schedule.settings.semester.start_date": "Semester Start Date",
"schedule.settings.semester.week_cycle": "Week Cycle",
"schedule.settings.semester.week_cycle_desc": "Set the week rotation cycle for multi-week schedules (e.g., 2 for odd/even weeks).",
"schedule.settings.semester.week_cycle_format": "{0}-week rotation",
"worldclock.settings.title": "World Clock Settings",
"worldclock.settings.desc": "Choose a time zone for each of the four clocks.",
"worldclock.settings.clock_1": "Clock 1",
@@ -248,6 +322,7 @@
"settings.region.language_label": "Language",
"settings.region.language_zh": "Chinese",
"settings.region.language_en": "English",
"settings.region.language_ja": "Japanese",
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
@@ -389,6 +464,11 @@
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
"settings.update.download_threads_label": "Download Threads",
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
"settings.update.force_check_label": "Force Check Update",
"settings.update.force_check_desc": "Force check for updates from GitHub, ignoring version comparison.",
"settings.update.status_force_checking": "Force checking GitHub releases...",
"settings.update.status_force_no_asset": "Release found but no compatible installer available.",
"settings.update.status_force_available_format": "Release {0} is available. Click Download & Install.",
"settings.update.install_now_button": "Install Now",
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
@@ -496,10 +576,10 @@
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"settings.nav.plugin_catalog": "Plugin Catalog",
"settings.plugin_catalog.title": "Plugin Catalog",
"settings.plugin_catalog.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_catalog.unavailable": "Plugin runtime is not available, so the official catalog cannot be opened right now.",
"settings.update.status_idle": "No update check has been performed yet.",
"settings.update.status_preferences_saved": "Update preferences saved.",
"settings.update.status_check_failed": "Failed to check for updates.",
@@ -930,6 +1010,10 @@
"study.interrupt_density.unavailable": "--",
"desktop.add_page": "Add page",
"desktop.delete_page": "Delete page",
"desktop.delete_page_confirm.title": "Confirm Delete Page",
"desktop.delete_page_confirm.message": "Are you sure you want to delete the current page?\n\nThis will remove all components on this page and cannot be undone.",
"desktop.delete_page_confirm.primary": "Delete",
"desktop.delete_page_confirm.close": "Cancel",
"placement.fill": "Fill",
"placement.fit": "Fit",
"placement.stretch": "Stretch",
@@ -939,5 +1023,19 @@
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ Plugin '{0}' installed successfully! Please restart the application to activate it.",
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?"
"market.dialog.restart_message_format": "Plugin '{0}' has been installed successfully.\n\nTo use this plugin, you need to restart the application now.\n\nWould you like to restart?",
"zhijiaohub.settings.source": "Image Source",
"zhijiaohub.settings.classisland": "ClassIsland Gallery",
"zhijiaohub.settings.sectl": "SECTL Gallery",
"zhijiaohub.settings.source_desc": "Select the image source. ClassIsland Gallery contains fun moments from the ClassIsland community, SECTL Gallery contains content from the SECTL community.",
"zhijiaohub.settings.mirror_source": "Mirror Acceleration",
"zhijiaohub.settings.mirror_direct": "Direct (GitHub)",
"zhijiaohub.settings.mirror_ghproxy": "Mirror Acceleration (Recommended)",
"zhijiaohub.settings.mirror_source_desc": "If images load slowly or fail, try using mirror acceleration. Mirror acceleration speeds up GitHub access through third-party proxy services.",
"zhijiaohub.settings.refresh": "Refresh Settings",
"zhijiaohub.settings.auto_refresh": "Auto Refresh",
"zhijiaohub.settings.auto_refresh_desc": "Automatically refresh the image list periodically.",
"zhijiaohub.settings.interval": "Refresh Interval (minutes)",
"zhijiaohub.settings.about": "About",
"zhijiaohub.settings.about_desc": "ZhiJiaoHub displays interesting images from the educational technology community. Images are fetched from GitHub repositories and cached locally."
}

View File

@@ -0,0 +1,978 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.show_desktop": "デスクトップを開く",
"tray.menu.settings": "設定",
"tray.menu.component_library": "ウィジェットライブラリ",
"tray.menu.restart": "アプリを再起動",
"tray.menu.exit": "アプリを終了",
"button.back_to_windows": "Windowsに戻る",
"button.back_to_platform": "{0}に戻る",
"tooltip.back_to_windows": "Windowsに戻る",
"tooltip.back_to_platform": "{0}に戻る",
"platform.windows": "Windows",
"platform.linux": "Linux",
"platform.macos": "macOS",
"tooltip.open_settings": "設定",
"settings.title": "設定",
"settings.shell.title": "設定",
"settings.shell.subtitle": "LanMountainDesktop 独立設定モジュール",
"settings.shell.sidebar_hint": "カテゴリを選択して、アプリの動作、デスクトップレイアウト、外観を調整します。",
"settings.shell.footer_hint": "トレイから開く設定は、この独立設定モジュールで管理されます。",
"settings.back_to_desktop": "デスクトップに戻る",
"settings.nav_header": "設定",
"settings.nav.group_desktop": "デスクトップ",
"settings.nav.group_system": "システム",
"settings.nav.group_extensions": "拡張機能",
"settings.nav.wallpaper": "壁紙",
"settings.nav.grid": "コンポーネント",
"settings.nav.color": "カラー",
"settings.nav.status_bar": "ステータスバー",
"settings.nav.weather": "天気",
"settings.nav.region": "地域",
"settings.nav.update": "アップデート",
"settings.nav.privacy": "プライバシー",
"settings.nav.launcher": "アプリランチャー",
"settings.nav.plugins": "プラグイン",
"settings.nav.about": "について",
"settings.wallpaper.title": "壁紙",
"settings.wallpaper.description": "画像または動画を選択して、アプリウィンドウの壁紙としてすぐに適用します。",
"settings.wallpaper.current_label": "現在の壁紙",
"settings.wallpaper.type_label": "壁紙タイプ",
"settings.wallpaper.type.image": "画像",
"settings.wallpaper.type.solid_color": "単色",
"settings.wallpaper.type.system": "システム壁紙",
"settings.wallpaper.system.label": "システム壁紙",
"settings.wallpaper.system.unavailable": "システム壁紙を読み込めません",
"settings.wallpaper.refresh_interval": "更新間隔",
"settings.wallpaper.refresh_now": "今すぐ更新",
"settings.wallpaper.refresh.30s": "30秒",
"settings.wallpaper.refresh.1m": "1分",
"settings.wallpaper.refresh.5m": "5分",
"settings.wallpaper.refresh.10m": "10分",
"settings.wallpaper.refresh.15m": "15分",
"settings.wallpaper.refresh.30m": "30分",
"settings.wallpaper.refresh.1h": "1時間",
"settings.wallpaper.refresh.2h": "2時間",
"settings.wallpaper.refresh.4h": "4時間",
"settings.wallpaper.refresh.8h": "8時間",
"settings.wallpaper.refresh.12h": "12時間",
"settings.wallpaper.refresh.24h": "24時間",
"settings.wallpaper.color_label": "壁紙の色",
"settings.wallpaper.placement_label": "配置",
"settings.wallpaper.placement_desc": "画像がデスクトップにどのように表示されるかを調整します。",
"settings.wallpaper.pick_button": "ファイルを参照",
"settings.wallpaper.clear_button": "単色にリセット",
"settings.wallpaper.no_selection": "壁紙が選択されていません。",
"settings.wallpaper.storage_unavailable": "ストレージプロバイダが利用できません。",
"settings.wallpaper.import_failed": "壁紙ファイルのインポートに失敗しました。",
"settings.wallpaper.image_applied": "画像の壁紙が適用されました。",
"settings.wallpaper.video_applied": "動画の壁紙が適用されました。",
"settings.wallpaper.unsupported_file": "選択されたファイルタイプはサポートされていません。",
"settings.wallpaper.apply_failed_format": "壁紙の適用に失敗しました: {0}",
"settings.wallpaper.mode_format": "壁紙モード: {0}。",
"settings.wallpaper.video_mode": "動画の壁紙は自動フィルモードを使用します。",
"settings.wallpaper.cleared": "背景が単色にリセットされました。",
"settings.wallpaper.default_status": "現在の背景は単色を使用しています。",
"settings.wallpaper.saved_not_found": "保存された壁紙ファイルが見つかりません。単色の背景を使用しています。",
"settings.wallpaper.restored": "保存された設定から壁紙が復元されました。",
"settings.wallpaper.video_restored": "保存された設定から動画の壁紙が復元されました。",
"settings.wallpaper.restore_failed": "保存された壁紙の復元に失敗しました。単色の背景を使用しています。",
"settings.wallpaper.video_not_found": "動画の壁紙ファイルが見つかりません。",
"settings.wallpaper.video_player_unavailable": "動画プレーヤーが利用できません。",
"settings.wallpaper.video_play_failed_format": "動画の壁紙の再生に失敗しました: {0}",
"settings.grid.title": "グリッドレイアウト",
"settings.grid.description": "すべてのコンポーネントは少なくとも1つのセルを占有する必要があります最小1x1。",
"settings.grid.short_side_label": "短辺のセル数",
"settings.grid.spacing_label": "グリッドの間隔",
"settings.grid.spacing_relaxed": "ゆとりありiOS",
"settings.grid.spacing_compact": "コンパクトAndroid",
"settings.grid.edge_inset_label": "画面の余白",
"settings.grid.edge_inset_px_format": "≈ {0:F1}px",
"settings.grid.apply_button": "適用",
"settings.grid.info_format": "グリッド: {0}列 x {1}行 | セル {2:F1}px (1:1)",
"settings.color.title": "カラー",
"settings.color.description": "昼夜モードを切り替え、アプリのアクセントカラーを選択します。",
"settings.color.day_night_label": "昼夜モード",
"settings.color.day_night_on": "夜",
"settings.color.day_night_off": "昼",
"settings.color.recommended_label": "おすすめの色",
"settings.color.system_monet_label": "システムMonetカラー",
"settings.color.refresh_button": "更新",
"settings.color.mode_night": "夜モードが有効",
"settings.color.mode_day": "昼モードが有効",
"settings.color.mode_status_format": "テーマモード: {0}。",
"settings.color.monet_refreshed": "Monetカラーが更新されました。",
"settings.color.theme_ready_format": "テーマカラーの準備完了: {0}。",
"settings.color.theme_applied_format": "{0}カラーが適用されました: {1}。",
"settings.color.theme_updated_wallpaper": "壁紙が更新されました。Monetカラーが更新されました。",
"settings.color.theme_updated_video": "動画の壁紙が更新されました。テーマカラーが更新されました。",
"settings.color.theme_cleared_wallpaper": "壁紙がクリアされました。Monetカラーが更新されました。",
"settings.status_bar.title": "ステータスバー",
"settings.status_bar.description": "上部のステータスバーに表示するコンポーネントを選択します。",
"settings.status_bar.clock_header": "時計コンポーネント",
"settings.status_bar.clock_description": "上部のステータスバーに時計を表示します。",
"settings.status_bar.clock_transparent_background_label": "透明な背景",
"settings.status_bar.clock_transparent_background_desc": "カプセルの背景を削除し、時計のテキストのみを保持します。",
"settings.status_bar.spacing_header": "コンポーネントの間隔",
"settings.status_bar.spacing_desc": "ステータスバーコンポーネント間の間隔を調整します。",
"settings.status_bar.spacing_mode_compact": "コンパクト",
"settings.status_bar.spacing_mode_relaxed": "ゆとりあり",
"settings.status_bar.spacing_mode_custom": "カスタム",
"settings.status_bar.spacing_custom_label": "カスタム間隔(%",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.privacy.title": "プライバシー",
"settings.privacy.description": "アプリの改善に役立つオプションの匿名アップロードを管理します。",
"settings.privacy.crash_upload_title": "匿名クラッシュデータのアップロード",
"settings.privacy.crash_upload_description": "アプリケーションの安定性向上にご協力ください。",
"settings.privacy.usage_upload_title": "匿名使用データのアップロード",
"settings.privacy.usage_upload_description": "アプリケーション機能の改善にご協力ください。",
"settings.privacy.device_id_title": "デバイスID",
"settings.privacy.device_id_description": "このデバイスの一意識別子。更新をクリックして再生成します。",
"settings.privacy.refresh_device_id": "更新",
"settings.privacy.policy_hint_prefix": "詳細については、",
"settings.privacy.view_policy": "プライバシーポリシーをご覧ください",
"settings.weather.title": "天気",
"settings.weather.description": "天気の場所、Xiaomi天気プレビュー、起動時の位置情報取得動作を設定します。",
"settings.weather.location_source_header": "位置情報ソース",
"settings.weather.location_source_desc": "天気ウィジェットが場所を解決する方法を選択します。",
"settings.weather.mode_city_search": "都市検索",
"settings.weather.mode_coordinates": "座標",
"settings.weather.auto_refresh": "起動時に位置情報を自動更新",
"settings.weather.city_search_header": "都市検索",
"settings.weather.city_search_desc": "都市を検索し、天気の場所を適用します。",
"settings.weather.search_placeholder": "例: 東京",
"settings.weather.search_button": "検索",
"settings.weather.apply_city_button": "都市を適用",
"settings.weather.search_hint": "都市名で検索し、場所を適用します。",
"settings.weather.search_required": "都市のキーワードを入力してください。",
"settings.weather.search_no_results": "場所が見つかりませんでした。",
"settings.weather.search_failed_format": "検索に失敗しました: {0}",
"settings.weather.search_result_count_format": "{0}件の場所が見つかりました。",
"settings.weather.search_select_required": "検索結果から場所を1つ選択してください。",
"settings.weather.search_applied_format": "場所が適用されました: {0}",
"settings.weather.coordinates_header": "座標",
"settings.weather.coordinates_desc": "緯度/経度とオプションのキー/名前を設定します。",
"settings.weather.latitude_label": "緯度",
"settings.weather.longitude_label": "経度",
"settings.weather.location_key_placeholder": "場所キー(オプション)",
"settings.weather.location_name_placeholder": "表示名(オプション)",
"settings.weather.apply_coordinates_button": "座標を適用",
"settings.weather.coordinates_saved_format": "座標が保存されました: {0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "座標 {0:F4}, {1:F4}",
"settings.weather.location_services_header": "位置情報サービス",
"settings.weather.location_services_desc": "現在のWindowsの場所を使用し、起動時に自動的に更新するかどうかを決定します。",
"settings.weather.use_current_location": "現在地を使用",
"settings.weather.location_unsupported": "現在のプラットフォームは現在地の取得をサポートしていません。",
"settings.weather.location_ready": "現在のWindowsの場所を使用できます。",
"settings.weather.location_refreshing": "現在地を取得中...",
"settings.weather.location_refresh_success_format": "現在地が適用されました: {0}",
"settings.weather.location_refresh_failed_format": "現在地の取得に失敗しました: {0}",
"settings.weather.preview_header": "接続テスト",
"settings.weather.preview_desc": "テストリクエストを送信して現在の設定を確認します。",
"settings.weather.preview_button": "テスト取得",
"settings.weather.preview_section": "天気プレビュー",
"settings.weather.settings_section": "設定",
"settings.weather.preview_panel_header": "天気プレビュー",
"settings.weather.preview_panel_desc": "現在の天気サービスの状態を更新して確認します。",
"settings.weather.refresh_button": "更新",
"settings.weather.preview_updated_format": "{0}に更新",
"settings.weather.preview_hint": "テスト取得を使用して天気の設定を確認します。",
"settings.weather.preview_missing_location": "テストする前に天気の場所を適用してください。",
"settings.weather.preview_success_format": "テスト成功: {0} · {1} · {2}",
"settings.weather.preview_failed_format": "テスト取得に失敗しました: {0}",
"settings.weather.preview_unknown": "不明",
"settings.weather.alert_filter_header": "除外するアラート",
"settings.weather.alert_filter_desc": "これらの単語を含むアラートは表示されません。1行に1つのルール。",
"settings.weather.alert_filter_placeholder": "1行に1つのキーワード",
"settings.weather.icon_style_header": "天気アイコンスタイル",
"settings.weather.icon_style_desc": "天気シンボルのFluentアイコンスタイルを選択します。",
"settings.weather.icon_style_fluent_regular": "Fluent Regular",
"settings.weather.icon_style_fluent_filled": "Fluent Filled",
"settings.weather.no_tls_header": "TLSなしの天気リクエスト",
"settings.weather.no_tls_desc": "推奨されません。互換性のないネットワーク環境でのみ有効にしてください。",
"settings.weather.status_city_empty": "都市の場所が設定されていません。",
"settings.weather.status_city_format": "モード: {0} | {1} | キー: {2}",
"settings.weather.status_coordinates_format": "モード: {0} | 緯度 {1:F4}, 経度 {2:F4} | キー: {3}",
"settings.weather.city_selection_label": "都市選択",
"settings.weather.coordinates_selection_label": "座標の場所",
"settings.weather.location_city_summary_desc": "天気の照会に使用される現在の都市を選択します。",
"settings.weather.location_coordinates_summary_desc": "天気の照会に使用される緯度/経度とオプションの場所名を設定します。",
"settings.weather.location_not_selected": "場所が選択されていません",
"settings.weather.alert_list_label": "除外リスト",
"settings.weather.alert_list_desc": "1行に1つの除外ルール。",
"settings.weather.no_tls_toggle": "非TLSリクエストのフォールバックを許可",
"settings.weather.footer_hint": "デスクトップ天気ウィジェットは、ここで設定された場所とアラート除外設定を再利用します。",
"settings.weather.location_header": "天気の場所",
"settings.weather.location_desc": "天気ウィジェットで使用する場所を設定します。",
"settings.weather.location_placeholder": "例: 東京",
"settings.weather.location_apply": "保存",
"settings.weather.location_empty": "天気の場所が設定されていません。",
"settings.weather.location_required": "天気の場所は空にできません。",
"settings.weather.location_current_format": "現在の天気の場所: {0}",
"settings.weather.location_saved_format": "天気の場所が保存されました: {0}",
"weather.widget.location_not_configured": "天気の場所が設定されていません",
"weather.widget.configure_hint": "設定 > 天気を開いて設定",
"weather.widget.loading": "読み込み中...",
"weather.widget.fetch_failed": "天気の取得に失敗しました",
"weather.widget.retrying": "自動的に再試行中",
"weather.widget.location_unknown": "不明な場所",
"weather.widget.condition_clear": "晴れ",
"weather.widget.condition_cloudy": "曇り",
"weather.widget.condition_rain": "雨",
"weather.widget.condition_storm": "雷雨",
"weather.widget.condition_snow": "雪",
"weather.widget.condition_fog": "霧",
"weather.widget.condition_unknown": "不明",
"weather.widget.range_unknown": "-- / --",
"weather.widget.range_format": "{0} / {1}",
"schedule.widget.no_source": "ClassIslandのスケジュールデータが見つかりません",
"schedule.widget.no_class_today": "今日の授業はありません",
"schedule.widget.layout_missing": "スケジュールの時間レイアウトがありません",
"schedule.widget.subject_fallback": "無題の授業",
"schedule.widget.detail_fallback": "詳細なし",
"schedule.settings.title": "スケジュールのインポート",
"schedule.settings.desc": "ClassIsland CSESスケジュールをインポートし、有効にするものを選択します。",
"schedule.settings.add": "スケジュールを追加",
"schedule.settings.empty": "インポートされたスケジュールはありません",
"schedule.settings.unnamed": "無題のスケジュール",
"schedule.settings.delete": "削除",
"schedule.settings.picker_title": "ClassIslandスケジュールファイルを選択",
"schedule.settings.picker_file_type.all": "ClassIslandスケジュールファイル",
"schedule.settings.picker_file_type.json": "ClassIslandプロファイルJSON",
"schedule.settings.picker_file_type.cses": "CSESスケジュールYAML",
"schedule.settings.semester.title": "学期設定",
"schedule.settings.semester.start_date": "学期開始日",
"schedule.settings.semester.week_cycle": "週サイクル",
"schedule.settings.semester.week_cycle_desc": "複数週スケジュールの週ローテーションサイクルを設定します(例: 奇数週/偶数週の場合は2。",
"schedule.settings.semester.week_cycle_format": "{0}週ローテーション",
"worldclock.settings.title": "世界時計の設定",
"worldclock.settings.desc": "4つの時計それぞれのタイムゾーンを選択します。",
"worldclock.settings.clock_1": "時計 1",
"worldclock.settings.clock_2": "時計 2",
"worldclock.settings.clock_3": "時計 3",
"worldclock.settings.clock_4": "時計 4",
"worldclock.settings.second_mode_label": "秒針",
"worldclock.widget.today": "今日",
"worldclock.widget.yesterday": "昨日",
"worldclock.widget.tomorrow": "明日",
"worldclock.widget.offset_same": "0時間",
"worldclock.widget.offset_ahead_hours": "{0}時間進む",
"worldclock.widget.offset_behind_hours": "{0}時間遅れる",
"worldclock.widget.offset_ahead_hm": "{0}時間{1}分進む",
"worldclock.widget.offset_behind_hm": "{0}時間{1}分遅れる",
"weather.widget.aqi_unknown": "AQI --",
"weather.widget.aqi_format": "AQI {0}",
"weather.widget.updated_format": "{0:HH:mm}に更新",
"weather.hourly.now": "現在",
"weather.hourly.sunset": "日没",
"weather.multiday.today": "今日",
"weather.multiday.tomorrow": "明日",
"weather.multiday.aqi_format": "空気質 {0}",
"weather.multiday.aqi_unknown": "空気質 --",
"settings.region.title": "地域",
"settings.region.description": "言語を選択し、設定と主要なUIにすぐに適用します。",
"settings.region.language_header": "言語",
"settings.region.language_label": "言語",
"settings.region.language_zh": "中国語",
"settings.region.language_en": "英語",
"settings.region.language_ja": "日本語",
"settings.region.timezone_header": "タイムゾーン",
"settings.region.timezone_desc": "タイムゾーンを選択します。時計とカレンダーウィジェットはこのゾーンに従います。",
"settings.region.applied_format": "言語が切り替わりました: {0}",
"settings.region.follow_system": "システムの既定に従う",
"settings.general.title": "一般",
"settings.general.description": "言語、タイムゾーン、ランタイムの動作を調整します。",
"settings.general.basic_header": "基本設定",
"settings.general.runtime_header": "ランタイム",
"settings.general.preview_header": "日時プレビュー",
"settings.general.preview_time_label": "時刻",
"settings.general.preview_date_label": "日付",
"settings.general.render_mode_restart_message": "レンダリングモードの変更にはアプリの再起動が必要です。",
"settings.appearance.title": "外観",
"settings.appearance.description": "テーマソース、システムマテリアル、ウィンドウクロームを調整します。",
"settings.appearance.theme_header": "テーマ",
"settings.color.enable_night_mode_toggle": "夜モードを有効にする",
"settings.color.use_system_chrome_toggle": "システムのウィンドウクロームを使用",
"settings.color.theme_color_label": "テーマのアクセントカラー",
"settings.appearance.theme_color_mode_label": "テーマカラーソース",
"settings.appearance.theme_color_mode.neutral": "デフォルトニュートラル",
"settings.appearance.theme_color_mode.user": "ユーザーテーマカラーMonet",
"settings.appearance.theme_color_mode.wallpaper": "壁紙Monet",
"settings.appearance.theme_color_mode_desc.neutral": "ライトモードとダークモードにデフォルトの白と黒のニュートラルサーフェスを使用します。",
"settings.appearance.theme_color_mode_desc.user": "選択したテーマカラーをシェル全体のMonetシードとして使用します。",
"settings.appearance.theme_color_mode_desc.wallpaper": "壁紙の色を使用します。アプリの壁紙が優先され、次にシステムの壁紙が使用されます。",
"settings.appearance.theme_color_preview.app": "現在、アプリの壁紙から抽出された色をプレビューしています。",
"settings.appearance.theme_color_preview.system": "現在、システムの壁紙から抽出された色をプレビューしています。",
"settings.appearance.theme_color_preview.fallback": "使用可能な壁紙が見つかりませんでした。アプリはフォールバックのアクセントを使用しています。",
"component.color_scheme.follow_system": "システムのカラースキームに従う",
"component.color_scheme.native": "コンポーネントのカスタムカラースキームを使用",
"settings.appearance.system_material.none": "なし",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
"settings.appearance.system_material_desc.switchable": "選択したマテリアルをウィンドウ、Dock、ステータスバー、コンポーネントホストに適用します。",
"settings.appearance.system_material_desc.fixed": "現在のシステムは、ここにリストされているマテリアルモードのみを公開しています。",
"settings.appearance.restart_message": "テーマソースとシステムマテリアルの変更にはアプリの再起動が必要です。",
"settings.appearance.preview.primary": "プライマリ",
"settings.appearance.preview.secondary": "セカンダリ",
"settings.appearance.preview.tertiary": "ターシャリ",
"settings.appearance.preview.neutral": "ニュートラル",
"settings.appearance.preview.seed": "シード",
"settings.appearance.preview.neutral_light": "白",
"settings.appearance.preview.neutral_dark": "黒",
"settings.appearance.preview.apply_seed": "適用",
"settings.appearance.preview.wallpaper_candidates": "壁紙シード候補",
"settings.appearance.preview.wallpaper_current": "現在",
"settings.wallpaper.placement.fill": "フィル",
"settings.wallpaper.placement.fit": "フィット",
"settings.wallpaper.placement.stretch": "ストレッチ",
"settings.wallpaper.placement.center": "中央",
"settings.wallpaper.placement.tile": "タイル",
"settings.status_bar.clock_format_label": "時計の形式",
"settings.status_bar.clock_format.hm": "時:分",
"settings.status_bar.clock_format.hms": "時:分:秒",
"settings.components.title": "コンポーネント",
"settings.components.description": "コンポーネントのレイアウトとコーナーデザインを調整します。",
"settings.components.grid_header": "グリッド設定",
"settings.components.header": "グリッド設定",
"settings.components.short_side_label": "短辺のセル数",
"settings.components.edge_inset_label": "画面の余白",
"settings.components.spacing_label": "コンポーネントの間隔",
"settings.components.spacing_compact": "コンパクト",
"settings.components.spacing_relaxed": "ゆとりあり",
"settings.components.corner_radius.header": "コーナーデザイン",
"settings.components.corner_radius.label": "コンポーネントのコーナー半径",
"settings.components.corner_radius.description": "角張った端からカプセルのような形まで、共通のコーナー半径を調整し、内部のセーフエリアを拡張します。",
"settings.update.title": "アップデート",
"settings.update.current_version_label": "現在のバージョン",
"settings.update.latest_version_label": "最新リリース",
"settings.update.published_at_label": "公開日",
"settings.update.options_header": "アップデートオプション",
"settings.update.options_desc": "アップデートチェックとリリースチャンネルを設定します。",
"settings.update.auto_check_toggle": "起動時に自動的にアップデートを確認",
"settings.update.include_prerelease_toggle": "プレリリース版を含める",
"settings.update.channel_label": "アップデートチャンネル",
"settings.update.channel_stable": "安定版",
"settings.update.channel_preview": "プレビュー",
"settings.update.actions_header": "アップデートアクション",
"settings.update.actions_desc": "リリースを確認し、インストーラーをダウンロードし、アップデートを開始します。",
"settings.update.check_button": "アップデートを確認",
"settings.update.download_install_button": "ダウンロードしてインストール",
"settings.update.download_progress_idle": "ダウンロード進捗: -",
"settings.update.download_progress_format": "ダウンロード進捗: {0:F0}%",
"settings.update.status_ready": "アップデートを確認する準備ができました。",
"settings.update.status_channel_changed": "アップデートチャンネルが変更されました。再度確認してください。",
"settings.update.status_channel_changed_format": "アップデートチャンネルが{0}に切り替わりました。再度確認してください。",
"settings.update.status_windows_only": "自動インストーラーアップデートは現在Windowsでのみ利用可能です。",
"settings.update.status_checking": "GitHubリリースを確認中...",
"settings.update.status_check_failed_format": "アップデートの確認に失敗しました: {0}",
"settings.update.status_up_to_date": "最新バージョンを使用しています。",
"settings.update.status_asset_missing": "新しいリリースが利用可能ですが、互換性のあるインストーラーが見つかりませんでした。",
"settings.update.status_available_format": "新しいバージョン{0}が利用可能です。ダウンロードしてインストールをクリックしてください。",
"settings.update.status_downloading": "インストーラーをダウンロード中...",
"settings.update.status_download_failed_format": "ダウンロードに失敗しました: {0}",
"settings.update.status_launching_installer": "ダウンロード完了。インストーラーを起動中...",
"settings.update.status_installer_missing": "ダウンロード後にインストーラーファイルが見つかりませんでした。",
"settings.update.status_installer_started": "インストーラーが開始されました。アプリはアップデートのために終了します。",
"settings.update.status_elevation_cancelled": "管理者権限が付与されませんでした。アップデートはキャンセルされました。",
"settings.update.status_launch_failed_format": "インストーラーの起動に失敗しました: {0}",
"settings.about.title": "について",
"settings.about.version_format": "バージョン: {0}",
"settings.about.codename_format": "コードネーム: {0}",
"settings.about.font_format": "フォント: {0}",
"settings.about.startup_header": "Windowsのスタートアップ",
"settings.about.startup_desc": "Windowsへのサインイン時にアプリを自動的に起動します。",
"settings.about.startup_toggle": "Windowsサインイン時に起動",
"settings.about.render_mode_header": "アプリのレンダリングモード",
"settings.about.render_mode_desc": "レンダリングバックエンドを選択します。このオプションを変更した後、アプリを再起動します。サポートされていないモードはソフトウェアにフォールバックします。",
"settings.about.render_mode.default": "デフォルト",
"settings.about.render_mode.software": "ソフトウェア",
"settings.about.render_mode.angle_egl": "angleEgl",
"settings.about.render_mode.wgl": "WGL",
"settings.about.render_mode.vulkan": "Vulkan",
"settings.about.render_mode.unknown": "不明",
"settings.about.render_mode.current_label": "現在の実際のバックエンド",
"settings.about.render_mode.current_format": "現在のバックエンド: {0}",
"settings.about.render_mode.impl_format": "ランタイム実装: {0}",
"settings.about.render_mode.impl_unavailable": "ランタイム実装の詳細は利用できません。",
"settings.about.description": "アプリケーションの詳細。",
"settings.update.description": "リリースを確認し、アップデートチャンネルとダウンロードソースを選択し、アップデートのインストール方法を制御します。",
"settings.update.status_card_title": "アップデートステータス",
"settings.update.status_card_description": "アップデートを確認し、リリースの詳細を確認し、新しいバージョンが利用可能な場合はダウンロードまたはインストールを続行します。",
"settings.update.preferences_header": "アップデート設定",
"settings.update.preferences_description": "リリースチャンネル、インストーラーのダウンロードソース、インストール動作、ダウンロードの並列度を選択します。",
"settings.update.last_checked_label": "最終確認日時",
"settings.update.source_label": "ダウンロードソース",
"settings.update.source_github": "GitHub",
"settings.update.source_ghproxy": "gh-proxy",
"settings.update.source_github_desc": "GitHubからリリースアセットを直接ダウンロードします。",
"settings.update.source_ghproxy_desc": "GitHubリリースアセットをダウンロードする際にgh-proxyミラーを使用します。",
"settings.update.mode_label": "アップデートモード",
"settings.update.mode_manual": "手動アップデート",
"settings.update.mode_download_then_confirm": "サイレントダウンロード",
"settings.update.mode_silent_on_exit": "サイレントインストール",
"settings.update.mode_manual_desc": "アップデートの確認のみ。ダウンロードとインストールのタイミングを決定します。",
"settings.update.mode_download_then_confirm_desc": "バックグラウンドでアップデートをダウンロードし、インストール前に確認を求めます。",
"settings.update.mode_silent_on_exit_desc": "バックグラウンドでアップデートをダウンロードし、アプリの終了時にインストールします。",
"settings.update.channel_stable_desc": "安定ビルドは信頼性を重視し、ほとんどのユーザーにおすすめです。",
"settings.update.channel_preview_desc": "プレビュービルドは新しい機能が含まれる可能性がありますが、安定性が低い場合があります。",
"settings.update.download_threads_label": "ダウンロードスレッド",
"settings.update.download_threads_desc": "アプリケーションのアップデートパッケージの並列ダウンロードスレッド数を設定します。",
"settings.update.force_check_label": "強制アップデート確認",
"settings.update.force_check_desc": "GitHubから強制的に最新バージョンを取得し、バージョン比較を無視します。",
"settings.update.status_force_checking": "GitHubリリースを強制確認中...",
"settings.update.status_force_no_asset": "リリースは見つかりましたが、互換性のあるインストーラーがありません。",
"settings.update.status_force_available_format": "リリース {0} が利用可能です。「ダウンロードしてインストール」をクリックしてください。",
"settings.update.install_now_button": "今すぐインストール",
"settings.update.status_downloaded_confirm": "アップデートがダウンロードされました。確認してインストールのタイミングを選択してください。",
"settings.update.status_downloaded_exit": "アップデートがダウンロードされました。アプリの終了時にインストールされます。",
"settings.about.app_info_header": "アプリケーション情報",
"settings.about.update_header": "アップデート",
"settings.about.version_label": "バージョン",
"settings.about.codename_label": "コードネーム",
"settings.about.render_backend_label": "レンダーバックエンド",
"settings.about.render_backend_format": "レンダーバックエンド: {0}",
"settings.restart_dialog.title": "再起動が必要",
"settings.restart_dialog.render_mode_message": "レンダリングモードを「{0}」から「{1}」に切り替えるには、アプリを再起動します。今すぐ再起動しますか?",
"settings.restart_dialog.restart": "今すぐ再起動",
"settings.restart_dialog.later": "後で",
"settings.restart_dialog.cancel": "キャンセル",
"settings.restart_dock.title": "再起動が必要",
"settings.restart_dock.description": "一部の変更はアプリの再起動後に有効になります。",
"settings.restart_dock.button": "アプリを再起動",
"settings.footer": "LanMountainDesktop 設定",
"filepicker.title": "壁紙を選択",
"filepicker.image_files": "画像ファイル",
"filepicker.video_files": "動画ファイル",
"common.day": "昼",
"common.night": "夜",
"common.back": "戻る",
"common.close": "閉じる",
"common.unknown": "不明なエラー",
"common.recommended": "おすすめ",
"common.monet": "Monet",
"desktop.page_index_format": "デスクトップ {0}",
"launcher.title": "アプリランチャー",
"launcher.folder": "フォルダ",
"launcher.subtitle": "Windowsスタートメニューからのアプリとフォルダ",
"launcher.subtitle_linux": "Linuxデスクトップエントリから発見されたインストール済みアプリ",
"launcher.empty": "スタートメニューのエントリが見つかりません。",
"launcher.empty_linux": "Linuxデスクトップエントリが見つかりませんでした。",
"launcher.empty_folder": "このフォルダは空です。",
"launcher.folder_items_format": "{0}個のアプリ",
"launcher.context.hide_icon": "アイコンを非表示",
"launcher.action.hide": "非表示",
"settings.launcher.title": "アプリランチャー",
"settings.launcher.description": "アプリランチャーの非表示アプリとフォルダを管理します。",
"settings.launcher.hidden_header": "非表示アイテム",
"settings.launcher.hidden_desc": "非表示のランチャーエントリを確認し、再度表示します。",
"settings.launcher.hidden_hint": "デスクトップ編集モードで、ランチャーアイコンを選択して非表示をクリックします。非表示のエントリはここに表示されます。",
"settings.launcher.hidden_empty": "非表示アイテムはありません。",
"settings.launcher.hidden_summary_format": "{0}個の非表示アイテム",
"settings.launcher.hidden_type_folder": "フォルダ",
"settings.launcher.hidden_type_shortcut": "アプリ",
"settings.launcher.restore_button": "再表示",
"settings.plugins.title": "プラグイン",
"settings.plugins.runtime_header": "プラグインランタイム",
"settings.plugins.runtime_desc": "プラグインランタイムの状態とロード結果を確認します。",
"settings.plugins.runtime_hint": "このページには、インストールされたプラグインの発見ステータス、ロード結果、ランタイム診断が表示されます。",
"settings.plugins.runtime_status": "プラグインの発見が完了すると、プラグインランタイムのステータスがここに表示されます。",
"settings.plugins.description": "インストールされたプラグインを管理し、ランタイムの状態を確認します。",
"settings.plugins.initial_status": "プラグインの状態を更新して、最新のインストール済みプラグインを確認してください。",
"settings.plugins.refresh_button": "プラグインを更新",
"settings.plugins.refresh_success_installed_format": "{0}個のインストール済みプラグインをロードしました。",
"settings.plugins.refresh_success_format": "{0}個のインストール済みプラグインと{1}個のマーケットプレイスエントリをロードしました。",
"settings.plugins.refresh_failed": "プラグインカタログインデックスのロードに失敗しました。",
"settings.plugins.marketplace_header": "マーケットプレイス",
"settings.plugins.marketplace_empty": "現在、マーケットプレイスのプラグインはありません。",
"settings.plugins.delete_button_short": "削除",
"settings.plugins.install_button_short": "インストール",
"settings.plugins.restart_required": "プラグインの変更は再起動後に有効になります。",
"settings.plugins.toggle_unchanged_format": "プラグイン「{0}」は変更されませんでした。",
"settings.plugins.delete_failed_name_format": "プラグイン「{0}」の削除に失敗しました。",
"settings.plugins.install_failed_name_format": "「{0}」のインストールに失敗しました。",
"settings.plugins.installed_header": "インストール済みプラグイン",
"settings.plugins.installed_desc": "インストール済みプラグインを確認し、ここで削除します。",
"settings.plugins.import_header": "パッケージからインストール",
"settings.plugins.import_desc": ".laappパッケージを開き、ローカルプラグインディレクトリにステージングします。",
"settings.plugins.restart_hint": "プラグインのインストールと削除の変更は、アプリの再起動後に有効になります。",
"settings.plugins.empty": "プラグインが見つかりません。",
"settings.plugins.runtime_unavailable": "プラグインランタイムは利用できません。",
"settings.plugins.summary_format": "{0}個のプラグインを検出; 有効 {1}; ロード済み {2}; 設定ページ {3}; ウィジェット {4}; 失敗 {5}。",
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
"settings.plugins.state.enabled": "有効",
"settings.plugins.state.enabled_failed": "有効 / ロード失敗",
"settings.plugins.state.disabled": "無効",
"settings.plugins.state.loaded": "ロード済み",
"settings.plugins.state.load_failed": "ロード失敗",
"settings.plugins.toggle_on": "有効",
"settings.plugins.toggle_off": "無効",
"settings.plugins.toggle_result_format": "プラグイン「{0}」は次回起動時に{1}になりました。ページとウィジェットの変更を適用するには、アプリを再起動してください。",
"settings.plugins.toggle_state_enabled": "有効",
"settings.plugins.toggle_state_disabled": "無効",
"settings.plugins.toggle_failed_detail_format": "プラグイン「{0}」の更新に失敗しました: {1}",
"settings.plugins.install_button": ".laappパッケージを開く",
"settings.plugins.install_unavailable": "プラグインランタイムが利用できないため、.laappパッケージをインストールできません。",
"settings.plugins.install_hint_format": ".laappパッケージを開いて次にインストールします: {0}",
"settings.plugins.install_picker_title": "プラグインパッケージを選択",
"settings.plugins.install_file_type": ".laappプラグインパッケージ",
"settings.plugins.install_picker_unavailable": "ストレージプロバイダが利用できません。",
"settings.plugins.install_copy_failed": "選択した.laappパッケージのコピーに失敗しました。",
"settings.plugins.install_success_format": "プラグイン「{0}」がインストールされました。新しく追加された設定ページとウィジェットを適用するには、アプリを再起動してください。",
"settings.plugins.install_failed_format": "プラグインパッケージのインストールに失敗しました: {0}",
"settings.plugins.delete_button": "プラグインを削除",
"settings.plugins.delete_success_format": "プラグイン「{0}」は削除のためにステージングされました。削除を完了するには、アプリを再起動してください。",
"settings.plugins.delete_failed_format": "プラグインの削除に失敗しました: {0}",
"settings.plugins.delete_failed_detail_format": "プラグイン「{0}」の削除に失敗しました: {1}",
"settings.plugins.publisher_format": "パブリッシャー: {0}",
"settings.plugins.publisher_unknown": "不明なパブリッシャー",
"settings.plugins.source_package": ".laappパッケージ",
"settings.plugins.source_manifest": "ルーズマニフェスト",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "設定ページ: {0} | ウィジェット: {1}",
"settings.nav.plugin_catalog": "プラグインカタログ",
"settings.plugin_catalog.title": "プラグインカタログ",
"settings.plugin_catalog.subtitle": "公式LanAirAppソースからプラグインを参照し、インストールをステージングします。",
"settings.plugin_catalog.unavailable": "プラグインランタイムが利用できないため、公式カタログを開けません。",
"settings.update.status_idle": "アップデートの確認はまだ実行されていません。",
"settings.update.status_preferences_saved": "アップデート設定が保存されました。",
"settings.update.status_check_failed": "アップデートの確認に失敗しました。",
"settings.update.status_available_summary_format": "アップデートあり: {0}(現在: {1}",
"settings.update.status_up_to_date_format": "最新版です({0})。",
"settings.window.drawer_default": "詳細",
"market.toolbar.search_placeholder": "プラグインを検索",
"market.toolbar.refresh": "更新",
"market.status.loading": "公式プラグインカタログをロード中...",
"market.status.loaded_network_format": "公式ソースから{0}個のプラグインをロードしました。",
"market.status.loaded_cache_format": "公式ソースが利用できません。キャッシュから{0}個のプラグインをロードしました。理由: {1}",
"market.status.load_failed_format": "プラグインカタログのロードに失敗しました: {0}",
"market.status.installing_format": "プラグイン「{0}」をダウンロードしてステージング中...",
"market.status.install_success_format": "プラグイン「{0}」がステージングされました。適用するにはアプリを再起動してください。",
"market.status.install_failed_format": "プラグインのインストールに失敗しました: {0}",
"market.status.host_incompatible_format": "このホストは古すぎます。バージョン{0}以降が必要です。",
"market.list.empty": "プラグインカタログはまだロードされていません。",
"market.list.no_results": "現在の検索に一致するプラグインはありません。",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "ロード済み",
"market.card.pending_restart": "再起動が必要",
"market.detail.placeholder": "左側のプラグインを選択して詳細を確認します。",
"market.detail.author": "パブリッシャー",
"market.detail.version": "バージョン",
"market.detail.api_version": "APIバージョン",
"market.detail.min_host_version": "最小ホストバージョン",
"market.detail.installed_version": "インストール済みバージョン",
"market.detail.not_installed": "未インストール",
"market.detail.readme": "README",
"market.detail.plugin_information": "プラグイン情報",
"market.detail.author_subtitle_format": "{0}作成",
"market.detail.package_size": "パッケージサイズ",
"market.detail.published_at": "公開日",
"market.detail.updated_at": "更新日",
"market.detail.tags": "タグ",
"market.detail.project": "プロジェクト",
"market.detail.state": "インストール状態",
"market.detail.market_source": "マーケットソース",
"market.detail.homepage": "ホームページ",
"market.detail.repository": "リポジトリ",
"market.detail.release_notes": "リリースノート",
"market.detail.dependencies": "依存関係",
"market.detail.dependencies_empty": "このプラグインは共有コントラクトの依存関係を宣言していません。",
"market.detail.readme_loading": "READMEをロード中...",
"market.detail.readme_empty": "READMEは空です。",
"market.detail.readme_error_format": "READMEをロードできませんでした: {0}",
"market.detail.state.not_installed": "未インストール",
"market.detail.state.update_available": "アップデートあり",
"market.detail.state.installed": "インストール済み",
"market.detail.unknown": "不明",
"market.button.install": "インストール",
"market.button.update": "アップデート",
"market.button.installed": "インストール済み",
"market.button.installing": "インストール中...",
"market.button.restart": "再起動して適用",
"button.component_library": "デスクトップを編集",
"tooltip.component_library": "デスクトップを編集",
"component_library.title": "ウィジェット",
"component_library.empty": "スワイプしてカテゴリを選択し、タップして開き、ウィジェットをデスクトップにドラッグします。",
"component_library.drag_hint": "ドラッグして配置",
"component.delete": "削除",
"component.edit": "編集",
"component.editor.instance_scope": "変更はこのコンポーネントインスタンスにのみ適用されます。",
"component.editor.info_header": "コンポーネント情報",
"component.editor.id_label": "コンポーネントID",
"component.editor.placement_label": "配置ID",
"component.editor.scope_label": "スコープ",
"component.editor.scope_instance": "インスタンススコープのエディタ",
"component_category.clock": "時計",
"component_category.date": "カレンダー",
"component_category.weather": "天気",
"component_category.board": "ボード",
"component_category.media": "メディア",
"component_category.info": "情報",
"component_category.calculator": "計算機",
"component_category.study": "学習",
"component_category.file": "ファイル",
"component.date": "カレンダー",
"component.month_calendar": "月間カレンダー",
"component.lunar_calendar": "旧暦カレンダー",
"component.desktop_clock": "時計",
"component.weather_clock": "天気時計",
"component.world_clock": "世界時計",
"component.desktop_timer": "タイマー",
"component.desktop_weather": "天気",
"component.hourly_weather": "時間別天気",
"component.multiday_weather": "数日間天気",
"component.extended_weather": "拡張天気",
"component.class_schedule": "時間割",
"component.music_control": "音楽コントロール",
"component.audio_recorder": "レコーダー",
"component.daily_poetry": "今日の詩",
"component.daily_artwork": "今日のアート",
"component.daily_word": "今日の言葉",
"component.daily_word_2x2": "今日の言葉 2x2",
"component.cnr_daily_news": "CNRヘッドライン",
"component.ifeng_news": "iFengニュース",
"component.bilibili_hot_search": "Bilibiliトレンド",
"component.baidu_hot_search": "Baiduトレンド",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "為替レート変換",
"component.whiteboard": "黒板(縦向き)",
"component.blackboard_landscape": "黒板(横向き)",
"component.browser": "ブラウザ",
"component.office_recent_documents": "最近のドキュメント",
"whiteboard.settings.desc": "各黒板は独自のノート履歴を保持し、独立して保存します。",
"whiteboard.settings.retention.title": "ノートの保持期間",
"whiteboard.settings.retention.desc": "この黒板が保存されたノートを保持する期間を選択します。期限切れのデータは自動的に削除されます。",
"whiteboard.settings.retention.option": "{0}日",
"whiteboard.settings.instance_scope": "この保持設定は黒板コンポーネントインスタンスごとに保存されます。",
"office_recent_documents.settings.desc": "このウィジェットが最近のドキュメントをスキャンするWindowsとOfficeのソースを選択します。",
"office_recent_documents.settings.sources_title": "最近のドキュメントソース",
"office_recent_documents.settings.sources_desc": "複数のソースを組み合わせることができます。レジストリ選択は、Office相互運用MRUフォールバックも利用可能にします。",
"office_recent_documents.settings.source.registry": "OfficeレジストリMRU",
"office_recent_documents.settings.source.recent_folders": "Windowsの最近使ったフォルダ",
"office_recent_documents.settings.source.jump_lists": "Windowsジャンプリスト",
"office_recent_documents.settings.hint": "すべてのソースを無効にすると、少なくとも1つのソースが再度有効になるまで、このウィジェットは空のままになります。",
"component.removable_storage": "リムーバブルストレージ",
"component.holiday_calendar": "祝日カレンダー",
"component.study_environment": "環境",
"component.study_session_control": "学習セッション制御",
"component.study_session_history": "セッション履歴",
"component.study_noise_curve": "ノイズカーブ",
"component.study_noise_distribution": "ノイズ分布",
"component.study_score_overview": "学習スコア概要",
"component.study_deduction_reasons": "減点理由",
"component.study_interrupt_density": "中断密度",
"desktop_clock.settings.title": "時計の設定",
"desktop_clock.settings.desc": "単一時計のタイムゾーンを選択します。",
"desktop_clock.settings.timezone_label": "タイムゾーン",
"desktop_clock.settings.second_mode_label": "秒針",
"clock.second_mode.tick": "ティック",
"clock.second_mode.sweep": "スイープ",
"poetry.widget.loading_content": "詩を読み込み中...",
"poetry.widget.loading_author": "読み込み中...",
"poetry.widget.fetch_failed": "詩の取得に失敗しました",
"poetry.widget.fallback_content": "今日の詩は一時的に利用できません。",
"poetry.widget.fallback_author": "後でもう一度お試しください",
"poetry.widget.unknown_author": "不明",
"artwork.widget.loading": "読み込み中...",
"artwork.widget.loading_title": "今日のアート",
"artwork.widget.loading_subtitle": "今日の傑作を取得中",
"artwork.widget.fetch_failed": "アートの取得に失敗しました",
"artwork.widget.fallback_title": "今日のアート",
"artwork.widget.fallback_artist": "おすすめサービスは利用できません",
"artwork.widget.fallback_year": "後でもう一度お試しください",
"artwork.widget.unknown_artist": "不明なアーティスト",
"dailyword.widget.loading": "読み込み中...",
"dailyword.widget.loading_word": "今日の言葉",
"dailyword.widget.loading_pronunciation": "発音を取得中...",
"dailyword.widget.loading_meaning": "意味を取得中...",
"dailyword.widget.loading_example": "例文を取得中...",
"dailyword.widget.loading_example_translation": "読み込み中...",
"dailyword.widget.fetch_failed": "今日の言葉の取得に失敗しました",
"dailyword.widget.fallback_word": "今日の言葉",
"dailyword.widget.fallback_pronunciation": "発音は利用できません",
"dailyword.widget.fallback_meaning": "Youdao辞書は一時的に利用できません。",
"dailyword.widget.fallback_example": "更新ボタンをタップして再試行してください。",
"dailyword.widget.fallback_example_translation": "ネットワークが回復すると再試行します。",
"dailyword2x2.widget.tap_to_show": "タップして意味を表示",
"cnrnews.widget.loading": "読み込み中...",
"cnrnews.widget.loading_title": "CNRヘッドラインを取得中",
"cnrnews.widget.loading_subtitle": "お待ちください",
"cnrnews.widget.fetch_failed": "ニュースの取得に失敗しました",
"cnrnews.widget.fallback_title": "CNRニュースは一時的に利用できません",
"cnrnews.widget.fallback_subtitle": "更新をタップして再試行してください",
"cnrnews.widget.hot_label": "ホット",
"bilihot.widget.brand": "Bilibiliトレンド",
"bilihot.widget.top_right_label": "Bilibiliトレンド",
"bilihot.widget.search_entry": "検索",
"bilihot.widget.search_placeholder": "トレンドトピックを検索",
"bilihot.widget.loading": "読み込み中...",
"bilihot.widget.loading_item": "読み込み中...",
"bilihot.widget.fetch_failed": "トレンドの取得に失敗しました",
"bilihot.widget.fallback_item": "トレンドデータなし",
"bilihot.widget.more_hot": "もっとトレンドを見る",
"baiduhot.widget.brand": "Baiduトレンド",
"baiduhot.widget.loading": "読み込み中...",
"baiduhot.widget.loading_item": "読み込み中...",
"baiduhot.widget.fetch_failed": "トレンドの取得に失敗しました",
"baiduhot.widget.fallback_item": "トレンドデータなし",
"baiduhot.widget.refresh_tooltip": "更新",
"ifeng.widget.brand": "iFengニュース",
"ifeng.widget.loading": "読み込み中...",
"ifeng.widget.loading_item": "読み込み中...",
"ifeng.widget.fetch_failed": "ニュースの取得に失敗しました",
"ifeng.widget.fallback_item": "ニュースデータなし",
"ifeng.widget.refresh_tooltip": "更新",
"dailyword.settings.title": "今日の言葉の設定",
"dailyword.settings.desc": "自動更新と更新間隔を設定します。",
"dailyword.settings.auto_refresh_label": "自動更新",
"dailyword.settings.auto_refresh_enabled": "自動更新を有効にする",
"dailyword.settings.frequency_label": "更新間隔",
"bilihot.settings.title": "Bilibiliトレンドの設定",
"bilihot.settings.desc": "自動更新と更新間隔を設定します。",
"bilihot.settings.auto_refresh_label": "自動更新",
"bilihot.settings.auto_refresh_enabled": "自動更新を有効にする",
"bilihot.settings.frequency_label": "更新間隔",
"baiduhot.settings.title": "Baiduトレンドの設定",
"baiduhot.settings.desc": "ソース、自動更新、更新間隔を設定します。",
"baiduhot.settings.source_label": "データソース",
"baiduhot.settings.source_official": "公式ソース",
"baiduhot.settings.source_rss": "サードパーティRSS",
"baiduhot.settings.auto_refresh_label": "自動更新",
"baiduhot.settings.auto_refresh_enabled": "自動更新を有効にする",
"baiduhot.settings.frequency_label": "更新間隔",
"ifeng.settings.title": "iFengニュースの設定",
"ifeng.settings.desc": "チャンネル、自動更新、更新間隔を設定します。",
"ifeng.settings.channel_label": "ニュースチャンネル",
"ifeng.settings.channel_comprehensive": "総合",
"ifeng.settings.channel_mainland": "中国本土",
"ifeng.settings.channel_taiwan": "台湾",
"ifeng.settings.auto_refresh_label": "自動更新",
"ifeng.settings.auto_refresh_enabled": "自動更新を有効にする",
"ifeng.settings.frequency_label": "更新間隔",
"refresh.frequency.5m": "5分",
"refresh.frequency.10m": "10分",
"refresh.frequency.12m": "12分",
"refresh.frequency.15m": "15分",
"refresh.frequency.20m": "20分",
"refresh.frequency.30m": "30分",
"refresh.frequency.40m": "40分",
"refresh.frequency.1h": "1時間",
"refresh.frequency.3h": "3時間",
"refresh.frequency.6h": "6時間",
"refresh.frequency.12h": "12時間",
"refresh.frequency.24h": "24時間",
"weather.widget.settings.title": "天気ウィジェットの設定",
"weather.widget.settings.desc": "すべての天気ウィジェットの自動更新と更新間隔を設定します。",
"weather.widget.settings.auto_refresh_label": "自動更新",
"weather.widget.settings.auto_refresh_enabled": "自動更新を有効にする",
"weather.widget.settings.frequency_label": "更新間隔",
"weather.widget.settings.frequency_10m": "10分",
"weather.widget.settings.frequency_12m": "12分",
"weather.widget.settings.frequency_15m": "15分",
"weather.widget.settings.frequency_30m": "30分",
"weather.widget.settings.frequency_1h": "1時間",
"weather.widget.settings.frequency_3h": "3時間",
"stcn24.widget.loading": "読み込み中...",
"stcn24.widget.loading_item": "読み込み中...",
"stcn24.widget.fetch_failed": "フォーラム投稿の取得に失敗しました",
"stcn24.widget.fallback_item": "投稿なし",
"stcn24.settings.title": "STCN 24の設定",
"stcn24.settings.desc": "情報ソース、自動更新、更新間隔を設定します。",
"stcn24.settings.source_label": "情報ソース",
"stcn24.settings.source_latest_created": "最新の投稿",
"stcn24.settings.source_latest_activity": "最新のアクティビティ",
"stcn24.settings.source_most_replies": "返信数順",
"stcn24.settings.source_earliest_created": "最古の投稿",
"stcn24.settings.source_earliest_activity": "最古のアクティビティ",
"stcn24.settings.source_least_replies": "返信が少ない順",
"stcn24.settings.source_frontpage_latest": "フロントページ最新",
"stcn24.settings.source_frontpage_earliest": "フロントページ最古",
"stcn24.settings.auto_refresh_label": "自動更新",
"stcn24.settings.auto_refresh_enabled": "自動更新を有効にする",
"stcn24.settings.frequency_label": "更新間隔",
"stcn24.settings.frequency_5m": "5分",
"stcn24.settings.frequency_10m": "10分",
"stcn24.settings.frequency_20m": "20分",
"stcn24.settings.frequency_30m": "30分",
"stcn24.settings.frequency_1h": "1時間",
"stcn24.settings.frequency_3h": "3時間",
"exchange.widget.loading": "為替レートを読み込み中...",
"exchange.widget.fetch_failed": "為替レートの取得に失敗しました",
"cnrnews.settings.title": "CNRの設定",
"cnrnews.settings.desc": "自動ローテーションと更新間隔を設定します。",
"cnrnews.settings.auto_rotate_label": "自動ローテーション",
"cnrnews.settings.auto_rotate_enabled": "自動ローテーションを有効にする",
"cnrnews.settings.frequency_label": "ローテーション間隔",
"cnrnews.settings.frequency_5m": "5分",
"cnrnews.settings.frequency_10m": "10分",
"cnrnews.settings.frequency_40m": "40分",
"cnrnews.settings.frequency_1h": "1時間",
"cnrnews.settings.frequency_12h": "12時間",
"cnrnews.settings.frequency_24h": "24時間",
"artwork.settings.title": "今日のアートの設定",
"artwork.settings.desc": "今日のアートで使用されるデータソースを切り替えます。",
"artwork.settings.source_label": "ミラーソース",
"artwork.settings.source_domestic": "国内ミラー",
"artwork.settings.source_overseas": "海外ミラー",
"artwork.settings.source_status_domestic": "現在のソース: 国内ミラー(中国ネットワーク向けに最適化)",
"artwork.settings.source_status_overseas": "現在のソース: 海外ミラー(美術館のおすすめ)",
"music.widget.unsupported": "このプラットフォームでは音楽コントロールはサポートされていません",
"music.widget.unsupported_hint": "このウィジェットにはWindows SMTCが必要です",
"music.widget.no_session": "音楽ソースなし",
"music.widget.no_session_hint": "アプリストアからQQ音楽/酷狗/網易雲音楽をインストールしてください",
"music.widget.open_player": "プレーヤーを開く",
"music.widget.unknown_title": "不明なタイトル",
"music.widget.unknown_artist": "不明なアーティスト",
"music.widget.status.opened": "開かれました",
"music.widget.status.changing": "変更中",
"music.widget.status.stopped": "停止",
"music.widget.status.playing": "再生中",
"music.widget.status.paused": "一時停止",
"recording.widget.title": "レコーダー",
"recording.widget.hint.ready": "赤いボタンをタップして録音",
"recording.widget.hint.recording": "録音中",
"recording.widget.hint.paused": "一時停止",
"recording.widget.hint.unsupported": "マイクが利用できません",
"recording.widget.hint.error": "録音に失敗しました",
"recording.widget.hint.saved_format": "保存しました {0}",
"recording.widget.save_picker_title": "録音ファイルを保存",
"recording.widget.save_picker_type": "WAVオーディオ",
"study.environment.status_label": "環境",
"study.environment.status.initializing": "初期化中",
"study.environment.status.ready": "準備完了",
"study.environment.status.quiet": "静か",
"study.environment.status.noisy": "うるさい",
"study.environment.status.paused": "一時停止",
"study.environment.status.error": "エラー",
"study.environment.status.unsupported": "未対応",
"study.environment.value.unavailable": "--",
"study.environment.value.display_format": "{0:F1} dB",
"study.environment.value.dbfs_format": "{0:F1} dBFS",
"study.environment.settings.title": "環境ウィジェットの設定",
"study.environment.settings.desc": "右側のリアルタイムノイズ値表示を設定します。",
"study.environment.settings.show_display_db": "表示dBを表示",
"study.environment.settings.show_dbfs": "dBFSを表示",
"study.environment.settings.hint": "少なくとも1つの表示モードを有効にしておく必要があります。",
"removable_storage.settings.desc": "接続されたUSBドライブを表示し、クイックオープンと取り出しアクションを提供します。",
"removable_storage.settings.behavior_title": "動作",
"removable_storage.settings.behavior_desc": "ウィジェットはリムーバブルドライブを自動的に監視し、最新の挿入されたUSBドライブに切り替わります。",
"removable_storage.action.open": "開く",
"removable_storage.action.eject": "取り出し",
"removable_storage.widget.default_name": "リムーバブルドライブ",
"removable_storage.widget.empty_title": "デバイスが挿入されていません",
"removable_storage.widget.empty_subtitle": "USBドライブを挿入してここに表示します。",
"removable_storage.widget.empty_hint": "リムーバブルデバイスが挿入されるまで、ボタンは無効のままです。",
"removable_storage.widget.ready": "開くか取り出す準備ができました。",
"removable_storage.widget.ejecting": "ドライブを取り出し中...",
"removable_storage.widget.eject_failed": "このドライブを取り出せませんでした。上のファイルを閉じて再試行してください。",
"removable_storage.widget.open_failed": "このドライブを開けませんでした。",
"removable_storage.widget.refresh_failed": "ドライブリストの更新に失敗しました。",
"study.session_control.action.start": "学習セッションを開始",
"study.session_control.action.stop": "学習セッションを停止",
"study.session_control.idle_hint": "右のボタンをタップして開始",
"study.session_control.report_preview": "レポートをプレビュー",
"study.session_control.report_confirm_hint": "右のボタンをタップして確認",
"study.session_control.running_elapsed_format": "経過 {0}",
"study.session_control.last_session_format": "前回 {0}",
"study.session_control.start_failed": "セッションを開始できません",
"study.session_control.stop_failed": "セッションを停止できません",
"study.session_history.title": "セッション履歴",
"study.session_history.empty": "セッション履歴なし",
"study.session_history.select_failed": "セッションを切り替えられません",
"study.session_history.rename_failed": "セッション名を変更できません",
"study.session_history.delete_failed": "セッションを削除できません",
"study.session_history.rename_placeholder": "セッション名を入力",
"study.session_history.rename_confirm": "名前変更を確認",
"study.session_history.rename_cancel": "名前変更をキャンセル",
"study.session_history.loading": "データを読み込み中...",
"study.session_history.loaded": "データが読み込まれました",
"study.session_history.duration_format": "{0:hh\\:mm\\:ss}",
"study.session_history.meta_format": "{0} · 平均 {1:F1}",
"study.session_history.action.view": "表示",
"study.session_history.action.rename": "名前変更",
"study.session_history.action.delete": "削除",
"study.session_history.dialog.rename_title": "セッション名を変更",
"study.session_history.dialog.rename_message": "「{0}」の新しい名前を入力してください。",
"study.session_history.dialog.delete_title": "セッションを削除",
"study.session_history.dialog.delete_message": "「{0}」を削除しますか?これは元に戻せません。",
"study.session_history.dialog.delete_confirm": "削除",
"study.noise_curve.value_format": "{0:F1} dB",
"study.noise_curve.axis.now": "現在",
"study.noise_distribution.title": "ノイズレベル分布",
"study.noise_distribution.mode.realtime": "リアルタイム",
"study.noise_distribution.mode.session": "セッション",
"study.noise_distribution.summary.mainly_format": "主に: {0}",
"study.noise_distribution.summary.latest_format": "最新: {0}",
"study.noise_distribution.summary.compact_format": "主 {0} · 新 {1}",
"study.noise_distribution.level.quiet": "静か",
"study.noise_distribution.level.normal": "普通",
"study.noise_distribution.level.noisy": "うるさい",
"study.noise_distribution.level.extreme": "極端",
"study.noise_distribution.axis.extreme": "極端",
"study.noise_distribution.axis.noisy": "うるさい",
"study.noise_distribution.axis.normal": "普通",
"study.noise_distribution.axis.quiet": "静か",
"study.noise_distribution.axis.now": "現在",
"study.score_overview.title": "学習スコア",
"study.score_overview.mode.realtime": "リアルタイム",
"study.score_overview.mode.session": "セッション",
"study.score_overview.current": "現在",
"study.score_overview.average": "平均",
"study.score_overview.minimum": "最小",
"study.score_overview.maximum": "最大",
"study.score_overview.average_short": "平均",
"study.score_overview.minimum_short": "最小",
"study.score_overview.maximum_short": "最大",
"study.score_overview.unavailable": "--",
"study.deduction.title": "減点理由",
"study.deduction.mode.realtime": "リアルタイム",
"study.deduction.mode.session": "セッション",
"study.deduction.reason.sustained": "持続ノイズ",
"study.deduction.reason.time": "閾値超過時間",
"study.deduction.reason.segment": "中断頻度",
"study.deduction.reason.sustained_short": "持続",
"study.deduction.reason.time_short": "時間",
"study.deduction.reason.segment_short": "中断",
"study.deduction.metric.sustained_format": "p50 {0:F1} dBFS",
"study.deduction.metric.sustained_short_format": "p50 {0:F1}",
"study.deduction.metric.time_format": "{0:F1}%超過",
"study.deduction.metric.time_short_format": "{0:F1}%",
"study.deduction.metric.segment_format": "{0:F1}/分",
"study.deduction.metric.segment_short_format": "{0:F1}/分",
"study.deduction.loss_format": "-{0:F1}",
"study.deduction.total_loss_format": "合計 -{0:F1}",
"study.deduction.total_score_format": "スコア {0:F1}",
"study.deduction.total_loss_unavailable": "合計 {0}",
"study.deduction.total_score_unavailable": "スコア {0}",
"study.deduction.unavailable": "--",
"study.interrupt_density.title": "中断密度",
"study.interrupt_density.mode.realtime": "リアルタイム",
"study.interrupt_density.mode.session": "セッション",
"study.interrupt_density.unit": "/分",
"study.interrupt_density.segment_count": "中断回数",
"study.interrupt_density.segment_count_short": "回数",
"study.interrupt_density.duration": "期間",
"study.interrupt_density.duration_short": "時間",
"study.interrupt_density.density_value_format": "{0:F1}",
"study.interrupt_density.segment_count_value_format": "{0}",
"study.interrupt_density.level_format": "レベル {0}",
"study.interrupt_density.level.calm": "穏やか",
"study.interrupt_density.level.normal": "普通",
"study.interrupt_density.level.frequent": "頻繁",
"study.interrupt_density.level.severe": "深刻",
"study.interrupt_density.threshold_format": "ペナルティ閾値 {0:F1}/分",
"study.interrupt_density.unavailable": "--",
"desktop.add_page": "ページを追加",
"desktop.delete_page": "ページを削除",
"placement.fill": "フィル",
"placement.fit": "フィット",
"placement.stretch": "ストレッチ",
"placement.center": "中央",
"placement.tile": "タイル",
"single_instance.notice.title": "アプリは既に実行中",
"single_instance.notice.description": "アプリは既に実行中です。複数回クリックして開く必要はありません。",
"single_instance.notice.button": "OK",
"market.status.install_success_restart_format": "✓ プラグイン「{0}」が正常にインストールされました!有効にするには、アプリケーションを再起動してください。",
"market.dialog.restart_message_format": "プラグイン「{0}」が正常にインストールされました。\n\nこのプラグインを使用するには、今すぐアプリケーションを再起動する必要があります。\n\n再起動しますか",
"component.settings.color_scheme": "カラースキーム"
}

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,23 @@
"settings.wallpaper.type_label": "壁纸类型",
"settings.wallpaper.type.image": "图片",
"settings.wallpaper.type.solid_color": "纯色",
"settings.wallpaper.type.system": "系统壁纸",
"settings.wallpaper.system.label": "系统壁纸",
"settings.wallpaper.system.unavailable": "无法读取系统壁纸",
"settings.wallpaper.refresh_interval": "刷新频率",
"settings.wallpaper.refresh_now": "立即刷新",
"settings.wallpaper.refresh.30s": "30 秒",
"settings.wallpaper.refresh.1m": "1 分钟",
"settings.wallpaper.refresh.5m": "5 分钟",
"settings.wallpaper.refresh.10m": "10 分钟",
"settings.wallpaper.refresh.15m": "15 分钟",
"settings.wallpaper.refresh.30m": "30 分钟",
"settings.wallpaper.refresh.1h": "1 小时",
"settings.wallpaper.refresh.2h": "2 小时",
"settings.wallpaper.refresh.4h": "4 小时",
"settings.wallpaper.refresh.8h": "8 小时",
"settings.wallpaper.refresh.12h": "12 小时",
"settings.wallpaper.refresh.24h": "24 小时",
"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.custom_color_tooltip": "自定义颜色",
"settings.wallpaper.custom_color_apply": "应用",
@@ -189,6 +206,52 @@
"settings.weather.location_required": "天气位置不能为空。",
"settings.weather.location_current_format": "当前天气位置:{0}",
"settings.weather.location_saved_format": "天气位置已保存:{0}",
"settings.study.title": "自习",
"settings.study.description": "配置自习环境监测、专注计时和提醒设置。",
"settings.study.noise_header": "噪音监测",
"settings.study.noise_description": "配置麦克风采集频率和噪音评分敏感度。",
"settings.study.sampling_rate_label": "采集频率",
"settings.study.sampling_rate_desc": "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。",
"settings.study.sampling_rate_20ms": "20ms (高频)",
"settings.study.sampling_rate_50ms": "50ms (标准)",
"settings.study.sampling_rate_100ms": "100ms (节能)",
"settings.study.sampling_rate_200ms": "200ms (低功耗)",
"settings.study.sensitivity_label": "噪音敏感度",
"settings.study.sensitivity_desc": "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。",
"settings.study.sensitivity_relaxed": "宽松 (-45dBFS)",
"settings.study.sensitivity_standard": "标准 (-50dBFS)",
"settings.study.sensitivity_strict": "严格 (-55dBFS)",
"settings.study.sensitivity_very_strict": "极严 (-60dBFS)",
"settings.study.current_threshold_format": "当前评分阈值: {0} dBFS",
"settings.study.timer_header": "专注计时",
"settings.study.timer_description": "配置专注时段和休息时段的时长。",
"settings.study.focus_duration_label": "专注时长",
"settings.study.focus_duration_desc": "单次专注时段的持续时间(分钟)。",
"settings.study.break_duration_label": "休息时长",
"settings.study.break_duration_desc": "短休息时段的持续时间(分钟)。",
"settings.study.long_break_duration_label": "长休息时长",
"settings.study.long_break_duration_desc": "长休息时段的持续时间(分钟)。",
"settings.study.sessions_before_long_break_label": "长休息间隔",
"settings.study.sessions_before_long_break_desc": "经过几个专注时段后触发长休息。",
"settings.study.auto_start_break_label": "自动开始休息",
"settings.study.auto_start_break_desc": "专注时段结束后自动开始休息计时。",
"settings.study.auto_start_focus_label": "自动开始专注",
"settings.study.auto_start_focus_desc": "休息时段结束后自动开始专注计时。",
"settings.study.alert_header": "提醒设置",
"settings.study.alert_description": "配置噪音干扰提醒。",
"settings.study.noise_alert_enabled_label": "启用噪音提醒",
"settings.study.noise_alert_enabled_desc": "当检测到超过容忍阈值的噪音干扰时显示提醒。",
"settings.study.max_interrupts_label": "最大容忍打断次数",
"settings.study.max_interrupts_desc": "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。",
"settings.study.display_header": "显示设置",
"settings.study.display_description": "配置噪音数据的显示方式。",
"settings.study.show_realtime_db_label": "显示实时分贝",
"settings.study.show_realtime_db_desc": "在组件中实时显示分贝值。",
"settings.study.baseline_db_label": "基准显示分贝",
"settings.study.baseline_db_desc": "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。",
"settings.study.avg_window_label": "平均时间窗",
"settings.study.avg_window_desc": "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。",
"settings.study.footer_hint": "这些设置将影响自习环境监测组件的行为。",
"weather.widget.location_not_configured": "尚未配置天气位置",
"weather.widget.configure_hint": "请前往 设置 > 天气 完成配置",
"weather.widget.loading": "加载中...",
@@ -216,7 +279,14 @@
"schedule.settings.unnamed": "未命名课表",
"schedule.settings.delete": "删除",
"schedule.settings.picker_title": "选择 ClassIsland 课表文件",
"schedule.settings.picker_file_type": "ClassIsland CSES 课表",
"schedule.settings.picker_file_type.all": "ClassIsland 课表文件",
"schedule.settings.picker_file_type.json": "ClassIsland 档案 (JSON)",
"schedule.settings.picker_file_type.cses": "CSES 课表 (YAML)",
"schedule.settings.semester.title": "学期设置",
"schedule.settings.semester.start_date": "学期开始日期",
"schedule.settings.semester.week_cycle": "周循环",
"schedule.settings.semester.week_cycle_desc": "设置多周课表轮换周期,用于计算当前是第几周。",
"schedule.settings.semester.week_cycle_format": "{0} 周轮换",
"worldclock.settings.title": "世界时钟设置",
"worldclock.settings.desc": "分别为四个时钟选择时区。",
"worldclock.settings.clock_1": "时钟 1",
@@ -247,6 +317,7 @@
"settings.region.language_label": "语言",
"settings.region.language_zh": "中文",
"settings.region.language_en": "英文",
"settings.region.language_ja": "日文",
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
@@ -388,6 +459,11 @@
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
"settings.update.download_threads_label": "下载线程数",
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
"settings.update.force_check_label": "强制检查更新",
"settings.update.force_check_desc": "强制从 GitHub 获取最新版本,忽略版本比较。",
"settings.update.status_force_checking": "正在强制检查 GitHub Release...",
"settings.update.status_force_no_asset": "已找到发布版本,但没有可用的兼容安装包。",
"settings.update.status_force_available_format": "发布版本 {0} 可用,点击“下载并安装”继续。",
"settings.update.install_now_button": "立即安装",
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
@@ -446,8 +522,8 @@
"settings.plugins.refresh_button": "刷新插件",
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
"settings.plugins.marketplace_header": "插件市场",
"settings.plugins.refresh_failed": "加载插件目录索引失败。",
"settings.plugins.marketplace_header": "插件目录",
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
"settings.plugins.delete_button_short": "删除",
"settings.plugins.install_button_short": "安装",
@@ -494,10 +570,10 @@
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"settings.nav.plugin_market": "插件市场",
"settings.plugin_market.title": "插件市场",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"settings.nav.plugin_catalog": "插件目录",
"settings.plugin_catalog.title": "插件目录",
"settings.plugin_catalog.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_catalog.unavailable": "插件运行时不可用,暂时无法打开官方目录。",
"settings.update.status_idle": "尚未执行更新检查。",
"settings.update.status_preferences_saved": "更新偏好已保存。",
"settings.update.status_check_failed": "检查更新失败。",
@@ -506,15 +582,15 @@
"settings.window.drawer_default": "详情",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...",
"market.status.loading": "正在加载官方插件目录...",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.load_failed_format": "加载插件目录失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.empty": "插件目录尚未加载。",
"market.list.no_results": "没有匹配当前搜索的插件。",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "已加载",
@@ -928,6 +1004,10 @@
"study.interrupt_density.unavailable": "--",
"desktop.add_page": "新增页面",
"desktop.delete_page": "删除页面",
"desktop.delete_page_confirm.title": "确认删除页面",
"desktop.delete_page_confirm.message": "确定要删除当前页面吗?\n\n此操作将删除当前页面上的所有组件且无法撤销。",
"desktop.delete_page_confirm.primary": "删除",
"desktop.delete_page_confirm.close": "取消",
"placement.fill": "填充",
"placement.fit": "适应",
"placement.stretch": "拉伸",
@@ -937,5 +1017,19 @@
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定",
"market.status.install_success_restart_format": "✓ 插件'{0}'安装成功!请重启应用以激活它。",
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启"
}
"market.dialog.restart_message_format": "插件'{0}'已成功安装。\n\n要使用此插件您需要立即重启应用。\n\n是否立即重启",
"zhijiaohub.settings.source": "图片源",
"zhijiaohub.settings.classisland": "ClassIsland 图库",
"zhijiaohub.settings.sectl": "SECTL 图库",
"zhijiaohub.settings.source_desc": "选择图片来源。ClassIsland 图库包含 ClassIsland 社区的趣味瞬间SECTL 图库包含 SECTL 社区的内容。",
"zhijiaohub.settings.mirror_source": "镜像加速",
"zhijiaohub.settings.mirror_direct": "直连GitHub",
"zhijiaohub.settings.mirror_ghproxy": "镜像加速(推荐)",
"zhijiaohub.settings.mirror_source_desc": "如果图片加载缓慢或失败,请尝试使用镜像加速。镜像加速通过第三方代理服务加速 GitHub 访问。",
"zhijiaohub.settings.refresh": "刷新设置",
"zhijiaohub.settings.auto_refresh": "自动刷新",
"zhijiaohub.settings.auto_refresh_desc": "定期自动刷新图片列表。",
"zhijiaohub.settings.interval": "刷新间隔(分钟)",
"zhijiaohub.settings.about": "关于",
"zhijiaohub.settings.about_desc": "智教Hub 展示来自教育技术社区的有趣图片。图片从 GitHub 仓库获取并缓存在本地。"
}

View File

@@ -33,6 +33,8 @@ public sealed class AppSettingsSnapshot
public string WallpaperPlacement { get; set; } = "Fill";
public int SystemWallpaperRefreshIntervalSeconds { get; set; } = 300;
public int SettingsTabIndex { get; set; } = 0;
public string? SettingsTabTag { get; set; }
@@ -71,9 +73,11 @@ public sealed class AppSettingsSnapshot
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";
@@ -91,6 +95,8 @@ public sealed class AppSettingsSnapshot
public long? LastUpdateCheckUtcMs { get; set; }
public string? PendingUpdateSha256 { get; set; }
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =
@@ -112,6 +118,36 @@ public sealed class AppSettingsSnapshot
public List<string> DisabledPluginIds { get; set; } = [];
#region Study Settings
public int? StudyFrameMs { get; set; }
public double? StudyScoreThresholdDbfs { get; set; }
public int? StudyFocusDurationMinutes { get; set; }
public int? StudyBreakDurationMinutes { get; set; }
public int? StudyLongBreakDurationMinutes { get; set; }
public int? StudySessionsBeforeLongBreak { get; set; }
public bool? StudyAutoStartBreak { get; set; }
public bool? StudyAutoStartFocus { get; set; }
public bool? StudyNoiseAlertEnabled { get; set; }
public int? StudyMaxInterruptsPerMinute { get; set; }
public bool? StudyShowRealtimeDb { get; set; }
public double? StudyBaselineDb { get; set; }
public int? StudyAvgWindowSec { get; set; }
#endregion
public AppSettingsSnapshot Clone()
{
var clone = (AppSettingsSnapshot)MemberwiseClone();

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
@@ -12,6 +13,10 @@ public sealed class ComponentSettingsSnapshot
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public DateOnly? SemesterStartDate { get; set; }
public int SemesterWeekCycle { get; set; } = 1;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
@@ -68,6 +73,17 @@ public sealed class ComponentSettingsSnapshot
public List<string>? OfficeRecentDocumentsEnabledSources { get; set; }
// 智教Hub组件配置
public string ZhiJiaoHubSource { get; set; } = ZhiJiaoHubSources.ClassIsland;
public string ZhiJiaoHubMirrorSource { get; set; } = ZhiJiaoHubMirrorSources.Direct;
public bool ZhiJiaoHubAutoRefreshEnabled { get; set; } = true;
public int ZhiJiaoHubAutoRefreshIntervalMinutes { get; set; } = 30;
public int ZhiJiaoHubCurrentImageIndex { get; set; } = 0;
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
@@ -102,3 +118,56 @@ public sealed class ComponentSettingsSnapshot
return clone;
}
}
// 智教Hub数据源常量
public static class ZhiJiaoHubSources
{
public const string ClassIsland = "classisland";
public const string Sectl = "sectl";
public static string Normalize(string? value)
{
return value?.ToLowerInvariant() switch
{
"sectl" => Sectl,
_ => ClassIsland
};
}
}
// 智教Hub镜像加速源常量
public static class ZhiJiaoHubMirrorSources
{
public const string Direct = "direct";
public const string GhProxy = "gh-proxy";
public const string GhProxyBaseUrl = "https://gh-proxy.com/";
public static string Normalize(string? value)
{
return string.Equals(value, GhProxy, StringComparison.OrdinalIgnoreCase)
? GhProxy
: Direct;
}
public static string ApplyMirror(string url, string? mirrorSource)
{
if (string.IsNullOrWhiteSpace(url))
{
return url;
}
if (!string.Equals(Normalize(mirrorSource), GhProxy, StringComparison.OrdinalIgnoreCase))
{
return url;
}
if (url.StartsWith("https://raw.githubusercontent.com/", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase))
{
return GhProxyBaseUrl.TrimEnd('/') + "/" + url;
}
return url;
}
}

View File

@@ -8,11 +8,10 @@ using LanMountainDesktop.DesktopHost;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using Sentry;
namespace LanMountainDesktop;
sealed class Program
public sealed class Program
{
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
@@ -21,11 +20,6 @@ sealed class Program
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
DesktopBootstrap.InitializeStartupServices(
InitializeDeviceId,
InitializeCrashReporting,
InitializeUserBehaviorAnalytics,
ScheduleWhiteboardNoteStartupCleanup);
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
@@ -44,6 +38,12 @@ sealed class Program
return;
}
DesktopBootstrap.InitializeStartupServices(
InitializeTelemetryIdentity,
InitializeCrashTelemetry,
InitializeUsageTelemetry,
ScheduleWhiteboardNoteStartupCleanup);
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
@@ -53,7 +53,6 @@ sealed class Program
StartupRenderMode = renderMode;
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
@@ -68,7 +67,12 @@ sealed class Program
}
}
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
public static AppBuilder BuildAvaloniaApp()
{
return BuildAvaloniaApp(AppRenderingModeHelper.Default);
}
public static AppBuilder BuildAvaloniaApp(string renderMode)
{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
@@ -185,204 +189,90 @@ sealed class Program
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
var exception = eventArgs.ExceptionObject as Exception
?? new Exception(eventArgs.ExceptionObject?.ToString() ?? "Unhandled exception.");
AppLogger.Critical(
"UnhandledException",
$"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) =>
{
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();
};
}
private static void InitializeDeviceId()
private static void InitializeTelemetryIdentity()
{
try
{
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
TelemetryIdentityService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
AppLogger.Info(
"Startup",
$"Telemetry identity initialized. InstallId={TelemetryIdentityService.Instance.InstallId}; TelemetryId={TelemetryIdentityService.Instance.TelemetryId}.");
}
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()
{
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()
private static void InitializeCrashTelemetry()
{
try
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
_crashReportService.RefreshEnabledState();
var crashTelemetry = new SentryCrashTelemetryService(settingsFacade);
TelemetryServices.Crash = crashTelemetry;
crashTelemetry.Initialize();
AppLogger.Info("Startup", $"Crash telemetry initialized. Enabled={crashTelemetry.IsEnabled}.");
}
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
{
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
_userBehaviorAnalyticsService.Initialize();
var usageTelemetry = new PostHogUsageTelemetryService(settingsFacade);
TelemetryServices.Usage = usageTelemetry;
usageTelemetry.Initialize();
AppLogger.Info("Startup", $"Usage telemetry initialized. Enabled={usageTelemetry.IsUsageEnabled}.");
}
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
}
}

View File

@@ -476,6 +476,7 @@ internal sealed class AppearanceThemeService : IAppearanceThemeService, IDisposa
resources["DesignCornerRadiusLg"] = snapshot.CornerRadiusTokens.Lg;
resources["DesignCornerRadiusXl"] = snapshot.CornerRadiusTokens.Xl;
resources["DesignCornerRadiusIsland"] = snapshot.CornerRadiusTokens.Island;
resources["DesignCornerRadiusComponent"] = snapshot.CornerRadiusTokens.Component;
}
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)

View File

@@ -12,7 +12,7 @@ namespace LanMountainDesktop.Services;
public interface IClassIslandScheduleDataService
{
ClassIslandScheduleReadResult Load(string? inputPath = null, string? profileFileName = null);
ClassIslandScheduleReadResult Load(string? inputPath = null, string? profileFileName = null, DateOnly? semesterStartDate = null, int semesterWeekCycle = 1);
bool TryResolveClassPlanForDate(
ClassIslandScheduleSnapshot snapshot,
@@ -43,7 +43,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
.IgnoreUnmatchedProperties()
.Build();
public ClassIslandScheduleReadResult Load(string? inputPath = null, string? profileFileName = null)
public ClassIslandScheduleReadResult Load(string? inputPath = null, string? profileFileName = null, DateOnly? semesterStartDate = null, int semesterWeekCycle = 1)
{
var warnings = new List<string>();
try
@@ -73,11 +73,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
ClassIslandScheduleSnapshot snapshot;
if (source.SourceKind == ScheduleSourceKind.Cses)
{
snapshot = ParseCsesSnapshot(source);
snapshot = ParseCsesSnapshot(source, semesterStartDate, semesterWeekCycle);
}
else
{
var cycleRule = ParseCycleRule(source.SettingsPath, warnings);
var cycleRule = ParseCycleRule(source.SettingsPath, warnings, semesterStartDate, semesterWeekCycle);
var profileJson = ReadJson(source.ProfilePath);
snapshot = ParseProfileSnapshot(profileJson.RootElement, source, cycleRule);
}
@@ -412,22 +412,50 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return null;
}
private static ClassIslandScheduleCycleRule ParseCycleRule(string? settingsPath, List<string> warnings)
private static ClassIslandScheduleCycleRule ParseCycleRule(
string? settingsPath,
List<string> warnings,
DateOnly? semesterStartDate = null,
int semesterWeekCycle = 1)
{
if (string.IsNullOrWhiteSpace(settingsPath) || !File.Exists(settingsPath))
DateOnly? singleWeekStartDate = semesterStartDate;
int maxCycle = semesterWeekCycle > 1 ? semesterWeekCycle : 4;
var offsetList = new List<int> { -1, -1, 0, 0, 0, 0, 0, 0 };
if (!string.IsNullOrWhiteSpace(settingsPath) && File.Exists(settingsPath))
{
warnings.Add("ClassIsland Settings.json not found, using default cycle rule.");
return new ClassIslandScheduleCycleRule(null, 4, new List<int> { -1, -1, 0, 0, 0 });
using var json = ReadJson(settingsPath);
var root = json.RootElement;
if (!singleWeekStartDate.HasValue)
{
singleWeekStartDate = TryReadDateOnly(root, "SingleWeekStartTime");
}
if (semesterWeekCycle <= 1)
{
maxCycle = TryReadInt(root, "MultiWeekRotationMaxCycle", 4);
}
var settingsOffsetList = ReadIntList(root, "MultiWeekRotationOffset");
if (settingsOffsetList.Count >= 2)
{
offsetList = settingsOffsetList;
}
}
else
{
warnings.Add("ClassIsland Settings.json not found, using semester settings from component.");
}
using var json = ReadJson(settingsPath);
var root = json.RootElement;
var singleWeekStartDate = TryReadDateOnly(root, "SingleWeekStartTime");
var maxCycle = TryReadInt(root, "MultiWeekRotationMaxCycle", 4);
var offsetList = ReadIntList(root, "MultiWeekRotationOffset");
if (offsetList.Count < 2)
if (maxCycle < 2)
{
offsetList = new List<int> { -1, -1, 0, 0, 0 };
maxCycle = 2;
}
while (offsetList.Count <= maxCycle)
{
offsetList.Add(0);
}
return new ClassIslandScheduleCycleRule(
@@ -469,7 +497,10 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
ClassPlanGroups: groups);
}
private static ClassIslandScheduleSnapshot ParseCsesSnapshot(ResolvedSource source)
private static ClassIslandScheduleSnapshot ParseCsesSnapshot(
ResolvedSource source,
DateOnly? semesterStartDate = null,
int semesterWeekCycle = 1)
{
var yaml = File.ReadAllText(source.ProfilePath);
var csesProfile = CsesDeserializer.Deserialize<CsesProfileDto>(yaml) ?? new CsesProfileDto();
@@ -600,12 +631,19 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
[GlobalClassPlanGroupId] = new ClassIslandClassPlanGroup(GlobalClassPlanGroupId, "Global", IsGlobal: true)
};
var maxCycle = semesterWeekCycle > 1 ? semesterWeekCycle : 4;
var offsetList = new List<int> { -1, -1, 0, 0, 0, 0, 0, 0 };
while (offsetList.Count <= maxCycle)
{
offsetList.Add(0);
}
return new ClassIslandScheduleSnapshot(
SourceRootPath: source.SourceRootPath,
ProfilePath: source.ProfilePath,
ProfileFileName: source.ProfileFileName,
LoadedAt: DateTimeOffset.Now,
CycleRule: new ClassIslandScheduleCycleRule(null, 4, new List<int> { -1, -1, 0, 0, 0 }),
CycleRule: new ClassIslandScheduleCycleRule(semesterStartDate, Math.Clamp(maxCycle, 2, 32), offsetList),
SelectedClassPlanGroupId: DefaultClassPlanGroupId,
TempClassPlanGroupId: null,
IsTempClassPlanGroupEnabled: false,

View File

@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media;
namespace LanMountainDesktop.Services;
public sealed class ComponentPreviewImageService : IComponentPreviewImageService
{
private readonly object _gate = new();
private readonly Dictionary<ComponentPreviewKey, ComponentPreviewImageEntry> _entries = new(ComponentPreviewKeyComparer.Instance);
private readonly Dictionary<ComponentPreviewKey, Task<ComponentPreviewImageEntry>> _inFlightRequests = new(ComponentPreviewKeyComparer.Instance);
private Task _queueTail = Task.CompletedTask;
public ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null)
{
lock (_gate)
{
if (_entries.TryGetValue(key, out var existing))
{
return existing;
}
var created = new ComponentPreviewImageEntry(key, visualSignature);
_entries[key] = created;
return created;
}
}
public bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry)
{
lock (_gate)
{
if (_entries.TryGetValue(key, out var existing))
{
entry = existing;
return true;
}
entry = null;
return false;
}
}
public IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot()
{
lock (_gate)
{
return _entries.Values.ToArray();
}
}
public Task<ComponentPreviewImageEntry> QueueGenerationAsync(
ComponentPreviewKey key,
string visualSignature,
Func<CancellationToken, Task<IImage?>> generationWork,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(generationWork);
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
lock (_gate)
{
var entry = GetOrCreateEntryCore(key);
if (entry.State == ComponentPreviewImageState.Ready &&
entry.Bitmap is not null &&
StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
{
return Task.FromResult(entry);
}
if (_inFlightRequests.TryGetValue(key, out var inFlight))
{
return inFlight;
}
var expectedRevision = entry.BeginGeneration(normalizedSignature);
var previousTask = _queueTail;
var queuedTask = RunGenerationAsync(
previousTask,
key,
entry,
expectedRevision,
normalizedSignature,
generationWork,
cancellationToken);
_inFlightRequests[key] = queuedTask;
_queueTail = queuedTask.ContinueWith(
static _ => { },
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return queuedTask;
}
}
public ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature)
{
ArgumentNullException.ThrowIfNull(bitmap);
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
lock (_gate)
{
var entry = GetOrCreateEntryCore(key);
entry.StoreBitmap(bitmap, normalizedSignature);
_inFlightRequests.Remove(key);
return entry;
}
}
public ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null)
{
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
lock (_gate)
{
var entry = GetOrCreateEntryCore(key);
entry.StoreFailure(normalizedSignature, errorMessage);
_inFlightRequests.Remove(key);
return entry;
}
}
public bool Invalidate(ComponentPreviewKey key, string? visualSignature = null)
{
lock (_gate)
{
if (!_entries.TryGetValue(key, out var entry))
{
return false;
}
entry.Invalidate(visualSignature);
_inFlightRequests.Remove(key);
return true;
}
}
public int RemovePlacementPreviews(string placementId)
{
var normalizedPlacementId = NormalizeRequired(placementId, nameof(placementId));
lock (_gate)
{
var entriesToRemove = _entries
.Where(static pair => pair.Key.Kind == ComponentPreviewKeyKind.PlacementInstance)
.Where(pair => StringComparer.OrdinalIgnoreCase.Equals(pair.Key.PlacementId, normalizedPlacementId))
.ToArray();
foreach (var pair in entriesToRemove)
{
pair.Value.DisposeBitmap();
_entries.Remove(pair.Key);
_inFlightRequests.Remove(pair.Key);
}
return entriesToRemove.Length;
}
}
public int InvalidateVisualSignature(string visualSignature)
{
var normalizedSignature = NormalizeRequired(visualSignature, nameof(visualSignature));
lock (_gate)
{
var entriesToInvalidate = _entries.Values
.Where(entry => StringComparer.Ordinal.Equals(entry.VisualSignature, normalizedSignature))
.ToArray();
foreach (var entry in entriesToInvalidate)
{
entry.Invalidate(normalizedSignature);
_inFlightRequests.Remove(entry.Key);
}
return entriesToInvalidate.Length;
}
}
private async Task<ComponentPreviewImageEntry> RunGenerationAsync(
Task previousTask,
ComponentPreviewKey key,
ComponentPreviewImageEntry entry,
long expectedRevision,
string visualSignature,
Func<CancellationToken, Task<IImage?>> generationWork,
CancellationToken cancellationToken)
{
try
{
try
{
await previousTask.ConfigureAwait(false);
}
catch
{
// Keep serial queue processing even if previous work faulted.
}
IImage? bitmap;
try
{
bitmap = await generationWork(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
lock (_gate)
{
entry.TryApplyFailure(expectedRevision, visualSignature, ex.Message);
}
return entry;
}
lock (_gate)
{
if (bitmap is null)
{
entry.TryApplyFailure(expectedRevision, visualSignature, "Preview generation returned no bitmap.");
}
else
{
entry.TryApplyGeneratedBitmap(expectedRevision, bitmap, visualSignature);
}
}
return entry;
}
finally
{
lock (_gate)
{
_inFlightRequests.Remove(key);
}
}
}
private ComponentPreviewImageEntry GetOrCreateEntryCore(ComponentPreviewKey key)
{
if (_entries.TryGetValue(key, out var existing))
{
return existing;
}
var created = new ComponentPreviewImageEntry(key);
_entries[key] = created;
return created;
}
private static string NormalizeRequired(string? value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
}

View File

@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
namespace LanMountainDesktop.Services;
public enum ComponentPreviewKeyKind
{
ComponentType = 0,
PlacementInstance = 1
}
public readonly record struct ComponentPreviewKey
{
private ComponentPreviewKey(
ComponentPreviewKeyKind kind,
string componentTypeId,
string? placementId,
int widthCells,
int heightCells)
{
Kind = kind;
ComponentTypeId = NormalizeRequired(componentTypeId, nameof(componentTypeId));
PlacementId = kind == ComponentPreviewKeyKind.PlacementInstance
? NormalizeRequired(placementId, nameof(placementId))
: null;
WidthCells = NormalizeSpan(widthCells, nameof(widthCells));
HeightCells = NormalizeSpan(heightCells, nameof(heightCells));
}
public ComponentPreviewKeyKind Kind { get; }
public string ComponentTypeId { get; }
public string? PlacementId { get; }
public int WidthCells { get; }
public int HeightCells { get; }
public static ComponentPreviewKey ForComponentType(string componentTypeId, int widthCells, int heightCells)
{
return new ComponentPreviewKey(ComponentPreviewKeyKind.ComponentType, componentTypeId, null, widthCells, heightCells);
}
public static ComponentPreviewKey ForPlacementInstance(string componentTypeId, string placementId, int widthCells, int heightCells)
{
return new ComponentPreviewKey(
ComponentPreviewKeyKind.PlacementInstance,
componentTypeId,
placementId,
widthCells,
heightCells);
}
public override string ToString()
{
return Kind == ComponentPreviewKeyKind.ComponentType
? $"Type:{ComponentTypeId}[{WidthCells}x{HeightCells}]"
: $"Placement:{ComponentTypeId}@{PlacementId}[{WidthCells}x{HeightCells}]";
}
private static string NormalizeRequired(string? value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException("Value cannot be null or whitespace.", paramName);
}
return value.Trim();
}
private static int NormalizeSpan(int value, string paramName)
{
if (value <= 0)
{
throw new ArgumentOutOfRangeException(paramName, value, "Span must be greater than zero.");
}
return value;
}
}
public enum ComponentPreviewImageState
{
Pending = 0,
Ready = 1,
Failed = 2
}
public sealed class ComponentPreviewImageEntry : ObservableObject
{
private IImage? _bitmap;
private ComponentPreviewImageState _state = ComponentPreviewImageState.Pending;
private string _visualSignature = string.Empty;
private string? _errorMessage;
private long _revision;
private DateTimeOffset _lastUpdatedUtc = DateTimeOffset.UtcNow;
public ComponentPreviewImageEntry(ComponentPreviewKey key, string? visualSignature = null)
{
Key = key;
VisualSignature = NormalizeSignature(visualSignature);
}
public ComponentPreviewKey Key { get; }
public IImage? Bitmap
{
get => _bitmap;
private set => SetProperty(ref _bitmap, value);
}
public ComponentPreviewImageState State
{
get => _state;
private set => SetProperty(ref _state, value);
}
public string VisualSignature
{
get => _visualSignature;
private set => SetProperty(ref _visualSignature, value);
}
public string? ErrorMessage
{
get => _errorMessage;
private set => SetProperty(ref _errorMessage, value);
}
public long Revision
{
get => _revision;
private set => SetProperty(ref _revision, value);
}
public DateTimeOffset LastUpdatedUtc
{
get => _lastUpdatedUtc;
private set => SetProperty(ref _lastUpdatedUtc, value);
}
internal long BeginGeneration(string visualSignature)
{
var normalizedVisualSignature = NormalizeSignature(visualSignature);
var nextRevision = Revision + 1;
Revision = nextRevision;
VisualSignature = normalizedVisualSignature;
State = ComponentPreviewImageState.Pending;
ReplaceBitmap(null);
ErrorMessage = null;
LastUpdatedUtc = DateTimeOffset.UtcNow;
return nextRevision;
}
internal bool TryApplyGeneratedBitmap(long expectedRevision, IImage bitmap, string visualSignature)
{
ArgumentNullException.ThrowIfNull(bitmap);
if (Revision != expectedRevision)
{
DisposeIfNeeded(bitmap);
return false;
}
VisualSignature = NormalizeSignature(visualSignature);
State = ComponentPreviewImageState.Ready;
ReplaceBitmap(bitmap);
ErrorMessage = null;
LastUpdatedUtc = DateTimeOffset.UtcNow;
return true;
}
internal bool TryApplyFailure(long expectedRevision, string visualSignature, string? errorMessage)
{
if (Revision != expectedRevision)
{
return false;
}
VisualSignature = NormalizeSignature(visualSignature);
State = ComponentPreviewImageState.Failed;
ReplaceBitmap(null);
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
LastUpdatedUtc = DateTimeOffset.UtcNow;
return true;
}
internal void StoreBitmap(IImage bitmap, string visualSignature)
{
ArgumentNullException.ThrowIfNull(bitmap);
Revision += 1;
VisualSignature = NormalizeSignature(visualSignature);
State = ComponentPreviewImageState.Ready;
ReplaceBitmap(bitmap);
ErrorMessage = null;
LastUpdatedUtc = DateTimeOffset.UtcNow;
}
internal void StoreFailure(string visualSignature, string? errorMessage)
{
Revision += 1;
VisualSignature = NormalizeSignature(visualSignature);
State = ComponentPreviewImageState.Failed;
ReplaceBitmap(null);
ErrorMessage = string.IsNullOrWhiteSpace(errorMessage) ? "Unknown preview generation failure." : errorMessage.Trim();
LastUpdatedUtc = DateTimeOffset.UtcNow;
}
internal void Invalidate(string? visualSignature = null)
{
Revision += 1;
if (visualSignature is not null)
{
VisualSignature = NormalizeSignature(visualSignature);
}
State = ComponentPreviewImageState.Pending;
ReplaceBitmap(null);
ErrorMessage = null;
LastUpdatedUtc = DateTimeOffset.UtcNow;
}
internal void DisposeBitmap()
{
ReplaceBitmap(null);
}
private void ReplaceBitmap(IImage? bitmap)
{
var previous = _bitmap;
if (ReferenceEquals(previous, bitmap))
{
return;
}
Bitmap = bitmap;
DisposeIfNeeded(previous);
}
private static void DisposeIfNeeded(IImage? bitmap)
{
if (bitmap is IDisposable disposable)
{
disposable.Dispose();
}
}
private static string NormalizeSignature(string? visualSignature)
{
return visualSignature?.Trim() ?? string.Empty;
}
}
internal sealed class ComponentPreviewKeyComparer : IEqualityComparer<ComponentPreviewKey>
{
public static ComponentPreviewKeyComparer Instance { get; } = new();
public bool Equals(ComponentPreviewKey x, ComponentPreviewKey y)
{
return x.Kind == y.Kind &&
StringComparer.OrdinalIgnoreCase.Equals(x.ComponentTypeId, y.ComponentTypeId) &&
StringComparer.OrdinalIgnoreCase.Equals(x.PlacementId, y.PlacementId) &&
x.WidthCells == y.WidthCells &&
x.HeightCells == y.HeightCells;
}
public int GetHashCode(ComponentPreviewKey obj)
{
var hash = new HashCode();
hash.Add(obj.Kind);
hash.Add(obj.ComponentTypeId, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.PlacementId, StringComparer.OrdinalIgnoreCase);
hash.Add(obj.WidthCells);
hash.Add(obj.HeightCells);
return hash.ToHashCode();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -262,7 +262,12 @@ public static class DesktopComponentEditorRegistryFactory
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
]
}))
})),
[BuiltInComponentIds.DesktopZhiJiaoHub] = new(
BuiltInComponentIds.DesktopZhiJiaoHub,
context => new ZhiJiaoHubComponentEditor(context),
preferredWidth: 480d,
preferredHeight: 520d)
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))

View File

@@ -0,0 +1,48 @@
using System;
using Avalonia.Media;
namespace LanMountainDesktop.Services;
public sealed class FontFamilyService
{
private const string FontsBasePath = "avares://LanMountainDesktop/Assets/Fonts";
public static readonly FontFamily DefaultFontFamily =
new($"{FontsBasePath}#MiSans");
public static readonly FontFamily JapaneseFontFamily =
new($"{FontsBasePath}#MiSans");
public static readonly FontFamily KoreanFontFamily =
new($"Malgun Gothic, {FontsBasePath}#MiSans");
public FontFamily GetFontFamilyForLanguage(string? languageCode)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
return DefaultFontFamily;
}
return languageCode.ToLowerInvariant() switch
{
"ja-jp" or "ja" => JapaneseFontFamily,
"ko-kr" or "ko" => KoreanFontFamily,
_ => DefaultFontFamily
};
}
public string GetFontFamilyResourceKey(string? languageCode)
{
if (string.IsNullOrWhiteSpace(languageCode))
{
return "AppFontFamily";
}
return languageCode.ToLowerInvariant() switch
{
"ja-jp" or "ja" => "AppFontFamilyJP",
"ko-kr" or "ko" => "AppFontFamilyKR",
_ => "AppFontFamily"
};
}
}

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +15,8 @@ namespace LanMountainDesktop.Services;
public sealed record GitHubReleaseAsset(
string Name,
string BrowserDownloadUrl,
long SizeBytes);
long SizeBytes,
string? Sha256 = null);
public sealed record GitHubReleaseInfo(
string TagName,
@@ -31,12 +33,16 @@ public sealed record UpdateCheckResult(
string LatestVersionText,
GitHubReleaseInfo? Release,
GitHubReleaseAsset? PreferredAsset,
string? ErrorMessage);
string? ErrorMessage,
bool ForceMode = false);
public sealed record UpdateDownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage);
string? ErrorMessage,
bool HashVerified = false,
string? ExpectedHash = null,
string? ActualHash = null);
public sealed class GitHubReleaseUpdateService : IDisposable
{
@@ -101,7 +107,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3);
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
@@ -135,7 +142,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? parsedTagVersion.ToString(3)
? FormatVersionText(parsedTagVersion)
: release.TagName;
var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion;
@@ -169,6 +176,81 @@ public sealed class GitHubReleaseUpdateService : IDisposable
}
}
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
var normalizedCurrentVersion = NormalizeVersion(currentVersion);
var normalizedCurrentVersionText = FormatVersionText(normalizedCurrentVersion);
if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo))
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "Repository information is not configured.",
ForceMode: true);
}
try
{
var release = includePrerelease
? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken)
: await GetLatestStableReleaseAsync(cancellationToken);
if (release is null)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: "No release data was returned from GitHub.",
ForceMode: true);
}
var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion);
var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null
? FormatVersionText(parsedTagVersion)
: release.TagName;
var preferredAsset = SelectPreferredInstallerAsset(release.Assets);
return new UpdateCheckResult(
Success: true,
IsUpdateAvailable: true,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: latestVersionText,
Release: release,
PreferredAsset: preferredAsset,
ErrorMessage: null,
ForceMode: true);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateCheckResult(
Success: false,
IsUpdateAvailable: false,
CurrentVersionText: normalizedCurrentVersionText,
LatestVersionText: "-",
Release: null,
PreferredAsset: null,
ErrorMessage: ex.Message,
ForceMode: true);
}
}
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -206,9 +288,128 @@ public sealed class GitHubReleaseUpdateService : IDisposable
progressAdapter,
cancellationToken);
return result.Success
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
: new UpdateDownloadResult(false, null, result.ErrorMessage);
if (!result.Success)
{
return new UpdateDownloadResult(false, null, result.ErrorMessage);
}
var filePath = result.FilePath ?? destinationFilePath;
var (hashVerified, actualHash) = await VerifyFileHashAsync(filePath, asset.Sha256, cancellationToken);
if (!string.IsNullOrEmpty(asset.Sha256) && !hashVerified)
{
return new UpdateDownloadResult(
false,
filePath,
$"Hash verification failed. Expected: {asset.Sha256}, Actual: {actualHash}",
false,
asset.Sha256,
actualHash);
}
return new UpdateDownloadResult(true, filePath, null, hashVerified, asset.Sha256, actualHash);
}
public async Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (File.Exists(destinationFilePath))
{
try
{
File.Delete(destinationFilePath);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete existing file for redownload: {destinationFilePath}", ex);
}
}
var partFile = destinationFilePath + ".part";
if (File.Exists(partFile))
{
try
{
File.Delete(partFile);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete part file for redownload: {partFile}", ex);
}
}
var packageFile = destinationFilePath + ".download";
if (File.Exists(packageFile))
{
try
{
File.Delete(packageFile);
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to delete package file for redownload: {packageFile}", ex);
}
}
return await DownloadAssetAsync(asset, destinationFilePath, downloadSource, maxParallelSegments, progress, cancellationToken);
}
public static async Task<(bool Success, string? Hash)> VerifyFileHashAsync(
string filePath,
string? expectedHash,
CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
return (false, null);
}
if (string.IsNullOrWhiteSpace(expectedHash))
{
var computedHash = await ComputeFileSha256Async(filePath, cancellationToken);
return (true, computedHash);
}
var actualHash = await ComputeFileSha256Async(filePath, cancellationToken);
var verified = string.Equals(
expectedHash?.Trim().ToLowerInvariant(),
actualHash?.Trim().ToLowerInvariant(),
StringComparison.OrdinalIgnoreCase);
return (verified, actualHash);
}
public static async Task<string?> ComputeFileSha256Async(string filePath, CancellationToken cancellationToken = default)
{
if (!File.Exists(filePath))
{
return null;
}
try
{
using var stream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
81920,
FileOptions.Asynchronous | FileOptions.SequentialScan);
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(stream, cancellationToken);
return Convert.ToHexString(hashBytes).ToLowerInvariant();
}
catch (Exception ex)
{
AppLogger.Warn("Update", $"Failed to compute SHA256 for file: {filePath}", ex);
return null;
}
}
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
@@ -343,13 +544,102 @@ public sealed class GitHubReleaseUpdateService : IDisposable
continue;
}
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes));
assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes, null));
}
}
var sha256Map = BuildSha256MapFromAssets(assets, element);
if (sha256Map.Count > 0)
{
assets = assets.Select(a =>
sha256Map.TryGetValue(a.Name, out var hash)
? a with { Sha256 = hash }
: a).ToList();
}
return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets);
}
private static Dictionary<string, string> BuildSha256MapFromAssets(List<GitHubReleaseAsset> assets, JsonElement releaseElement)
{
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var asset in assets)
{
if (asset.Name.EndsWith(".sha256", StringComparison.OrdinalIgnoreCase) ||
asset.Name.EndsWith(".sha256sum", StringComparison.OrdinalIgnoreCase))
{
var baseName = asset.Name[..asset.Name.LastIndexOf('.')];
var targetAsset = assets.FirstOrDefault(a =>
a.Name.Equals(baseName, StringComparison.OrdinalIgnoreCase) ||
a.Name.StartsWith(baseName + ".", StringComparison.OrdinalIgnoreCase));
if (targetAsset is not null && !map.ContainsKey(targetAsset.Name))
{
map[targetAsset.Name] = asset.BrowserDownloadUrl;
}
}
}
if (releaseElement.TryGetProperty("body", out var bodyNode) &&
bodyNode.ValueKind == JsonValueKind.String)
{
var body = bodyNode.GetString() ?? string.Empty;
ParseSha256FromBody(body, assets, map);
}
return map;
}
private static void ParseSha256FromBody(string body, List<GitHubReleaseAsset> assets, Dictionary<string, string> map)
{
var lines = body.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
var trimmedLine = line.Trim();
if (string.IsNullOrEmpty(trimmedLine) || trimmedLine.StartsWith("#"))
{
continue;
}
var parts = trimmedLine.Split([' ', '\t'], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length >= 2)
{
var hash = parts[0];
var fileName = parts[1];
if (hash.Length == 64 && IsHexString(hash))
{
foreach (var asset in assets)
{
if (asset.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase) ||
fileName.Equals("*" + asset.Name, StringComparison.OrdinalIgnoreCase))
{
if (!map.ContainsKey(asset.Name))
{
map[asset.Name] = hash.ToLowerInvariant();
}
break;
}
}
}
}
}
}
private static bool IsHexString(string value)
{
foreach (var c in value)
{
if (!Uri.IsHexDigit(c))
{
return false;
}
}
return true;
}
private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList<GitHubReleaseAsset> assets)
{
if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows())
@@ -452,8 +742,18 @@ public sealed class GitHubReleaseUpdateService : IDisposable
{
var major = Math.Max(0, version.Major);
var minor = Math.Max(0, version.Minor);
var build = Math.Max(0, version.Build);
return new Version(major, minor, build);
var build = Math.Max(0, version.Build >= 0 ? version.Build : 0);
var revision = Math.Max(0, version.Revision >= 0 ? version.Revision : 0);
return revision > 0
? new Version(major, minor, build, revision)
: new Version(major, minor, build);
}
private static string FormatVersionText(Version version)
{
return version.Revision > 0
? version.ToString(4)
: version.ToString(3);
}
private static string Truncate(string value, int maxLength)

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media;
namespace LanMountainDesktop.Services;
public interface IComponentPreviewImageService
{
ComponentPreviewImageEntry GetOrCreateEntry(ComponentPreviewKey key, string? visualSignature = null);
bool TryGetEntry(ComponentPreviewKey key, out ComponentPreviewImageEntry? entry);
IReadOnlyCollection<ComponentPreviewImageEntry> GetEntriesSnapshot();
Task<ComponentPreviewImageEntry> QueueGenerationAsync(
ComponentPreviewKey key,
string visualSignature,
Func<CancellationToken, Task<IImage?>> generationWork,
CancellationToken cancellationToken = default);
ComponentPreviewImageEntry Store(ComponentPreviewKey key, IImage bitmap, string visualSignature);
ComponentPreviewImageEntry StoreFailure(ComponentPreviewKey key, string visualSignature, string? errorMessage = null);
bool Invalidate(ComponentPreviewKey key, string? visualSignature = null);
int RemovePlacementPreviews(string placementId);
int InvalidateVisualSignature(string visualSignature);
}

View File

@@ -52,6 +52,35 @@ public sealed record ExchangeRateQuery(
string? TargetCurrency = null,
bool ForceRefresh = false);
public sealed record ZhiJiaoHubQuery(
string? Source = null,
int? ImageIndex = null,
bool ForceRefresh = false,
string? MirrorSource = null);
public sealed record ZhiJiaoHubImageItem(
string Name,
string Url,
int Index);
public sealed record ZhiJiaoHubSnapshot(
IReadOnlyList<ZhiJiaoHubImageItem> Images,
int CurrentIndex,
string Source);
public sealed record ZhiJiaoHubHybridImageItem(
string Name,
string RemoteUrl,
string? LocalPath,
int Index,
bool IsCached);
public sealed record ZhiJiaoHubHybridSnapshot(
IReadOnlyList<ZhiJiaoHubHybridImageItem> Images,
string Source,
int CachedCount,
int TotalCount);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -285,6 +314,14 @@ public sealed record RecommendationApiOptions
public int DefaultBaiduHotSearchCount { get; init; } = 4;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
public string ClassIslandHubApiUrl { get; init; } = "https://api.github.com/repos/ClassIsland/classisland-hub/contents/images";
public string SectlHubApiUrl { get; init; } = "https://api.github.com/repos/SECTL/SECTL-hub/contents/images";
public string ClassIslandHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/ClassIsland/classisland-hub/main/images/{0}";
public string SectlHubRawUrlTemplate { get; init; } = "https://raw.githubusercontent.com/SECTL/SECTL-hub/main/images/{0}";
}
public interface IRecommendationInfoService
@@ -325,5 +362,37 @@ public interface IRecommendationInfoService
ExchangeRateQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default);
Task<string?> DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default);
Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default);
Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default);
ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source);
bool HasZhiJiaoHubLocalCache(string source);
void ClearCache();
}

View File

@@ -36,9 +36,18 @@ public sealed class LocalizationService
public string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en-US"
: "zh-CN";
if (string.IsNullOrWhiteSpace(languageCode))
{
return "zh-CN";
}
return languageCode.ToLowerInvariant() switch
{
"en-us" or "en" => "en-US",
"ja-jp" or "ja" => "ja-JP",
"ko-kr" or "ko" => "ko-KR",
_ => "zh-CN"
};
}
public string GetString(string languageCode, string key, string fallback)

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

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -53,6 +53,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
Dictionary<string, decimal> Rates,
DateTimeOffset ExpireAt,
DateTimeOffset FetchedAt);
private sealed record ZhiJiaoHubCacheEntry(ZhiJiaoHubSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
string Title,
string? Artist,
@@ -80,6 +81,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ExchangeRateTableCacheEntry> _exchangeRateCacheByBaseCurrency =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ZhiJiaoHubCacheEntry> _zhiJiaoHubCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
private int _dailyNewsRotationCursor;
static RecommendationDataService()
@@ -94,7 +97,15 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_options = options ?? new RecommendationApiOptions();
if (httpClient is null)
{
_httpClient = new HttpClient
// 配置 HttpClientHandler 以支持所有 TLS 版本
var handler = new HttpClientHandler
{
SslProtocols = System.Security.Authentication.SslProtocols.Tls12 |
System.Security.Authentication.SslProtocols.Tls13,
ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true
};
_httpClient = new HttpClient(handler)
{
Timeout = _options.RequestTimeout
};
@@ -128,6 +139,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
_dailyWordCache = null;
_stcn24ForumPostsCacheBySource.Clear();
_exchangeRateCacheByBaseCurrency.Clear();
_zhiJiaoHubCacheBySource.Clear();
}
}
@@ -3194,4 +3206,368 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
? text
: $"{text[..maxLength]}...";
}
// 智教Hub相关方法
public async Task<RecommendationQueryResult<ZhiJiaoHubSnapshot>> GetZhiJiaoHubImagesAsync(
ZhiJiaoHubQuery query,
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new ZhiJiaoHubQuery();
var source = ZhiJiaoHubSources.Normalize(normalizedQuery.Source);
var mirrorSource = ZhiJiaoHubMirrorSources.Normalize(normalizedQuery.MirrorSource);
var cacheKey = $"{source}|{mirrorSource}";
if (!normalizedQuery.ForceRefresh && TryGetZhiJiaoHubFromCache(cacheKey, out var cached))
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(cached);
}
try
{
var snapshot = await FetchZhiJiaoHubSnapshotAsync(source, mirrorSource, cancellationToken);
SetZhiJiaoHubCache(cacheKey, snapshot);
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<ZhiJiaoHubSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private async Task<ZhiJiaoHubSnapshot> FetchZhiJiaoHubSnapshotAsync(string source, string mirrorSource, CancellationToken cancellationToken)
{
var (owner, repo, path) = source switch
{
ZhiJiaoHubSources.Sectl => ("SECTL", "SECTL-hub", "docs/.vuepress/public/images"),
_ => ("ClassIsland", "classisland-hub", "images")
};
var contentsUrl = $"https://api.github.com/repos/{owner}/{repo}/contents/{path}";
// 如果使用镜像加速,代理 GitHub API 请求
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
contentsUrl = ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + contentsUrl;
}
try
{
var images = await FetchImagesFromContentsApi(owner, repo, path, contentsUrl, mirrorSource, cancellationToken);
if (images.Count == 0)
{
throw new InvalidOperationException("未找到图片文件");
}
// 随机打乱图片顺序
var random = new Random();
var shuffled = images.OrderBy(_ => random.Next()).ToList();
// 重新设置索引
for (int i = 0; i < shuffled.Count; i++)
{
var item = shuffled[i];
shuffled[i] = item with { Index = i };
}
return new ZhiJiaoHubSnapshot(shuffled, 0, source);
}
catch (HttpRequestException ex) when (ex.Message.Contains("403") || ex.Message.Contains("rate limit"))
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
catch (Exception ex)
{
throw new HttpRequestException($"获取图片列表失败: {ex.Message}");
}
}
private async Task<List<ZhiJiaoHubImageItem>> FetchImagesFromContentsApi(string owner, string repo, string path, string contentsUrl, string mirrorSource, CancellationToken cancellationToken)
{
var images = new List<ZhiJiaoHubImageItem>();
using var request = new HttpRequestMessage(HttpMethod.Get, contentsUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "LanMountainDesktop/1.0");
request.Headers.TryAddWithoutValidation("Accept", "application/vnd.github+json");
request.Headers.TryAddWithoutValidation("X-GitHub-Api-Version", "2022-11-28");
using var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
var errorText = await response.Content.ReadAsStringAsync(cancellationToken);
if ((int)response.StatusCode == 403)
{
throw new HttpRequestException("GitHub API 速率限制,请稍后重试");
}
throw new HttpRequestException($"API 返回错误: {(int)response.StatusCode} - {Truncate(errorText, 200)}");
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Array)
{
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("message", out var messageNode))
{
var errorMessage = messageNode.GetString();
throw new InvalidOperationException($"GitHub API 错误: {errorMessage}");
}
throw new InvalidOperationException("Invalid response format from GitHub API.");
}
int index = 0;
foreach (var item in root.EnumerateArray())
{
var type = ReadString(item, "type");
if (type != "file")
{
continue;
}
var name = ReadString(item, "name");
var downloadUrl = ReadString(item, "download_url");
if (string.IsNullOrWhiteSpace(name))
{
continue;
}
// 只处理图片文件
var extension = Path.GetExtension(name).ToLowerInvariant();
if (extension != ".png" && extension != ".jpg" && extension != ".jpeg" && extension != ".gif" && extension != ".webp")
{
continue;
}
// 解码文件名
var decodedName = Uri.UnescapeDataString(name);
decodedName = Path.GetFileNameWithoutExtension(decodedName);
// 构造图片 URL
string imageUrl;
if (!string.IsNullOrWhiteSpace(downloadUrl))
{
imageUrl = downloadUrl;
}
else
{
imageUrl = $"https://raw.githubusercontent.com/{owner}/{repo}/main/{path}/{Uri.EscapeDataString(name)}";
}
// 应用镜像加速到图片 URL
imageUrl = ZhiJiaoHubMirrorSources.ApplyMirror(imageUrl, mirrorSource);
images.Add(new ZhiJiaoHubImageItem(decodedName, imageUrl, index));
index++;
}
return images;
}
private bool TryGetZhiJiaoHubFromCache(string cacheKey, out ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
if (_zhiJiaoHubCacheBySource.TryGetValue(cacheKey, out var cacheEntry) &&
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = cacheEntry.Snapshot;
return true;
}
}
snapshot = null!;
return false;
}
private void SetZhiJiaoHubCache(string cacheKey, ZhiJiaoHubSnapshot snapshot)
{
lock (_cacheGate)
{
// 使用较长的缓存时间1小时因为图片列表不常变化
_zhiJiaoHubCacheBySource[cacheKey] = new ZhiJiaoHubCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(TimeSpan.FromHours(1)));
}
}
private readonly ZhiJiaoHubCacheService _zhiJiaoHubCacheService = new();
public async Task<ZhiJiaoHubSyncResult> SyncZhiJiaoHubImagesAsync(
string source,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return new ZhiJiaoHubSyncResult(
false,
null,
0,
0,
0,
result.ErrorMessage ?? "Failed to fetch image list");
}
return await _zhiJiaoHubCacheService.SyncImagesAsync(
normalizedSource,
result.Data.Images,
normalizedMirror,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, ex.Message);
}
}
public ZhiJiaoHubLocalSnapshot? LoadZhiJiaoHubLocalSnapshot(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.LoadLocalSnapshot(normalizedSource);
}
public bool HasZhiJiaoHubLocalCache(string source)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
return _zhiJiaoHubCacheService.HasLocalCache(normalizedSource);
}
public async Task<RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>> GetZhiJiaoHubHybridImagesAsync(
string source,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
var localPathMap = _zhiJiaoHubCacheService.LoadLocalPathMap(normalizedSource);
try
{
var query = new ZhiJiaoHubQuery(normalizedSource, ForceRefresh: true, MirrorSource: normalizedMirror);
var result = await GetZhiJiaoHubImagesAsync(query, cancellationToken);
if (!result.Success || result.Data == null)
{
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Fail(
result.ErrorCode ?? "upstream_error",
result.ErrorMessage ?? "Failed to fetch image list");
}
var hybridImages = result.Data.Images.Select((img, idx) =>
{
var hasLocal = localPathMap.TryGetValue(img.Url, out var localPath);
return new ZhiJiaoHubHybridImageItem(
img.Name,
img.Url,
hasLocal ? localPath : null,
idx,
hasLocal);
}).ToList();
var snapshot = new ZhiJiaoHubHybridSnapshot(
hybridImages,
normalizedSource,
hybridImages.Count(i => i.IsCached),
hybridImages.Count);
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<ZhiJiaoHubHybridSnapshot>.Fail("upstream_network_error", ex.Message);
}
}
public async Task<string?> DownloadAndCacheImageAsync(
string source,
ZhiJiaoHubImageItem image,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.Url,
normalizedMirror,
cancellationToken);
}
public Task StartBackgroundDownloadAsync(
string source,
IReadOnlyList<ZhiJiaoHubHybridImageItem> images,
string mirrorSource,
Action<int, int, string>? onProgress = null,
CancellationToken cancellationToken = default)
{
var normalizedSource = ZhiJiaoHubSources.Normalize(source);
var normalizedMirror = ZhiJiaoHubMirrorSources.Normalize(mirrorSource);
return Task.Run(async () =>
{
var uncachedImages = images.Where(i => !i.IsCached).ToList();
var total = uncachedImages.Count;
var downloaded = 0;
foreach (var image in uncachedImages)
{
if (cancellationToken.IsCancellationRequested)
{
break;
}
try
{
var localPath = await _zhiJiaoHubCacheService.DownloadAndSaveImageAsync(
normalizedSource,
image.Name,
image.RemoteUrl,
normalizedMirror,
cancellationToken);
if (localPath != null)
{
downloaded++;
}
onProgress?.Invoke(downloaded, total, image.Name);
}
catch
{
}
}
}, cancellationToken);
}
}

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

View File

@@ -1,13 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Settings.Core;
namespace LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services.Settings
{
public enum WallpaperMediaType
{
@@ -16,7 +19,13 @@ public enum WallpaperMediaType
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement, string? CustomColor = null);
public sealed record WallpaperSettingsState(
string? WallpaperPath,
string Type,
string? Color,
string Placement,
string? CustomColor = null,
int SystemWallpaperRefreshIntervalSeconds = 300);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
string? ThemeColor,
@@ -58,44 +67,173 @@ public sealed record UpdateSettingsState(
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs);
long? LastUpdateCheckUtcMs,
string? PendingUpdateSha256);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public sealed record PluginMarketDependencyInfo(
public enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
public sealed record PluginCatalogSourceInfo(
string Id,
string Name,
string? Description,
string? SourceUrl,
string? CachePath,
bool IsOfficial,
int Priority);
public sealed record PluginCatalogSharedContractInfo(
string Id,
string Version,
string AssemblyName);
public sealed record PluginMarketPluginInfo(
public sealed record PluginCapabilityInfo(
string Id,
string? Version,
string? AssemblyName);
public sealed record PluginPackageSourceInfo(
PluginPackageSourceKind Kind,
string Url,
string Sha256,
long PackageSizeBytes);
public sealed record PluginCatalogManifestInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string EntranceAssembly,
IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts);
public sealed record PluginCatalogCompatibilityInfo(
string MinHostVersion,
string DownloadUrl,
string ReleaseTag,
string ReleaseAssetName,
string ApiVersion);
public sealed record PluginCatalogRepositoryInfo(
string IconUrl,
string ProjectUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
string ReleaseNotes);
public sealed record PluginCatalogPublicationInfo(
string ReleaseTag,
string ReleaseAssetName,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
public sealed record PluginMarketIndexResult(
DateTimeOffset UpdatedAt,
long PackageSizeBytes,
string Sha256,
string? Md5);
public sealed record PluginCatalogItemInfo(
PluginCatalogManifestInfo Manifest,
PluginCatalogCompatibilityInfo Compatibility,
PluginCatalogRepositoryInfo Repository,
PluginCatalogPublicationInfo Publication,
IReadOnlyList<PluginPackageSourceInfo> PackageSources,
IReadOnlyList<PluginCapabilityInfo> Capabilities)
{
public string Id => Manifest.Id;
public string Name => Manifest.Name;
public string Description => Manifest.Description;
public string Author => Manifest.Author;
public string Version => Manifest.Version;
public string ApiVersion => Manifest.ApiVersion;
public string MinHostVersion => Compatibility.MinHostVersion;
public string DownloadUrl => PackageSources.FirstOrDefault()?.Url ?? string.Empty;
public string Sha256 => Publication.Sha256;
public long PackageSizeBytes => Publication.PackageSizeBytes;
public string IconUrl => Repository.IconUrl;
public string ProjectUrl => Repository.ProjectUrl;
public string ReadmeUrl => Repository.ReadmeUrl;
public string HomepageUrl => Repository.HomepageUrl;
public string RepositoryUrl => Repository.RepositoryUrl;
public IReadOnlyList<string> Tags => Repository.Tags;
public IReadOnlyList<PluginCatalogSharedContractInfo> SharedContracts => Manifest.SharedContracts;
public DateTimeOffset PublishedAt => Publication.PublishedAt;
public DateTimeOffset UpdatedAt => Publication.UpdatedAt;
public string ReleaseTag => Publication.ReleaseTag;
public string ReleaseAssetName => Publication.ReleaseAssetName;
public string ReleaseNotes => Repository.ReleaseNotes;
}
public sealed record PluginCatalogIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
IReadOnlyList<PluginCatalogItemInfo> Plugins,
IReadOnlyList<PluginCatalogSourceInfo> Sources,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
public sealed record PluginMarketInstallResult(
public sealed record PluginInstallDiagnostic(
string Code,
string Message,
string? Details = null);
public sealed record PluginCatalogInstallResult(
bool Success,
string? PluginId,
string? PluginName,
PluginManifest? InstalledManifest,
IReadOnlyList<PluginInstallDiagnostic> Diagnostics,
string? ErrorMessage);
public interface IPluginCatalogSourceProvider
{
Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
}
public interface IPluginCatalogService : IPluginCatalogSourceProvider
{
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IPackageSourceResolver
{
IReadOnlyList<PluginPackageSourceInfo> ResolveSources(PluginCatalogItemInfo item);
}
public interface IPluginCompatibilityEvaluator
{
PluginInstallDiagnostic? Evaluate(PluginCatalogItemInfo item, Version? hostVersion);
}
public interface IPluginInstallOrchestrator
{
Task<PluginCatalogInstallResult> InstallAsync(PluginCatalogItemInfo item, CancellationToken cancellationToken = default);
}
public interface IGridSettingsService
{
GridSettingsState Get();
@@ -188,6 +326,7 @@ public interface IUpdateSettingsService
UpdateSettingsState Get();
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateCheckResult> ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -195,6 +334,13 @@ public interface IUpdateSettingsService
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
}
public interface ILauncherCatalogService
@@ -217,10 +363,10 @@ public interface IPluginManagementSettingsService
bool DeleteInstalledPlugin(string pluginId);
}
public interface IPluginMarketSettingsService
public interface IPluginCatalogSettingsService : IPluginCatalogSourceProvider
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
new Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default);
Task<PluginCatalogInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
@@ -246,6 +392,18 @@ public interface ISettingsFacadeService
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginMarketSettingsService PluginMarket { get; }
IPluginCatalogSettingsService PluginCatalog { get; }
IApplicationInfoService ApplicationInfo { get; }
}
}
namespace LanMountainDesktop.Services.PluginMarket
{
internal enum PluginPackageSourceKind
{
ReleaseAsset = 0,
RawFallback = 1,
WorkspaceLocal = 2
}
}

View File

@@ -101,7 +101,9 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
: snapshot.WallpaperPath,
normalizedType,
snapshot.WallpaperColor,
snapshot.WallpaperPlacement);
snapshot.WallpaperPlacement,
CustomColor: null,
SystemWallpaperRefreshIntervalSeconds: NormalizeRefreshInterval(snapshot.SystemWallpaperRefreshIntervalSeconds));
}
public void Save(WallpaperSettingsState state)
@@ -128,6 +130,7 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
snapshot.WallpaperPlacement = string.IsNullOrWhiteSpace(state.Placement)
? "Fill"
: state.Placement.Trim();
snapshot.SystemWallpaperRefreshIntervalSeconds = NormalizeRefreshInterval(state.SystemWallpaperRefreshIntervalSeconds);
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
@@ -136,9 +139,21 @@ internal sealed class WallpaperSettingsService : IWallpaperSettingsService
nameof(AppSettingsSnapshot.WallpaperPath),
nameof(AppSettingsSnapshot.WallpaperType),
nameof(AppSettingsSnapshot.WallpaperColor),
nameof(AppSettingsSnapshot.WallpaperPlacement)
nameof(AppSettingsSnapshot.WallpaperPlacement),
nameof(AppSettingsSnapshot.SystemWallpaperRefreshIntervalSeconds)
]);
}
private static int NormalizeRefreshInterval(int seconds)
{
return seconds switch
{
<= 0 => 300,
< 30 => 30,
> 86400 => 86400,
_ => seconds
};
}
}
internal sealed class WallpaperMediaService : IWallpaperMediaService
@@ -609,17 +624,32 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
public void Save(PrivacySettingsState state)
{
var snapshot = _settingsService.Load();
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
var changedKeys = new List<string>();
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(
SettingsScope.App,
snapshot,
changedKeys:
[
nameof(AppSettingsSnapshot.UploadAnonymousCrashData),
nameof(AppSettingsSnapshot.UploadAnonymousUsageData)
]);
changedKeys: changedKeys);
}
}
@@ -648,7 +678,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.PendingUpdateInstallerPath,
snapshot.PendingUpdateVersion,
snapshot.PendingUpdatePublishedAtUtcMs,
snapshot.LastUpdateCheckUtcMs);
snapshot.LastUpdateCheckUtcMs,
snapshot.PendingUpdateSha256);
}
public void Save(UpdateSettingsState state)
@@ -677,6 +708,9 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
snapshot.LastUpdateCheckUtcMs = state.LastUpdateCheckUtcMs is > 0
? state.LastUpdateCheckUtcMs
: null;
snapshot.PendingUpdateSha256 = string.IsNullOrWhiteSpace(state.PendingUpdateSha256)
? null
: state.PendingUpdateSha256.Trim().ToLowerInvariant();
_settingsService.SaveSnapshot(
SettingsScope.App,
snapshot,
@@ -691,7 +725,8 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
nameof(AppSettingsSnapshot.PendingUpdateInstallerPath),
nameof(AppSettingsSnapshot.PendingUpdateVersion),
nameof(AppSettingsSnapshot.PendingUpdatePublishedAtUtcMs),
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs)
nameof(AppSettingsSnapshot.LastUpdateCheckUtcMs),
nameof(AppSettingsSnapshot.PendingUpdateSha256)
]);
}
@@ -703,6 +738,14 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
return _releaseUpdateService.CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
bool includePrerelease,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.ForceCheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken);
}
public Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
@@ -720,6 +763,23 @@ internal sealed class UpdateSettingsService : IUpdateSettingsService, IDisposabl
cancellationToken);
}
public Task<UpdateDownloadResult> RedownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
return _releaseUpdateService.RedownloadAssetAsync(
asset,
destinationFilePath,
downloadSource,
maxParallelSegments,
progress,
cancellationToken);
}
public void Dispose()
{
_releaseUpdateService.Dispose();
@@ -799,14 +859,14 @@ internal sealed class PluginManagementSettingsService : IPluginManagementSetting
}
}
internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService, IDisposable
internal sealed class PluginCatalogSettingsService : IPluginCatalogSettingsService, IDisposable
{
private PluginRuntimeService? _pluginRuntimeService;
private AirAppMarketIndexService _indexService;
private AirAppMarketInstallService? _installService;
private readonly Dictionary<string, AirAppMarketPluginEntry> _cachedPlugins = new(StringComparer.OrdinalIgnoreCase);
public PluginMarketSettingsService(PluginRuntimeService? pluginRuntimeService)
public PluginCatalogSettingsService(PluginRuntimeService? pluginRuntimeService)
{
_pluginRuntimeService = pluginRuntimeService;
@@ -840,14 +900,29 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
_installService = new AirAppMarketInstallService(_pluginRuntimeService, dataRoot);
}
public async Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default)
public Task<PluginCatalogIndexResult> LoadCatalogAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken);
return LoadCatalogCoreAsync(cancellationToken);
}
public Task<PluginCatalogInstallResult> InstallAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
return InstallCatalogCoreAsync(pluginId, cancellationToken);
}
private async Task<PluginCatalogIndexResult> LoadCatalogCoreAsync(CancellationToken cancellationToken = default)
{
var result = await _indexService.LoadAsync(cancellationToken).ConfigureAwait(false);
var sources = BuildCatalogSources(result.Source?.ToString(), result.SourceLocation, result.WarningMessage);
if (!result.Success || result.Document is null)
{
return new PluginMarketIndexResult(
_cachedPlugins.Clear();
return new PluginCatalogIndexResult(
false,
[],
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
@@ -859,81 +934,191 @@ internal sealed class PluginMarketSettingsService : IPluginMarketSettingsService
.Select(entry =>
{
_cachedPlugins[entry.Id] = entry;
return new PluginMarketPluginInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
entry.MinHostVersion,
entry.DownloadUrl,
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.IconUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags,
entry.SharedContracts
.Select(contract => new PluginMarketDependencyInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray(),
entry.PublishedAt,
entry.UpdatedAt);
return MapCatalogItem(entry);
})
.ToArray();
return new PluginMarketIndexResult(
return new PluginCatalogIndexResult(
true,
plugins,
sources,
result.Source?.ToString(),
result.SourceLocation,
result.WarningMessage,
null);
}
public async Task<PluginMarketInstallResult> InstallAsync(
private async Task<PluginCatalogInstallResult> InstallCatalogCoreAsync(
string pluginId,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return new PluginMarketInstallResult(false, null, null, "Plugin id is required.");
return new PluginCatalogInstallResult(
false,
null,
null,
null,
[new PluginInstallDiagnostic("invalid_request", "Plugin id is required.")],
"Plugin id is required.");
}
if (_installService is null || _pluginRuntimeService is null)
{
return new PluginMarketInstallResult(
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("runtime_unavailable", "Plugin runtime is unavailable.")],
"Plugin runtime is unavailable.");
}
if (!_cachedPlugins.TryGetValue(pluginId, out var entry))
{
var load = await LoadIndexAsync(cancellationToken);
var load = await LoadCatalogCoreAsync(cancellationToken).ConfigureAwait(false);
if (!load.Success)
{
return new PluginMarketInstallResult(false, pluginId, null, load.ErrorMessage);
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("catalog_load_failed", load.ErrorMessage ?? "Failed to load the plugin catalog.")],
load.ErrorMessage);
}
if (!_cachedPlugins.TryGetValue(pluginId, out entry))
{
return new PluginMarketInstallResult(false, pluginId, null, "Plugin was not found in market index.");
return new PluginCatalogInstallResult(
false,
pluginId,
null,
null,
[new PluginInstallDiagnostic("not_found", "Plugin was not found in the official catalog.")],
"Plugin was not found in the official catalog.");
}
}
var result = await _installService.InstallAsync(entry, cancellationToken);
var result = await _installService.InstallAsync(entry, cancellationToken).ConfigureAwait(false);
if (!result.Success)
{
return new PluginMarketInstallResult(false, entry.Id, entry.Name, result.ErrorMessage);
return new PluginCatalogInstallResult(
false,
entry.Id,
entry.Name,
null,
[new PluginInstallDiagnostic("install_failed", result.ErrorMessage ?? "Plugin install failed.")],
result.ErrorMessage);
}
return new PluginMarketInstallResult(true, result.Manifest?.Id ?? entry.Id, result.Manifest?.Name ?? entry.Name, null);
return new PluginCatalogInstallResult(
true,
result.Manifest?.Id ?? entry.Id,
result.Manifest?.Name ?? entry.Name,
result.Manifest,
[],
null);
}
private static PluginCatalogItemInfo MapCatalogItem(AirAppMarketPluginEntry entry)
{
var manifest = new PluginCatalogManifestInfo(
entry.Id,
entry.Name,
entry.Description,
entry.Author,
entry.Version,
entry.ApiVersion,
string.Empty,
entry.SharedContracts
.Select(contract => new PluginCatalogSharedContractInfo(
contract.Id,
contract.Version,
contract.AssemblyName))
.ToArray());
var compatibility = new PluginCatalogCompatibilityInfo(
entry.MinHostVersion,
entry.ApiVersion);
var repository = new PluginCatalogRepositoryInfo(
entry.IconUrl,
entry.ProjectUrl,
entry.ReadmeUrl,
entry.HomepageUrl,
entry.RepositoryUrl,
entry.Tags.ToArray(),
entry.ReleaseNotes);
var publication = new PluginCatalogPublicationInfo(
entry.ReleaseTag,
entry.ReleaseAssetName,
entry.PublishedAt,
entry.UpdatedAt,
entry.PackageSizeBytes,
entry.Sha256,
null);
var sources = BuildPackageSources(entry);
return new PluginCatalogItemInfo(
manifest,
compatibility,
repository,
publication,
sources,
[]);
}
private static IReadOnlyList<PluginPackageSourceInfo> BuildPackageSources(AirAppMarketPluginEntry entry)
{
var sources = entry.GetPackageSourcesInInstallOrder();
if (sources.Count == 0)
{
return [];
}
return sources
.Select(source => new PluginPackageSourceInfo(
source.SourceKind switch
{
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.ReleaseAsset => PluginPackageSourceKind.ReleaseAsset,
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.RawFallback => PluginPackageSourceKind.RawFallback,
LanMountainDesktop.Services.PluginMarket.PluginPackageSourceKind.WorkspaceLocal => PluginPackageSourceKind.WorkspaceLocal,
_ => PluginPackageSourceKind.RawFallback
},
source.Url,
entry.Sha256,
entry.PackageSizeBytes))
.ToArray();
}
private static IReadOnlyList<PluginCatalogSourceInfo> BuildCatalogSources(
string? sourceId,
string? sourceLocation,
string? warningMessage)
{
if (string.IsNullOrWhiteSpace(sourceId) && string.IsNullOrWhiteSpace(sourceLocation))
{
return [];
}
var normalizedSourceId = string.IsNullOrWhiteSpace(sourceId)
? "plugin-catalog"
: sourceId.Trim();
return
[
new PluginCatalogSourceInfo(
normalizedSourceId,
normalizedSourceId,
string.IsNullOrWhiteSpace(warningMessage) ? null : warningMessage.Trim(),
string.IsNullOrWhiteSpace(sourceLocation) ? null : sourceLocation.Trim(),
null,
true,
0)
];
}
public void Dispose()
@@ -1000,7 +1185,7 @@ internal sealed class ApplicationInfoService : IApplicationInfoService
internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposable
{
private readonly UpdateSettingsService _updateSettingsService;
private readonly PluginMarketSettingsService _pluginMarketSettingsService;
private readonly PluginCatalogSettingsService _pluginCatalogSettingsService;
private readonly PluginManagementSettingsService _pluginManagementSettingsService;
private readonly WeatherSettingsService _weatherSettingsService;
@@ -1023,8 +1208,8 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
LauncherPolicy = new LauncherPolicyService();
_pluginManagementSettingsService = new PluginManagementSettingsService(Settings, pluginRuntimeService);
PluginManagement = _pluginManagementSettingsService;
_pluginMarketSettingsService = new PluginMarketSettingsService(pluginRuntimeService);
PluginMarket = _pluginMarketSettingsService;
_pluginCatalogSettingsService = new PluginCatalogSettingsService(pluginRuntimeService);
PluginCatalog = _pluginCatalogSettingsService;
ApplicationInfo = new ApplicationInfoService();
}
@@ -1056,20 +1241,20 @@ internal sealed class SettingsFacadeService : ISettingsFacadeService, IDisposabl
public IPluginManagementSettingsService PluginManagement { get; }
public IPluginMarketSettingsService PluginMarket { get; }
public IPluginCatalogSettingsService PluginCatalog { get; }
public IApplicationInfoService ApplicationInfo { get; }
public void BindPluginRuntime(PluginRuntimeService? pluginRuntimeService)
{
_pluginManagementSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginMarketSettingsService.SetPluginRuntime(pluginRuntimeService);
_pluginCatalogSettingsService.SetPluginRuntime(pluginRuntimeService);
}
public void Dispose()
{
_weatherSettingsService.Dispose();
_updateSettingsService.Dispose();
_pluginMarketSettingsService.Dispose();
_pluginCatalogSettingsService.Dispose();
}
}

View File

@@ -0,0 +1,65 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Avalonia.Media.Imaging;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public interface ISystemWallpaperProvider
{
bool IsSupported { get; }
string? GetWallpaperPath();
event EventHandler? WallpaperChanged;
}
internal sealed class SystemWallpaperProvider : ISystemWallpaperProvider, IDisposable
{
public bool IsSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public event EventHandler? WallpaperChanged;
public string? GetWallpaperPath()
{
if (!IsSupported)
{
return null;
}
try
{
using var key = Registry.CurrentUser.OpenSubKey(@"Control Panel\Desktop");
var wallpaperPath = key?.GetValue("Wallpaper") as string;
if (string.IsNullOrWhiteSpace(wallpaperPath))
{
return null;
}
if (!File.Exists(wallpaperPath))
{
return null;
}
return wallpaperPath;
}
catch
{
return null;
}
}
public void Dispose()
{
}
}
public static class HostSystemWallpaperProvider
{
private static ISystemWallpaperProvider? _instance;
public static ISystemWallpaperProvider GetOrCreate()
{
return _instance ??= new SystemWallpaperProvider();
}
}

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

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

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

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

View File

@@ -13,7 +13,15 @@ namespace LanMountainDesktop.Services;
public sealed record UpdatePendingInfo(
string InstallerPath,
string VersionText,
DateTimeOffset? PublishedAt);
DateTimeOffset? PublishedAt,
string? Sha256 = null);
public sealed record UpdateVerifyResult(
bool Success,
bool HashMatched,
string? ExpectedHash,
string? ActualHash,
string? ErrorMessage);
public sealed record UpdateInstallerLaunchResult(
bool Success,
@@ -56,6 +64,7 @@ public sealed class UpdateWorkflowService
public async Task<UpdateCheckResult> CheckForUpdatesAsync(
Version currentVersion,
bool isForce = false,
CancellationToken cancellationToken = default)
{
var state = _settingsFacade.Update.Get();
@@ -64,10 +73,15 @@ public sealed class UpdateWorkflowService
UpdateSettingsValues.ChannelPreview,
StringComparison.OrdinalIgnoreCase);
var result = await _settingsFacade.Update.CheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken);
var result = isForce
? await _settingsFacade.Update.ForceCheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken)
: await _settingsFacade.Update.CheckForUpdatesAsync(
currentVersion,
includePrerelease,
cancellationToken);
SaveState(state with
{
@@ -77,6 +91,13 @@ public sealed class UpdateWorkflowService
return result;
}
public async Task<UpdateCheckResult> ForceCheckForUpdatesAsync(
Version currentVersion,
CancellationToken cancellationToken = default)
{
return await CheckForUpdatesAsync(currentVersion, true, cancellationToken);
}
public async Task<UpdateDownloadResult> DownloadReleaseAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
@@ -95,7 +116,13 @@ public sealed class UpdateWorkflowService
string.Equals(existingPending.VersionText, checkResult.LatestVersionText, StringComparison.OrdinalIgnoreCase) &&
File.Exists(existingPending.InstallerPath))
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null);
var verifyResult = await VerifyPendingUpdateAsync();
if (verifyResult.Success)
{
return new UpdateDownloadResult(true, existingPending.InstallerPath, null, verifyResult.HashMatched, verifyResult.ExpectedHash, verifyResult.ActualHash);
}
AppLogger.Warn("UpdateWorkflow", $"Existing installer hash verification failed, will redownload. Expected: {verifyResult.ExpectedHash}, Actual: {verifyResult.ActualHash}");
}
Directory.CreateDirectory(_updatesDirectory);
@@ -119,13 +146,111 @@ public sealed class UpdateWorkflowService
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
public async Task<UpdateDownloadResult> RedownloadReleaseAsync(
UpdateCheckResult checkResult,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(checkResult);
if (!checkResult.Success || !checkResult.IsUpdateAvailable || checkResult.Release is null || checkResult.PreferredAsset is null)
{
return new UpdateDownloadResult(false, null, "No compatible update asset is available.");
}
var state = _settingsFacade.Update.Get();
var existingPending = GetPendingUpdate(state);
if (existingPending is not null && File.Exists(existingPending.InstallerPath))
{
try
{
File.Delete(existingPending.InstallerPath);
AppLogger.Info("UpdateWorkflow", $"Deleted existing installer for redownload: {existingPending.InstallerPath}");
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", $"Failed to delete existing installer: {existingPending.InstallerPath}", ex);
}
}
ClearPendingUpdate();
Directory.CreateDirectory(_updatesDirectory);
var fileName = SanitizeFileName(checkResult.PreferredAsset.Name);
var destinationPath = Path.Combine(_updatesDirectory, fileName);
state = _settingsFacade.Update.Get();
var result = await _settingsFacade.Update.DownloadAssetAsync(
checkResult.PreferredAsset,
destinationPath,
state.UpdateDownloadSource,
state.UpdateDownloadThreads,
progress,
cancellationToken);
if (result.Success)
{
SaveState(state with
{
PendingUpdateInstallerPath = result.FilePath ?? destinationPath,
PendingUpdateVersion = checkResult.LatestVersionText,
PendingUpdatePublishedAtUtcMs = checkResult.Release.PublishedAt == DateTimeOffset.MinValue
? null
: checkResult.Release.PublishedAt.ToUnixTimeMilliseconds(),
LastUpdateCheckUtcMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
PendingUpdateSha256 = result.ActualHash
});
}
return result;
}
public async Task<UpdateVerifyResult> VerifyPendingUpdateAsync()
{
var state = _settingsFacade.Update.Get();
var pending = GetPendingUpdate(state);
if (pending is null)
{
return new UpdateVerifyResult(false, false, null, null, "No pending update available.");
}
if (!File.Exists(pending.InstallerPath))
{
return new UpdateVerifyResult(false, false, null, null, "Installer file does not exist.");
}
var expectedHash = pending.Sha256;
var actualHash = await GitHubReleaseUpdateService.ComputeFileSha256Async(pending.InstallerPath);
if (string.IsNullOrEmpty(expectedHash))
{
return new UpdateVerifyResult(true, true, null, actualHash, null);
}
var hashMatched = string.Equals(
expectedHash?.Trim().ToLowerInvariant(),
actualHash?.Trim().ToLowerInvariant(),
StringComparison.OrdinalIgnoreCase);
return new UpdateVerifyResult(
hashMatched,
hashMatched,
expectedHash,
actualHash,
hashMatched ? null : $"Hash mismatch. Expected: {expectedHash}, Actual: {actualHash}");
}
public async Task AutoCheckIfEnabledAsync(
Version currentVersion,
CancellationToken cancellationToken = default)
@@ -135,7 +260,7 @@ public sealed class UpdateWorkflowService
try
{
// Always check for updates on startup (removed AutoCheckUpdates check)
var result = await CheckForUpdatesAsync(currentVersion, cancellationToken);
var result = await CheckForUpdatesAsync(currentVersion, isForce: false, cancellationToken);
if (!result.Success || !result.IsUpdateAvailable || result.PreferredAsset is null)
{
return;
@@ -193,7 +318,8 @@ public sealed class UpdateWorkflowService
{
PendingUpdateInstallerPath = null,
PendingUpdateVersion = null,
PendingUpdatePublishedAtUtcMs = null
PendingUpdatePublishedAtUtcMs = null,
PendingUpdateSha256 = null
});
}
@@ -262,7 +388,8 @@ public sealed class UpdateWorkflowService
return new UpdatePendingInfo(
installerPath,
string.IsNullOrWhiteSpace(state.PendingUpdateVersion) ? Path.GetFileNameWithoutExtension(installerPath) : state.PendingUpdateVersion,
publishedAt);
publishedAt,
state.PendingUpdateSha256);
}
private void SaveState(UpdateSettingsState state)

View File

@@ -5,13 +5,13 @@ using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Services;
internal static class WallpaperImageBrushFactory
public static class WallpaperImageBrushFactory
{
internal const string Fill = "Fill";
internal const string Fit = "Fit";
internal const string StretchMode = "Stretch";
internal const string Center = "Center";
internal const string Tile = "Tile";
public const string Fill = "Fill";
public const string Fit = "Fit";
public const string StretchMode = "Stretch";
public const string Center = "Center";
public const string Tile = "Tile";
public static string NormalizePlacement(string? placement)
{

View File

@@ -0,0 +1,512 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Authentication;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed record ZhiJiaoHubLocalImageItem(
string Name,
string OriginalUrl,
string LocalPath,
int Index);
public sealed record ZhiJiaoHubLocalSnapshot(
IReadOnlyList<ZhiJiaoHubLocalImageItem> Images,
string Source,
DateTimeOffset LastUpdated,
int TotalCount);
public sealed record ZhiJiaoHubSyncResult(
bool Success,
ZhiJiaoHubLocalSnapshot? Snapshot,
int DownloadedCount,
int SkippedCount,
int FailedCount,
string? ErrorMessage = null);
public sealed class ZhiJiaoHubCacheService : IDisposable
{
private static readonly HttpClient DownloadClient;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
WriteIndented = true
};
private readonly string _cacheDirectory;
private readonly string _manifestPath;
private readonly object _manifestLock = new();
private bool _isDisposed;
static ZhiJiaoHubCacheService()
{
var handler = new HttpClientHandler
{
SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
};
DownloadClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30)
};
DownloadClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop/1.0");
}
public ZhiJiaoHubCacheService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var dataDirectory = Path.Combine(appData, "LanMountainDesktop", "cache", "zhijiaohub");
_cacheDirectory = dataDirectory;
_manifestPath = Path.Combine(dataDirectory, "manifest.json");
}
public string CacheDirectory => _cacheDirectory;
public bool HasLocalCache(string source)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return false;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
return manifest?.Entries?.ContainsKey(source) == true &&
manifest.Entries[source].Images.Count > 0 &&
Directory.Exists(GetSourceDirectory(source));
}
catch
{
return false;
}
}
}
public ZhiJiaoHubLocalSnapshot? LoadLocalSnapshot(string source)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return null;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return null;
}
var sourceDir = GetSourceDirectory(source);
var images = entry.Images
.Where(img => File.Exists(Path.Combine(sourceDir, img.LocalFileName)))
.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
img.Name,
img.OriginalUrl,
Path.Combine(sourceDir, img.LocalFileName),
idx))
.ToList();
if (images.Count == 0)
{
return null;
}
return new ZhiJiaoHubLocalSnapshot(
images,
source,
entry.LastUpdated,
images.Count);
}
catch
{
return null;
}
}
}
public Dictionary<string, string> LoadLocalPathMap(string source)
{
lock (_manifestLock)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (!File.Exists(_manifestPath))
{
return result;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return result;
}
var sourceDir = GetSourceDirectory(source);
foreach (var img in entry.Images)
{
var localPath = Path.Combine(sourceDir, img.LocalFileName);
if (File.Exists(localPath))
{
result[img.OriginalUrl] = localPath;
}
}
return result;
}
catch
{
return result;
}
}
}
public string? GetLocalPath(string source, string originalUrl)
{
lock (_manifestLock)
{
if (!File.Exists(_manifestPath))
{
return null;
}
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries?.TryGetValue(source, out var entry) != true)
{
return null;
}
var img = entry.Images.FirstOrDefault(i =>
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
if (img == null)
{
return null;
}
var sourceDir = GetSourceDirectory(source);
var localPath = Path.Combine(sourceDir, img.LocalFileName);
return File.Exists(localPath) ? localPath : null;
}
catch
{
return null;
}
}
}
public async Task<string?> DownloadAndSaveImageAsync(
string source,
string name,
string remoteUrl,
string mirrorSource,
CancellationToken cancellationToken = default)
{
var sourceDir = GetSourceDirectory(source);
Directory.CreateDirectory(sourceDir);
var fileName = GetSafeFileName(name, remoteUrl);
var localPath = Path.Combine(sourceDir, fileName);
if (File.Exists(localPath))
{
AddToManifest(source, name, remoteUrl, fileName);
return localPath;
}
try
{
var downloadUrl = ResolveDownloadUrl(remoteUrl, mirrorSource);
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(localPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
AddToManifest(source, name, remoteUrl, fileName);
return localPath;
}
catch
{
return null;
}
}
public async Task<ZhiJiaoHubSyncResult> SyncImagesAsync(
string source,
IReadOnlyList<ZhiJiaoHubImageItem> remoteImages,
string mirrorSource,
IProgress<(int Current, int Total, string Status)>? progress = null,
CancellationToken cancellationToken = default)
{
if (remoteImages == null || remoteImages.Count == 0)
{
return new ZhiJiaoHubSyncResult(false, null, 0, 0, 0, "No images to sync");
}
var sourceDir = GetSourceDirectory(source);
Directory.CreateDirectory(sourceDir);
var downloadedCount = 0;
var skippedCount = 0;
var failedCount = 0;
var localImages = new List<CachedImageInfo>();
for (var i = 0; i < remoteImages.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var remoteImage = remoteImages[i];
var fileName = GetSafeFileName(remoteImage.Name, remoteImage.Url);
var localPath = Path.Combine(sourceDir, fileName);
progress?.Report((i + 1, remoteImages.Count, $"Downloading {remoteImage.Name}..."));
if (File.Exists(localPath))
{
skippedCount++;
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
continue;
}
try
{
var downloadUrl = ResolveDownloadUrl(remoteImage.Url, mirrorSource);
using var response = await DownloadClient.GetAsync(downloadUrl, cancellationToken);
response.EnsureSuccessStatusCode();
await using var fileStream = File.Create(localPath);
await response.Content.CopyToAsync(fileStream, cancellationToken);
downloadedCount++;
localImages.Add(new CachedImageInfo(remoteImage.Name, remoteImage.Url, fileName));
}
catch (Exception)
{
failedCount++;
}
}
if (localImages.Count == 0)
{
return new ZhiJiaoHubSyncResult(false, null, downloadedCount, skippedCount, failedCount, "All downloads failed");
}
SaveManifest(source, localImages);
var snapshot = new ZhiJiaoHubLocalSnapshot(
localImages.Select((img, idx) => new ZhiJiaoHubLocalImageItem(
img.Name,
img.OriginalUrl,
Path.Combine(sourceDir, img.LocalFileName),
idx)).ToList(),
source,
DateTimeOffset.UtcNow,
localImages.Count);
return new ZhiJiaoHubSyncResult(true, snapshot, downloadedCount, skippedCount, failedCount);
}
public void ClearCache(string? source = null)
{
lock (_manifestLock)
{
if (source != null)
{
var sourceDir = GetSourceDirectory(source);
if (Directory.Exists(sourceDir))
{
Directory.Delete(sourceDir, true);
}
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
var manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions);
if (manifest?.Entries != null && manifest.Entries.ContainsKey(source))
{
manifest.Entries.Remove(source);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
catch
{
}
}
}
else
{
if (Directory.Exists(_cacheDirectory))
{
Directory.Delete(_cacheDirectory, true);
}
}
}
}
private string GetSourceDirectory(string source)
{
return Path.Combine(_cacheDirectory, source.ToLowerInvariant().Replace(" ", "-"));
}
private static string GetSafeFileName(string name, string url)
{
var ext = Path.GetExtension(new Uri(url).AbsolutePath);
if (string.IsNullOrEmpty(ext) || ext.Length > 5)
{
ext = ".jpg";
}
var safeName = string.Concat(name.Split(Path.GetInvalidFileNameChars()));
if (string.IsNullOrWhiteSpace(safeName))
{
safeName = Guid.NewGuid().ToString("N")[..8];
}
return $"{safeName}{ext}";
}
private static string ResolveDownloadUrl(string originalUrl, string mirrorSource)
{
if (string.Equals(mirrorSource, ZhiJiaoHubMirrorSources.GhProxy, StringComparison.OrdinalIgnoreCase))
{
return ZhiJiaoHubMirrorSources.GhProxyBaseUrl.TrimEnd('/') + "/" + originalUrl;
}
return originalUrl;
}
private void AddToManifest(string source, string name, string originalUrl, string localFileName)
{
lock (_manifestLock)
{
CacheManifest manifest;
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
}
catch
{
manifest = new CacheManifest();
}
}
else
{
manifest = new CacheManifest();
}
if (!manifest.Entries.TryGetValue(source, out var entry))
{
entry = new CacheEntry(new List<CachedImageInfo>(), DateTimeOffset.UtcNow);
manifest.Entries[source] = entry;
}
var existingIndex = entry.Images.FindIndex(i =>
string.Equals(i.OriginalUrl, originalUrl, StringComparison.OrdinalIgnoreCase));
if (existingIndex >= 0)
{
entry.Images[existingIndex] = new CachedImageInfo(name, originalUrl, localFileName);
}
else
{
entry.Images.Add(new CachedImageInfo(name, originalUrl, localFileName));
}
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
private void SaveManifest(string source, List<CachedImageInfo> images)
{
lock (_manifestLock)
{
CacheManifest manifest;
if (File.Exists(_manifestPath))
{
try
{
var json = File.ReadAllText(_manifestPath);
manifest = JsonSerializer.Deserialize<CacheManifest>(json, JsonOptions) ?? new CacheManifest();
}
catch
{
manifest = new CacheManifest();
}
}
else
{
manifest = new CacheManifest();
}
manifest.Entries[source] = new CacheEntry(images, DateTimeOffset.UtcNow);
Directory.CreateDirectory(Path.GetDirectoryName(_manifestPath)!);
File.WriteAllText(_manifestPath, JsonSerializer.Serialize(manifest, JsonOptions));
}
}
public void Dispose()
{
if (_isDisposed) return;
_isDisposed = true;
}
private sealed class CacheManifest
{
public Dictionary<string, CacheEntry> Entries { get; set; } = new();
}
private sealed class CacheEntry
{
public List<CachedImageInfo> Images { get; set; }
public DateTimeOffset LastUpdated { get; set; }
public CacheEntry(List<CachedImageInfo> images, DateTimeOffset lastUpdated)
{
Images = images;
LastUpdated = lastUpdated;
}
}
private sealed class CachedImageInfo
{
public string Name { get; set; }
public string OriginalUrl { get; set; }
public string LocalFileName { get; set; }
public CachedImageInfo(string name, string originalUrl, string localFileName)
{
Name = name;
OriginalUrl = originalUrl;
LocalFileName = localFileName;
}
}
}

View File

@@ -18,7 +18,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="FontSize" Value="14" />
@@ -40,7 +40,7 @@
<Setter Property="Background" Value="{DynamicResource EditorSelectFieldBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource EditorSelectOutlineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Padding" Value="16,14,12,14" />
<Setter Property="MinHeight" Value="56" />
<Setter Property="Foreground" Value="{DynamicResource ComponentEditorPrimaryTextBrush}" />

View File

@@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Fast">0:0:0.12</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.16</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.20</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.24</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.32</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Standard">0:0:0.20</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Slow">0:0:0.28</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Page">0:0:0.32</x:TimeSpan>
<x:TimeSpan x:Key="FluttermotionToken.Duration.Intro">0:0:0.40</x:TimeSpan>
<x:Double x:Key="FluttermotionToken.BackdropBlurRadiusStrong">30</x:Double>
</Styles.Resources>

View File

@@ -11,6 +11,7 @@
<CornerRadius x:Key="DesignCornerRadiusSm">14</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusXs">12</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusIsland">36</CornerRadius>
<CornerRadius x:Key="DesignCornerRadiusComponent">18</CornerRadius>
</Styles.Resources>
<Style Selector="TextBlock">

View File

@@ -0,0 +1,151 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
<Styles.Resources>
<x:Double x:Key="PaneToggleButtonWidth">40</x:Double>
<x:Double x:Key="PaneToggleButtonHeight">40</x:Double>
<x:Double x:Key="NavigationViewItemIconBoxHeight">20</x:Double>
<GridLength x:Key="PaneToggleButtonHeightGridLength">40</GridLength>
</Styles.Resources>
<Style Selector="Button.pane-toggle-button">
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="LayoutRoot"
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}">
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Border.Transitions>
<Grid x:Name="ContentRoot"
ColumnDefinitions="Auto,*">
<Grid.RowDefinitions>
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
</Grid.RowDefinitions>
<Border Width="{TemplateBinding Width}">
<ContentPresenter x:Name="IconPresenter"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Content="{TemplateBinding Content}" />
</Border>
<ContentPresenter x:Name="ContentPresenter"
VerticalContentAlignment="Center"
Content="{TemplateBinding Tag}"
FontSize="{TemplateBinding FontSize}"
Padding="4,0,0,0"
Grid.Column="1" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Button.pane-toggle-button:pointerover /template/ Border#LayoutRoot">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
</Style>
<Style Selector="Button.pane-toggle-button:pressed /template/ Border#LayoutRoot">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
</Style>
<Style Selector="Button.nav-back">
<Setter Property="Width" Value="{DynamicResource PaneToggleButtonWidth}" />
<Setter Property="Height" Value="{DynamicResource PaneToggleButtonHeight}" />
<Setter Property="Padding" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="{DynamicResource ControlCornerRadius}" />
<Setter Property="VerticalAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Center" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="LayoutRoot"
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}">
<Border.Transitions>
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Border.Transitions>
<Grid x:Name="ContentRoot"
ColumnDefinitions="Auto,*">
<Grid.RowDefinitions>
<RowDefinition Height="{DynamicResource PaneToggleButtonHeightGridLength}" />
</Grid.RowDefinitions>
<Border Width="{TemplateBinding Width}">
<fi:FluentIcon Icon="ChevronLeft"
IconVariant="Regular"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<ContentPresenter x:Name="ContentPresenter"
VerticalContentAlignment="Center"
Content="{TemplateBinding Content}"
FontSize="{TemplateBinding FontSize}"
Padding="4,0,0,0"
Grid.Column="1" />
</Grid>
</Border>
</ControlTemplate>
</Setter>
</Style>
<Style Selector="Button.nav-back:pointerover /template/ Border#LayoutRoot">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
</Style>
<Style Selector="Button.nav-back:pressed /template/ Border#LayoutRoot">
<Setter Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
</Style>
<Style Selector="ui|NavigationView.settings-navigation-view">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="0:0:0.2" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
<Style Selector="ui|NavigationView.settings-navigation-view /template/ Border#NavigationViewBorder">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.167" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
<Style Selector="ui|NavigationViewItem.settings-nav-item">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.083" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
<Style Selector="ui|NavigationViewItem.settings-nav-item:pointerover">
<Setter Property="RenderTransform" Value="scale(1.01)" />
</Style>
<Style Selector="ui|NavigationViewItem.settings-nav-item:pressed">
<Setter Property="RenderTransform" Value="scale(0.99)" />
</Style>
</Styles>

View File

@@ -1,4 +1,4 @@
<Styles xmlns="https://github.com/avaloniaui"
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
@@ -16,17 +16,17 @@
<Setter Property="Opacity" Value="0" />
<Setter Property="RenderTransform">
<Setter.Value>
<TranslateTransform Y="14" />
<TranslateTransform Y="24" />
</Setter.Value>
</Setter>
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
<Style.Animations>
<Animation Duration="{StaticResource FluttermotionToken.Duration.Intro}"
<Animation Duration="0:0:0.65"
FillMode="Both"
Easing="0.22,1,0.36,1">
Easing="0.05, 0.75, 0.10, 1.00">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0" />
<Setter Property="TranslateTransform.Y" Value="14" />
<Setter Property="TranslateTransform.Y" Value="24" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="1" />
@@ -53,9 +53,9 @@
<Setter Property="MinHeight" Value="34" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
@@ -74,8 +74,8 @@
<Style Selector=".settings-scope ComboBox">
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
@@ -87,8 +87,8 @@
<Style Selector=".settings-scope ToggleSwitch">
<Setter Property="Transitions">
<Transitions>
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.22,1,0.36,1" />
<DoubleTransition Property="Opacity" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Standard}" Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>

View File

@@ -1,7 +1,8 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:fi="using:FluentIcons.Avalonia.Fluent">
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
xmlns:behaviors="using:LanMountainDesktop.Behaviors">
<Style Selector="StackPanel.settings-page-container">
<Setter Property="Spacing" Value="0" />
@@ -9,6 +10,34 @@
<Setter Property="MaxWidth" Value="{DynamicResource SettingsContainerMaxWidth}" />
</Style>
<Style Selector="StackPanel.settings-page-animated">
<Setter Property="behaviors:PanelIntroAnimationBehavior.IsEnabled" Value="True" />
<Style Selector="^ > :is(Control)[(behaviors|PanelIntroAnimationBehavior.CanPlayAnimation)=True]">
<Setter Property="Opacity" Value="0" />
<Setter Property="RenderTransform">
<Setter.Value>
<TranslateTransform Y="20" />
</Setter.Value>
</Setter>
<Style Selector="^[(behaviors|PanelIntroAnimationBehavior.IsAnimationPlayed)=True]">
<Style.Animations>
<Animation Duration="0:0:0.55"
FillMode="Both"
Easing="0.05, 0.75, 0.10, 1.00">
<KeyFrame Cue="0%">
<Setter Property="Opacity" Value="0" />
<Setter Property="TranslateTransform.Y" Value="20" />
</KeyFrame>
<KeyFrame Cue="100%">
<Setter Property="Opacity" Value="1" />
<Setter Property="TranslateTransform.Y" Value="0" />
</KeyFrame>
</Animation>
</Style.Animations>
</Style>
</Style>
</Style>
<Style Selector="TextBlock.settings-section-title">
<Setter Property="FontSize" Value="30" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -39,10 +68,10 @@
<Transitions>
<BrushTransition Property="Background"
Duration="{StaticResource FluttermotionToken.Duration.Standard}"
Easing="0.22,1,0.36,1" />
Easing="0.05,0.75,0.10,1.00" />
<BoxShadowsTransition Property="BoxShadow"
Duration="{StaticResource FluttermotionToken.Duration.Fast}"
Easing="0.22,1,0.36,1" />
Easing="0.05,0.75,0.10,1.00" />
</Transitions>
</Setter>
</Style>
@@ -238,7 +267,7 @@
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentLightBrush}" />
</Style>
<Style Selector="Button.plugin-market-row-button">
<Style Selector="Button.plugin-catalog-row-button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
@@ -246,11 +275,11 @@
<Setter Property="HorizontalAlignment" Value="Stretch" />
</Style>
<Style Selector="Button.plugin-market-row-button:pointerover">
<Style Selector="Button.plugin-catalog-row-button:pointerover">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.plugin-market-icon-button">
<Style Selector="Button.plugin-catalog-icon-button">
<Setter Property="Width" Value="36" />
<Setter Property="Height" Value="36" />
<Setter Property="Padding" Value="0" />
@@ -261,11 +290,11 @@
<Setter Property="BorderThickness" Value="1" />
</Style>
<Style Selector="Button.plugin-market-icon-button:pointerover">
<Style Selector="Button.plugin-catalog-icon-button:pointerover">
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
</Style>
<Style Selector="Button.plugin-market-icon-button fi|SymbolIcon">
<Style Selector="Button.plugin-catalog-icon-button fi|SymbolIcon">
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
<Setter Property="FontSize" Value="16" />
</Style>

View File

@@ -5,13 +5,15 @@ namespace LanMountainDesktop.Theme;
public static class FluttermotionToken
{
public static readonly TimeSpan Fast = TimeSpan.FromMilliseconds(120);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(160);
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(200);
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(240);
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(320);
public static readonly TimeSpan Standard = TimeSpan.FromMilliseconds(200);
public static readonly TimeSpan Slow = TimeSpan.FromMilliseconds(280);
public static readonly TimeSpan Page = TimeSpan.FromMilliseconds(320);
public static readonly TimeSpan Intro = TimeSpan.FromMilliseconds(400);
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(24);
public static readonly TimeSpan StaggerStepInterval = TimeSpan.FromMilliseconds(32);
public static readonly TimeSpan WeatherAnimationFrameInterval = TimeSpan.FromMilliseconds(64);
public const string StandardBezier = "0.22, 1, 0.36, 1";
public const string StandardBezier = "0.05, 0.75, 0.10, 1.00";
public const string DecelerateBezier = "0.05, 0.75, 0.10, 1.00";
public const string AccelerateBezier = "0.30, 0.00, 0.60, 0.00";
}

View File

@@ -1,13 +1,21 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Avalonia.Controls;
using System.ComponentModel;
using LanMountainDesktop.Services;
using FluentIcons.Common;
using CommunityToolkit.Mvvm.ComponentModel;
namespace LanMountainDesktop.ViewModels;
public sealed class ComponentLibraryWindowViewModel : ViewModelBase
{
public string Title { get; set; } = "Widgets";
private string _title = "Widgets";
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public ObservableCollection<ComponentLibraryCategoryViewModel> Categories { get; } = [];
@@ -38,20 +46,134 @@ public sealed class ComponentLibraryCategoryViewModel
}
public sealed class ComponentLibraryItemViewModel
: ObservableObject
{
private readonly string _loadingPreviewText;
private readonly string _previewUnavailableText;
private string _displayName;
private ComponentPreviewKey _previewKey;
private ComponentPreviewImageEntry? _previewImageEntry;
private ComponentPreviewImageState _previewState;
private string? _previewErrorMessage;
private string _previewStatusText;
public ComponentLibraryItemViewModel(
string componentId,
string displayName,
Control? previewControl)
ComponentPreviewKey previewKey,
string loadingPreviewText = "Loading preview...",
string previewUnavailableText = "Preview unavailable",
ComponentPreviewImageEntry? previewImageEntry = null)
{
ComponentId = componentId;
DisplayName = displayName;
PreviewControl = previewControl;
_displayName = displayName;
_previewKey = previewKey;
_loadingPreviewText = loadingPreviewText;
_previewUnavailableText = previewUnavailableText;
_previewStatusText = loadingPreviewText;
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: false);
}
public string ComponentId { get; }
public string DisplayName { get; }
public string DisplayName
{
get => _displayName;
set => SetProperty(ref _displayName, value);
}
public Control? PreviewControl { get; }
public ComponentPreviewKey PreviewKey
{
get => _previewKey;
set => SetProperty(ref _previewKey, value);
}
public ComponentPreviewImageEntry? PreviewImageEntry => _previewImageEntry;
public object? PreviewBitmap => _previewImageEntry?.Bitmap;
public ComponentPreviewImageState PreviewState => _previewState;
public bool IsPreviewPending => _previewState == ComponentPreviewImageState.Pending;
public bool IsPreviewReady => _previewState == ComponentPreviewImageState.Ready && _previewImageEntry?.Bitmap is not null;
public bool IsPreviewFailed => _previewState == ComponentPreviewImageState.Failed;
public string? PreviewErrorMessage => _previewErrorMessage;
public string PreviewStatusText => _previewStatusText;
public void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry)
{
UpdatePreviewImageEntry(previewImageEntry, raiseEntryChanged: true);
}
private void UpdatePreviewImageEntry(ComponentPreviewImageEntry? previewImageEntry, bool raiseEntryChanged)
{
if (raiseEntryChanged && ReferenceEquals(_previewImageEntry, previewImageEntry))
{
return;
}
if (_previewImageEntry is not null)
{
_previewImageEntry.PropertyChanged -= OnPreviewImageEntryPropertyChanged;
}
_previewImageEntry = previewImageEntry;
_previewState = previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
_previewErrorMessage = previewImageEntry?.ErrorMessage;
_previewStatusText = _previewState switch
{
ComponentPreviewImageState.Ready => string.Empty,
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
? _previewUnavailableText
: _previewErrorMessage!,
_ => _loadingPreviewText
};
if (_previewImageEntry is not null)
{
_previewImageEntry.PropertyChanged += OnPreviewImageEntryPropertyChanged;
}
RaisePreviewDependentProperties();
}
private void OnPreviewImageEntryPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
_ = sender;
if (string.IsNullOrWhiteSpace(e.PropertyName) ||
e.PropertyName is nameof(ComponentPreviewImageEntry.Bitmap) or
nameof(ComponentPreviewImageEntry.State) or
nameof(ComponentPreviewImageEntry.ErrorMessage))
{
_previewState = _previewImageEntry?.State ?? ComponentPreviewImageState.Pending;
_previewErrorMessage = _previewImageEntry?.ErrorMessage;
_previewStatusText = _previewState switch
{
ComponentPreviewImageState.Ready => string.Empty,
ComponentPreviewImageState.Failed => string.IsNullOrWhiteSpace(_previewErrorMessage)
? _previewUnavailableText
: _previewErrorMessage!,
_ => _loadingPreviewText
};
RaisePreviewDependentProperties();
}
}
private void RaisePreviewDependentProperties()
{
OnPropertyChanged(nameof(PreviewImageEntry));
OnPropertyChanged(nameof(PreviewBitmap));
OnPropertyChanged(nameof(PreviewState));
OnPropertyChanged(nameof(IsPreviewPending));
OnPropertyChanged(nameof(IsPreviewReady));
OnPropertyChanged(nameof(IsPreviewFailed));
OnPropertyChanged(nameof(PreviewErrorMessage));
OnPropertyChanged(nameof(PreviewStatusText));
}
}

View File

@@ -15,7 +15,7 @@ using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ViewModels;
public enum PluginMarketPrimaryActionState
public enum PluginCatalogPrimaryActionState
{
Install,
Update,
@@ -24,14 +24,14 @@ public enum PluginMarketPrimaryActionState
Incompatible
}
public sealed partial class PluginMarketItemViewModel : ViewModelBase
public sealed partial class PluginCatalogItemViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService;
private readonly string _languageCode;
private bool _isLoadingIcon;
public PluginMarketItemViewModel(
PluginMarketPluginInfo plugin,
public PluginCatalogItemViewModel(
PluginCatalogItemInfo plugin,
LocalizationService localizationService,
string languageCode)
{
@@ -46,7 +46,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
ActionTooltip = L("market.button.install", "Install");
}
public PluginMarketPluginInfo Info { get; }
public PluginCatalogItemInfo Info { get; }
public string PluginId => Info.Id;
@@ -64,7 +64,11 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public string ReadmeUrl => Info.ReadmeUrl;
public IReadOnlyList<PluginMarketDependencyInfo> Dependencies => Info.Dependencies;
public IReadOnlyList<PluginCatalogSharedContractInfo> Dependencies => Info.SharedContracts;
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Info.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Info.Capabilities;
public string IconFallbackText { get; }
@@ -100,7 +104,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
public bool HasIcon => IconBitmap is not null;
public PluginMarketPrimaryActionState ActionState { get; private set; }
public PluginCatalogPrimaryActionState ActionState { get; private set; }
partial void OnIconBitmapChanged(Bitmap? value)
{
@@ -160,7 +164,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
{
if (IsInstalling)
{
ActionState = IsUpdateAvailable ? PluginMarketPrimaryActionState.Update : PluginMarketPrimaryActionState.Install;
ActionState = IsUpdateAvailable ? PluginCatalogPrimaryActionState.Update : PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.installing", "Installing...");
IsActionEnabled = false;
@@ -169,7 +173,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (!IsCompatibleWithHost)
{
ActionState = PluginMarketPrimaryActionState.Incompatible;
ActionState = PluginCatalogPrimaryActionState.Incompatible;
ActionSymbol = Symbol.Warning;
ActionTooltip = string.Format(
CultureInfo.CurrentCulture,
@@ -181,7 +185,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (RequiresRestart)
{
ActionState = PluginMarketPrimaryActionState.RestartRequired;
ActionState = PluginCatalogPrimaryActionState.RestartRequired;
ActionSymbol = Symbol.ArrowClockwise;
ActionTooltip = L("market.button.restart", "Restart to apply");
IsActionEnabled = true;
@@ -190,7 +194,7 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsUpdateAvailable)
{
ActionState = PluginMarketPrimaryActionState.Update;
ActionState = PluginCatalogPrimaryActionState.Update;
ActionSymbol = Symbol.ArrowSync;
ActionTooltip = L("market.button.update", "Update");
IsActionEnabled = true;
@@ -199,14 +203,14 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
if (IsInstalled)
{
ActionState = PluginMarketPrimaryActionState.Installed;
ActionState = PluginCatalogPrimaryActionState.Installed;
ActionSymbol = Symbol.CheckmarkCircle;
ActionTooltip = L("market.button.installed", "Installed");
IsActionEnabled = false;
return;
}
ActionState = PluginMarketPrimaryActionState.Install;
ActionState = PluginCatalogPrimaryActionState.Install;
ActionSymbol = Symbol.ArrowDownload;
ActionTooltip = L("market.button.install", "Install");
IsActionEnabled = true;
@@ -238,20 +242,20 @@ public sealed partial class PluginMarketItemViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public sealed partial class PluginCatalogDetailViewModel : ViewModelBase
{
private readonly LocalizationService _localizationService;
private readonly string _languageCode;
private readonly AirAppMarketReadmeService _readmeService;
private readonly Func<PluginMarketItemViewModel, Task> _primaryActionAsync;
private readonly Func<PluginCatalogItemViewModel, Task> _primaryActionAsync;
private bool _isInitialized;
public PluginMarketDetailViewModel(
PluginMarketItemViewModel item,
public PluginCatalogDetailViewModel(
PluginCatalogItemViewModel item,
LocalizationService localizationService,
string languageCode,
AirAppMarketReadmeService readmeService,
Func<PluginMarketItemViewModel, Task> primaryActionAsync)
Func<PluginCatalogItemViewModel, Task> primaryActionAsync)
{
Item = item;
_localizationService = localizationService;
@@ -259,7 +263,7 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
_readmeService = readmeService;
_primaryActionAsync = primaryActionAsync;
Dependencies = new ObservableCollection<PluginMarketDependencyInfo>(item.Dependencies);
Dependencies = new ObservableCollection<PluginCatalogSharedContractInfo>(item.Dependencies);
VersionLabel = L("market.detail.version", "Version");
PublisherLabel = L("market.detail.author", "Author");
ApiVersionLabel = L("market.detail.api_version", "API Version");
@@ -269,9 +273,9 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
EmptyDependenciesText = L("market.detail.dependencies_empty", "No dependencies were declared by this plugin.");
}
public PluginMarketItemViewModel Item { get; }
public PluginCatalogItemViewModel Item { get; }
public ObservableCollection<PluginMarketDependencyInfo> Dependencies { get; }
public ObservableCollection<PluginCatalogSharedContractInfo> Dependencies { get; }
public string DrawerTitle => Item.Name;
@@ -306,6 +310,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
public bool HasReadmeContent => !IsReadmeLoading && !HasReadmeError && !string.IsNullOrWhiteSpace(ReadmeMarkdown);
public IReadOnlyList<PluginPackageSourceInfo> PackageSources => Item.PackageSources;
public IReadOnlyList<PluginCapabilityInfo> Capabilities => Item.Capabilities;
public async Task InitializeAsync()
{
if (_isInitialized)
@@ -367,9 +375,10 @@ public sealed partial class PluginMarketDetailViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
public sealed partial class PluginCatalogSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IPluginCatalogSettingsService _pluginCatalog;
private readonly LocalizationService _localizationService;
private readonly AirAppMarketIconService _iconService;
private readonly AirAppMarketReadmeService _readmeService;
@@ -377,31 +386,32 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private readonly Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private readonly Version? _hostVersion;
private bool _isInitialized;
private bool _hasLoadedMarket;
private bool _hasLoadedCatalog;
public PluginMarketSettingsPageViewModel(
public PluginCatalogSettingsPageViewModel(
ISettingsFacadeService settingsFacade,
LocalizationService localizationService,
AirAppMarketIconService iconService,
AirAppMarketReadmeService readmeService)
{
_settingsFacade = settingsFacade;
_pluginCatalog = _settingsFacade.PluginCatalog;
_localizationService = localizationService;
_iconService = iconService;
_readmeService = readmeService;
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
Version.TryParse(_settingsFacade.ApplicationInfo.GetAppVersionText(), out _hostVersion);
RefreshLocalizedText();
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
}
public event Action<string?>? RestartRequested;
public event Action<PluginMarketItemViewModel>? DetailsRequested;
public event Action<PluginCatalogItemViewModel>? DetailsRequested;
public ObservableCollection<PluginMarketItemViewModel> MarketPlugins { get; } = [];
public ObservableCollection<PluginCatalogItemViewModel> CatalogPlugins { get; } = [];
public ObservableCollection<PluginMarketItemViewModel> FilteredPlugins { get; } = [];
public ObservableCollection<PluginCatalogItemViewModel> FilteredPlugins { get; } = [];
[ObservableProperty]
private string _statusMessage = string.Empty;
@@ -444,9 +454,9 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
await RefreshAsync();
}
public PluginMarketDetailViewModel CreateDetailViewModel(PluginMarketItemViewModel item)
public PluginCatalogDetailViewModel CreateDetailViewModel(PluginCatalogItemViewModel item)
{
return new PluginMarketDetailViewModel(
return new PluginCatalogDetailViewModel(
item,
_localizationService,
_languageCode,
@@ -465,35 +475,35 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
try
{
IsBusy = true;
StatusMessage = L("market.status.loading", "Loading the official plugin market...");
StatusMessage = L("market.status.loading", "Loading the official plugin catalog...");
RefreshInstalledSnapshot();
var result = await _settingsFacade.PluginMarket.LoadIndexAsync();
var result = await _pluginCatalog.LoadCatalogAsync();
if (!result.Success)
{
_hasLoadedMarket = false;
MarketPlugins.Clear();
_hasLoadedCatalog = false;
CatalogPlugins.Clear();
FilteredPlugins.Clear();
ShowEmptyState = true;
EmptyStateText = string.IsNullOrWhiteSpace(result.ErrorMessage)
? L("market.list.empty", "The plugin market has not been loaded yet.")
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
: result.ErrorMessage;
StatusMessage = string.IsNullOrWhiteSpace(result.ErrorMessage)
? L("market.status.load_failed_format", "Failed to load the plugin market: Unknown")
? L("market.status.load_failed_format", "Failed to load the plugin catalog: Unknown")
: string.Format(
CultureInfo.CurrentCulture,
L("market.status.load_failed_format", "Failed to load the plugin market: {0}"),
L("market.status.load_failed_format", "Failed to load the plugin catalog: {0}"),
result.ErrorMessage);
return;
}
_hasLoadedMarket = true;
MarketPlugins.Clear();
_hasLoadedCatalog = true;
CatalogPlugins.Clear();
foreach (var plugin in result.Plugins)
{
var item = new PluginMarketItemViewModel(plugin, _localizationService, _languageCode);
var item = new PluginCatalogItemViewModel(plugin, _localizationService, _languageCode);
item.ApplyInstallState(ResolveInstalledPlugin(plugin.Id), _hostVersion);
MarketPlugins.Add(item);
CatalogPlugins.Add(item);
_ = item.EnsureIconLoadedAsync(_iconService);
}
@@ -503,12 +513,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
? string.Format(
CultureInfo.CurrentCulture,
L("market.status.loaded_cache_format", "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}"),
MarketPlugins.Count,
CatalogPlugins.Count,
result.WarningMessage ?? L("market.detail.unknown", "Unknown"))
: string.Format(
CultureInfo.CurrentCulture,
L("market.status.loaded_network_format", "Loaded {0} plugin(s) from the official source."),
MarketPlugins.Count);
CatalogPlugins.Count);
}
finally
{
@@ -517,7 +527,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
[RelayCommand]
private void OpenDetails(PluginMarketItemViewModel? item)
private void OpenDetails(PluginCatalogItemViewModel? item)
{
if (item is null)
{
@@ -528,19 +538,19 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
[RelayCommand]
private Task ExecutePrimaryActionAsync(PluginMarketItemViewModel? item)
private Task ExecutePrimaryActionAsync(PluginCatalogItemViewModel? item)
{
return item is null ? Task.CompletedTask : ExecutePrimaryActionCoreAsync(item);
}
private async Task ExecutePrimaryActionCoreAsync(PluginMarketItemViewModel item)
private async Task ExecutePrimaryActionCoreAsync(PluginCatalogItemViewModel item)
{
if (item.IsInstalling)
{
return;
}
if (item.ActionState == PluginMarketPrimaryActionState.RestartRequired)
if (item.ActionState == PluginCatalogPrimaryActionState.RestartRequired)
{
RestartRequested?.Invoke(RestartRequiredMessage);
return;
@@ -559,7 +569,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
L("market.status.installing_format", "Downloading and staging plugin '{0}'..."),
item.Name);
var result = await _settingsFacade.PluginMarket.InstallAsync(item.PluginId);
var result = await _pluginCatalog.InstallAsync(item.PluginId);
if (result.Success)
{
RefreshInstalledSnapshot();
@@ -604,7 +614,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshItemStates()
{
foreach (var item in MarketPlugins)
foreach (var item in CatalogPlugins)
{
item.ApplyInstallState(ResolveInstalledPlugin(item.PluginId), _hostVersion);
}
@@ -632,7 +642,7 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
{
FilteredPlugins.Clear();
IEnumerable<PluginMarketItemViewModel> filtered = MarketPlugins;
IEnumerable<PluginCatalogItemViewModel> filtered = CatalogPlugins;
var query = SearchText?.Trim();
if (!string.IsNullOrWhiteSpace(query))
{
@@ -650,8 +660,8 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
}
ShowEmptyState = FilteredPlugins.Count == 0;
EmptyStateText = !_hasLoadedMarket
? L("market.list.empty", "The plugin market has not been loaded yet.")
EmptyStateText = !_hasLoadedCatalog
? L("market.list.empty", "The plugin catalog has not been loaded yet.")
: string.IsNullOrWhiteSpace(query)
? L("settings.plugins.marketplace_empty", "No marketplace plugins are available right now.")
: L("market.list.no_results", "No plugins match the current search.");
@@ -659,12 +669,12 @@ public sealed partial class PluginMarketSettingsPageViewModel : ViewModelBase
private void RefreshLocalizedText()
{
PageTitle = L("settings.plugin_market.title", "Plugin Market");
PageDescription = L("settings.plugin_market.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
PageTitle = L("settings.plugin_catalog.title", "Plugin Catalog");
PageDescription = L("settings.plugin_catalog.subtitle", "Browse plugins from the official LanAirApp source and stage installs.");
SearchPlaceholder = L("market.toolbar.search_placeholder", "Search plugins");
RefreshButtonText = L("market.toolbar.refresh", "Refresh");
RestartRequiredMessage = L("settings.plugins.restart_required", "Plugin changes take effect after restart.");
EmptyStateText = L("market.list.empty", "The plugin market has not been loaded yet.");
EmptyStateText = L("market.list.empty", "The plugin catalog has not been loaded yet.");
}
private string L(string key, string fallback)

View File

@@ -2,9 +2,9 @@ using System;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.ViewModels;
@@ -19,7 +19,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
public PrivacySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
@@ -35,7 +35,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private bool _uploadAnonymousUsageData;
[ObservableProperty]
private string _deviceId = string.Empty;
private string _telemetryId = string.Empty;
[ObservableProperty]
private string _privacyHeader = string.Empty;
@@ -53,13 +53,10 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
private string _usageUploadDescription = string.Empty;
[ObservableProperty]
private string _deviceIdHeader = string.Empty;
private string _telemetryIdHeader = string.Empty;
[ObservableProperty]
private string _deviceIdDescription = string.Empty;
[ObservableProperty]
private string _refreshDeviceIdText = string.Empty;
private string _telemetryIdDescription = string.Empty;
[ObservableProperty]
private string _viewPrivacyPolicyText = string.Empty;
@@ -72,33 +69,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
var state = _settingsFacade.Privacy.Get();
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
DeviceId = DeviceIdService.Instance.DeviceId;
}
[RelayCommand]
private void RefreshDeviceId()
{
try
{
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
var snapshot = _settingsFacade.Settings.LoadSnapshot<Models.AppSettingsSnapshot>(SettingsScope.App);
snapshot.DeviceId = newDeviceId;
_settingsFacade.Settings.SaveSnapshot(
SettingsScope.App,
snapshot,
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
DeviceId = newDeviceId;
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
}
catch (Exception ex)
{
AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
}
TelemetryId = TelemetryServices.Identity?.TelemetryId ?? string.Empty;
}
partial void OnUploadAnonymousCrashDataChanged(bool value)
@@ -132,12 +103,17 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
{
PrivacyHeader = L("settings.privacy.title", "Privacy");
CrashUploadHeader = L("settings.privacy.crash_upload_title", "Anonymous crash data uploads");
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
CrashUploadDescription = L(
"settings.privacy.crash_upload_description",
"Send crash reports to help us improve stability.");
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage analytics");
UsageUploadDescription = L(
"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 ");
ViewPrivacyPolicyText = L("settings.privacy.view_policy", "view our privacy policy");
}
@@ -147,10 +123,7 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
{
try
{
// 触发隐私政策查看事件
AppLogger.Info("PrivacySettings", "User requested to view privacy policy.");
// 发送事件通知显示隐私政策
ViewPrivacyPolicyRequested?.Invoke();
}
catch (Exception ex)

View File

@@ -326,7 +326,9 @@ public sealed partial class GeneralSettingsPageViewModel : ViewModelBase
return
[
new SelectionOption("zh-CN", L("settings.region.language_zh", "中文")),
new SelectionOption("en-US", L("settings.region.language_en", "English"))
new SelectionOption("en-US", L("settings.region.language_en", "English")),
new SelectionOption("ja-JP", L("settings.region.language_ja", "日本語")),
new SelectionOption("ko-KR", L("settings.region.language_ko", "한국어"))
];
}
@@ -1515,6 +1517,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _installNowButtonText = string.Empty;
[ObservableProperty]
private string _redownloadButtonText = string.Empty;
[ObservableProperty]
private string _latestVersionText = string.Empty;
@@ -1554,6 +1559,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _downloadThreadsDescription = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateLabel = string.Empty;
[ObservableProperty]
private string _forceCheckUpdateDescription = string.Empty;
[ObservableProperty]
private string _stableChannelText = string.Empty;
@@ -1617,6 +1628,8 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
public bool IsInstallButtonVisible => HasPendingInstaller;
public bool IsRedownloadButtonVisible => HasPendingInstaller && !IsDownloading;
public string DownloadThreadsValueText =>
UpdateSettingsValues.NormalizeDownloadThreads((int)Math.Round(DownloadThreadsSliderValue)).ToString(CultureInfo.CurrentCulture);
@@ -1836,6 +1849,19 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync()
{
await CheckForUpdatesCoreAsync(isForce: false);
}
private bool CanForceCheckUpdate() => !IsBusy;
[RelayCommand(CanExecute = nameof(CanForceCheckUpdate))]
private async Task ForceCheckUpdateAsync()
{
await CheckForUpdatesCoreAsync(isForce: true);
}
private async Task CheckForUpdatesCoreAsync(bool isForce)
{
try
{
@@ -1843,9 +1869,11 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
IsDownloadProgressVisible = false;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_checking", "Checking GitHub releases...");
UpdateStatus = isForce
? L("settings.update.status_force_checking", "Force checking GitHub releases...")
: L("settings.update.status_checking", "Checking GitHub releases...");
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion);
var result = await _updateWorkflowService.CheckForUpdatesAsync(_currentVersion, isForce);
_lastCheckResult = result.Success ? result : null;
RefreshLastCheckedFromSettings();
@@ -1861,16 +1889,16 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
}
ApplyCheckResultDisplay(result);
if (!result.IsUpdateAvailable)
if (!result.IsUpdateAvailable && !isForce)
{
return;
}
if (result.PreferredAsset is null)
{
UpdateStatus = L(
"settings.update.status_asset_missing",
"A new release is available, but no compatible installer was found.");
UpdateStatus = isForce
? L("settings.update.status_force_no_asset", "Release found but no compatible installer available.")
: L("settings.update.status_asset_missing", "A new release is available, but no compatible installer was found.");
return;
}
@@ -1882,7 +1910,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateStatus = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
isForce
? L("settings.update.status_force_available_format", "Release {0} is available. Click Download & Install.")
: L("settings.update.status_available_format", "New version {0} is available. Click Download & Install."),
result.LatestVersionText);
}
finally
@@ -1924,6 +1954,59 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
result.ErrorMessage ?? L("settings.update.status_installer_missing", "Installer file was not found after download."));
}
private bool CanRedownloadUpdate() => !IsBusy && HasPendingInstaller && _lastCheckResult is not null;
[RelayCommand(CanExecute = nameof(CanRedownloadUpdate))]
private async Task RedownloadUpdateAsync()
{
if (_lastCheckResult is null || !_lastCheckResult.Success || !_lastCheckResult.IsUpdateAvailable || _lastCheckResult.PreferredAsset is null)
{
UpdateStatus = L("settings.update.status_redownload_no_check", "Please check for updates first before redownloading.");
return;
}
try
{
IsDownloading = true;
IsDownloadProgressVisible = true;
DownloadProgressValue = 0;
DownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -");
UpdateStatus = L("settings.update.status_redownloading", "Redownloading installer...");
var progress = new Progress<double>(value =>
{
DownloadProgressValue = Math.Clamp(value * 100d, 0d, 100d);
DownloadProgressText = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.download_progress_format", "Download progress: {0:F0}%"),
DownloadProgressValue);
});
var downloadResult = await _updateWorkflowService.RedownloadReleaseAsync(_lastCheckResult, progress);
if (!downloadResult.Success)
{
UpdateStatus = string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_redownload_failed_format", "Redownload failed: {0}"),
downloadResult.ErrorMessage ?? L("settings.update.status_check_failed", "Failed to check for updates."));
return;
}
ApplyPendingState(_settingsFacade.Update.Get());
UpdateStatus = downloadResult.HashVerified
? BuildPendingReadyStatus()
: string.Format(
CultureInfo.CurrentCulture,
L("settings.update.status_downloaded_no_hash_format", "Update downloaded. Hash: {0}"),
downloadResult.ActualHash ?? "N/A");
}
finally
{
IsDownloading = false;
IsDownloadProgressVisible = false;
}
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.update.title", "Update");
@@ -1937,9 +2020,12 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
UpdateModeLabel = L("settings.update.mode_label", "Update Mode");
DownloadThreadsLabel = L("settings.update.download_threads_label", "Download Threads");
DownloadThreadsDescription = L("settings.update.download_threads_desc", "Choose how many parallel download threads are used for application updates.");
ForceCheckUpdateLabel = L("settings.update.force_check_label", "Force Check Update");
ForceCheckUpdateDescription = L("settings.update.force_check_desc", "Force check for updates from GitHub, ignoring version comparison.");
CheckForUpdatesButtonText = L("settings.update.check_button", "Check for Updates");
DownloadButtonText = L("settings.update.download_install_button", "Download & Install");
InstallNowButtonText = L("settings.update.install_now_button", "Install Now");
RedownloadButtonText = L("settings.update.redownload_button", "Redownload");
CurrentVersionLabel = L("settings.update.current_version_label", "Current Version");
LatestVersionLabel = L("settings.update.latest_version_label", "Latest Release");
PublishedAtLabel = L("settings.update.published_at_label", "Published At");
@@ -2145,7 +2231,9 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsDownloadButtonVisible));
OnPropertyChanged(nameof(IsInstallButtonVisible));
OnPropertyChanged(nameof(IsRedownloadButtonVisible));
OnPropertyChanged(nameof(DownloadThreadsValueText));
RedownloadUpdateCommand.NotifyCanExecuteChanged();
}
private IReadOnlyList<SelectionOption> CreateUpdateChannelOptions()
@@ -2234,6 +2322,527 @@ public sealed partial class UpdateSettingsPageViewModel : ViewModelBase
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed partial class StudySettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
private bool _isInitializing;
private readonly IStudyAnalyticsService _studyAnalyticsService = StudyAnalyticsServiceFactory.CreateDefault();
public StudySettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
RefreshLocalizedText();
_isInitializing = true;
LoadSettings();
_isInitializing = false;
}
#region Properties - Noise Monitoring
[ObservableProperty]
private string _noiseMonitoringHeader = string.Empty;
[ObservableProperty]
private string _noiseMonitoringDescription = string.Empty;
[ObservableProperty]
private string _samplingRateLabel = string.Empty;
[ObservableProperty]
private string _samplingRateDescription = string.Empty;
[ObservableProperty]
private int _samplingRateMs = 50;
[ObservableProperty]
private string _samplingRateValueText = string.Empty;
[ObservableProperty]
private string _noiseSensitivityLabel = string.Empty;
[ObservableProperty]
private string _noiseSensitivityDescription = string.Empty;
[ObservableProperty]
private double _noiseSensitivityDbfs = -50;
[ObservableProperty]
private string _noiseSensitivityValueText = string.Empty;
[ObservableProperty]
private string _currentThresholdText = string.Empty;
partial void OnNoiseSensitivityDbfsChanged(double value)
{
UpdateSensitivityText();
UpdateThresholdText();
if (!_isInitializing)
{
SaveNoiseSettings();
}
}
partial void OnSamplingRateMsChanged(int value)
{
UpdateSamplingRateText();
if (!_isInitializing)
{
SaveNoiseSettings();
}
}
private void UpdateSamplingRateText()
{
SamplingRateValueText = $"{SamplingRateMs}ms";
}
private void UpdateSensitivityText()
{
NoiseSensitivityValueText = $"{NoiseSensitivityDbfs:F0} dBFS";
}
#endregion
#region Properties - Focus Timer
[ObservableProperty]
private string _focusTimerHeader = string.Empty;
[ObservableProperty]
private string _focusTimerDescription = string.Empty;
[ObservableProperty]
private string _focusDurationLabel = string.Empty;
[ObservableProperty]
private string _focusDurationDescription = string.Empty;
[ObservableProperty]
private int _focusDurationMinutes = 25;
[ObservableProperty]
private string _focusDurationValueText = string.Empty;
[ObservableProperty]
private string _breakDurationLabel = string.Empty;
[ObservableProperty]
private string _breakDurationDescription = string.Empty;
[ObservableProperty]
private int _breakDurationMinutes = 5;
[ObservableProperty]
private string _breakDurationValueText = string.Empty;
[ObservableProperty]
private string _longBreakDurationLabel = string.Empty;
[ObservableProperty]
private string _longBreakDurationDescription = string.Empty;
[ObservableProperty]
private int _longBreakDurationMinutes = 15;
[ObservableProperty]
private string _longBreakDurationValueText = string.Empty;
[ObservableProperty]
private string _sessionsBeforeLongBreakLabel = string.Empty;
[ObservableProperty]
private string _sessionsBeforeLongBreakDescription = string.Empty;
[ObservableProperty]
private int _sessionsBeforeLongBreak = 4;
[ObservableProperty]
private string _sessionsBeforeLongBreakValueText = string.Empty;
[ObservableProperty]
private string _autoStartBreakLabel = string.Empty;
[ObservableProperty]
private string _autoStartBreakDescription = string.Empty;
[ObservableProperty]
private bool _autoStartBreak;
[ObservableProperty]
private string _autoStartFocusLabel = string.Empty;
[ObservableProperty]
private string _autoStartFocusDescription = string.Empty;
[ObservableProperty]
private bool _autoStartFocus;
partial void OnFocusDurationMinutesChanged(int value)
{
UpdateFocusDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnBreakDurationMinutesChanged(int value)
{
UpdateBreakDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnLongBreakDurationMinutesChanged(int value)
{
UpdateLongBreakDurationText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnSessionsBeforeLongBreakChanged(int value)
{
UpdateSessionsBeforeLongBreakText();
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnAutoStartBreakChanged(bool value)
{
if (!_isInitializing)
{
SaveTimerSettings();
}
}
partial void OnAutoStartFocusChanged(bool value)
{
if (!_isInitializing)
{
SaveTimerSettings();
}
}
private void UpdateFocusDurationText()
{
FocusDurationValueText = $"{FocusDurationMinutes} 分钟";
}
private void UpdateBreakDurationText()
{
BreakDurationValueText = $"{BreakDurationMinutes} 分钟";
}
private void UpdateLongBreakDurationText()
{
LongBreakDurationValueText = $"{LongBreakDurationMinutes} 分钟";
}
private void UpdateSessionsBeforeLongBreakText()
{
SessionsBeforeLongBreakValueText = $"{SessionsBeforeLongBreak} 次";
}
#endregion
#region Properties - Alert
[ObservableProperty]
private string _alertHeader = string.Empty;
[ObservableProperty]
private string _alertDescription = string.Empty;
[ObservableProperty]
private string _noiseAlertEnabledLabel = string.Empty;
[ObservableProperty]
private string _noiseAlertEnabledDescription = string.Empty;
[ObservableProperty]
private bool _noiseAlertEnabled;
[ObservableProperty]
private string _maxInterruptsPerMinuteLabel = string.Empty;
[ObservableProperty]
private string _maxInterruptsPerMinuteDescription = string.Empty;
[ObservableProperty]
private int _maxInterruptsPerMinute = 6;
partial void OnNoiseAlertEnabledChanged(bool value)
{
if (!_isInitializing)
{
SaveAlertSettings();
}
}
partial void OnMaxInterruptsPerMinuteChanged(int value)
{
if (!_isInitializing)
{
SaveAlertSettings();
}
}
#endregion
#region Properties - Display
[ObservableProperty]
private string _displayHeader = string.Empty;
[ObservableProperty]
private string _displayDescription = string.Empty;
[ObservableProperty]
private string _showRealtimeDbLabel = string.Empty;
[ObservableProperty]
private string _showRealtimeDbDescription = string.Empty;
[ObservableProperty]
private bool _showRealtimeDb = true;
[ObservableProperty]
private string _baselineDbLabel = string.Empty;
[ObservableProperty]
private string _baselineDbDescription = string.Empty;
[ObservableProperty]
private double _baselineDb = 45;
[ObservableProperty]
private string _baselineDbValueText = string.Empty;
[ObservableProperty]
private string _avgWindowSecLabel = string.Empty;
[ObservableProperty]
private string _avgWindowSecDescription = string.Empty;
[ObservableProperty]
private int _avgWindowSec = 1;
[ObservableProperty]
private string _avgWindowSecValueText = string.Empty;
partial void OnShowRealtimeDbChanged(bool value)
{
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
partial void OnBaselineDbChanged(double value)
{
UpdateBaselineDbText();
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
partial void OnAvgWindowSecChanged(int value)
{
UpdateAvgWindowSecText();
if (!_isInitializing)
{
SaveDisplaySettings();
}
}
#endregion
[ObservableProperty]
private string _footerHint = string.Empty;
private void UpdateBaselineDbText()
{
BaselineDbValueText = $"{BaselineDb:F0} dB";
}
private void UpdateAvgWindowSecText()
{
AvgWindowSecValueText = $"{AvgWindowSec} 秒";
}
private void LoadSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
// Noise settings
SamplingRateMs = appSnapshot.StudyFrameMs is > 0 ? appSnapshot.StudyFrameMs.Value : 50;
NoiseSensitivityDbfs = appSnapshot.StudyScoreThresholdDbfs ?? -50;
// Timer settings
FocusDurationMinutes = appSnapshot.StudyFocusDurationMinutes is > 0 ? appSnapshot.StudyFocusDurationMinutes.Value : 25;
BreakDurationMinutes = appSnapshot.StudyBreakDurationMinutes is > 0 ? appSnapshot.StudyBreakDurationMinutes.Value : 5;
LongBreakDurationMinutes = appSnapshot.StudyLongBreakDurationMinutes is > 0 ? appSnapshot.StudyLongBreakDurationMinutes.Value : 15;
SessionsBeforeLongBreak = appSnapshot.StudySessionsBeforeLongBreak is > 0 ? appSnapshot.StudySessionsBeforeLongBreak.Value : 4;
AutoStartBreak = appSnapshot.StudyAutoStartBreak ?? false;
AutoStartFocus = appSnapshot.StudyAutoStartFocus ?? false;
// Alert settings
NoiseAlertEnabled = appSnapshot.StudyNoiseAlertEnabled ?? false;
MaxInterruptsPerMinute = appSnapshot.StudyMaxInterruptsPerMinute is > 0 ? appSnapshot.StudyMaxInterruptsPerMinute.Value : 6;
// Display settings
ShowRealtimeDb = appSnapshot.StudyShowRealtimeDb ?? true;
BaselineDb = appSnapshot.StudyBaselineDb ?? 45;
AvgWindowSec = appSnapshot.StudyAvgWindowSec ?? 1;
UpdateSamplingRateText();
UpdateSensitivityText();
UpdateThresholdText();
UpdateFocusDurationText();
UpdateBreakDurationText();
UpdateLongBreakDurationText();
UpdateSessionsBeforeLongBreakText();
UpdateBaselineDbText();
UpdateAvgWindowSecText();
}
private void SaveNoiseSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyFrameMs = SamplingRateMs;
appSnapshot.StudyScoreThresholdDbfs = NoiseSensitivityDbfs;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyFrameMs), nameof(AppSettingsSnapshot.StudyScoreThresholdDbfs)]);
UpdateThresholdText();
UpdateStudyAnalyticsConfig();
}
private void SaveTimerSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyFocusDurationMinutes = FocusDurationMinutes;
appSnapshot.StudyBreakDurationMinutes = BreakDurationMinutes;
appSnapshot.StudyLongBreakDurationMinutes = LongBreakDurationMinutes;
appSnapshot.StudySessionsBeforeLongBreak = SessionsBeforeLongBreak;
appSnapshot.StudyAutoStartBreak = AutoStartBreak;
appSnapshot.StudyAutoStartFocus = AutoStartFocus;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [
nameof(AppSettingsSnapshot.StudyFocusDurationMinutes),
nameof(AppSettingsSnapshot.StudyBreakDurationMinutes),
nameof(AppSettingsSnapshot.StudyLongBreakDurationMinutes),
nameof(AppSettingsSnapshot.StudySessionsBeforeLongBreak),
nameof(AppSettingsSnapshot.StudyAutoStartBreak),
nameof(AppSettingsSnapshot.StudyAutoStartFocus)
]);
}
private void SaveAlertSettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyNoiseAlertEnabled = NoiseAlertEnabled;
appSnapshot.StudyMaxInterruptsPerMinute = MaxInterruptsPerMinute;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyNoiseAlertEnabled), nameof(AppSettingsSnapshot.StudyMaxInterruptsPerMinute)]);
UpdateStudyAnalyticsConfig();
}
private void SaveDisplaySettings()
{
var appSnapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
appSnapshot.StudyShowRealtimeDb = ShowRealtimeDb;
appSnapshot.StudyBaselineDb = BaselineDb;
appSnapshot.StudyAvgWindowSec = AvgWindowSec;
_settingsFacade.Settings.SaveSnapshot(SettingsScope.App, appSnapshot,
changedKeys: [nameof(AppSettingsSnapshot.StudyShowRealtimeDb), nameof(AppSettingsSnapshot.StudyBaselineDb), nameof(AppSettingsSnapshot.StudyAvgWindowSec)]);
UpdateStudyAnalyticsConfig();
}
private void UpdateStudyAnalyticsConfig()
{
var currentConfig = _studyAnalyticsService.GetConfig();
var newConfig = currentConfig with
{
FrameMs = SamplingRateMs,
ScoreThresholdDbfs = NoiseSensitivityDbfs,
BaselineDb = BaselineDb,
AvgWindowSec = AvgWindowSec,
ShowRelativeDb = ShowRealtimeDb,
MaxSegmentsPerMin = MaxInterruptsPerMinute,
AlertSoundEnabled = NoiseAlertEnabled
};
_studyAnalyticsService.UpdateConfig(newConfig);
}
private void UpdateThresholdText()
{
CurrentThresholdText = string.Format(
CultureInfo.CurrentCulture,
L("settings.study.current_threshold_format", "当前评分阈值: {0} dBFS"),
NoiseSensitivityDbfs);
}
private void RefreshLocalizedText()
{
NoiseMonitoringHeader = L("settings.study.noise_header", "噪音监测");
NoiseMonitoringDescription = L("settings.study.noise_description", "配置麦克风采集频率和噪音评分敏感度。");
SamplingRateLabel = L("settings.study.sampling_rate_label", "采集频率");
SamplingRateDescription = L("settings.study.sampling_rate_desc", "麦克风采集音频的时间间隔。更高的频率会更准确地捕捉噪音变化,但会增加电量消耗。");
NoiseSensitivityLabel = L("settings.study.sensitivity_label", "噪音敏感度");
NoiseSensitivityDescription = L("settings.study.sensitivity_desc", "评分阈值决定了什么级别的噪音会被认为是干扰。阈值越严格,越容易检测到轻微噪音。");
FocusTimerHeader = L("settings.study.timer_header", "专注计时");
FocusTimerDescription = L("settings.study.timer_description", "配置专注时段和休息时段的时长。");
FocusDurationLabel = L("settings.study.focus_duration_label", "专注时长");
FocusDurationDescription = L("settings.study.focus_duration_desc", "单次专注时段的持续时间(分钟)。");
BreakDurationLabel = L("settings.study.break_duration_label", "休息时长");
BreakDurationDescription = L("settings.study.break_duration_desc", "短休息时段的持续时间(分钟)。");
LongBreakDurationLabel = L("settings.study.long_break_duration_label", "长休息时长");
LongBreakDurationDescription = L("settings.study.long_break_duration_desc", "长休息时段的持续时间(分钟)。");
SessionsBeforeLongBreakLabel = L("settings.study.sessions_before_long_break_label", "长休息间隔");
SessionsBeforeLongBreakDescription = L("settings.study.sessions_before_long_break_desc", "经过几个专注时段后触发长休息。");
AutoStartBreakLabel = L("settings.study.auto_start_break_label", "自动开始休息");
AutoStartBreakDescription = L("settings.study.auto_start_break_desc", "专注时段结束后自动开始休息计时。");
AutoStartFocusLabel = L("settings.study.auto_start_focus_label", "自动开始专注");
AutoStartFocusDescription = L("settings.study.auto_start_focus_desc", "休息时段结束后自动开始专注计时。");
AlertHeader = L("settings.study.alert_header", "提醒设置");
AlertDescription = L("settings.study.alert_description", "配置噪音干扰提醒。");
NoiseAlertEnabledLabel = L("settings.study.noise_alert_enabled_label", "启用噪音提醒");
NoiseAlertEnabledDescription = L("settings.study.noise_alert_enabled_desc", "当检测到超过容忍阈值的噪音干扰时显示提醒。");
MaxInterruptsPerMinuteLabel = L("settings.study.max_interrupts_label", "最大容忍打断次数");
MaxInterruptsPerMinuteDescription = L("settings.study.max_interrupts_desc", "每分钟最多允许多少次噪音干扰事件,超过此值将触发提醒。");
DisplayHeader = L("settings.study.display_header", "显示设置");
DisplayDescription = L("settings.study.display_description", "配置噪音数据的显示方式。");
ShowRealtimeDbLabel = L("settings.study.show_realtime_db_label", "显示实时分贝");
ShowRealtimeDbDescription = L("settings.study.show_realtime_db_desc", "在组件中实时显示分贝值。");
BaselineDbLabel = L("settings.study.baseline_db_label", "基准显示分贝");
BaselineDbDescription = L("settings.study.baseline_db_desc", "校准后的显示分贝基准值,用于将 dBFS 转换为用户可读的 dB 值。");
AvgWindowSecLabel = L("settings.study.avg_window_label", "平均时间窗");
AvgWindowSecDescription = L("settings.study.avg_window_desc", "噪音平滑显示的时间窗口,较大的值会使显示更稳定但响应更慢。");
FooterHint = L("settings.study.footer_hint", "这些设置将影响自习环境监测组件的行为。");
UpdateThresholdText();
}
private string L(string key, string fallback)
=> _localizationService.GetString(_languageCode, key, fallback);
}
public sealed class PluginGeneratedSettingsPageViewModel
{
public PluginGeneratedSettingsPageViewModel(

View File

@@ -1,7 +1,8 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia.Media;
using Avalonia.Media.Imaging;
@@ -15,6 +16,7 @@ namespace LanMountainDesktop.ViewModels;
public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly ISystemWallpaperProvider _systemWallpaperProvider;
private readonly LocalizationService _localizationService = new();
private readonly string _languageCode;
private bool _isInitializing;
@@ -22,9 +24,11 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
public WallpaperSettingsPageViewModel(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade;
_systemWallpaperProvider = HostSystemWallpaperProvider.GetOrCreate();
_languageCode = _localizationService.NormalizeLanguageCode(_settingsFacade.Region.Get().LanguageCode);
WallpaperPlacements = CreateWallpaperPlacements();
WallpaperTypes = CreateWallpaperTypes();
RefreshIntervals = CreateRefreshIntervals();
PresetColors = CreatePresetColors();
RefreshLocalizedText();
@@ -35,8 +39,11 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
public IReadOnlyList<SelectionOption> WallpaperPlacements { get; }
public IReadOnlyList<SelectionOption> WallpaperTypes { get; }
public IReadOnlyList<SelectionOption> RefreshIntervals { get; }
public IReadOnlyList<string> PresetColors { get; }
public bool IsSystemWallpaperSupported => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
[ObservableProperty]
private string _wallpaperPath = string.Empty;
@@ -49,6 +56,9 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private SelectionOption _selectedWallpaperPlacement = null!;
[ObservableProperty]
private SelectionOption _selectedRefreshInterval = null!;
[ObservableProperty]
private string _wallpaperHeader = string.Empty;
@@ -73,6 +83,18 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private string _filePickerTitle = string.Empty;
[ObservableProperty]
private string _systemWallpaperLabel = string.Empty;
[ObservableProperty]
private string _refreshIntervalLabel = string.Empty;
[ObservableProperty]
private string _refreshButtonTooltip = string.Empty;
[ObservableProperty]
private string _systemWallpaperStatus = string.Empty;
[ObservableProperty]
private bool _isImageOrVideo;
@@ -82,13 +104,15 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
[ObservableProperty]
private bool _isImage;
[ObservableProperty]
private bool _isSystemWallpaper;
[ObservableProperty]
private Bitmap? _previewImage;
[ObservableProperty]
private IBrush? _previewBrush;
// 自定义颜色持久化
[ObservableProperty]
private Color _customColor = Colors.White;
@@ -110,7 +134,11 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
string.Equals(option.Value, wallpaperPlacement, StringComparison.OrdinalIgnoreCase))
?? WallpaperPlacements[0];
// 加载自定义颜色
var refreshIntervalSeconds = wallpaper.SystemWallpaperRefreshIntervalSeconds;
SelectedRefreshInterval = RefreshIntervals.FirstOrDefault(option =>
GetIntervalSeconds(option.Value) == refreshIntervalSeconds)
?? RefreshIntervals[2];
if (!string.IsNullOrWhiteSpace(wallpaper.CustomColor) && Color.TryParse(wallpaper.CustomColor, out var customColor))
{
CustomColor = customColor;
@@ -119,6 +147,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
UpdateVisibility();
UpdatePreviewFromCurrentSelection();
UpdateSystemWallpaperStatus();
}
partial void OnSelectedWallpaperTypeChanged(SelectionOption value)
@@ -132,8 +161,9 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private void UpdateVisibility()
{
IsImage = SelectedWallpaperType?.Value == "Image";
IsImageOrVideo = IsImage;
IsImageOrVideo = IsImage || SelectedWallpaperType?.Value == "SystemWallpaper";
IsSolidColor = SelectedWallpaperType?.Value == "SolidColor";
IsSystemWallpaper = SelectedWallpaperType?.Value == "SystemWallpaper";
}
partial void OnSelectedColorChanged(string? value)
@@ -145,13 +175,18 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
partial void OnCustomColorChanged(Color value)
{
CustomColorBrush = new SolidColorBrush(value);
// 将自定义颜色应用到壁纸
var colorHex = $"#{value.A:X2}{value.R:X2}{value.G:X2}{value.B:X2}";
SelectedColor = colorHex;
if (_isInitializing) return;
SaveWallpaper();
}
partial void OnSelectedRefreshIntervalChanged(SelectionOption value)
{
if (_isInitializing) return;
SaveWallpaper();
}
public async Task ImportWallpaperAsync(string sourcePath)
{
var importedPath = await _settingsFacade.WallpaperMedia.ImportAssetAsync(sourcePath);
@@ -170,6 +205,12 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private void UpdatePreviewFromCurrentSelection()
{
if (IsSystemWallpaper)
{
UpdateSystemWallpaperPreview();
return;
}
if (!IsImage)
{
ClearPreviewImage();
@@ -180,10 +221,24 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
UpdatePreviewImage(WallpaperPath);
}
private void UpdatePreviewImage(string path)
private void UpdateSystemWallpaperPreview()
{
var systemPath = _systemWallpaperProvider.GetWallpaperPath();
if (string.IsNullOrWhiteSpace(systemPath))
{
ClearPreviewImage();
SystemWallpaperStatus = L("settings.wallpaper.system.unavailable", "Unable to read system wallpaper");
return;
}
SystemWallpaperStatus = systemPath;
UpdatePreviewImage(systemPath);
}
private void UpdatePreviewImage(string? path)
{
var previousPreview = PreviewImage;
if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
previousPreview?.Dispose();
PreviewImage = null;
@@ -193,7 +248,7 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
try
{
using var stream = System.IO.File.OpenRead(path);
using var stream = File.OpenRead(path);
var bitmap = new Bitmap(stream);
PreviewImage = bitmap;
PreviewBrush = WallpaperImageBrushFactory.Create(bitmap, SelectedWallpaperPlacement?.Value);
@@ -215,9 +270,21 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
previousPreview?.Dispose();
}
private void UpdateSystemWallpaperStatus()
{
if (!IsSystemWallpaper) return;
UpdateSystemWallpaperPreview();
}
[RelayCommand]
private void RefreshSystemWallpaper()
{
UpdateSystemWallpaperPreview();
}
partial void OnSelectedWallpaperPlacementChanged(SelectionOption value)
{
if (IsImage && PreviewImage is not null)
if ((IsImage || IsSystemWallpaper) && PreviewImage is not null)
{
PreviewBrush = WallpaperImageBrushFactory.Create(PreviewImage, value?.Value);
}
@@ -236,16 +303,46 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
{
var selectedType = SelectedWallpaperType?.Value ?? "Image";
var selectedPlacement = SelectedWallpaperPlacement?.Value ?? WallpaperImageBrushFactory.Fill;
var normalizedPath = SelectedWallpaperType?.Value == "SolidColor" || string.IsNullOrWhiteSpace(WallpaperPath)
? null
: WallpaperPath;
var refreshIntervalSeconds = GetIntervalSeconds(SelectedRefreshInterval?.Value);
string? normalizedPath;
if (selectedType == "SolidColor" || selectedType == "SystemWallpaper")
{
normalizedPath = null;
}
else
{
normalizedPath = string.IsNullOrWhiteSpace(WallpaperPath) ? null : WallpaperPath;
}
var customColorHex = $"#{CustomColor.A:X2}{CustomColor.R:X2}{CustomColor.G:X2}{CustomColor.B:X2}";
_settingsFacade.Wallpaper.Save(new WallpaperSettingsState(
normalizedPath,
selectedType,
SelectedColor,
selectedPlacement,
customColorHex));
customColorHex,
refreshIntervalSeconds));
}
private static int GetIntervalSeconds(string? value)
{
return value switch
{
"30s" => 30,
"1m" => 60,
"5m" => 300,
"10m" => 600,
"15m" => 900,
"30m" => 1800,
"1h" => 3600,
"2h" => 7200,
"4h" => 14400,
"8h" => 28800,
"12h" => 43200,
"24h" => 86400,
_ => 300
};
}
private IReadOnlyList<SelectionOption> CreateWallpaperPlacements()
@@ -262,10 +359,36 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
private IReadOnlyList<SelectionOption> CreateWallpaperTypes()
{
return
[
var types = new List<SelectionOption>
{
new SelectionOption("Image", L("settings.wallpaper.type.image", "Image")),
new SelectionOption("SolidColor", L("settings.wallpaper.type.solid_color", "Solid Color"))
};
if (IsSystemWallpaperSupported)
{
types.Add(new SelectionOption("SystemWallpaper", L("settings.wallpaper.type.system", "System Wallpaper")));
}
return types;
}
private IReadOnlyList<SelectionOption> CreateRefreshIntervals()
{
return
[
new SelectionOption("30s", L("settings.wallpaper.refresh.30s", "30 seconds")),
new SelectionOption("1m", L("settings.wallpaper.refresh.1m", "1 minute")),
new SelectionOption("5m", L("settings.wallpaper.refresh.5m", "5 minutes")),
new SelectionOption("10m", L("settings.wallpaper.refresh.10m", "10 minutes")),
new SelectionOption("15m", L("settings.wallpaper.refresh.15m", "15 minutes")),
new SelectionOption("30m", L("settings.wallpaper.refresh.30m", "30 minutes")),
new SelectionOption("1h", L("settings.wallpaper.refresh.1h", "1 hour")),
new SelectionOption("2h", L("settings.wallpaper.refresh.2h", "2 hours")),
new SelectionOption("4h", L("settings.wallpaper.refresh.4h", "4 hours")),
new SelectionOption("8h", L("settings.wallpaper.refresh.8h", "8 hours")),
new SelectionOption("12h", L("settings.wallpaper.refresh.12h", "12 hours")),
new SelectionOption("24h", L("settings.wallpaper.refresh.24h", "24 hours"))
];
}
@@ -289,6 +412,9 @@ public sealed partial class WallpaperSettingsPageViewModel : ViewModelBase
WallpaperPlacementDescription = L("settings.wallpaper.placement_desc", "Adjust how the image fills the desktop.");
ImportWallpaperButtonText = L("settings.wallpaper.pick_button", "Import Wallpaper");
FilePickerTitle = L("filepicker.title", "Select wallpaper");
SystemWallpaperLabel = L("settings.wallpaper.system.label", "System Wallpaper");
RefreshIntervalLabel = L("settings.wallpaper.refresh_interval", "Refresh Interval");
RefreshButtonTooltip = L("settings.wallpaper.refresh_now", "Refresh Now");
}
private string L(string key, string fallback)

View File

@@ -27,7 +27,8 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
ISettingsFacadeService settingsFacade,
LocalizationService localizationService,
ILocationService locationService,
WeatherLocationRefreshService weatherLocationRefreshService)
WeatherLocationRefreshService weatherLocationRefreshService,
bool enableStartupPreviewRefresh = true)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
@@ -52,7 +53,10 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
? LocationReadyText
: LocationUnsupportedText;
_ = RefreshPreviewAsync();
if (enableStartupPreviewRefresh)
{
_ = RefreshPreviewAsync();
}
}
public IReadOnlyList<SelectionOption> LocationModes { get; }
@@ -476,6 +480,65 @@ public sealed partial class WeatherSettingsPageViewModel : ViewModelBase
}
}
internal void ApplyDesignTimePreview()
{
_isInitializing = true;
var previewLocation = new WeatherLocation(
"Shenzhen Nanshan",
"101280601",
22.5431,
114.0579,
"Guangdong, China");
var alternateLocation = new WeatherLocation(
"Shanghai Pudong",
"101020600",
31.2304,
121.4737,
"Shanghai, China");
SelectedLocationMode = LocationModes.FirstOrDefault(option =>
string.Equals(option.Value, "CitySearch", StringComparison.OrdinalIgnoreCase))
?? LocationModes[0];
SearchKeyword = "shenzhen";
SelectedSearchResult = previewLocation;
SearchResults.Clear();
SearchResults.Add(previewLocation);
SearchResults.Add(alternateLocation);
Latitude = previewLocation.Latitude;
Longitude = previewLocation.Longitude;
LocationKey = previewLocation.LocationKey;
LocationName = previewLocation.Name;
AutoRefreshLocation = true;
ExcludedAlerts = "Heat\nThunderstorm";
NoTlsRequests = false;
IsLocationSupported = true;
IsRefreshingLocation = false;
IsRefreshingPreview = false;
_isInitializing = false;
UpdateModeVisibility();
UpdateCurrentLocationSummary();
var preview = XiaomiWeatherVisualResolver.Resolve(
"Partly cloudy",
4,
isNight: false,
_languageCode);
SearchStatus = "2 sample locations are shown for design preview.";
LocationActionStatus = "Using mocked Windows location support in design mode.";
PreviewIcon = HyperOS3WeatherAssetLoader.LoadImage(preview.PrimaryIconAsset);
PreviewLocation = previewLocation.Name;
PreviewTemperature = "24 deg C";
PreviewCondition = preview.DisplayText;
PreviewUpdated = "Updated 09:42";
PreviewStatus = "Preview data is mocked for Avalonia design mode.";
}
private void RefreshLocalizedText()
{
PageTitle = L("settings.weather.title", "Weather");

View File

@@ -53,7 +53,7 @@
<!-- MD3 Button Styles -->
<Style Selector="Button.component-editor-footer-button">
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusMd}" />
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusComponent}" />
<Setter Property="Background" Value="{DynamicResource EditorPrimaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource EditorOnPrimaryBrush}" />
<Setter Property="Height" Value="40" />
@@ -99,37 +99,43 @@
</Grid>
</Border>
<Panel Grid.Row="1">
<ScrollViewer Classes="component-editor-scroll-host"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ContentControl x:Name="EditorContentHost"
Margin="24,0,24,100"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ScrollViewer>
<Border Grid.Row="1"
Background="{DynamicResource EditorSurfaceContainerBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Margin="16,0,16,16"
ClipToBounds="True">
<Panel>
<ScrollViewer Classes="component-editor-scroll-host"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ContentControl x:Name="EditorContentHost"
Margin="24,0,24,100"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch" />
</ScrollViewer>
<!-- Floating Save Button (MD3 Style) -->
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64"
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusMd}"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
</Button.Styles>
<mi:MaterialIcon Kind="Check"
Width="32"
Height="32" />
</Button>
</Panel>
<!-- Floating Save Button (MD3 Style) -->
<Button x:Name="SaveFAB"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="28"
Width="64"
Height="64"
Background="{DynamicResource EditorPrimaryBrush}"
Foreground="{DynamicResource EditorOnPrimaryBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusComponent}"
Classes="accent"
Click="OnCloseClick">
<Button.Styles>
<Style Selector="Button:pointerover">
<Setter Property="RenderTransform" Value="scale(1.05)" />
</Style>
</Button.Styles>
<mi:MaterialIcon Kind="Check"
Width="32"
Height="32" />
</Button>
</Panel>
</Border>
</Grid>
</Window>

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