mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:42:59 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcf4be6d50 | ||
|
|
6c9f6be1b1 |
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal file
24
.trae/specs/class-schedule-enhancement/checklist.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Checklist
|
||||
|
||||
## 1. 课表单双周解析修复
|
||||
|
||||
- [x] 单周课程(WeekCountDiv=1)在单周正确显示
|
||||
- [x] 双周课程(WeekCountDiv=2)在双周正确显示
|
||||
- [x] 每周课程(WeekCountDiv=0)在所有周正确显示
|
||||
- [x] 多周轮转(2-32周)正确计算当前周期位置
|
||||
|
||||
## 2. 课程动态移动功能
|
||||
|
||||
- [x] 课程结束自动从视图移除
|
||||
- [x] 新课程自动移入视图可见区域
|
||||
- [x] 当日课程全部结束后自动切换到次日课程表
|
||||
|
||||
## 3. 拖动交互功能
|
||||
|
||||
- [x] 课程表支持上下拖动滚动
|
||||
- [x] 拖动操作流畅、响应及时
|
||||
|
||||
## 4. 自动复位功能
|
||||
|
||||
- [x] 用户手动拖动后,标记拖动状态
|
||||
- [x] 当前课程变化时自动复位到最新进行中课程
|
||||
101
.trae/specs/class-schedule-enhancement/spec.md
Normal file
101
.trae/specs/class-schedule-enhancement/spec.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# 课程表组件功能优化规格说明书
|
||||
|
||||
## Why
|
||||
|
||||
当前课程表组件存在以下问题:
|
||||
1. 单双周课程解析逻辑存在缺陷,无法正确识别单周/双周/每周模式
|
||||
2. 课程无法动态移动,第一列始终显示进行中的课程,但存在无法正常移动的问题
|
||||
3. 缺少用户拖动交互功能
|
||||
4. 缺少拖动后的自动复位机制
|
||||
|
||||
## What Changes
|
||||
|
||||
- 修复 ClassIsland 课程单双周解析逻辑
|
||||
- 实现课程动态移动机制(当前课程结束自动上移)
|
||||
- 实现课程表上下拖动交互功能
|
||||
- 实现自动复位功能(课程结束后视图复位到最新进行中课程)
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected specs
|
||||
- 课程表组件功能规范
|
||||
|
||||
### Affected code
|
||||
- `Services/ClassIslandScheduleDataService.cs` - 课表解析服务
|
||||
- `Views/Components/ClassScheduleWidget.axaml.cs` - 课表组件
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 单双周课程解析
|
||||
|
||||
系统 SHALL 能够正确解析包含单双周信息的课程数据。
|
||||
|
||||
#### Scenario: 单周课程
|
||||
- **WHEN** 课程设置为单周上课
|
||||
- **THEN** 课程仅在单周显示
|
||||
|
||||
#### Scenario: 双周课程
|
||||
- **WHEN** 课程设置为双周上课
|
||||
- **THEN** 课程仅在双周显示
|
||||
|
||||
#### Scenario: 每周课程
|
||||
- **WHEN** 课程设置为每周上课
|
||||
- **THEN** 课程在所有周显示
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 课程动态移动
|
||||
|
||||
系统 SHALL 实现课程的动态移动机制。
|
||||
|
||||
#### Scenario: 课程结束自动上移
|
||||
- **WHEN** 当前进行中的课程结束
|
||||
- **THEN** 课程列表自动向上移动
|
||||
- **AND THEN** 下一个进行中或即将开始的课程移至视图可见区域
|
||||
|
||||
#### Scenario: 新课程移入视图
|
||||
- **WHEN** 新的课程即将开始
|
||||
- **THEN** 该课程自动移至视图可见区域
|
||||
|
||||
#### Scenario: 当日课程全部结束
|
||||
- **WHEN** 当日所有课程已结束
|
||||
- **THEN** 自动显示次日课程表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 拖动交互功能
|
||||
|
||||
系统 SHALL 提供课程表的上下拖动功能。
|
||||
|
||||
#### Scenario: 拖动查看课程
|
||||
- **WHEN** 用户在课程表区域进行上下拖动
|
||||
- **THEN** 课程列表随拖动方向滚动
|
||||
- **AND THEN** 拖动操作流畅、响应及时
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 自动复位功能
|
||||
|
||||
系统 SHALL 在用户手动拖动后自动复位到当前课程。
|
||||
|
||||
#### Scenario: 当前课程结束触发复位
|
||||
- **WHEN** 用户手动拖动课程表后,当前课程结束
|
||||
- **THEN** 视图自动复位到显示最新进行中课程的位置
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 课程解析逻辑
|
||||
|
||||
**当前**: 单双周解析可能存在缺陷
|
||||
|
||||
**修改后**: 正确识别 WeekCountDiv 和 WeekCountDivTotal 参数,准确判断单周/双周/每周模式
|
||||
|
||||
---
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
(无)
|
||||
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal file
61
.trae/specs/class-schedule-enhancement/tasks.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Tasks
|
||||
|
||||
## 1. 课表单双周解析修复
|
||||
|
||||
- [x] Task 1.1: 分析 ClassIsland 课表单双周数据结构
|
||||
- [x] 分析 ClassIsland Schedule.json 和 Profile.json 中的周数规则字段
|
||||
- [x] 确认 WeekCountDiv 和 WeekCountDivTotal 的含义和取值范围
|
||||
|
||||
- [x] Task 1.2: 修复 GetCyclePositionsByDate 方法
|
||||
- [x] 检查单周开始日期的计算逻辑
|
||||
- [x] 修复周期位置计算公式
|
||||
|
||||
- [x] Task 1.3: 修复 CheckRegularClassPlan 方法
|
||||
- [x] 验证 weekCountDiv 和 weekCountDivTotal 的匹配逻辑
|
||||
- [x] 确保单周=1、双周=2、每周=0 的正确处理
|
||||
|
||||
## 2. 课程动态移动功能
|
||||
|
||||
- [x] Task 2.1: 分析当前课程状态检测逻辑
|
||||
- [x] 查看如何判断课程是否为"当前进行中"
|
||||
|
||||
- [x] Task 2.2: 实现定时刷新机制
|
||||
- [x] 增加更频繁的刷新定时器(每分钟检查一次)
|
||||
- [x] 实现课程状态变化检测
|
||||
|
||||
- [x] Task 2.3: 实现动态移动逻辑
|
||||
- [x] 课程结束后自动上移
|
||||
- [x] 新课程自动移入视图
|
||||
|
||||
- [x] Task 2.4: 实现次日课程切换
|
||||
- [x] 当日所有课程结束后自动切换到次日
|
||||
|
||||
## 3. 拖动交互功能
|
||||
|
||||
- [x] Task 3.1: 实现 ScrollViewer 包裹
|
||||
- [x] 修改 XAML 使用 ScrollViewer 包裹课程列表
|
||||
|
||||
- [x] Task 3.2: 实现拖动手势处理
|
||||
- [x] 添加 PointerPressed/PointerMoved/PointerReleased 处理
|
||||
- [x] 实现平滑滚动逻辑
|
||||
|
||||
## 4. 自动复位功能
|
||||
|
||||
- [x] Task 4.1: 记录用户拖动状态
|
||||
- [x] 添加用户是否手动拖动的标志位
|
||||
|
||||
- [x] Task 4.2: 实现自动复位逻辑
|
||||
- [x] 检测当前课程变化
|
||||
- [x] 当用户手动拖动且当前课程变化时自动复位
|
||||
|
||||
# Task Dependencies
|
||||
|
||||
- Task 1.1 -> Task 1.2 -> Task 1.3
|
||||
- Task 2.1 -> Task 2.2 -> Task 2.3 -> Task 2.4
|
||||
- Task 3.1 -> Task 3.2
|
||||
- Task 4.1 -> Task 4.2
|
||||
|
||||
# Parallelizable Tasks
|
||||
|
||||
- Task 1.x (解析修复) 与 Task 3.x (拖动) 可以并行开发
|
||||
- Task 2.x (动态移动) 可以在 Task 1 完成后进行
|
||||
@@ -1,21 +1,33 @@
|
||||
# LanAirApp
|
||||
# LanAirApp (Mirror)
|
||||
|
||||
## 中文
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
这里的 `LanAirApp/` 是放在宿主仓库里的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源。
|
||||
|
||||
### 目录说明
|
||||
### 这份镜像的角色
|
||||
|
||||
- `docs/`:插件开发与打包文档。
|
||||
- `samples/`:示例插件与参考项目。
|
||||
- `standards/`:插件清单和目录结构约定。
|
||||
- `tools/`:插件打包与辅助工具。
|
||||
- 提供本地工作区里的 `airappmarket` 索引副本
|
||||
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
|
||||
- 不承担宿主运行时职责
|
||||
|
||||
### 与宿主的关系
|
||||
### 权威来源
|
||||
|
||||
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
|
||||
- 插件市场与开发文档:独立 `LanAirApp` 仓库
|
||||
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
|
||||
|
||||
## English
|
||||
|
||||
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
|
||||
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
|
||||
|
||||
### Role of this mirror
|
||||
|
||||
- keep a local copy of the `airappmarket` index for workspace integration
|
||||
- keep mirrored docs, tools, and sample templates for local development
|
||||
- avoid duplicating host runtime responsibilities
|
||||
|
||||
### Sources of truth
|
||||
|
||||
- Plugin market and developer docs: standalone `LanAirApp`
|
||||
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||
[Obsolete("Plugin API 3.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||
public interface IPluginContext : IPluginRuntimeContext
|
||||
{
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>2.0.0</Version>
|
||||
<Version>3.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -63,6 +63,7 @@ public partial class App : Application
|
||||
private bool _uiUnhandledExceptionHooked;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
|
||||
@@ -569,6 +570,18 @@ public partial class App : Application
|
||||
_exitCleanupCompleted = true;
|
||||
_settingsFacade.Settings.Changed -= OnSettingsChanged;
|
||||
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
|
||||
|
||||
try
|
||||
{
|
||||
var (analytics, crashReport) = App.AnalyticsServices;
|
||||
analytics?.SendShutdownEvent();
|
||||
crashReport?.SendShutdownEvent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public static class BuiltInComponentIds
|
||||
{
|
||||
@@ -40,4 +40,5 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopWhiteboard = "DesktopWhiteboard";
|
||||
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
|
||||
public const string DesktopBrowser = "DesktopBrowser";
|
||||
public const string DesktopOfficeRecentDocuments = "DesktopOfficeRecentDocuments";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public static class ComponentColorSchemeHelper
|
||||
{
|
||||
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
|
||||
{
|
||||
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string GetCurrentGlobalThemeColorMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
var service = HostAppearanceThemeProvider.GetOrCreate();
|
||||
var appearance = service.GetCurrent();
|
||||
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return ThemeAppearanceValues.ColorModeDefaultNeutral;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
@@ -327,6 +327,15 @@ public sealed class ComponentRegistry
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||
"Office Recent Documents",
|
||||
"Folder",
|
||||
"File",
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.Date,
|
||||
"Calendar",
|
||||
|
||||
@@ -27,8 +27,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -56,6 +55,8 @@
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
<PackageReference Include="MaterialColorUtilities" Version="0.3.0" />
|
||||
<PackageReference Include="PostHog" Version="2.4.0" />
|
||||
<PackageReference Include="Sentry" Version="4.0.0" />
|
||||
<PackageReference Include="System.Runtime.WindowsRuntime" Version="4.7.0" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="10.0.0" />
|
||||
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23" Condition="('$(RuntimeIdentifier)' == '' and $([MSBuild]::IsOSPlatform('Windows')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 or '$(RuntimeIdentifier)' == 'win-x86'" />
|
||||
@@ -69,17 +70,13 @@
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
|
||||
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)" DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
|
||||
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)" DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -255,7 +255,6 @@
|
||||
"settings.color.use_system_chrome_toggle": "Use system window chrome",
|
||||
"settings.color.theme_color_label": "Theme accent color",
|
||||
"settings.appearance.theme_color_mode_label": "Theme color source",
|
||||
"settings.appearance.system_material_label": "System material",
|
||||
"settings.appearance.theme_color_mode.neutral": "Default neutral",
|
||||
"settings.appearance.theme_color_mode.user": "User theme color Monet",
|
||||
"settings.appearance.theme_color_mode.wallpaper": "Wallpaper Monet",
|
||||
@@ -265,6 +264,8 @@
|
||||
"settings.appearance.theme_color_preview.app": "Currently previewing colors extracted from the app wallpaper.",
|
||||
"settings.appearance.theme_color_preview.system": "Currently previewing colors extracted from the system wallpaper.",
|
||||
"settings.appearance.theme_color_preview.fallback": "No usable wallpaper was found. The app is using a fallback accent.",
|
||||
"component.color_scheme.follow_system": "Follow system color scheme",
|
||||
"component.color_scheme.native": "Use component custom color scheme",
|
||||
"settings.appearance.system_material.none": "None",
|
||||
"settings.appearance.system_material.mica": "Mica",
|
||||
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||
@@ -580,6 +581,7 @@
|
||||
"component.whiteboard": "Blackboard (Portrait)",
|
||||
"component.blackboard_landscape": "Blackboard (Landscape)",
|
||||
"component.browser": "Browser",
|
||||
"component.office_recent_documents": "Recent Documents",
|
||||
"component.holiday_calendar": "Holiday Calendar",
|
||||
"component.study_environment": "Environment",
|
||||
"component.study_session_control": "Study Session Control",
|
||||
|
||||
@@ -260,7 +260,6 @@
|
||||
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
|
||||
"settings.color.theme_color_label": "主题强调色",
|
||||
"settings.appearance.theme_color_mode_label": "主题色来源",
|
||||
"settings.appearance.system_material_label": "系统材质",
|
||||
"settings.appearance.theme_color_mode.neutral": "默认中性",
|
||||
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
|
||||
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
|
||||
@@ -270,6 +269,8 @@
|
||||
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
|
||||
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
|
||||
"component.color_scheme.follow_system": "跟随系统配色",
|
||||
"component.color_scheme.native": "使用组件自定义配色",
|
||||
"settings.appearance.system_material.none": "无",
|
||||
"settings.appearance.system_material.mica": "Mica",
|
||||
"settings.appearance.system_material.acrylic": "Acrylic",
|
||||
@@ -585,6 +586,7 @@
|
||||
"component.whiteboard": "竖向小黑板",
|
||||
"component.blackboard_landscape": "横向小黑板",
|
||||
"component.browser": "浏览器",
|
||||
"component.office_recent_documents": "最近文档",
|
||||
"component.holiday_calendar": "节假日日历",
|
||||
"component.study_environment": "环境",
|
||||
"component.study_session_control": "自习时段控制",
|
||||
|
||||
@@ -70,6 +70,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool UploadAnonymousUsageData { get; set; }
|
||||
|
||||
public string? DeviceId { get; set; }
|
||||
|
||||
public string UpdateChannel { get; set; } = "stable";
|
||||
|
||||
public string UpdateMode { get; set; } = "download_then_confirm";
|
||||
|
||||
@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
public string? ColorSchemeSource { get; set; }
|
||||
|
||||
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
|
||||
|
||||
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
|
||||
|
||||
@@ -7,6 +7,7 @@ using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Sentry;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -14,14 +15,14 @@ sealed class Program
|
||||
{
|
||||
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
|
||||
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
RegisterGlobalExceptionLogging();
|
||||
InitializeDeviceId();
|
||||
InitializeCrashReporting();
|
||||
InitializeUserBehaviorAnalytics();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
@@ -49,6 +50,7 @@ sealed class Program
|
||||
StartupRenderMode = renderMode;
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
App.AnalyticsServices = (_userBehaviorAnalyticsService, _crashReportService);
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
@@ -63,7 +65,6 @@ sealed class Program
|
||||
}
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
@@ -151,7 +152,6 @@ sealed class Program
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// The previous process already exited before we started waiting.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -167,6 +167,11 @@ sealed class Program
|
||||
"UnhandledException",
|
||||
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
||||
eventArgs.ExceptionObject as Exception);
|
||||
|
||||
if (eventArgs.IsTerminating)
|
||||
{
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||
@@ -175,4 +180,187 @@ sealed class Program
|
||||
eventArgs.SetObserved();
|
||||
};
|
||||
}
|
||||
|
||||
private static void InitializeDeviceId()
|
||||
{
|
||||
try
|
||||
{
|
||||
DeviceIdService.Initialize(HostSettingsFacadeProvider.GetOrCreate());
|
||||
AppLogger.Info("Startup", $"DeviceId initialized: {DeviceIdService.Instance.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize DeviceIdService.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeSentryForAnalytics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var deviceId = DeviceIdService.Instance.DeviceId;
|
||||
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
options.AutoSessionTracking = true;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = deviceId
|
||||
};
|
||||
|
||||
scope.SetTag("data_type", "analytics");
|
||||
scope.SetTag("device_id", deviceId);
|
||||
scope.SetTag("app_version", GetAppVersion());
|
||||
scope.SetTag("os_name", GetOsName());
|
||||
scope.SetTag("os_version", GetOsVersion());
|
||||
scope.SetTag("os_build", GetOsBuild());
|
||||
scope.SetTag("device_model", GetDeviceModel());
|
||||
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||
scope.SetTag("processor_count", GetProcessorCount().ToString());
|
||||
scope.SetTag("total_memory_mb", GetTotalMemoryMB().ToString());
|
||||
scope.SetTag("runtime_version", GetRuntimeVersion());
|
||||
scope.SetTag("language", GetSystemLanguage());
|
||||
scope.SetTag("clr_version", GetClrVersion());
|
||||
scope.SetTag("is_64bit", Environment.Is64BitOperatingSystem.ToString());
|
||||
});
|
||||
|
||||
SentrySdk.CaptureMessage("user_active");
|
||||
|
||||
AppLogger.Info("Startup", $"Analytics service initialized. DeviceId={deviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize analytics service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var version = typeof(Program).Assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private static string GetOsName()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return "Windows";
|
||||
if (OperatingSystem.IsLinux()) return "Linux";
|
||||
if (OperatingSystem.IsMacOS()) return "macOS";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetOsBuild()
|
||||
{
|
||||
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return "Windows PC";
|
||||
if (OperatingSystem.IsLinux()) return "Linux PC";
|
||||
if (OperatingSystem.IsMacOS()) return "Mac";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return Environment.Is64BitOperatingSystem ? "x64" : "x86";
|
||||
}
|
||||
|
||||
private static int GetProcessorCount()
|
||||
{
|
||||
return Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
private static long GetTotalMemoryMB()
|
||||
{
|
||||
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static string GetRuntimeVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetClrVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static CrashReportService? _crashReportService;
|
||||
private static UserBehaviorAnalyticsService? _userBehaviorAnalyticsService;
|
||||
|
||||
private static void InitializeCrashReporting()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_crashReportService = new CrashReportService(settingsFacade, DeviceIdService.Instance);
|
||||
_crashReportService.RefreshEnabledState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize crash reporting service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeUserBehaviorAnalytics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
|
||||
_userBehaviorAnalyticsService = new UserBehaviorAnalyticsService(settingsFacade, DeviceIdService.Instance);
|
||||
_userBehaviorAnalyticsService.Initialize();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to initialize user behavior analytics service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetReleaseVersion()
|
||||
{
|
||||
var assembly = typeof(Program).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
if (version is null)
|
||||
{
|
||||
return "1.0.0";
|
||||
}
|
||||
return version.Major >= 0 ? $"{version.Major}.{version.Minor}.{version.Build}" : "1.0.0";
|
||||
}
|
||||
|
||||
private static string GetEnvironment()
|
||||
{
|
||||
#if DEBUG
|
||||
return "development";
|
||||
#else
|
||||
return "production";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,6 +248,15 @@ internal sealed class WindowMaterialService : IWindowMaterialService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(window);
|
||||
|
||||
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
|
||||
|
||||
if (normalizedMode == ThemeAppearanceValues.MaterialNone)
|
||||
{
|
||||
window.Background = Brushes.White;
|
||||
window.TransparencyLevelHint = [WindowTransparencyLevel.None];
|
||||
return;
|
||||
}
|
||||
|
||||
window.Background = Brushes.Transparent;
|
||||
|
||||
if (!OperatingSystem.IsWindows() || !IsTransparencyEnabled())
|
||||
@@ -259,7 +268,6 @@ internal sealed class WindowMaterialService : IWindowMaterialService
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedMode = ThemeAppearanceValues.NormalizeSystemMaterialMode(materialMode);
|
||||
window.TransparencyLevelHint = normalizedMode switch
|
||||
{
|
||||
ThemeAppearanceValues.MaterialMica =>
|
||||
|
||||
@@ -163,7 +163,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
var totalElapsedWeeks = (int)Math.Floor(
|
||||
(referenceDate.ToDateTime(TimeOnly.MinValue) - cycleRule.SingleWeekStartDate.Value.ToDateTime(TimeOnly.MinValue)).TotalDays / 7d);
|
||||
|
||||
for (var cycleLength = 2; cycleLength <= maxCycle; cycleLength++)
|
||||
for (var cycleLength = 1; cycleLength <= maxCycle; cycleLength++)
|
||||
{
|
||||
var cycleOffset = cycleLength < cycleRule.MultiWeekRotationOffset.Count
|
||||
? cycleRule.MultiWeekRotationOffset[cycleLength]
|
||||
@@ -668,7 +668,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
|
||||
return true;
|
||||
}
|
||||
|
||||
if (weekCountDivTotal <= 1 || weekCountDivTotal >= cyclePositions.Count)
|
||||
if (weekCountDivTotal <= 0 || weekCountDivTotal >= cyclePositions.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
@@ -0,0 +1,943 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Sentry;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class DeviceIdService
|
||||
{
|
||||
private static DeviceIdService? _instance;
|
||||
private string? _deviceId;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private bool _isInitialized;
|
||||
|
||||
public static DeviceIdService Instance => _instance ?? throw new InvalidOperationException("DeviceIdService not initialized");
|
||||
|
||||
public DeviceIdService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
}
|
||||
|
||||
public static void Initialize(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_instance = new DeviceIdService(settingsFacade);
|
||||
_instance.EnsureDeviceId();
|
||||
}
|
||||
|
||||
public string DeviceId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_deviceId is null)
|
||||
{
|
||||
throw new InvalidOperationException("DeviceId not initialized");
|
||||
}
|
||||
return _deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDeviceId()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
if (string.IsNullOrEmpty(snapshot.DeviceId))
|
||||
{
|
||||
snapshot.DeviceId = GenerateDeviceId();
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
|
||||
_deviceId = snapshot.DeviceId;
|
||||
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_deviceId = snapshot.DeviceId;
|
||||
AppLogger.Info("DeviceId", $"Loaded existing device ID: {_deviceId}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_deviceId = GenerateDeviceId();
|
||||
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateDeviceId()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{timestamp}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||
return Convert.ToHexString(hash)[..32].ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
{
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||
|
||||
private bool _isEnabled;
|
||||
private bool _isInitialized;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly DeviceIdService _deviceIdService;
|
||||
private readonly Queue<UserBehaviorEvent> _eventQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
private System.Threading.Timer? _flushTimer;
|
||||
private readonly PluginSdk.ISettingsService _settingsService;
|
||||
|
||||
public UserBehaviorAnalyticsService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||
e.ChangedKeys is not null &&
|
||||
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||
{
|
||||
AppLogger.Info("UserBehaviorAnalytics", "Settings changed, refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
RefreshEnabledState();
|
||||
|
||||
try
|
||||
{
|
||||
_flushTimer = new System.Threading.Timer(
|
||||
_ => FlushEvents(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
CaptureEvent("app_online", new Dictionary<string, object>
|
||||
{
|
||||
{ "event_type", "app_start" }
|
||||
});
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to initialize analytics.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackClick(string componentName, string? action = null)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("ui_click", new Dictionary<string, object>
|
||||
{
|
||||
{ "component", componentName },
|
||||
{ "action", action ?? "click" }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackComponentDrag(string componentId, string action)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("component_drag", new Dictionary<string, object>
|
||||
{
|
||||
{ "component_id", componentId },
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackComponentDrop(string componentId, string targetPosition)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("component_drop", new Dictionary<string, object>
|
||||
{
|
||||
{ "component_id", componentId },
|
||||
{ "target_position", targetPosition }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsOpen(string settingsPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_open", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_change", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage },
|
||||
{ "key", settingKey },
|
||||
{ "old_value", oldValue ?? "" },
|
||||
{ "new_value", newValue }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsClose(string settingsPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_close", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackUpdateAction(string action, string? version = null)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var props = new Dictionary<string, object>
|
||||
{
|
||||
{ "action", action }
|
||||
};
|
||||
|
||||
if (version is not null)
|
||||
{
|
||||
props["version"] = version;
|
||||
}
|
||||
|
||||
CaptureEvent("update_action", props);
|
||||
}
|
||||
|
||||
public void TrackRestartAction(string action)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("restart_action", new Dictionary<string, object>
|
||||
{
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackNavigation(string fromPage, string toPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("navigation", new Dictionary<string, object>
|
||||
{
|
||||
{ "from", fromPage },
|
||||
{ "to", toPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void SendCrashEvent()
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "app_version", GetAppVersion() },
|
||||
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "event_type", "app_crash" }
|
||||
};
|
||||
|
||||
CaptureEvent("app_crash", properties);
|
||||
FlushEvents();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Crash event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send crash event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void SendShutdownEvent()
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "app_version", GetAppVersion() },
|
||||
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "event_type", "app_shutdown" }
|
||||
};
|
||||
|
||||
if (_isEnabled)
|
||||
{
|
||||
properties["os_name"] = GetOsName();
|
||||
properties["os_version"] = GetOsVersion();
|
||||
properties["device_name"] = GetDeviceName();
|
||||
properties["device_model"] = GetDeviceModel();
|
||||
properties["device_arch"] = GetDeviceArchitecture();
|
||||
properties["language"] = GetSystemLanguage();
|
||||
}
|
||||
|
||||
CaptureEvent("app_shutdown", properties);
|
||||
FlushEvents();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send shutdown event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshEnabledState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var newEnabled = snapshot.UploadAnonymousUsageData;
|
||||
|
||||
if (_isEnabled != newEnabled)
|
||||
{
|
||||
_isEnabled = newEnabled;
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"User behavior analytics enabled state changed to '{_isEnabled}'.");
|
||||
|
||||
if (_isEnabled && _isInitialized)
|
||||
{
|
||||
CaptureEvent("analytics_enabled", new Dictionary<string, object>());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
|
||||
_isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void CaptureEvent(string eventName, Dictionary<string, object>? properties = null)
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var eventData = new UserBehaviorEvent
|
||||
{
|
||||
Event = eventName,
|
||||
DistinctId = _deviceIdService.DeviceId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Properties = properties ?? new Dictionary<string, object>(),
|
||||
IncludeDetailedData = _isEnabled
|
||||
};
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Enqueue(eventData);
|
||||
|
||||
if (_eventQueue.Count >= 20)
|
||||
{
|
||||
FlushEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"Failed to capture event '{eventName}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void CapturePageView(string pageName, string? sourcePage = null)
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "page_name", pageName }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sourcePage))
|
||||
{
|
||||
properties["source_page"] = sourcePage;
|
||||
}
|
||||
|
||||
CaptureEvent("page_view", properties);
|
||||
}
|
||||
|
||||
public void CaptureFeatureUsage(string featureName, string action)
|
||||
{
|
||||
CaptureEvent("feature_usage", new Dictionary<string, object>
|
||||
{
|
||||
{ "feature_name", featureName },
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
private void FlushEvents()
|
||||
{
|
||||
List<UserBehaviorEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<UserBehaviorEvent>();
|
||||
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||
{
|
||||
eventsToSend.Add(_eventQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SendEventsToPostHog(eventsToSend);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog.", ex);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var evt in eventsToSend)
|
||||
{
|
||||
if (_eventQueue.Count < 100)
|
||||
{
|
||||
_eventQueue.Enqueue(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendEventsToPostHog(List<UserBehaviorEvent> events)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Http.HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
var firstEvent = events.FirstOrDefault();
|
||||
if (firstEvent is not null)
|
||||
{
|
||||
SendIdentifyToPostHog(client, firstEvent.DistinctId);
|
||||
}
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "distinct_id", e.DistinctId }
|
||||
};
|
||||
|
||||
if (e.IncludeDetailedData)
|
||||
{
|
||||
properties["$os"] = GetOsName();
|
||||
properties["$os_version"] = GetOsVersion();
|
||||
properties["$app_version"] = GetAppVersion();
|
||||
properties["$device_id"] = e.DistinctId;
|
||||
}
|
||||
|
||||
foreach (var kvp in e.Properties)
|
||||
{
|
||||
properties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
var requestBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "api_key", PostHogApiKey },
|
||||
{ "event", e.Event },
|
||||
{ "timestamp", e.Timestamp.ToString("o") },
|
||||
{ "properties", properties }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog API error for event '{e.Event}': {response.StatusCode} - {responseBody}");
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Successfully sent {events.Count} events to PostHog.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog API.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendIdentifyToPostHog(System.Net.Http.HttpClient client, string distinctId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userProperties = new Dictionary<string, object>
|
||||
{
|
||||
{ "$device_id", distinctId },
|
||||
{ "$app_version", GetAppVersion() },
|
||||
{ "$os", GetOsName() },
|
||||
{ "$os_version", GetOsVersion() }
|
||||
};
|
||||
|
||||
var requestBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "api_key", PostHogApiKey },
|
||||
{ "event", "$identify" },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "properties", new Dictionary<string, object>
|
||||
{
|
||||
{ "distinct_id", distinctId },
|
||||
{ "$set", userProperties }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"PostHog identify response: {response.StatusCode}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog identify failed: {response.StatusCode} - {responseBody}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send identify to PostHog.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> GetEventProperties(UserBehaviorEvent e)
|
||||
{
|
||||
var props = new Dictionary<string, object>
|
||||
{
|
||||
{ "$os", GetOsName() },
|
||||
{ "$os_version", GetOsVersion() },
|
||||
{ "$app_version", GetAppVersion() },
|
||||
{ "$device_id", e.DistinctId }
|
||||
};
|
||||
|
||||
foreach (var kvp in e.Properties)
|
||||
{
|
||||
props[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _isEnabled;
|
||||
|
||||
public string DeviceId => _deviceIdService.DeviceId;
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var assembly = typeof(UserBehaviorAnalyticsService).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private 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";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
var osDesc = RuntimeInformation.OSDescription;
|
||||
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||
if (osDesc.Contains("Darwin")) return "Mac";
|
||||
return osDesc;
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture.ToString();
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetOsBuild()
|
||||
{
|
||||
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
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 GetClrVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static string GetDotNetVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_flushTimer?.Dispose();
|
||||
FlushEvents();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Error disposing analytics service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private class UserBehaviorEvent
|
||||
{
|
||||
public string Event { get; set; } = string.Empty;
|
||||
public string DistinctId { get; set; } = string.Empty;
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public Dictionary<string, object> Properties { get; set; } = new();
|
||||
public bool IncludeDetailedData { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class DictionaryExtensions
|
||||
{
|
||||
public static Dictionary<string, object> Merge(this Dictionary<string, object> first, Dictionary<string, object> second)
|
||||
{
|
||||
var result = new Dictionary<string, object>(first);
|
||||
foreach (var kvp in second)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CrashReportService
|
||||
{
|
||||
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
|
||||
private bool _isInitialized;
|
||||
private bool _isEnabled;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly DeviceIdService _deviceIdService;
|
||||
private readonly PluginSdk.ISettingsService _settingsService;
|
||||
|
||||
public CrashReportService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||
e.ChangedKeys is not null &&
|
||||
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||
{
|
||||
AppLogger.Info("CrashReport", "Settings changed, refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshEnabledState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var newEnabled = snapshot.UploadAnonymousCrashData;
|
||||
|
||||
if (_isEnabled != newEnabled)
|
||||
{
|
||||
_isEnabled = newEnabled;
|
||||
AppLogger.Info("CrashReport", $"Crash reporting enabled state changed to '{_isEnabled}'.");
|
||||
|
||||
if (_isEnabled && !_isInitialized)
|
||||
{
|
||||
InitializeSentry();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to refresh crash reporting enabled state.", ex);
|
||||
_isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeSentry()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
try
|
||||
{
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = SentryDsn;
|
||||
options.AutoSessionTracking = true;
|
||||
options.AttachStacktrace = true;
|
||||
options.MaxBreadcrumbs = 100;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
|
||||
ConfigureCrashReportingScope();
|
||||
|
||||
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
|
||||
|
||||
#if DEBUG
|
||||
SentrySdk.CaptureMessage($"Crash reporting enabled - Debug mode test. DeviceId={_deviceIdService.DeviceId}");
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to initialize Sentry crash reporting.", ex);
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureCrashReportingScope()
|
||||
{
|
||||
try
|
||||
{
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = _deviceIdService.DeviceId
|
||||
};
|
||||
|
||||
scope.SetTag("data_type", "crash_report");
|
||||
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||
scope.SetTag("device_name", GetDeviceName());
|
||||
scope.SetTag("device_model", GetDeviceModel());
|
||||
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||
scope.SetTag("os_name", GetOsName());
|
||||
scope.SetTag("os_version", GetOsVersion());
|
||||
scope.SetTag("language", GetSystemLanguage());
|
||||
});
|
||||
|
||||
AppLogger.Info("CrashReport", $"Crash reporting scope configured. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to configure crash reporting scope.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEnabled => _isEnabled;
|
||||
|
||||
public string DeviceId => _deviceIdService.DeviceId;
|
||||
|
||||
public void SendShutdownEvent()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_isEnabled && _isInitialized)
|
||||
{
|
||||
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isInitialized)
|
||||
{
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = SentryDsn;
|
||||
options.AutoSessionTracking = false;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
}
|
||||
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = _deviceIdService.DeviceId
|
||||
};
|
||||
scope.SetTag("data_type", "shutdown");
|
||||
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||
scope.SetTag("app_version", GetAppVersion());
|
||||
});
|
||||
|
||||
SentrySdk.CaptureMessage($"app_shutdown - DeviceId={_deviceIdService.DeviceId}");
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||
|
||||
AppLogger.Info("CrashReport", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to send shutdown event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
var osDesc = RuntimeInformation.OSDescription;
|
||||
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||
if (osDesc.Contains("Darwin")) return "Mac";
|
||||
return osDesc;
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture.ToString();
|
||||
}
|
||||
|
||||
private 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";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var version = typeof(CrashReportService).Assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private static string GetEnvironment()
|
||||
{
|
||||
#if DEBUG
|
||||
return "development";
|
||||
#else
|
||||
return "production";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
153
LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
Normal file
153
LanMountainDesktop/Services/OfficeRecentDocumentsService.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IOfficeRecentDocumentsService
|
||||
{
|
||||
List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20);
|
||||
void OpenDocument(string filePath);
|
||||
}
|
||||
|
||||
public sealed class OfficeRecentDocument
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string Extension { get; set; } = string.Empty;
|
||||
public DateTime LastModifiedTime { get; set; }
|
||||
public long FileSizeBytes { get; set; }
|
||||
public string IconGlyph { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class OfficeRecentDocumentsService : IOfficeRecentDocumentsService
|
||||
{
|
||||
private static readonly string[] OfficeExtensions = { ".doc", ".docx", ".dot", ".dotx", ".rtf" };
|
||||
private static readonly string[] ExcelExtensions = { ".xls", ".xlsx", ".xlsm", ".xlsb", ".csv" };
|
||||
private static readonly string[] PowerPointExtensions = { ".ppt", ".pptx", ".pptm", ".pps", ".ppsx" };
|
||||
|
||||
public List<OfficeRecentDocument> GetRecentDocuments(int maxCount = 20)
|
||||
{
|
||||
var documents = new List<OfficeRecentDocument>();
|
||||
var recentPaths = GetRecentFolders();
|
||||
|
||||
foreach (var recentPath in recentPaths)
|
||||
{
|
||||
if (!Directory.Exists(recentPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var files = Directory.GetFiles(recentPath, "*.lnk");
|
||||
foreach (var lnkPath in files)
|
||||
{
|
||||
var targetPath = GetShortcutTarget(lnkPath);
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(targetPath).ToLowerInvariant();
|
||||
if (!IsOfficeFile(extension))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(targetPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(targetPath);
|
||||
var doc = new OfficeRecentDocument
|
||||
{
|
||||
FileName = Path.GetFileNameWithoutExtension(targetPath),
|
||||
FilePath = targetPath,
|
||||
Extension = extension,
|
||||
LastModifiedTime = fileInfo.LastWriteTime,
|
||||
FileSizeBytes = fileInfo.Length,
|
||||
IconGlyph = GetIconGlyph(extension)
|
||||
};
|
||||
|
||||
if (!documents.Any(d => d.FilePath == targetPath))
|
||||
{
|
||||
documents.Add(doc);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return documents
|
||||
.OrderByDescending(d => d.LastModifiedTime)
|
||||
.Take(maxCount)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public void OpenDocument(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = filePath,
|
||||
UseShellExecute = true
|
||||
};
|
||||
Process.Start(startInfo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetRecentFolders()
|
||||
{
|
||||
var folders = new List<string>();
|
||||
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Word", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "Excel", "Recent"));
|
||||
folders.Add(Path.Combine(appData, "Microsoft", "PowerPoint", "Recent"));
|
||||
|
||||
return folders;
|
||||
}
|
||||
|
||||
private static bool IsOfficeFile(string extension)
|
||||
{
|
||||
return OfficeExtensions.Contains(extension) ||
|
||||
ExcelExtensions.Contains(extension) ||
|
||||
PowerPointExtensions.Contains(extension);
|
||||
}
|
||||
|
||||
private static string GetIconGlyph(string extension)
|
||||
{
|
||||
return extension switch
|
||||
{
|
||||
".doc" or ".docx" or ".dot" or ".dotx" or ".rtf" => "\uE8A5",
|
||||
".xls" or ".xlsx" or ".xlsm" or ".xlsb" or ".csv" => "\uE9F9",
|
||||
".ppt" or ".pptx" or ".pptm" or ".pps" or ".ppsx" => "\uE8A1",
|
||||
_ => "\uE8A5"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetShortcutTarget(string lnkPath)
|
||||
{
|
||||
return ShortcutHelper.GetShortcutTarget(lnkPath);
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,7 @@ internal sealed class PrivacySettingsService : IPrivacySettingsService
|
||||
var snapshot = _settingsService.Load();
|
||||
snapshot.UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||
snapshot.UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||
AppLogger.Info("PrivacySettings", $"Saving: UploadAnonymousCrashData={state.UploadAnonymousCrashData}, UploadAnonymousUsageData={state.UploadAnonymousUsageData}");
|
||||
_settingsService.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
|
||||
32
LanMountainDesktop/Services/ShortcutHelper.cs
Normal file
32
LanMountainDesktop/Services/ShortcutHelper.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class ShortcutHelper
|
||||
{
|
||||
[ComImport]
|
||||
[Guid("72C24DD5-D70A-438B-8A42-98424B88AFB8")]
|
||||
internal class WshShell { }
|
||||
|
||||
[ComImport]
|
||||
[Guid("F935DC21-1CF0-11D0-ADB9-00C04FD58A0B")]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
|
||||
internal interface IWshShortcut
|
||||
{
|
||||
string TargetPath { get; set; }
|
||||
}
|
||||
|
||||
public static string? GetShortcutTarget(string lnkPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
dynamic shell = new WshShell();
|
||||
dynamic shortcut = shell.CreateShortcut(lnkPath);
|
||||
return shortcut.TargetPath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,9 @@ public static class ThemeAppearanceValues
|
||||
public const string ColorModeSeedMonet = "seed_monet";
|
||||
public const string ColorModeWallpaperMonet = "wallpaper_monet";
|
||||
|
||||
public const string ColorSchemeFollowSystem = "follow_system";
|
||||
public const string ColorSchemeNative = "native";
|
||||
|
||||
public const string MaterialNone = "none";
|
||||
public const string MaterialMica = "mica";
|
||||
public const string MaterialAcrylic = "acrylic";
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.ViewModels;
|
||||
|
||||
@@ -28,6 +32,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private bool _uploadAnonymousUsageData;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _deviceId = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _privacyHeader = string.Empty;
|
||||
|
||||
@@ -43,11 +50,47 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private string _usageUploadDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _deviceIdHeader = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _deviceIdDescription = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _refreshDeviceIdText = string.Empty;
|
||||
|
||||
public void Load()
|
||||
{
|
||||
var state = _settingsFacade.Privacy.Get();
|
||||
UploadAnonymousCrashData = state.UploadAnonymousCrashData;
|
||||
UploadAnonymousUsageData = state.UploadAnonymousUsageData;
|
||||
DeviceId = DeviceIdService.Instance.DeviceId;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void RefreshDeviceId()
|
||||
{
|
||||
try
|
||||
{
|
||||
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||
var newDeviceId = Convert.ToHexString(hash)[..32].ToLower();
|
||||
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<Models.AppSettingsSnapshot>(SettingsScope.App);
|
||||
snapshot.DeviceId = newDeviceId;
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(Models.AppSettingsSnapshot.DeviceId)]);
|
||||
|
||||
DeviceId = newDeviceId;
|
||||
AppLogger.Info("PrivacySettings", $"Device ID refreshed: {newDeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PrivacySettings", "Failed to refresh device ID.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnUploadAnonymousCrashDataChanged(bool value)
|
||||
@@ -84,6 +127,9 @@ public sealed partial class PrivacySettingsPageViewModel : ViewModelBase
|
||||
CrashUploadDescription = L("settings.privacy.crash_upload_description", "Help us improve application stability.");
|
||||
UsageUploadHeader = L("settings.privacy.usage_upload_title", "Anonymous usage data uploads");
|
||||
UsageUploadDescription = L("settings.privacy.usage_upload_description", "Help us improve application features.");
|
||||
DeviceIdHeader = L("settings.privacy.device_id_title", "Device ID");
|
||||
DeviceIdDescription = L("settings.privacy.device_id_description", "Unique identifier for this device. Click refresh to regenerate.");
|
||||
RefreshDeviceIdText = L("settings.privacy.refresh_device_id", "Refresh");
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
|
||||
@@ -17,6 +17,22 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
@@ -11,13 +11,15 @@ using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
{
|
||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||
private string _activeScheduleId = string.Empty;
|
||||
private string? _activeScheduleId;
|
||||
private bool _suppressEvents;
|
||||
|
||||
public ClassScheduleComponentEditor()
|
||||
: this(null)
|
||||
@@ -62,10 +64,49 @@ public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
|
||||
|
||||
private void ApplyState()
|
||||
{
|
||||
var snapshot = LoadSnapshot();
|
||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Class Schedule";
|
||||
DescriptionTextBlock.Text = L("schedule.settings.desc", "导入 ClassIsland 的 CSES 课表文件并选择启用项。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
AddScheduleButton.Content = L("schedule.settings.add", "添加课表");
|
||||
EmptyStateTextBlock.Text = L("schedule.settings.empty", "暂无导入课表");
|
||||
|
||||
_suppressEvents = true;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = true;
|
||||
}
|
||||
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ColorSchemeSource = colorSchemeSource;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||
}
|
||||
|
||||
private async void OnAddScheduleClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
mc:Ignorable="d"
|
||||
x:Class="LanMountainDesktop.Views.ComponentEditors.StudyEnvironmentComponentEditor">
|
||||
<StackPanel Spacing="16">
|
||||
<Border Classes="component-editor-hero-card"
|
||||
<Border Classes="component-editor-hero_card"
|
||||
Padding="24">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="HeadlineTextBlock"
|
||||
@@ -17,6 +18,22 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="ColorSchemeHeaderTextBlock"
|
||||
Classes="component-editor-section-title" />
|
||||
<StackPanel Spacing="8">
|
||||
<RadioButton x:Name="FollowSystemRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
<RadioButton x:Name="UseNativeRadioButton"
|
||||
GroupName="ColorScheme"
|
||||
IsCheckedChanged="OnColorSchemeChanged" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
@@ -27,7 +44,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="component-editor-card"
|
||||
<Border Classes="component-editor_card"
|
||||
Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="DbfsHeaderTextBlock"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.ComponentEditors;
|
||||
|
||||
@@ -25,6 +26,8 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
||||
var snapshot = LoadSnapshot();
|
||||
var showDisplayDb = snapshot.StudyEnvironmentShowDisplayDb;
|
||||
var showDbfs = snapshot.StudyEnvironmentShowDbfs;
|
||||
var colorSchemeSource = snapshot.ColorSchemeSource;
|
||||
|
||||
if (!showDisplayDb && !showDbfs)
|
||||
{
|
||||
showDisplayDb = true;
|
||||
@@ -32,16 +35,49 @@ public partial class StudyEnvironmentComponentEditor : ComponentEditorViewBase
|
||||
|
||||
HeadlineTextBlock.Text = Context?.Definition.DisplayName ?? "Study Environment";
|
||||
DescriptionTextBlock.Text = L("study.environment.settings.desc", "配置右侧实时噪音值显示内容。");
|
||||
|
||||
ColorSchemeHeaderTextBlock.Text = L("component.settings.color_scheme", "配色方案");
|
||||
FollowSystemRadioButton.Content = L("component.color_scheme.follow_system", "跟随系统配色");
|
||||
UseNativeRadioButton.Content = L("component.color_scheme.native", "使用组件自定义配色");
|
||||
|
||||
DisplayDbToggleSwitch.Content = L("study.environment.settings.show_display_db", "显示 display dB");
|
||||
DbfsToggleSwitch.Content = L("study.environment.settings.show_dbfs", "显示 dBFS");
|
||||
HintTextBlock.Text = L("study.environment.settings.hint", "至少启用一种显示方式。");
|
||||
|
||||
_suppressEvents = true;
|
||||
|
||||
if (string.IsNullOrEmpty(colorSchemeSource) ||
|
||||
colorSchemeSource == ThemeAppearanceValues.ColorSchemeFollowSystem)
|
||||
{
|
||||
FollowSystemRadioButton.IsChecked = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
UseNativeRadioButton.IsChecked = true;
|
||||
}
|
||||
|
||||
DisplayDbToggleSwitch.IsChecked = showDisplayDb;
|
||||
DbfsToggleSwitch.IsChecked = showDbfs;
|
||||
_suppressEvents = false;
|
||||
}
|
||||
|
||||
private void OnColorSchemeChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_suppressEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var useNative = UseNativeRadioButton.IsChecked == true;
|
||||
var colorSchemeSource = useNative
|
||||
? ThemeAppearanceValues.ColorSchemeNative
|
||||
: ThemeAppearanceValues.ColorSchemeFollowSystem;
|
||||
|
||||
var snapshot = LoadSnapshot();
|
||||
snapshot.ColorSchemeSource = colorSchemeSource;
|
||||
SaveSnapshot(snapshot, nameof(ComponentSettingsSnapshot.ColorSchemeSource));
|
||||
}
|
||||
|
||||
private void OnToggleChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_ = sender;
|
||||
|
||||
@@ -13,6 +13,7 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
@@ -50,6 +51,7 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _sourceType = BaiduHotSearchSourceTypes.Official;
|
||||
private bool _isNightVisual = true;
|
||||
private string? _componentColorScheme;
|
||||
|
||||
private sealed record HotItemVisual(
|
||||
Border Host,
|
||||
@@ -180,17 +182,25 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void ApplyNightModeVisual()
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var brandColor = useMonetColor
|
||||
? (_isNightVisual ? Color.Parse("#9FABFF") : Color.Parse("#4F6BEB"))
|
||||
: (_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
|
||||
|
||||
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
|
||||
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
|
||||
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
|
||||
BrandTextBlock.Foreground = new SolidColorBrush(brandColor);
|
||||
|
||||
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
|
||||
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
|
||||
|
||||
foreach (var visual in _hotItemVisuals)
|
||||
{
|
||||
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
|
||||
visual.IndexTextBlock.Foreground = new SolidColorBrush(brandColor);
|
||||
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
|
||||
}
|
||||
|
||||
@@ -488,10 +498,11 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
|
||||
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
|
||||
_componentColorScheme = snapshot.ColorSchemeSource;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep fallback defaults.
|
||||
_componentColorScheme = null;
|
||||
}
|
||||
|
||||
_autoRefreshEnabled = enabled;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
@@ -25,9 +26,17 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private readonly DispatcherTimer _refreshTimer = new()
|
||||
{
|
||||
Interval = TimeSpan.FromMinutes(4)
|
||||
Interval = TimeSpan.FromMinutes(1)
|
||||
};
|
||||
|
||||
private int _lastCurrentCourseIndex = -1;
|
||||
private DateOnly _lastRefreshDate = DateOnly.MinValue;
|
||||
|
||||
private bool _isUserScrolling;
|
||||
private Vector _lastScrollOffset;
|
||||
private Point _dragStartPoint;
|
||||
private Point _lastDragPoint;
|
||||
|
||||
private ISettingsService _settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||
@@ -39,6 +48,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||
private string _placementId = string.Empty;
|
||||
private string? _componentColorScheme;
|
||||
|
||||
public ClassScheduleWidget()
|
||||
{
|
||||
@@ -50,6 +60,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
SizeChanged += OnSizeChanged;
|
||||
ActualThemeVariantChanged += OnActualThemeVariantChanged;
|
||||
|
||||
ContentScrollViewer.PointerPressed += OnScrollViewerPointerPressed;
|
||||
ContentScrollViewer.PointerMoved += OnScrollViewerPointerMoved;
|
||||
ContentScrollViewer.PointerReleased += OnScrollViewerPointerReleased;
|
||||
|
||||
ApplyCellSize(_currentCellSize);
|
||||
RefreshSchedule();
|
||||
}
|
||||
@@ -107,9 +121,89 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
private void OnScrollViewerPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
_isUserScrolling = true;
|
||||
_dragStartPoint = e.GetCurrentPoint(ContentScrollViewer).Position;
|
||||
_lastDragPoint = _dragStartPoint;
|
||||
_lastScrollOffset = ContentScrollViewer.Offset;
|
||||
}
|
||||
|
||||
private void OnScrollViewerPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (!_isUserScrolling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var currentPoint = e.GetCurrentPoint(ContentScrollViewer);
|
||||
var currentPosition = currentPoint.Position;
|
||||
var deltaY = currentPosition.Y - _lastDragPoint.Y;
|
||||
|
||||
var newOffset = _lastScrollOffset;
|
||||
newOffset = newOffset.WithY(newOffset.Y - deltaY);
|
||||
|
||||
ContentScrollViewer.Offset = newOffset;
|
||||
_lastDragPoint = currentPosition;
|
||||
}
|
||||
|
||||
private void OnScrollViewerPointerReleased(object? sender, PointerReleasedEventArgs e)
|
||||
{
|
||||
_lastScrollOffset = ContentScrollViewer.Offset;
|
||||
}
|
||||
|
||||
private void OnRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
var currentDate = DateOnly.FromDateTime(now);
|
||||
|
||||
var previousCourseIndex = _lastCurrentCourseIndex;
|
||||
|
||||
RefreshSchedule();
|
||||
|
||||
var newCurrentCourseIndex = FindCurrentCourseIndex();
|
||||
_lastCurrentCourseIndex = newCurrentCourseIndex;
|
||||
|
||||
if (previousCourseIndex != newCurrentCourseIndex && newCurrentCourseIndex >= 0)
|
||||
{
|
||||
if (_isUserScrolling)
|
||||
{
|
||||
_isUserScrolling = false;
|
||||
}
|
||||
ScrollToCurrentCourse(newCurrentCourseIndex);
|
||||
}
|
||||
|
||||
if (_lastRefreshDate != currentDate && currentDate > _lastRefreshDate)
|
||||
{
|
||||
_lastRefreshDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
private int FindCurrentCourseIndex()
|
||||
{
|
||||
for (var i = 0; i < _courseItems.Count; i++)
|
||||
{
|
||||
if (_courseItems[i].IsCurrent)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void ScrollToCurrentCourse(int courseIndex)
|
||||
{
|
||||
if (courseIndex < 0 || courseIndex >= _courseItems.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (courseIndex < CourseListPanel.Children.Count)
|
||||
{
|
||||
var targetChild = CourseListPanel.Children[courseIndex];
|
||||
var bounds = targetChild.Bounds;
|
||||
ContentScrollViewer.Offset = new Vector(0, bounds.Position.Y);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshFromSettings()
|
||||
@@ -134,44 +228,75 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
_componentId,
|
||||
_placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||
_componentColorScheme = componentSettings.ColorSchemeSource;
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
UpdateHeader(now);
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
|
||||
var readResult = _scheduleService.Load(importedSchedulePath);
|
||||
if (!readResult.Success || readResult.Snapshot is null)
|
||||
{
|
||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.no_source", "未读取到 ClassIsland 课表"));
|
||||
RenderScheduleItems();
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = readResult.Snapshot;
|
||||
var today = DateOnly.FromDateTime(now);
|
||||
|
||||
if (!_scheduleService.TryResolveClassPlanForDate(snapshot, today, out var resolvedClassPlan))
|
||||
{
|
||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||
RenderScheduleItems();
|
||||
return;
|
||||
var nextDay = today.AddDays(1);
|
||||
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan))
|
||||
{
|
||||
resolvedClassPlan = nextDayClassPlan;
|
||||
today = nextDay;
|
||||
}
|
||||
else
|
||||
{
|
||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||
RenderScheduleItems();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!snapshot.TimeLayouts.TryGetValue(resolvedClassPlan.ClassPlan.TimeLayoutId, out var layout))
|
||||
{
|
||||
_courseItems = Array.Empty<CourseItemViewModel>();
|
||||
UpdateHeader(now);
|
||||
ShowStatus(L("schedule.widget.layout_missing", "课表时间布局缺失"));
|
||||
RenderScheduleItems();
|
||||
return;
|
||||
}
|
||||
|
||||
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, now);
|
||||
var adjustedNow = today == DateOnly.FromDateTime(now) ? now : DateTime.Today.AddHours(8);
|
||||
_courseItems = BuildCourseItemViewModels(snapshot, resolvedClassPlan.ClassPlan, layout, adjustedNow);
|
||||
|
||||
if (_courseItems.Count == 0)
|
||||
{
|
||||
var nextDay = today.AddDays(1);
|
||||
if (_scheduleService.TryResolveClassPlanForDate(snapshot, nextDay, out var nextDayClassPlan) &&
|
||||
snapshot.TimeLayouts.TryGetValue(nextDayClassPlan.ClassPlan.TimeLayoutId, out var nextLayout))
|
||||
{
|
||||
today = nextDay;
|
||||
adjustedNow = DateTime.Today.AddHours(8);
|
||||
_courseItems = BuildCourseItemViewModels(snapshot, nextDayClassPlan.ClassPlan, nextLayout, adjustedNow);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateHeader(today.ToDateTime(TimeOnly.MinValue));
|
||||
|
||||
if (_courseItems.Count == 0)
|
||||
{
|
||||
ShowStatus(L("schedule.widget.no_class_today", "今天没有课程"));
|
||||
}
|
||||
else
|
||||
{
|
||||
var currentIndex = FindCurrentCourseIndex();
|
||||
_lastCurrentCourseIndex = currentIndex;
|
||||
HideStatus();
|
||||
}
|
||||
|
||||
@@ -336,6 +461,10 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
return;
|
||||
}
|
||||
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var scale = ResolveScale();
|
||||
var bulletSize = Math.Clamp(10 * scale, 5, 12);
|
||||
var courseNameSize = Math.Clamp(42 * scale, 14, 42);
|
||||
@@ -350,7 +479,9 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
var primaryBrush = CreateBrush(_isNightVisual ? "#F9FBFF" : "#151821");
|
||||
var secondaryBrush = CreateBrush(_isNightVisual ? "#848B99" : "#667084");
|
||||
var currentBrush = CreateBrush("#FF4D5A");
|
||||
var currentBrush = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF4D5A");
|
||||
var normalBulletBrush = CreateBrush(_isNightVisual ? "#B8BEC9" : "#9AA3B2");
|
||||
|
||||
var visibleItems = _courseItems.Take(maxVisibleItems).ToList();
|
||||
@@ -438,9 +569,22 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
private void ApplyAdaptiveLayout()
|
||||
{
|
||||
if (Bounds.Width <= 0 || Bounds.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = ResolveScale();
|
||||
_isNightVisual = ResolveNightMode();
|
||||
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
var slashBrush = useMonetColor
|
||||
? CreateBrush("#FF4FC3F7")
|
||||
: CreateBrush("#FF3250");
|
||||
|
||||
var cornerRadius = Math.Clamp(_currentCellSize * 0.45, 24, 44);
|
||||
RootBorder.CornerRadius = new CornerRadius(cornerRadius);
|
||||
RootBorder.Background = _isNightVisual
|
||||
@@ -468,7 +612,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
MonthTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
||||
DayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#F8FAFF" : "#131722");
|
||||
SlashTextBlock.Foreground = CreateBrush("#FF3250");
|
||||
SlashTextBlock.Foreground = slashBrush;
|
||||
WeekdayTextBlock.Foreground = CreateBrush(_isNightVisual ? "#C6CBD5" : "#4B5463");
|
||||
ClassCountTextBlock.Foreground = CreateBrush(_isNightVisual ? "#8D95A4" : "#738095");
|
||||
StatusTextBlock.Foreground = CreateBrush(_isNightVisual ? "#9AA2B1" : "#4B5565");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
@@ -439,6 +439,11 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.browser",
|
||||
() => new BrowserWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.24, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.DesktopOfficeRecentDocuments,
|
||||
"component.office_recent_documents",
|
||||
() => new OfficeRecentDocumentsWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.50, 10, 24)),
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.HolidayCalendar,
|
||||
"component.holiday_calendar",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed class OfficeRecentDocumentViewModel
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
public string TimeAgo { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:vm="using:LanMountainDesktop.Views.Components"
|
||||
mc:Ignorable="d"
|
||||
d:DesignWidth="640"
|
||||
d:DesignHeight="320"
|
||||
x:Class="LanMountainDesktop.Views.Components.OfficeRecentDocumentsWidget">
|
||||
|
||||
<Border x:Name="RootBorder"
|
||||
CornerRadius="34"
|
||||
Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
ClipToBounds="True"
|
||||
BorderThickness="0"
|
||||
Padding="0">
|
||||
<Grid>
|
||||
<Border x:Name="AccentCorner"
|
||||
Width="140"
|
||||
Height="140"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,-40,-40,0"
|
||||
CornerRadius="70"
|
||||
Background="{DynamicResource SystemAccentColorLight2Brush}"
|
||||
Opacity="0.2"
|
||||
IsHitTestVisible="False" />
|
||||
|
||||
<Grid RowDefinitions="Auto,*" RowSpacing="8" Margin="16,14,16,14">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="*,Auto">
|
||||
<TextBlock x:Name="HeaderTextBlock"
|
||||
Text="最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
FontSize="18"
|
||||
FontWeight="SemiBold"
|
||||
VerticalAlignment="Center" />
|
||||
<Button x:Name="RefreshButton"
|
||||
Grid.Column="1"
|
||||
Width="28"
|
||||
Height="28"
|
||||
CornerRadius="14"
|
||||
Background="Transparent"
|
||||
BorderBrush="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
Focusable="False"
|
||||
PointerPressed="OnRefreshPointerPressed">
|
||||
<fi:SymbolIcon Symbol="ArrowSync"
|
||||
FontSize="14"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled"
|
||||
Margin="0,4,0,0">
|
||||
<ItemsControl x:Name="DocumentsItemsControl">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:OfficeRecentDocumentViewModel">
|
||||
<Border x:Name="DocumentCard"
|
||||
Width="130"
|
||||
Height="90"
|
||||
CornerRadius="10"
|
||||
Background="{DynamicResource AdaptiveGlassPanelBackgroundBrush}"
|
||||
Padding="10"
|
||||
Cursor="Hand"
|
||||
PointerPressed="OnDocumentCardPointerPressed">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Row="0"
|
||||
Text="{Binding FileName}"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
FontSize="12"
|
||||
FontWeight="Medium"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" />
|
||||
<TextBlock Grid.Row="2"
|
||||
Text="{Binding TimeAgo}"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
FontSize="10"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
MaxLines="1" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<TextBlock x:Name="StatusTextBlock"
|
||||
IsVisible="False"
|
||||
Text="暂无最近文档"
|
||||
Foreground="{DynamicResource AdaptiveTextTertiaryBrush}"
|
||||
FontSize="14"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class OfficeRecentDocumentsWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget
|
||||
{
|
||||
private readonly IOfficeRecentDocumentsService _recentDocumentsService;
|
||||
private List<OfficeRecentDocument> _documents = new();
|
||||
private bool _isOnActivePage;
|
||||
private bool _isEditMode;
|
||||
private bool _isLoading;
|
||||
|
||||
public OfficeRecentDocumentsWidget()
|
||||
{
|
||||
InitializeComponent();
|
||||
_recentDocumentsService = new OfficeRecentDocumentsService();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
if (RootBorder is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scale = cellSize / 100.0;
|
||||
RootBorder.CornerRadius = new Avalonia.CornerRadius(Math.Max(8, 34 * scale));
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActivePage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
|
||||
if (_isOnActivePage && !_isLoading)
|
||||
{
|
||||
LoadDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadDocuments()
|
||||
{
|
||||
try
|
||||
{
|
||||
_isLoading = true;
|
||||
StatusTextBlock.IsVisible = false;
|
||||
|
||||
_documents = _recentDocumentsService.GetRecentDocuments(20);
|
||||
|
||||
if (_documents.Count == 0)
|
||||
{
|
||||
StatusTextBlock.Text = "暂无最近文档";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateDisplay();
|
||||
}
|
||||
catch
|
||||
{
|
||||
StatusTextBlock.Text = "加载失败";
|
||||
StatusTextBlock.IsVisible = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDisplay()
|
||||
{
|
||||
var displayItems = _documents.Select(d => new OfficeRecentDocumentViewModel
|
||||
{
|
||||
FileName = d.FileName,
|
||||
FilePath = d.FilePath,
|
||||
TimeAgo = GetTimeAgo(d.LastModifiedTime)
|
||||
}).ToList();
|
||||
|
||||
DocumentsItemsControl.ItemsSource = displayItems;
|
||||
}
|
||||
|
||||
private static string GetTimeAgo(DateTime dateTime)
|
||||
{
|
||||
var span = DateTime.Now - dateTime;
|
||||
|
||||
if (span.TotalMinutes < 1)
|
||||
return "刚刚";
|
||||
if (span.TotalMinutes < 60)
|
||||
return $"{(int)span.TotalMinutes} 分钟前";
|
||||
if (span.TotalHours < 24)
|
||||
return $"{(int)span.TotalHours} 小时前";
|
||||
if (span.TotalDays < 7)
|
||||
return $"{(int)span.TotalDays} 天前";
|
||||
if (span.TotalDays < 30)
|
||||
return $"{(int)(span.TotalDays / 7)} 周前";
|
||||
|
||||
return dateTime.ToString("MM/dd");
|
||||
}
|
||||
|
||||
private void OnRefreshPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
LoadDocuments();
|
||||
}
|
||||
|
||||
private void OnDocumentCardPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is Border border && border.DataContext is { } data)
|
||||
{
|
||||
var filePathProperty = data.GetType().GetProperty("FilePath");
|
||||
var filePath = filePathProperty?.GetValue(data) as string;
|
||||
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
{
|
||||
_recentDocumentsService.OpenDocument(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
@@ -24,6 +25,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
private double _currentCellSize = 48;
|
||||
private bool _showDisplayDb = true;
|
||||
private bool _showDbfs;
|
||||
private string? _componentColorScheme;
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _isAttached;
|
||||
private bool _isOnActivePage = true;
|
||||
@@ -147,6 +149,7 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_showDisplayDb = componentSnapshot.StudyEnvironmentShowDisplayDb;
|
||||
_showDbfs = componentSnapshot.StudyEnvironmentShowDbfs;
|
||||
_componentColorScheme = componentSnapshot.ColorSchemeSource;
|
||||
if (!_showDisplayDb && !_showDbfs)
|
||||
{
|
||||
_showDisplayDb = true;
|
||||
@@ -287,22 +290,26 @@ public partial class StudyEnvironmentWidget : UserControl, IDesktopComponentWidg
|
||||
|
||||
private IBrush ResolveStatusBrush(StudyAnalyticsSnapshot snapshot)
|
||||
{
|
||||
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
|
||||
_componentColorScheme,
|
||||
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
|
||||
|
||||
if (snapshot.State == StudyAnalyticsRuntimeState.Unsupported ||
|
||||
snapshot.State == StudyAnalyticsRuntimeState.Error ||
|
||||
snapshot.StreamStatus == NoiseStreamStatus.Error)
|
||||
{
|
||||
return CreateBrush("#FFFF7B7B");
|
||||
return useMonetColor ? CreateBrush("#FF6FD7A2") : CreateBrush("#FFFF7B7B");
|
||||
}
|
||||
|
||||
if (snapshot.StreamStatus == NoiseStreamStatus.Noisy)
|
||||
{
|
||||
return CreateBrush("#FFFFB14A");
|
||||
return useMonetColor ? CreateBrush("#FF4FC3F7") : CreateBrush("#FFFFB14A");
|
||||
}
|
||||
|
||||
if (snapshot.State == StudyAnalyticsRuntimeState.Running &&
|
||||
snapshot.StreamStatus == NoiseStreamStatus.Quiet)
|
||||
{
|
||||
return CreateBrush("#FF6FD7A2");
|
||||
return useMonetColor ? CreateBrush("#FF81C784") : CreateBrush("#FF6FD7A2");
|
||||
}
|
||||
|
||||
return TryResolveThemeBrush("AdaptiveTextPrimaryBrush", "#FFEFF3FF");
|
||||
|
||||
@@ -31,6 +31,33 @@
|
||||
<ToggleSwitch IsChecked="{Binding UploadAnonymousUsageData}" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
<Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}"
|
||||
CornerRadius="8"
|
||||
Padding="16"
|
||||
Margin="0,16,0,0">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0">
|
||||
<TextBlock Text="{Binding DeviceIdHeader}"
|
||||
FontWeight="SemiBold"
|
||||
FontSize="14" />
|
||||
<TextBlock Text="{Binding DeviceIdDescription}"
|
||||
FontSize="12"
|
||||
Opacity="0.7"
|
||||
Margin="0,4,0,8" />
|
||||
<TextBox Text="{Binding DeviceId}"
|
||||
IsReadOnly="True"
|
||||
FontFamily="Consolas"
|
||||
FontSize="12" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1"
|
||||
Content="{Binding RefreshDeviceIdText}"
|
||||
Command="{Binding RefreshDeviceIdCommand}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="16,0,0,0"
|
||||
Classes="accent-button" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
# 宿主侧插件运行时
|
||||
# 宿主侧插件运行时 / Host Plugin Runtime
|
||||
|
||||
## 中文
|
||||
|
||||
本目录保存阑山桌面宿主程序中的插件运行时实现。
|
||||
本目录保存阑山桌面宿主侧插件运行时实现。
|
||||
|
||||
### 主要职责
|
||||
|
||||
- 发现已安装插件
|
||||
- 安装和替换 `.laapp` 插件包
|
||||
- 加载插件程序集
|
||||
- 接入插件贡献的设置页和桌面组件
|
||||
- 在宿主设置界面中展示插件与市场信息
|
||||
- 发现、安装和替换 `.laapp` 插件包
|
||||
- 加载插件程序集和共享契约
|
||||
- 接入插件设置页、桌面组件与市场界面
|
||||
- 为 `3.0.0` API 基线插件构建插件作用域的 `IServiceCollection` / `ServiceProvider`
|
||||
- 在激活前解析共享契约缓存,并暴露显式插件导出
|
||||
|
||||
### 市场安装优先级
|
||||
### 与 LanAirApp 的分工
|
||||
|
||||
1. 宿主先连接 `LanAirApp/airappmarket/index.json`。
|
||||
2. 当条目同时提供 `releaseTag` 和 `releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
|
||||
3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`。
|
||||
4. 插件介绍始终读取仓库根目录 `README.md`。
|
||||
5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
|
||||
- `LanAirApp` 负责官方市场索引、开发文档、校验工具和镜像样例
|
||||
- 本目录负责宿主运行时发现、安装、加载和界面接入
|
||||
- 权威示例插件是独立仓库 `LanMountainDesktop.SamplePlugin`,`LanAirApp` 中的样例目录只是镜像模板
|
||||
|
||||
### 核心文件
|
||||
### 市场安装顺序
|
||||
|
||||
- `PluginLoader.cs`
|
||||
- `PluginLoadContext.cs`
|
||||
- `PluginRuntimeService.cs`
|
||||
- `PluginCatalogEntry.cs`
|
||||
- `PluginMarketIndexService.cs`
|
||||
- `PluginMarketInstallService.cs`
|
||||
|
||||
### 与 `LanAirApp` 的分工
|
||||
|
||||
- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
|
||||
- 宿主目录负责运行时发现、安装、加载和界面接入。
|
||||
1. 宿主读取官方 `LanAirApp/airappmarket/index.json`
|
||||
2. 若条目同时包含 `releaseTag` 与 `releaseAssetName`,优先解析 GitHub Release 资产
|
||||
3. 若 Release 解析失败,则回退到仓库根目录 `.laapp`
|
||||
4. 插件详情始终读取插件仓库根目录 `README.md`
|
||||
5. 市场安装为暂存安装,重启后生效
|
||||
|
||||
## English
|
||||
|
||||
@@ -40,25 +32,22 @@ This directory contains the host-side plugin runtime for LanMountainDesktop.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- discover installed plugins
|
||||
- install and replace `.laapp` packages
|
||||
- load plugin assemblies
|
||||
- integrate plugin settings pages and desktop components
|
||||
- expose market and plugin management in the host UI
|
||||
- build a plugin-scoped `IServiceCollection`/`ServiceProvider` for API `2.0.0` plugins
|
||||
- resolve shared contract assemblies into a version-isolated cache before plugin activation
|
||||
- expose explicit cross-plugin exports through `IPluginExportRegistry`
|
||||
- discover, install, and replace `.laapp` packages
|
||||
- load plugin assemblies and shared contracts
|
||||
- integrate plugin settings pages, desktop components, and market UI
|
||||
- build a plugin-scoped `IServiceCollection` / `ServiceProvider` for API `3.0.0` plugins
|
||||
- resolve shared contract caches before activation and expose explicit plugin exports
|
||||
|
||||
### Relationship with LanAirApp
|
||||
|
||||
- `LanAirApp` owns the official market index, developer docs, validation tools, and mirrored sample templates
|
||||
- this directory owns host-side discovery, installation, loading, and UI integration
|
||||
- the authoritative sample plugin lives in the standalone `LanMountainDesktop.SamplePlugin` repository; the `LanAirApp` sample directory is only a mirror/template copy
|
||||
|
||||
### Market install order
|
||||
|
||||
1. The host reads `LanAirApp/airappmarket/index.json`.
|
||||
2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
|
||||
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
|
||||
4. Plugin details always come from the repository root `README.md`.
|
||||
5. Market installs are staged and take effect after restart.
|
||||
|
||||
### Dependency model
|
||||
|
||||
- Plugin-private managed and native NuGet dependencies remain plugin-local and are resolved through `AssemblyDependencyResolver`.
|
||||
- Shared contract assemblies are downloaded from the official market index, cached under `LocalAppData/LanMountainDesktop/SharedContracts/<id>/<version>/`, and loaded into the default context so host and plugins share the same contract types.
|
||||
- Different contract versions are isolated on disk. If two active plugins request incompatible versions of the same shared assembly name in one process, the host fails the later activation with a clear error instead of loading an ambiguous contract.
|
||||
1. The host reads the official `LanAirApp/airappmarket/index.json`
|
||||
2. If an entry contains both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset
|
||||
3. If Release resolution fails, the host falls back to the repository-root `.laapp`
|
||||
4. Plugin details always come from the plugin repository root `README.md`
|
||||
5. Market installs are staged and take effect after restart
|
||||
|
||||
68
README.md
68
README.md
@@ -1,49 +1,47 @@
|
||||
# 阑山桌面(LanMountainDesktop)
|
||||
# 阑山桌面 / LanMountainDesktop
|
||||
|
||||
## 中文
|
||||
|
||||
阑山桌面是一个基于 Avalonia 的桌面壳层项目。它不是单纯的启动器,而是一个可编排、可扩展、可长期演进的桌面信息空间。
|
||||
`LanMontainDesktop` 是阑山桌面的宿主应用权威仓库,负责应用本体、宿主侧插件运行时,以及宿主侧 `PluginSdk` API 基线。
|
||||
|
||||
### 核心目标
|
||||
### 本仓库负责什么
|
||||
|
||||
- 通过网格化布局管理桌面组件。
|
||||
- 提供状态栏、任务栏和多页桌面的统一外壳。
|
||||
- 通过主题、玻璃效果和动效塑造统一体验。
|
||||
- 通过组件系统和插件系统持续扩展能力。
|
||||
- `LanMountainDesktop/`:桌面宿主应用
|
||||
- `LanMountainDesktop.PluginSdk/`:宿主侧插件 API 真源
|
||||
- `LanMountainDesktop/plugins/`:插件发现、安装、加载、市场接入
|
||||
- `LanMountainDesktop.Tests/`:宿主与插件运行时测试
|
||||
- `LanAirApp/`:仅用于联调的镜像副本,权威版本仍以独立 `LanAirApp` 仓库为准
|
||||
|
||||
### 当前工程结构
|
||||
### 生态边界
|
||||
|
||||
- `LanMountainDesktop/`:桌面主程序。
|
||||
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端。
|
||||
- `LanMountainDesktop/ComponentSystem/`:组件定义与注册系统。
|
||||
- `LanMountainDesktop/plugins/`:宿主侧插件加载、安装和设置集成。
|
||||
- `docs/`:视觉与设计规范。
|
||||
- `LanAirApp/`:插件开发资料镜像,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
- 应用本体:`LanMontainDesktop`
|
||||
- 插件市场与开发资料:独立 `LanAirApp`
|
||||
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
|
||||
|
||||
### 生态关系
|
||||
### 当前插件 API 基线
|
||||
|
||||
- 宿主程序只连接 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 官方市场索引返回插件列表以及各插件项目根目录链接。
|
||||
- 插件项目根目录提供 `.laapp` 安装包和 `README.md`。
|
||||
|
||||
### 当前状态
|
||||
|
||||
- Windows 是当前主要目标平台。
|
||||
- 已提供组件系统、插件系统、主题系统和设置系统。
|
||||
- 中文为主语言,英文为附加扩展语言。
|
||||
- 仓库主入口解决方案文件已切换为 `LanMountainDesktop.slnx`,SDK 版本由根目录 `global.json` 锁定。
|
||||
|
||||
### 运行说明
|
||||
|
||||
运行方法见 [run.md](./run.md)。
|
||||
- 宿主插件 API 基线:`3.0.0`
|
||||
- `SampleClock` 共享契约:`2.0.0`
|
||||
|
||||
## English
|
||||
|
||||
LanMountainDesktop is an Avalonia-based desktop shell. It is designed as a composable and extensible desktop environment rather than a simple launcher.
|
||||
`LanMontainDesktop` is the authoritative host repository for LanMountainDesktop. It owns the desktop application, the host-side plugin runtime, and the host-side `PluginSdk` API baseline.
|
||||
|
||||
### Main goals
|
||||
### What this repository owns
|
||||
|
||||
- manage desktop widgets with a grid-based layout
|
||||
- provide a unified shell with status bar, taskbar, and multi-page desktop support
|
||||
- build a consistent experience through themes, glass effects, and motion
|
||||
- extend capabilities through the component and plugin systems
|
||||
- `LanMountainDesktop/`: the desktop host application
|
||||
- `LanMountainDesktop.PluginSdk/`: the canonical host-side plugin API
|
||||
- `LanMountainDesktop/plugins/`: plugin discovery, installation, loading, and market integration
|
||||
- `LanMountainDesktop.Tests/`: host and plugin runtime tests
|
||||
- `LanAirApp/`: a mirror kept for local workspace integration only; the standalone `LanAirApp` repository remains the source of truth
|
||||
|
||||
### Ecosystem boundaries
|
||||
|
||||
- Application host: `LanMontainDesktop`
|
||||
- Plugin market and developer-facing materials: standalone `LanAirApp`
|
||||
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
|
||||
|
||||
### Current plugin API baseline
|
||||
|
||||
- Host plugin API baseline: `3.0.0`
|
||||
- `SampleClock` shared contract: `2.0.0`
|
||||
|
||||
Reference in New Issue
Block a user