Compare commits

..

2 Commits

Author SHA1 Message Date
lincube
bcf4be6d50 0.6.1
课表组件修复。加入最近文档组件。
2026-03-16 15:19:46 +08:00
lincube
6c9f6be1b1 0.6.0.1
应用遥测,插件市场
2026-03-16 09:50:48 +08:00
38 changed files with 2303 additions and 133 deletions

View File

@@ -0,0 +1,24 @@
# Checklist
## 1. 课表单双周解析修复
- [x] 单周课程WeekCountDiv=1在单周正确显示
- [x] 双周课程WeekCountDiv=2在双周正确显示
- [x] 每周课程WeekCountDiv=0在所有周正确显示
- [x] 多周轮转2-32周正确计算当前周期位置
## 2. 课程动态移动功能
- [x] 课程结束自动从视图移除
- [x] 新课程自动移入视图可见区域
- [x] 当日课程全部结束后自动切换到次日课程表
## 3. 拖动交互功能
- [x] 课程表支持上下拖动滚动
- [x] 拖动操作流畅、响应及时
## 4. 自动复位功能
- [x] 用户手动拖动后,标记拖动状态
- [x] 当前课程变化时自动复位到最新进行中课程

View File

@@ -0,0 +1,101 @@
# 课程表组件功能优化规格说明书
## Why
当前课程表组件存在以下问题:
1. 单双周课程解析逻辑存在缺陷,无法正确识别单周/双周/每周模式
2. 课程无法动态移动,第一列始终显示进行中的课程,但存在无法正常移动的问题
3. 缺少用户拖动交互功能
4. 缺少拖动后的自动复位机制
## What Changes
- 修复 ClassIsland 课程单双周解析逻辑
- 实现课程动态移动机制(当前课程结束自动上移)
- 实现课程表上下拖动交互功能
- 实现自动复位功能(课程结束后视图复位到最新进行中课程)
## Impact
### Affected specs
- 课程表组件功能规范
### Affected code
- `Services/ClassIslandScheduleDataService.cs` - 课表解析服务
- `Views/Components/ClassScheduleWidget.axaml.cs` - 课表组件
---
## ADDED Requirements
### Requirement: 单双周课程解析
系统 SHALL 能够正确解析包含单双周信息的课程数据。
#### Scenario: 单周课程
- **WHEN** 课程设置为单周上课
- **THEN** 课程仅在单周显示
#### Scenario: 双周课程
- **WHEN** 课程设置为双周上课
- **THEN** 课程仅在双周显示
#### Scenario: 每周课程
- **WHEN** 课程设置为每周上课
- **THEN** 课程在所有周显示
---
### Requirement: 课程动态移动
系统 SHALL 实现课程的动态移动机制。
#### Scenario: 课程结束自动上移
- **WHEN** 当前进行中的课程结束
- **THEN** 课程列表自动向上移动
- **AND THEN** 下一个进行中或即将开始的课程移至视图可见区域
#### Scenario: 新课程移入视图
- **WHEN** 新的课程即将开始
- **THEN** 该课程自动移至视图可见区域
#### Scenario: 当日课程全部结束
- **WHEN** 当日所有课程已结束
- **THEN** 自动显示次日课程表
---
### Requirement: 拖动交互功能
系统 SHALL 提供课程表的上下拖动功能。
#### Scenario: 拖动查看课程
- **WHEN** 用户在课程表区域进行上下拖动
- **THEN** 课程列表随拖动方向滚动
- **AND THEN** 拖动操作流畅、响应及时
---
### Requirement: 自动复位功能
系统 SHALL 在用户手动拖动后自动复位到当前课程。
#### Scenario: 当前课程结束触发复位
- **WHEN** 用户手动拖动课程表后,当前课程结束
- **THEN** 视图自动复位到显示最新进行中课程的位置
---
## MODIFIED Requirements
### Requirement: 课程解析逻辑
**当前**: 单双周解析可能存在缺陷
**修改后**: 正确识别 WeekCountDiv 和 WeekCountDivTotal 参数,准确判断单周/双周/每周模式
---
## REMOVED Requirements
(无)

View File

@@ -0,0 +1,61 @@
# Tasks
## 1. 课表单双周解析修复
- [x] Task 1.1: 分析 ClassIsland 课表单双周数据结构
- [x] 分析 ClassIsland Schedule.json 和 Profile.json 中的周数规则字段
- [x] 确认 WeekCountDiv 和 WeekCountDivTotal 的含义和取值范围
- [x] Task 1.2: 修复 GetCyclePositionsByDate 方法
- [x] 检查单周开始日期的计算逻辑
- [x] 修复周期位置计算公式
- [x] Task 1.3: 修复 CheckRegularClassPlan 方法
- [x] 验证 weekCountDiv 和 weekCountDivTotal 的匹配逻辑
- [x] 确保单周=1、双周=2、每周=0 的正确处理
## 2. 课程动态移动功能
- [x] Task 2.1: 分析当前课程状态检测逻辑
- [x] 查看如何判断课程是否为"当前进行中"
- [x] Task 2.2: 实现定时刷新机制
- [x] 增加更频繁的刷新定时器(每分钟检查一次)
- [x] 实现课程状态变化检测
- [x] Task 2.3: 实现动态移动逻辑
- [x] 课程结束后自动上移
- [x] 新课程自动移入视图
- [x] Task 2.4: 实现次日课程切换
- [x] 当日所有课程结束后自动切换到次日
## 3. 拖动交互功能
- [x] Task 3.1: 实现 ScrollViewer 包裹
- [x] 修改 XAML 使用 ScrollViewer 包裹课程列表
- [x] Task 3.2: 实现拖动手势处理
- [x] 添加 PointerPressed/PointerMoved/PointerReleased 处理
- [x] 实现平滑滚动逻辑
## 4. 自动复位功能
- [x] Task 4.1: 记录用户拖动状态
- [x] 添加用户是否手动拖动的标志位
- [x] Task 4.2: 实现自动复位逻辑
- [x] 检测当前课程变化
- [x] 当用户手动拖动且当前课程变化时自动复位
# Task Dependencies
- Task 1.1 -> Task 1.2 -> Task 1.3
- Task 2.1 -> Task 2.2 -> Task 2.3 -> Task 2.4
- Task 3.1 -> Task 3.2
- Task 4.1 -> Task 4.2
# Parallelizable Tasks
- Task 1.x (解析修复) 与 Task 3.x (拖动) 可以并行开发
- Task 2.x (动态移动) 可以在 Task 1 完成后进行

View File

@@ -1,21 +1,33 @@
# LanAirApp
# LanAirApp (Mirror)
## 中文
`LanAirApp`阑山桌面插件生态的对外工作区。这个目录是宿主仓库的镜像副本,权威版本以独立 `LanAirApp` 仓库为准
这里的 `LanAirApp/`放在宿主仓库的镜像副本,只用于本地联调和工作区构建,不是插件市场或插件开发资料的最终权威来源
### 目录说明
### 这份镜像的角色
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目。
- `standards/`:插件清单和目录结构约定。
- `tools/`:插件打包与辅助工具。
- 提供本地工作区里的 `airappmarket` 索引副本
- 提供插件文档、工具和样例镜像,便于和宿主一起联调
- 不承担宿主运行时职责
### 与宿主的关系
### 权威来源
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
- 插件市场与开发文档:独立 `LanAirApp` 仓库
- 权威示例插件:独立 `LanMountainDesktop.SamplePlugin`
- 本目录中的 `samples/LanMountainDesktop.SamplePlugin` 只是镜像模板副本
## English
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
This `LanAirApp/` directory is a mirror that lives inside the host repository. It exists for local workspace integration and build convenience only. It is not the final authority for the plugin market or developer-facing plugin materials.
### Role of this mirror
- keep a local copy of the `airappmarket` index for workspace integration
- keep mirrored docs, tools, and sample templates for local development
- avoid duplicating host runtime responsibilities
### Sources of truth
- Plugin market and developer docs: standalone `LanAirApp`
- Authoritative sample plugin: standalone `LanMountainDesktop.SamplePlugin`
- `samples/LanMountainDesktop.SamplePlugin` in this mirror is template/mirror content only

View File

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

View File

@@ -4,7 +4,7 @@
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>2.0.0</Version>
<Version>3.0.0</Version>
</PropertyGroup>
<ItemGroup>

View File

@@ -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();

View File

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

View File

@@ -0,0 +1,37 @@
using System;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.ComponentSystem;
public static class ComponentColorSchemeHelper
{
public static bool ShouldUseMonetColor(string? componentColorScheme, string globalThemeColorMode)
{
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeNative, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.Equals(componentColorScheme, ThemeAppearanceValues.ColorSchemeFollowSystem, StringComparison.OrdinalIgnoreCase))
{
return true;
}
return !string.Equals(globalThemeColorMode, ThemeAppearanceValues.ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase);
}
public static string GetCurrentGlobalThemeColorMode()
{
try
{
var service = HostAppearanceThemeProvider.GetOrCreate();
var appearance = service.GetCurrent();
return appearance?.ThemeColorMode ?? ThemeAppearanceValues.ColorModeDefaultNeutral;
}
catch
{
return ThemeAppearanceValues.ColorModeDefaultNeutral;
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.ComponentSystem.Extensions;
@@ -327,6 +327,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",

View File

@@ -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')))&#xA; or '$(RuntimeIdentifier)' == 'win-x64'&#xA; 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>

View File

@@ -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",

View File

@@ -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": "自习时段控制",

View File

@@ -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";

View File

@@ -6,6 +6,8 @@ public sealed class ComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public string? ColorSchemeSource { get; set; }
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;

View File

@@ -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
}
}

View File

@@ -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 =>

View File

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

View File

@@ -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
}
}

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

View File

@@ -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,

View File

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

View File

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

View File

@@ -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)

View File

@@ -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">

View File

@@ -11,13 +11,15 @@ using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.ComponentEditors;
public partial class ClassScheduleComponentEditor : ComponentEditorViewBase
{
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
private string? _activeScheduleId;
private bool _suppressEvents;
public ClassScheduleComponentEditor()
: this(null)
@@ -62,10 +64,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)

View File

@@ -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"

View File

@@ -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;

View File

@@ -13,6 +13,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
@@ -50,6 +51,7 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private bool _autoRefreshEnabled = true;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private string? _componentColorScheme;
private sealed record HotItemVisual(
Border Host,
@@ -180,17 +182,25 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
private void ApplyNightModeVisual()
{
var useMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(
_componentColorScheme,
ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode());
var brandColor = useMonetColor
? (_isNightVisual ? Color.Parse("#9FABFF") : Color.Parse("#4F6BEB"))
: (_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
BrandTextBlock.Foreground = new SolidColorBrush(brandColor);
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.IndexTextBlock.Foreground = new SolidColorBrush(brandColor);
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
@@ -488,10 +498,11 @@ public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
_componentColorScheme = snapshot.ColorSchemeSource;
}
catch
{
// Keep fallback defaults.
_componentColorScheme = null;
}
_autoRefreshEnabled = enabled;

View File

@@ -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");

View File

@@ -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",

View File

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

View File

@@ -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>

View File

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

View File

@@ -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");

View File

@@ -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>

View File

@@ -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

View File

@@ -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`