mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c9f6be1b1 | ||
|
|
557b79e8c0 | ||
|
|
f83c6ede1d | ||
|
|
c7fb48c8ee | ||
|
|
85b70c4a8a | ||
|
|
689be7b585 | ||
|
|
91f9f3d6fb | ||
|
|
8d4f00efcb | ||
|
|
e8be0f0576 | ||
|
|
5fdaa2539b | ||
|
|
3b3f060f33 | ||
|
|
c4df243610 | ||
|
|
40a3a00cfe | ||
|
|
4679ee006f |
75
.github/workflows/release.yml
vendored
75
.github/workflows/release.yml
vendored
@@ -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
10
.gitignore
vendored
@@ -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
|
||||
|
||||
32
.trae/specs/settings-page-fluent-redesign/checklist.md
Normal file
32
.trae/specs/settings-page-fluent-redesign/checklist.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Checklist - 设置页面 Fluent 设计改造
|
||||
|
||||
## Phase 1: 分析与准备
|
||||
|
||||
- [ ] SettingsExpander 控件分析完成
|
||||
- [ ] 当前布局问题定位完成
|
||||
|
||||
## Phase 2: 窗口布局调整
|
||||
|
||||
- [ ] SettingsWindow 内容区域无额外 Border 包裹
|
||||
- [ ] 窗口整体视觉效果正常
|
||||
- [ ] 窗口圆角在不同模式下正确显示
|
||||
|
||||
## Phase 3: 设置页面改造
|
||||
|
||||
- [ ] AppearanceSettingsPage 无额外边框包裹
|
||||
- [ ] GeneralSettingsPage 无额外边框包裹
|
||||
- [ ] ComponentsSettingsPage 无额外边框包裹
|
||||
- [ ] PluginsSettingsPage 无额外边框包裹
|
||||
- [ ] AboutSettingsPage 无额外边框包裹
|
||||
|
||||
## Phase 4: 视觉规范
|
||||
|
||||
- [ ] 设置项间距统一
|
||||
- [ ] 圆角样式统一
|
||||
- [ ] 页面标题样式统一
|
||||
|
||||
## 验证
|
||||
|
||||
- [ ] 编译通过,无错误
|
||||
- [ ] 运行正常,设置页面可正常显示
|
||||
- [ ] 视觉效果符合 Fluent 设计风格
|
||||
76
.trae/specs/settings-page-fluent-redesign/spec.md
Normal file
76
.trae/specs/settings-page-fluent-redesign/spec.md
Normal 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 包裹改为直接内容布局
|
||||
51
.trae/specs/settings-page-fluent-redesign/tasks.md
Normal file
51
.trae/specs/settings-page-fluent-redesign/tasks.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
10
LanMountainDesktop.PluginSdk/IComponentEditorHostContext.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IComponentEditorHostContext
|
||||
{
|
||||
void RequestRefresh();
|
||||
|
||||
void CloseEditor();
|
||||
|
||||
void RequestRestart(string? reason = null);
|
||||
}
|
||||
24
LanMountainDesktop.PluginSdk/IComponentSettingsAccessor.cs
Normal file
24
LanMountainDesktop.PluginSdk/IComponentSettingsAccessor.cs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal file
21
LanMountainDesktop.PluginSdk/IPluginSettingsService.cs
Normal 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);
|
||||
}
|
||||
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
10
LanMountainDesktop.PluginSdk/ISettingsCatalog.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface ISettingsCatalog
|
||||
{
|
||||
IReadOnlyList<SettingsSectionDefinition> GetSections();
|
||||
|
||||
IReadOnlyList<SettingsSectionDefinition> GetSections(SettingsScope scope);
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal file
12
LanMountainDesktop.PluginSdk/ISettingsPageHostContext.cs
Normal 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);
|
||||
}
|
||||
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal file
56
LanMountainDesktop.PluginSdk/ISettingsService.cs
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal file
147
LanMountainDesktop.PluginSdk/PluginSettingsSectionBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal file
14
LanMountainDesktop.PluginSdk/SettingsCategories.cs
Normal 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";
|
||||
}
|
||||
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal file
32
LanMountainDesktop.PluginSdk/SettingsChangedEvent.cs
Normal 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; }
|
||||
}
|
||||
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal file
17
LanMountainDesktop.PluginSdk/SettingsOptionChoice.cs
Normal 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; }
|
||||
}
|
||||
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsOptionDefinition.cs
Normal 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; }
|
||||
}
|
||||
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsOptionType.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsOptionType
|
||||
{
|
||||
Toggle = 0,
|
||||
Select = 1,
|
||||
Text = 2,
|
||||
Number = 3,
|
||||
Path = 4,
|
||||
List = 5
|
||||
}
|
||||
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal file
54
LanMountainDesktop.PluginSdk/SettingsPageBase.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
11
LanMountainDesktop.PluginSdk/SettingsPageCategory.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsPageCategory
|
||||
{
|
||||
General = 0,
|
||||
Appearance = 10,
|
||||
Components = 20,
|
||||
Plugins = 30,
|
||||
PluginMarket = 35,
|
||||
About = 40
|
||||
}
|
||||
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal file
46
LanMountainDesktop.PluginSdk/SettingsPageInfoAttribute.cs
Normal 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;
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
9
LanMountainDesktop.PluginSdk/SettingsScope.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum SettingsScope
|
||||
{
|
||||
App = 0,
|
||||
Launcher = 1,
|
||||
Plugin = 2,
|
||||
ComponentInstance = 3
|
||||
}
|
||||
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal file
53
LanMountainDesktop.PluginSdk/SettingsSectionDefinition.cs
Normal 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; }
|
||||
}
|
||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal 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>
|
||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal 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()));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
BIN
LanMountainDesktop/Assets/about_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 999 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
BIN
LanMountainDesktop/Assets/logo_nightly.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal file
38
LanMountainDesktop/Assets/logo_nightly.svg
Normal 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 |
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal file
31
LanMountainDesktop/Assets/logo_nightly_render.html
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentSettingsStoreAware
|
||||
{
|
||||
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
|
||||
}
|
||||
19
LanMountainDesktop/Controls/IconText.axaml
Normal file
19
LanMountainDesktop/Controls/IconText.axaml
Normal 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>
|
||||
52
LanMountainDesktop/Controls/IconText.axaml.cs
Normal file
52
LanMountainDesktop/Controls/IconText.axaml.cs
Normal 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?>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
LanMountainDesktop/Controls/SettingsOptionCard.axaml
Normal file
37
LanMountainDesktop/Controls/SettingsOptionCard.axaml
Normal 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>
|
||||
113
LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
Normal file
113
LanMountainDesktop/Controls/SettingsOptionCard.axaml.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
33
LanMountainDesktop/Controls/SettingsSectionCard.axaml
Normal file
33
LanMountainDesktop/Controls/SettingsSectionCard.axaml
Normal 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>
|
||||
98
LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs
Normal file
98
LanMountainDesktop/Controls/SettingsSectionCard.axaml.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal file
47
LanMountainDesktop/Helpers/PluginMarketMarkdownHelper.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')))
 or '$(RuntimeIdentifier)' == 'win-x64'
 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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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": "确定"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
namespace LanMountainDesktop.Models;
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public enum TaskbarActionId
|
||||
{
|
||||
MinimizeToWindows,
|
||||
OpenSettings,
|
||||
AddDesktopPage,
|
||||
DeleteDesktopPage,
|
||||
DeleteComponent,
|
||||
EditComponent,
|
||||
DeleteComponent,
|
||||
HideLauncherEntry
|
||||
}
|
||||
|
||||
@@ -2,11 +2,5 @@
|
||||
|
||||
public enum TaskbarContext
|
||||
{
|
||||
Desktop,
|
||||
SettingsWallpaper,
|
||||
SettingsGrid,
|
||||
SettingsColor,
|
||||
SettingsStatusBar,
|
||||
SettingsWeather,
|
||||
SettingsRegion
|
||||
Desktop
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
72
LanMountainDesktop/Services/AppLogoService.cs
Normal file
72
LanMountainDesktop/Services/AppLogoService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1070
LanMountainDesktop/Services/AppearanceThemeService.cs
Normal file
1070
LanMountainDesktop/Services/AppearanceThemeService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
190
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal file
190
LanMountainDesktop/Services/ComponentEditorWindowService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
158
LanMountainDesktop/Services/ComponentLibraryServices.cs
Normal file
158
LanMountainDesktop/Services/ComponentLibraryServices.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
943
LanMountainDesktop/Services/CrashReportService.cs
Normal file
@@ -0,0 +1,943 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Sentry;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class DeviceIdService
|
||||
{
|
||||
private static DeviceIdService? _instance;
|
||||
private string? _deviceId;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private bool _isInitialized;
|
||||
|
||||
public static DeviceIdService Instance => _instance ?? throw new InvalidOperationException("DeviceIdService not initialized");
|
||||
|
||||
public DeviceIdService(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
}
|
||||
|
||||
public static void Initialize(ISettingsFacadeService settingsFacade)
|
||||
{
|
||||
_instance = new DeviceIdService(settingsFacade);
|
||||
_instance.EnsureDeviceId();
|
||||
}
|
||||
|
||||
public string DeviceId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_deviceId is null)
|
||||
{
|
||||
throw new InvalidOperationException("DeviceId not initialized");
|
||||
}
|
||||
return _deviceId;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureDeviceId()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
|
||||
if (string.IsNullOrEmpty(snapshot.DeviceId))
|
||||
{
|
||||
snapshot.DeviceId = GenerateDeviceId();
|
||||
_settingsFacade.Settings.SaveSnapshot(
|
||||
SettingsScope.App,
|
||||
snapshot,
|
||||
changedKeys: [nameof(AppSettingsSnapshot.DeviceId)]);
|
||||
_deviceId = snapshot.DeviceId;
|
||||
AppLogger.Info("DeviceId", $"Generated new device ID: {_deviceId}");
|
||||
}
|
||||
else
|
||||
{
|
||||
_deviceId = snapshot.DeviceId;
|
||||
AppLogger.Info("DeviceId", $"Loaded existing device ID: {_deviceId}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_deviceId = GenerateDeviceId();
|
||||
AppLogger.Warn("DeviceId", $"Failed to persist device ID, using generated ID: {_deviceId}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateDeviceId()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
var deviceInfo = $"{Environment.MachineName}|{Environment.ProcessorCount}|{Environment.OSVersion}|{Environment.UserName}|{timestamp}";
|
||||
using var sha = System.Security.Cryptography.SHA256.Create();
|
||||
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(deviceInfo));
|
||||
return Convert.ToHexString(hash)[..32].ToLower();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UserBehaviorAnalyticsService : IDisposable
|
||||
{
|
||||
private const string PostHogApiKey = "phc_bhQZvKDDfsEdLT6kkRFvrWMT8Pc5aCGGsnxoc5ijSf9";
|
||||
private const string PostHogHost = "https://us.i.posthog.com/capture/";
|
||||
|
||||
private bool _isEnabled;
|
||||
private bool _isInitialized;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly DeviceIdService _deviceIdService;
|
||||
private readonly Queue<UserBehaviorEvent> _eventQueue = new();
|
||||
private readonly object _queueLock = new();
|
||||
private System.Threading.Timer? _flushTimer;
|
||||
private readonly PluginSdk.ISettingsService _settingsService;
|
||||
|
||||
public UserBehaviorAnalyticsService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||
e.ChangedKeys is not null &&
|
||||
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||
{
|
||||
AppLogger.Info("UserBehaviorAnalytics", "Settings changed, refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
}
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
RefreshEnabledState();
|
||||
|
||||
try
|
||||
{
|
||||
_flushTimer = new System.Threading.Timer(
|
||||
_ => FlushEvents(),
|
||||
null,
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30));
|
||||
|
||||
CaptureEvent("app_online", new Dictionary<string, object>
|
||||
{
|
||||
{ "event_type", "app_start" }
|
||||
});
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Analytics initialized. DeviceId={_deviceIdService.DeviceId}, Enabled={_isEnabled}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to initialize analytics.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackClick(string componentName, string? action = null)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("ui_click", new Dictionary<string, object>
|
||||
{
|
||||
{ "component", componentName },
|
||||
{ "action", action ?? "click" }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackComponentDrag(string componentId, string action)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("component_drag", new Dictionary<string, object>
|
||||
{
|
||||
{ "component_id", componentId },
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackComponentDrop(string componentId, string targetPosition)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("component_drop", new Dictionary<string, object>
|
||||
{
|
||||
{ "component_id", componentId },
|
||||
{ "target_position", targetPosition }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsOpen(string settingsPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_open", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsChange(string settingsPage, string settingKey, string? oldValue, string newValue)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_change", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage },
|
||||
{ "key", settingKey },
|
||||
{ "old_value", oldValue ?? "" },
|
||||
{ "new_value", newValue }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackSettingsClose(string settingsPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("settings_close", new Dictionary<string, object>
|
||||
{
|
||||
{ "page", settingsPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackUpdateAction(string action, string? version = null)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var props = new Dictionary<string, object>
|
||||
{
|
||||
{ "action", action }
|
||||
};
|
||||
|
||||
if (version is not null)
|
||||
{
|
||||
props["version"] = version;
|
||||
}
|
||||
|
||||
CaptureEvent("update_action", props);
|
||||
}
|
||||
|
||||
public void TrackRestartAction(string action)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("restart_action", new Dictionary<string, object>
|
||||
{
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
public void TrackNavigation(string fromPage, string toPage)
|
||||
{
|
||||
if (!_isEnabled || !_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CaptureEvent("navigation", new Dictionary<string, object>
|
||||
{
|
||||
{ "from", fromPage },
|
||||
{ "to", toPage }
|
||||
});
|
||||
}
|
||||
|
||||
public void SendCrashEvent()
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "app_version", GetAppVersion() },
|
||||
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "event_type", "app_crash" }
|
||||
};
|
||||
|
||||
CaptureEvent("app_crash", properties);
|
||||
FlushEvents();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Crash event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send crash event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void SendShutdownEvent()
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "app_version", GetAppVersion() },
|
||||
{ "event_time", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "event_type", "app_shutdown" }
|
||||
};
|
||||
|
||||
if (_isEnabled)
|
||||
{
|
||||
properties["os_name"] = GetOsName();
|
||||
properties["os_version"] = GetOsVersion();
|
||||
properties["device_name"] = GetDeviceName();
|
||||
properties["device_model"] = GetDeviceModel();
|
||||
properties["device_arch"] = GetDeviceArchitecture();
|
||||
properties["language"] = GetSystemLanguage();
|
||||
}
|
||||
|
||||
CaptureEvent("app_shutdown", properties);
|
||||
FlushEvents();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send shutdown event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshEnabledState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var newEnabled = snapshot.UploadAnonymousUsageData;
|
||||
|
||||
if (_isEnabled != newEnabled)
|
||||
{
|
||||
_isEnabled = newEnabled;
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"User behavior analytics enabled state changed to '{_isEnabled}'.");
|
||||
|
||||
if (_isEnabled && _isInitialized)
|
||||
{
|
||||
CaptureEvent("analytics_enabled", new Dictionary<string, object>());
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to refresh analytics enabled state.", ex);
|
||||
_isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void CaptureEvent(string eventName, Dictionary<string, object>? properties = null)
|
||||
{
|
||||
if (!_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var eventData = new UserBehaviorEvent
|
||||
{
|
||||
Event = eventName,
|
||||
DistinctId = _deviceIdService.DeviceId,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Properties = properties ?? new Dictionary<string, object>(),
|
||||
IncludeDetailedData = _isEnabled
|
||||
};
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
_eventQueue.Enqueue(eventData);
|
||||
|
||||
if (_eventQueue.Count >= 20)
|
||||
{
|
||||
FlushEvents();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"Failed to capture event '{eventName}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void CapturePageView(string pageName, string? sourcePage = null)
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "page_name", pageName }
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(sourcePage))
|
||||
{
|
||||
properties["source_page"] = sourcePage;
|
||||
}
|
||||
|
||||
CaptureEvent("page_view", properties);
|
||||
}
|
||||
|
||||
public void CaptureFeatureUsage(string featureName, string action)
|
||||
{
|
||||
CaptureEvent("feature_usage", new Dictionary<string, object>
|
||||
{
|
||||
{ "feature_name", featureName },
|
||||
{ "action", action }
|
||||
});
|
||||
}
|
||||
|
||||
private void FlushEvents()
|
||||
{
|
||||
List<UserBehaviorEvent> eventsToSend;
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
if (_eventQueue.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
eventsToSend = new List<UserBehaviorEvent>();
|
||||
while (_eventQueue.Count > 0 && eventsToSend.Count < 20)
|
||||
{
|
||||
eventsToSend.Add(_eventQueue.Dequeue());
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
SendEventsToPostHog(eventsToSend);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog.", ex);
|
||||
|
||||
lock (_queueLock)
|
||||
{
|
||||
foreach (var evt in eventsToSend)
|
||||
{
|
||||
if (_eventQueue.Count < 100)
|
||||
{
|
||||
_eventQueue.Enqueue(evt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SendEventsToPostHog(List<UserBehaviorEvent> events)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new System.Net.Http.HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
var firstEvent = events.FirstOrDefault();
|
||||
if (firstEvent is not null)
|
||||
{
|
||||
SendIdentifyToPostHog(client, firstEvent.DistinctId);
|
||||
}
|
||||
|
||||
foreach (var e in events)
|
||||
{
|
||||
var properties = new Dictionary<string, object>
|
||||
{
|
||||
{ "distinct_id", e.DistinctId }
|
||||
};
|
||||
|
||||
if (e.IncludeDetailedData)
|
||||
{
|
||||
properties["$os"] = GetOsName();
|
||||
properties["$os_version"] = GetOsVersion();
|
||||
properties["$app_version"] = GetAppVersion();
|
||||
properties["$device_id"] = e.DistinctId;
|
||||
}
|
||||
|
||||
foreach (var kvp in e.Properties)
|
||||
{
|
||||
properties[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
var requestBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "api_key", PostHogApiKey },
|
||||
{ "event", e.Event },
|
||||
{ "timestamp", e.Timestamp.ToString("o") },
|
||||
{ "properties", properties }
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog API error for event '{e.Event}': {response.StatusCode} - {responseBody}");
|
||||
}
|
||||
}
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"Successfully sent {events.Count} events to PostHog.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send events to PostHog API.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void SendIdentifyToPostHog(System.Net.Http.HttpClient client, string distinctId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userProperties = new Dictionary<string, object>
|
||||
{
|
||||
{ "$device_id", distinctId },
|
||||
{ "$app_version", GetAppVersion() },
|
||||
{ "$os", GetOsName() },
|
||||
{ "$os_version", GetOsVersion() }
|
||||
};
|
||||
|
||||
var requestBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "api_key", PostHogApiKey },
|
||||
{ "event", "$identify" },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToString("o") },
|
||||
{ "properties", new Dictionary<string, object>
|
||||
{
|
||||
{ "distinct_id", distinctId },
|
||||
{ "$set", userProperties }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var content = new System.Net.Http.ByteArrayContent(bytes);
|
||||
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
|
||||
|
||||
var response = client.PostAsync(PostHogHost, content).GetAwaiter().GetResult();
|
||||
var responseBody = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
|
||||
AppLogger.Info("UserBehaviorAnalytics", $"PostHog identify response: {response.StatusCode}");
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", $"PostHog identify failed: {response.StatusCode} - {responseBody}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Failed to send identify to PostHog.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> GetEventProperties(UserBehaviorEvent e)
|
||||
{
|
||||
var props = new Dictionary<string, object>
|
||||
{
|
||||
{ "$os", GetOsName() },
|
||||
{ "$os_version", GetOsVersion() },
|
||||
{ "$app_version", GetAppVersion() },
|
||||
{ "$device_id", e.DistinctId }
|
||||
};
|
||||
|
||||
foreach (var kvp in e.Properties)
|
||||
{
|
||||
props[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
public bool IsEnabled => _isEnabled;
|
||||
|
||||
public string DeviceId => _deviceIdService.DeviceId;
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var assembly = typeof(UserBehaviorAnalyticsService).Assembly;
|
||||
var version = assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private static string GetOsName()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
var osDesc = RuntimeInformation.OSDescription;
|
||||
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||
if (osDesc.Contains("Darwin")) return "Mac";
|
||||
return osDesc;
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture.ToString();
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetOsBuild()
|
||||
{
|
||||
try { return Environment.OSVersion.Version.Build.ToString() ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static int GetProcessorCount()
|
||||
{
|
||||
return Environment.ProcessorCount;
|
||||
}
|
||||
|
||||
private static long GetTotalMemoryMB()
|
||||
{
|
||||
try { return GC.GetGCMemoryInfo().TotalAvailableMemoryBytes / (1024 * 1024); }
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
private static string GetRuntimeVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static string GetClrVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
private static string GetDotNetVersion()
|
||||
{
|
||||
return Environment.Version.ToString();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
_flushTimer?.Dispose();
|
||||
FlushEvents();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("UserBehaviorAnalytics", "Error disposing analytics service.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private class UserBehaviorEvent
|
||||
{
|
||||
public string Event { get; set; } = string.Empty;
|
||||
public string DistinctId { get; set; } = string.Empty;
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
public Dictionary<string, object> Properties { get; set; } = new();
|
||||
public bool IncludeDetailedData { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public static class DictionaryExtensions
|
||||
{
|
||||
public static Dictionary<string, object> Merge(this Dictionary<string, object> first, Dictionary<string, object> second)
|
||||
{
|
||||
var result = new Dictionary<string, object>(first);
|
||||
foreach (var kvp in second)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CrashReportService
|
||||
{
|
||||
private const string SentryDsn = "https://f2aad3a1c63b5f2213ad82683ce93c06@o4511049423257600.ingest.us.sentry.io/4511049425813504";
|
||||
|
||||
private bool _isInitialized;
|
||||
private bool _isEnabled;
|
||||
private readonly ISettingsFacadeService _settingsFacade;
|
||||
private readonly DeviceIdService _deviceIdService;
|
||||
private readonly PluginSdk.ISettingsService _settingsService;
|
||||
|
||||
public CrashReportService(ISettingsFacadeService settingsFacade, DeviceIdService deviceIdService)
|
||||
{
|
||||
_settingsFacade = settingsFacade ?? throw new ArgumentNullException(nameof(settingsFacade));
|
||||
_settingsService = settingsFacade.Settings;
|
||||
_deviceIdService = deviceIdService ?? throw new ArgumentNullException(nameof(deviceIdService));
|
||||
_settingsService.Changed += OnSettingsChanged;
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PluginSdk.SettingsChangedEvent e)
|
||||
{
|
||||
if (e.Scope == PluginSdk.SettingsScope.App &&
|
||||
e.ChangedKeys is not null &&
|
||||
(e.ChangedKeys.Contains("UploadAnonymousCrashData") || e.ChangedKeys.Contains("UploadAnonymousUsageData")))
|
||||
{
|
||||
AppLogger.Info("CrashReport", "Settings changed, refreshing enabled state.");
|
||||
RefreshEnabledState();
|
||||
}
|
||||
}
|
||||
|
||||
public void RefreshEnabledState()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _settingsFacade.Settings.LoadSnapshot<AppSettingsSnapshot>(SettingsScope.App);
|
||||
var newEnabled = snapshot.UploadAnonymousCrashData;
|
||||
|
||||
if (_isEnabled != newEnabled)
|
||||
{
|
||||
_isEnabled = newEnabled;
|
||||
AppLogger.Info("CrashReport", $"Crash reporting enabled state changed to '{_isEnabled}'.");
|
||||
|
||||
if (_isEnabled && !_isInitialized)
|
||||
{
|
||||
InitializeSentry();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to refresh crash reporting enabled state.", ex);
|
||||
_isEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeSentry()
|
||||
{
|
||||
if (_isInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInitialized = true;
|
||||
|
||||
try
|
||||
{
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = SentryDsn;
|
||||
options.AutoSessionTracking = true;
|
||||
options.AttachStacktrace = true;
|
||||
options.MaxBreadcrumbs = 100;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
|
||||
ConfigureCrashReportingScope();
|
||||
|
||||
AppLogger.Info("CrashReport", $"Sentry crash reporting initialized. DeviceId={_deviceIdService.DeviceId}");
|
||||
|
||||
#if DEBUG
|
||||
SentrySdk.CaptureMessage($"Crash reporting enabled - Debug mode test. DeviceId={_deviceIdService.DeviceId}");
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to initialize Sentry crash reporting.", ex);
|
||||
_isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ConfigureCrashReportingScope()
|
||||
{
|
||||
try
|
||||
{
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = _deviceIdService.DeviceId
|
||||
};
|
||||
|
||||
scope.SetTag("data_type", "crash_report");
|
||||
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||
scope.SetTag("device_name", GetDeviceName());
|
||||
scope.SetTag("device_model", GetDeviceModel());
|
||||
scope.SetTag("device_arch", GetDeviceArchitecture());
|
||||
scope.SetTag("os_name", GetOsName());
|
||||
scope.SetTag("os_version", GetOsVersion());
|
||||
scope.SetTag("language", GetSystemLanguage());
|
||||
});
|
||||
|
||||
AppLogger.Info("CrashReport", $"Crash reporting scope configured. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to configure crash reporting scope.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsEnabled => _isEnabled;
|
||||
|
||||
public string DeviceId => _deviceIdService.DeviceId;
|
||||
|
||||
public void SendShutdownEvent()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_isEnabled && _isInitialized)
|
||||
{
|
||||
AppLogger.Info("CrashReport", $"Shutdown event will be sent via Sentry. DeviceId={_deviceIdService.DeviceId}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isInitialized)
|
||||
{
|
||||
SentrySdk.Init(options =>
|
||||
{
|
||||
options.Dsn = SentryDsn;
|
||||
options.AutoSessionTracking = false;
|
||||
options.Release = GetAppVersion();
|
||||
options.Environment = GetEnvironment();
|
||||
});
|
||||
}
|
||||
|
||||
SentrySdk.ConfigureScope(scope =>
|
||||
{
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = _deviceIdService.DeviceId
|
||||
};
|
||||
scope.SetTag("data_type", "shutdown");
|
||||
scope.SetTag("device_id", _deviceIdService.DeviceId);
|
||||
scope.SetTag("app_version", GetAppVersion());
|
||||
});
|
||||
|
||||
SentrySdk.CaptureMessage($"app_shutdown - DeviceId={_deviceIdService.DeviceId}");
|
||||
SentrySdk.Flush(TimeSpan.FromSeconds(3));
|
||||
|
||||
AppLogger.Info("CrashReport", $"Shutdown event sent. DeviceId={_deviceIdService.DeviceId}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("CrashReport", "Failed to send shutdown event.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetDeviceName()
|
||||
{
|
||||
try { return Environment.MachineName ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetDeviceModel()
|
||||
{
|
||||
var osDesc = RuntimeInformation.OSDescription;
|
||||
if (osDesc.Contains("Windows")) return "Windows PC";
|
||||
if (osDesc.Contains("Linux")) return "Linux PC";
|
||||
if (osDesc.Contains("Darwin")) return "Mac";
|
||||
return osDesc;
|
||||
}
|
||||
|
||||
private static string GetDeviceArchitecture()
|
||||
{
|
||||
return RuntimeInformation.OSArchitecture.ToString();
|
||||
}
|
||||
|
||||
private static string GetOsName()
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return "Windows";
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) return "Linux";
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return "macOS";
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
private static string GetOsVersion()
|
||||
{
|
||||
try { return Environment.OSVersion.VersionString ?? "Unknown"; }
|
||||
catch { return "Unknown"; }
|
||||
}
|
||||
|
||||
private static string GetSystemLanguage()
|
||||
{
|
||||
try { return System.Globalization.CultureInfo.CurrentUICulture.Name ?? "en-US"; }
|
||||
catch { return "en-US"; }
|
||||
}
|
||||
|
||||
private static string GetAppVersion()
|
||||
{
|
||||
var version = typeof(CrashReportService).Assembly.GetName().Version;
|
||||
return version is null ? "1.0.0" : $"{version.Major}.{version.Minor}.{version.Build}";
|
||||
}
|
||||
|
||||
private static string GetEnvironment()
|
||||
{
|
||||
#if DEBUG
|
||||
return "development";
|
||||
#else
|
||||
return "production";
|
||||
#endif
|
||||
}
|
||||
}
|
||||
196
LanMountainDesktop/Services/CurrentUserProfileService.cs
Normal file
196
LanMountainDesktop/Services/CurrentUserProfileService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
116
LanMountainDesktop/Services/DesktopGridLayoutService.cs
Normal file
116
LanMountainDesktop/Services/DesktopGridLayoutService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
41
LanMountainDesktop/Services/IComponentLibraryService.cs
Normal file
41
LanMountainDesktop/Services/IComponentLibraryService.cs
Normal 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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
351
LanMountainDesktop/Services/LocationService.cs
Normal file
351
LanMountainDesktop/Services/LocationService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
845
LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs
Normal file
845
LanMountainDesktop/Services/Settings/ComponentDomainStorage.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
250
LanMountainDesktop/Services/Settings/SettingsContracts.cs
Normal file
250
LanMountainDesktop/Services/Settings/SettingsContracts.cs
Normal 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; }
|
||||
}
|
||||
1075
LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
Normal file
1075
LanMountainDesktop/Services/Settings/SettingsDomainServices.cs
Normal file
File diff suppressed because it is too large
Load Diff
341
LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
Normal file
341
LanMountainDesktop/Services/Settings/SettingsPageRegistry.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
429
LanMountainDesktop/Services/Settings/SettingsService.cs
Normal file
429
LanMountainDesktop/Services/Settings/SettingsService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
337
LanMountainDesktop/Services/Settings/SettingsWindowService.cs
Normal file
337
LanMountainDesktop/Services/Settings/SettingsWindowService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
87
LanMountainDesktop/Services/ThemeAppearanceValues.cs
Normal file
87
LanMountainDesktop/Services/ThemeAppearanceValues.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
63
LanMountainDesktop/Services/UpdateSettingsValues.cs
Normal file
63
LanMountainDesktop/Services/UpdateSettingsValues.cs
Normal 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
Reference in New Issue
Block a user