Compare commits

..

14 Commits

Author SHA1 Message Date
lincube
6c9f6be1b1 0.6.0.1
应用遥测,插件市场
2026-03-16 09:50:48 +08:00
lincube
557b79e8c0 ,0.6.0
重构了设置系统。解决了大量的bug,正式添加了图标。引入了遥测的同意与许可(暂无实际功能)
2026-03-15 21:27:48 +08:00
lincube
f83c6ede1d settings_re11 2026-03-15 17:08:07 +08:00
lincube
c7fb48c8ee settings_re10 2026-03-15 04:35:34 +08:00
lincube
85b70c4a8a settings_re9 2026-03-14 23:52:26 +08:00
lincube
689be7b585 settings_re8 2026-03-14 22:45:09 +08:00
lincube
91f9f3d6fb settings_re7 2026-03-14 16:38:56 +08:00
lincube
8d4f00efcb settings_re6 2026-03-14 15:08:49 +08:00
lincube
e8be0f0576 settings_re5 2026-03-14 13:36:18 +08:00
lincube
5fdaa2539b settings_re4 2026-03-13 22:20:12 +08:00
lincube
3b3f060f33 setting_re3 2026-03-13 09:10:00 +08:00
lincube
c4df243610 setting_re2
设置架构革新中
2026-03-13 00:33:00 +08:00
lincube
40a3a00cfe setting_re1 2026-03-12 21:01:23 +08:00
lincube
4679ee006f 0.5.20
我认为很稳定了,后面就要开始弄插件不稳定了
2026-03-12 12:25:22 +08:00
283 changed files with 25115 additions and 19431 deletions

View File

@@ -25,6 +25,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
assembly_version: ${{ steps.version.outputs.assembly_version }}
informational_version: ${{ steps.version.outputs.informational_version }}
tag: ${{ steps.version.outputs.tag }}
checkout_ref: ${{ steps.version.outputs.checkout_ref }}
@@ -47,8 +49,15 @@ jobs:
CHECKOUT_REF="${GITHUB_SHA}"
fi
VERSION="${TAG#v}"
IFS='.' read -r -a VERSION_PARTS <<< "${VERSION}"
while [ "${#VERSION_PARTS[@]}" -lt 4 ]; do
VERSION_PARTS+=("0")
done
ASSEMBLY_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}.${VERSION_PARTS[3]}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "assembly_version=${ASSEMBLY_VERSION}" >> $GITHUB_OUTPUT
echo "informational_version=${VERSION}" >> $GITHUB_OUTPUT
echo "checkout_ref=${CHECKOUT_REF}" >> $GITHUB_OUTPUT
build-windows:
@@ -73,26 +82,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
$VERSION = "${{ needs.prepare.outputs.version }}"
$csprojFiles = @(
"LanMountainDesktop/LanMountainDesktop.csproj"
)
foreach ($csprojPath in $csprojFiles) {
Write-Host "Updating version in $csprojPath to $VERSION"
$content = Get-Content $csprojPath -Raw
$content = $content -replace '<Version>.*?</Version>', "<Version>$VERSION</Version>"
Set-Content $csprojPath $content
}
shell: pwsh
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -106,7 +105,11 @@ jobs:
-p:DebugType=none `
-p:DebugSymbols=false `
-p:PublishTrimmed=false `
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false `
-p:Version=${{ needs.prepare.outputs.version }} `
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} `
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
shell: pwsh
- name: Install Inno Setup
@@ -242,17 +245,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -266,7 +268,11 @@ jobs:
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DEB
run: |
@@ -384,17 +390,16 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Update version in .csproj
run: |
VERSION="${{ needs.prepare.outputs.version }}"
echo "Updating version in LanMountainDesktop.csproj to $VERSION"
sed -i '' "s/<Version>.*<\/Version>/<Version>$VERSION<\/Version>/" LanMountainDesktop/LanMountainDesktop.csproj
- name: Restore
run: dotnet restore ${{ env.Solution_Name }}
- name: Build
run: dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
run: >
dotnet build ${{ env.Solution_Name }} -c Release --no-restore -v minimal
-p:Version=${{ needs.prepare.outputs.version }}
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }}
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }}
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Publish
run: |
@@ -408,7 +413,11 @@ jobs:
-p:DebugType=none \
-p:DebugSymbols=false \
-p:PublishTrimmed=false \
-p:PublishReadyToRun=false
-p:PublishReadyToRun=false \
-p:Version=${{ needs.prepare.outputs.version }} \
-p:AssemblyVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:FileVersion=${{ needs.prepare.outputs.assembly_version }} \
-p:InformationalVersion=${{ needs.prepare.outputs.informational_version }}
- name: Package as DMG
run: |

10
.gitignore vendored
View File

@@ -502,3 +502,13 @@ nul
/validator-restore.log
/temp_old_main.axaml
/temp_old_main_utf8.axaml
# LanMountainDesktop local packaging outputs
/build-installer/
/build-deb/
/dmg-temp/
/release-files/
/LanMountainDesktop.app/
/*.deb
/*.dmg
/*.AppImage

View File

@@ -0,0 +1,32 @@
# Checklist - 设置页面 Fluent 设计改造
## Phase 1: 分析与准备
- [ ] SettingsExpander 控件分析完成
- [ ] 当前布局问题定位完成
## Phase 2: 窗口布局调整
- [ ] SettingsWindow 内容区域无额外 Border 包裹
- [ ] 窗口整体视觉效果正常
- [ ] 窗口圆角在不同模式下正确显示
## Phase 3: 设置页面改造
- [ ] AppearanceSettingsPage 无额外边框包裹
- [ ] GeneralSettingsPage 无额外边框包裹
- [ ] ComponentsSettingsPage 无额外边框包裹
- [ ] PluginsSettingsPage 无额外边框包裹
- [ ] AboutSettingsPage 无额外边框包裹
## Phase 4: 视觉规范
- [ ] 设置项间距统一
- [ ] 圆角样式统一
- [ ] 页面标题样式统一
## 验证
- [ ] 编译通过,无错误
- [ ] 运行正常,设置页面可正常显示
- [ ] 视觉效果符合 Fluent 设计风格

View File

@@ -0,0 +1,76 @@
# 设置页面 Fluent 设计改造规格说明书
## Why
当前 LanMountainDesktop 设置页面存在以下问题:
1. 右侧详细设置区域被额外边框包裹,未能实现 Fluent Avalonia 控件的完整填充效果
2. 设置项未采用 Fluent 卡片设计风格,仍使用传统 Border + StackPanel 布局
3. 与 ClassIsland 项目的视觉风格差异较大
## What Changes
- 移除页面内容区域的额外 Border 包裹,直接使用 ScrollViewer + StackPanel
- 参考 ClassIsland 项目,引入 SettingsExpander 控件替代传统布局
- 统一设置项的间距、圆角、字体等视觉规范
- 修改窗口布局,移除内容区域的 glass-panel 样式
## Impact
### Affected specs
- 设置页面 UI 布局规范
- Fluent 设计风格适配
### Affected code
- `Views/SettingsPages/*.axaml` - 所有设置页面
- `Views/SettingsWindow.axaml` - 设置窗口布局
- `Styles/GlassModule.axaml` - 样式资源
---
## ADDED Requirements
### Requirement: 设置页面 Fluent 卡片设计
系统 SHALL 提供类似 ClassIsland 的 SettingsExpander 卡片式设置项。
#### Scenario: 设置页面布局
- **WHEN** 用户打开任意设置页面
- **THEN** 页面使用 ScrollViewer 直接包裹内容,无额外 Border 包裹
- **AND THEN** 设置项使用 SettingsExpander 或 Fluent 卡片样式
### Requirement: 移除内容区域额外边框
系统 SHALL 移除右侧内容区域的 glass-panel 边框包裹。
#### Scenario: 内容区域无额外边框
- **WHEN** 用户查看设置页面内容
- **THEN** 内容直接显示在透明背景上,无额外边框包裹
### Requirement: 设置项视觉规范
系统 SHALL 统一设置项的视觉样式。
#### Scenario: 设置项样式
- **WHEN** 开发者创建新的设置项
- **THEN** 使用统一的间距Spacing、圆角、字体大小
- **AND THEN** 参考 ClassIsland 的 SettingsExpander 样式
---
## MODIFIED Requirements
### Requirement: 设置页面布局结构
**当前**: Border → ScrollViewer → Border → StackPanel → 内容
**修改后**: ScrollViewer → StackPanel → 设置项(无额外 Border
---
## REMOVED Requirements
### Requirement: 传统 Border 包裹布局
**Reason**: 实现 Fluent 设计风格,移除视觉噪音
**Migration**: 将现有 Border 包裹改为直接内容布局

View File

@@ -0,0 +1,51 @@
# Tasks - 设置页面 Fluent 设计改造
## Phase 1: 分析与准备
- [ ] Task 1.1: 分析 ClassIsland SettingsExpander 控件实现
- [ ] 查看 ClassIsland.Core 中的 SettingsExpander 定义
- [ ] 分析样式模板和视觉效果
- [ ] 确定是否需要自定义控件或使用现有替代方案
- [ ] Task 1.2: 分析当前设置页面布局问题
- [ ] 定位右侧内容区域的 Border 包裹代码
- [ ] 分析 glass-panel 样式对布局的影响
## Phase 2: 窗口布局调整
- [ ] Task 2.1: 修改 SettingsWindow.axaml 内容区域布局
- [ ] 移除 Frame 外部的 glass-panel Border
- [ ] 直接使用透明背景
- [ ] 验证窗口整体视觉效果
## Phase 3: 设置页面改造
- [ ] Task 3.1: 改造 AppearanceSettingsPage 页面
- [ ] 移除外部的 glass-panel Border
- [ ] 调整内容布局为直接填充
- [ ] 验证视觉效果
- [ ] Task 3.2: 改造 GeneralSettingsPage 页面
- [ ] 移除外部的 glass-panel Border
- [ ] 调整内容布局
- [ ] Task 3.3: 改造其他设置页面
- [ ] ComponentsSettingsPage
- [ ] PluginsSettingsPage
- [ ] AboutSettingsPage
## Phase 4: 视觉规范统一
- [ ] Task 4.1: 统一设置项间距和圆角
- [ ] 定义统一的 Spacing 值
- [ ] 统一圆角大小
- [ ] Task 4.2: 优化页面标题区域样式
- [ ] 调整 Page Header 字体大小
- [ ] 优化 Description 样式
## Task Dependencies
- Task 1.2 依赖 Task 1.1
- Task 2.1 依赖 Task 1.2
- Task 3.x 依赖 Task 2.1
- Task 4.x 依赖 Task 3.x

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

@@ -9,9 +9,9 @@
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
<PluginPackageOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Packages\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).$(Version).laapp</PluginPackagePath>
<LegacyLoosePluginOutputDirectory>$(MSBuildThisFileDirectory)artifacts\Loose\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>

View File

@@ -16,7 +16,6 @@ public sealed class SamplePlugin : PluginBase, IDisposable
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
@@ -44,31 +43,26 @@ public sealed class SamplePlugin : PluginBase, IDisposable
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady(localizer.Format(
"status.backend.detail.log_written",
"初始化日志已写入:{0}",
"Initialization log written: {0}",
logPath));
}
catch (Exception ex)
{
_stateService.MarkBackendFaulted(localizer.Format(
"status.backend.detail.log_write_failed",
"初始化日志写入失败:{0}",
"Initialization log failed: {0}",
ex.Message));
throw;
}
_clockService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
localizer.GetString("settings.page_title", "插件状态"),
() => new SamplePluginSettingsView(context)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
localizer.GetString("widget.display_name", "示例插件状态时钟"),
localizer.GetString("widget.display_name", "Sample Plugin Status Clock"),
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: localizer.GetString("widget.category", "插件"),
category: localizer.GetString("widget.category", "Plugins"),
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
@@ -78,10 +72,10 @@ public sealed class SamplePlugin : PluginBase, IDisposable
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.CloseDesktop",
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
localizer.GetString("widget.close_desktop.display_name", "Close Desktop"),
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
iconKey: "DismissCircle",
category: localizer.GetString("widget.category", "鎻掍欢"),
category: localizer.GetString("widget.category", "Plugins"),
minWidthCells: 2,
minHeightCells: 1,
allowDesktopPlacement: true,

View File

@@ -0,0 +1,10 @@
namespace LanMountainDesktop.PluginSdk;
public interface IComponentEditorHostContext
{
void RequestRefresh();
void CloseEditor();
void RequestRestart(string? reason = null);
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IComponentSettingsAccessor
{
string ComponentId { get; }
string? PlacementId { get; }
T LoadSnapshot<T>() where T : new();
void SaveSnapshot<T>(T snapshot, IReadOnlyCollection<string>? changedKeys = null);
T LoadSection<T>(string sectionId) where T : new();
void SaveSection<T>(string sectionId, T section, IReadOnlyCollection<string>? changedKeys = null);
void DeleteSection(string sectionId);
T? GetValue<T>(string key);
void SetValue<T>(string key, T value, IReadOnlyCollection<string>? changedKeys = null);
}

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

@@ -0,0 +1,21 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginSettingsService
{
string PluginId { get; }
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
T LoadComponentSection<T>(string componentId, string? placementId, string sectionId) where T : new();
void SaveComponentSection<T>(
string componentId,
string? placementId,
string sectionId,
T section,
IReadOnlyCollection<string>? changedKeys = null);
void DeleteComponentSection(string componentId, string? placementId, string sectionId);
}

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsCatalog
{
IReadOnlyList<SettingsSectionDefinition> GetSections();
IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope);
}

View File

@@ -0,0 +1,12 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsPageHostContext
{
void OpenDrawer(Control content, string? title = null);
void CloseDrawer();
void RequestRestart(string? reason = null);
}

View File

@@ -0,0 +1,56 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface ISettingsService
{
event EventHandler<SettingsChangedEvent>? Changed;
T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new();
void SaveSnapshot<T>(
SettingsScope scope,
T snapshot,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null);
T LoadSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null) where T : new();
void SaveSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
T section,
string? placementId = null,
IReadOnlyCollection<string>? changedKeys = null);
void DeleteSection(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null);
T? GetValue<T>(
SettingsScope scope,
string key,
string? subjectId = null,
string? placementId = null,
string? sectionId = null);
void SetValue<T>(
SettingsScope scope,
string key,
T value,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null);
IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId);
}

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

@@ -10,7 +10,8 @@ public sealed class PluginDesktopComponentContext
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
double cellSize)
double cellSize,
IPluginSettingsService? pluginSettings = null)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
@@ -27,6 +28,7 @@ public sealed class PluginDesktopComponentContext
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
CellSize = Math.Max(1, cellSize);
PluginSettings = pluginSettings;
}
public PluginManifest Manifest { get; }
@@ -45,6 +47,8 @@ public sealed class PluginDesktopComponentContext
public double CellSize { get; }
public IPluginSettingsService? PluginSettings { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));

View File

@@ -0,0 +1,71 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorContext
{
public PluginDesktopComponentEditorContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
IPluginSettingsService? pluginSettings,
IComponentEditorHostContext hostContext)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
ArgumentNullException.ThrowIfNull(hostContext);
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
PluginSettings = pluginSettings;
HostContext = hostContext;
}
public PluginManifest Manifest { get; }
public string PluginDirectory { get; }
public string DataDirectory { get; }
public IServiceProvider Services { get; }
public IReadOnlyDictionary<string, object?> Properties { get; }
public string ComponentId { get; }
public string? PlacementId { get; }
public IPluginSettingsService? PluginSettings { get; }
public IComponentEditorHostContext HostContext { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,77 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentEditorRegistration
{
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(editorFactory);
if (preferredWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
}
if (preferredHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
}
if (minScale <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minScale));
}
if (maxScale < minScale)
{
throw new ArgumentOutOfRangeException(nameof(maxScale));
}
ComponentId = componentId.Trim();
EditorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = preferredWidth / preferredHeight;
}
public PluginDesktopComponentEditorRegistration(
string componentId,
Func<PluginDesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
: this(
componentId,
(_, context) => editorFactory(context),
preferredWidth,
preferredHeight,
minScale,
maxScale)
{
}
public string ComponentId { get; }
public Func<IServiceProvider, PluginDesktopComponentEditorContext, Control> EditorFactory { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
}

View File

@@ -85,7 +85,10 @@ public sealed record PluginManifest(
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'. Upgrade the plugin to API {PluginSdkInfo.ApiVersion}.");
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}' (major {requestedVersion.Major}), " +
$"but the host provides '{PluginSdkInfo.ApiVersion}' (major {currentVersion.Major}). " +
$"This host only supports v{currentVersion.Major}.x plugins. " +
$"Migrate the plugin to API {PluginSdkInfo.ApiVersion} and rebuild the package.");
}
return normalized;

View File

@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "2.0.0";
public const string ApiVersion = "3.0.0";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";

View File

@@ -5,20 +5,26 @@ namespace LanMountainDesktop.PluginSdk;
public static class PluginServiceCollectionExtensions
{
public static IServiceCollection AddPluginSettingsPage<TControl>(
public static IServiceCollection AddPluginSettingsSection(
this IServiceCollection services,
string id,
string title,
string titleLocalizationKey,
Action<PluginSettingsSectionBuilder> configure,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure);
services.AddSingleton(new PluginSettingsPageRegistration(
var builder = new PluginSettingsSectionBuilder(
id,
title,
provider => ActivatorUtilities.CreateInstance<TControl>(provider),
sortOrder));
titleLocalizationKey,
descriptionLocalizationKey,
iconKey,
sortOrder);
configure(builder);
services.AddSingleton(builder.Build());
return services;
}
@@ -55,6 +61,27 @@ public static class PluginServiceCollectionExtensions
return services;
}
public static IServiceCollection AddPluginDesktopComponentEditor<TControl>(
this IServiceCollection services,
string componentId,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
where TControl : Control
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton(new PluginDesktopComponentEditorRegistration(
componentId,
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
preferredWidth,
preferredHeight,
minScale,
maxScale));
return services;
}
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
where TContract : class
where TImplementation : class, TContract

View File

@@ -1,39 +0,0 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsPageRegistration
{
public PluginSettingsPageRegistration(
string id,
string title,
Func<IServiceProvider, Control> contentFactory,
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentNullException.ThrowIfNull(contentFactory);
Id = id.Trim();
Title = title.Trim();
ContentFactory = contentFactory;
SortOrder = sortOrder;
}
public PluginSettingsPageRegistration(
string id,
string title,
Func<Control> contentFactory,
int sortOrder = 0)
: this(id, title, _ => contentFactory(), sortOrder)
{
}
public string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<IServiceProvider, Control> ContentFactory { get; }
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionBuilder
{
private readonly List<SettingsOptionDefinition> _options = [];
internal PluginSettingsSectionBuilder(
string id,
string titleLocalizationKey,
string? descriptionLocalizationKey,
string iconKey,
int sortOrder)
{
Id = id;
TitleLocalizationKey = titleLocalizationKey;
DescriptionLocalizationKey = descriptionLocalizationKey;
IconKey = iconKey;
SortOrder = sortOrder;
}
public string Id { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options => _options;
public PluginSettingsSectionBuilder AddOption(SettingsOptionDefinition option)
{
ArgumentNullException.ThrowIfNull(option);
_options.Add(option);
return this;
}
public PluginSettingsSectionBuilder AddToggle(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
bool defaultValue = false)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Toggle,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue));
}
public PluginSettingsSectionBuilder AddText(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string defaultValue = "",
string? validationPattern = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Text,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
validationPattern: validationPattern));
}
public PluginSettingsSectionBuilder AddNumber(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
double defaultValue = 0,
double? minimum = null,
double? maximum = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Number,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
minimum: minimum,
maximum: maximum));
}
public PluginSettingsSectionBuilder AddSelect(
string key,
string titleLocalizationKey,
IEnumerable<SettingsOptionChoice> choices,
string? descriptionLocalizationKey = null,
string? defaultValue = null)
{
ArgumentNullException.ThrowIfNull(choices);
var normalizedChoices = choices.ToArray();
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Select,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue,
normalizedChoices));
}
public PluginSettingsSectionBuilder AddPath(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string defaultValue = "")
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.Path,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue));
}
public PluginSettingsSectionBuilder AddList(
string key,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
IReadOnlyList<string>? defaultValue = null)
{
return AddOption(new SettingsOptionDefinition(
key,
SettingsOptionType.List,
titleLocalizationKey,
descriptionLocalizationKey,
defaultValue ?? Array.Empty<string>()));
}
internal PluginSettingsSectionRegistration Build()
{
return new PluginSettingsSectionRegistration(
Id,
TitleLocalizationKey,
_options.ToArray(),
DescriptionLocalizationKey,
IconKey,
SortOrder);
}
}

View File

@@ -0,0 +1,40 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsSectionRegistration
{
public PluginSettingsSectionRegistration(
string id,
string titleLocalizationKey,
IReadOnlyList<SettingsOptionDefinition> options,
string? descriptionLocalizationKey = null,
string iconKey = "PuzzlePiece",
int sortOrder = 0)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
Id = id.Trim();
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
IconKey = iconKey.Trim();
SortOrder = sortOrder;
Options = options ?? [];
}
public string Id { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
}

View File

@@ -0,0 +1,14 @@
namespace LanMountainDesktop.PluginSdk;
public static class SettingsCategories
{
public const string General = "General";
public const string Appearance = "Appearance";
public const string Components = "Components";
public const string Plugins = "Plugins";
public const string PluginMarket = "PluginMarket";
public const string Update = "Update";
public const string About = "About";
public const string Advanced = "Advanced";
public const string External = "External";
}

View File

@@ -0,0 +1,32 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsChangedEvent
{
public SettingsChangedEvent(
SettingsScope scope,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
Scope = scope;
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
SectionId = string.IsNullOrWhiteSpace(sectionId) ? null : sectionId.Trim();
ChangedKeys = changedKeys is { Count: > 0 }
? changedKeys.ToArray()
: [];
}
public SettingsScope Scope { get; }
public string? SubjectId { get; }
public string? PlacementId { get; }
public string? SectionId { get; }
public IReadOnlyCollection<string> ChangedKeys { get; }
}

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsOptionChoice
{
public SettingsOptionChoice(string value, string titleLocalizationKey)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
Value = value.Trim();
TitleLocalizationKey = titleLocalizationKey.Trim();
}
public string Value { get; }
public string TitleLocalizationKey { get; }
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsOptionDefinition
{
public SettingsOptionDefinition(
string key,
SettingsOptionType optionType,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
object? defaultValue = null,
IReadOnlyList<SettingsOptionChoice>? choices = null,
double? minimum = null,
double? maximum = null,
string? validationPattern = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
Key = key.Trim();
OptionType = optionType;
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
DefaultValue = defaultValue;
Choices = choices ?? [];
Minimum = minimum;
Maximum = maximum;
ValidationPattern = string.IsNullOrWhiteSpace(validationPattern)
? null
: validationPattern.Trim();
}
public string Key { get; }
public SettingsOptionType OptionType { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public object? DefaultValue { get; }
public IReadOnlyList<SettingsOptionChoice> Choices { get; }
public double? Minimum { get; }
public double? Maximum { get; }
public string? ValidationPattern { get; }
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsOptionType
{
Toggle = 0,
Select = 1,
Text = 2,
Number = 3,
Path = 4,
List = 5
}

View File

@@ -0,0 +1,54 @@
using System;
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public abstract class SettingsPageBase : UserControl
{
public static readonly string DialogHostIdentifier = "LanMountainDesktop.SettingsWindow";
private ISettingsPageHostContext? _hostContext;
public ISettingsPageHostContext? HostContext => _hostContext;
public Uri? NavigationUri { get; set; }
public void InitializeHostContext(ISettingsPageHostContext hostContext)
{
_hostContext = hostContext;
}
public virtual void OnNavigatedTo(object? parameter)
{
}
protected void OpenDrawer(Control content, string? title = null)
{
_hostContext?.OpenDrawer(content, title);
}
protected void OpenDrawer(object content, bool usePageDataContext = false, object? dataContext = null, string? title = null)
{
if (content is Control control && !usePageDataContext)
{
control.DataContext = dataContext ?? DataContext ?? this;
OpenDrawer(control, title);
return;
}
if (content is Control drawerControl)
{
OpenDrawer(drawerControl, title);
}
}
protected void CloseDrawer()
{
_hostContext?.CloseDrawer();
}
protected void RequestRestart(string? reason = null)
{
_hostContext?.RequestRestart(reason);
}
}

View File

@@ -0,0 +1,11 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsPageCategory
{
General = 0,
Appearance = 10,
Components = 20,
Plugins = 30,
PluginMarket = 35,
About = 40
}

View File

@@ -0,0 +1,46 @@
using System;
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class SettingsPageInfoAttribute : Attribute
{
public SettingsPageInfoAttribute(
string id,
string name,
SettingsPageCategory category)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(name);
Id = id.Trim();
Name = name.Trim();
Category = category;
}
public string Id { get; }
public string Name { get; }
public SettingsPageCategory Category { get; }
public string? TitleLocalizationKey { get; init; }
public string? DescriptionLocalizationKey { get; init; }
public string IconKey { get; init; } = "Settings";
public string? SelectedIconKey { get; init; }
public int SortOrder { get; init; }
public bool HideDefault { get; init; }
public bool HidePageTitle { get; init; }
public bool UseFullWidth { get; init; }
public string? GroupId { get; init; }
public SettingsScope Scope { get; init; } = SettingsScope.App;
}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.PluginSdk;
public enum SettingsScope
{
App = 0,
Launcher = 1,
Plugin = 2,
ComponentInstance = 3
}

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public sealed class SettingsSectionDefinition
{
public SettingsSectionDefinition(
string id,
string category,
SettingsScope scope,
string titleLocalizationKey,
string? descriptionLocalizationKey = null,
string iconKey = "Settings",
int sortOrder = 0,
string? subjectId = null,
IReadOnlyList<SettingsOptionDefinition>? options = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(id);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentException.ThrowIfNullOrWhiteSpace(titleLocalizationKey);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
Id = id.Trim();
Category = category.Trim();
Scope = scope;
TitleLocalizationKey = titleLocalizationKey.Trim();
DescriptionLocalizationKey = string.IsNullOrWhiteSpace(descriptionLocalizationKey)
? null
: descriptionLocalizationKey.Trim();
IconKey = iconKey.Trim();
SortOrder = sortOrder;
SubjectId = string.IsNullOrWhiteSpace(subjectId) ? null : subjectId.Trim();
Options = options ?? [];
}
public string Id { get; }
public string Category { get; }
public SettingsScope Scope { get; }
public string TitleLocalizationKey { get; }
public string? DescriptionLocalizationKey { get; }
public string IconKey { get; }
public int SortOrder { get; }
public string? SubjectId { get; }
public IReadOnlyList<SettingsOptionDefinition> Options { get; }
}

View File

@@ -0,0 +1,187 @@
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ComponentSettingsServiceTests
{
[Fact]
public void Load_MigratesLegacySnapshotFileToCanonicalDocument()
{
using var sandbox = new ComponentSettingsSandbox();
File.WriteAllText(
sandbox.SettingsPath,
"""
{
"DesktopClockSecondHandMode": "Sweep",
"ImportedClassSchedules": [
{
"Id": "spring-2026",
"DisplayName": "Spring 2026",
"FilePath": "C:\\Schedules\\spring-2026.yaml"
}
],
"ActiveImportedClassScheduleId": "spring-2026"
}
""");
var service = sandbox.CreateService();
var snapshot = service.Load();
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
Assert.Single(snapshot.ImportedClassSchedules);
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
}
[Fact]
public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument()
{
using var sandbox = new ComponentSettingsSandbox();
File.WriteAllText(
sandbox.SettingsPath,
"""
{
"DefaultSettings": {
"DesktopClockSecondHandMode": "Tick"
},
"InstanceSettings": {
"DesktopClock::clock-2x2": {
"DesktopClockSecondHandMode": "Sweep"
}
},
"PluginSettings": {
"DesktopClock::clock-2x2": {
"SampleFlag": true
}
}
}
""");
var service = sandbox.CreateService();
var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2");
var pluginSettings = service.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
Assert.True(pluginSettings.SampleFlag);
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
}
[Fact]
public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService()
{
using var sandbox = new ComponentSettingsSandbox();
var service = sandbox.CreateService();
service.SaveForComponent(
"DesktopClock",
"clock-2x2",
new ComponentSettingsSnapshot
{
DesktopClockSecondHandMode = "Sweep"
});
service.SaveForComponent(
"DesktopClassSchedule",
"class-schedule-2x2",
new ComponentSettingsSnapshot
{
ImportedClassSchedules =
[
new ImportedClassScheduleSnapshot
{
Id = "spring-2026",
DisplayName = "Spring 2026",
FilePath = "C:\\Schedules\\spring-2026.yaml"
}
],
ActiveImportedClassScheduleId = "spring-2026"
});
service.SavePluginSettings(
"DesktopClassSchedule",
"class-schedule-2x2",
new SamplePluginSettings
{
SampleFlag = true,
Title = "schedule-settings"
});
ComponentSettingsService.ResetCacheForTests();
var reloadedService = sandbox.CreateService();
var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2");
var pluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>(
"DesktopClassSchedule",
"class-schedule-2x2");
Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode);
Assert.Single(classScheduleSnapshot.ImportedClassSchedules);
Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId);
Assert.True(pluginSettings.SampleFlag);
Assert.Equal("schedule-settings", pluginSettings.Title);
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
}
private sealed class ComponentSettingsSandbox : IDisposable
{
private readonly string _directoryPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.ComponentSettingsTests",
Guid.NewGuid().ToString("N"));
public ComponentSettingsSandbox()
{
Directory.CreateDirectory(_directoryPath);
ComponentSettingsService.ResetCacheForTests();
}
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
public ComponentSettingsService CreateService()
{
return new ComponentSettingsService(_directoryPath);
}
public void Dispose()
{
ComponentSettingsService.ResetCacheForTests();
try
{
if (Directory.Exists(_directoryPath))
{
Directory.Delete(_directoryPath, true);
}
}
catch
{
// Temporary test directories are best-effort cleanup.
}
}
}
private sealed class SamplePluginSettings
{
public bool SampleFlag { get; set; }
public string Title { get; set; } = string.Empty;
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,43 @@
using System;
using System.Threading.Tasks;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UiExceptionGuardTests
{
[Fact]
public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler()
{
var handlerCalled = false;
await UiExceptionGuard.RunGuardedUiActionAsync(
() => throw new InvalidOperationException("boom"),
"UnitTest.NonFatal",
onHandledException: ex =>
{
handlerCalled = ex is InvalidOperationException;
return Task.CompletedTask;
});
Assert.True(handlerCalled);
}
[Fact]
public async Task RunGuardedUiActionAsync_RethrowsFatalException()
{
await Assert.ThrowsAsync<OutOfMemoryException>(() =>
UiExceptionGuard.RunGuardedUiActionAsync(
() => throw new OutOfMemoryException("fatal"),
"UnitTest.Fatal"));
}
[Fact]
public void IsFatalException_ReturnsExpectedClassification()
{
Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException()));
Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException()));
Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException()));
}
}

View File

@@ -3,4 +3,5 @@
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
</Solution>

View File

@@ -1,4 +1,4 @@
<Application xmlns="https://github.com/avaloniaui"
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
xmlns:fi="using:FluentIcons.Avalonia"
@@ -22,6 +22,7 @@
<StyleInclude Source="avares://LanMountainDesktop/Styles/FluttermotionToken.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/GlassModule.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsAnimations.axaml" />
<StyleInclude Source="avares://LanMountainDesktop/Styles/SettingsCardStyles.axaml" />
<Style Selector="Window">
<Setter Property="FontFamily" Value="{DynamicResource AppFontFamily}" />

View File

@@ -1,38 +1,94 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Styling;
using Avalonia.Threading;
using AvaloniaWebView;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop;
public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private static readonly Color DefaultAccentColor = Color.Parse("#FF3B82F6");
private enum DesktopShellState
{
ForegroundDesktop = 0,
MinimizedToTaskbar = 1,
TrayOnly = 2
}
private enum ShutdownIntent
{
None = 0,
ExitRequested = 1,
RestartRequested = 2
}
private readonly ISettingsFacadeService _settingsFacade = HostSettingsFacadeProvider.GetOrCreate();
private readonly IAppearanceThemeService _appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
private readonly IAppLogoService _appLogoService = HostAppLogoProvider.GetOrCreate();
private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private readonly IDetachedComponentLibraryWindowService _detachedComponentLibraryWindowService = new DetachedComponentLibraryWindowService();
private readonly ILocationService _locationService = HostLocationServiceProvider.GetOrCreate();
private ISettingsPageRegistry? _settingsPageRegistry;
private ISettingsWindowService? _settingsWindowService;
private WeatherLocationRefreshService? _weatherLocationRefreshService;
private bool _exitCleanupCompleted;
private DesktopShellState _desktopShellState = DesktopShellState.ForegroundDesktop;
private ShutdownIntent _shutdownIntent;
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
private MainWindow? _mainWindow;
private bool _mainWindowClosed;
private bool _uiUnhandledExceptionHooked;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static (UserBehaviorAnalyticsService?, CrashReportService?) AnalyticsServices { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public ISettingsFacadeService SettingsFacade => _settingsFacade;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
internal ISettingsWindowService? SettingsWindowService => _settingsWindowService;
internal void OpenIndependentSettingsModule(string source, string? pageTag = null)
{
EnsureSettingsWindowService();
AppLogger.Info(
"SettingsFacade",
$"Opening settings window. Source='{source}'; PageTag='{pageTag ?? "<default>"}'.");
_settingsWindowService?.Open(new SettingsWindowOpenRequest(
Source: source,
Owner: _mainWindow is { IsVisible: true } ? _mainWindow : null,
PageId: pageTag));
}
public App()
{
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
public override void Initialize()
{
@@ -40,14 +96,18 @@ public partial class App : Application
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
ApplyThemeFromSettings();
ApplyCurrentCultureFromSettings();
EnsureSettingsWindowService();
EnsureWeatherLocationRefreshService();
}
public override void OnFrameworkInitializationCompleted()
{
AppLogger.Info("App", "Framework initialization completed.");
RegisterUiUnhandledExceptionGuard();
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
@@ -61,14 +121,13 @@ public partial class App : Application
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
CreateAndAssignMainWindow(desktop, "FrameworkInitialization");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
}
StartWeatherLocationRefreshIfNeeded();
base.OnFrameworkInitializationCompleted();
}
@@ -79,42 +138,9 @@ public partial class App : Application
Reason: "User selected Exit App from the tray menu."));
}
private void OnTraySettingsClick(object? sender, EventArgs e)
private void OnTrayShowDesktopClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
try
{
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
{
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
existingWindow.Activate();
return;
}
var settingsWindow = new SettingsWindow();
settingsWindow.Closed += (_, _) =>
{
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
{
_traySettingsWindow = null;
}
};
_traySettingsWindow = settingsWindow;
settingsWindow.Show();
settingsWindow.Activate();
}
catch (Exception ex)
{
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
}
}, DispatcherPriority.Normal);
RestoreOrCreateMainWindow(showSingleInstanceNotice: false, source: "TrayMenu");
}
private void OnTrayRestartClick(object? sender, EventArgs e)
@@ -124,6 +150,25 @@ public partial class App : Application
Reason: "User selected Restart App from the tray menu."));
}
private void OnTraySettingsClick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
OpenIndependentSettingsModule("TrayMenu");
}
private void OnTrayComponentLibraryClick(object? sender, EventArgs e)
{
_ = sender;
_ = e;
if (_mainWindow is null)
{
return;
}
_detachedComponentLibraryWindowService.Open(_mainWindow);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -170,7 +215,8 @@ public partial class App : Application
try
{
_pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService();
_pluginRuntimeService = new PluginRuntimeService(_settingsFacade);
HostSettingsFacadeProvider.BindPluginRuntime(_pluginRuntimeService);
_pluginRuntimeService.LoadInstalledPlugins();
}
catch (Exception ex)
@@ -185,10 +231,9 @@ public partial class App : Application
{
DisposeTrayIcon();
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
var trayIcon = new TrayIcon
{
Icon = new WindowIcon(iconStream),
Icon = _appLogoService.CreateTrayIcon(),
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
Menu = BuildTrayMenu(),
IsVisible = true
@@ -207,19 +252,27 @@ public partial class App : Application
{
var menu = new NativeMenu();
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置"));
var showDesktopItem = new NativeMenuItem(L("tray.menu.show_desktop", "Open Desktop"));
showDesktopItem.Click += OnTrayShowDesktopClick;
menu.Items.Add(showDesktopItem);
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "Settings"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
var componentLibraryItem = new NativeMenuItem(L("tray.menu.component_library", "Component Library"));
componentLibraryItem.Click += OnTrayComponentLibraryClick;
menu.Items.Add(componentLibraryItem);
menu.Items.Add(new NativeMenuItemSeparator());
var restartItem = new NativeMenuItem(L("tray.menu.restart", "重启应用"));
var restartItem = new NativeMenuItem(L("tray.menu.restart", "Restart App"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Exit App"));
exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem);
@@ -242,7 +295,84 @@ public partial class App : Application
_trayIcons = null;
}
private void EnsureSettingsWindowService()
{
_settingsPageRegistry ??= new SettingsPageRegistry(
_settingsFacade,
_hostApplicationLifecycle,
_localizationService,
() => _pluginRuntimeService);
_settingsWindowService ??= new SettingsWindowService(
_settingsPageRegistry,
_hostApplicationLifecycle,
_settingsFacade);
}
private void EnsureWeatherLocationRefreshService()
{
_weatherLocationRefreshService ??= new WeatherLocationRefreshService(
_settingsFacade,
_locationService,
_localizationService);
}
private void StartWeatherLocationRefreshIfNeeded()
{
EnsureWeatherLocationRefreshService();
if (_weatherLocationRefreshService is null)
{
return;
}
_ = Task.Run(async () =>
{
try
{
await _weatherLocationRefreshService.TryRefreshOnStartupAsync();
}
catch (Exception ex)
{
AppLogger.Warn("Weather.Location", "Failed to refresh weather location during startup.", ex);
}
});
}
private void ApplyThemeFromSettings()
{
var snapshot = _appearanceThemeService.GetCurrent();
RequestedThemeVariant = snapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
ApplyAdaptiveThemeResources();
}
private void ApplyCurrentCultureFromSettings()
{
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
CultureInfo culture;
try
{
culture = CultureInfo.GetCultureInfo(languageCode);
}
catch (CultureNotFoundException)
{
culture = CultureInfo.GetCultureInfo("zh-CN");
}
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
Thread.CurrentThread.CurrentCulture = culture;
Thread.CurrentThread.CurrentUICulture = culture;
}
private void ActivateMainWindow()
{
RestoreOrCreateMainWindow(showSingleInstanceNotice: true, source: "SingleInstance");
}
private void RestoreOrCreateMainWindow(bool showSingleInstanceNotice, string source)
{
Dispatcher.UIThread.Post(() =>
{
@@ -251,13 +381,11 @@ public partial class App : Application
return;
}
if (desktop.MainWindow is not Window mainWindow)
{
return;
}
try
{
var mainWindow = GetOrCreateMainWindow(desktop, source);
mainWindow.ShowInTaskbar = true;
if (!mainWindow.IsVisible)
{
mainWindow.Show();
@@ -268,32 +396,170 @@ public partial class App : Application
mainWindow.WindowState = WindowState.Normal;
}
if (mainWindow.WindowState != WindowState.FullScreen)
{
mainWindow.WindowState = WindowState.FullScreen;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
if (mainWindow is MainWindow lanMountainMainWindow)
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"Restore:{source}");
AppLogger.Info(
"DesktopShell",
$"Desktop restored. Source='{source}'; MainWindowClosed={_mainWindowClosed}; ShowSingleInstanceNotice={showSingleInstanceNotice}; WindowState='{mainWindow.WindowState}'.");
if (showSingleInstanceNotice)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
mainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
AppLogger.Warn("DesktopShell", $"Failed to restore desktop shell. Source='{source}'.", ex);
}
}, DispatcherPriority.Send);
}
private void OnAppSettingsSaved(string _)
internal void PrepareForShutdown(bool isRestart, string source)
{
void Mark()
{
_shutdownIntent = isRestart
? ShutdownIntent.RestartRequested
: ShutdownIntent.ExitRequested;
AppLogger.Info(
"DesktopShell",
$"Shutdown intent marked. Intent='{_shutdownIntent}'; Source='{source}'; CurrentShellState='{_desktopShellState}'.");
}
if (Dispatcher.UIThread.CheckAccess())
{
Mark();
return;
}
Dispatcher.UIThread.InvokeAsync(Mark, DispatcherPriority.Send).GetAwaiter().GetResult();
}
internal void ResetShutdownIntent(string source)
{
void Reset()
{
if (_shutdownIntent == ShutdownIntent.None)
{
return;
}
AppLogger.Warn(
"DesktopShell",
$"Shutdown intent cleared without process exit. PreviousIntent='{_shutdownIntent}'; Source='{source}'.");
_shutdownIntent = ShutdownIntent.None;
}
if (Dispatcher.UIThread.CheckAccess())
{
Reset();
return;
}
Dispatcher.UIThread.InvokeAsync(Reset, DispatcherPriority.Send).GetAwaiter().GetResult();
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
_ = sender;
if (e.Scope != SettingsScope.App)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase)));
var languageChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
if (themeChanged)
{
InitializeTrayIcon();
ApplyThemeFromSettings();
}
if (languageChanged)
{
ApplyCurrentCultureFromSettings();
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}
}, DispatcherPriority.Background);
}
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
_ = sender;
_ = e;
Dispatcher.UIThread.Post(ApplyThemeFromSettings, DispatcherPriority.Background);
}
private void ApplyAdaptiveThemeResources()
{
_appearanceThemeService.ApplyThemeResources(Resources);
}
private void RegisterUiUnhandledExceptionGuard()
{
if (_uiUnhandledExceptionHooked)
{
return;
}
Dispatcher.UIThread.UnhandledException += OnUiThreadUnhandledException;
_uiUnhandledExceptionHooked = true;
}
private void OnUiThreadUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
{
if (!IsKnownWebViewStartupException(e.Exception))
{
return;
}
e.Handled = true;
AppLogger.Warn(
"WebView2",
"Suppressed a known WebView startup exception from AvaloniaWebView.Navigate to keep the host process alive.",
e.Exception);
}
private static bool IsKnownWebViewStartupException(Exception exception)
{
if (exception is not NullReferenceException)
{
return false;
}
var stackTrace = exception.StackTrace ?? string.Empty;
return stackTrace.Contains("AvaloniaWebView.WebView.Navigate", StringComparison.Ordinal) &&
stackTrace.Contains("AvaloniaWebView.WebView.OnAttachedToVisualTree", StringComparison.Ordinal);
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
@@ -302,19 +568,27 @@ public partial class App : Application
}
_exitCleanupCompleted = true;
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
_settingsFacade.Settings.Changed -= OnSettingsChanged;
_appearanceThemeService.Changed -= OnAppearanceThemeChanged;
try
{
_traySettingsWindow?.Close();
var (analytics, crashReport) = App.AnalyticsServices;
analytics?.SendShutdownEvent();
crashReport?.SendShutdownEvent();
}
catch (Exception ex)
{
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
AppLogger.Warn("Analytics", "Failed to send shutdown events during exit cleanup.", ex);
}
finally
try
{
_traySettingsWindow = null;
HostUpdateWorkflowServiceProvider.GetOrCreate().TryApplyPendingUpdateOnExit();
}
catch (Exception ex)
{
AppLogger.Warn("UpdateWorkflow", "Failed to apply pending update during exit cleanup.", ex);
}
try
@@ -330,14 +604,213 @@ public partial class App : Application
_pluginRuntimeService = null;
}
_settingsWindowService?.Close();
if (_settingsPageRegistry is IDisposable disposableRegistry)
{
disposableRegistry.Dispose();
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
}
private MainWindow CreateAndAssignMainWindow(
IClassicDesktopStyleApplicationLifetime desktop,
string reason)
{
var mainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
ShowInTaskbar = true
};
AttachMainWindow(mainWindow);
desktop.MainWindow = mainWindow;
AppLogger.Info("App", $"Main window created. Reason='{reason}'. LogFile={AppLogger.LogFilePath}");
LogBrowserStartupDiagnostics();
SetDesktopShellState(DesktopShellState.ForegroundDesktop, $"MainWindowCreated:{reason}");
return mainWindow;
}
private MainWindow GetOrCreateMainWindow(
IClassicDesktopStyleApplicationLifetime desktop,
string reason)
{
if (_mainWindow is not null && !_mainWindowClosed)
{
return _mainWindow;
}
if (desktop.MainWindow is MainWindow desktopMainWindow && !_mainWindowClosed)
{
AttachMainWindow(desktopMainWindow);
return desktopMainWindow;
}
return CreateAndAssignMainWindow(desktop, reason);
}
private void AttachMainWindow(MainWindow mainWindow)
{
if (ReferenceEquals(_mainWindow, mainWindow))
{
_mainWindowClosed = false;
return;
}
if (_mainWindow is not null)
{
_mainWindow.Closing -= OnMainWindowClosing;
_mainWindow.Closed -= OnMainWindowClosed;
_mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
}
_mainWindow = mainWindow;
_mainWindowClosed = false;
mainWindow.Closing += OnMainWindowClosing;
mainWindow.Closed += OnMainWindowClosed;
mainWindow.PropertyChanged += OnMainWindowPropertyChanged;
}
private void OnMainWindowClosing(object? sender, WindowClosingEventArgs e)
{
if (sender is not MainWindow mainWindow)
{
return;
}
AppLogger.Info(
"DesktopShell",
$"Main window closing requested. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'; WindowState='{mainWindow.WindowState}'; IsVisible={mainWindow.IsVisible}.");
if (_shutdownIntent is ShutdownIntent.ExitRequested or ShutdownIntent.RestartRequested)
{
AppLogger.Info(
"DesktopShell",
$"Main window close allowed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
return;
}
e.Cancel = true;
HideMainWindowToTray(mainWindow, "MainWindowClosing");
}
private void OnMainWindowClosed(object? sender, EventArgs e)
{
if (sender is not MainWindow mainWindow)
{
return;
}
mainWindow.Closing -= OnMainWindowClosing;
mainWindow.Closed -= OnMainWindowClosed;
mainWindow.PropertyChanged -= OnMainWindowPropertyChanged;
if (ReferenceEquals(_mainWindow, mainWindow))
{
_mainWindow = null;
}
_mainWindowClosed = true;
AppLogger.Info(
"DesktopShell",
$"Main window closed. Intent='{_shutdownIntent}'; ShellState='{_desktopShellState}'.");
if (_shutdownIntent == ShutdownIntent.None)
{
SetDesktopShellState(DesktopShellState.TrayOnly, "MainWindowClosedUnexpected");
}
}
private void OnMainWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (sender is not MainWindow mainWindow)
{
return;
}
if (e.Property != Window.WindowStateProperty)
{
return;
}
if (_shutdownIntent != ShutdownIntent.None || !mainWindow.IsVisible)
{
return;
}
if (mainWindow.WindowState == WindowState.Minimized)
{
SetDesktopShellState(DesktopShellState.MinimizedToTaskbar, "MainWindowMinimized");
return;
}
SetDesktopShellState(DesktopShellState.ForegroundDesktop, "MainWindowRestored");
}
private void HideMainWindowToTray(MainWindow mainWindow, string source)
{
try
{
mainWindow.ShowInTaskbar = false;
mainWindow.Hide();
SetDesktopShellState(DesktopShellState.TrayOnly, source);
AppLogger.Info(
"DesktopShell",
$"Main window hidden to tray. Source='{source}'; WindowState='{mainWindow.WindowState}'.");
}
catch (Exception ex)
{
AppLogger.Warn("DesktopShell", $"Failed to hide main window to tray. Source='{source}'.", ex);
}
}
private void SetDesktopShellState(DesktopShellState state, string source)
{
if (_desktopShellState == state)
{
return;
}
var previous = _desktopShellState;
_desktopShellState = state;
AppLogger.Info(
"DesktopShell",
$"Shell state changed. Previous='{previous}'; Current='{state}'; Source='{source}'.");
}
private void LogBrowserStartupDiagnostics()
{
try
{
var snapshot = new DesktopLayoutSettingsService().Load();
var browserPlacements = snapshot.DesktopComponentPlacements
.Where(placement => string.Equals(
placement.ComponentId,
BuiltInComponentIds.DesktopBrowser,
StringComparison.OrdinalIgnoreCase))
.ToList();
var runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
AppLogger.Info(
"StartupDiagnostics",
$"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " +
$"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " +
$"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " +
$"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " +
$"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " +
$"WebViewRuntimeMessage={runtimeAvailability.Message}");
}
catch (Exception ex)
{
AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex);
}
}
private string L(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
return _localizationService.GetString(languageCode, key, fallback);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="375" height="374.999991" viewBox="0 0 375 374.999991">
<defs>
<clipPath id="clip-0">
<path clip-rule="nonzero" d="M 196.875 178.398438 L 285.058594 178.398438 L 285.058594 266.582031 L 196.875 266.582031 Z M 196.875 178.398438 "/>
</clipPath>
<clipPath id="clip-1">
<path clip-rule="nonzero" d="M 240.96875 178.398438 C 216.617188 178.398438 196.875 198.140625 196.875 222.492188 C 196.875 246.839844 216.617188 266.582031 240.96875 266.582031 C 265.320312 266.582031 285.058594 246.839844 285.058594 222.492188 C 285.058594 198.140625 265.320312 178.398438 240.96875 178.398438 Z M 240.96875 178.398438 "/>
</clipPath>
<clipPath id="clip-2">
<path clip-rule="nonzero" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
</clipPath>
<clipPath id="clip-3">
<path clip-rule="nonzero" d="M 44.96875 0.398438 C 20.617188 0.398438 0.875 20.140625 0.875 44.492188 C 0.875 68.839844 20.617188 88.582031 44.96875 88.582031 C 69.320312 88.582031 89.058594 68.839844 89.058594 44.492188 C 89.058594 20.140625 69.320312 0.398438 44.96875 0.398438 Z M 44.96875 0.398438 "/>
</clipPath>
<clipPath id="clip-4">
<rect x="0" y="0" width="90" height="89"/>
</clipPath>
<g id="source-5" clip-path="url(#clip-4)">
<g clip-path="url(#clip-2)">
<g clip-path="url(#clip-3)">
<path fill-rule="nonzero" fill="rgb(100%, 100%, 100%)" fill-opacity="1" d="M 0.875 0.398438 L 89.058594 0.398438 L 89.058594 88.582031 L 0.875 88.582031 Z M 0.875 0.398438 "/>
</g>
</g>
</g>
</defs>
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(100%, 100%, 100%)" fill-opacity="1"/>
<rect x="-37.5" y="-37.499999" width="450" height="449.999989" fill="rgb(0%, 0%, 0%)" fill-opacity="1"/>
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00219613 10.500482 L 127.627201 10.500482 " transform="matrix(0.75, 0, 0, 0.75, 93.623353, 248.98792)"/>
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 0.00244625 10.500708 L 127.622243 10.500708 " transform="matrix(0.75, 0, 0, 0.75, 189.341915, 110.327595)"/>
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.492181 33.974247 C 11.537452 2.677252 36.02023 2.673354 83.94005 33.972962 " transform="matrix(-0.0333864, -0.749256, 0.749256, -0.0333864, 70.972996, 265.851083)"/>
<path fill="none" stroke-width="21" stroke-linecap="butt" stroke-linejoin="miter" stroke="rgb(100%, 100%, 100%)" stroke-opacity="1" stroke-miterlimit="4" d="M 10.498903 37.532093 C 10.670763 1.489257 37.447477 1.488034 90.82363 37.533419 " transform="matrix(0.0300175, 0.749399, -0.749399, 0.0300175, 310.455894, 109.212543)"/>
<g clip-path="url(#clip-0)">
<g clip-path="url(#clip-1)">
<use xlink:href="#source-5" transform="matrix(1, 0, 0, 1, 196, 178)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,31 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
.stage {
width: 512px;
height: 512px;
background: #000;
}
.stage img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>
</head>
<body>
<div class="stage">
<img src="./logo_nightly.svg" alt="logo" />
</div>
</body>
</html>

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentEditorContext(
DesktopComponentDefinition Definition,
string ComponentId,
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore,
IComponentEditorHostContext HostContext);
public sealed class DesktopComponentEditorRegistration
{
public DesktopComponentEditorRegistration(
string componentId,
Func<DesktopComponentEditorContext, Control> editorFactory,
double preferredWidth = 720d,
double preferredHeight = 540d,
double minScale = 0.85d,
double maxScale = 1.45d)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(editorFactory);
if (preferredWidth <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredWidth));
}
if (preferredHeight <= 0)
{
throw new ArgumentOutOfRangeException(nameof(preferredHeight));
}
if (minScale <= 0)
{
throw new ArgumentOutOfRangeException(nameof(minScale));
}
if (maxScale < minScale)
{
throw new ArgumentOutOfRangeException(nameof(maxScale));
}
ComponentId = componentId.Trim();
EditorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = preferredWidth / preferredHeight;
}
public string ComponentId { get; }
public Func<DesktopComponentEditorContext, Control> EditorFactory { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
}
public sealed class DesktopComponentEditorDescriptor
{
internal DesktopComponentEditorDescriptor(
DesktopComponentDefinition definition,
Func<DesktopComponentEditorContext, Control> editorFactory,
double preferredWidth,
double preferredHeight,
double minScale,
double maxScale,
double aspectRatio)
{
Definition = definition;
_editorFactory = editorFactory;
PreferredWidth = preferredWidth;
PreferredHeight = preferredHeight;
MinScale = minScale;
MaxScale = maxScale;
AspectRatio = aspectRatio;
}
private readonly Func<DesktopComponentEditorContext, Control> _editorFactory;
public DesktopComponentDefinition Definition { get; }
public double PreferredWidth { get; }
public double PreferredHeight { get; }
public double MinScale { get; }
public double MaxScale { get; }
public double AspectRatio { get; }
public Control CreateEditor(DesktopComponentEditorContext context)
{
return _editorFactory(context);
}
}
public sealed class DesktopComponentEditorRegistry
{
private readonly Dictionary<string, DesktopComponentEditorDescriptor> _descriptors;
public DesktopComponentEditorRegistry(
ComponentRegistry componentRegistry,
IEnumerable<DesktopComponentEditorRegistration> registrations)
{
ArgumentNullException.ThrowIfNull(componentRegistry);
ArgumentNullException.ThrowIfNull(registrations);
_descriptors = new Dictionary<string, DesktopComponentEditorDescriptor>(StringComparer.OrdinalIgnoreCase);
foreach (var registration in registrations)
{
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition))
{
continue;
}
_descriptors[registration.ComponentId] = new DesktopComponentEditorDescriptor(
definition,
registration.EditorFactory,
registration.PreferredWidth,
registration.PreferredHeight,
registration.MinScale,
registration.MaxScale,
registration.AspectRatio);
}
}
public bool TryGetDescriptor(string componentId, out DesktopComponentEditorDescriptor descriptor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
return _descriptors.TryGetValue(componentId.Trim(), out descriptor!);
}
public IReadOnlyList<DesktopComponentEditorDescriptor> GetAll()
{
return _descriptors.Values.ToList();
}
}

View File

@@ -1,8 +1,14 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentRuntimeContext(
string ComponentId,
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
IAppearanceThemeService AppearanceTheme,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);

View File

@@ -0,0 +1,19 @@
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentSettingsContext(
string ComponentId,
string? PlacementId,
ISettingsFacadeService SettingsFacade,
ISettingsService SettingsService,
IAppearanceThemeService AppearanceTheme,
IComponentSettingsAccessor ComponentSettingsAccessor,
IComponentInstanceSettingsStore ComponentSettingsStore);
public interface IComponentSettingsContextAware
{
void SetComponentSettingsContext(DesktopComponentSettingsContext context);
}

View File

@@ -1,8 +0,0 @@
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentSettingsStoreAware
{
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
}

View File

@@ -0,0 +1,19 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Controls.IconText">
<StackPanel Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<fi:FluentIcon x:Name="IconElement"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontSize="14"
VerticalAlignment="Center" />
<TextBlock x:Name="TextElement"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
FontSize="14"
FontWeight="SemiBold"
VerticalAlignment="Center" />
</StackPanel>
</UserControl>

View File

@@ -0,0 +1,52 @@
using Avalonia;
using Avalonia.Controls;
using FluentIcons.Avalonia;
using FluentIcons.Common;
namespace LanMountainDesktop.Controls;
public partial class IconText : UserControl
{
public static readonly StyledProperty<Icon> IconProperty =
AvaloniaProperty.Register<IconText, Icon>(nameof(Icon), Icon.Info);
public static readonly StyledProperty<string> TextProperty =
AvaloniaProperty.Register<IconText, string>(nameof(Text), string.Empty);
public IconText()
{
InitializeComponent();
}
public Icon Icon
{
get => GetValue(IconProperty);
set => SetValue(IconProperty, value);
}
public string Text
{
get => GetValue(TextProperty);
set => SetValue(TextProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IconProperty)
{
if (IconElement is not null)
{
IconElement.Icon = change.GetNewValue<Icon>();
}
}
else if (change.Property == TextProperty)
{
if (TextElement is not null)
{
TextElement.Text = change.GetNewValue<string?>();
}
}
}
}

View File

@@ -0,0 +1,37 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LanMountainDesktop.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Controls.SettingsOptionCard"
x:Name="Root">
<Border Classes="settings-option-card">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="12">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="14">
<Border x:Name="IconHost"
Classes="settings-option-card-icon-host">
<fi:SymbolIcon x:Name="CardIcon"
Classes="icon-m" />
</Border>
<StackPanel Grid.Column="1"
Spacing="4"
VerticalAlignment="Center">
<TextBlock x:Name="TitleTextBlock"
Classes="settings-item-label" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="settings-item-description" />
</StackPanel>
<ContentPresenter x:Name="ActionContentHost"
Grid.Column="2"
VerticalAlignment="Center" />
</Grid>
<ContentPresenter x:Name="DetailsContentHost"
Grid.Row="1"
Margin="54,0,0,0" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,113 @@
using Avalonia;
using Avalonia.Controls;
using FluentIcons.Common;
namespace LanMountainDesktop.Controls;
public partial class SettingsOptionCard : UserControl
{
public static readonly StyledProperty<string?> IconKeyProperty =
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(IconKey), "Settings");
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(Title));
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingsOptionCard, string?>(nameof(Description));
public static readonly StyledProperty<object?> ActionContentProperty =
AvaloniaProperty.Register<SettingsOptionCard, object?>(nameof(ActionContent));
public static readonly StyledProperty<object?> DetailsContentProperty =
AvaloniaProperty.Register<SettingsOptionCard, object?>(nameof(DetailsContent));
public SettingsOptionCard()
{
InitializeComponent();
RefreshVisualState();
}
public string? IconKey
{
get => GetValue(IconKeyProperty);
set => SetValue(IconKeyProperty, value);
}
public string? Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public string? Description
{
get => GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public object? ActionContent
{
get => GetValue(ActionContentProperty);
set => SetValue(ActionContentProperty, value);
}
public object? DetailsContent
{
get => GetValue(DetailsContentProperty);
set => SetValue(DetailsContentProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IconKeyProperty ||
change.Property == TitleProperty ||
change.Property == DescriptionProperty ||
change.Property == ActionContentProperty ||
change.Property == DetailsContentProperty)
{
RefreshVisualState();
}
}
private void RefreshVisualState()
{
if (CardIcon is null ||
IconHost is null ||
TitleTextBlock is null ||
DescriptionTextBlock is null ||
ActionContentHost is null ||
DetailsContentHost is null)
{
return;
}
CardIcon.Symbol = MapIcon(IconKey);
IconHost.IsVisible = !string.IsNullOrWhiteSpace(IconKey);
TitleTextBlock.Text = Title ?? string.Empty;
DescriptionTextBlock.Text = Description ?? string.Empty;
DescriptionTextBlock.IsVisible = !string.IsNullOrWhiteSpace(Description);
ActionContentHost.Content = ActionContent;
ActionContentHost.IsVisible = ActionContent is not null;
DetailsContentHost.Content = DetailsContent;
DetailsContentHost.IsVisible = DetailsContent is not null;
}
private static Symbol MapIcon(string? iconKey)
{
return iconKey?.Trim() switch
{
"DesignIdeas" => Symbol.Color,
"Image" => Symbol.Image,
"GridDots" => Symbol.GridDots,
"PuzzlePiece" => Symbol.PuzzlePiece,
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
_ => Symbol.Settings
};
}
}

View File

@@ -0,0 +1,33 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LanMountainDesktop.Controls"
xmlns:fi="using:FluentIcons.Avalonia"
x:Class="LanMountainDesktop.Controls.SettingsSectionCard"
x:Name="Root">
<Border Classes="settings-section-card">
<Grid RowDefinitions="Auto,Auto"
RowSpacing="16">
<Grid ColumnDefinitions="Auto,*"
ColumnSpacing="14">
<Border x:Name="IconHost"
Classes="settings-section-card-icon-host">
<fi:SymbolIcon x:Name="CardIcon"
Classes="icon-l" />
</Border>
<StackPanel Grid.Column="1"
Spacing="4">
<TextBlock x:Name="TitleTextBlock"
Classes="settings-card-header"
Margin="0" />
<TextBlock x:Name="DescriptionTextBlock"
Classes="settings-card-description"
Margin="0" />
</StackPanel>
</Grid>
<ContentPresenter x:Name="CardContentHost"
Grid.Row="1" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,98 @@
using Avalonia;
using Avalonia.Controls;
using FluentIcons.Common;
namespace LanMountainDesktop.Controls;
public partial class SettingsSectionCard : UserControl
{
public static readonly StyledProperty<string?> IconKeyProperty =
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(IconKey), "Settings");
public static readonly StyledProperty<string?> TitleProperty =
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(Title));
public static readonly StyledProperty<string?> DescriptionProperty =
AvaloniaProperty.Register<SettingsSectionCard, string?>(nameof(Description));
public static readonly StyledProperty<object?> CardContentProperty =
AvaloniaProperty.Register<SettingsSectionCard, object?>(nameof(CardContent));
public SettingsSectionCard()
{
InitializeComponent();
RefreshVisualState();
}
public string? IconKey
{
get => GetValue(IconKeyProperty);
set => SetValue(IconKeyProperty, value);
}
public string? Title
{
get => GetValue(TitleProperty);
set => SetValue(TitleProperty, value);
}
public string? Description
{
get => GetValue(DescriptionProperty);
set => SetValue(DescriptionProperty, value);
}
public object? CardContent
{
get => GetValue(CardContentProperty);
set => SetValue(CardContentProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IconKeyProperty ||
change.Property == TitleProperty ||
change.Property == DescriptionProperty ||
change.Property == CardContentProperty)
{
RefreshVisualState();
}
}
private void RefreshVisualState()
{
if (CardIcon is null ||
IconHost is null ||
TitleTextBlock is null ||
DescriptionTextBlock is null ||
CardContentHost is null)
{
return;
}
CardIcon.Symbol = MapIcon(IconKey);
IconHost.IsVisible = !string.IsNullOrWhiteSpace(IconKey);
TitleTextBlock.Text = Title ?? string.Empty;
DescriptionTextBlock.Text = Description ?? string.Empty;
DescriptionTextBlock.IsVisible = !string.IsNullOrWhiteSpace(Description);
CardContentHost.Content = CardContent;
}
private static Symbol MapIcon(string? iconKey)
{
return iconKey?.Trim() switch
{
"DesignIdeas" => Symbol.Color,
"Image" => Symbol.Image,
"GridDots" => Symbol.GridDots,
"PuzzlePiece" => Symbol.PuzzlePiece,
"Info" => Symbol.Info,
"ArrowSync" => Symbol.ArrowSync,
_ => Symbol.Settings
};
}
}

View File

@@ -0,0 +1,47 @@
using System;
using System.Diagnostics;
using System.Windows.Input;
using CommunityToolkit.Mvvm.Input;
using Markdown.Avalonia;
namespace LanMountainDesktop.Helpers;
public static class PluginMarketMarkdownHelper
{
private static Markdown.Avalonia.Markdown? _engine;
public static ICommand OpenLinkCommand { get; } = new RelayCommand<object?>(OpenLink);
public static Markdown.Avalonia.Markdown Engine => _engine ??= new Markdown.Avalonia.Markdown
{
HyperlinkCommand = OpenLinkCommand
};
private static void OpenLink(object? parameter)
{
var url = parameter switch
{
Uri uri => uri.ToString(),
string text => text,
_ => null
};
if (string.IsNullOrWhiteSpace(url))
{
return;
}
try
{
Process.Start(new ProcessStartInfo
{
FileName = url,
UseShellExecute = true
});
}
catch
{
// Ignore browser launch failures inside the markdown viewer.
}
}
}

View File

@@ -5,6 +5,7 @@
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\logo_nightly.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
<SelfContained Condition="'$(RuntimeIdentifier)' != ''">true</SelfContained>
</PropertyGroup>
@@ -26,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>
@@ -46,12 +46,17 @@
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
<PackageReference Include="Material.Avalonia" Version="3.13.4" />
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
<PackageReference Include="ClassIsland.Markdown.Avalonia" Version="11.0.3.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
<PackageReference Include="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'" />
@@ -65,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

@@ -1,17 +1,19 @@
{
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.show_desktop": "Open Desktop",
"tray.menu.settings": "Settings",
"tray.menu.component_library": "Component Library",
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.shell.title": "Application Settings",
"settings.shell.subtitle": "LanMountainDesktop standalone preferences",
"settings.shell.title": "Settings",
"settings.shell.subtitle": "LanMountainDesktop independent settings module",
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
"settings.shell.footer_hint": "Tray-opened settings are managed in this standalone window.",
"settings.shell.footer_hint": "Tray-opened settings are managed in this independent settings module.",
"settings.back_to_desktop": "Back to Desktop",
"settings.nav_header": "Settings",
"settings.nav.group_desktop": "Desktop",
@@ -24,6 +26,7 @@
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
"settings.nav.privacy": "Privacy",
"settings.nav.launcher": "App Launcher",
"settings.nav.plugins": "Plugins",
"settings.nav.about": "About",
@@ -90,7 +93,14 @@
"settings.status_bar.spacing_mode_custom": "Custom",
"settings.status_bar.spacing_custom_label": "Custom spacing (%)",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.privacy.title": "Privacy",
"settings.privacy.description": "Manage optional anonymous uploads that help improve the app over time.",
"settings.privacy.crash_upload_title": "Anonymous crash data uploads",
"settings.privacy.crash_upload_description": "Help us improve application stability.",
"settings.privacy.usage_upload_title": "Anonymous usage data uploads",
"settings.privacy.usage_upload_description": "Help us improve application features.",
"settings.weather.title": "Weather",
"settings.weather.description": "Configure weather location, Xiaomi weather preview, and startup positioning behavior.",
"settings.weather.location_source_header": "Location Source",
"settings.weather.location_source_desc": "Choose how weather widgets resolve location.",
"settings.weather.mode_city_search": "City Search",
@@ -117,6 +127,14 @@
"settings.weather.apply_coordinates_button": "Apply Coordinates",
"settings.weather.coordinates_saved_format": "Coordinates saved: {0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "Coordinate {0:F4}, {1:F4}",
"settings.weather.location_services_header": "Location Service",
"settings.weather.location_services_desc": "Use the current Windows location and decide whether it refreshes automatically on startup.",
"settings.weather.use_current_location": "Use Current Location",
"settings.weather.location_unsupported": "Current platform does not support retrieving the current location.",
"settings.weather.location_ready": "You can use the current Windows location.",
"settings.weather.location_refreshing": "Requesting current location...",
"settings.weather.location_refresh_success_format": "Current location applied: {0}",
"settings.weather.location_refresh_failed_format": "Failed to get current location: {0}",
"settings.weather.preview_header": "Connection Test",
"settings.weather.preview_desc": "Send one test request to verify current settings.",
"settings.weather.preview_button": "Test Fetch",
@@ -125,6 +143,7 @@
"settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
"settings.weather.refresh_button": "Refresh",
"settings.weather.preview_updated_format": "Updated {0}",
"settings.weather.preview_hint": "Use test fetch to verify your weather configuration.",
"settings.weather.preview_missing_location": "Please apply one weather location before testing.",
"settings.weather.preview_success_format": "Test success: {0} · {1} · {2}",
@@ -220,6 +239,59 @@
"settings.region.timezone_header": "Time Zone",
"settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.",
"settings.region.applied_format": "Language switched to: {0}",
"settings.region.follow_system": "Follow system default",
"settings.general.title": "General",
"settings.general.description": "Adjust language, time zone, and runtime behavior.",
"settings.general.basic_header": "Basic Settings",
"settings.general.runtime_header": "Runtime",
"settings.general.preview_header": "Date & Time Preview",
"settings.general.preview_time_label": "Time",
"settings.general.preview_date_label": "Date",
"settings.general.render_mode_restart_message": "Rendering mode changes require restarting the app.",
"settings.appearance.title": "Appearance",
"settings.appearance.description": "Adjust theme source, system material, and window chrome.",
"settings.appearance.theme_header": "Theme",
"settings.color.enable_night_mode_toggle": "Enable night mode",
"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",
"settings.appearance.theme_color_mode_desc.neutral": "Use the default white and black neutral surfaces for light and dark mode.",
"settings.appearance.theme_color_mode_desc.user": "Use the selected theme color as the Monet seed for the whole shell.",
"settings.appearance.theme_color_mode_desc.wallpaper": "Use wallpaper colors. The app wallpaper is preferred, then the system wallpaper.",
"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.",
"settings.appearance.system_material.none": "None",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
"settings.appearance.system_material_desc.switchable": "Apply the selected material to windows, Dock, status bar, and component hosts.",
"settings.appearance.system_material_desc.fixed": "Your current system only exposes the material modes listed here.",
"settings.appearance.restart_message": "Theme source and system material changes require restarting the app.",
"settings.appearance.preview.primary": "Primary",
"settings.appearance.preview.secondary": "Secondary",
"settings.appearance.preview.tertiary": "Tertiary",
"settings.appearance.preview.neutral": "Neutral",
"settings.appearance.preview.seed": "Seed",
"settings.appearance.preview.neutral_light": "White",
"settings.appearance.preview.neutral_dark": "Black",
"settings.appearance.preview.apply_seed": "Apply",
"settings.appearance.preview.wallpaper_candidates": "Wallpaper seed candidates",
"settings.appearance.preview.wallpaper_current": "Current",
"settings.wallpaper.placement.fill": "Fill",
"settings.wallpaper.placement.fit": "Fit",
"settings.wallpaper.placement.stretch": "Stretch",
"settings.wallpaper.placement.center": "Center",
"settings.wallpaper.placement.tile": "Tile",
"settings.status_bar.clock_format_label": "Clock format",
"settings.status_bar.clock_format.hm": "Hour:Minute",
"settings.status_bar.clock_format.hms": "Hour:Minute:Second",
"settings.components.title": "Components",
"settings.components.description": "Adjust desktop grid density and widget placement.",
"settings.components.grid_header": "Grid Layout",
"settings.update.title": "Update",
"settings.update.current_version_label": "Current Version",
"settings.update.latest_version_label": "Latest Release",
@@ -272,9 +344,42 @@
"settings.about.render_mode.current_format": "Current backend: {0}",
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
"settings.about.description": "Application details.",
"settings.update.description": "Check releases, choose the update channel and download source, and control how updates are installed.",
"settings.update.status_card_title": "Update Status",
"settings.update.status_card_description": "Check for updates, review release details, and continue with download or installation when a new version is available.",
"settings.update.preferences_header": "Update Preferences",
"settings.update.preferences_description": "Choose the release channel, installer download source, installation behavior, and download parallelism.",
"settings.update.last_checked_label": "Last Checked",
"settings.update.source_label": "Download Source",
"settings.update.source_github": "GitHub",
"settings.update.source_ghproxy": "gh-proxy",
"settings.update.source_github_desc": "Download release assets directly from GitHub.",
"settings.update.source_ghproxy_desc": "Use the gh-proxy mirror when downloading GitHub release assets.",
"settings.update.mode_label": "Update Mode",
"settings.update.mode_manual": "Manual Update",
"settings.update.mode_download_then_confirm": "Silent Download",
"settings.update.mode_silent_on_exit": "Silent Install",
"settings.update.mode_manual_desc": "Only check for updates. You decide when downloads and installation happen.",
"settings.update.mode_download_then_confirm_desc": "Download updates in the background and ask for confirmation before installing them.",
"settings.update.mode_silent_on_exit_desc": "Download updates in the background and install them the next time you exit the app.",
"settings.update.channel_stable_desc": "Stable builds prioritize reliability and are recommended for most users.",
"settings.update.channel_preview_desc": "Preview builds may contain newer features but can be less stable.",
"settings.update.download_threads_label": "Download Threads",
"settings.update.download_threads_desc": "Set the number of parallel download threads for application update packages.",
"settings.update.install_now_button": "Install Now",
"settings.update.status_downloaded_confirm": "Update downloaded. Review it and choose when to install.",
"settings.update.status_downloaded_exit": "Update downloaded. It will be installed when you exit the app.",
"settings.about.app_info_header": "Application Information",
"settings.about.update_header": "Updates",
"settings.about.version_label": "Version",
"settings.about.codename_label": "Codename",
"settings.about.render_backend_label": "Render Backend",
"settings.about.render_backend_format": "Render Backend: {0}",
"settings.restart_dialog.title": "Restart required",
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
"settings.restart_dialog.restart": "Restart now",
"settings.restart_dialog.later": "Later",
"settings.restart_dialog.cancel": "Cancel",
"settings.restart_dock.title": "Restart required",
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
@@ -301,18 +406,34 @@
"launcher.context.hide_icon": "Hide Icon",
"launcher.action.hide": "Hide",
"settings.launcher.title": "App Launcher",
"settings.launcher.description": "Manage hidden apps and folders in the App Launcher.",
"settings.launcher.hidden_header": "Hidden Items",
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
"settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_summary_format": "{0} hidden items",
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.hidden_type_shortcut": "App",
"settings.launcher.restore_button": "Unhide",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
"settings.plugins.description": "Manage installed plugins and review their runtime state.",
"settings.plugins.initial_status": "Refresh plugin state to see the latest installed plugins.",
"settings.plugins.refresh_button": "Refresh Plugins",
"settings.plugins.refresh_success_installed_format": "Loaded {0} installed plugins.",
"settings.plugins.refresh_success_format": "Loaded {0} installed plugins and {1} marketplace entries.",
"settings.plugins.refresh_failed": "Failed to load plugin market index.",
"settings.plugins.marketplace_header": "Marketplace",
"settings.plugins.marketplace_empty": "No marketplace plugins are available right now.",
"settings.plugins.delete_button_short": "Delete",
"settings.plugins.install_button_short": "Install",
"settings.plugins.restart_required": "Plugin changes take effect after restart.",
"settings.plugins.toggle_unchanged_format": "Plugin '{0}' did not change.",
"settings.plugins.delete_failed_name_format": "Failed to remove plugin '{0}'.",
"settings.plugins.install_failed_name_format": "Failed to install '{0}'.",
"settings.plugins.installed_header": "Installed Plugins",
"settings.plugins.installed_desc": "Review installed plugins and remove them here.",
"settings.plugins.import_header": "Install From Package",
@@ -356,6 +477,12 @@
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"settings.update.status_idle": "No update check has been performed yet.",
"settings.update.status_preferences_saved": "Update preferences saved.",
"settings.update.status_check_failed": "Failed to check for updates.",
"settings.update.status_available_summary_format": "Update available: {0} (current: {1})",
"settings.update.status_up_to_date_format": "You are up to date ({0}).",
"settings.window.drawer_default": "Details",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...",
@@ -372,7 +499,7 @@
"market.card.loaded": "Loaded",
"market.card.pending_restart": "Restart required",
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
"market.detail.author": "Author",
"market.detail.author": "Publisher",
"market.detail.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
@@ -391,6 +518,11 @@
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.dependencies": "Dependencies",
"market.detail.dependencies_empty": "No shared contract dependencies were declared by this plugin.",
"market.detail.readme_loading": "Loading README...",
"market.detail.readme_empty": "README is empty.",
"market.detail.readme_error_format": "README could not be loaded: {0}",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
@@ -399,6 +531,7 @@
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing...",
"market.button.restart": "Restart to apply",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
@@ -406,6 +539,12 @@
"component_library.drag_hint": "Drag to place",
"component.delete": "Delete",
"component.edit": "Edit",
"component.editor.instance_scope": "Changes apply to this component instance only.",
"component.editor.info_header": "Component Info",
"component.editor.id_label": "Component ID",
"component.editor.placement_label": "Placement ID",
"component.editor.scope_label": "Scope",
"component.editor.scope_instance": "Instance-scoped editor",
"component_category.clock": "Clock",
"component_category.date": "Calendar",
"component_category.weather": "Weather",
@@ -744,9 +883,7 @@
"placement.stretch": "Stretch",
"placement.center": "Center",
"placement.tile": "Tile",
"single_instance.notice.title": "App already open",
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
"single_instance.notice.button": "Got it"
"single_instance.notice.title": "App already running",
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
"single_instance.notice.button": "OK"
}

View File

@@ -1,17 +1,19 @@
{
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.show_desktop": "打开桌面",
"tray.menu.settings": "设置",
"tray.menu.component_library": "独立组件库",
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"tooltip.back_to_windows": "回到Windows",
"tooltip.open_settings": "设置",
"settings.title": "设置",
"settings.shell.title": "应用设置",
"settings.shell.subtitle": "LanMountainDesktop 独立设置窗口",
"settings.shell.title": "设置",
"settings.shell.subtitle": "LanMountainDesktop 独立设置模块",
"settings.shell.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立窗口中管理。",
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立设置模块中管理。",
"settings.back_to_desktop": "返回桌面",
"settings.nav_header": "设置选项",
"settings.nav.group_desktop": "桌面",
@@ -24,15 +26,21 @@
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
"settings.nav.privacy": "隐私",
"settings.nav.launcher": "应用启动台",
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
"settings.wallpaper.current_label": "当前壁纸",
"settings.wallpaper.type_label": "壁纸类型",
"settings.wallpaper.type.image": "图片",
"settings.wallpaper.type.video": "视频",
"settings.wallpaper.type.solid_color": "纯色",
"settings.wallpaper.color_label": "壁纸颜色",
"settings.wallpaper.placement_label": "显示方式",
"settings.wallpaper.placement_desc": "调整图像在桌面上的填充方式。",
"settings.wallpaper.pick_button": "浏览文件",
"settings.wallpaper.pick_button": "选择文件",
"settings.wallpaper.clear_button": "恢复纯色",
"settings.wallpaper.no_selection": "未选择壁纸。",
"settings.wallpaper.storage_unavailable": "存储提供器不可用。",
@@ -90,7 +98,14 @@
"settings.status_bar.spacing_mode_custom": "自定义",
"settings.status_bar.spacing_custom_label": "自定义间距(%",
"settings.status_bar.spacing_custom_px_format": "≈ {0:F1}px",
"settings.privacy.title": "隐私",
"settings.privacy.description": "管理可选的匿名上传设置,帮助我们逐步改进应用体验。",
"settings.privacy.crash_upload_title": "匿名上传崩溃数据",
"settings.privacy.crash_upload_description": "帮助我们提高应用稳定性。",
"settings.privacy.usage_upload_title": "匿名上传使用数据",
"settings.privacy.usage_upload_description": "帮助我们改善应用功能。",
"settings.weather.title": "天气",
"settings.weather.description": "配置天气位置、小米天气预览和启动时的位置刷新行为。",
"settings.weather.location_source_header": "位置来源",
"settings.weather.location_source_desc": "选择天气组件如何解析当前位置。",
"settings.weather.mode_city_search": "城市搜索",
@@ -117,6 +132,14 @@
"settings.weather.apply_coordinates_button": "应用坐标",
"settings.weather.coordinates_saved_format": "坐标已保存:{0:F4}, {1:F4}",
"settings.weather.coordinates_default_name_format": "坐标 {0:F4}, {1:F4}",
"settings.weather.location_services_header": "定位服务",
"settings.weather.location_services_desc": "使用当前 Windows 定位,并决定是否在启动时自动刷新天气位置。",
"settings.weather.use_current_location": "使用当前位置",
"settings.weather.location_unsupported": "当前平台不支持获取当前位置。",
"settings.weather.location_ready": "可以使用当前 Windows 定位。",
"settings.weather.location_refreshing": "正在获取当前位置……",
"settings.weather.location_refresh_success_format": "已应用当前位置:{0}",
"settings.weather.location_refresh_failed_format": "获取当前位置失败:{0}",
"settings.weather.preview_header": "连接测试",
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
"settings.weather.preview_button": "测试获取",
@@ -125,6 +148,7 @@
"settings.weather.preview_panel_header": "天气预览",
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
"settings.weather.refresh_button": "刷新",
"settings.weather.preview_updated_format": "更新于 {0}",
"settings.weather.preview_hint": "可通过测试获取快速验证天气配置。",
"settings.weather.preview_missing_location": "请先应用一个天气位置后再测试。",
"settings.weather.preview_success_format": "测试成功:{0} · {1} · {2}",
@@ -220,6 +244,59 @@
"settings.region.timezone_header": "时区",
"settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。",
"settings.region.applied_format": "语言已切换为:{0}",
"settings.region.follow_system": "跟随系统默认",
"settings.general.title": "基本设置",
"settings.general.description": "调整语言、时区与运行时行为。",
"settings.general.basic_header": "基础设置",
"settings.general.runtime_header": "运行设置",
"settings.general.preview_header": "日期与时间预览",
"settings.general.preview_time_label": "时间",
"settings.general.preview_date_label": "日期",
"settings.general.render_mode_restart_message": "渲染模式变更需要重启应用。",
"settings.appearance.title": "外观",
"settings.appearance.description": "调整主题来源、系统材质与窗口外观。",
"settings.appearance.theme_header": "主题",
"settings.color.enable_night_mode_toggle": "启用夜间模式",
"settings.color.use_system_chrome_toggle": "使用系统窗口标题栏",
"settings.color.theme_color_label": "主题强调色",
"settings.appearance.theme_color_mode_label": "主题色来源",
"settings.appearance.system_material_label": "系统材质",
"settings.appearance.theme_color_mode.neutral": "默认中性",
"settings.appearance.theme_color_mode.user": "用户主题色 Monet",
"settings.appearance.theme_color_mode.wallpaper": "壁纸 Monet 取色",
"settings.appearance.theme_color_mode_desc.neutral": "使用标准的日间白底黑字与夜间黑底白字中性色表面。",
"settings.appearance.theme_color_mode_desc.user": "使用用户选择的主题色作为整个桌面壳层的 Monet 种子色。",
"settings.appearance.theme_color_mode_desc.wallpaper": "使用壁纸颜色。优先取应用壁纸,失败后回退系统桌面壁纸。",
"settings.appearance.theme_color_preview.app": "当前正在预览从应用壁纸提取的颜色。",
"settings.appearance.theme_color_preview.system": "当前正在预览从系统壁纸提取的颜色。",
"settings.appearance.theme_color_preview.fallback": "没有可用壁纸,当前使用回退强调色。",
"settings.appearance.system_material.none": "无",
"settings.appearance.system_material.mica": "Mica",
"settings.appearance.system_material.acrylic": "Acrylic",
"settings.appearance.system_material_desc.switchable": "将所选材质应用到窗口、Dock、状态栏和组件宿主背板。",
"settings.appearance.system_material_desc.fixed": "当前系统仅提供这里列出的材质模式。",
"settings.appearance.restart_message": "主题色来源和系统材质更改需要重启应用。",
"settings.appearance.preview.primary": "主色",
"settings.appearance.preview.secondary": "次色",
"settings.appearance.preview.tertiary": "三次色",
"settings.appearance.preview.neutral": "中性色",
"settings.appearance.preview.seed": "种子色",
"settings.appearance.preview.neutral_light": "白色",
"settings.appearance.preview.neutral_dark": "黑色",
"settings.appearance.preview.apply_seed": "应用",
"settings.appearance.preview.wallpaper_candidates": "壁纸候选主题色",
"settings.appearance.preview.wallpaper_current": "当前",
"settings.wallpaper.placement.fill": "填充",
"settings.wallpaper.placement.fit": "适应",
"settings.wallpaper.placement.stretch": "拉伸",
"settings.wallpaper.placement.center": "居中",
"settings.wallpaper.placement.tile": "平铺",
"settings.status_bar.clock_format_label": "时钟格式",
"settings.status_bar.clock_format.hm": "时:分",
"settings.status_bar.clock_format.hms": "时:分:秒",
"settings.components.title": "网格",
"settings.components.description": "调整桌面网格与布局。",
"settings.components.grid_header": "网格布局",
"settings.update.title": "更新",
"settings.update.current_version_label": "当前版本",
"settings.update.latest_version_label": "最新发布",
@@ -272,9 +349,42 @@
"settings.about.render_mode.current_format": "当前后端:{0}",
"settings.about.render_mode.impl_format": "运行时实现:{0}",
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
"settings.about.description": "应用信息。",
"settings.update.description": "检查更新、选择发布通道与下载源,并控制更新安装方式。",
"settings.update.status_card_title": "更新状态",
"settings.update.status_card_description": "检查新版本、查看发布信息,并在有更新时继续下载或安装。",
"settings.update.preferences_header": "更新偏好",
"settings.update.preferences_description": "选择发布通道、安装包下载源、安装方式以及下载并行线程数。",
"settings.update.last_checked_label": "上次检查",
"settings.update.source_label": "下载源",
"settings.update.source_github": "GitHub",
"settings.update.source_ghproxy": "gh-proxy",
"settings.update.source_github_desc": "直接从 GitHub 下载发布安装包。",
"settings.update.source_ghproxy_desc": "下载 GitHub 发布安装包时使用 gh-proxy 镜像。",
"settings.update.mode_label": "更新模式",
"settings.update.mode_manual": "手动更新",
"settings.update.mode_download_then_confirm": "静默下载",
"settings.update.mode_silent_on_exit": "静默安装",
"settings.update.mode_manual_desc": "仅检查更新,何时下载和安装都由你决定。",
"settings.update.mode_download_then_confirm_desc": "后台下载更新,下载完成后由你确认是否安装。",
"settings.update.mode_silent_on_exit_desc": "后台下载更新,并在你下次退出应用时静默安装。",
"settings.update.channel_stable_desc": "正式版以稳定性优先,适合大多数用户。",
"settings.update.channel_preview_desc": "预览版可能包含更早的新功能,但稳定性可能较低。",
"settings.update.download_threads_label": "下载线程数",
"settings.update.download_threads_desc": "设置应用更新安装包使用的并行下载线程数。",
"settings.update.install_now_button": "立即安装",
"settings.update.status_downloaded_confirm": "更新已下载完成,请查看并选择安装时机。",
"settings.update.status_downloaded_exit": "更新已下载完成,将在你退出应用时安装。",
"settings.about.app_info_header": "应用信息",
"settings.about.update_header": "更新",
"settings.about.version_label": "版本",
"settings.about.codename_label": "版本代号",
"settings.about.render_backend_label": "渲染后端",
"settings.about.render_backend_format": "渲染后端:{0}",
"settings.restart_dialog.title": "需要重启应用",
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
"settings.restart_dialog.restart": "立即重启",
"settings.restart_dialog.later": "稍后",
"settings.restart_dialog.cancel": "取消",
"settings.restart_dock.title": "需要重启应用",
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
@@ -301,18 +411,34 @@
"launcher.context.hide_icon": "隐藏图标",
"launcher.action.hide": "隐藏",
"settings.launcher.title": "应用启动台",
"settings.launcher.description": "管理应用启动台中已隐藏的应用与文件夹。",
"settings.launcher.hidden_header": "已隐藏项目",
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
"settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_summary_format": "共 {0} 个隐藏项目",
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.hidden_type_shortcut": "应用",
"settings.launcher.restore_button": "取消隐藏",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.description": "管理已安装插件并查看其运行时状态。",
"settings.plugins.initial_status": "刷新插件状态以查看最新的已安装插件。",
"settings.plugins.refresh_button": "刷新插件",
"settings.plugins.refresh_success_installed_format": "已加载 {0} 个已安装插件。",
"settings.plugins.refresh_success_format": "已加载 {0} 个已安装插件和 {1} 个市场条目。",
"settings.plugins.refresh_failed": "加载插件市场索引失败。",
"settings.plugins.marketplace_header": "插件市场",
"settings.plugins.marketplace_empty": "当前没有可用的市场插件。",
"settings.plugins.delete_button_short": "删除",
"settings.plugins.install_button_short": "安装",
"settings.plugins.restart_required": "插件变更将在重启后生效。",
"settings.plugins.toggle_unchanged_format": "插件“{0}”没有变化。",
"settings.plugins.delete_failed_name_format": "移除插件“{0}”失败。",
"settings.plugins.install_failed_name_format": "安装插件“{0}”失败。",
"settings.plugins.installed_header": "已安装插件",
"settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
"settings.plugins.import_header": "从安装包导入",
@@ -356,6 +482,12 @@
"settings.plugin_market.title": "插件市场",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"settings.update.status_idle": "尚未执行更新检查。",
"settings.update.status_preferences_saved": "更新偏好已保存。",
"settings.update.status_check_failed": "检查更新失败。",
"settings.update.status_available_summary_format": "发现更新:{0}(当前:{1})。",
"settings.update.status_up_to_date_format": "当前已是最新版本({0})。",
"settings.window.drawer_default": "详情",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...",
@@ -372,7 +504,7 @@
"market.card.loaded": "已加载",
"market.card.pending_restart": "需要重启",
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
"market.detail.author": "者",
"market.detail.author": "发行者",
"market.detail.version": "版本",
"market.detail.api_version": "API 版本",
"market.detail.min_host_version": "最低宿主版本",
@@ -391,6 +523,11 @@
"market.detail.homepage": "主页",
"market.detail.repository": "仓库",
"market.detail.release_notes": "发布说明",
"market.detail.dependencies": "依赖项",
"market.detail.dependencies_empty": "该插件没有声明 SharedContracts 依赖项。",
"market.detail.readme_loading": "正在加载 README...",
"market.detail.readme_empty": "README 为空。",
"market.detail.readme_error_format": "README 加载失败:{0}",
"market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装",
@@ -399,6 +536,7 @@
"market.button.update": "更新",
"market.button.installed": "已安装",
"market.button.installing": "安装中...",
"market.button.restart": "重启后应用",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
@@ -406,6 +544,12 @@
"component_library.drag_hint": "拖动放置",
"component.delete": "删除",
"component.edit": "编辑",
"component.editor.instance_scope": "设置仅对当前组件实例生效。",
"component.editor.info_header": "组件信息",
"component.editor.id_label": "组件 ID",
"component.editor.placement_label": "实例 ID",
"component.editor.scope_label": "作用域",
"component.editor.scope_instance": "实例级编辑器",
"component_category.clock": "时钟",
"component_category.date": "日历",
"component_category.weather": "天气",
@@ -744,9 +888,7 @@
"placement.stretch": "拉伸",
"placement.center": "居中",
"placement.tile": "平铺",
"single_instance.notice.title": "应用已打开",
"single_instance.notice.description": "阑山桌面已经运行,已为你切换到当前正在使用的桌面。",
"single_instance.notice.button": "知道了"
"single_instance.notice.title": "应用已经运行",
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
"single_instance.notice.button": "确定"
}

View File

@@ -14,8 +14,20 @@ public sealed class AppSettingsSnapshot
public string? ThemeColor { get; set; }
public bool UseSystemChrome { get; set; }
public string ThemeColorMode { get; set; } = "default_neutral";
public string SystemMaterialMode { get; set; } = "none";
public string? SelectedWallpaperSeed { get; set; }
public string? WallpaperPath { get; set; }
public string WallpaperType { get; set; } = "Image";
public string? WallpaperColor { get; set; }
public string WallpaperPlacement { get; set; } = "Fill";
public int SettingsTabIndex { get; set; } = 0;
@@ -42,7 +54,7 @@ public sealed class AppSettingsSnapshot
public string WeatherExcludedAlerts { get; set; } = string.Empty;
public string WeatherIconPackId { get; set; } = "FluentRegular";
public string WeatherIconPackId { get; set; } = "HyperOS3";
public bool WeatherNoTlsRequests { get; set; }
@@ -54,14 +66,33 @@ public sealed class AppSettingsSnapshot
public bool IncludePrereleaseUpdates { get; set; }
public string UpdateChannel { get; set; } = string.Empty;
public bool UploadAnonymousCrashData { get; set; }
public bool UploadAnonymousUsageData { get; set; }
public string? DeviceId { get; set; }
public string UpdateChannel { get; set; } = "stable";
public string UpdateMode { get; set; } = "download_then_confirm";
public string UpdateDownloadSource { get; set; } = "github";
public int UpdateDownloadThreads { get; set; } = 4;
public string? PendingUpdateInstallerPath { get; set; }
public string? PendingUpdateVersion { get; set; }
public long? PendingUpdatePublishedAtUtcMs { get; set; }
public long? LastUpdateCheckUtcMs { get; set; }
public List<string> TopStatusComponentIds { get; set; } = [];
public List<string> PinnedTaskbarActions { get; set; } =
[
TaskbarActionId.MinimizeToWindows.ToString(),
TaskbarActionId.OpenSettings.ToString()
TaskbarActionId.MinimizeToWindows.ToString()
];
public bool EnableDynamicTaskbarActions { get; set; } = true;

View File

@@ -1,8 +1,49 @@
using System.Collections.Generic;
using System.Collections.Generic;
using Avalonia.Media;
namespace LanMountainDesktop.Models;
public sealed record MonetPalette(
IReadOnlyList<Color> RecommendedColors,
IReadOnlyList<Color> MonetColors);
public sealed record MonetPalette
{
public MonetPalette(
IReadOnlyList<Color> recommendedColors,
Color seed,
Color primary,
Color secondary,
Color tertiary,
Color neutral,
Color neutralVariant)
{
RecommendedColors = recommendedColors;
Seed = seed;
Primary = primary;
Secondary = secondary;
Tertiary = tertiary;
Neutral = neutral;
NeutralVariant = neutralVariant;
MonetColors =
[
primary,
secondary,
tertiary,
neutral,
neutralVariant
];
}
public IReadOnlyList<Color> RecommendedColors { get; }
public IReadOnlyList<Color> MonetColors { get; }
public Color Seed { get; }
public Color Primary { get; }
public Color Secondary { get; }
public Color Tertiary { get; }
public Color Neutral { get; }
public Color NeutralVariant { get; }
}

View File

@@ -1,12 +1,11 @@
namespace LanMountainDesktop.Models;
namespace LanMountainDesktop.Models;
public enum TaskbarActionId
{
MinimizeToWindows,
OpenSettings,
AddDesktopPage,
DeleteDesktopPage,
DeleteComponent,
EditComponent,
DeleteComponent,
HideLauncherEntry
}

View File

@@ -2,11 +2,5 @@
public enum TaskbarContext
{
Desktop,
SettingsWallpaper,
SettingsGrid,
SettingsColor,
SettingsStatusBar,
SettingsWeather,
SettingsRegion
Desktop
}

View File

@@ -4,24 +4,38 @@ using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Services.Settings;
using Sentry;
namespace LanMountainDesktop;
sealed class Program
{
// 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.
internal static string StartupRenderMode { get; private set; } = AppRenderingModeHelper.Default;
[STAThread]
public static void Main(string[] args)
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
InitializeDeviceId();
InitializeCrashReporting();
InitializeUserBehaviorAnalytics();
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
using var singleInstance = AcquireSingleInstance(args);
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
if (!singleInstance.IsPrimaryInstance)
{
if (restartParentProcessId is not null)
{
AppLogger.Warn(
"Startup",
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
return;
}
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
return;
@@ -33,8 +47,10 @@ sealed class Program
try
{
var renderMode = LoadConfiguredRenderMode();
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.");
}
@@ -49,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>()
@@ -73,9 +88,8 @@ sealed class Program
return builder;
}
private static SingleInstanceService AcquireSingleInstance(string[] args)
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
{
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var singleInstance = SingleInstanceService.CreateDefault();
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
{
@@ -113,7 +127,10 @@ sealed class Program
{
try
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
var snapshot = HostSettingsFacadeProvider.GetOrCreate()
.Settings
.LoadSnapshot<AppSettingsSnapshot>(LanMountainDesktop.PluginSdk.SettingsScope.App);
return AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
}
catch (Exception ex)
{
@@ -135,7 +152,6 @@ sealed class Program
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
@@ -151,11 +167,200 @@ sealed class Program
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
if (eventArgs.IsTerminating)
{
SentrySdk.Flush(TimeSpan.FromSeconds(5));
}
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
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

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]

View File

@@ -0,0 +1,72 @@
using System;
using Avalonia.Controls;
using Avalonia.Platform;
namespace LanMountainDesktop.Services;
public enum AppLogoVariant
{
Auto = 0,
Day = 1,
Night = 2
}
public interface IAppLogoService
{
WindowIcon CreateWindowIcon(AppLogoVariant variant = AppLogoVariant.Auto);
WindowIcon CreateTrayIcon(AppLogoVariant variant = AppLogoVariant.Auto);
Uri GetVectorLogoUri(AppLogoVariant variant = AppLogoVariant.Auto);
}
internal sealed class AppLogoService : IAppLogoService
{
private static readonly Uri NightVectorLogoUri = new("avares://LanMountainDesktop/Assets/logo_nightly.svg");
private static readonly Uri DayVectorLogoUri = new("avares://LanMountainDesktop/Assets/logo_nightly.svg");
private static readonly Uri NightIconUri = new("avares://LanMountainDesktop/Assets/logo_nightly.ico");
private static readonly Uri DayIconUri = new("avares://LanMountainDesktop/Assets/logo_nightly.ico");
public WindowIcon CreateWindowIcon(AppLogoVariant variant = AppLogoVariant.Auto) => CreateIcon(ResolveIconUri(variant));
public WindowIcon CreateTrayIcon(AppLogoVariant variant = AppLogoVariant.Auto) => CreateIcon(ResolveIconUri(variant));
public Uri GetVectorLogoUri(AppLogoVariant variant = AppLogoVariant.Auto) => ResolveVectorLogoUri(variant);
private static WindowIcon CreateIcon(Uri assetUri)
{
using var stream = AssetLoader.Open(assetUri);
return new WindowIcon(stream);
}
private static Uri ResolveIconUri(AppLogoVariant variant) => ResolveVariant(variant) switch
{
AppLogoVariant.Day => DayIconUri,
_ => NightIconUri
};
private static Uri ResolveVectorLogoUri(AppLogoVariant variant) => ResolveVariant(variant) switch
{
AppLogoVariant.Day => DayVectorLogoUri,
_ => NightVectorLogoUri
};
private static AppLogoVariant ResolveVariant(AppLogoVariant variant) => variant switch
{
AppLogoVariant.Day => AppLogoVariant.Day,
AppLogoVariant.Night => AppLogoVariant.Night,
_ => AppLogoVariant.Night
};
}
internal static class HostAppLogoProvider
{
private static readonly object Gate = new();
private static IAppLogoService? _instance;
public static IAppLogoService GetOrCreate()
{
lock (Gate)
{
return _instance ??= new AppLogoService();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,161 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Services;
internal sealed record ComponentEditorThemePalette(
bool IsNightMode,
Color PrimaryColor,
Color SecondaryColor,
Color TertiaryColor,
Color WindowBackgroundColor,
Color SurfaceColor,
Color SurfaceContainerColor,
Color SurfaceContainerHighColor,
Color TopAppBarColor,
Color HeaderIconBackgroundColor,
Color TitleBarButtonHoverColor,
Color OutlineColor,
Color DividerColor,
Color OnSurfaceColor,
Color OnSurfaceVariantColor,
Color OnPrimaryColor);
internal static class ComponentEditorMaterialThemeAdapter
{
private static readonly Color DefaultPrimary = Color.Parse("#FF6750A4");
private static readonly Color DarkBackgroundBase = Color.Parse("#FF0B0F14");
private static readonly Color DarkSurfaceBase = Color.Parse("#FF10161D");
private static readonly Color DarkSurfaceContainerBase = Color.Parse("#FF151C24");
private static readonly Color DarkSurfaceContainerHighBase = Color.Parse("#FF1A232D");
private static readonly Color LightBackgroundBase = Color.Parse("#FFFCFCFF");
private static readonly Color LightSurfaceBase = Color.Parse("#FFFFFFFF");
private static readonly Color LightSurfaceContainerBase = Color.Parse("#FFF6F8FD");
private static readonly Color LightSurfaceContainerHighBase = Color.Parse("#FFF0F4FA");
private static readonly Color LightOnSurfaceBase = Color.Parse("#FF101316");
private static readonly Color DarkOnSurfaceBase = Color.Parse("#FFF6F8FC");
public static ComponentEditorThemePalette Build(
ThemeAppearanceSettingsState themeState,
WallpaperSettingsState wallpaperState,
MonetPalette monetPalette,
WallpaperMediaType wallpaperMediaType)
{
ArgumentNullException.ThrowIfNull(monetPalette);
var isNightMode = themeState.IsNightMode;
var fallbackThemeColor = TryParseColor(themeState.ThemeColor);
var useWallpaperPalette = wallpaperMediaType == WallpaperMediaType.Image && monetPalette.Primary.A > 0;
var primary = useWallpaperPalette
? monetPalette.Primary
: fallbackThemeColor ?? monetPalette.Primary;
if (primary == default)
{
primary = DefaultPrimary;
}
var secondary = ResolveSecondaryColor(primary, monetPalette, isNightMode);
var tertiary = ResolveTertiaryColor(primary, secondary, monetPalette, isNightMode);
var backgroundBase = isNightMode ? DarkBackgroundBase : LightBackgroundBase;
var surfaceBase = isNightMode ? DarkSurfaceBase : LightSurfaceBase;
var surfaceContainerBase = isNightMode ? DarkSurfaceContainerBase : LightSurfaceContainerBase;
var surfaceContainerHighBase = isNightMode ? DarkSurfaceContainerHighBase : LightSurfaceContainerHighBase;
var background = ColorMath.Blend(backgroundBase, primary, isNightMode ? 0.10 : 0.025);
var surface = ColorMath.Blend(surfaceBase, primary, isNightMode ? 0.12 : 0.035);
var surfaceContainer = ColorMath.Blend(surfaceContainerBase, primary, isNightMode ? 0.18 : 0.065);
var surfaceContainerHigh = ColorMath.Blend(surfaceContainerHighBase, primary, isNightMode ? 0.24 : 0.09);
var topAppBar = ColorMath.Blend(surfaceContainerHigh, primary, isNightMode ? 0.10 : 0.06);
var onSurfaceBase = isNightMode ? DarkOnSurfaceBase : LightOnSurfaceBase;
var onSurface = ColorMath.EnsureContrast(onSurfaceBase, background, 7.0);
var onSurfaceVariantBase = ColorMath.Blend(
onSurface,
surfaceContainer,
isNightMode ? 0.30 : 0.42);
var onSurfaceVariant = ColorMath.EnsureContrast(onSurfaceVariantBase, surfaceContainer, 4.5);
var outlineBase = ColorMath.Blend(onSurface, surfaceContainer, isNightMode ? 0.74 : 0.82);
var outline = Color.FromArgb(
isNightMode ? (byte)0x66 : (byte)0x42,
outlineBase.R,
outlineBase.G,
outlineBase.B);
var divider = Color.FromArgb(
isNightMode ? (byte)0x52 : (byte)0x26,
outlineBase.R,
outlineBase.G,
outlineBase.B);
var headerIconBackground = Color.FromArgb(
isNightMode ? (byte)0x36 : (byte)0x1F,
primary.R,
primary.G,
primary.B);
var titleBarButtonHover = Color.FromArgb(
isNightMode ? (byte)0x24 : (byte)0x12,
onSurface.R,
onSurface.G,
onSurface.B);
var onPrimaryBase = isNightMode ? Color.Parse("#FF111318") : Color.Parse("#FFFFFFFF");
var onPrimary = ColorMath.EnsureContrast(onPrimaryBase, primary, 4.5);
return new ComponentEditorThemePalette(
isNightMode,
primary,
secondary,
tertiary,
background,
surface,
surfaceContainer,
surfaceContainerHigh,
topAppBar,
headerIconBackground,
titleBarButtonHover,
outline,
divider,
onSurface,
onSurfaceVariant,
onPrimary);
}
private static Color ResolveSecondaryColor(Color primary, MonetPalette monetPalette, bool isNightMode)
{
if (monetPalette.Secondary != default)
{
return monetPalette.Secondary;
}
return ColorMath.Blend(
primary,
isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF1F1B24"),
isNightMode ? 0.18 : 0.16);
}
private static Color ResolveTertiaryColor(
Color primary,
Color secondary,
MonetPalette monetPalette,
bool isNightMode)
{
if (monetPalette.Tertiary != default)
{
return monetPalette.Tertiary;
}
var blendTarget = isNightMode ? Color.Parse("#FFFFFFFF") : Color.Parse("#FF2A2230");
return ColorMath.Blend(ColorMath.Blend(primary, secondary, 0.5), blendTarget, isNightMode ? 0.12 : 0.14);
}
private static Color? TryParseColor(string? value)
{
return !string.IsNullOrWhiteSpace(value) && Color.TryParse(value, out var parsed)
? parsed
: null;
}
}

View File

@@ -0,0 +1,190 @@
using System;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Services;
public readonly record struct ComponentEditorOpenRequest(
Window Owner,
DesktopComponentEditorDescriptor Descriptor,
string ComponentId,
string PlacementId,
Action RefreshAction,
Action<string?>? RestartAction = null);
public interface IComponentEditorWindowService
{
bool IsOpen { get; }
string? CurrentPlacementId { get; }
void Open(ComponentEditorOpenRequest request);
void Close();
}
internal sealed class ComponentEditorWindowService : IComponentEditorWindowService
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IAppearanceThemeService _appearanceThemeService;
private ComponentEditorWindow? _window;
private string? _currentPlacementId;
public ComponentEditorWindowService(ISettingsFacadeService settingsFacade)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
public bool IsOpen => _window is { IsVisible: true };
public string? CurrentPlacementId => _currentPlacementId;
public void Open(ComponentEditorOpenRequest request)
{
ArgumentNullException.ThrowIfNull(request.Owner);
ArgumentNullException.ThrowIfNull(request.RefreshAction);
_window ??= CreateWindow();
var settingsService = _settingsFacade.Settings;
var accessor = settingsService.GetComponentAccessor(request.ComponentId, request.PlacementId);
var scopedStore = new ComponentSettingsService(settingsService);
scopedStore.SetScopedComponentContext(request.ComponentId, request.PlacementId);
var hostContext = new HostContext(this, request.RefreshAction, request.RestartAction);
var context = new DesktopComponentEditorContext(
request.Descriptor.Definition,
request.ComponentId,
request.PlacementId,
_settingsFacade,
settingsService,
accessor,
scopedStore,
hostContext);
_currentPlacementId = request.PlacementId;
_window.ApplyDescriptor(request.Descriptor, context);
if (!_window.IsVisible)
{
_window.Show(request.Owner);
return;
}
_window.Activate();
}
public void Close()
{
_window?.Close();
}
private ComponentEditorWindow CreateWindow()
{
var window = new ComponentEditorWindow();
ApplyTheme(window);
window.ShowInTaskbar = false;
window.Closed += (_, _) =>
{
_window = null;
_currentPlacementId = null;
};
return window;
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
if (_window is null || e.Scope != SettingsScope.App)
{
return;
}
var changedKeys = e.ChangedKeys?.ToArray() ?? [];
var liveAppearance = _appearanceThemeService.GetCurrent();
if (changedKeys.Length > 0 &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) &&
!(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) &&
!(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) &&
!changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase))
{
return;
}
ApplyTheme(_window);
}
private void ApplyTheme(ComponentEditorWindow window)
{
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
var themeState = _settingsFacade.Theme.Get();
var wallpaperState = _settingsFacade.Wallpaper.Get();
var wallpaperMediaType = _settingsFacade.WallpaperMedia.DetectMediaType(
appearanceSnapshot.ResolvedWallpaperPath ?? wallpaperState.WallpaperPath);
var palette = ComponentEditorMaterialThemeAdapter.Build(
themeState,
wallpaperState,
appearanceSnapshot.MonetPalette,
wallpaperMediaType);
window.ApplyTheme(palette);
window.ApplyChromeMode(themeState.UseSystemChrome);
_appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.WindowBackground);
}
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
_ = sender;
_ = e;
if (_window is null)
{
return;
}
ApplyTheme(_window);
}
private sealed class HostContext : IComponentEditorHostContext
{
private readonly ComponentEditorWindowService _owner;
private readonly Action _refreshAction;
private readonly Action<string?>? _restartAction;
public HostContext(
ComponentEditorWindowService owner,
Action refreshAction,
Action<string?>? restartAction)
{
_owner = owner;
_refreshAction = refreshAction;
_restartAction = restartAction;
}
public void RequestRefresh()
{
_refreshAction();
}
public void CloseEditor()
{
_owner.Close();
}
public void RequestRestart(string? reason = null)
{
_restartAction?.Invoke(reason);
}
}
}

View File

@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Views;
using LanMountainDesktop.Views.Components;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public interface IEmbeddedComponentLibraryService
{
void Open(MainWindow window);
void Close(MainWindow window);
void Toggle(MainWindow window);
}
public interface IDetachedComponentLibraryWindowService
{
void Open(MainWindow window);
void Close(MainWindow window);
void Toggle(MainWindow window);
}
internal sealed class ComponentLibraryService : IComponentLibraryService
{
private readonly ComponentRegistry _registry;
private readonly DesktopComponentRuntimeRegistry _runtimeRegistry;
public ComponentLibraryService(ComponentRegistry registry, DesktopComponentRuntimeRegistry runtimeRegistry)
{
_registry = registry;
_runtimeRegistry = runtimeRegistry;
}
public IReadOnlyList<DesktopComponentDefinition> GetDefinitions()
{
return _registry.GetAll().ToArray();
}
public IReadOnlyList<ComponentLibraryCategoryEntry> GetDesktopCategories()
{
return _runtimeRegistry
.GetDesktopComponents()
.GroupBy(
descriptor => string.IsNullOrWhiteSpace(descriptor.Definition.Category)
? "Other"
: descriptor.Definition.Category.Trim(),
StringComparer.OrdinalIgnoreCase)
.OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)
.Select(group => new ComponentLibraryCategoryEntry(
group.Key,
group
.OrderBy(descriptor => descriptor.Definition.DisplayName, StringComparer.OrdinalIgnoreCase)
.Select(descriptor => new ComponentLibraryComponentEntry(
descriptor.Definition.Id,
descriptor.Definition.DisplayName,
descriptor.DisplayNameLocalizationKey,
group.Key,
descriptor.Definition.MinWidthCells,
descriptor.Definition.MinHeightCells))
.ToArray()))
.ToArray();
}
public bool TryCreateControl(
string componentId,
ComponentLibraryCreateContext context,
out Control? control,
out Exception? exception)
{
control = null;
exception = null;
if (!_runtimeRegistry.TryGetDescriptor(componentId, out var descriptor))
{
return false;
}
try
{
control = descriptor.CreateControl(
context.CellSize,
context.TimeZoneService,
context.WeatherInfoService,
context.RecommendationInfoService,
context.CalculatorDataService,
context.SettingsFacade,
context.PlacementId);
return true;
}
catch (Exception ex)
{
exception = ex;
return false;
}
}
}
internal sealed class EmbeddedComponentLibraryService : IEmbeddedComponentLibraryService
{
public void Open(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.OpenComponentLibraryWindowFromService();
}
public void Close(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.CloseComponentLibraryWindowFromService();
}
public void Toggle(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
if (window.IsComponentLibraryOpenFromService)
{
window.CloseComponentLibraryWindowFromService();
return;
}
window.OpenComponentLibraryWindowFromService();
}
}
internal sealed class DetachedComponentLibraryWindowService : IDetachedComponentLibraryWindowService
{
public void Open(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.OpenDetachedComponentLibraryWindowFromService();
}
public void Close(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
window.CloseDetachedComponentLibraryWindowFromService();
}
public void Toggle(MainWindow window)
{
ArgumentNullException.ThrowIfNull(window);
if (window.IsDetachedComponentLibraryWindowOpenFromService)
{
window.CloseDetachedComponentLibraryWindowFromService();
return;
}
window.OpenDetachedComponentLibraryWindowFromService();
}
}

View File

@@ -1,39 +1,40 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
private const string LegacySectionId = "__legacy__";
private readonly ISettingsService? _settingsService;
private readonly IComponentStateStore? _stateStore;
private readonly IComponentMessageStore? _messageStore;
private string _scopedComponentId = string.Empty;
private string _scopedPlacementId = string.Empty;
public ComponentSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
_settingsService = HostSettingsFacadeProvider.GetOrCreate().Settings;
}
public ComponentSettingsService(ISettingsService settingsService)
{
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
}
internal ComponentSettingsService(string settingsDirectory)
{
if (string.IsNullOrWhiteSpace(settingsDirectory))
{
throw new ArgumentException("Settings directory cannot be null or whitespace.", nameof(settingsDirectory));
}
var storage = new SqliteComponentDomainStorage(settingsDirectory);
_stateStore = storage;
_messageStore = storage;
}
public ComponentSettingsSnapshot Load()
@@ -43,19 +44,15 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
}
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
return document.DefaultSettings.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsSnapshot();
return _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
SettingsScope.ComponentInstance,
subjectId: string.Empty,
placementId: null);
}
return _stateStore?.LoadState(componentId: string.Empty, placementId: null) ?? new ComponentSettingsSnapshot();
}
public void Save(ComponentSettingsSnapshot snapshot)
@@ -66,186 +63,116 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return;
}
var snapshotToPersist = NormalizeSnapshot(snapshot);
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
snapshot ?? new ComponentSettingsSnapshot(),
subjectId: string.Empty,
placementId: null);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.DefaultSettings = snapshotToPersist;
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
}
_stateStore?.SaveState(componentId: string.Empty, placementId: null, snapshot ?? new ComponentSettingsSnapshot());
}
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
{
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (!string.IsNullOrWhiteSpace(instanceKey) &&
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
{
return snapshot.Clone();
}
return _settingsService.LoadSnapshot<ComponentSettingsSnapshot>(
SettingsScope.ComponentInstance,
subjectId: componentId,
placementId: placementId);
}
return document.DefaultSettings.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new ComponentSettingsSnapshot();
}
return _stateStore?.LoadState(componentId, placementId) ?? new ComponentSettingsSnapshot();
}
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var normalizedSnapshot = NormalizeSnapshot(snapshot);
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
Save(normalizedSnapshot);
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
snapshot ?? new ComponentSettingsSnapshot(),
subjectId: componentId,
placementId: placementId);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.InstanceSettings[instanceKey] = normalizedSnapshot;
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
_stateStore?.SaveState(componentId, placementId, snapshot ?? new ComponentSettingsSnapshot());
}
public void DeleteForComponent(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.SaveSnapshot(
SettingsScope.ComponentInstance,
new ComponentSettingsSnapshot(),
subjectId: componentId,
placementId: placementId);
_settingsService.DeleteSection(SettingsScope.ComponentInstance, componentId, LegacySectionId, placementId);
return;
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var changed = document.InstanceSettings.Remove(instanceKey);
changed |= document.PluginSettings.Remove(instanceKey);
if (changed)
{
PersistDocumentLocked(document);
}
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
}
_stateStore?.DeleteState(componentId, placementId);
}
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
{
try
if (_settingsService is not null)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey) ||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
{
return new T();
}
return _settingsService.LoadSection<T>(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
placementId: placementId);
}
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
}
}
catch (Exception ex)
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new T();
return sqliteStorage.LoadLegacyMessage<T>(componentId, placementId);
}
return new T();
}
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.SaveSection(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
section: settings,
placementId: placementId);
return;
}
try
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
PersistDocumentLocked(document);
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
sqliteStorage.SaveLegacyMessage(componentId, placementId, settings);
}
}
public void DeletePluginSettings(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
if (_settingsService is not null)
{
_settingsService.DeleteSection(
SettingsScope.ComponentInstance,
subjectId: componentId,
sectionId: LegacySectionId,
placementId: placementId);
return;
}
try
if (_messageStore is SqliteComponentDomainStorage sqliteStorage)
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
if (document.PluginSettings.Remove(instanceKey))
{
PersistDocumentLocked(document);
}
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
sqliteStorage.DeleteLegacyMessage(componentId, placementId);
}
}
@@ -297,377 +224,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
internal static void ResetCacheForTests()
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
ComponentSettingsDocumentSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
};
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
}
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object &&
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
document.RootElement.TryGetProperty("pluginSettings", out _)))
{
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
return NormalizeDocument(snapshot);
}
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(legacySnapshot)
};
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsDocumentSnapshot();
}
}
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
{
snapshot = new ComponentSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new ComponentSettingsSnapshot
{
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
IfengNewsChannelType = legacy.IfengNewsChannelType,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
{
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
}
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
normalized.ActiveImportedClassScheduleId,
normalized.ImportedClassSchedules);
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
{
normalized.StudyEnvironmentShowDisplayDb = true;
}
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
.ToList();
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
return normalized;
}
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.InstanceSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
instanceSettings[key] = NormalizeSnapshot(pair.Value);
}
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.PluginSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
pluginSettings[key] = pair.Value.Clone();
}
normalized.InstanceSettings = instanceSettings;
normalized.PluginSettings = pluginSettings;
return normalized;
}
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
{
if (schedules is null || schedules.Count == 0)
{
return [];
}
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
var id = schedule.Id?.Trim() ?? string.Empty;
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
{
continue;
}
if (!seenIds.Add(id))
{
continue;
}
result.Add(new ImportedClassScheduleSnapshot
{
Id = id,
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
FilePath = filePath
});
}
return result;
}
private static string NormalizeActiveScheduleId(
string? activeScheduleId,
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
{
var activeId = activeScheduleId?.Trim() ?? string.Empty;
if (schedules.Count == 0)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(activeId))
{
return schedules[0].Id;
}
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
? activeId
: schedules[0].Id;
}
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
{
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
? "China Standard Time"
: timeZoneId.Trim();
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
}
private static int NormalizeCnrInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 60);
}
private static int NormalizeDailyWordInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 360);
}
private static int NormalizeIfengNewsInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeBaiduHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeWeatherInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 12);
}
private static int NormalizeStcn24ForumInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return string.Empty;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeInstanceKey(string? key)
{
return key?.Trim() ?? string.Empty;
// no-op: SQLite storage is directly persisted without in-memory cache.
}
private bool HasScopedComponentContext()
@@ -675,100 +234,4 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
!string.IsNullOrWhiteSpace(_scopedPlacementId);
}
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class ComponentSettingsDocumentSnapshot
{
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public ComponentSettingsDocumentSnapshot Clone()
{
var clone = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
};
foreach (var pair in InstanceSettings)
{
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
}
foreach (var pair in PluginSettings)
{
clone.PluginSettings[pair.Key] = pair.Value.Clone();
}
return clone;
}
}
private sealed class LegacyComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
public string? ActiveImportedClassScheduleId { get; set; }
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string>? WorldClockTimeZoneIds { get; set; }
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
}
}

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,196 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia.Media.Imaging;
namespace LanMountainDesktop.Services;
public sealed record CurrentUserProfileSnapshot(
string DisplayName,
Bitmap? AvatarBitmap,
string FallbackMonogram,
bool IsPlaceholder);
public interface ICurrentUserProfileService
{
CurrentUserProfileSnapshot GetCurrentProfile();
}
internal sealed class CurrentUserProfileService : ICurrentUserProfileService, IDisposable
{
private readonly object _gate = new();
private CurrentUserProfileSnapshot? _cachedSnapshot;
private Bitmap? _cachedAvatarBitmap;
public CurrentUserProfileSnapshot GetCurrentProfile()
{
lock (_gate)
{
if (_cachedSnapshot is not null)
{
return _cachedSnapshot;
}
var displayName = ResolveDisplayName();
_cachedAvatarBitmap = TryLoadSystemAvatarBitmap();
_cachedSnapshot = new CurrentUserProfileSnapshot(
displayName,
_cachedAvatarBitmap,
BuildMonogram(displayName),
_cachedAvatarBitmap is null);
return _cachedSnapshot;
}
}
public void Dispose()
{
lock (_gate)
{
_cachedSnapshot = null;
_cachedAvatarBitmap?.Dispose();
_cachedAvatarBitmap = null;
}
}
private static string ResolveDisplayName()
{
var userName = Environment.UserName?.Trim();
return string.IsNullOrWhiteSpace(userName) ? "User" : userName;
}
private static Bitmap? TryLoadSystemAvatarBitmap()
{
foreach (var path in EnumerateAvatarCandidates())
{
try
{
using var stream = File.OpenRead(path);
return new Bitmap(stream);
}
catch
{
// Ignore unreadable avatar files and continue with the next candidate.
}
}
return null;
}
private static IEnumerable<string> EnumerateAvatarCandidates()
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var path in EnumerateDirectoryCandidates(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"Microsoft",
"Windows",
"AccountPictures")))
{
if (seen.Add(path))
{
yield return path;
}
}
foreach (var path in EnumerateDirectoryCandidates(
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"Windows",
"AccountPictures")))
{
if (seen.Add(path))
{
yield return path;
}
}
var commonPicturesDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
"Microsoft",
"User Account Pictures");
foreach (var fileName in new[]
{
"user-448.png",
"user-240.png",
"user-192.png",
"user-96.png",
"user-64.png",
"user-48.png",
"user.png"
})
{
var path = Path.Combine(commonPicturesDirectory, fileName);
if (File.Exists(path) && seen.Add(path))
{
yield return path;
}
}
}
private static IEnumerable<string> EnumerateDirectoryCandidates(string directoryPath)
{
if (!Directory.Exists(directoryPath))
{
yield break;
}
var files = Directory.EnumerateFiles(directoryPath)
.Where(path =>
{
var extension = Path.GetExtension(path);
return extension.Equals(".png", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".webp", StringComparison.OrdinalIgnoreCase);
})
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTimeUtc)
.ThenByDescending(file => file.Length);
foreach (var file in files)
{
yield return file.FullName;
}
}
private static string BuildMonogram(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return "?";
}
var letters = text
.Trim()
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Select(part => part[0])
.Take(2)
.ToArray();
if (letters.Length == 0)
{
return "?";
}
return new string(letters).ToUpperInvariant();
}
}
internal static class HostCurrentUserProfileProvider
{
private static readonly object Gate = new();
private static ICurrentUserProfileService? _instance;
public static ICurrentUserProfileService GetOrCreate()
{
lock (Gate)
{
return _instance ??= new CurrentUserProfileService();
}
}
}

View File

@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Views.ComponentEditors;
namespace LanMountainDesktop.Services;
public static class DesktopComponentEditorRegistryFactory
{
public static DesktopComponentEditorRegistry Create(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService)
{
ArgumentNullException.ThrowIfNull(componentRegistry);
var registrations = GetBuiltInRegistrations(componentRegistry).ToList();
var registeredIds = new HashSet<string>(
registrations.Select(registration => registration.ComponentId),
StringComparer.OrdinalIgnoreCase);
if (pluginRuntimeService is not null)
{
foreach (var contribution in pluginRuntimeService.DesktopComponentEditors)
{
var registration = contribution.Registration;
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out var definition) ||
!definition.AllowDesktopPlacement ||
!registeredIds.Add(registration.ComponentId))
{
continue;
}
registrations.Add(new DesktopComponentEditorRegistration(
registration.ComponentId,
context => CreatePluginEditor(contribution, context),
registration.PreferredWidth,
registration.PreferredHeight,
registration.MinScale,
registration.MaxScale));
}
}
return new DesktopComponentEditorRegistry(componentRegistry, registrations);
}
private static IEnumerable<DesktopComponentEditorRegistration> GetBuiltInRegistrations(ComponentRegistry componentRegistry)
{
var registrations = new Dictionary<string, DesktopComponentEditorRegistration>(StringComparer.OrdinalIgnoreCase)
{
[BuiltInComponentIds.DesktopClock] = new(
BuiltInComponentIds.DesktopClock,
context => new ClockComponentEditor(context)),
[BuiltInComponentIds.DesktopWorldClock] = new(
BuiltInComponentIds.DesktopWorldClock,
context => new WorldClockComponentEditor(context),
preferredWidth: 820d,
preferredHeight: 620d),
[BuiltInComponentIds.DesktopClassSchedule] = new(
BuiltInComponentIds.DesktopClassSchedule,
context => new ClassScheduleComponentEditor(context),
preferredWidth: 860d,
preferredHeight: 640d),
[BuiltInComponentIds.DesktopDailyArtwork] = new(
BuiltInComponentIds.DesktopDailyArtwork,
context => new DailyArtworkComponentEditor(context)),
[BuiltInComponentIds.DesktopStudyEnvironment] = new(
BuiltInComponentIds.DesktopStudyEnvironment,
context => new StudyEnvironmentComponentEditor(context)),
[BuiltInComponentIds.DesktopWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeather),
[BuiltInComponentIds.DesktopWeatherClock] = CreateWeatherRegistration(BuiltInComponentIds.DesktopWeatherClock),
[BuiltInComponentIds.DesktopHourlyWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopHourlyWeather),
[BuiltInComponentIds.DesktopMultiDayWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopMultiDayWeather),
[BuiltInComponentIds.DesktopExtendedWeather] = CreateWeatherRegistration(BuiltInComponentIds.DesktopExtendedWeather),
[BuiltInComponentIds.DesktopCnrDailyNews] = new(
BuiltInComponentIds.DesktopCnrDailyNews,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "cnr.settings.desc",
DescriptionFallback = "Configure auto rotation for this CNR news widget.",
ToggleLabelKey = "cnr.settings.auto_rotate",
ToggleLabelFallback = "Auto rotate",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "cnr.settings.rotate_interval",
IntervalLabelFallback = "Rotate interval",
DefaultInterval = 60,
GetEnabled = snapshot => snapshot.CnrDailyNewsAutoRotateEnabled,
SetEnabled = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateEnabled = value,
GetInterval = snapshot => snapshot.CnrDailyNewsAutoRotateIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.CnrDailyNewsAutoRotateIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateEnabled),
nameof(ComponentSettingsSnapshot.CnrDailyNewsAutoRotateIntervalMinutes)
]
})),
[BuiltInComponentIds.DesktopIfengNews] = new(
BuiltInComponentIds.DesktopIfengNews,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "ifeng.settings.desc",
DescriptionFallback = "Configure auto refresh and source channel for this iFeng widget.",
ToggleLabelKey = "ifeng.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "ifeng.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 20,
GetEnabled = snapshot => snapshot.IfengNewsAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.IfengNewsAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.IfengNewsAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.IfengNewsAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "ifeng.settings.channel",
ExtraSelectorLabelFallback = "Channel",
ExtraOptions =
[
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Comprehensive,
"ifeng.settings.channel.comprehensive",
"Comprehensive"),
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Mainland,
"ifeng.settings.channel.mainland",
"Mainland"),
new ComponentEditorSelectionOption(
IfengNewsChannelTypes.Taiwan,
"ifeng.settings.channel.taiwan",
"Taiwan")
],
GetExtraValue = snapshot => IfengNewsChannelTypes.Normalize(snapshot.IfengNewsChannelType),
SetExtraValue = (snapshot, value) => snapshot.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.IfengNewsAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.IfengNewsChannelType)
]
})),
[BuiltInComponentIds.DesktopDailyWord] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord),
[BuiltInComponentIds.DesktopDailyWord2x2] = CreateDailyWordRegistration(BuiltInComponentIds.DesktopDailyWord2x2),
[BuiltInComponentIds.DesktopBilibiliHotSearch] = new(
BuiltInComponentIds.DesktopBilibiliHotSearch,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "bilibili.settings.desc",
DescriptionFallback = "Configure auto refresh for this Bilibili hot search widget.",
ToggleLabelKey = "bilibili.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "bilibili.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = snapshot => snapshot.BilibiliHotSearchAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes)
]
})),
[BuiltInComponentIds.DesktopBaiduHotSearch] = new(
BuiltInComponentIds.DesktopBaiduHotSearch,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "baidu.settings.desc",
DescriptionFallback = "Configure auto refresh and source for this Baidu hot search widget.",
ToggleLabelKey = "baidu.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "baidu.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 15,
GetEnabled = snapshot => snapshot.BaiduHotSearchAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "baidu.settings.source",
ExtraSelectorLabelFallback = "Source",
ExtraOptions =
[
new ComponentEditorSelectionOption(
BaiduHotSearchSourceTypes.Official,
"baidu.settings.source.official",
"Official"),
new ComponentEditorSelectionOption(
BaiduHotSearchSourceTypes.ThirdPartyRss,
"baidu.settings.source.third_party",
"Third-party RSS")
],
GetExtraValue = snapshot => BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType),
SetExtraValue = (snapshot, value) => snapshot.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.BaiduHotSearchSourceType)
]
})),
[BuiltInComponentIds.DesktopStcn24Forum] = new(
BuiltInComponentIds.DesktopStcn24Forum,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "stcn.settings.desc",
DescriptionFallback = "Configure auto refresh and sort mode for this STCN forum widget.",
ToggleLabelKey = "stcn.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "stcn.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 20,
GetEnabled = snapshot => snapshot.Stcn24ForumAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.Stcn24ForumAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.Stcn24ForumAutoRefreshIntervalMinutes = value,
ExtraSelectorLabelKey = "stcn.settings.sort_mode",
ExtraSelectorLabelFallback = "Sort mode",
ExtraOptions = Stcn24ForumSourceTypes.SupportedValues
.Select(value => new ComponentEditorSelectionOption(
value,
$"stcn.settings.source.{value}",
value))
.ToArray(),
GetExtraValue = snapshot => Stcn24ForumSourceTypes.Normalize(snapshot.Stcn24ForumSourceType),
SetExtraValue = (snapshot, value) => snapshot.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(value),
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.Stcn24ForumAutoRefreshIntervalMinutes),
nameof(ComponentSettingsSnapshot.Stcn24ForumSourceType)
]
}))
};
foreach (var componentId in GetBuiltInDesktopComponentIds(componentRegistry))
{
if (registrations.ContainsKey(componentId))
{
continue;
}
registrations[componentId] = new DesktopComponentEditorRegistration(
componentId,
context => new InformationalComponentEditor(
context,
$"This {context.Definition.DisplayName} component currently exposes instance-scoped editor metadata only."));
}
return registrations.Values;
}
private static IEnumerable<string> GetBuiltInDesktopComponentIds(ComponentRegistry componentRegistry)
{
return typeof(BuiltInComponentIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(field => field.FieldType == typeof(string))
.Select(field => field.GetRawConstantValue() as string)
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!)
.Where(id => componentRegistry.TryGetDefinition(id, out var definition) && definition.AllowDesktopPlacement)
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static DesktopComponentEditorRegistration CreateWeatherRegistration(string componentId)
{
return new DesktopComponentEditorRegistration(
componentId,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "weather.settings.desc",
DescriptionFallback = "Configure weather auto refresh for this component instance.",
ToggleLabelKey = "weather.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "weather.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 12,
GetEnabled = snapshot => snapshot.WeatherAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.WeatherAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.WeatherAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.WeatherAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.WeatherAutoRefreshIntervalMinutes)
]
}));
}
private static DesktopComponentEditorRegistration CreateDailyWordRegistration(string componentId)
{
return new DesktopComponentEditorRegistration(
componentId,
context => new ToggleIntervalComponentEditor(
context,
new ToggleIntervalComponentEditorOptions
{
DescriptionKey = "dailyword.settings.desc",
DescriptionFallback = "Configure auto refresh for this Daily Word component.",
ToggleLabelKey = "dailyword.settings.auto_refresh",
ToggleLabelFallback = "Auto refresh",
ToggleDescriptionKey = "component.editor.instance_scope",
ToggleDescriptionFallback = "Changes are stored per component instance.",
IntervalLabelKey = "dailyword.settings.refresh_interval",
IntervalLabelFallback = "Refresh interval",
DefaultInterval = 360,
GetEnabled = snapshot => snapshot.DailyWordAutoRefreshEnabled,
SetEnabled = (snapshot, value) => snapshot.DailyWordAutoRefreshEnabled = value,
GetInterval = snapshot => snapshot.DailyWordAutoRefreshIntervalMinutes,
SetInterval = (snapshot, value) => snapshot.DailyWordAutoRefreshIntervalMinutes = value,
ChangedKeys =
[
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshEnabled),
nameof(ComponentSettingsSnapshot.DailyWordAutoRefreshIntervalMinutes)
]
}));
}
private static Control CreatePluginEditor(
PluginDesktopComponentEditorContribution contribution,
DesktopComponentEditorContext context)
{
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
?? context.SettingsService;
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
var pluginContext = new PluginDesktopComponentEditorContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
contribution.Plugin.Context.DataDirectory,
contribution.Plugin.Services,
contribution.Plugin.Context.Properties,
context.ComponentId,
context.PlacementId,
pluginSettings,
context.HostContext);
return contribution.Registration.EditorFactory(contribution.Plugin.Services, pluginContext);
}
}

View File

@@ -10,6 +10,7 @@ using Avalonia.Media;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ComponentSystem.Extensions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services.Settings;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
@@ -32,7 +33,8 @@ public static class DesktopComponentRegistryFactory
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService)
PluginRuntimeService? pluginRuntimeService,
ISettingsFacadeService settingsFacade)
{
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
var registeredIds = new HashSet<string>(
@@ -64,6 +66,7 @@ public static class DesktopComponentRegistryFactory
}
}
_ = settingsFacade;
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
}
@@ -114,6 +117,11 @@ public static class DesktopComponentRegistryFactory
{
try
{
var settingsService = contribution.Plugin.Services.GetService(typeof(ISettingsService)) as ISettingsService
?? context.SettingsService;
var pluginSettings = new PluginScopedSettingsService(
contribution.Plugin.Manifest.Id,
settingsService);
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
@@ -122,7 +130,8 @@ public static class DesktopComponentRegistryFactory
contribution.Plugin.Context.Properties,
contribution.Registration.ComponentId,
context.PlacementId,
context.CellSize);
context.CellSize,
pluginSettings);
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
}

View File

@@ -0,0 +1,116 @@
using System;
namespace LanMountainDesktop.Services;
public readonly record struct DesktopGridMetrics(
int ColumnCount,
int RowCount,
double CellSize,
double GapPx,
double EdgeInsetPx,
double GridWidthPx,
double GridHeightPx)
{
public double Pitch => CellSize + GapPx;
}
public sealed class DesktopGridLayoutService
{
public const string RelaxedSpacingPreset = "Relaxed";
public const string CompactSpacingPreset = "Compact";
public string NormalizeSpacingPreset(string? value)
{
return string.Equals(value, CompactSpacingPreset, StringComparison.OrdinalIgnoreCase)
? CompactSpacingPreset
: RelaxedSpacingPreset;
}
public double ResolveGapRatio(string? preset)
{
return string.Equals(preset, CompactSpacingPreset, StringComparison.OrdinalIgnoreCase) ? 0.06 : 0.12;
}
public double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent)
{
if (hostWidth <= 1 || hostHeight <= 1)
{
return 0;
}
var cells = Math.Max(1, shortSideCells);
var shortSidePx = Math.Max(1, Math.Min(hostWidth, hostHeight));
var baseCell = shortSidePx / cells;
var insetRatio = Math.Clamp(insetPercent, 0, 30) / 100d;
return Math.Clamp(baseCell * insetRatio, 0, 80);
}
public DesktopGridMetrics CalculateGridMetrics(
double hostWidth,
double hostHeight,
int shortSideCells,
double gapRatio,
double edgeInsetPx)
{
if (hostWidth <= 1 || hostHeight <= 1)
{
return default;
}
var shortSide = Math.Max(1, shortSideCells);
var clampedGapRatio = Math.Max(0, gapRatio);
var inset = Math.Max(0, edgeInsetPx);
var availableWidth = Math.Max(1, hostWidth - inset * 2);
var availableHeight = Math.Max(1, hostHeight - inset * 2);
if (hostWidth >= hostHeight)
{
var rowCount = shortSide;
var denominator = rowCount + Math.Max(0, rowCount - 1) * clampedGapRatio;
if (denominator <= 0)
{
return default;
}
var cellSize = availableHeight / denominator;
var gapPx = cellSize * clampedGapRatio;
var pitch = cellSize + gapPx;
if (pitch <= 0)
{
return default;
}
var columnCount = Math.Max(1, (int)Math.Floor((availableWidth + gapPx) / pitch));
var gridWidth = columnCount * cellSize + Math.Max(0, columnCount - 1) * gapPx;
var gridHeight = rowCount * cellSize + Math.Max(0, rowCount - 1) * gapPx;
return new DesktopGridMetrics(columnCount, rowCount, cellSize, gapPx, inset, gridWidth, gridHeight);
}
var columnCountPortrait = shortSide;
var denominatorPortrait = columnCountPortrait + Math.Max(0, columnCountPortrait - 1) * clampedGapRatio;
if (denominatorPortrait <= 0)
{
return default;
}
var cellSizePortrait = availableWidth / denominatorPortrait;
var gapPxPortrait = cellSizePortrait * clampedGapRatio;
var pitchPortrait = cellSizePortrait + gapPxPortrait;
if (pitchPortrait <= 0)
{
return default;
}
var rowCountPortrait = Math.Max(1, (int)Math.Floor((availableHeight + gapPxPortrait) / pitchPortrait));
var gridWidthPortrait = columnCountPortrait * cellSizePortrait + Math.Max(0, columnCountPortrait - 1) * gapPxPortrait;
var gridHeightPortrait = rowCountPortrait * cellSizePortrait + Math.Max(0, rowCountPortrait - 1) * gapPxPortrait;
return new DesktopGridMetrics(
columnCountPortrait,
rowCountPortrait,
cellSizePortrait,
gapPxPortrait,
inset,
gridWidthPortrait,
gridHeightPortrait);
}
}

View File

@@ -1,251 +1,19 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed class DesktopLayoutSettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public DesktopLayoutSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
private readonly IComponentLayoutStore _layoutStore = ComponentDomainStorageProvider.Instance;
public DesktopLayoutSettingsSnapshot Load()
{
try
{
lock (CacheGate)
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
DesktopLayoutSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
return _layoutStore.LoadLayout();
}
public void Save(DesktopLayoutSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
return new DesktopLayoutSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
{
snapshot = new DesktopLayoutSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new DesktopLayoutSettingsSnapshot
{
DesktopPageCount = legacy.DesktopPageCount,
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
if (normalized.DesktopComponentPlacements is not null)
{
foreach (var placement in normalized.DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
PageIndex = Math.Max(0, placement.PageIndex),
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
Row = Math.Max(0, placement.Row),
Column = Math.Max(0, placement.Column),
WidthCells = Math.Max(1, placement.WidthCells),
HeightCells = Math.Max(1, placement.HeightCells)
});
}
}
normalized.DesktopComponentPlacements = placements;
return normalized;
}
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyDesktopLayoutSettingsSnapshot
{
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; }
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
_layoutStore.SaveLayout(snapshot ?? new DesktopLayoutSettingsSnapshot());
}
}

View File

@@ -172,6 +172,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
public async Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
@@ -193,11 +195,14 @@ public sealed class GitHubReleaseUpdateService : IDisposable
var progressAdapter = progress is null
? null
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
var effectiveSource = ApplyDownloadSource(asset.BrowserDownloadUrl, downloadSource);
var result = await _downloadService.DownloadAsync(
asset.BrowserDownloadUrl,
effectiveSource,
destinationFilePath,
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
new DownloadOptions(
ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null,
MaxParallelSegments: UpdateSettingsValues.NormalizeDownloadThreads(maxParallelSegments)),
progressAdapter,
cancellationToken);
@@ -460,4 +465,23 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return value[..maxLength];
}
private static string ApplyDownloadSource(string browserDownloadUrl, string? downloadSource)
{
if (!string.Equals(
UpdateSettingsValues.NormalizeDownloadSource(downloadSource),
UpdateSettingsValues.DownloadSourceGhProxy,
StringComparison.OrdinalIgnoreCase))
{
return browserDownloadUrl;
}
var normalizedBase = UpdateSettingsValues.DefaultGhProxyBaseUrl.TrimEnd('/') + "/";
if (browserDownloadUrl.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase))
{
return browserDownloadUrl;
}
return normalizedBase + browserDownloadUrl;
}
}

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Theme;
@@ -6,63 +7,97 @@ namespace LanMountainDesktop.Services;
public static class GlassEffectService
{
private const double DayPanelBlurRadius = 40;
private const double DayStrongBlurRadius = 60;
private const double DayOverlayBlurRadius = 80;
private const double NightPanelBlurRadius = 45;
private const double NightStrongBlurRadius = 65;
private const double NightOverlayBlurRadius = 85;
public static void ApplyGlassResources(IResourceDictionary resources, ThemeColorContext context)
{
// Mica 材质:不透明,但混合壁纸颜色
// 提取壁纸颜色的透明度0-1用于控制 Mica 效果强度
var wallpaperTintOpacity = 0.15; // 壁纸颜色混合比例
var neutralBase = context.IsNightMode ? Color.Parse("#FF202020") : Color.Parse("#FFF3F3F3");
var neutralElevated = context.IsNightMode ? Color.Parse("#FF2C2C2C") : Color.Parse("#FFFAFAFA");
// Mica 效果:将壁纸颜色混合到中性基色中
var micaBackground = ColorMath.Blend(neutralBase, context.AccentColor, wallpaperTintOpacity);
var micaElevated = ColorMath.Blend(neutralElevated, context.AccentColor, wallpaperTintOpacity * 0.8);
// 按钮颜色
var buttonBackground = context.IsNightMode ?
Color.FromArgb(0x33, micaBackground.R, micaBackground.G, micaBackground.B) :
Color.FromArgb(0x4D, micaBackground.R, micaBackground.G, micaBackground.B);
var materialSurfaceService = new MaterialSurfaceService();
var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
var primary = context.UseNeutralSurfaces
? context.AccentColor
: monetPalette?.Primary ?? (monetColors.Length > 0 ? monetColors[0] : context.AccentColor);
var neutralButtonBase = context.IsNightMode
? Color.Parse("#FF171C24")
: Color.Parse("#FFFFFFFF");
if (!context.UseNeutralSurfaces)
{
neutralButtonBase = ColorMath.Blend(
neutralButtonBase,
primary,
context.IsNightMode ? 0.08 : 0.04);
}
var buttonBackground = Color.FromArgb(
context.IsNightMode ? (byte)0xF0 : (byte)0xFF,
neutralButtonBase.R,
neutralButtonBase.G,
neutralButtonBase.B);
var buttonBorder = ColorMath.WithAlpha(
context.IsNightMode
? ColorMath.Blend(neutralButtonBase, Color.Parse("#FFFFFFFF"), 0.14)
: ColorMath.Blend(neutralButtonBase, Color.Parse("#FF334155"), 0.10),
context.IsNightMode ? (byte)0x26 : (byte)0x14);
resources["AdaptiveButtonBackgroundBrush"] = new SolidColorBrush(buttonBackground);
resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(
Color.FromArgb(0x1A, neutralElevated.R, neutralElevated.G, neutralElevated.B));
resources["AdaptiveButtonBorderBrush"] = new SolidColorBrush(buttonBorder);
resources["AdaptiveButtonHoverBackgroundBrush"] = new SolidColorBrush(
ColorMath.WithAlpha(buttonBackground, context.IsNightMode ? (byte)0x4D : (byte)0x66));
ColorMath.WithAlpha(
ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.14 : 0.08),
context.IsNightMode ? (byte)0xF4 : (byte)0xFF));
resources["AdaptiveButtonPressedBackgroundBrush"] = new SolidColorBrush(
ColorMath.WithAlpha(buttonBackground, context.IsNightMode ? (byte)0x66 : (byte)0x80));
ColorMath.WithAlpha(
ColorMath.Blend(buttonBackground, primary, context.IsNightMode ? 0.24 : 0.16),
context.IsNightMode ? (byte)0xF8 : (byte)0xFF));
// 面板颜色 - 使用 Mica 材质
resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(
Color.FromArgb(context.IsNightMode ? (byte)0xF0 : (byte)0xF8,
micaBackground.R, micaBackground.G, micaBackground.B));
resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(
Color.FromArgb(0x1F, neutralElevated.R, neutralElevated.G, neutralElevated.B));
resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(
Color.FromArgb(context.IsNightMode ? (byte)0xF4 : (byte)0xFB,
micaElevated.R, micaElevated.G, micaElevated.B));
resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(
Color.FromArgb(0x29, neutralElevated.R, neutralElevated.G, neutralElevated.B));
resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(
Color.FromArgb(context.IsNightMode ? (byte)0xE6 : (byte)0xF2,
micaBackground.R, micaBackground.G, micaBackground.B));
var windowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.WindowBackground);
var settingsWindowSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.SettingsWindowBackground);
var dockSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DockBackground);
var statusBarSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarBackground);
var desktopComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.DesktopComponentHost);
var statusBarComponentSurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.StatusBarComponentHost);
var overlaySurface = materialSurfaceService.GetSurface(context, MaterialSurfaceRole.OverlayPanel);
var strongSurfaceColor = ColorMath.Blend(
desktopComponentSurface.BackgroundColor,
overlaySurface.BackgroundColor,
context.IsNightMode ? 0.18 : 0.12);
var strongBorderColor = ColorMath.WithAlpha(
desktopComponentSurface.BorderColor,
context.IsNightMode ? (byte)0x20 : (byte)0x12);
var panelBorderColor = ColorMath.WithAlpha(
desktopComponentSurface.BorderColor,
context.IsNightMode ? (byte)0x18 : (byte)0x10);
// 模糊半径Mica 不需要强模糊)
resources["AdaptiveGlassPanelBlurRadius"] = context.IsNightMode ? 20.0 : 30.0;
resources["AdaptiveGlassStrongBlurRadius"] = context.IsNightMode ? 25.0 : 35.0;
resources["AdaptiveGlassOverlayBlurRadius"] = context.IsNightMode ? 30.0 : 40.0;
// 不透明度Mica 材质接近不透明)
resources["AdaptiveGlassPanelOpacity"] = context.IsNightMode ? 0.99 : 1.0;
resources["AdaptiveGlassStrongOpacity"] = context.IsNightMode ? 1.0 : 1.0;
resources["AdaptiveGlassOverlayOpacity"] = context.IsNightMode ? 0.94 : 0.97;
resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.01 : 0.008;
resources["AdaptiveWindowBackgroundBrush"] = new SolidColorBrush(windowSurface.BackgroundColor);
resources["AdaptiveWindowBorderBrush"] = new SolidColorBrush(windowSurface.BorderColor);
resources["AdaptiveSettingsWindowBackgroundBrush"] = new SolidColorBrush(settingsWindowSurface.BackgroundColor);
resources["AdaptiveSettingsWindowBorderBrush"] = new SolidColorBrush(settingsWindowSurface.BorderColor);
resources["AdaptiveDockBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor);
resources["AdaptiveDockBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor);
resources["AdaptiveStatusBarBackgroundBrush"] = new SolidColorBrush(statusBarSurface.BackgroundColor);
resources["AdaptiveStatusBarBorderBrush"] = new SolidColorBrush(statusBarSurface.BorderColor);
resources["AdaptiveDesktopComponentHostBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor);
resources["AdaptiveDesktopComponentHostBorderBrush"] = new SolidColorBrush(desktopComponentSurface.BorderColor);
resources["AdaptiveStatusBarComponentHostBackgroundBrush"] = new SolidColorBrush(statusBarComponentSurface.BackgroundColor);
resources["AdaptiveStatusBarComponentHostBorderBrush"] = new SolidColorBrush(statusBarComponentSurface.BorderColor);
resources["AdaptiveGlassPanelBackgroundBrush"] = new SolidColorBrush(desktopComponentSurface.BackgroundColor);
resources["AdaptiveGlassPanelBorderBrush"] = new SolidColorBrush(panelBorderColor);
resources["AdaptiveGlassStrongBackgroundBrush"] = new SolidColorBrush(strongSurfaceColor);
resources["AdaptiveGlassStrongBorderBrush"] = new SolidColorBrush(strongBorderColor);
resources["AdaptiveDockGlassBackgroundBrush"] = new SolidColorBrush(dockSurface.BackgroundColor);
resources["AdaptiveDockGlassBorderBrush"] = new SolidColorBrush(dockSurface.BorderColor);
resources["AdaptiveGlassOverlayBackgroundBrush"] = new SolidColorBrush(overlaySurface.BackgroundColor);
resources["AdaptiveGlassPanelBlurRadius"] = desktopComponentSurface.BlurRadius;
resources["AdaptiveGlassStrongBlurRadius"] = dockSurface.BlurRadius;
resources["AdaptiveGlassOverlayBlurRadius"] = overlaySurface.BlurRadius;
resources["AdaptiveGlassPanelOpacity"] = 1.0;
resources["AdaptiveGlassStrongOpacity"] = 1.0;
resources["AdaptiveGlassOverlayOpacity"] = overlaySurface.Opacity;
resources["AdaptiveGlassNoiseOpacity"] = context.IsNightMode ? 0.012 : 0.008;
resources["AdaptiveDockOpacity"] = dockSurface.Opacity;
resources["AdaptiveStatusBarOpacity"] = statusBarSurface.Opacity;
resources["AdaptiveDesktopComponentHostOpacity"] = desktopComponentSurface.Opacity;
resources["AdaptiveStatusBarComponentHostOpacity"] = statusBarComponentSurface.Opacity;
}
}

View File

@@ -11,18 +11,21 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
App? app = null;
try
{
AppLogger.Info(
"HostLifecycle",
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
app = Application.Current as App;
if (app?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
app.PrepareForShutdown(isRestart: false, request?.Source ?? "Unknown");
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
@@ -36,6 +39,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
catch (Exception ex)
{
app?.ResetShutdownIntent(request?.Source ?? "Unknown");
AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex);
return false;
}
@@ -43,6 +47,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
public bool TryRestart(HostApplicationLifecycleRequest? request = null)
{
App? app = null;
try
{
var startInfo = AppRestartService.CreateRestartStartInfo();
@@ -55,6 +60,8 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
Process.Start(startInfo);
app = Application.Current as App;
app?.PrepareForShutdown(isRestart: true, request?.Source ?? "Unknown");
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
@@ -68,6 +75,7 @@ public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
}
catch (Exception ex)
{
app?.ResetShutdownIntent(request?.Source ?? "Unknown");
AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex);
return false;
}

View File

@@ -0,0 +1,14 @@
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
internal static class HostComponentSettingsStoreProvider
{
private static readonly IComponentInstanceSettingsStore Instance =
new ComponentSettingsService(HostSettingsFacadeProvider.GetOrCreate().Settings);
public static IComponentInstanceSettingsStore GetOrCreate()
{
return Instance;
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services.Settings;
namespace LanMountainDesktop.Services;
public sealed record ComponentLibraryComponentEntry(
string ComponentId,
string DisplayName,
string? DisplayNameLocalizationKey,
string CategoryId,
int MinWidthCells,
int MinHeightCells);
public sealed record ComponentLibraryCategoryEntry(
string Id,
IReadOnlyList<ComponentLibraryComponentEntry> Components);
public sealed record ComponentLibraryCreateContext(
double CellSize,
TimeZoneService TimeZoneService,
IWeatherInfoService WeatherInfoService,
IRecommendationInfoService RecommendationInfoService,
ICalculatorDataService CalculatorDataService,
ISettingsFacadeService SettingsFacade,
string? PlacementId = null);
public interface IComponentLibraryService
{
IReadOnlyList<DesktopComponentDefinition> GetDefinitions();
IReadOnlyList<ComponentLibraryCategoryEntry> GetDesktopCategories();
bool TryCreateControl(
string componentId,
ComponentLibraryCreateContext context,
out Control? control,
out Exception? exception);
}

View File

@@ -34,6 +34,12 @@ public sealed record WeatherQueryResult<T>(
public interface IWeatherInfoService
{
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(WeatherQuery query, CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
}
public interface IWeatherDataService : IWeatherInfoService

View File

@@ -0,0 +1,351 @@
using System;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public enum LocationFailureReason
{
None = 0,
Unsupported = 1,
PermissionDenied = 2,
Disabled = 3,
Timeout = 4,
Cancelled = 5,
Unavailable = 6,
Unknown = 7
}
public readonly record struct LocationCoordinate(
double Latitude,
double Longitude,
double? AccuracyMeters = null);
public sealed record LocationRequestResult(
bool Success,
bool IsSupported,
LocationCoordinate? Coordinate = null,
LocationFailureReason FailureReason = LocationFailureReason.None,
string? ErrorMessage = null)
{
public static LocationRequestResult Unsupported(string? errorMessage = null)
=> new(false, false, null, LocationFailureReason.Unsupported, errorMessage);
public static LocationRequestResult Ok(LocationCoordinate coordinate)
=> new(true, true, coordinate, LocationFailureReason.None, null);
public static LocationRequestResult Fail(LocationFailureReason reason, string? errorMessage = null)
=> new(false, true, null, reason, errorMessage);
}
public interface ILocationService
{
bool IsSupported { get; }
Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default);
}
public sealed class UnsupportedLocationService : ILocationService
{
public bool IsSupported => false;
public Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
{
_ = cancellationToken;
return Task.FromResult(LocationRequestResult.Unsupported("Location service is not supported on this platform."));
}
}
public sealed class WindowsLocationService : ILocationService
{
private static readonly Type? GeolocatorType = ResolveWinRtType("Windows.Devices.Geolocation.Geolocator");
private static readonly MethodInfo? RequestAccessAsyncMethod =
GeolocatorType?.GetMethod("RequestAccessAsync", BindingFlags.Public | BindingFlags.Static);
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
public bool IsSupported =>
OperatingSystem.IsWindows() &&
GeolocatorType is not null &&
RequestAccessAsyncMethod is not null &&
AsTaskGenericMethodDefinition is not null;
public async Task<LocationRequestResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken = default)
{
if (!IsSupported)
{
return LocationRequestResult.Unsupported();
}
try
{
var access = await AwaitWinRtOperationAsync(RequestAccessAsyncMethod!.Invoke(null, null), cancellationToken);
var accessText = access?.ToString();
if (string.Equals(accessText, "Denied", StringComparison.OrdinalIgnoreCase))
{
return LocationRequestResult.Fail(
LocationFailureReason.PermissionDenied,
"Location permission was denied by the system.");
}
if (string.Equals(accessText, "Unspecified", StringComparison.OrdinalIgnoreCase))
{
return LocationRequestResult.Fail(
LocationFailureReason.Disabled,
"Location access is unavailable on this device.");
}
var geolocator = Activator.CreateInstance(GeolocatorType!);
if (geolocator is null)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Failed to create a Windows geolocator instance.");
}
SetPropertyValue(geolocator, "DesiredAccuracyInMeters", (uint)50);
SetPropertyValue(geolocator, "MovementThreshold", 0d);
SetPropertyValue(geolocator, "ReportInterval", (uint)0);
var geoposition = await AwaitWinRtOperationAsync(
InvokeMethod(geolocator, "GetGeopositionAsync"),
cancellationToken);
if (geoposition is null)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location request returned no position.");
}
var coordinate = GetPropertyValue(geoposition, "Coordinate");
var point = GetPropertyValue(coordinate, "Point");
var position = GetPropertyValue(point, "Position");
var latitude = ReadDoubleProperty(position, "Latitude");
var longitude = ReadDoubleProperty(position, "Longitude");
if (!latitude.HasValue || !longitude.HasValue)
{
return LocationRequestResult.Fail(LocationFailureReason.Unavailable, "Location coordinates are not available.");
}
var accuracy = ReadDoubleProperty(coordinate, "Accuracy");
return LocationRequestResult.Ok(new LocationCoordinate(latitude.Value, longitude.Value, accuracy));
}
catch (OperationCanceledException)
{
return LocationRequestResult.Fail(
cancellationToken.IsCancellationRequested ? LocationFailureReason.Cancelled : LocationFailureReason.Timeout,
"Location request was cancelled.");
}
catch (TargetInvocationException ex) when (ex.InnerException is not null)
{
return MapException(ex.InnerException);
}
catch (Exception ex)
{
return MapException(ex);
}
}
private static LocationRequestResult MapException(Exception ex)
{
if (ex is UnauthorizedAccessException)
{
return LocationRequestResult.Fail(LocationFailureReason.PermissionDenied, ex.Message);
}
if (ex is TimeoutException)
{
return LocationRequestResult.Fail(LocationFailureReason.Timeout, ex.Message);
}
var hr = ex.HResult;
if (hr == unchecked((int)0x80070422))
{
return LocationRequestResult.Fail(LocationFailureReason.Disabled, ex.Message);
}
return LocationRequestResult.Fail(LocationFailureReason.Unknown, ex.Message);
}
private static async Task<object?> AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
{
if (operation is null || AsTaskGenericMethodDefinition is null)
{
return null;
}
var resultType = ResolveWinRtOperationResultType(operation.GetType());
if (resultType is null)
{
return null;
}
var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType);
var taskObject = asTaskMethod.Invoke(null, [operation]) as Task;
if (taskObject is null)
{
return null;
}
await taskObject.WaitAsync(cancellationToken);
return taskObject
.GetType()
.GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?
.GetValue(taskObject);
}
private static Type? ResolveWinRtOperationResultType(Type operationType)
{
if (operationType.IsGenericType)
{
var genericArguments = operationType.GetGenericArguments();
if (genericArguments.Length == 1)
{
return genericArguments[0];
}
}
foreach (var iface in operationType.GetInterfaces())
{
if (!iface.IsGenericType)
{
continue;
}
var genericTypeDef = iface.GetGenericTypeDefinition();
if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
{
return iface.GetGenericArguments()[0];
}
}
return null;
}
private static MethodInfo? ResolveAsTaskGenericMethod()
{
try
{
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
if (type is null)
{
return null;
}
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
try
{
if (!string.Equals(method.Name, "AsTask", StringComparison.Ordinal) ||
!method.IsGenericMethodDefinition)
{
continue;
}
var parameters = method.GetParameters();
if (parameters.Length == 1)
{
return method;
}
}
catch (PlatformNotSupportedException)
{
// Some WinRT bridge overloads throw during metadata inspection on unsupported runtimes.
}
catch
{
// Ignore unusable overloads and keep probing for a compatible AsTask<T>.
}
}
}
catch
{
// If the WinRT bridge is unavailable, the location service will gracefully report unsupported.
}
return null;
}
private static Type? ResolveWinRtType(string typeName)
{
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
}
private static object? InvokeMethod(object? target, string methodName)
{
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null);
}
private static object? GetPropertyValue(object? target, string propertyName)
{
return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target);
}
private static void SetPropertyValue(object target, string propertyName, object value)
{
var property = target.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
if (property is null || !property.CanWrite)
{
return;
}
try
{
property.SetValue(target, value);
}
catch
{
}
}
private static double? ReadDoubleProperty(object? target, string propertyName)
{
var value = GetPropertyValue(target, propertyName);
if (value is null)
{
return null;
}
try
{
return Convert.ToDouble(value);
}
catch
{
return null;
}
}
}
internal static class HostLocationServiceProvider
{
private static readonly object Gate = new();
private static ILocationService? _instance;
public static ILocationService GetOrCreate()
{
lock (Gate)
{
if (_instance is not null)
{
return _instance;
}
if (!OperatingSystem.IsWindows())
{
_instance = new UnsupportedLocationService();
return _instance;
}
try
{
_instance = new WindowsLocationService();
}
catch (Exception ex)
{
AppLogger.Warn("Location", "Failed to initialize Windows location service. Falling back to unsupported mode.", ex);
_instance = new UnsupportedLocationService();
}
return _instance;
}
}
}

View File

@@ -1,84 +1,86 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using LanMountainDesktop.Models;
using MaterialColorUtilities.Palettes;
using MaterialColorUtilities.Utils;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed class MonetColorService
{
public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode)
private static readonly Color DefaultSeedColor = Color.Parse("#FF3B82F6");
public MonetPalette BuildPalette(Bitmap? wallpaper, bool nightMode, Color? preferredSeed = null)
{
var recommended = BuildRecommendedPalette(nightMode);
var seed = TryExtractSeedColor(wallpaper) ?? TryGetSystemMonetSeedColor() ?? Color.Parse("#FF3B82F6");
var monet = BuildMonetPalette(seed, nightMode);
return new MonetPalette(recommended, monet);
var wallpaperCandidates = wallpaper is null
? []
: ExtractSeedCandidates(wallpaper);
return BuildPaletteCore(wallpaperCandidates, nightMode, preferredSeed);
}
private static IReadOnlyList<Color> BuildRecommendedPalette(bool nightMode)
public MonetPalette BuildPaletteFromSeedCandidates(
IReadOnlyList<Color>? seedCandidates,
bool nightMode,
Color? preferredSeed = null)
{
if (nightMode)
{
return
[
Color.Parse("#FF3B82F6"),
Color.Parse("#FF22C55E"),
Color.Parse("#FFF59E0B"),
Color.Parse("#FFF97316"),
Color.Parse("#FFA855F7"),
Color.Parse("#FFEF4444")
];
}
return
[
Color.Parse("#FF1D4ED8"),
Color.Parse("#FF15803D"),
Color.Parse("#FFB45309"),
Color.Parse("#FFC2410C"),
Color.Parse("#FF7E22CE"),
Color.Parse("#FFB91C1C")
];
return BuildPaletteCore(seedCandidates ?? [], nightMode, preferredSeed);
}
private static IReadOnlyList<Color> BuildMonetPalette(Color seed, bool nightMode)
public IReadOnlyList<Color> ExtractSeedCandidates(Bitmap wallpaper)
{
var (hue, saturation, value) = ToHsv(seed);
var valueBase = nightMode ? Math.Max(0.70, value) : Math.Min(0.72, Math.Max(0.35, value));
var saturationBase = Math.Clamp(saturation, 0.22, 0.74);
var offsets = new[] { 0d, 16d, -16d, 36d, -36d, 180d };
var palette = new List<Color>(offsets.Length);
for (var i = 0; i < offsets.Length; i++)
{
var hueShift = NormalizeHue(hue + offsets[i]);
var sat = Math.Clamp(saturationBase + ((i % 2 == 0) ? 0.05 : -0.05), 0.18, 0.86);
var val = Math.Clamp(valueBase + ((i < 3) ? 0.06 : -0.04), 0.32, 0.92);
palette.Add(FromHsv(hueShift, sat, val));
}
return palette;
ArgumentNullException.ThrowIfNull(wallpaper);
return ExtractWallpaperSeedCandidates(wallpaper);
}
private static Color? TryExtractSeedColor(Bitmap? wallpaper)
private static Color? ResolveSeedColor(
IReadOnlyList<Color> wallpaperCandidates,
Color? preferredSeed)
{
if (wallpaper is null)
if (wallpaperCandidates.Count == 0)
{
return null;
}
if (preferredSeed is { } explicitSeed)
{
var exact = wallpaperCandidates.FirstOrDefault(candidate => candidate == explicitSeed);
if (exact != default)
{
return exact;
}
}
return wallpaperCandidates[0];
}
private static IReadOnlyList<Color> BuildFallbackSeedCandidates()
{
return
[
Color.Parse("#FF3B82F6"),
Color.Parse("#FF22C55E"),
Color.Parse("#FFF59E0B"),
Color.Parse("#FFF97316"),
Color.Parse("#FFA855F7")
];
}
private static IReadOnlyList<Color> ExtractWallpaperSeedCandidates(Bitmap wallpaper)
{
try
{
var sampleWidth = Math.Clamp(wallpaper.PixelSize.Width, 1, 48);
var sampleHeight = Math.Clamp(wallpaper.PixelSize.Height, 1, 48);
var width = Math.Clamp(wallpaper.PixelSize.Width, 1, 96);
var height = Math.Clamp(wallpaper.PixelSize.Height, 1, 96);
using var scaledBitmap = wallpaper.CreateScaledBitmap(
new PixelSize(sampleWidth, sampleHeight),
new PixelSize(width, height),
BitmapInterpolationMode.MediumQuality);
using var writeable = new WriteableBitmap(
scaledBitmap.PixelSize,
@@ -91,55 +93,52 @@ public sealed class MonetColorService
var byteCount = framebuffer.RowBytes * framebuffer.Size.Height;
if (byteCount <= 0 || framebuffer.Address == IntPtr.Zero)
{
return null;
return [];
}
var pixelBuffer = new byte[byteCount];
Marshal.Copy(framebuffer.Address, pixelBuffer, 0, byteCount);
double bestScore = double.MinValue;
Color? bestColor = null;
var argbPixels = new List<uint>(framebuffer.Size.Width * framebuffer.Size.Height);
for (var y = 0; y < framebuffer.Size.Height; y++)
{
var rowOffset = y * framebuffer.RowBytes;
for (var x = 0; x < framebuffer.Size.Width; x++)
{
var index = rowOffset + (x * 4);
var alpha = pixelBuffer[index + 3] / 255d;
if (alpha <= 0.15)
var alpha = pixelBuffer[index + 3];
if (alpha <= 32)
{
continue;
}
var blue = (pixelBuffer[index] / 255d) / alpha;
var green = (pixelBuffer[index + 1] / 255d) / alpha;
var red = (pixelBuffer[index + 2] / 255d) / alpha;
red = Math.Clamp(red, 0, 1);
green = Math.Clamp(green, 0, 1);
blue = Math.Clamp(blue, 0, 1);
var color = Color.FromRgb(
(byte)Math.Round(red * 255),
(byte)Math.Round(green * 255),
(byte)Math.Round(blue * 255));
var (_, saturation, value) = ToHsv(color);
var score = (saturation * 1.8) + (value * 0.6);
if (score <= bestScore)
{
continue;
}
bestScore = score;
bestColor = color;
var blue = pixelBuffer[index];
var green = pixelBuffer[index + 1];
var red = pixelBuffer[index + 2];
argbPixels.Add(
((uint)alpha << 24) |
((uint)red << 16) |
((uint)green << 8) |
blue);
}
}
return bestColor;
if (argbPixels.Count == 0)
{
return [];
}
var extracted = ImageUtils.ColorsFromImage(argbPixels.ToArray());
return extracted
.Select(FromArgb)
.Distinct()
.Take(6)
.ToArray();
}
catch
catch (Exception ex)
{
return null;
AppLogger.Warn("Appearance.WallpaperPalette", "Failed to extract wallpaper seed candidates.", ex);
return [];
}
}
@@ -161,11 +160,17 @@ public sealed class MonetColorService
return null;
}
var bytes = BitConverter.GetBytes(accentDword);
var blue = bytes[0];
var green = bytes[1];
var red = bytes[2];
return Color.FromRgb(red, green, blue);
var accentColor = unchecked((uint)accentDword);
var a = (byte)((accentColor >> 24) & 0xFF);
var b = (byte)((accentColor >> 16) & 0xFF);
var g = (byte)((accentColor >> 8) & 0xFF);
var r = (byte)(accentColor & 0xFF);
if (a == 0)
{
a = 0xFF;
}
return Color.FromArgb(a, r, g, b);
}
catch
{
@@ -173,78 +178,51 @@ public sealed class MonetColorService
}
}
private static (double Hue, double Saturation, double Value) ToHsv(Color color)
private static uint ToArgb(Color color)
{
var red = color.R / 255d;
var green = color.G / 255d;
var blue = color.B / 255d;
var max = Math.Max(red, Math.Max(green, blue));
var min = Math.Min(red, Math.Min(green, blue));
var delta = max - min;
double hue;
if (delta < 0.0001)
{
hue = 0;
}
else if (Math.Abs(max - red) < 0.0001)
{
hue = 60 * (((green - blue) / delta) % 6);
}
else if (Math.Abs(max - green) < 0.0001)
{
hue = 60 * (((blue - red) / delta) + 2);
}
else
{
hue = 60 * (((red - green) / delta) + 4);
}
hue = NormalizeHue(hue);
var saturation = max <= 0.0001 ? 0 : delta / max;
return (hue, saturation, max);
return
((uint)color.A << 24) |
((uint)color.R << 16) |
((uint)color.G << 8) |
color.B;
}
private static Color FromHsv(double hue, double saturation, double value)
private static Color FromArgb(uint argb)
{
hue = NormalizeHue(hue);
saturation = Math.Clamp(saturation, 0, 1);
value = Math.Clamp(value, 0, 1);
if (saturation <= 0.0001)
{
var gray = (byte)Math.Round(value * 255);
return Color.FromRgb(gray, gray, gray);
}
var chroma = value * saturation;
var x = chroma * (1 - Math.Abs(((hue / 60d) % 2) - 1));
var m = value - chroma;
(double r, double g, double b) = hue switch
{
>= 0 and < 60 => (chroma, x, 0d),
>= 60 and < 120 => (x, chroma, 0d),
>= 120 and < 180 => (0d, chroma, x),
>= 180 and < 240 => (0d, x, chroma),
>= 240 and < 300 => (x, 0d, chroma),
_ => (chroma, 0d, x)
};
var red = (byte)Math.Round((r + m) * 255);
var green = (byte)Math.Round((g + m) * 255);
var blue = (byte)Math.Round((b + m) * 255);
return Color.FromRgb(red, green, blue);
var a = (byte)((argb >> 24) & 0xFF);
var r = (byte)((argb >> 16) & 0xFF);
var g = (byte)((argb >> 8) & 0xFF);
var b = (byte)(argb & 0xFF);
return Color.FromArgb(a, r, g, b);
}
private static double NormalizeHue(double hue)
private static MonetPalette BuildPaletteCore(
IReadOnlyList<Color> wallpaperCandidates,
bool nightMode,
Color? preferredSeed)
{
hue %= 360;
if (hue < 0)
{
hue += 360;
}
var recommendedColors = wallpaperCandidates.Count > 0
? wallpaperCandidates
: BuildFallbackSeedCandidates();
var seed = ResolveSeedColor(wallpaperCandidates, preferredSeed)
?? preferredSeed
?? TryGetSystemMonetSeedColor()
?? DefaultSeedColor;
return hue;
var corePalette = CorePalette.Of(ToArgb(seed), Style.TonalSpot);
var primary = FromArgb(corePalette.Primary.Tone(nightMode ? 80u : 40u));
var secondary = FromArgb(corePalette.Secondary.Tone(nightMode ? 80u : 40u));
var tertiary = FromArgb(corePalette.Tertiary.Tone(nightMode ? 80u : 40u));
var neutral = FromArgb(corePalette.Neutral.Tone(nightMode ? 20u : 94u));
var neutralVariant = FromArgb(corePalette.NeutralVariant.Tone(nightMode ? 30u : 90u));
return new MonetPalette(
recommendedColors,
seed,
primary,
secondary,
tertiary,
neutral,
neutralVariant);
}
}

View File

@@ -7,6 +7,7 @@ public static class PendingRestartStateService
{
public const string RenderModeReason = "RenderMode";
public const string PluginCatalogReason = "PluginCatalog";
public const string SettingsWindowReason = "SettingsWindow";
private static readonly object Gate = new();
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);

View File

@@ -0,0 +1,845 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
using Microsoft.Data.Sqlite;
namespace LanMountainDesktop.Services.Settings;
public interface IComponentLayoutStore
{
DesktopLayoutSettingsSnapshot LoadLayout();
void SaveLayout(DesktopLayoutSettingsSnapshot snapshot);
}
public interface IComponentStateStore
{
ComponentSettingsSnapshot LoadState(string componentId, string? placementId);
void SaveState(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
void DeleteState(string componentId, string? placementId);
}
public interface IComponentMessageStore
{
T LoadSection<T>(string componentId, string? placementId, string sectionId) where T : new();
void SaveSection<T>(string componentId, string? placementId, string sectionId, T section);
void DeleteSection(string componentId, string? placementId, string sectionId);
}
internal static class ComponentDomainStorageProvider
{
private static readonly object Gate = new();
private static SqliteComponentDomainStorage? _instance;
public static SqliteComponentDomainStorage Instance
{
get
{
lock (Gate)
{
_instance ??= new SqliteComponentDomainStorage();
return _instance;
}
}
}
}
internal sealed class SqliteComponentDomainStorage :
IComponentLayoutStore,
IComponentStateStore,
IComponentMessageStore
{
private const string MigrationMarkerKey = "component_domain_v1";
private const string DefaultInstanceKey = "__default__";
private const string LegacySectionId = "__legacy__";
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly object _gate = new();
private readonly string _settingsRoot;
private readonly string _dbPath;
private readonly string _layoutJsonPath;
private readonly string _componentJsonPath;
public SqliteComponentDomainStorage(string? settingsRoot = null)
{
_settingsRoot = string.IsNullOrWhiteSpace(settingsRoot)
? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop")
: settingsRoot.Trim();
_dbPath = Path.Combine(_settingsRoot, "component-state.db");
_layoutJsonPath = Path.Combine(_settingsRoot, "desktop-layout-settings.json");
_componentJsonPath = Path.Combine(_settingsRoot, "component-settings.json");
Directory.CreateDirectory(_settingsRoot);
InitializeDatabase();
}
public DesktopLayoutSettingsSnapshot LoadLayout()
{
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT desktop_page_count, current_desktop_surface_index
FROM component_layout
WHERE id = 1;
""";
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return new DesktopLayoutSettingsSnapshot();
}
return new DesktopLayoutSettingsSnapshot
{
DesktopPageCount = Math.Max(1, reader.GetInt32(0)),
CurrentDesktopSurfaceIndex = Math.Max(0, reader.GetInt32(1)),
DesktopComponentPlacements = LoadPlacements(connection)
};
}
}
public void SaveLayout(DesktopLayoutSettingsSnapshot snapshot)
{
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
lock (_gate)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_layout(id, desktop_page_count, current_desktop_surface_index, updated_utc)
VALUES(1, $count, $index, $updated)
ON CONFLICT(id) DO UPDATE SET
desktop_page_count = excluded.desktop_page_count,
current_desktop_surface_index = excluded.current_desktop_surface_index,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$count", normalized.DesktopPageCount);
command.Parameters.AddWithValue("$index", normalized.CurrentDesktopSurfaceIndex);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
using (var deleteCommand = connection.CreateCommand())
{
deleteCommand.Transaction = transaction;
deleteCommand.CommandText = "DELETE FROM component_placement;";
deleteCommand.ExecuteNonQuery();
}
if (normalized.DesktopComponentPlacements is { Count: > 0 })
{
foreach (var placement in normalized.DesktopComponentPlacements)
{
if (placement is null || string.IsNullOrWhiteSpace(placement.PlacementId))
{
continue;
}
using var insertCommand = connection.CreateCommand();
insertCommand.Transaction = transaction;
insertCommand.CommandText = """
INSERT INTO component_placement(
placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells, updated_utc)
VALUES($placementId, $page, $componentId, $row, $column, $width, $height, $updated);
""";
insertCommand.Parameters.AddWithValue("$placementId", placement.PlacementId.Trim());
insertCommand.Parameters.AddWithValue("$page", Math.Max(0, placement.PageIndex));
insertCommand.Parameters.AddWithValue("$componentId", placement.ComponentId?.Trim() ?? string.Empty);
insertCommand.Parameters.AddWithValue("$row", Math.Max(0, placement.Row));
insertCommand.Parameters.AddWithValue("$column", Math.Max(0, placement.Column));
insertCommand.Parameters.AddWithValue("$width", Math.Max(1, placement.WidthCells));
insertCommand.Parameters.AddWithValue("$height", Math.Max(1, placement.HeightCells));
insertCommand.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
insertCommand.ExecuteNonQuery();
}
}
transaction.Commit();
}
}
public ComponentSettingsSnapshot LoadState(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT state_json
FROM component_state
WHERE instance_key = $instanceKey
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
var json = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(json))
{
if (string.Equals(instanceKey, DefaultInstanceKey, StringComparison.OrdinalIgnoreCase))
{
return new ComponentSettingsSnapshot();
}
return LoadDefaultState(connection);
}
return DeserializeState(json);
}
}
public void SaveState(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
var json = JsonSerializer.Serialize(snapshot ?? new ComponentSettingsSnapshot(), SerializerOptions);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO component_state(instance_key, component_id, placement_id, state_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $stateJson, $updated)
ON CONFLICT(instance_key) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
state_json = excluded.state_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$stateJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
public void DeleteState(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.Equals(instanceKey, DefaultInstanceKey, StringComparison.OrdinalIgnoreCase))
{
return;
}
lock (_gate)
{
using var connection = OpenConnection();
using var transaction = connection.BeginTransaction();
using (var stateDelete = connection.CreateCommand())
{
stateDelete.Transaction = transaction;
stateDelete.CommandText = "DELETE FROM component_state WHERE instance_key = $instanceKey;";
stateDelete.Parameters.AddWithValue("$instanceKey", instanceKey);
stateDelete.ExecuteNonQuery();
}
using (var messageDelete = connection.CreateCommand())
{
messageDelete.Transaction = transaction;
messageDelete.CommandText = "DELETE FROM component_message WHERE instance_key = $instanceKey;";
messageDelete.Parameters.AddWithValue("$instanceKey", instanceKey);
messageDelete.ExecuteNonQuery();
}
transaction.Commit();
}
}
public T LoadSection<T>(string componentId, string? placementId, string sectionId) where T : new()
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedSectionId = NormalizeSection(sectionId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT message_json
FROM component_message
WHERE instance_key = $instanceKey
AND section_id = $sectionId
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
var json = command.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(json))
{
return new T();
}
try
{
return JsonSerializer.Deserialize<T>(json, SerializerOptions) ?? new T();
}
catch
{
return new T();
}
}
}
public void SaveSection<T>(string componentId, string? placementId, string sectionId, T section)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
var normalizedSectionId = NormalizeSection(sectionId);
var json = JsonSerializer.Serialize(section, SerializerOptions);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO component_message(instance_key, component_id, placement_id, section_id, message_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $sectionId, $messageJson, $updated)
ON CONFLICT(instance_key, section_id) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
message_json = excluded.message_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", normalizedComponentId);
command.Parameters.AddWithValue("$placementId", normalizedPlacementId);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
command.Parameters.AddWithValue("$messageJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
public void DeleteSection(string componentId, string? placementId, string sectionId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
var normalizedSectionId = NormalizeSection(sectionId);
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM component_message
WHERE instance_key = $instanceKey
AND section_id = $sectionId;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$sectionId", normalizedSectionId);
command.ExecuteNonQuery();
}
}
public T LoadLegacyMessage<T>(string componentId, string? placementId) where T : new()
{
return LoadSection<T>(componentId, placementId, LegacySectionId);
}
public void SaveLegacyMessage<T>(string componentId, string? placementId, T section)
{
SaveSection(componentId, placementId, LegacySectionId, section);
}
public void DeleteLegacyMessage(string componentId, string? placementId)
{
DeleteSection(componentId, placementId, LegacySectionId);
}
private void InitializeDatabase()
{
lock (_gate)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS settings_meta(
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_layout(
id INTEGER PRIMARY KEY CHECK(id = 1),
desktop_page_count INTEGER NOT NULL,
current_desktop_surface_index INTEGER NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_placement(
placement_id TEXT PRIMARY KEY,
page_index INTEGER NOT NULL,
component_id TEXT NOT NULL,
row_index INTEGER NOT NULL,
column_index INTEGER NOT NULL,
width_cells INTEGER NOT NULL,
height_cells INTEGER NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_state(
instance_key TEXT PRIMARY KEY,
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS component_message(
instance_key TEXT NOT NULL,
component_id TEXT NOT NULL,
placement_id TEXT NOT NULL,
section_id TEXT NOT NULL,
message_json TEXT NOT NULL,
updated_utc TEXT NOT NULL,
PRIMARY KEY(instance_key, section_id)
);
""";
command.ExecuteNonQuery();
if (!IsMigrationApplied(connection))
{
ApplyInitialMigration(connection);
}
}
}
private bool IsMigrationApplied(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = """
SELECT value
FROM settings_meta
WHERE key = $key
LIMIT 1;
""";
command.Parameters.AddWithValue("$key", MigrationMarkerKey);
var raw = command.ExecuteScalar() as string;
return string.Equals(raw, "applied", StringComparison.OrdinalIgnoreCase);
}
private void ApplyInitialMigration(SqliteConnection connection)
{
AppLogger.Info("ComponentDomainStorage", "Starting one-shot migration from legacy JSON files to SQLite.");
using var transaction = connection.BeginTransaction();
try
{
if (TryLoadLegacyLayout(out var layout))
{
PersistLayout(connection, transaction, layout);
}
if (TryLoadLegacyComponentDocument(out var document))
{
PersistComponentDocument(connection, transaction, document);
}
using var markerCommand = connection.CreateCommand();
markerCommand.Transaction = transaction;
markerCommand.CommandText = """
INSERT INTO settings_meta(key, value)
VALUES($key, 'applied')
ON CONFLICT(key) DO UPDATE SET value = 'applied';
""";
markerCommand.Parameters.AddWithValue("$key", MigrationMarkerKey);
markerCommand.ExecuteNonQuery();
transaction.Commit();
BackupLegacyFile(_layoutJsonPath);
BackupLegacyFile(_componentJsonPath);
AppLogger.Info("ComponentDomainStorage", "Legacy JSON migration completed.");
}
catch (Exception ex)
{
transaction.Rollback();
AppLogger.Error("ComponentDomainStorage", "Legacy JSON migration failed. SQLite writes are blocked for this session.", ex);
throw;
}
}
private void PersistLayout(
SqliteConnection connection,
SqliteTransaction transaction,
DesktopLayoutSettingsSnapshot snapshot)
{
using (var command = connection.CreateCommand())
{
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_layout(id, desktop_page_count, current_desktop_surface_index, updated_utc)
VALUES(1, $count, $index, $updated)
ON CONFLICT(id) DO UPDATE SET
desktop_page_count = excluded.desktop_page_count,
current_desktop_surface_index = excluded.current_desktop_surface_index,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$count", Math.Max(1, snapshot.DesktopPageCount));
command.Parameters.AddWithValue("$index", Math.Max(0, snapshot.CurrentDesktopSurfaceIndex));
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
if (snapshot.DesktopComponentPlacements is not { Count: > 0 })
{
return;
}
foreach (var placement in snapshot.DesktopComponentPlacements)
{
if (placement is null || string.IsNullOrWhiteSpace(placement.PlacementId))
{
continue;
}
using var placementCommand = connection.CreateCommand();
placementCommand.Transaction = transaction;
placementCommand.CommandText = """
INSERT INTO component_placement(
placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells, updated_utc)
VALUES($placementId, $page, $componentId, $row, $column, $width, $height, $updated)
ON CONFLICT(placement_id) DO UPDATE SET
page_index = excluded.page_index,
component_id = excluded.component_id,
row_index = excluded.row_index,
column_index = excluded.column_index,
width_cells = excluded.width_cells,
height_cells = excluded.height_cells,
updated_utc = excluded.updated_utc;
""";
placementCommand.Parameters.AddWithValue("$placementId", placement.PlacementId.Trim());
placementCommand.Parameters.AddWithValue("$page", Math.Max(0, placement.PageIndex));
placementCommand.Parameters.AddWithValue("$componentId", placement.ComponentId?.Trim() ?? string.Empty);
placementCommand.Parameters.AddWithValue("$row", Math.Max(0, placement.Row));
placementCommand.Parameters.AddWithValue("$column", Math.Max(0, placement.Column));
placementCommand.Parameters.AddWithValue("$width", Math.Max(1, placement.WidthCells));
placementCommand.Parameters.AddWithValue("$height", Math.Max(1, placement.HeightCells));
placementCommand.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
placementCommand.ExecuteNonQuery();
}
}
private void PersistComponentDocument(
SqliteConnection connection,
SqliteTransaction transaction,
LegacyComponentDocument document)
{
PersistComponentState(connection, transaction, DefaultInstanceKey, "__default__", string.Empty, document.DefaultSettings ?? new ComponentSettingsSnapshot());
if (document.InstanceSettings is not null)
{
foreach (var pair in document.InstanceSettings)
{
if (!TrySplitInstanceKey(pair.Key, out var componentId, out var placementId))
{
continue;
}
PersistComponentState(connection, transaction, pair.Key.Trim(), componentId, placementId, pair.Value ?? new ComponentSettingsSnapshot());
}
}
if (document.PluginSettings is null)
{
return;
}
foreach (var pair in document.PluginSettings)
{
if (!TrySplitInstanceKey(pair.Key, out var componentId, out var placementId))
{
continue;
}
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_message(instance_key, component_id, placement_id, section_id, message_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $sectionId, $json, $updated)
ON CONFLICT(instance_key, section_id) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
message_json = excluded.message_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", pair.Key.Trim());
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.Parameters.AddWithValue("$sectionId", LegacySectionId);
command.Parameters.AddWithValue("$json", pair.Value.GetRawText());
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
}
private static void PersistComponentState(
SqliteConnection connection,
SqliteTransaction transaction,
string instanceKey,
string componentId,
string placementId,
ComponentSettingsSnapshot snapshot)
{
var json = JsonSerializer.Serialize(snapshot ?? new ComponentSettingsSnapshot(), SerializerOptions);
using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO component_state(instance_key, component_id, placement_id, state_json, updated_utc)
VALUES($instanceKey, $componentId, $placementId, $stateJson, $updated)
ON CONFLICT(instance_key) DO UPDATE SET
component_id = excluded.component_id,
placement_id = excluded.placement_id,
state_json = excluded.state_json,
updated_utc = excluded.updated_utc;
""";
command.Parameters.AddWithValue("$instanceKey", instanceKey);
command.Parameters.AddWithValue("$componentId", componentId);
command.Parameters.AddWithValue("$placementId", placementId);
command.Parameters.AddWithValue("$stateJson", json);
command.Parameters.AddWithValue("$updated", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture));
command.ExecuteNonQuery();
}
private bool TryLoadLegacyLayout(out DesktopLayoutSettingsSnapshot snapshot)
{
snapshot = new DesktopLayoutSettingsSnapshot();
if (!File.Exists(_layoutJsonPath))
{
return false;
}
try
{
var json = File.ReadAllText(_layoutJsonPath);
snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions) ?? new DesktopLayoutSettingsSnapshot();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to read legacy layout file '{_layoutJsonPath}'.", ex);
return false;
}
}
private bool TryLoadLegacyComponentDocument(out LegacyComponentDocument document)
{
document = new LegacyComponentDocument();
if (!File.Exists(_componentJsonPath))
{
return false;
}
try
{
var json = File.ReadAllText(_componentJsonPath);
using var parsed = JsonDocument.Parse(json);
if (parsed.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
var hasDocumentShape = false;
foreach (var property in parsed.RootElement.EnumerateObject())
{
if (string.Equals(property.Name, "defaultSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "instanceSettings", StringComparison.OrdinalIgnoreCase) ||
string.Equals(property.Name, "pluginSettings", StringComparison.OrdinalIgnoreCase))
{
hasDocumentShape = true;
break;
}
}
if (hasDocumentShape)
{
document = JsonSerializer.Deserialize<LegacyComponentDocument>(json, SerializerOptions) ?? new LegacyComponentDocument();
document.DefaultSettings ??= new ComponentSettingsSnapshot();
document.InstanceSettings ??= new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
document.PluginSettings ??= new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
return true;
}
var legacySingle = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions) ?? new ComponentSettingsSnapshot();
document = new LegacyComponentDocument
{
DefaultSettings = legacySingle
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to read legacy component settings file '{_componentJsonPath}'.", ex);
return false;
}
}
private static void BackupLegacyFile(string path)
{
if (!File.Exists(path))
{
return;
}
try
{
var backupPath = $"{path}.migrated.bak";
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
File.Move(path, backupPath);
}
catch (Exception ex)
{
AppLogger.Warn("ComponentDomainStorage", $"Failed to backup migrated legacy file '{path}'.", ex);
}
}
private static bool TrySplitInstanceKey(string key, out string componentId, out string placementId)
{
componentId = string.Empty;
placementId = string.Empty;
if (string.IsNullOrWhiteSpace(key))
{
return false;
}
var normalized = key.Trim();
var parts = normalized.Split("::", 2, StringSplitOptions.TrimEntries);
if (parts.Length != 2 ||
string.IsNullOrWhiteSpace(parts[0]) ||
string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
componentId = parts[0];
placementId = parts[1];
return true;
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = NormalizeKey(componentId);
var normalizedPlacementId = NormalizePlacement(placementId);
if (string.IsNullOrWhiteSpace(normalizedComponentId) ||
string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return DefaultInstanceKey;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeKey(string? key)
{
return key?.Trim() ?? string.Empty;
}
private static string NormalizePlacement(string? placementId)
{
return placementId?.Trim() ?? string.Empty;
}
private static string NormalizeSection(string? sectionId)
{
return string.IsNullOrWhiteSpace(sectionId) ? LegacySectionId : sectionId.Trim();
}
private static ComponentSettingsSnapshot DeserializeState(string json)
{
try
{
return JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions) ?? new ComponentSettingsSnapshot();
}
catch
{
return new ComponentSettingsSnapshot();
}
}
private static ComponentSettingsSnapshot LoadDefaultState(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = """
SELECT state_json
FROM component_state
WHERE instance_key = $instanceKey
LIMIT 1;
""";
command.Parameters.AddWithValue("$instanceKey", DefaultInstanceKey);
var json = command.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(json) ? new ComponentSettingsSnapshot() : DeserializeState(json);
}
private static List<DesktopComponentPlacementSnapshot> LoadPlacements(SqliteConnection connection)
{
var placements = new List<DesktopComponentPlacementSnapshot>();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT placement_id, page_index, component_id, row_index, column_index, width_cells, height_cells
FROM component_placement
ORDER BY page_index, row_index, column_index;
""";
using var reader = command.ExecuteReader();
while (reader.Read())
{
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
PageIndex = reader.IsDBNull(1) ? 0 : Math.Max(0, reader.GetInt32(1)),
ComponentId = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
Row = reader.IsDBNull(3) ? 0 : Math.Max(0, reader.GetInt32(3)),
Column = reader.IsDBNull(4) ? 0 : Math.Max(0, reader.GetInt32(4)),
WidthCells = reader.IsDBNull(5) ? 1 : Math.Max(1, reader.GetInt32(5)),
HeightCells = reader.IsDBNull(6) ? 1 : Math.Max(1, reader.GetInt32(6))
});
}
return placements;
}
private SqliteConnection OpenConnection()
{
var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadWriteCreate;Cache=Shared");
connection.Open();
return connection;
}
private sealed class LegacyComponentDocument
{
public ComponentSettingsSnapshot? DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot>? InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement>? PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,28 @@
using System;
namespace LanMountainDesktop.Services.Settings;
internal static class HostSettingsFacadeProvider
{
private static readonly object Gate = new();
private static SettingsFacadeService? _instance;
public static ISettingsFacadeService GetOrCreate()
{
lock (Gate)
{
_instance ??= new SettingsFacadeService();
return _instance;
}
}
public static void BindPluginRuntime(PluginRuntimeService pluginRuntimeService)
{
ArgumentNullException.ThrowIfNull(pluginRuntimeService);
lock (Gate)
{
_instance ??= new SettingsFacadeService(pluginRuntimeService);
_instance.BindPluginRuntime(pluginRuntimeService);
}
}
}

View File

@@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.Settings;
internal sealed class SettingsCatalogService : ISettingsCatalog
{
private readonly List<SettingsSectionDefinition> _sections = [];
private readonly object _gate = new();
public SettingsCatalogService()
{
// Built-in host sections for the next settings UI.
_sections.AddRange(
[
new SettingsSectionDefinition("general", SettingsCategories.General, SettingsScope.App, "settings.general.title", iconKey: "Settings", sortOrder: 0),
new SettingsSectionDefinition("appearance", SettingsCategories.Appearance, SettingsScope.App, "settings.appearance.title", iconKey: "DesignIdeas", sortOrder: 10),
new SettingsSectionDefinition("components", SettingsCategories.Components, SettingsScope.ComponentInstance, "settings.components.title", iconKey: "GridDots", sortOrder: 20),
new SettingsSectionDefinition("plugins", SettingsCategories.Plugins, SettingsScope.Plugin, "settings.plugins.title", iconKey: "PuzzlePiece", sortOrder: 30),
new SettingsSectionDefinition("about", SettingsCategories.About, SettingsScope.App, "settings.about.title", iconKey: "Info", sortOrder: 40)
]);
}
public IReadOnlyList<SettingsSectionDefinition> GetSections()
{
lock (_gate)
{
return _sections
.OrderBy(section => section.SortOrder)
.ThenBy(section => section.Id, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope)
{
lock (_gate)
{
return _sections
.Where(section => section.Scope == scope)
.OrderBy(section => section.SortOrder)
.ThenBy(section => section.Id, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
public void RegisterPluginSections(string pluginId, IReadOnlyList<PluginSettingsSectionRegistration> sections)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginId);
var normalizedPluginId = pluginId.Trim();
lock (_gate)
{
_sections.RemoveAll(section =>
section.Scope == SettingsScope.Plugin &&
string.Equals(section.SubjectId, normalizedPluginId, StringComparison.OrdinalIgnoreCase));
foreach (var registration in sections)
{
var definition = new SettingsSectionDefinition(
id: $"{normalizedPluginId}:{registration.Id}",
category: SettingsCategories.External,
scope: SettingsScope.Plugin,
titleLocalizationKey: registration.TitleLocalizationKey,
descriptionLocalizationKey: registration.DescriptionLocalizationKey,
iconKey: registration.IconKey,
sortOrder: registration.SortOrder,
subjectId: normalizedPluginId,
options: registration.Options);
_sections.Add(definition);
}
}
}
public void RemovePluginSections(string pluginId)
{
if (string.IsNullOrWhiteSpace(pluginId))
{
return;
}
lock (_gate)
{
_sections.RemoveAll(section =>
section.Scope == SettingsScope.Plugin &&
string.Equals(section.SubjectId, pluginId, StringComparison.OrdinalIgnoreCase));
}
}
}

View File

@@ -0,0 +1,250 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Services.Settings;
public enum WallpaperMediaType
{
None,
Image,
Video
}
public sealed record GridSettingsState(int ShortSideCells, string SpacingPreset, int EdgeInsetPercent);
public sealed record WallpaperSettingsState(string? WallpaperPath, string Type, string? Color, string Placement);
public sealed record ThemeAppearanceSettingsState(
bool IsNightMode,
string? ThemeColor,
bool UseSystemChrome,
string ThemeColorMode = ThemeAppearanceValues.ColorModeDefaultNeutral,
string SystemMaterialMode = ThemeAppearanceValues.MaterialNone,
string? SelectedWallpaperSeed = null);
public sealed record StatusBarSettingsState(
IReadOnlyList<string> TopStatusComponentIds,
IReadOnlyList<string> PinnedTaskbarActions,
bool EnableDynamicTaskbarActions,
string TaskbarLayoutMode,
string ClockDisplayFormat,
string SpacingMode,
int CustomSpacingPercent);
public sealed record WeatherSettingsState(
string LocationMode,
string LocationKey,
string LocationName,
double Latitude,
double Longitude,
bool AutoRefreshLocation,
string ExcludedAlerts,
string IconPackId,
bool NoTlsRequests,
string LocationQuery);
public sealed record RegionSettingsState(string LanguageCode, string? TimeZoneId);
public sealed record PrivacySettingsState(
bool UploadAnonymousCrashData,
bool UploadAnonymousUsageData);
public sealed record UpdateSettingsState(
bool AutoCheckUpdates,
bool IncludePrereleaseUpdates,
string UpdateChannel,
string UpdateMode,
string UpdateDownloadSource,
int UpdateDownloadThreads,
string? PendingUpdateInstallerPath,
string? PendingUpdateVersion,
long? PendingUpdatePublishedAtUtcMs,
long? LastUpdateCheckUtcMs);
public sealed record PluginManagementSettingsState(IReadOnlyList<string> DisabledPluginIds);
public sealed record PluginMarketDependencyInfo(
string Id,
string Version,
string AssemblyName);
public sealed record PluginMarketPluginInfo(
string Id,
string Name,
string Description,
string Author,
string Version,
string ApiVersion,
string MinHostVersion,
string DownloadUrl,
string ReleaseTag,
string ReleaseAssetName,
string IconUrl,
string ReadmeUrl,
string HomepageUrl,
string RepositoryUrl,
IReadOnlyList<string> Tags,
IReadOnlyList<PluginMarketDependencyInfo> Dependencies,
DateTimeOffset PublishedAt,
DateTimeOffset UpdatedAt);
public sealed record PluginMarketIndexResult(
bool Success,
IReadOnlyList<PluginMarketPluginInfo> Plugins,
string? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
public sealed record PluginMarketInstallResult(
bool Success,
string? PluginId,
string? PluginName,
string? ErrorMessage);
public interface IGridSettingsService
{
GridSettingsState Get();
void Save(GridSettingsState state);
string NormalizeSpacingPreset(string? value);
double ResolveGapRatio(string? preset);
double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent);
DesktopGridMetrics CalculateGridMetrics(
double hostWidth,
double hostHeight,
int shortSideCells,
double gapRatio,
double edgeInsetPx);
}
public interface IWallpaperSettingsService
{
WallpaperSettingsState Get();
void Save(WallpaperSettingsState state);
}
public interface IWallpaperMediaService
{
WallpaperMediaType DetectMediaType(string? path);
Task<string?> ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default);
}
public interface IThemeAppearanceService
{
ThemeAppearanceSettingsState Get();
void Save(ThemeAppearanceSettingsState state);
MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null);
}
public interface IStatusBarSettingsService
{
StatusBarSettingsState Get();
void Save(StatusBarSettingsState state);
}
public interface IWeatherProvider
{
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherSnapshot>> GetWeatherAsync(
WeatherQuery query,
CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
}
public interface IWeatherSettingsService
{
WeatherSettingsState Get();
void Save(WeatherSettingsState state);
Task<WeatherQueryResult<IReadOnlyList<WeatherLocation>>> SearchLocationsAsync(
string keyword,
string? locale = null,
CancellationToken cancellationToken = default);
Task<WeatherQueryResult<WeatherLocation>> ResolveLocationAsync(
double latitude,
double longitude,
string? locale = null,
CancellationToken cancellationToken = default);
IWeatherInfoService GetWeatherInfoService();
}
public interface IRegionSettingsService
{
RegionSettingsState Get();
void Save(RegionSettingsState state);
TimeZoneService GetTimeZoneService();
}
public interface IPrivacySettingsService
{
PrivacySettingsState Get();
void Save(PrivacySettingsState state);
}
public interface IUpdateSettingsService
{
UpdateSettingsState Get();
void Save(UpdateSettingsState state);
Task<UpdateCheckResult> CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default);
Task<UpdateDownloadResult> DownloadAssetAsync(
GitHubReleaseAsset asset,
string destinationFilePath,
string downloadSource,
int maxParallelSegments,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
}
public interface ILauncherCatalogService
{
StartMenuFolderNode LoadCatalog();
}
public interface ILauncherPolicyService
{
LauncherSettingsSnapshot Get();
void Save(LauncherSettingsSnapshot snapshot);
}
public interface IPluginManagementSettingsService
{
PluginManagementSettingsState Get();
void Save(PluginManagementSettingsState state);
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
bool SetPluginEnabled(string pluginId, bool isEnabled);
bool DeleteInstalledPlugin(string pluginId);
}
public interface IPluginMarketSettingsService
{
Task<PluginMarketIndexResult> LoadIndexAsync(CancellationToken cancellationToken = default);
Task<PluginMarketInstallResult> InstallAsync(string pluginId, CancellationToken cancellationToken = default);
}
public interface IApplicationInfoService
{
string GetAppVersionText();
string GetAppCodenameText();
AppRenderBackendInfo GetRenderBackendInfo();
}
public interface ISettingsFacadeService
{
ISettingsService Settings { get; }
ISettingsCatalog Catalog { get; }
IGridSettingsService Grid { get; }
IWallpaperSettingsService Wallpaper { get; }
IWallpaperMediaService WallpaperMedia { get; }
IThemeAppearanceService Theme { get; }
IStatusBarSettingsService StatusBar { get; }
IWeatherSettingsService Weather { get; }
IRegionSettingsService Region { get; }
IPrivacySettingsService Privacy { get; }
IUpdateSettingsService Update { get; }
ILauncherCatalogService LauncherCatalog { get; }
ILauncherPolicyService LauncherPolicy { get; }
IPluginManagementSettingsService PluginManagement { get; }
IPluginMarketSettingsService PluginMarket { get; }
IApplicationInfoService ApplicationInfo { get; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,341 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Avalonia.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.Services.PluginMarket;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views.SettingsPages;
using Microsoft.Extensions.DependencyInjection;
namespace LanMountainDesktop.Services.Settings;
public sealed class SettingsPageDescriptor
{
private readonly Func<ISettingsPageHostContext, Control> _factory;
public SettingsPageDescriptor(
string pageId,
string title,
string? description,
string iconKey,
string? selectedIconKey,
SettingsPageCategory category,
int sortOrder,
string? pluginId,
bool isBuiltIn,
bool hideDefault,
bool hidePageTitle,
bool useFullWidth,
string? groupId,
Func<ISettingsPageHostContext, Control> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pageId);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentNullException.ThrowIfNull(factory);
PageId = pageId.Trim();
Title = title.Trim();
Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
IconKey = iconKey.Trim();
SelectedIconKey = string.IsNullOrWhiteSpace(selectedIconKey) ? IconKey : selectedIconKey.Trim();
Category = category;
SortOrder = sortOrder;
PluginId = string.IsNullOrWhiteSpace(pluginId) ? null : pluginId.Trim();
IsBuiltIn = isBuiltIn;
HideDefault = hideDefault;
HidePageTitle = hidePageTitle;
UseFullWidth = useFullWidth;
GroupId = string.IsNullOrWhiteSpace(groupId) ? null : groupId.Trim();
_factory = factory;
}
public string PageId { get; }
public string Title { get; }
public string? Description { get; }
public string IconKey { get; }
public string SelectedIconKey { get; }
public SettingsPageCategory Category { get; }
public int SortOrder { get; }
public string? PluginId { get; }
public bool IsBuiltIn { get; }
public bool HideDefault { get; }
public bool HidePageTitle { get; }
public bool UseFullWidth { get; }
public string? GroupId { get; }
public Control CreatePage(ISettingsPageHostContext hostContext) => _factory(hostContext);
}
public interface ISettingsPageRegistry
{
void Rebuild();
IReadOnlyList<SettingsPageDescriptor> GetPages();
bool TryGetPage(string pageId, out SettingsPageDescriptor? descriptor);
}
internal sealed class SettingsPageRegistry : ISettingsPageRegistry, IDisposable
{
private readonly ISettingsFacadeService _settingsFacade;
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
private readonly LocalizationService _localizationService;
private readonly Func<PluginRuntimeService?> _pluginRuntimeAccessor;
private readonly object _gate = new();
private readonly List<SettingsPageDescriptor> _pages = [];
private ServiceProvider? _hostServices;
public SettingsPageRegistry(
ISettingsFacadeService settingsFacade,
IHostApplicationLifecycle hostApplicationLifecycle,
LocalizationService localizationService,
Func<PluginRuntimeService?> pluginRuntimeAccessor)
{
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
_hostApplicationLifecycle = hostApplicationLifecycle ?? throw new ArgumentNullException(nameof(hostApplicationLifecycle));
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
_pluginRuntimeAccessor = pluginRuntimeAccessor ?? throw new ArgumentNullException(nameof(pluginRuntimeAccessor));
}
public void Rebuild()
{
lock (_gate)
{
_pages.Clear();
RebuildHostServices();
RegisterAssemblyPages(
typeof(App).Assembly,
_hostServices!,
pluginId: null,
isBuiltIn: true);
var pluginRuntime = _pluginRuntimeAccessor();
if (pluginRuntime is null)
{
SortPages();
return;
}
foreach (var loadedPlugin in pluginRuntime.LoadedPlugins)
{
RegisterPluginPages(loadedPlugin);
RegisterLegacyPluginSections(loadedPlugin);
}
SortPages();
}
}
public IReadOnlyList<SettingsPageDescriptor> GetPages()
{
lock (_gate)
{
return _pages.ToArray();
}
}
public bool TryGetPage(string pageId, out SettingsPageDescriptor? descriptor)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pageId);
lock (_gate)
{
descriptor = _pages.FirstOrDefault(item =>
string.Equals(item.PageId, pageId, StringComparison.OrdinalIgnoreCase));
return descriptor is not null;
}
}
public void Dispose()
{
_hostServices?.Dispose();
}
private void RebuildHostServices()
{
_hostServices?.Dispose();
var services = new ServiceCollection();
services.AddSingleton(_settingsFacade);
services.AddSingleton(_settingsFacade.Settings);
services.AddSingleton(_settingsFacade.Catalog);
services.AddSingleton<IAppearanceThemeService>(_ => HostAppearanceThemeProvider.GetOrCreate());
services.AddSingleton(_hostApplicationLifecycle);
services.AddSingleton(_localizationService);
services.AddSingleton<ILocationService>(_ => HostLocationServiceProvider.GetOrCreate());
services.AddSingleton<WeatherLocationRefreshService>();
services.AddSingleton<AirAppMarketIconService>();
services.AddSingleton<AirAppMarketReadmeService>();
var pluginRuntime = _pluginRuntimeAccessor();
if (pluginRuntime is not null)
{
services.AddSingleton(pluginRuntime);
}
_hostServices = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = false,
ValidateOnBuild = false
});
}
private void RegisterAssemblyPages(
Assembly assembly,
IServiceProvider services,
string? pluginId,
bool isBuiltIn)
{
foreach (var pageType in assembly.GetTypes()
.Where(type => !type.IsAbstract && typeof(SettingsPageBase).IsAssignableFrom(type)))
{
var pageInfo = pageType.GetCustomAttribute<SettingsPageInfoAttribute>();
if (pageInfo is null)
{
continue;
}
var category = isBuiltIn ? pageInfo.Category : SettingsPageCategory.Plugins;
var sortOrder = isBuiltIn ? pageInfo.SortOrder : 100 + pageInfo.SortOrder;
var title = ResolveLocalizedText(pageInfo.TitleLocalizationKey, pageInfo.Name);
var description = ResolveLocalizedText(pageInfo.DescriptionLocalizationKey, null);
_pages.Add(new SettingsPageDescriptor(
pageInfo.Id,
title,
description,
pageInfo.IconKey,
pageInfo.SelectedIconKey,
category,
sortOrder,
pluginId,
isBuiltIn,
pageInfo.HideDefault,
pageInfo.HidePageTitle,
pageInfo.UseFullWidth,
pageInfo.GroupId,
hostContext => CreatePage(services, pageType, hostContext)));
}
}
private void RegisterPluginPages(LoadedPlugin loadedPlugin)
{
RegisterAssemblyPages(
loadedPlugin.Assembly,
loadedPlugin.Services,
loadedPlugin.Manifest.Id,
isBuiltIn: false);
}
private void RegisterLegacyPluginSections(LoadedPlugin loadedPlugin)
{
var localizer = PluginLocalizer.Create(loadedPlugin.RuntimeContext);
foreach (var section in loadedPlugin.SettingsSections)
{
var pageId = $"plugin:{loadedPlugin.Manifest.Id}:{section.Id}";
var title = localizer.GetString(section.TitleLocalizationKey, section.TitleLocalizationKey);
var description = string.IsNullOrWhiteSpace(section.DescriptionLocalizationKey)
? null
: localizer.GetString(section.DescriptionLocalizationKey, section.DescriptionLocalizationKey);
_pages.Add(new SettingsPageDescriptor(
pageId,
title,
description,
section.IconKey,
section.IconKey,
SettingsPageCategory.Plugins,
200 + section.SortOrder,
loadedPlugin.Manifest.Id,
isBuiltIn: false,
hideDefault: false,
hidePageTitle: false,
useFullWidth: false,
groupId: null,
hostContext =>
{
var page = new GeneratedPluginSettingsPage(
new PluginGeneratedSettingsPageViewModel(
_settingsFacade.Settings,
loadedPlugin.Manifest.Id,
section,
localizer));
page.InitializeHostContext(hostContext);
return page;
}));
}
}
private void SortPages()
{
_pages.Sort(static (left, right) =>
{
var categoryCompare = left.Category.CompareTo(right.Category);
if (categoryCompare != 0)
{
return categoryCompare;
}
var sortOrderCompare = left.SortOrder.CompareTo(right.SortOrder);
if (sortOrderCompare != 0)
{
return sortOrderCompare;
}
var pluginCompare = string.Compare(left.PluginId, right.PluginId, StringComparison.OrdinalIgnoreCase);
if (pluginCompare != 0)
{
return pluginCompare;
}
return string.Compare(left.PageId, right.PageId, StringComparison.OrdinalIgnoreCase);
});
}
private string ResolveLocalizedText(string? localizationKey, string? fallback)
{
if (string.IsNullOrWhiteSpace(localizationKey))
{
return fallback ?? string.Empty;
}
var languageCode = _settingsFacade.Region.Get().LanguageCode;
var normalizedLanguageCode = _localizationService.NormalizeLanguageCode(languageCode);
return _localizationService.GetString(
normalizedLanguageCode,
localizationKey,
string.IsNullOrWhiteSpace(fallback) ? localizationKey : fallback);
}
private static Control CreatePage(
IServiceProvider services,
Type pageType,
ISettingsPageHostContext hostContext)
{
var page = (Control)ActivatorUtilities.CreateInstance(services, pageType);
if (page is SettingsPageBase settingsPage)
{
settingsPage.InitializeHostContext(hostContext);
}
return page;
}
}

View File

@@ -0,0 +1,429 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services.Settings;
internal sealed class SettingsService : ISettingsService
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
private readonly AppSettingsService _appSettingsService = new();
private readonly LauncherSettingsService _launcherSettingsService = new();
private readonly IComponentStateStore _componentStateStore = ComponentDomainStorageProvider.Instance;
private readonly IComponentMessageStore _componentMessageStore = ComponentDomainStorageProvider.Instance;
private readonly string _pluginSettingsPath;
private readonly object _pluginSettingsGate = new();
public SettingsService()
{
var root = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop");
_pluginSettingsPath = Path.Combine(root, "plugin-settings.json");
}
public event EventHandler<SettingsChangedEvent>? Changed;
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
{
return scope switch
{
SettingsScope.App => ConvertSnapshot<AppSettingsSnapshot, T>(_appSettingsService.Load()),
SettingsScope.Launcher => ConvertSnapshot<LauncherSettingsSnapshot, T>(_launcherSettingsService.Load()),
SettingsScope.ComponentInstance => LoadComponentSnapshot<T>(subjectId, placementId),
SettingsScope.Plugin => LoadSection<T>(scope, EnsureKey(subjectId), sectionId: "__snapshot__", placementId),
_ => new T()
};
}
public void SaveSnapshot<T>(
SettingsScope scope,
T snapshot,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
switch (scope)
{
case SettingsScope.App:
_appSettingsService.Save(ConvertSnapshot<T, AppSettingsSnapshot>(snapshot));
break;
case SettingsScope.Launcher:
_launcherSettingsService.Save(ConvertSnapshot<T, LauncherSettingsSnapshot>(snapshot));
break;
case SettingsScope.ComponentInstance:
SaveComponentSnapshot(subjectId, placementId, snapshot);
break;
case SettingsScope.Plugin:
SaveSection(scope, EnsureKey(subjectId), "__snapshot__", snapshot, placementId, changedKeys);
break;
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
}
public T LoadSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
string? placementId = null) where T : new()
{
if (scope == SettingsScope.ComponentInstance)
{
return _componentMessageStore.LoadSection<T>(EnsureKey(subjectId), placementId, EnsureKey(sectionId));
}
if (scope != SettingsScope.Plugin)
{
return new T();
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
if (!document.Sections.TryGetValue(EnsureKey(subjectId), out var pluginSections) ||
!pluginSections.TryGetValue(EnsureKey(sectionId), out var payload))
{
return new T();
}
return JsonSerializer.Deserialize<T>(payload.GetRawText(), SerializerOptions) ?? new T();
}
}
public void SaveSection<T>(
SettingsScope scope,
string subjectId,
string sectionId,
T section,
string? placementId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
if (scope == SettingsScope.ComponentInstance)
{
_componentMessageStore.SaveSection(EnsureKey(subjectId), placementId, EnsureKey(sectionId), section);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
return;
}
if (scope != SettingsScope.Plugin)
{
return;
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
var pluginId = EnsureKey(subjectId);
if (!document.Sections.TryGetValue(pluginId, out var pluginSections))
{
pluginSections = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
document.Sections[pluginId] = pluginSections;
}
pluginSections[EnsureKey(sectionId)] = JsonSerializer.SerializeToElement(section, SerializerOptions).Clone();
PersistPluginDocumentLocked(document);
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys));
}
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
{
if (scope == SettingsScope.ComponentInstance)
{
_componentMessageStore.DeleteSection(EnsureKey(subjectId), placementId, EnsureKey(sectionId));
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId));
return;
}
if (scope != SettingsScope.Plugin)
{
return;
}
lock (_pluginSettingsGate)
{
var document = LoadPluginDocumentLocked();
var pluginId = EnsureKey(subjectId);
if (document.Sections.TryGetValue(pluginId, out var sections) &&
sections.Remove(EnsureKey(sectionId)))
{
if (sections.Count == 0)
{
document.Sections.Remove(pluginId);
}
PersistPluginDocumentLocked(document);
}
}
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId));
}
public T? GetValue<T>(
SettingsScope scope,
string key,
string? subjectId = null,
string? placementId = null,
string? sectionId = null)
{
var snapshot = scope switch
{
SettingsScope.App => JsonSerializer.SerializeToElement(_appSettingsService.Load(), SerializerOptions),
SettingsScope.Launcher => JsonSerializer.SerializeToElement(_launcherSettingsService.Load(), SerializerOptions),
SettingsScope.ComponentInstance => JsonSerializer.SerializeToElement(
LoadSection<Dictionary<string, JsonElement>>(
SettingsScope.ComponentInstance,
EnsureKey(subjectId),
sectionId ?? "__root__",
placementId),
SerializerOptions),
SettingsScope.Plugin => JsonSerializer.SerializeToElement(
LoadSection<Dictionary<string, JsonElement>>(SettingsScope.Plugin, EnsureKey(subjectId), sectionId ?? "__root__", placementId),
SerializerOptions),
_ => default
};
if (snapshot.ValueKind != JsonValueKind.Object)
{
return default;
}
foreach (var property in snapshot.EnumerateObject())
{
if (!string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
return property.Value.Deserialize<T>(SerializerOptions);
}
catch
{
return default;
}
}
return default;
}
public void SetValue<T>(
SettingsScope scope,
string key,
T value,
string? subjectId = null,
string? placementId = null,
string? sectionId = null,
IReadOnlyCollection<string>? changedKeys = null)
{
if (scope == SettingsScope.Plugin)
{
var dict = LoadSection<Dictionary<string, JsonElement>>(
SettingsScope.Plugin,
EnsureKey(subjectId),
sectionId ?? "__root__",
placementId);
dict[key] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
SaveSection(SettingsScope.Plugin, EnsureKey(subjectId), sectionId ?? "__root__", dict, placementId, changedKeys ?? [key]);
return;
}
if (scope == SettingsScope.ComponentInstance)
{
var effectiveSection = sectionId ?? "__root__";
var dict = _componentMessageStore.LoadSection<Dictionary<string, JsonElement>>(EnsureKey(subjectId), placementId, effectiveSection);
dict[key] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
_componentMessageStore.SaveSection(EnsureKey(subjectId), placementId, effectiveSection, dict);
OnChanged(new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys ?? [key]));
return;
}
if (scope == SettingsScope.App)
{
var snapshot = _appSettingsService.Load();
var updated = UpdateObjectKey(snapshot, key, value);
_appSettingsService.Save(updated);
OnChanged(new SettingsChangedEvent(scope, null, null, sectionId, changedKeys ?? [key]));
return;
}
if (scope == SettingsScope.Launcher)
{
var snapshot = _launcherSettingsService.Load();
var updated = UpdateObjectKey(snapshot, key, value);
_launcherSettingsService.Save(updated);
OnChanged(new SettingsChangedEvent(scope, null, null, sectionId, changedKeys ?? [key]));
}
}
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
{
return new ComponentSettingsAccessor(this, componentId, placementId);
}
private T LoadComponentSnapshot<T>(string? componentId, string? placementId) where T : new()
{
var snapshot = _componentStateStore.LoadState(EnsureKey(componentId), placementId);
return ConvertSnapshot<ComponentSettingsSnapshot, T>(snapshot);
}
private void SaveComponentSnapshot<T>(string? componentId, string? placementId, T snapshot)
{
var converted = ConvertSnapshot<T, ComponentSettingsSnapshot>(snapshot);
_componentStateStore.SaveState(EnsureKey(componentId), placementId, converted);
}
private static TOut ConvertSnapshot<TIn, TOut>(TIn source) where TOut : new()
{
if (source is null)
{
return new TOut();
}
if (source is TOut direct)
{
return direct;
}
try
{
var json = JsonSerializer.Serialize(source, SerializerOptions);
return JsonSerializer.Deserialize<TOut>(json, SerializerOptions) ?? new TOut();
}
catch
{
return new TOut();
}
}
private static TSnapshot UpdateObjectKey<TSnapshot, TValue>(TSnapshot snapshot, string key, TValue value)
where TSnapshot : new()
{
var bag = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
JsonSerializer.Serialize(snapshot, SerializerOptions),
SerializerOptions) ?? new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
var actualKey = bag.Keys.FirstOrDefault(existing => string.Equals(existing, key, StringComparison.OrdinalIgnoreCase)) ?? key;
bag[actualKey] = JsonSerializer.SerializeToElement(value, SerializerOptions).Clone();
try
{
var json = JsonSerializer.Serialize(bag, SerializerOptions);
return JsonSerializer.Deserialize<TSnapshot>(json, SerializerOptions) ?? new TSnapshot();
}
catch
{
return snapshot is null ? new TSnapshot() : snapshot;
}
}
private PluginSettingsDocument LoadPluginDocumentLocked()
{
try
{
if (!File.Exists(_pluginSettingsPath))
{
return new PluginSettingsDocument();
}
var json = File.ReadAllText(_pluginSettingsPath);
return JsonSerializer.Deserialize<PluginSettingsDocument>(json, SerializerOptions) ?? new PluginSettingsDocument();
}
catch (Exception ex)
{
AppLogger.Warn("SettingsService", $"Failed to load plugin settings '{_pluginSettingsPath}'.", ex);
return new PluginSettingsDocument();
}
}
private void PersistPluginDocumentLocked(PluginSettingsDocument document)
{
try
{
var directory = Path.GetDirectoryName(_pluginSettingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_pluginSettingsPath, JsonSerializer.Serialize(document, SerializerOptions));
}
catch (Exception ex)
{
AppLogger.Warn("SettingsService", $"Failed to persist plugin settings '{_pluginSettingsPath}'.", ex);
}
}
private static string EnsureKey(string? value)
{
return string.IsNullOrWhiteSpace(value) ? "__default__" : value.Trim();
}
private void OnChanged(SettingsChangedEvent e)
{
try
{
Changed?.Invoke(this, e);
}
catch
{
// Never let a subscriber break settings persistence.
}
}
private sealed class ComponentSettingsAccessor : IComponentSettingsAccessor
{
private readonly SettingsService _settingsService;
public ComponentSettingsAccessor(SettingsService settingsService, string componentId, string? placementId)
{
_settingsService = settingsService;
ComponentId = componentId;
PlacementId = placementId;
}
public string ComponentId { get; }
public string? PlacementId { get; }
public T LoadSnapshot<T>() where T : new()
=> _settingsService.LoadSnapshot<T>(SettingsScope.ComponentInstance, ComponentId, PlacementId);
public void SaveSnapshot<T>(T snapshot, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SaveSnapshot(SettingsScope.ComponentInstance, snapshot, ComponentId, PlacementId, changedKeys: changedKeys);
public T LoadSection<T>(string sectionId) where T : new()
=> _settingsService.LoadSection<T>(SettingsScope.ComponentInstance, ComponentId, sectionId, PlacementId);
public void SaveSection<T>(string sectionId, T section, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SaveSection(SettingsScope.ComponentInstance, ComponentId, sectionId, section, PlacementId, changedKeys);
public void DeleteSection(string sectionId)
=> _settingsService.DeleteSection(SettingsScope.ComponentInstance, ComponentId, sectionId, PlacementId);
public T? GetValue<T>(string key)
=> _settingsService.GetValue<T>(SettingsScope.ComponentInstance, key, ComponentId, PlacementId);
public void SetValue<T>(string key, T value, IReadOnlyCollection<string>? changedKeys = null)
=> _settingsService.SetValue(SettingsScope.ComponentInstance, key, value, ComponentId, PlacementId, changedKeys: changedKeys);
}
private sealed class PluginSettingsDocument
{
public Dictionary<string, Dictionary<string, JsonElement>> Sections { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,20 @@
using System;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public static class SettingsServiceAppSnapshotExtensions
{
public static AppSettingsSnapshot Load(this ISettingsService settingsService)
{
ArgumentNullException.ThrowIfNull(settingsService);
return settingsService.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
}
public static void Save(this ISettingsService settingsService, AppSettingsSnapshot snapshot)
{
ArgumentNullException.ThrowIfNull(settingsService);
settingsService.SaveSnapshot(SettingsScope.App, snapshot ?? new AppSettingsSnapshot());
}
}

View File

@@ -0,0 +1,337 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
namespace LanMountainDesktop.Services.Settings;
public enum SettingsWindowAnchorTarget
{
DesktopDockTrailingEdge = 0
}
public enum SettingsWindowFallbackMode
{
None = 0,
ScreenBottomRight = 1
}
public readonly record struct SettingsWindowOpenRequest(
string Source,
Window? Owner = null,
string? PageId = null,
SettingsWindowAnchorTarget AnchorTarget = SettingsWindowAnchorTarget.DesktopDockTrailingEdge,
SettingsWindowFallbackMode FallbackMode = SettingsWindowFallbackMode.ScreenBottomRight);
public interface ISettingsWindowAnchorProvider
{
bool TryGetSettingsWindowAnchorBounds(out PixelRect anchorBounds);
}
public interface ISettingsWindowService
{
bool IsOpen { get; }
event EventHandler? StateChanged;
void Open(SettingsWindowOpenRequest request);
void Close();
void Toggle(SettingsWindowOpenRequest request);
}
internal sealed class SettingsWindowService : ISettingsWindowService
{
private readonly ISettingsPageRegistry _pageRegistry;
private readonly IHostApplicationLifecycle _hostApplicationLifecycle;
private readonly ISettingsFacadeService _settingsFacade;
private readonly IAppearanceThemeService _appearanceThemeService;
private readonly LocalizationService _localizationService;
private SettingsWindowViewModel _viewModel = null!;
private SettingsWindow? _window;
public SettingsWindowService(
ISettingsPageRegistry pageRegistry,
IHostApplicationLifecycle hostApplicationLifecycle,
ISettingsFacadeService settingsFacade)
{
_pageRegistry = pageRegistry;
_hostApplicationLifecycle = hostApplicationLifecycle;
_settingsFacade = settingsFacade;
_appearanceThemeService = HostAppearanceThemeProvider.GetOrCreate();
_localizationService = new();
_settingsFacade.Settings.Changed += OnSettingsChanged;
_appearanceThemeService.Changed += OnAppearanceThemeChanged;
}
private string L(string key)
{
var regionState = _settingsFacade.Region.Get();
var languageCode = regionState.LanguageCode ?? "zh-CN";
return _localizationService.GetString(languageCode, key, key);
}
public bool IsOpen => _window is { IsVisible: true };
public event EventHandler? StateChanged;
public void Open(SettingsWindowOpenRequest request)
{
_pageRegistry.Rebuild();
_window ??= CreateWindow();
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
ApplyTheme(_window);
_window.ReloadPages(request.PageId);
PositionWindow(_window, request);
if (!_window.IsVisible)
{
if (request.Owner is not null && request.Owner.IsVisible)
{
_window.Show(request.Owner);
}
else
{
_window.Show();
}
NotifyStateChanged();
PositionWindowLater(_window, request);
return;
}
_window.Activate();
PositionWindowLater(_window, request);
}
public void Close()
{
_window?.Close();
}
public void Toggle(SettingsWindowOpenRequest request)
{
if (IsOpen)
{
Close();
return;
}
Open(request);
}
private SettingsWindow CreateWindow()
{
var regionState = _settingsFacade.Region.Get();
var languageCode = regionState.LanguageCode ?? "zh-CN";
_viewModel = new SettingsWindowViewModel(_localizationService, languageCode).Initialize();
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
var useSystemChrome = appearanceSnapshot.UseSystemChrome;
var window = new SettingsWindow(
_viewModel,
_pageRegistry,
_hostApplicationLifecycle,
useSystemChrome);
ApplyTheme(window);
window.ShowInTaskbar = false;
window.Closed += (_, _) =>
{
_window = null;
NotifyStateChanged();
};
return window;
}
private void PositionWindowLater(SettingsWindow window, SettingsWindowOpenRequest request)
{
Dispatcher.UIThread.Post(
() =>
{
if (!window.IsVisible)
{
return;
}
PositionWindow(window, request);
},
DispatcherPriority.Background);
}
private static void PositionWindow(SettingsWindow window, SettingsWindowOpenRequest request)
{
if (request.AnchorTarget == SettingsWindowAnchorTarget.DesktopDockTrailingEdge &&
request.Owner is ISettingsWindowAnchorProvider anchorProvider &&
anchorProvider.TryGetSettingsWindowAnchorBounds(out var anchorBounds))
{
PositionWindowAboveAnchor(window, anchorBounds, request);
return;
}
if (request.FallbackMode == SettingsWindowFallbackMode.ScreenBottomRight)
{
PositionWindowNearScreenBottomRight(window, request);
}
}
private static void PositionWindowAboveAnchor(Window window, PixelRect anchorBounds, SettingsWindowOpenRequest request)
{
var workingArea = GetWorkingArea(window, request);
if (anchorBounds.Width <= 0 || anchorBounds.Height <= 0 ||
anchorBounds.Right < workingArea.X || anchorBounds.Y > workingArea.Bottom)
{
PositionWindowNearScreenBottomRight(window, request);
return;
}
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
var width = ResolveWindowWidth(window, scale);
var height = ResolveWindowHeight(window, scale);
var inset = (int)Math.Round(24 * scale);
var gap = (int)Math.Round(16 * scale);
var x = anchorBounds.Right - width - inset;
var y = anchorBounds.Y - height - gap;
x = Math.Clamp(x, workingArea.X + inset, Math.Max(workingArea.X + inset, workingArea.Right - width - inset));
y = Math.Clamp(y, workingArea.Y + inset, Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset));
window.Position = new PixelPoint(x, y);
}
private static void PositionWindowNearScreenBottomRight(Window window, SettingsWindowOpenRequest request)
{
var workingArea = GetWorkingArea(window, request);
var scale = window.RenderScaling > 0 ? window.RenderScaling : 1d;
var width = ResolveWindowWidth(window, scale);
var height = ResolveWindowHeight(window, scale);
var inset = (int)Math.Round(24 * scale);
var x = Math.Max(workingArea.X + inset, workingArea.Right - width - inset);
var y = Math.Max(workingArea.Y + inset, workingArea.Bottom - height - inset);
window.Position = new PixelPoint(x, y);
}
private static PixelRect GetWorkingArea(Window window, SettingsWindowOpenRequest request)
{
if (request.Owner is not null && request.Owner.Screens?.ScreenFromWindow(request.Owner) is { } ownerScreen)
{
return ownerScreen.WorkingArea;
}
if (window.Screens?.ScreenFromWindow(window) is { } windowScreen)
{
return windowScreen.WorkingArea;
}
return window.Screens?.Primary?.WorkingArea
?? new PixelRect(
0,
0,
Math.Max(1280, ResolveWindowWidth(window, 1d) + 96),
Math.Max(720, ResolveWindowHeight(window, 1d) + 96));
}
private static int ResolveWindowWidth(Window window, double scale)
{
var widthDip = window.Bounds.Width > 1 ? window.Bounds.Width : Math.Max(window.Width, window.MinWidth);
return Math.Max(320, (int)Math.Round(widthDip * scale));
}
private static int ResolveWindowHeight(Window window, double scale)
{
var heightDip = window.Bounds.Height > 1 ? window.Bounds.Height : Math.Max(window.Height, window.MinHeight);
return Math.Max(240, (int)Math.Round(heightDip * scale));
}
private void NotifyStateChanged()
{
StateChanged?.Invoke(this, EventArgs.Empty);
}
private void OnSettingsChanged(object? sender, SettingsChangedEvent e)
{
_ = sender;
if (e.Scope != SettingsScope.App)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
if (_window is null || _viewModel is null)
{
return;
}
var changedKeys = e.ChangedKeys?.ToArray();
var refreshAll = changedKeys is null || changedKeys.Length == 0;
var languageChanged = refreshAll || changedKeys.Contains(nameof(AppSettingsSnapshot.LanguageCode), StringComparer.OrdinalIgnoreCase);
var liveAppearance = _appearanceThemeService.GetCurrent();
var themeChanged =
refreshAll ||
changedKeys.Contains(nameof(AppSettingsSnapshot.IsNightMode), StringComparer.OrdinalIgnoreCase) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase) &&
changedKeys.Contains(nameof(AppSettingsSnapshot.ThemeColor), StringComparer.OrdinalIgnoreCase)) ||
(string.Equals(liveAppearance.ThemeColorMode, ThemeAppearanceValues.ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase) &&
(changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperPath), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperType), StringComparer.OrdinalIgnoreCase) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.WallpaperColor), StringComparer.OrdinalIgnoreCase))) ||
changedKeys.Contains(nameof(AppSettingsSnapshot.UseSystemChrome), StringComparer.OrdinalIgnoreCase);
if (languageChanged)
{
var regionState = _settingsFacade.Region.Get();
_viewModel.RefreshLanguage(regionState.LanguageCode);
_pageRegistry.Rebuild();
_window.ReloadPages(_viewModel.CurrentPageId);
_window.RefreshShellText();
}
if (themeChanged)
{
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
_window.ApplyChromeMode(appearanceSnapshot.UseSystemChrome);
ApplyTheme(_window);
}
}, DispatcherPriority.Background);
}
private void ApplyTheme(SettingsWindow window)
{
var appearanceSnapshot = _appearanceThemeService.GetCurrent();
window.RequestedThemeVariant = appearanceSnapshot.IsNightMode
? ThemeVariant.Dark
: ThemeVariant.Light;
_appearanceThemeService.ApplyThemeResources(window.Resources);
_appearanceThemeService.ApplyWindowMaterial(window, MaterialSurfaceRole.SettingsWindowBackground);
}
private void OnAppearanceThemeChanged(object? sender, AppearanceThemeSnapshot e)
{
_ = sender;
_ = e;
Dispatcher.UIThread.Post(() =>
{
if (_window is null || _viewModel is null)
{
return;
}
ApplyTheme(_window);
}, DispatcherPriority.Background);
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Services;
public static class ThemeAppearanceValues
{
public const string ColorModeDefaultNeutral = "default_neutral";
public const string ColorModeSeedMonet = "seed_monet";
public const string ColorModeWallpaperMonet = "wallpaper_monet";
public const string MaterialNone = "none";
public const string MaterialMica = "mica";
public const string MaterialAcrylic = "acrylic";
public static readonly IReadOnlyList<string> AllColorModes =
[
ColorModeDefaultNeutral,
ColorModeSeedMonet,
ColorModeWallpaperMonet
];
public static readonly IReadOnlyList<string> AllMaterialModes =
[
MaterialNone,
MaterialMica,
MaterialAcrylic
];
public static string NormalizeThemeColorMode(string? value, string? themeColor = null)
{
if (string.Equals(value, ColorModeDefaultNeutral, StringComparison.OrdinalIgnoreCase))
{
return ColorModeDefaultNeutral;
}
if (string.Equals(value, ColorModeWallpaperMonet, StringComparison.OrdinalIgnoreCase))
{
return ColorModeWallpaperMonet;
}
if (string.Equals(value, ColorModeSeedMonet, StringComparison.OrdinalIgnoreCase))
{
return ColorModeSeedMonet;
}
return string.IsNullOrWhiteSpace(themeColor)
? ColorModeDefaultNeutral
: ColorModeSeedMonet;
}
public static string NormalizeSystemMaterialMode(string? value)
{
if (string.Equals(value, MaterialMica, StringComparison.OrdinalIgnoreCase))
{
return MaterialMica;
}
if (string.Equals(value, MaterialAcrylic, StringComparison.OrdinalIgnoreCase))
{
return MaterialAcrylic;
}
return MaterialNone;
}
public static IReadOnlyList<string> NormalizeAvailableMaterialModes(IEnumerable<string>? values)
{
if (values is null)
{
return [MaterialNone];
}
var normalized = values
.Select(NormalizeSystemMaterialMode)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (!normalized.Contains(MaterialNone, StringComparer.OrdinalIgnoreCase))
{
normalized.Insert(0, MaterialNone);
}
return normalized;
}
}

View File

@@ -1,4 +1,6 @@
using Avalonia.Controls;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Media;
using LanMountainDesktop.Theme;
@@ -67,7 +69,20 @@ public static class ThemeColorSystemService
private static AppThemePalette BuildPalette(ThemeColorContext context)
{
var accent = context.AccentColor;
var monetPalette = context.MonetPalette;
var monetColors = context.MonetColors?.Where(color => color.A > 0).ToArray() ?? [];
var accent = context.UseNeutralSurfaces
? context.AccentColor
: monetPalette?.Primary ?? GetColorOrDefault(monetColors, 0, context.AccentColor);
var secondarySeed = monetPalette?.Secondary
?? GetColorOrDefault(monetColors, 1, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.14));
var tertiarySeed = monetPalette?.Tertiary
?? GetColorOrDefault(monetColors, 2, ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22));
var neutralSeed = monetPalette?.Neutral
?? GetColorOrDefault(monetColors, 3, context.IsNightMode ? Color.Parse("#FF171C23") : Color.Parse("#FFF2F4F7"));
var neutralVariantSeed = monetPalette?.NeutralVariant
?? GetColorOrDefault(monetColors, 4, context.IsNightMode ? Color.Parse("#FF20262E") : Color.Parse("#FFE8EDF2"));
var accentLight1 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.22);
var accentLight2 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.38);
var accentLight3 = ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.54);
@@ -76,11 +91,29 @@ public static class ThemeColorSystemService
var accentDark3 = ColorMath.Blend(accent, Color.Parse("#FF020617"), 0.40);
var primary = context.IsNightMode ? accentLight1 : accentDark1;
var secondary = context.IsNightMode ? accentLight2 : accentDark2;
var secondary = context.IsNightMode
? ColorMath.Blend(secondarySeed, Color.Parse("#FFFFFFFF"), 0.12)
: ColorMath.Blend(secondarySeed, Color.Parse("#FF111827"), 0.10);
var surfaceBase = context.IsNightMode ? Color.Parse("#FF0B1220") : Color.Parse("#FFF3F7FB");
var surfaceRaised = context.IsNightMode ? Color.Parse("#FF1E293B") : Color.Parse("#FFFFFFFF");
var surfaceOverlay = context.IsNightMode ? Color.Parse("#CC0B1220") : Color.Parse("#CCE2E8F0");
var baseSurface = context.IsNightMode ? Color.Parse("#FF0B0F14") : Color.Parse("#FFF7F8FA");
var raisedSurface = context.IsNightMode ? Color.Parse("#FF131922") : Color.Parse("#FFFFFFFF");
var overlaySurface = context.IsNightMode ? Color.Parse("#FF171E28") : Color.Parse("#FFF1F4F8");
var navSurfaceBase = context.IsLightNavBackground ? Color.Parse("#FFF8FAFC") : Color.Parse("#FF111827");
var surfaceBase = context.UseNeutralSurfaces
? baseSurface
: ColorMath.Blend(baseSurface, neutralSeed, context.IsNightMode ? 0.84 : 0.78);
var surfaceRaised = context.UseNeutralSurfaces
? raisedSurface
: ColorMath.Blend(raisedSurface, neutralVariantSeed, context.IsNightMode ? 0.72 : 0.60);
var surfaceOverlayBase = context.UseNeutralSurfaces
? overlaySurface
: ColorMath.Blend(overlaySurface, neutralVariantSeed, context.IsNightMode ? 0.76 : 0.64);
var surfaceOverlay = Color.FromArgb(
context.IsNightMode ? (byte)0xE8 : (byte)0xF2,
surfaceOverlayBase.R,
surfaceOverlayBase.G,
surfaceOverlayBase.B);
var textPrimaryPreferred = context.IsLightBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC");
var textPrimary = ColorMath.EnsureContrast(textPrimaryPreferred, surfaceRaised, WcagNormalTextContrast);
@@ -96,7 +129,9 @@ public static class ThemeColorSystemService
? ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FF0B1220"), 0.20), surfaceRaised, WcagNormalTextContrast)
: ColorMath.EnsureContrast(ColorMath.Blend(accent, Color.Parse("#FFFFFFFF"), 0.16), surfaceRaised, WcagNormalTextContrast);
var navSurface = context.IsLightNavBackground ? surfaceRaised : Color.Parse("#FF111827");
var navSurface = context.UseNeutralSurfaces
? navSurfaceBase
: ColorMath.Blend(navSurfaceBase, neutralSeed, context.IsNightMode ? 0.66 : 0.70);
var navText = ColorMath.EnsureContrast(
context.IsLightNavBackground ? Color.Parse("#FF0B1220") : Color.Parse("#FFF8FAFC"),
navSurface,
@@ -104,16 +139,27 @@ public static class ThemeColorSystemService
var selectedSurfaceForContrast = ColorMath.Blend(accent, navSurface, 0.18);
var navSelectedText = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), selectedSurfaceForContrast, WcagNormalTextContrast);
var navItemBackground = context.IsLightNavBackground ? Color.Parse("#33FFFFFF") : Color.Parse("#2A0F172A");
var navItemBackground = context.IsLightNavBackground
? Color.FromArgb(0x33, surfaceRaised.R, surfaceRaised.G, surfaceRaised.B)
: Color.FromArgb(0x38, navSurface.R, navSurface.G, navSurface.B);
var navItemHoverBackground = context.IsLightNavBackground
? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, Color.Parse("#FFFFFFFF"), 0.48), 0x66)
: ColorMath.WithAlpha(ColorMath.Blend(accentDark1, Color.Parse("#33111827"), 0.32), 0x78);
var navItemSelectedBackground = ColorMath.WithAlpha(accent, context.IsNightMode ? (byte)0xCE : (byte)0xD9);
var navSelectionIndicator = ColorMath.EnsureContrast(accentLight1, navSurface, WcagLargeTextContrast);
? ColorMath.WithAlpha(ColorMath.Blend(accentLight2, navSurface, 0.12), 0x64)
: ColorMath.WithAlpha(ColorMath.Blend(accentDark1, navSurface, 0.18), 0x74);
var navItemSelectedBackground = context.UseNeutralSurfaces
? ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, 0.24), context.IsNightMode ? (byte)0x8F : (byte)0x6A)
: ColorMath.WithAlpha(ColorMath.Blend(accent, navSurface, context.IsNightMode ? 0.28 : 0.22), context.IsNightMode ? (byte)0x88 : (byte)0x72);
var navSelectionIndicator = ColorMath.EnsureContrast(
context.UseNeutralSurfaces ? accent : accentLight1,
navSurface,
WcagLargeTextContrast);
var toggleOn = context.IsNightMode ? accent : accentDark1;
var toggleOff = context.IsNightMode ? Color.Parse("#66475569") : Color.Parse("#66CBD5E1");
var toggleBorder = context.IsNightMode ? Color.Parse("#80E2E8F0") : Color.Parse("#8094A3B8");
var toggleOff = context.IsNightMode
? Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B)
: Color.FromArgb(0x88, neutralVariantSeed.R, neutralVariantSeed.G, neutralVariantSeed.B);
var toggleBorder = context.IsNightMode
? ColorMath.WithAlpha(ColorMath.Blend(neutralVariantSeed, Color.Parse("#FFF8FAFC"), 0.20), 0x8C)
: ColorMath.WithAlpha(ColorMath.Blend(tertiarySeed, Color.Parse("#FF334155"), 0.18), 0x78);
var onAccent = ColorMath.EnsureContrast(Color.Parse("#FFFFFFFF"), accent, WcagNormalTextContrast);
return new AppThemePalette(
@@ -144,4 +190,11 @@ public static class ThemeColorSystemService
toggleOff,
toggleBorder);
}
private static Color GetColorOrDefault(IReadOnlyList<Color> colors, int index, Color fallback)
{
return index >= 0 && index < colors.Count
? colors[index]
: fallback;
}
}

View File

@@ -0,0 +1,80 @@
using System;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
internal static class UiExceptionGuard
{
public static bool IsFatalException(Exception? exception)
{
return exception is OutOfMemoryException or AccessViolationException or StackOverflowException;
}
public static void FireAndForgetGuarded(
Func<Task> action,
string actionName,
string? context = null,
Func<Exception, Task>? onHandledException = null)
{
_ = RunGuardedUiActionAsync(action, actionName, context, onHandledException);
}
public static async Task RunGuardedUiActionAsync(
Func<Task> action,
string actionName,
string? context = null,
Func<Exception, Task>? onHandledException = null)
{
ArgumentNullException.ThrowIfNull(action);
try
{
await action();
}
catch (Exception ex) when (!IsFatalException(ex))
{
LogHandledException("GuardedUiAction", actionName, ex, context, isFatal: false);
if (onHandledException is not null)
{
try
{
await onHandledException(ex);
}
catch (Exception handlerEx) when (!IsFatalException(handlerEx))
{
LogHandledException("GuardedUiActionHandler", actionName, handlerEx, context, isFatal: false);
}
}
}
}
public static string BuildContext(params (string Key, object? Value)[] parts)
{
if (parts is null || parts.Length == 0)
{
return string.Empty;
}
return string.Join(
"; ",
Array.ConvertAll(parts, part => $"{part.Key}={part.Value ?? "<null>"}"));
}
private static void LogHandledException(
string category,
string actionName,
Exception exception,
string? context,
bool isFatal)
{
var message =
$"Action={actionName}; ExceptionType={exception.GetType().FullName}; IsFatal={isFatal}; Context={context ?? string.Empty}";
if (isFatal)
{
AppLogger.Critical(category, message, exception);
return;
}
AppLogger.Warn(category, message, exception);
}
}

View File

@@ -0,0 +1,63 @@
using System;
namespace LanMountainDesktop.Services;
public static class UpdateSettingsValues
{
public const string ChannelStable = "stable";
public const string ChannelPreview = "preview";
public const string ModeManual = "manual";
public const string ModeDownloadThenConfirm = "download_then_confirm";
public const string ModeSilentOnExit = "silent_on_exit";
public const string DownloadSourceGitHub = "github";
public const string DownloadSourceGhProxy = "gh-proxy";
public const int DefaultDownloadThreads = 4;
public const int MinDownloadThreads = 1;
public const int MaxDownloadThreads = 128;
public const string DefaultGhProxyBaseUrl = "https://gh-proxy.com/";
public static string NormalizeChannel(string? value, bool includePrereleaseFallback = false)
{
if (string.Equals(value, ChannelPreview, StringComparison.OrdinalIgnoreCase))
{
return ChannelPreview;
}
if (string.Equals(value, ChannelStable, StringComparison.OrdinalIgnoreCase))
{
return ChannelStable;
}
return includePrereleaseFallback ? ChannelPreview : ChannelStable;
}
public static string NormalizeMode(string? value)
{
if (string.Equals(value, ModeManual, StringComparison.OrdinalIgnoreCase))
{
return ModeManual;
}
if (string.Equals(value, ModeSilentOnExit, StringComparison.OrdinalIgnoreCase))
{
return ModeSilentOnExit;
}
return ModeDownloadThenConfirm;
}
public static string NormalizeDownloadSource(string? value)
{
return string.Equals(value, DownloadSourceGhProxy, StringComparison.OrdinalIgnoreCase)
? DownloadSourceGhProxy
: DownloadSourceGitHub;
}
public static int NormalizeDownloadThreads(int value)
{
return Math.Clamp(value, MinDownloadThreads, MaxDownloadThreads);
}
}

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