Compare commits

...

21 Commits

Author SHA1 Message Date
lincube
efdfa68dab 0.5.16 2026-03-11 17:43:31 +08:00
lincube
87110f1d69 0.5.15
市场插件安装机制修复,然后修复了一大堆东西
2026-03-11 15:14:08 +08:00
lincube
e7a03404ce 0.5.14
二次启动拦截,统一了生命进程API
2026-03-11 09:40:36 +08:00
lincube
2781d7e0d9 0.5.13
插件市场安装优化
2026-03-11 06:38:11 +08:00
lincube
5003ff1be2 0.5.12 2026-03-10 21:25:47 +08:00
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
lincube
85f7a18cbc 0.5.9
中文与插件市场
2026-03-10 12:14:49 +08:00
lincube
cdffaa16eb 0.5.8
插件市场
2026-03-10 09:55:49 +08:00
lincube
d33d8d3391 0.5.7
示例插件
2026-03-10 00:40:26 +08:00
lincube
9c89c08448 0.5.6
插件系统再进化
2026-03-10 00:04:33 +08:00
lincube
ec7b78bc63 0.5.5
现已支持更改关键设置时提醒重启功能
2026-03-09 22:26:42 +08:00
lincube
e97db00999 0.5.4
项目重启优化。
2026-03-09 17:54:49 +08:00
lincube
8bb6b01236 0.5.3
试验性引入渲染模式切换
2026-03-09 15:11:48 +08:00
lincube
103b215e35 0.5.2
后端服务支持
2026-03-09 14:14:50 +08:00
lincube
cab35f4c22 0.5.1
插件系统试验
2026-03-09 12:27:33 +08:00
lincube
c9f92a4755 0.5.0
设置优化
2026-03-08 14:00:13 +08:00
lincube
854deae801 0.4.12
模块化解耦
2026-03-08 04:22:19 +08:00
lincube
d72cd42483 0.4.11 2026-03-08 03:23:03 +08:00
lincube
3aee31c6c0 0.4.10
自习数据采样优化
2026-03-07 22:44:00 +08:00
lincube
435b96c50c 0.4.9.3
内存泄露问题解决
2026-03-07 22:05:18 +08:00
201 changed files with 23844 additions and 3398 deletions

17
.github/FIX_REPORT.md vendored
View File

@@ -8,14 +8,14 @@ MSBUILD : error MSB1003: Specify a project or solution file.
The current working directory does not contain a project or solution file.
```
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
**原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
---
## 🔧 已采取的修复
### 1. 创建解决方案文件
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
### 1. 创建 `.slnx` 解决方案文件
✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
- `LanMountainDesktop/LanMountainDesktop.csproj`
### 2. 验证本地构建工作
@@ -35,10 +35,10 @@ The current working directory does not contain a project or solution file.
## 📋 解决方案文件内容
包含主桌面项目的标准 Visual Studio 解决方案格式:
包含主桌面项目的标准 XML 解决方案格式:
```
LanMountainDesktop.sln
LanMountainDesktop.slnx
└── LanMountainDesktop (Desktop UI - Avalonia)
```
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
```bash
# 1. 添加新创建的解决方案文件
git add LanMountainDesktop.sln
git add LanMountainDesktop.slnx
git add global.json
# 2. 提交
git commit -m "Add solution file for desktop project"
git commit -m "Migrate desktop solution to .slnx"
# 3. 推送
git push origin main
@@ -92,7 +93,7 @@ git push origin v1.0.1
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 |
| `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
---

3
.github/README.md vendored
View File

@@ -36,9 +36,10 @@
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`
## 当前状态
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`
- 当前体验以 Windows 为主要目标平台。
- SDK 版本由仓库根目录 `global.json` 锁定。
## 运行说明
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)

View File

@@ -0,0 +1,27 @@
name: AirAppMarket Validate
on:
push:
paths:
- "airappmarket/**"
- ".github/workflows/airappmarket-validate.yml"
pull_request:
paths:
- "airappmarket/**"
- ".github/workflows/airappmarket-validate.yml"
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Validate AirAppMarket index
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json

View File

@@ -9,7 +9,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
build-windows:

View File

@@ -8,7 +8,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
analyze:

View File

@@ -18,7 +18,7 @@ on:
env:
DOTNET_VERSION: '10.0.x'
Solution_Name: LanMountainDesktop.sln
Solution_Name: LanMountainDesktop.slnx
jobs:
prepare:

10
.gitignore vendored
View File

@@ -482,3 +482,13 @@ $RECYCLE.BIN/
*.swp
nul
/publish-test
/_build_verify
/_build_verify_tray
/_build_verify_plugin
/_build_verify_plugin_tabs
/_build_verify_sample_plugin
/_build_verify_sample_plugin_capabilities
/_build_verify_plugin_page_host
/_build_verify_plugin_services
/LanMountainDesktop.PluginSdk/_build_verify_*/
/_build_obj

21
LanAirApp/README.md Normal file
View File

@@ -0,0 +1,21 @@
# LanAirApp
## 中文
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
### 目录说明
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目。
- `standards/`:插件清单和目录结构约定。
- `tools/`:插件打包与辅助工具。
### 与宿主的关系
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
## 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.

View File

@@ -0,0 +1,16 @@
# 插件开发指南
## 中文
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
- `plugin.json`
- 插件入口程序集
- 入口类
- 本地化资源
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
## English
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.

View File

@@ -0,0 +1,14 @@
# 插件打包指南
## 中文
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
- `.laapp` 安装包
- `README.md`
官方市场索引只负责记录链接和校验信息。
## English
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.

View File

@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
<EnableDynamicLoading>true</EnableDynamicLoading>
<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>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="CreateLaappPackage" AfterTargets="Build">
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
</Target>
</Project>

View File

@@ -0,0 +1,84 @@
{
"settings.page_title": "Plugin Status",
"plugin.name": "LanMountain Sample Plugin",
"plugin.description": "Example plugin used to validate PluginSdk loading, services, communication, and localization.",
"widget.display_name": "Sample Plugin Status Clock",
"widget.category": "Plugins",
"settings.header.title": "Sample Plugin Capability Inspector",
"settings.section.info": "Plugin Info",
"settings.section.capabilities": "Accessible Capabilities",
"settings.section.status": "Live Runtime Status",
"settings.info.plugin_name": "Plugin Name",
"settings.info.plugin_id": "Plugin Id",
"settings.info.version": "Version",
"settings.info.author": "Author",
"settings.info.description": "Description",
"settings.info.plugin_directory": "Plugin Directory",
"settings.info.data_directory": "Data Directory",
"settings.info.host_application": "Host Application",
"settings.info.host_version": "Host Version",
"settings.info.sdk_api_version": "SDK API Version",
"settings.info.state_service_resolved": "State Service Resolved",
"settings.info.clock_service_resolved": "Clock Service Resolved",
"settings.info.message_bus_resolved": "Message Bus Resolved",
"settings.info.component_placed": "Component Placed",
"settings.info.placed_count": "Placed Count",
"settings.info.preview_count": "Preview Count",
"settings.info.placement_ids": "Placement Ids",
"settings.info.last_component_id": "Last Component Id",
"settings.info.last_cell_size": "Last Cell Size",
"settings.info.clock_service_time": "Clock Service Time",
"settings.status.updated_at": "Updated: {0}",
"status.frontend.title": "Frontend Status",
"status.component.title": "Component Status",
"status.backend.title": "Backend Status",
"status.service.title": "Clock Service",
"status.summary.pending": "Pending",
"status.summary.attached": "Attached",
"status.summary.healthy": "Healthy",
"status.summary.faulted": "Faulted",
"status.summary.placed": "Placed",
"status.summary.preview": "Preview",
"status.frontend.detail.pending": "Waiting for a plugin UI surface to connect.",
"status.frontend.detail.settings_connected": "Settings page is connected to plugin services and communication.",
"status.frontend.detail.widget_connected": "Widget surface is connected to plugin services and communication.",
"status.component.detail.pending": "No component instance has been created yet.",
"status.component.detail.none": "No component instance is active.",
"status.component.detail.preview": "Preview instances: {0}; no placed desktop instance is active yet.",
"status.component.detail.placed": "Placed count: {0}; preview count: {1}; placements: {2}",
"status.backend.detail.pending": "Plugin initialization is in progress.",
"status.backend.detail.log_written": "Initialization log written to: {0}",
"status.backend.detail.log_write_failed": "Initialization log write failed: {0}",
"status.service.detail.pending": "Clock service is not attached yet.",
"status.service.detail.attached": "Clock service was attached and is waiting for the first tick.",
"status.service.detail.running": "Clock service is running. Current service time: {0}",
"status.service.detail.write_failed": "Clock state write failed: {0}",
"capability.manifest.title": "IPluginContext.Manifest",
"capability.manifest.detail": "Readable. Current plugin id: {0}; version: {1}.",
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
"capability.directories.detail": "Readable. Plugin directory: {0}; data directory: {1}.",
"capability.properties.title": "IPluginContext.Properties",
"capability.properties.detail": "Readable. Host properties currently exposed: {0}.",
"capability.get_service.title": "IPluginContext.GetService<T>()",
"capability.get_service.detail": "Callable. State service resolved: {0}; clock service resolved: {1}; message bus resolved: {2}.",
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
"capability.register_service.detail": "Callable during plugin initialization. This sample plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container.",
"capability.message_bus.title": "Plugin Communication Bus",
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
"capability.widget_context.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
"widget.close_desktop.display_name": "Close Desktop",
"widget.close_desktop.text": "Close Desktop",
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
"widget.close_desktop.failed": "Host rejected the exit request",
"widget.subtitle.preview": "Preview surface | placed: {0}",
"widget.subtitle.placement": "Placement {0} | placed: {1}",
"common.dev": "dev",
"common.none": "(none)",
"common.unknown": "(unknown)",
"common.true": "true",
"common.false": "false",
"common.yes": "Yes",
"common.no": "No"
}

View File

@@ -0,0 +1,79 @@
{
"settings.page_title": "插件状态",
"plugin.name": "阑山示例插件",
"plugin.description": "用于验证 PluginSdk 加载、服务、通信与本地化能力的示例插件。",
"widget.display_name": "示例插件状态时钟",
"widget.category": "插件",
"settings.header.title": "示例插件能力检查器",
"settings.section.info": "插件信息",
"settings.section.capabilities": "可访问能力",
"settings.section.status": "实时运行状态",
"settings.info.plugin_name": "插件名称",
"settings.info.plugin_id": "插件 Id",
"settings.info.version": "版本",
"settings.info.author": "作者",
"settings.info.description": "描述",
"settings.info.plugin_directory": "插件目录",
"settings.info.data_directory": "数据目录",
"settings.info.host_application": "宿主应用",
"settings.info.host_version": "宿主版本",
"settings.info.sdk_api_version": "SDK API 版本",
"settings.info.state_service_resolved": "状态服务已解析",
"settings.info.clock_service_resolved": "时钟服务已解析",
"settings.info.message_bus_resolved": "消息总线已解析",
"settings.info.component_placed": "组件是否已放置",
"settings.info.placed_count": "已放置数量",
"settings.info.preview_count": "预览数量",
"settings.info.placement_ids": "放置位置 Id",
"settings.info.last_component_id": "最近组件 Id",
"settings.info.last_cell_size": "最近单元尺寸",
"settings.info.clock_service_time": "时钟服务时间",
"settings.status.updated_at": "更新时间:{0}",
"status.frontend.title": "前端状态",
"status.component.title": "组件状态",
"status.backend.title": "后端状态",
"status.service.title": "时钟服务",
"status.summary.pending": "等待中",
"status.summary.attached": "已挂接",
"status.summary.healthy": "正常",
"status.summary.faulted": "异常",
"status.summary.placed": "已放置",
"status.summary.preview": "预览中",
"status.frontend.detail.pending": "等待插件界面接入。",
"status.frontend.detail.settings_connected": "设置页已接入插件服务与通信。",
"status.frontend.detail.widget_connected": "组件界面已接入插件服务与通信。",
"status.component.detail.pending": "当前还没有创建组件实例。",
"status.component.detail.none": "当前没有活动中的组件实例。",
"status.component.detail.preview": "当前预览实例数量:{0};尚未有已放置的桌面实例。",
"status.component.detail.placed": "已放置数量:{0};预览数量:{1};放置位置:{2}",
"status.backend.detail.pending": "插件初始化进行中。",
"status.backend.detail.log_written": "初始化日志已写入:{0}",
"status.backend.detail.log_write_failed": "初始化日志写入失败:{0}",
"status.service.detail.pending": "时钟服务尚未挂接。",
"status.service.detail.attached": "时钟服务已挂接,正在等待第一次心跳。",
"status.service.detail.running": "时钟服务运行中,当前服务时间:{0}",
"status.service.detail.write_failed": "时钟状态写入失败:{0}",
"capability.manifest.title": "IPluginContext.Manifest",
"capability.manifest.detail": "可读取。当前插件 id{0};版本:{1}。",
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
"capability.directories.detail": "可读取。插件目录:{0};数据目录:{1}。",
"capability.properties.title": "IPluginContext.Properties",
"capability.properties.detail": "可读取。宿主当前暴露的属性:{0}。",
"capability.get_service.title": "IPluginContext.GetService<T>()",
"capability.get_service.detail": "可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
"capability.register_service.detail": "可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。",
"capability.message_bus.title": "插件通信总线",
"capability.message_bus.detail": "这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。",
"capability.widget_context.title": "PluginDesktopComponentContext",
"capability.widget_context.detail": "组件可以读取 ComponentId、PlacementId、CellSize并能在同一个插件服务容器上调用 GetService<T>()。",
"widget.subtitle.preview": "预览界面 | 已放置:{0}",
"widget.subtitle.placement": "位置 {0} | 已放置:{1}",
"common.dev": "开发版",
"common.none": "(无)",
"common.unknown": "(未知)",
"common.true": "是",
"common.false": "否",
"common.yes": "是",
"common.no": "否"
}

View File

@@ -0,0 +1,9 @@
# LanMountainDesktop.SamplePlugin
## 中文
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
## English
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.

View File

@@ -0,0 +1,106 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginRuntimeStateService? _stateService;
private SamplePluginClockService? _clockService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
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.");
_stateService = new SamplePluginRuntimeStateService(
context.Manifest,
context.PluginDirectory,
context.DataDirectory,
hostName,
hostVersion,
sdkApiVersion,
messageBus,
localizer);
context.RegisterService(_stateService);
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
context.RegisterService(_clockService);
_stateService.AttachClockService(_clockService);
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
var initMessage =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
try
{
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady(localizer.Format(
"status.backend.detail.log_written",
"初始化日志已写入:{0}",
logPath));
}
catch (Exception ex)
{
_stateService.MarkBackendFaulted(localizer.Format(
"status.backend.detail.log_write_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", "示例插件状态时钟"),
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: localizer.GetString("widget.category", "插件"),
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.CloseDesktop",
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
iconKey: "DismissCircle",
category: localizer.GetString("widget.category", "鎻掍欢"),
minWidthCells: 2,
minHeightCells: 1,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Free,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
}
public void Dispose()
{
_clockService?.Dispose();
_clockService = null;
_stateService = null;
}
private static string GetHostProperty(IPluginContext context, string key, string fallback)
{
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: fallback;
}
}

View File

@@ -0,0 +1,166 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginCloseDesktopWidget : Border
{
private readonly PluginLocalizer _localizer;
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
private readonly TextBlock _titleTextBlock;
private readonly TextBlock _statusTextBlock;
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
{
_localizer = PluginLocalizer.Create(context);
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
_titleTextBlock = new TextBlock
{
Text = T("widget.close_desktop.text", "关闭桌面"),
Foreground = Brushes.White,
FontWeight = FontWeight.SemiBold,
VerticalAlignment = VerticalAlignment.Center
};
_statusTextBlock = new TextBlock
{
Text = _hostApplicationLifecycle is null
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
VerticalAlignment = VerticalAlignment.Center
};
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
CreateIconShell(),
new StackPanel
{
Spacing = 2,
VerticalAlignment = VerticalAlignment.Center,
Children =
{
_titleTextBlock,
_statusTextBlock
}
}
}
};
Grid.SetColumn(contentGrid.Children[1], 1);
var actionButton = new Button
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
HorizontalContentAlignment = HorizontalAlignment.Stretch,
VerticalContentAlignment = VerticalAlignment.Stretch,
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
IsEnabled = _hostApplicationLifecycle is not null,
Content = contentGrid
};
actionButton.Click += OnButtonClick;
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF0B1220"), 0),
new GradientStop(Color.Parse("#FF172554"), 0.55),
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
BorderThickness = new Thickness(1);
CornerRadius = new CornerRadius(18);
Padding = new Thickness(14, 10);
Child = actionButton;
SizeChanged += OnSizeChanged;
ApplyScale();
}
private Border CreateIconShell()
{
return new Border
{
Width = 36,
Height = 36,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(Color.Parse("#33F87171")),
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
BorderThickness = new Thickness(1),
VerticalAlignment = VerticalAlignment.Center,
Child = new TextBlock
{
Text = "⏻",
FontSize = 18,
Foreground = Brushes.White,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
}
};
}
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
Source: "SamplePlugin.CloseDesktopWidget",
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
{
return;
}
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
}
private void ApplyScale()
{
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
{
return;
}
if (contentGrid.Children[0] is Border iconShell)
{
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
iconShell.Width = iconSize;
iconShell.Height = iconSize;
if (iconShell.Child is TextBlock iconText)
{
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
}
}
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
}

View File

@@ -0,0 +1,524 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal enum SamplePluginHealthState
{
Healthy,
Pending,
Faulted
}
internal sealed record SamplePluginStatusEntry(
string Key,
string Title,
SamplePluginHealthState State,
string Summary,
string Detail,
DateTimeOffset UpdatedAt);
internal sealed record SamplePluginCapabilityItem(
string Title,
string Detail);
internal sealed record SamplePluginRuntimeSnapshot(
PluginManifest Manifest,
string PluginDirectory,
string DataDirectory,
string HostApplicationName,
string HostVersion,
string SdkApiVersion,
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
bool HasPlacedComponent,
int PlacedCount,
int PreviewCount,
IReadOnlyList<string> PlacementIds,
string? LastComponentId,
double LastCellSize,
DateTimeOffset? ServiceClockTime);
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
internal sealed record SamplePluginStateChangedMessage(string Reason);
internal sealed record SamplePluginComponentInstance(
string ComponentId,
string? PlacementId,
double CellSize)
{
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
}
internal sealed class SamplePluginRuntimeStateService
{
private readonly object _gate = new();
private readonly IPluginMessageBus _messageBus;
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
new(StringComparer.OrdinalIgnoreCase);
private readonly PluginManifest _manifest;
private readonly string _pluginDirectory;
private readonly string _dataDirectory;
private readonly string _hostApplicationName;
private readonly string _hostVersion;
private readonly string _sdkApiVersion;
private readonly PluginLocalizer _localizer;
private SamplePluginStatusEntry _frontend;
private SamplePluginStatusEntry _component;
private SamplePluginStatusEntry _backend;
private SamplePluginStatusEntry _service;
private string? _lastComponentId;
private double _lastCellSize;
private DateTimeOffset? _serviceClockTime;
public SamplePluginRuntimeStateService(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
string hostApplicationName,
string hostVersion,
string sdkApiVersion,
IPluginMessageBus messageBus,
PluginLocalizer localizer)
{
_manifest = manifest;
_pluginDirectory = pluginDirectory;
_dataDirectory = dataDirectory;
_hostApplicationName = hostApplicationName;
_hostVersion = hostVersion;
_sdkApiVersion = sdkApiVersion;
_messageBus = messageBus;
_localizer = localizer;
_frontend = CreateEntry(
"frontend",
T("status.frontend.title", "前端状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.frontend.detail.pending", "等待插件界面接入。"));
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.component.detail.pending", "当前还没有创建组件实例。"));
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.backend.detail.pending", "插件初始化进行中。"));
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.service.detail.pending", "时钟服务尚未挂接。"));
}
public void AttachClockService(SamplePluginClockService clockService)
{
ArgumentNullException.ThrowIfNull(clockService);
lock (_gate)
{
_serviceClockTime = clockService.CurrentTime;
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Pending,
T("status.summary.attached", "已挂接"),
T("status.service.detail.attached", "时钟服务已挂接,正在等待第一次心跳。"));
}
PublishStateChanged("Clock service attached");
}
public void MarkFrontendReady(string detail)
{
lock (_gate)
{
_frontend = CreateEntry(
"frontend",
T("status.frontend.title", "前端状态"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
detail);
}
PublishStateChanged("Frontend updated");
}
public void MarkBackendReady(string detail)
{
lock (_gate)
{
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
detail);
}
PublishStateChanged("Backend updated");
}
public void MarkBackendFaulted(string detail)
{
lock (_gate)
{
_backend = CreateEntry(
"backend",
T("status.backend.title", "后端状态"),
SamplePluginHealthState.Faulted,
T("status.summary.faulted", "异常"),
detail);
}
PublishStateChanged("Backend faulted");
}
public void MarkClockServiceTick(DateTimeOffset currentTime)
{
lock (_gate)
{
_serviceClockTime = currentTime;
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Healthy,
T("status.summary.healthy", "正常"),
Tf(
"status.service.detail.running",
"时钟服务运行中,当前服务时间:{0}",
currentTime.LocalDateTime.ToString("HH:mm:ss")));
}
PublishStateChanged("Clock service tick");
}
public void MarkClockServiceFaulted(string detail)
{
lock (_gate)
{
_service = CreateEntry(
"service",
T("status.service.title", "时钟服务"),
SamplePluginHealthState.Faulted,
T("status.summary.faulted", "异常"),
detail);
}
PublishStateChanged("Clock service faulted");
}
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
{
var instanceId = Guid.NewGuid().ToString("N");
lock (_gate)
{
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
_lastComponentId = componentId;
_lastCellSize = cellSize;
UpdateComponentStatusNoLock();
}
PublishStateChanged("Component attached");
return instanceId;
}
public void UnregisterComponentInstance(string instanceId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
var removed = false;
lock (_gate)
{
removed = _componentInstances.Remove(instanceId);
if (removed)
{
UpdateComponentStatusNoLock();
}
}
if (removed)
{
PublishStateChanged("Component detached");
}
}
public SamplePluginRuntimeSnapshot GetSnapshot()
{
lock (_gate)
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
return new SamplePluginRuntimeSnapshot(
_manifest,
_pluginDirectory,
_dataDirectory,
_hostApplicationName,
_hostVersion,
_sdkApiVersion,
[_frontend, _component, _backend, _service],
placementIds.Length > 0,
placementIds.Length,
previewCount,
placementIds,
_lastComponentId,
_lastCellSize,
_serviceClockTime);
}
}
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
IPluginContext context,
bool hasStateService,
bool hasClockService,
bool hasMessageBus)
{
ArgumentNullException.ThrowIfNull(context);
var propertyNames = context.Properties.Count == 0
? T("common.none", "(无)")
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
return
[
new SamplePluginCapabilityItem(
T("capability.manifest.title", "IPluginContext.Manifest"),
Tf(
"capability.manifest.detail",
"可读取。当前插件 id{0};版本:{1}。",
context.Manifest.Id,
context.Manifest.Version ?? T("common.dev", "开发版"))),
new SamplePluginCapabilityItem(
T("capability.directories.title", "IPluginContext.PluginDirectory / DataDirectory"),
Tf(
"capability.directories.detail",
"可读取。插件目录:{0};数据目录:{1}。",
context.PluginDirectory,
context.DataDirectory)),
new SamplePluginCapabilityItem(
T("capability.properties.title", "IPluginContext.Properties"),
Tf(
"capability.properties.detail",
"可读取。宿主当前暴露的属性:{0}。",
propertyNames)),
new SamplePluginCapabilityItem(
T("capability.get_service.title", "IPluginContext.GetService<T>()"),
Tf(
"capability.get_service.detail",
"可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
FormatBoolean(hasStateService),
FormatBoolean(hasClockService),
FormatBoolean(hasMessageBus))),
new SamplePluginCapabilityItem(
T("capability.register_service.title", "IPluginContext.RegisterService<TService>()"),
T(
"capability.register_service.detail",
"可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。")),
new SamplePluginCapabilityItem(
T("capability.message_bus.title", "插件通信总线"),
T(
"capability.message_bus.detail",
"这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。")),
new SamplePluginCapabilityItem(
T("capability.widget_context.title", "PluginDesktopComponentContext"),
T(
"capability.widget_context.detail",
"组件可以读取 ComponentId、PlacementId、CellSize并能在同一个插件服务容器上调用 GetService<T>()。"))
];
}
private void UpdateComponentStatusNoLock()
{
var placementIds = _componentInstances.Values
.Where(instance => instance.IsPlaced)
.Select(instance => instance.PlacementId!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
.ToArray();
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
if (placementIds.Length > 0)
{
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Healthy,
T("status.summary.placed", "已放置"),
Tf(
"status.component.detail.placed",
"已放置数量:{0};预览数量:{1};放置位置:{2}",
placementIds.Length,
previewCount,
string.Join(", ", placementIds)));
return;
}
if (previewCount > 0)
{
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Healthy,
T("status.summary.preview", "预览中"),
Tf(
"status.component.detail.preview",
"当前预览实例数量:{0};尚未有已放置的桌面实例。",
previewCount));
return;
}
_component = CreateEntry(
"component",
T("status.component.title", "组件状态"),
SamplePluginHealthState.Pending,
T("status.summary.pending", "等待中"),
T("status.component.detail.none", "当前没有活动中的组件实例。"));
}
private void PublishStateChanged(string reason)
{
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
}
private static SamplePluginStatusEntry CreateEntry(
string key,
string title,
SamplePluginHealthState state,
string summary,
string detail)
{
return new SamplePluginStatusEntry(
key,
title,
state,
summary,
detail,
DateTimeOffset.Now);
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
private string FormatBoolean(bool value)
{
return value
? T("common.true", "是")
: T("common.false", "否");
}
}
internal sealed class SamplePluginClockService : IDisposable
{
private readonly object _gate = new();
private readonly string _clockStateFilePath;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly IPluginMessageBus _messageBus;
private readonly PluginLocalizer _localizer;
private readonly Timer _timer;
private DateTimeOffset _currentTime = DateTimeOffset.Now;
private int _disposed;
public SamplePluginClockService(
string dataDirectory,
SamplePluginRuntimeStateService stateService,
IPluginMessageBus messageBus,
PluginLocalizer localizer)
{
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
_stateService = stateService;
_messageBus = messageBus;
_localizer = localizer;
_timer = new Timer(OnTimerTick);
}
public DateTimeOffset CurrentTime
{
get
{
lock (_gate)
{
return _currentTime;
}
}
}
public void Start()
{
PublishTick();
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_timer.Dispose();
}
private void OnTimerTick(object? state)
{
PublishTick();
}
private void PublishTick()
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
var now = DateTimeOffset.Now;
lock (_gate)
{
_currentTime = now;
}
try
{
File.WriteAllText(
_clockStateFilePath,
now.ToString("O", CultureInfo.InvariantCulture));
_stateService.MarkClockServiceTick(now);
_messageBus.Publish(new SamplePluginClockTickMessage(now));
}
catch (Exception ex)
{
_stateService.MarkClockServiceFaulted(_localizer.Format(
"status.service.detail.write_failed",
"时钟状态写入失败:{0}",
ex.Message));
}
}
}

View File

@@ -0,0 +1,374 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginSettingsView : UserControl
{
private readonly IPluginContext _context;
private readonly PluginLocalizer _localizer;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
private readonly List<IDisposable> _subscriptions = [];
public SamplePluginSettingsView(IPluginContext context)
{
_context = context;
_localizer = PluginLocalizer.Create(context);
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
_stateService.MarkFrontendReady(T(
"status.frontend.detail.settings_connected",
"设置页已接入插件服务与通信。"));
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
Content = new Border
{
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#1F0B1120"), 0),
new GradientStop(Color.Parse("#260C4A6E"), 1)
]
},
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(18),
Child = new StackPanel
{
Spacing = 14,
Children =
{
new TextBlock
{
Text = T("settings.header.title", "示例插件能力检查器"),
FontSize = 22,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
CreateSection(T("settings.section.info", "插件信息"), _pluginInfoPanel),
CreateSection(T("settings.section.capabilities", "可访问能力"), _capabilityPanel),
CreateSection(T("settings.section.status", "实时运行状态"), _statusPanel)
}
}
};
RefreshView();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
SubscribeToPluginBus();
RefreshView();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
}
private void SubscribeToPluginBus()
{
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(RefreshView)));
}
private void RefreshView()
{
var snapshot = _stateService.GetSnapshot();
RefreshPluginInfo(snapshot);
RefreshCapabilities();
RefreshStatuses(snapshot);
}
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
{
_pluginInfoPanel.Children.Clear();
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.plugin_name", "插件名称"),
T("plugin.name", snapshot.Manifest.Name)));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_id", "插件 Id"), snapshot.Manifest.Id));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.version", "版本"), snapshot.Manifest.Version ?? T("common.dev", "开发版")));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.author", "作者"), snapshot.Manifest.Author ?? T("common.none", "(无)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.description", "描述"),
T("plugin.description", snapshot.Manifest.Description ?? T("common.none", "(无)"))));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_directory", "插件目录"), snapshot.PluginDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.data_directory", "数据目录"), snapshot.DataDirectory));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_application", "宿主应用"), snapshot.HostApplicationName));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_version", "宿主版本"), snapshot.HostVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.sdk_api_version", "SDK API 版本"), snapshot.SdkApiVersion));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.state_service_resolved", "状态服务已解析"),
FormatBoolean(_context.GetService<SamplePluginRuntimeStateService>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.clock_service_resolved", "时钟服务已解析"),
FormatBoolean(_context.GetService<SamplePluginClockService>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.message_bus_resolved", "消息总线已解析"),
FormatBoolean(_context.GetService<IPluginMessageBus>() is not null)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.component_placed", "组件是否已放置"),
snapshot.HasPlacedComponent ? T("common.yes", "是") : T("common.no", "否")));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.placed_count", "已放置数量"), snapshot.PlacedCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.preview_count", "预览数量"), snapshot.PreviewCount.ToString()));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.placement_ids", "放置位置 Id"),
snapshot.PlacementIds.Count == 0 ? T("common.none", "(无)") : string.Join(", ", snapshot.PlacementIds)));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.last_component_id", "最近组件 Id"),
snapshot.LastComponentId ?? T("common.none", "(无)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.last_cell_size", "最近单元尺寸"),
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : T("common.unknown", "(未知)")));
_pluginInfoPanel.Children.Add(CreateInfoLine(
T("settings.info.clock_service_time", "时钟服务时间"),
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
}
private void RefreshCapabilities()
{
var capabilities = _stateService.GetCapabilities(
_context,
_context.GetService<SamplePluginRuntimeStateService>() is not null,
_context.GetService<SamplePluginClockService>() is not null,
_context.GetService<IPluginMessageBus>() is not null);
_capabilityPanel.Children.Clear();
foreach (var capability in capabilities)
{
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
}
}
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
{
_statusPanel.Children.Clear();
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
CreateStatusHeader(entry, palette),
new TextBlock
{
Text = entry.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = Tf("settings.status.updated_at", "更新时间:{0}", entry.UpdatedAt.LocalDateTime.ToString("HH:mm:ss")),
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
}
}
}
});
}
}
private Border CreateSection(string title, Control content)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#14000000")),
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 12,
Children =
{
new TextBlock
{
Text = title,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
content
}
}
};
}
private Control CreateInfoLine(string label, string value)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("180,*"),
ColumnSpacing = 10
};
var labelText = new TextBlock
{
Text = label,
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
};
var valueText = new TextBlock
{
Text = value,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
};
grid.Children.Add(labelText);
grid.Children.Add(valueText);
Grid.SetColumn(valueText, 1);
return grid;
}
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10),
Child = new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = item.Title,
Foreground = Brushes.White,
FontWeight = FontWeight.SemiBold
},
new TextBlock
{
Text = item.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
private static Control CreateStatusHeader(
SamplePluginStatusEntry entry,
(Color Background, Color Border, Color Dot) palette)
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8
};
var dot = new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
};
var title = new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
};
var summary = new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
};
grid.Children.Add(dot);
grid.Children.Add(title);
grid.Children.Add(summary);
Grid.SetColumn(title, 1);
Grid.SetColumn(summary, 2);
return grid;
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F115E59"),
Color.Parse("#665EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#291B1B"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#2B3A2A0D"),
Color.Parse("#66FBBF24"),
Color.Parse("#FBBF24"))
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
private string FormatBoolean(bool value)
{
return value
? T("common.true", "是")
: T("common.false", "否");
}
}

View File

@@ -0,0 +1,298 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginStatusClockWidget : Border
{
private readonly PluginDesktopComponentContext _context;
private readonly PluginLocalizer _localizer;
private readonly SamplePluginRuntimeStateService _stateService;
private readonly SamplePluginClockService _clockService;
private readonly IPluginMessageBus _messageBus;
private readonly TextBlock _timeTextBlock;
private readonly TextBlock _subtitleTextBlock;
private readonly StackPanel _statusPanel;
private readonly Border _statusHost;
private readonly List<IDisposable> _subscriptions = [];
private string? _instanceId;
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
{
_context = context;
_localizer = PluginLocalizer.Create(context);
_stateService = context.GetService<SamplePluginRuntimeStateService>()
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
_clockService = context.GetService<SamplePluginClockService>()
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
_messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
_timeTextBlock = new TextBlock
{
Foreground = Brushes.White,
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Left
};
_subtitleTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
HorizontalAlignment = HorizontalAlignment.Left,
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 8
};
_statusHost = new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
Child = _statusPanel
};
Background = new LinearGradientBrush
{
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
GradientStops =
[
new GradientStop(Color.Parse("#FF07111F"), 0),
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
]
};
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
BorderThickness = new Thickness(1);
HorizontalAlignment = HorizontalAlignment.Stretch;
VerticalAlignment = VerticalAlignment.Stretch;
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 14,
Children =
{
new StackPanel
{
Spacing = 4,
HorizontalAlignment = HorizontalAlignment.Left,
Children =
{
_timeTextBlock,
_subtitleTextBlock
}
},
_statusHost
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
if (string.IsNullOrWhiteSpace(_instanceId))
{
_instanceId = _stateService.RegisterComponentInstance(
_context.ComponentId,
_context.PlacementId,
_context.CellSize);
}
_stateService.MarkFrontendReady(T(
"status.frontend.detail.widget_connected",
"组件界面已接入插件服务与通信。"));
SubscribeToPluginBus();
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
_subscriptions.Clear();
if (string.IsNullOrWhiteSpace(_instanceId))
{
return;
}
_stateService.UnregisterComponentInstance(_instanceId);
_instanceId = null;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyScale();
RefreshStatusPanel();
}
private void SubscribeToPluginBus()
{
if (_subscriptions.Count > 0)
{
return;
}
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
Dispatcher.UIThread.Post(() =>
{
UpdateSubtitle();
RefreshStatusPanel();
})));
}
private void RefreshClock(DateTimeOffset currentTime)
{
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
}
private void UpdateSubtitle()
{
var snapshot = _stateService.GetSnapshot();
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
}
private void RefreshStatusPanel()
{
_statusPanel.Children.Clear();
var snapshot = _stateService.GetSnapshot();
var basis = GetLayoutBasis();
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
BorderBrush = new SolidColorBrush(palette.Border),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(10, 8),
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = Math.Clamp(basis * 0.038, 8, 11),
Height = Math.Clamp(basis * 0.038, 8, 11),
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = titleSize,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = entry.Summary,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right,
TextAlignment = TextAlignment.Right,
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Detail,
FontSize = detailSize,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
TextWrapping = TextWrapping.Wrap
}
}
}
});
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
Grid.SetColumnSpan(row.Children[3], 3);
Grid.SetRow(row.Children[3], 1);
}
}
private void ApplyScale()
{
var basis = GetLayoutBasis();
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
}
private double GetLayoutBasis()
{
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
}
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
{
return state switch
{
SamplePluginHealthState.Healthy => (
Color.Parse("#1F0F766E"),
Color.Parse("#4D5EEAD4"),
Color.Parse("#5EEAD4")),
SamplePluginHealthState.Faulted => (
Color.Parse("#29B91C1C"),
Color.Parse("#66F87171"),
Color.Parse("#F87171")),
_ => (
Color.Parse("#1F7C2D12"),
Color.Parse("#66FDBA74"),
Color.Parse("#FDBA74"))
};
}
private string T(string key, string fallback)
{
return _localizer.GetString(key, fallback);
}
private string Tf(string key, string fallback, params object[] args)
{
return _localizer.Format(key, fallback, args);
}
}

View File

@@ -0,0 +1,9 @@
{
"id": "LanMountainDesktop.SamplePlugin",
"name": "LanMountain Sample Plugin",
"description": "Example plugin used to validate PluginSdk loading and isolation.",
"author": "LanMountainDesktop",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
}

View File

@@ -0,0 +1,11 @@
# 示例插件目录
## 中文
本目录用于存放阑山桌面的示例插件和参考实现。
当前标准示例为 `LanMountainDesktop.SamplePlugin`
## English
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.

View File

@@ -0,0 +1,9 @@
# 插件标准说明
## 中文
本目录存放插件开发需要遵循的基础约定,包括 `.laapp``plugin.json``Localization/` 以及仓库根目录 README 和安装包等要求。
## English
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.

View File

@@ -0,0 +1,9 @@
{
"id": "LanMountainDesktop.YourPlugin",
"name": "Your Plugin",
"description": "Describe what your plugin adds to LanMountainDesktop.",
"author": "Your Name",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
}

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,136 @@
using System.IO.Compression;
using LanMountainDesktop.PluginSdk;
return await RunAsync(args);
static async Task<int> RunAsync(string[] args)
{
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
{
PrintUsage();
return 0;
}
string? inputDirectory = null;
string? outputPath = null;
var overwrite = false;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--input":
inputDirectory = ReadValue(args, ref i, "--input");
break;
case "--output":
outputPath = ReadValue(args, ref i, "--output");
break;
case "--overwrite":
overwrite = true;
break;
default:
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
}
}
if (string.IsNullOrWhiteSpace(inputDirectory))
{
throw new InvalidOperationException("Missing required argument '--input'.");
}
var fullInputDirectory = Path.GetFullPath(inputDirectory);
if (!Directory.Exists(fullInputDirectory))
{
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
}
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
if (!File.Exists(manifestPath))
{
throw new FileNotFoundException(
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
manifestPath);
}
var manifest = PluginManifest.Load(manifestPath);
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
if (!File.Exists(entranceAssemblyPath))
{
throw new FileNotFoundException(
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
entranceAssemblyPath);
}
outputPath ??= Path.Combine(
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
BuildPackageFileName(manifest.Id));
var fullOutputPath = Path.GetFullPath(outputPath);
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
}
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
if (string.IsNullOrWhiteSpace(destinationDirectory))
{
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
}
Directory.CreateDirectory(destinationDirectory);
if (File.Exists(fullOutputPath))
{
if (!overwrite)
{
throw new InvalidOperationException(
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
}
File.Delete(fullOutputPath);
}
await Task.Run(() => ZipFile.CreateFromDirectory(
fullInputDirectory,
fullOutputPath,
CompressionLevel.Optimal,
includeBaseDirectory: false));
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
return 0;
}
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
{
var nextIndex = index + 1;
if (nextIndex >= args.Count)
{
throw new InvalidOperationException($"Missing value for '{optionName}'.");
}
index = nextIndex;
return args[nextIndex];
}
static string BuildPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return safeName + PluginSdkInfo.PackageFileExtension;
}
static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
static void PrintUsage()
{
Console.WriteLine("LanMountainDesktop.PluginPackager");
Console.WriteLine("Usage:");
Console.WriteLine(" --input <plugin build directory> Required");
Console.WriteLine(" --output <path to .laapp> Optional");
Console.WriteLine(" --overwrite Optional");
}

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record HostApplicationLifecycleRequest(
string? Source = null,
string? Reason = null);
public interface IHostApplicationLifecycle
{
bool TryExit(HostApplicationLifecycleRequest? request = null);
bool TryRestart(HostApplicationLifecycleRequest? request = null);
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPlugin
{
void Initialize(IPluginContext context);
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
namespace LanMountainDesktop.PluginSdk;
public interface IPluginContext
{
PluginManifest Manifest { get; }
string PluginDirectory { get; }
string DataDirectory { get; }
IServiceProvider Services { get; }
IReadOnlyDictionary<string, object?> Properties { get; }
T? GetService<T>();
bool TryGetProperty<T>(string key, out T? value);
void RegisterService<TService>(TService service)
where TService : class;
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginMessageBus
{
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
void Publish<TMessage>(TMessage message);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public interface IPluginPackageManager
{
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
PluginPackageInstallResult InstallPackage(string packagePath);
}

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record InstalledPluginInfo(
PluginManifest Manifest,
bool IsEnabled,
bool IsLoaded,
bool IsPackage,
string? ErrorMessage);

View File

@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
public abstract class PluginBase : IPlugin
{
public virtual void Initialize(IPluginContext context)
{
}
}

View File

@@ -0,0 +1,66 @@
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentContext
{
public PluginDesktopComponentContext(
PluginManifest manifest,
string pluginDirectory,
string dataDirectory,
IServiceProvider services,
IReadOnlyDictionary<string, object?> properties,
string componentId,
string? placementId,
double cellSize)
{
ArgumentNullException.ThrowIfNull(manifest);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(properties);
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
Properties = properties;
ComponentId = componentId.Trim();
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
CellSize = Math.Max(1, cellSize);
}
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 double CellSize { get; }
public T? GetService<T>()
{
return (T?)Services.GetService(typeof(T));
}
public bool TryGetProperty<T>(string key, out T? value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
{
value = typedValue;
return true;
}
value = default;
return false;
}
}

View File

@@ -0,0 +1,66 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginDesktopComponentRegistration
{
public PluginDesktopComponentRegistration(
string componentId,
string displayName,
Func<PluginDesktopComponentContext, Control> controlFactory,
string iconKey = "PuzzlePiece",
string category = "Plugins",
int minWidthCells = 2,
int minHeightCells = 2,
bool allowDesktopPlacement = true,
bool allowStatusBarPlacement = false,
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
string? displayNameLocalizationKey = null,
Func<double, double>? cornerRadiusResolver = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
ArgumentException.ThrowIfNullOrWhiteSpace(category);
ArgumentNullException.ThrowIfNull(controlFactory);
ComponentId = componentId.Trim();
DisplayName = displayName.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
IconKey = iconKey.Trim();
Category = category.Trim();
MinWidthCells = Math.Max(1, minWidthCells);
MinHeightCells = Math.Max(1, minHeightCells);
AllowDesktopPlacement = allowDesktopPlacement;
AllowStatusBarPlacement = allowStatusBarPlacement;
ResizeMode = resizeMode;
CornerRadiusResolver = cornerRadiusResolver;
}
public string ComponentId { get; }
public string DisplayName { get; }
public string? DisplayNameLocalizationKey { get; }
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
public string IconKey { get; }
public string Category { get; }
public int MinWidthCells { get; }
public int MinHeightCells { get; }
public bool AllowDesktopPlacement { get; }
public bool AllowStatusBarPlacement { get; }
public PluginDesktopComponentResizeMode ResizeMode { get; }
public Func<double, double>? CornerRadiusResolver { get; }
}

View File

@@ -0,0 +1,7 @@
namespace LanMountainDesktop.PluginSdk;
public enum PluginDesktopComponentResizeMode
{
Proportional = 0,
Free = 1
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class PluginEntranceAttribute : Attribute
{
}

View File

@@ -0,0 +1,9 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginHostPropertyKeys
{
public const string HostApplicationName = "HostApplicationName";
public const string HostVersion = "HostVersion";
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
public const string HostLanguageCode = "HostLanguageCode";
}

View File

@@ -0,0 +1,114 @@
using System.Globalization;
using System.Text.Json;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginLocalizer
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
private readonly Dictionary<string, Dictionary<string, string>> _cache =
new(StringComparer.OrdinalIgnoreCase);
public PluginLocalizer(string pluginDirectory, string? languageCode)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
PluginDirectory = pluginDirectory;
LanguageCode = NormalizeLanguageCode(languageCode);
}
public string PluginDirectory { get; }
public string LanguageCode { get; }
public static PluginLocalizer Create(IPluginContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
}
public static PluginLocalizer Create(PluginDesktopComponentContext context)
{
ArgumentNullException.ThrowIfNull(context);
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
}
public string GetString(string key, string fallback)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
var primaryTable = LoadLanguageTable(LanguageCode);
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
{
var fallbackTable = LoadLanguageTable("en-US");
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return fallback;
}
public string Format(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
}
public static string NormalizeLanguageCode(string? languageCode)
{
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
? "en-US"
: "zh-CN";
}
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
{
ArgumentNullException.ThrowIfNull(properties);
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
rawValue is string languageCode
? NormalizeLanguageCode(languageCode)
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
}
private Dictionary<string, string> LoadLanguageTable(string languageCode)
{
if (_cache.TryGetValue(languageCode, out var table))
{
return table;
}
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
{
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
}
}
}
catch
{
// Keep empty localization table for plugin resilience.
}
_cache[languageCode] = result;
return result;
}
}

View File

@@ -0,0 +1,107 @@
using System.Text.Json;
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginManifest(
string Id,
string Name,
string EntranceAssembly,
string? Description = null,
string? Author = null,
string? Version = null,
string? ApiVersion = null)
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public static PluginManifest Load(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
using var stream = File.OpenRead(manifestPath);
return Load(stream, manifestPath);
}
public static PluginManifest Load(Stream stream, string sourceName)
{
ArgumentNullException.ThrowIfNull(stream);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
if (manifest is null)
{
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
}
return manifest.NormalizeAndValidate(sourceName);
}
public string ResolveEntranceAssemblyPath(string manifestPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
if (Path.IsPathRooted(EntranceAssembly))
{
return Path.GetFullPath(EntranceAssembly);
}
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
}
private PluginManifest NormalizeAndValidate(string manifestPath)
{
var normalized = this with
{
Id = RequireValue(Id, nameof(Id), manifestPath),
Name = RequireValue(Name, nameof(Name), manifestPath),
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
Description = NormalizeOptionalValue(Description),
Author = NormalizeOptionalValue(Author),
Version = NormalizeOptionalValue(Version),
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
};
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
}
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
{
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
}
if (requestedVersion.Major != currentVersion.Major)
{
throw new InvalidOperationException(
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
}
return normalized;
}
private static string RequireValue(string? value, string propertyName, string manifestPath)
{
var normalized = NormalizeOptionalValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException(
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
}
return normalized;
}
private static string? NormalizeOptionalValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.PluginSdk;
public sealed record PluginPackageInstallResult(
PluginManifest Manifest,
bool ReplacedExisting,
bool RestartRequired);

View File

@@ -0,0 +1,12 @@
namespace LanMountainDesktop.PluginSdk;
public static class PluginSdkInfo
{
public const string ApiVersion = "1.0.0";
public const string ManifestFileName = "plugin.json";
public const string PackageFileExtension = ".laapp";
public const string DataDirectoryName = "Data";
public const string RuntimeDirectoryName = ".runtime";
public const string ExtractedPackagesDirectoryName = "packages";
public const string PackagedDataDirectoryName = "data";
}

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
namespace LanMountainDesktop.PluginSdk;
public sealed class PluginSettingsPageRegistration
{
public PluginSettingsPageRegistration(
string id,
string title,
Func<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 string Id { get; }
public string Title { get; }
public int SortOrder { get; }
public Func<Control> ContentFactory { get; }
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>1.0.0</Version>
<PackageVersion>$(Version)</PackageVersion>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,242 @@
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
internal static class Program
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
private static async Task<int> Main(string[] args)
{
var result = new HelperResult();
string? resultPath = null;
try
{
var parsedArgs = ParseArgs(args);
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
!parsedArgs.TryGetValue("result", out resultPath) ||
string.IsNullOrWhiteSpace(sourcePath) ||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
string.IsNullOrWhiteSpace(resultPath))
{
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
}
var fullSourcePath = Path.GetFullPath(sourcePath);
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
resultPath = Path.GetFullPath(resultPath);
if (!File.Exists(fullSourcePath))
{
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
}
var manifest = ReadManifestFromPackage(fullSourcePath);
Directory.CreateDirectory(fullPluginsDirectory);
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
var stagingPath = destinationPath + ".incoming";
DeleteFileWithRetry(stagingPath);
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
MoveWithOverwriteRetry(stagingPath, destinationPath);
result = new HelperResult
{
Success = true,
InstalledPackagePath = destinationPath,
ManifestId = manifest.Id,
ManifestName = manifest.Name
};
}
catch (Exception ex)
{
result = new HelperResult
{
Success = false,
ErrorMessage = ex.Message
};
}
if (!string.IsNullOrWhiteSpace(resultPath))
{
var resultDirectory = Path.GetDirectoryName(resultPath);
if (!string.IsNullOrWhiteSpace(resultDirectory))
{
Directory.CreateDirectory(resultDirectory);
}
await File.WriteAllTextAsync(
resultPath,
JsonSerializer.Serialize(result, new JsonSerializerOptions
{
WriteIndented = true
}),
Encoding.UTF8);
}
return result.Success ? 0 : 1;
}
private static Dictionary<string, string> ParseArgs(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < args.Length; i++)
{
var current = args[i];
if (!current.StartsWith("--", StringComparison.Ordinal))
{
continue;
}
var key = current[2..];
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
{
continue;
}
values[key] = args[++i];
}
return values;
}
private static PluginManifest ReadManifestFromPackage(string packagePath)
{
using var archive = ZipFile.OpenRead(packagePath);
var entries = archive.Entries
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
.ToArray();
if (entries.Length == 0)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
}
if (entries.Length > 1)
{
throw new InvalidOperationException(
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
}
using var stream = entries[0].Open();
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
{
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
foreach (var existingPackagePath in Directory
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
.Select(Path.GetFullPath)
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
{
try
{
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
{
continue;
}
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
DeleteFileWithRetry(existingPackagePath);
}
catch
{
// Ignore unrelated or malformed packages while replacing an install target.
}
}
}
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
{
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
}
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
{
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
}
private static void DeleteFileWithRetry(string filePath)
{
Retry(() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
});
}
private static void Retry(Action action)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
Thread.Sleep(RetryDelays[attempt]);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static string BuildInstalledPackageFileName(string pluginId)
{
var invalidChars = Path.GetInvalidFileNameChars();
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
return fileName + PluginSdkInfo.PackageFileExtension;
}
private static string EnsureTrailingSeparator(string path)
{
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
? path
: path + Path.DirectorySeparatorChar;
}
private sealed class HelperResult
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ManifestId { get; init; }
public string? ManifestName { get; init; }
public string? ErrorMessage { get; init; }
}
}

View File

@@ -1,19 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

7
LanMountainDesktop.slnx Normal file
View File

@@ -0,0 +1,7 @@
<Solution>
<Project Path="LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj" />
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
</Solution>

View File

@@ -16,21 +16,6 @@
<local:ViewLocator/>
</Application.DataTemplates>
<TrayIcon.Icons>
<TrayIcons>
<TrayIcon Icon="/Assets/avalonia-logo.ico"
ToolTipText="LanMountainDesktop">
<TrayIcon.Menu>
<NativeMenu>
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
<NativeMenuItemSeparator />
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
</NativeMenu>
</TrayIcon.Menu>
</TrayIcon>
</TrayIcons>
</TrayIcon.Icons>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />

View File

@@ -1,22 +1,42 @@
using Avalonia;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Diagnostics;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
using AvaloniaWebView;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop;
public partial class App : Application
{
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
private bool _exitCleanupCompleted;
private SettingsWindow? _traySettingsWindow;
private TrayIcons? _trayIcons;
private PluginRuntimeService? _pluginRuntimeService;
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
(Current as App)?._hostApplicationLifecycle;
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
public override void Initialize()
{
AppLogger.Info("App", "Initializing application resources.");
ConfigureWebViewUserDataFolder();
AvaloniaWebViewBuilder.Initialize(default);
AvaloniaXamlLoader.Load(this);
@@ -24,17 +44,29 @@ public partial class App : Application
public override void OnFrameworkInitializationCompleted()
{
AppLogger.Info("App", "Framework initialization completed.");
LinuxDesktopEntryInstaller.EnsureInstalled();
InitializePluginRuntime();
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
InitializeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
desktop.Exit += (_, _) =>
{
AppLogger.Info("App", "Desktop lifetime exit triggered.");
PerformExitCleanup();
};
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
}
base.OnFrameworkInitializationCompleted();
@@ -42,53 +74,54 @@ public partial class App : Application
private void OnTrayExitClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Exit App from the tray menu."));
}
private void OnTrayRestartClick(object? sender, EventArgs e)
private void OnTraySettingsClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
return;
}
if (TryStartCurrentProcess())
Dispatcher.UIThread.Post(() =>
{
desktop.Shutdown();
}
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);
}
private static bool TryStartCurrentProcess()
private void OnTrayRestartClick(object? sender, EventArgs e)
{
try
{
var args = Environment.GetCommandLineArgs();
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
{
return false;
}
var startInfo = new ProcessStartInfo
{
FileName = args[0],
UseShellExecute = false
};
for (var i = 1; i < args.Length; i++)
{
startInfo.ArgumentList.Add(args[i]);
}
Process.Start(startInfo);
return true;
}
catch
{
return false;
}
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
Source: "TrayMenu",
Reason: "User selected Restart App from the tray menu."));
}
private void DisableAvaloniaDataAnnotationValidation()
@@ -125,9 +158,187 @@ public partial class App : Application
userDataFolder,
EnvironmentVariableTarget.Process);
}
catch
catch (Exception ex)
{
// Keep startup resilient if user profile folders are unavailable.
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
}
}
private void InitializePluginRuntime()
{
try
{
_pluginRuntimeService?.Dispose();
_pluginRuntimeService = new PluginRuntimeService();
_pluginRuntimeService.LoadInstalledPlugins();
}
catch (Exception ex)
{
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
}
}
private void InitializeTrayIcon()
{
try
{
DisposeTrayIcon();
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
var trayIcon = new TrayIcon
{
Icon = new WindowIcon(iconStream),
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
Menu = BuildTrayMenu(),
IsVisible = true
};
_trayIcons = [trayIcon];
TrayIcon.SetIcons(this, _trayIcons);
}
catch (Exception ex)
{
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
}
}
private NativeMenu BuildTrayMenu()
{
var menu = new NativeMenu();
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置"));
settingsItem.Click += OnTraySettingsClick;
menu.Items.Add(settingsItem);
menu.Items.Add(new NativeMenuItemSeparator());
var restartItem = new NativeMenuItem(L("tray.menu.restart", "重启应用"));
restartItem.Click += OnTrayRestartClick;
menu.Items.Add(restartItem);
menu.Items.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
exitItem.Click += OnTrayExitClick;
menu.Items.Add(exitItem);
return menu;
}
private void DisposeTrayIcon()
{
if (_trayIcons is null)
{
return;
}
TrayIcon.SetIcons(this, null);
foreach (var trayIcon in _trayIcons)
{
trayIcon.Dispose();
}
_trayIcons = null;
}
private void ActivateMainWindow()
{
Dispatcher.UIThread.Post(() =>
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
if (desktop.MainWindow is not Window mainWindow)
{
return;
}
try
{
if (!mainWindow.IsVisible)
{
mainWindow.Show();
}
if (mainWindow.WindowState == WindowState.Minimized)
{
mainWindow.WindowState = WindowState.Normal;
}
mainWindow.Activate();
mainWindow.Topmost = true;
mainWindow.Topmost = false;
if (mainWindow is MainWindow lanMountainMainWindow)
{
lanMountainMainWindow.ShowSingleInstanceNotice();
}
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
}
}, DispatcherPriority.Send);
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void PerformExitCleanup()
{
if (_exitCleanupCompleted)
{
return;
}
_exitCleanupCompleted = true;
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
try
{
_traySettingsWindow?.Close();
}
catch (Exception ex)
{
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
}
finally
{
_traySettingsWindow = null;
}
try
{
_pluginRuntimeService?.Dispose();
}
catch (Exception ex)
{
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
}
finally
{
_pluginRuntimeService = null;
}
AudioRecorderServiceFactory.DisposeSharedServices();
StudyAnalyticsServiceFactory.DisposeSharedService();
DisposeTrayIcon();
}
private string L(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
return _localizationService.GetString(languageCode, key, fallback);
}
}

View File

@@ -1,22 +1,35 @@
# MiSans Font Notice
# MiSans 字体说明
This app bundles MiSans fonts for consistent cross-device rendering.
## 中文
## Included files
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
### 包含文件
- `MiSans-Regular.ttf`
- `MiSans-Semibold.ttf`
- `MiSans-Bold.ttf`
## Source
### 来源
- 上游仓库https://github.com/dsrkafuu/misans
- 上游所引用的小米字体页面https://hyperos.mi.com/font/zh/
### 许可与使用说明
- 上游脚本或打包仓库使用 Apache-2.0 许可。
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
## English
This project bundles MiSans fonts for more consistent cross-device rendering.
### Sources
- Upstream package repository: https://github.com/dsrkafuu/misans
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
## License and usage notes
- Script/package license in upstream repository: Apache-2.0
- MiSans font copyright and additional usage terms:
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
Please review and comply with the MiSans font terms when distributing this app.
Please review and comply with the MiSans font terms before redistributing this application.

View File

@@ -1,9 +1,12 @@
# Weather Background Assets
# 天气背景资源署名
Weather card background images are sourced from **Pexels** and used under the Pexels license:
https://www.pexels.com/license/
## 中文
## Sources
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
- License: https://www.pexels.com/license/
### 原始来源
- `clear_sky.jpg`
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
- `storm.jpg`
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
## Derived Variants (for widget scene mapping)
### 派生资源
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
- `clear_day.jpg` (from `clear_sky.jpg`)
- `clear_night.jpg` (from `clear_sky.jpg`)
- `cloudy_day.jpg` (from `clear_sky.jpg`)
- `cloudy_night.jpg` (from `clear_sky.jpg`)
- `rain_light.jpg` (from `rain.jpg`)
- `rain_heavy.jpg` (from `rain.jpg`)
- `storm_dark.jpg` (from `storm.jpg`)
- `fog_haze.jpg` (from `storm.jpg`)
- `snow_soft.jpg` (from `snow.jpg`)
- `clear_day.jpg`
- `clear_night.jpg`
- `cloudy_day.jpg`
- `cloudy_night.jpg`
- `rain_light.jpg`
- `rain_heavy.jpg`
- `storm_dark.jpg`
- `fog_haze.jpg`
- `snow_soft.jpg`
## English
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
- License: https://www.pexels.com/license/
Derived variants in this repository are adjusted from the listed base assets for widget presentation.

View File

@@ -1,45 +1,23 @@
# HyperOS3 Weather Assets (Official Xiaomi Package)
# HyperOS3 天气资源署名
## 中文
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
### 提取来源
These assets were extracted from the official Xiaomi Weather APK provided by the user:
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
- Package: `com.miui.weather2` (Mi Weather)
- Extraction date: 2026-03-03
- Package: `com.miui.weather2`
- Extraction date: `2026-03-03`
Extracted source paths inside APK:
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
### 用途说明
Extracted weather icon paths inside APK (`res/*.webp`):
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
- `res/mH1.webp` -> `Icons/icon_snow.webp`
- `res/jB.webp` -> `Icons/icon_sleet.webp`
- `res/Wl.webp` -> `Icons/icon_haze.webp`
- `res/Mg.webp` -> `Icons/icon_windy.webp`
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
- 使用时应遵守小米相关许可与使用条款。
Use only according to Xiaomi's applicable license and usage terms.
### 额外派生资源
## Soft Widget Icon Set (2026-03-05)
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
以下文件为项目内基于上述视觉方向制作的派生素材:
- `Icons/icon_hero_sun_soft.png`
- `Icons/icon_hero_moon_soft.png`
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
- `Icons/icon_mini_snow_soft.png`
- `Icons/icon_mini_fog_soft.png`
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
## English
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
Use these resources only in accordance with Xiaomi's applicable license and usage terms.

View File

@@ -385,6 +385,13 @@ public sealed class ComponentRegistry
return new ComponentRegistry(merged);
}
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
{
var merged = _definitions.Values.ToList();
merged.AddRange(definitions);
return new ComponentRegistry(merged);
}
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
{
return _definitions.TryGetValue(componentId, out definition!);

View File

@@ -0,0 +1,8 @@
using LanMountainDesktop.Services;
namespace LanMountainDesktop.ComponentSystem;
public sealed record DesktopComponentRuntimeContext(
string ComponentId,
string? PlacementId,
IComponentInstanceSettingsStore ComponentSettingsStore);

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentPlacementContextAware
{
void SetComponentPlacementContext(string componentId, string? placementId);
}

View File

@@ -0,0 +1,6 @@
namespace LanMountainDesktop.ComponentSystem;
public interface IComponentRuntimeContextAware
{
void SetComponentRuntimeContext(DesktopComponentRuntimeContext context);
}

View File

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

View File

@@ -1,77 +1,38 @@
# 组件系统模块Component System Module
# 组件系统说明
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
This directory provides the modular foundation for built-in component management and third-party extension integration.
## 中文
## 核心文件职责Core Files
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
Built-in component ID constants (for example `Clock`).
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
Component metadata model (name, category, minimum size, placement permissions).
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
Placement rules (minimum size, status-bar height rule, grid clamping).
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
Registry that merges built-in and extension components.
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
Extension provider interface contract.
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
JSON-based extension loader.
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
## 第三方扩展契约Extension Contract
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
Third parties can provide component definitions via `IComponentExtensionProvider`.
- 当前内置了 JSON 提供者,运行时扫描目录:
Built-in JSON provider scans at runtime:
- `Extensions/Components/*.json`(相对应用输出目录)
`Extensions/Components/*.json` (relative to app output directory)
### 主要职责
## 加载流程Load Flow
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
Register built-in components first via `ComponentRegistry.CreateDefault()`.
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
Merge extension components via `.RegisterExtensions(...)`.
3. 主窗口通过注册中心校验组件合法性与放置权限。
Main window validates component identity and placement permission through the registry.
- 管理内置组件 ID 和元数据
- 约束组件最小尺寸与可放置区域
- 合并内置组件与扩展组件
- 通过 JSON 或扩展提供者接入第三方组件
## JSON 清单格式Manifest Schema
JSON 文件为数组,每一项代表一个组件定义。
The JSON file is an array, where each item represents one component definition.
### 关键文件
```json
[
{
"id": "Weather",
"displayName": "Weather",
"iconKey": "WeatherSunny",
"category": "Status",
"minWidthCells": 1,
"minHeightCells": 1,
"allowStatusBarPlacement": true,
"allowDesktopPlacement": true
}
]
```
- `BuiltInComponentIds.cs`:内置组件 ID 常量
- `DesktopComponentDefinition.cs`:组件元数据模型
- `ComponentPlacementRules.cs`:放置规则
- `ComponentRegistry.cs`:组件注册中心
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
- `Extensions/JsonComponentExtensionProvider.cs`JSON 扩展加载器
字段说明Field notes
- `id`:组件唯一 ID建议英文、稳定不变
Unique component ID (prefer stable English key).
- `displayName`:显示名。
Display name.
- `iconKey`:图标键(由上层 UI 解释)。
Icon key resolved by UI layer.
- `category`:组件分类。
Component category.
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`
Minimum cell size, must satisfy `>= 1`.
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
Whether placing in top status bar is allowed.
- `allowDesktopPlacement`:是否允许放到桌面区域。
Whether placing in desktop area is allowed.
### 扩展方式
## 放置规则摘要Placement Rules Summary
- 最小尺寸约束:`minWidthCells >= 1``minHeightCells >= 1`
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
- 状态栏约束:状态栏组件高度必须为 `1` 格。
Status bar constraint: component height must be exactly `1` cell.
- 越界约束所有组件坐标会被网格边界钳制clamp
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
- 当前默认扫描 `Extensions/Components/*.json`
- 组件清单定义显示名、分类、最小尺寸和可放置区域
- 主程序通过注册中心统一验证组件是否合法
## English
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
### Responsibilities
- manage built-in component IDs and metadata
- enforce placement rules
- merge built-in and extension components
- support third-party registration through JSON or provider contracts

View File

@@ -24,6 +24,12 @@
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.12" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
@@ -36,6 +42,7 @@
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
<PackageReference Include="Downloader" Version="4.1.1" />
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
@@ -51,4 +58,22 @@
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
</ItemGroup>
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
<ItemGroup>
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
</ItemGroup>
<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" />
</Target>
</Project>

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "Settings",
"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.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.back_to_desktop": "Back to Desktop",
"settings.nav_header": "Settings",
"settings.nav.group_desktop": "Desktop",
"settings.nav.group_system": "System",
"settings.nav.group_extensions": "Extensions",
"settings.nav.wallpaper": "Wallpaper",
"settings.nav.grid": "Grid",
"settings.nav.color": "Color",
@@ -109,6 +120,8 @@
"settings.weather.preview_header": "Connection Test",
"settings.weather.preview_desc": "Send one test request to verify current settings.",
"settings.weather.preview_button": "Test Fetch",
"settings.weather.preview_section": "Weather Preview",
"settings.weather.settings_section": "Settings",
"settings.weather.preview_panel_header": "Weather Preview",
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
"settings.weather.refresh_button": "Refresh",
@@ -129,6 +142,15 @@
"settings.weather.status_city_empty": "No city location is configured.",
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
"settings.weather.city_selection_label": "City Selection",
"settings.weather.coordinates_selection_label": "Coordinate Location",
"settings.weather.location_city_summary_desc": "Select the current city used for weather queries.",
"settings.weather.location_coordinates_summary_desc": "Set latitude/longitude and optional location name used for weather queries.",
"settings.weather.location_not_selected": "No location selected",
"settings.weather.alert_list_label": "Exclude List",
"settings.weather.alert_list_desc": "One exclusion rule per line.",
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
"settings.weather.location_header": "Weather Location",
"settings.weather.location_desc": "Set the location used by weather widgets.",
"settings.weather.location_placeholder": "e.g. Beijing",
@@ -229,6 +251,7 @@
"settings.update.status_launching_installer": "Download complete. Launching installer...",
"settings.update.status_installer_missing": "Installer file was not found after download.",
"settings.update.status_installer_started": "Installer started. The app will close for update.",
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
"settings.about.title": "About",
"settings.about.version_format": "Version: {0}",
@@ -237,6 +260,25 @@
"settings.about.startup_header": "Windows Startup",
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
"settings.about.startup_toggle": "Launch at Windows sign-in",
"settings.about.render_mode_header": "App Rendering Mode",
"settings.about.render_mode_desc": "Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.",
"settings.about.render_mode.default": "Default",
"settings.about.render_mode.software": "Software",
"settings.about.render_mode.angle_egl": "angleEgl",
"settings.about.render_mode.wgl": "WGL",
"settings.about.render_mode.vulkan": "Vulkan",
"settings.about.render_mode.unknown": "Unknown",
"settings.about.render_mode.current_label": "Current actual backend",
"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.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.cancel": "Cancel",
"settings.restart_dock.title": "Restart required",
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
"settings.restart_dock.button": "Restart app",
"settings.footer": "LanMountainDesktop Settings",
"filepicker.title": "Select wallpaper",
"filepicker.image_files": "Image files",
@@ -265,12 +307,98 @@
"settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.restore_button": "Show Again",
"settings.launcher.restore_button": "Unhide",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.",
"settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.",
"settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.",
"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.installed_header": "Installed Plugins",
"settings.plugins.installed_desc": "Review installed plugins and remove them here.",
"settings.plugins.import_header": "Install From Package",
"settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.",
"settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.",
"settings.plugins.empty": "No plugins found.",
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
"settings.plugins.state.enabled": "Enabled",
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
"settings.plugins.state.disabled": "Disabled",
"settings.plugins.state.loaded": "Loaded",
"settings.plugins.state.load_failed": "Load failed",
"settings.plugins.toggle_on": "Enabled",
"settings.plugins.toggle_off": "Disabled",
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
"settings.plugins.toggle_state_enabled": "enabled",
"settings.plugins.toggle_state_disabled": "disabled",
"settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}",
"settings.plugins.install_button": "Open .laapp package",
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
"settings.plugins.install_picker_title": "Select plugin package",
"settings.plugins.install_file_type": ".laapp plugin package",
"settings.plugins.install_picker_unavailable": "Storage provider is unavailable.",
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
"settings.plugins.delete_button": "Delete plugin",
"settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
"settings.plugins.delete_failed_format": "Failed to delete plugin: {0}",
"settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}",
"settings.plugins.publisher_format": "Publisher: {0}",
"settings.plugins.publisher_unknown": "Unknown publisher",
"settings.plugins.source_package": ".laapp package",
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
"settings.nav.plugin_market": "Plugin Market",
"settings.plugin_market.title": "Plugin Market",
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
"market.toolbar.search_placeholder": "Search plugins",
"market.toolbar.refresh": "Refresh",
"market.status.loading": "Loading the official plugin market...",
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
"market.status.install_failed_format": "Failed to install plugin: {0}",
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
"market.list.empty": "The plugin market has not been loaded yet.",
"market.list.no_results": "No plugins match the current search.",
"market.card.subtitle_format": "{0} | v{1}",
"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.version": "Version",
"market.detail.api_version": "API Version",
"market.detail.min_host_version": "Minimum Host Version",
"market.detail.installed_version": "Installed Version",
"market.detail.not_installed": "Not installed",
"market.detail.readme": "README",
"market.detail.plugin_information": "Plugin Information",
"market.detail.author_subtitle_format": "By {0}",
"market.detail.package_size": "Package Size",
"market.detail.published_at": "Published At",
"market.detail.updated_at": "Updated At",
"market.detail.tags": "Tags",
"market.detail.project": "Project",
"market.detail.state": "Install State",
"market.detail.market_source": "Market Source",
"market.detail.homepage": "Homepage",
"market.detail.repository": "Repository",
"market.detail.release_notes": "Release Notes",
"market.detail.state.not_installed": "Not installed",
"market.detail.state.update_available": "Update available",
"market.detail.state.installed": "Installed",
"market.detail.unknown": "Unknown",
"market.button.install": "Install",
"market.button.update": "Update",
"market.button.installed": "Installed",
"market.button.installing": "Installing...",
"button.component_library": "Edit Desktop",
"tooltip.component_library": "Edit Desktop",
"component_library.title": "Widgets",
@@ -615,5 +743,10 @@
"placement.fit": "Fit",
"placement.stretch": "Stretch",
"placement.center": "Center",
"placement.tile": "Tile"
}
"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"
}

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "设置",
"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.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立窗口中管理。",
"settings.back_to_desktop": "返回桌面",
"settings.nav_header": "设置选项",
"settings.nav.group_desktop": "桌面",
"settings.nav.group_system": "系统",
"settings.nav.group_extensions": "扩展",
"settings.nav.wallpaper": "壁纸",
"settings.nav.grid": "网格",
"settings.nav.color": "颜色",
@@ -109,6 +120,8 @@
"settings.weather.preview_header": "连接测试",
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
"settings.weather.preview_button": "测试获取",
"settings.weather.preview_section": "天气预览",
"settings.weather.settings_section": "设置",
"settings.weather.preview_panel_header": "天气预览",
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
"settings.weather.refresh_button": "刷新",
@@ -129,6 +142,15 @@
"settings.weather.status_city_empty": "尚未配置城市位置。",
"settings.weather.status_city_format": "模式:{0}{1}Key{2}",
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}Key{3}",
"settings.weather.city_selection_label": "城市选择",
"settings.weather.coordinates_selection_label": "坐标定位",
"settings.weather.location_city_summary_desc": "选择当前所在的城市,用于天气查询。",
"settings.weather.location_coordinates_summary_desc": "设置经纬度与可选的位置名称,用于天气查询。",
"settings.weather.location_not_selected": "未选择位置",
"settings.weather.alert_list_label": "排除列表",
"settings.weather.alert_list_desc": "一行一条排除项。",
"settings.weather.no_tls_toggle": "允许在兼容性较差的网络环境下回退到非 TLS 请求",
"settings.weather.footer_hint": "桌面上的天气组件会共享这里配置的天气位置与预警排除规则。",
"settings.weather.location_header": "天气位置",
"settings.weather.location_desc": "设置天气组件使用的位置。",
"settings.weather.location_placeholder": "例如:北京",
@@ -229,6 +251,7 @@
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
"settings.about.title": "关于",
"settings.about.version_format": "版本号: {0}",
@@ -237,6 +260,25 @@
"settings.about.startup_header": "Windows 自启动",
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
"settings.about.startup_toggle": "登录 Windows 时启动",
"settings.about.render_mode_header": "应用渲染模式",
"settings.about.render_mode_desc": "选择应用渲染后端。更改后需要重启应用生效。不支持的模式会回退到软件渲染。",
"settings.about.render_mode.default": "默认",
"settings.about.render_mode.software": "软件",
"settings.about.render_mode.angle_egl": "angleEgl",
"settings.about.render_mode.wgl": "WGL",
"settings.about.render_mode.vulkan": "Vulkan",
"settings.about.render_mode.unknown": "未知",
"settings.about.render_mode.current_label": "当前实际渲染后端",
"settings.about.render_mode.current_format": "当前后端:{0}",
"settings.about.render_mode.impl_format": "运行时实现:{0}",
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
"settings.restart_dialog.title": "需要重启应用",
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
"settings.restart_dialog.restart": "立即重启",
"settings.restart_dialog.cancel": "取消",
"settings.restart_dock.title": "需要重启应用",
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
"settings.restart_dock.button": "重启应用",
"settings.footer": "LanMountainDesktop 设置",
"filepicker.title": "选择壁纸",
"filepicker.image_files": "图片文件",
@@ -265,12 +307,98 @@
"settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.restore_button": "重新显示",
"settings.launcher.restore_button": "取消隐藏",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。",
"settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。",
"settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.installed_header": "已安装插件",
"settings.plugins.installed_desc": "在这里查看和删除已安装的插件。",
"settings.plugins.import_header": "从安装包导入",
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
"settings.plugins.empty": "未找到插件。",
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
"settings.plugins.state.enabled": "已启用",
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
"settings.plugins.state.disabled": "已禁用",
"settings.plugins.state.loaded": "已加载",
"settings.plugins.state.load_failed": "加载失败",
"settings.plugins.toggle_on": "启用",
"settings.plugins.toggle_off": "禁用",
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
"settings.plugins.toggle_state_enabled": "启用",
"settings.plugins.toggle_state_disabled": "禁用",
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
"settings.plugins.install_button": "打开 .laapp 插件包",
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
"settings.plugins.install_picker_title": "选择插件安装包",
"settings.plugins.install_file_type": ".laapp 插件包",
"settings.plugins.install_picker_unavailable": "文件存储提供程序不可用。",
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
"settings.plugins.delete_button": "删除插件",
"settings.plugins.delete_success_format": "插件“{0}”已暂存删除。重启应用后会完成移除。",
"settings.plugins.delete_failed_format": "删除插件失败:{0}",
"settings.plugins.delete_failed_detail_format": "删除插件“{0}”失败:{1}",
"settings.plugins.publisher_format": "发布者:{0}",
"settings.plugins.publisher_unknown": "未知发布者",
"settings.plugins.source_package": ".laapp 包",
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
"settings.nav.plugin_market": "插件市场",
"settings.plugin_market.title": "插件市场",
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
"market.toolbar.search_placeholder": "搜索插件",
"market.toolbar.refresh": "刷新",
"market.status.loading": "正在加载官方插件市场...",
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
"market.status.load_failed_format": "加载插件市场失败:{0}",
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
"market.status.install_failed_format": "安装插件失败:{0}",
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
"market.list.empty": "插件市场尚未加载。",
"market.list.no_results": "没有匹配当前搜索的插件。",
"market.card.subtitle_format": "{0} | v{1}",
"market.card.loaded": "已加载",
"market.card.pending_restart": "需要重启",
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
"market.detail.author": "作者",
"market.detail.version": "版本",
"market.detail.api_version": "API 版本",
"market.detail.min_host_version": "最低宿主版本",
"market.detail.installed_version": "已安装版本",
"market.detail.not_installed": "未安装",
"market.detail.readme": "README",
"market.detail.plugin_information": "插件信息",
"market.detail.author_subtitle_format": "作者:{0}",
"market.detail.package_size": "包大小",
"market.detail.published_at": "首次发布",
"market.detail.updated_at": "最近更新",
"market.detail.tags": "标签",
"market.detail.project": "项目",
"market.detail.state": "安装状态",
"market.detail.market_source": "市场源",
"market.detail.homepage": "主页",
"market.detail.repository": "仓库",
"market.detail.release_notes": "发布说明",
"market.detail.state.not_installed": "未安装",
"market.detail.state.update_available": "可更新",
"market.detail.state.installed": "已安装",
"market.detail.unknown": "未知",
"market.button.install": "安装",
"market.button.update": "更新",
"market.button.installed": "已安装",
"market.button.installing": "安装中...",
"button.component_library": "桌面编辑",
"tooltip.component_library": "桌面编辑",
"component_library.title": "桌面编辑",
@@ -615,5 +743,10 @@
"placement.fit": "适应",
"placement.stretch": "拉伸",
"placement.center": "居中",
"placement.tile": "平铺"
}
"placement.tile": "平铺",
"single_instance.notice.title": "应用已打开",
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
"single_instance.notice.button": "知道了"
}

View File

@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
public int SettingsTabIndex { get; set; } = 0;
public string? SettingsTabTag { get; set; }
public string LanguageCode { get; set; } = "zh-CN";
public string? TimeZoneId { get; set; }
@@ -46,6 +48,8 @@ public sealed class AppSettingsSnapshot
public bool AutoStartWithWindows { get; set; }
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
@@ -70,15 +74,7 @@ public sealed class AppSettingsSnapshot
public int StatusBarCustomSpacingPercent { get; set; } = 12;
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public List<string> DisabledPluginIds { get; set; } = [];
public AppSettingsSnapshot Clone()
{
@@ -90,35 +86,8 @@ public sealed class AppSettingsSnapshot
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
? new List<string>(PinnedTaskbarActions)
: [];
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
if (DesktopComponentPlacements is not null)
{
foreach (var placement in DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId,
PageIndex = placement.PageIndex,
ComponentId = placement.ComponentId,
Row = placement.Row,
Column = placement.Column,
WidthCells = placement.WidthCells,
HeightCells = placement.HeightCells
});
}
}
clone.DesktopComponentPlacements = placements;
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
? new List<string>(HiddenLauncherFolderPaths)
: [];
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
? new List<string>(HiddenLauncherAppPaths)
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds)
: [];
return clone;

View File

@@ -0,0 +1,43 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class DesktopLayoutSettingsSnapshot
{
public int DesktopPageCount { get; set; } = 1;
public int CurrentDesktopSurfaceIndex { get; set; }
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
public DesktopLayoutSettingsSnapshot Clone()
{
var clone = (DesktopLayoutSettingsSnapshot)MemberwiseClone();
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
if (DesktopComponentPlacements is not null)
{
foreach (var placement in DesktopComponentPlacements)
{
if (placement is null)
{
continue;
}
placements.Add(new DesktopComponentPlacementSnapshot
{
PlacementId = placement.PlacementId,
PageIndex = placement.PageIndex,
ComponentId = placement.ComponentId,
Row = placement.Row,
Column = placement.Column,
WidthCells = placement.WidthCells,
HeightCells = placement.HeightCells
});
}
}
clone.DesktopComponentPlacements = placements;
return clone;
}
}

View File

@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class LauncherSettingsSnapshot
{
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
public List<string> HiddenLauncherAppPaths { get; set; } = [];
public LauncherSettingsSnapshot Clone()
{
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
? new List<string>(HiddenLauncherFolderPaths)
: [];
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
? new List<string>(HiddenLauncherAppPaths)
: [];
return clone;
}
}

View File

@@ -1,75 +1,51 @@
# Desktop Packaging Guide
# 桌面端打包指南
## Prerequisites
- Install `.NET SDK 10`
- Windows installer build only:
- Install `Inno Setup 6` (`ISCC.exe`)
## 中文
## Local packaging commands
本指南说明阑山桌面的本地打包和 CI 打包流程。
### 前置条件
- 安装 .NET SDK 10
- Windows 安装包需要 Inno Setup 6`ISCC.exe`
### 本地打包命令
#### Windows 安装包
### Windows installer (`win-x64`)
```powershell
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/win-x64`
- Installer: `artifacts/installer`
#### Linux 包
### Linux package (`linux-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/linux-x64`
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-linux-x64.zip`
#### macOS 包
### macOS package (`osx-x64`)
```powershell
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
```
Output:
- Published files: `artifacts/publish/osx-x64`
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-osx-x64.zip`
### 产物位置
## Optional script flags
```powershell
# Publish only (skip Windows installer step)
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
- 发布目录:`artifacts/publish/<rid>`
- 安装包或压缩包:`artifacts/installer``artifacts/packages`
# Publish only (skip Linux/macOS zip package step)
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
```
### CI 流程
## Runtime dependency notes
- Linux build does not bundle a native `libvlc` package from NuGet.
- Install VLC runtime on target machine, for example:
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
- macOS packaging target in CI is currently `osx-x64`.
- 工作流文件:`.github/workflows/windows-ci.yml`
- 日常构建会验证桌面端可编译
- 手动触发或 `v*` 标签可生成正式包并上传到 Release
## CI workflow
- Workflow file: `.github/workflows/windows-ci.yml`
- Workflow name: `Desktop CI`
## English
Jobs:
- `Validate Build (Windows)` runs on every push and pull request.
- Package flow runs on manual trigger or `v*` tag push:
- `Resolve Package Version` (single shared version source)
- `Package (Windows)` (`win-x64` installer)
- `Package (Linux)` (`linux-x64` zip)
- `Package (macOS)` (`osx-x64` zip)
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
This guide covers local packaging and CI packaging for LanMountainDesktop.
### Trigger manual packaging
1. Open GitHub Actions.
2. Choose `Desktop CI`.
3. Click `Run workflow`.
4. Optional: set `version` input, for example `1.0.1`.
### Key points
### Trigger by tag
```powershell
git tag v1.0.1
git push origin v1.0.1
```
- use `scripts/package.ps1` with the target runtime identifier
- Windows installer requires Inno Setup
- CI can publish artifacts and attach them to GitHub Releases

View File

@@ -1,6 +1,10 @@
using Avalonia;
using Avalonia.WebView.Desktop;
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
namespace LanMountainDesktop;
@@ -10,14 +14,148 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
AppLogger.Initialize();
RegisterGlobalExceptionLogging();
using var singleInstance = AcquireSingleInstance(args);
if (!singleInstance.IsPrimaryInstance)
{
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
return;
}
var diagnostics = StartupDiagnosticsService.Run(args);
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
try
{
var renderMode = LoadConfiguredRenderMode();
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
App.CurrentSingleInstanceService = singleInstance;
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
AppLogger.Info("Startup", "Application exited normally.");
}
catch (Exception ex)
{
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
throw;
}
finally
{
App.CurrentSingleInstanceService = null;
}
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseDesktopWebView()
.WithInterFont()
.LogToTrace();
if (OperatingSystem.IsWindows())
{
var configuredModes = AppRenderingModeHelper.GetWin32RenderingModes(renderMode);
if (configuredModes is { Length: > 0 })
{
builder = builder.With(new Win32PlatformOptions
{
RenderingMode = configuredModes
});
}
}
return builder;
}
private static SingleInstanceService AcquireSingleInstance(string[] args)
{
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
var singleInstance = SingleInstanceService.CreateDefault();
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
{
return singleInstance;
}
AppLogger.Info(
"Startup",
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
singleInstance.Dispose();
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
while (DateTime.UtcNow < deadline)
{
var retryInstance = SingleInstanceService.CreateDefault();
if (retryInstance.IsPrimaryInstance)
{
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
return retryInstance;
}
retryInstance.Dispose();
Thread.Sleep(150);
}
AppLogger.Warn(
"Startup",
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
return SingleInstanceService.CreateDefault();
}
private static string LoadConfiguredRenderMode()
{
try
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
}
catch (Exception ex)
{
AppLogger.Warn("Startup", "Failed to load configured render mode. Falling back to default.", ex);
return AppRenderingModeHelper.Default;
}
}
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
{
try
{
using var process = Process.GetProcessById(processId);
var remaining = deadlineUtc - DateTime.UtcNow;
if (remaining > TimeSpan.Zero)
{
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
}
}
catch (ArgumentException)
{
// The previous process already exited before we started waiting.
}
catch (Exception ex)
{
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
}
}
private static void RegisterGlobalExceptionLogging()
{
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
AppLogger.Critical(
"UnhandledException",
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
eventArgs.ExceptionObject as Exception);
};
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
{
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
};
}
}

View File

@@ -0,0 +1,169 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace LanMountainDesktop.Services;
public static class AppLogger
{
private static readonly object SyncRoot = new();
private static bool _initialized;
private static string _logDirectory = string.Empty;
private static string _logFilePath = string.Empty;
public static string LogDirectory
{
get
{
EnsureInitialized();
return _logDirectory;
}
}
public static string LogFilePath
{
get
{
EnsureInitialized();
return _logFilePath;
}
}
public static void Initialize()
{
lock (SyncRoot)
{
if (_initialized)
{
return;
}
var preferredDirectory = Path.Combine(AppContext.BaseDirectory, "log");
var fallbackDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"LanMountainDesktop",
"log");
var preferredReady = TryPrepareDirectory(preferredDirectory, out var preferredError);
var fallbackReady = false;
string? fallbackError = null;
if (preferredReady)
{
_logDirectory = preferredDirectory;
}
else
{
fallbackReady = TryPrepareDirectory(fallbackDirectory, out fallbackError);
_logDirectory = fallbackReady ? fallbackDirectory : preferredDirectory;
}
_logFilePath = Path.Combine(_logDirectory, $"app-{DateTime.Now:yyyyMMdd}.log");
_initialized = true;
WriteCore(
"INFO",
"Logger",
$"Initialized. Directory={_logDirectory}; File={_logFilePath}; PreferredDirectory={preferredDirectory}");
if (!preferredReady && !string.IsNullOrWhiteSpace(preferredError))
{
WriteCore(
"WARN",
"Logger",
$"Failed to use program log directory '{preferredDirectory}'. Falling back to '{_logDirectory}'. Reason: {preferredError}");
}
if (!preferredReady && !fallbackReady && !string.IsNullOrWhiteSpace(fallbackError))
{
Trace.WriteLine(
$"[LanMountainDesktop][Logger][ERROR] Failed to initialize fallback log directory '{fallbackDirectory}': {fallbackError}");
}
}
}
public static void Info(string category, string message)
{
Write("INFO", category, message, null);
}
public static void Warn(string category, string message, Exception? exception = null)
{
Write("WARN", category, message, exception);
}
public static void Error(string category, string message, Exception? exception = null)
{
Write("ERROR", category, message, exception);
}
public static void Critical(string category, string message, Exception? exception = null)
{
Write("CRITICAL", category, message, exception);
}
private static void Write(string level, string category, string message, Exception? exception)
{
EnsureInitialized();
var payload = exception is null
? message
: $"{message}{Environment.NewLine}{exception}";
WriteCore(level, category, payload);
}
private static void EnsureInitialized()
{
if (_initialized)
{
return;
}
Initialize();
}
private static void WriteCore(string level, string category, string message)
{
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
var line = $"[{timestamp}] [{level}] [{category}] {message}";
lock (SyncRoot)
{
try
{
var directory = Path.GetDirectoryName(_logFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
File.AppendAllText(_logFilePath, line + Environment.NewLine, Encoding.UTF8);
}
catch (Exception ex)
{
Trace.WriteLine($"[LanMountainDesktop][Logger][ERROR] {ex}");
}
}
Trace.WriteLine(line);
}
private static bool TryPrepareDirectory(string directory, out string? error)
{
try
{
Directory.CreateDirectory(directory);
var probePath = Path.Combine(directory, $".probe-{Guid.NewGuid():N}.tmp");
File.WriteAllText(probePath, "probe");
File.Delete(probePath);
error = null;
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Reflection;
using Avalonia;
using Avalonia.Platform;
namespace LanMountainDesktop.Services;
public readonly record struct AppRenderBackendInfo(
string ActualBackend,
string? ImplementationTypeName);
public static class AppRenderBackendDiagnostics
{
public const string Unknown = "Unknown";
public static AppRenderBackendInfo Detect()
{
var platformGraphics = GetPlatformGraphics();
var implementationTypeName = platformGraphics?.GetType().FullName;
var actualBackend = DetectBackendFromImplementationType(implementationTypeName, platformGraphics is null);
return new AppRenderBackendInfo(actualBackend, implementationTypeName);
}
private static object? GetPlatformGraphics()
{
var currentResolver = typeof(AvaloniaLocator)
.GetProperty("Current", BindingFlags.Public | BindingFlags.Static)
?.GetValue(null);
var getServiceMethod = currentResolver?
.GetType()
.GetMethod(
"GetService",
BindingFlags.Public | BindingFlags.Instance,
binder: null,
types: [typeof(Type)],
modifiers: null);
return getServiceMethod?.Invoke(currentResolver, [typeof(IPlatformGraphics)]);
}
private static string DetectBackendFromImplementationType(string? implementationTypeName, bool isSoftwareFallback)
{
if (isSoftwareFallback)
{
return AppRenderingModeHelper.Software;
}
if (string.IsNullOrWhiteSpace(implementationTypeName))
{
return Unknown;
}
if (implementationTypeName.Contains("Vulkan", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.Vulkan;
}
if (implementationTypeName.Contains("Wgl", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.Wgl;
}
if (implementationTypeName.Contains("Angle", StringComparison.OrdinalIgnoreCase) ||
implementationTypeName.Contains("Egl", StringComparison.OrdinalIgnoreCase))
{
return AppRenderingModeHelper.AngleEgl;
}
return Unknown;
}
}

View File

@@ -0,0 +1,42 @@
using Avalonia;
namespace LanMountainDesktop.Services;
public static class AppRenderingModeHelper
{
public const string Default = "Default";
public const string Software = "Software";
public const string AngleEgl = "AngleEgl";
public const string Wgl = "Wgl";
public const string Vulkan = "Vulkan";
public static string Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Default;
}
return value.Trim().ToUpperInvariant() switch
{
"SOFTWARE" => Software,
"ANGLEEGL" => AngleEgl,
"ANGLE_EGL" => AngleEgl,
"WGL" => Wgl,
"VULKAN" => Vulkan,
_ => Default
};
}
public static Win32RenderingMode[]? GetWin32RenderingModes(string? value)
{
return Normalize(value) switch
{
Software => [Win32RenderingMode.Software],
AngleEgl => [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software],
Wgl => [Win32RenderingMode.Wgl, Win32RenderingMode.Software],
Vulkan => [Win32RenderingMode.Vulkan, Win32RenderingMode.Software],
_ => null
};
}
}

View File

@@ -0,0 +1,205 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public static class AppRestartService
{
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
public static bool TryRestartApplication()
{
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
Source: nameof(AppRestartService),
Reason: "Legacy restart entry point invoked.")) == true;
}
public static bool TryRestartCurrentProcess()
{
try
{
var startInfo = CreateRestartStartInfo();
if (startInfo is null)
{
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
return false;
}
Process.Start(startInfo);
return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
return false;
}
}
public static ProcessStartInfo? CreateRestartStartInfo(
string[]? commandLineArgs = null,
string? processPath = null,
string? entryAssemblyLocation = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingPath(
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
if (IsDotnetHost(resolvedProcessPath))
{
return CreateDotnetStartInfo(
resolvedProcessPath!,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
{
return CreateExecutableStartInfo(
resolvedProcessPath,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
{
return CreateDotnetStartInfo(
"dotnet",
resolvedEntryAssemblyPath,
args);
}
return null;
}
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
{
ArgumentNullException.ThrowIfNull(commandLineArgs);
foreach (var argument in commandLineArgs)
{
if (TryParseRestartParentProcessId(argument, out var processId))
{
return processId;
}
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo;
}
private static ProcessStartInfo? CreateDotnetStartInfo(
string dotnetHostPath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
{
return null;
}
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
AppendRestartParentProcessArgument(startInfo);
return startInfo;
}
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
{
continue;
}
startInfo.ArgumentList.Add(commandLineArgs[i]);
}
}
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
{
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
}
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
{
processId = 0;
if (string.IsNullOrWhiteSpace(argument) ||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return int.TryParse(
argument[RestartParentPidArgumentPrefix.Length..],
out processId) && processId > 0;
}
private static string? NormalizeExistingPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return File.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static bool IsDotnetHost(string? processPath)
{
if (string.IsNullOrWhiteSpace(processPath))
{
return false;
}
var fileName = Path.GetFileName(processPath);
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
{
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
? entryAssemblyPath
: launchPath;
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
}
}

View File

@@ -7,6 +7,8 @@ namespace LanMountainDesktop.Services;
public sealed class AppSettingsService
{
public static event Action<string>? SettingsSaved;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
@@ -21,6 +23,8 @@ public sealed class AppSettingsService
private readonly string _settingsPath;
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
public AppSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
@@ -59,8 +63,9 @@ public sealed class AppSettingsService
return loadedSnapshot.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot();
}
}
@@ -88,10 +93,12 @@ public sealed class AppSettingsService
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
SettingsSaved?.Invoke(InstanceId);
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("AppSettings", $"Failed to save settings to '{_settingsPath}'.", ex);
}
}
@@ -130,8 +137,9 @@ public sealed class AppSettingsService
var json = File.ReadAllText(_settingsPath);
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
}
catch
catch (Exception ex)
{
AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex);
return new AppSettingsSnapshot();
}
}

View File

@@ -2,12 +2,13 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class ComponentSettingsService
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
@@ -18,12 +19,14 @@ public sealed class ComponentSettingsService
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static ComponentSettingsSnapshot? _cachedSnapshot;
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 string _scopedComponentId = string.Empty;
private string _scopedPlacementId = string.Empty;
public ComponentSettingsService()
{
@@ -35,79 +38,266 @@ public sealed class ComponentSettingsService
public ComponentSettingsSnapshot Load()
{
if (HasScopedComponentContext())
{
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
}
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;
}
ComponentSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new ComponentSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
var document = LoadDocumentLocked();
return document.DefaultSettings.Clone();
}
}
catch
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsSnapshot();
}
}
public void Save(ComponentSettingsSnapshot snapshot)
{
if (HasScopedComponentContext())
{
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
return;
}
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
var document = LoadDocumentLocked();
document.DefaultSettings = snapshotToPersist;
PersistDocumentLocked(document);
}
}
catch
catch (Exception ex)
{
// Swallow persistence errors to keep UI interactions uninterrupted.
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot)
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
{
try
{
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 document.DefaultSettings.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new ComponentSettingsSnapshot();
}
}
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
{
var normalizedSnapshot = NormalizeSnapshot(snapshot);
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
Save(normalizedSnapshot);
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);
}
}
public void DeleteForComponent(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
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);
}
}
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
{
try
{
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 JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
}
}
catch (Exception ex)
{
AppLogger.Warn(
"ComponentSettings",
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
ex);
return new T();
}
}
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
return;
}
try
{
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);
}
}
public void DeletePluginSettings(string componentId, string? placementId)
{
var instanceKey = BuildInstanceKey(componentId, placementId);
if (string.IsNullOrWhiteSpace(instanceKey))
{
return;
}
try
{
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);
}
}
public void SetScopedComponentContext(string componentId, string? placementId)
{
_scopedComponentId = componentId?.Trim() ?? string.Empty;
_scopedPlacementId = placementId?.Trim() ?? string.Empty;
}
public void ClearScopedComponentContext()
{
_scopedComponentId = string.Empty;
_scopedPlacementId = string.Empty;
}
public static void ApplyScopedContextToTarget(object? target, string componentId, string? placementId)
{
if (target is null)
{
return;
}
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
foreach (var field in target.GetType().GetFields(flags))
{
if (field.FieldType != typeof(ComponentSettingsService))
{
continue;
}
if (field.GetValue(target) is ComponentSettingsService settingsService)
{
settingsService.SetScopedComponentContext(componentId, placementId);
}
}
foreach (var property in target.GetType().GetProperties(flags))
{
if (property.PropertyType != typeof(ComponentSettingsService) ||
!property.CanRead)
{
continue;
}
if (property.GetValue(target) is ComponentSettingsService settingsService)
{
settingsService.SetScopedComponentContext(componentId, placementId);
}
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
@@ -121,7 +311,7 @@ public sealed class ComponentSettingsService
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot)
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
@@ -135,17 +325,79 @@ public sealed class ComponentSettingsService
return false;
}
private ComponentSettingsSnapshot LoadSnapshotFromDisk()
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);
var snapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
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
catch (Exception ex)
{
return new ComponentSettingsSnapshot();
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
return new ComponentSettingsDocumentSnapshot();
}
}
@@ -198,13 +450,20 @@ public sealed class ComponentSettingsService
return true;
}
catch
catch (Exception ex)
{
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot)
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))
@@ -257,6 +516,40 @@ public sealed class ComponentSettingsService
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)
{
@@ -360,7 +653,30 @@ public sealed class ComponentSettingsService
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
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;
}
private bool HasScopedComponentContext()
{
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
!string.IsNullOrWhiteSpace(_scopedPlacementId);
}
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
@@ -368,6 +684,39 @@ public sealed class ComponentSettingsService
_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;

View File

@@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.ComponentSystem.Extensions;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Services;
public static class DesktopComponentRegistryFactory
{
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
{
var registry = ComponentRegistry
.CreateDefault()
.RegisterExtensions(
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
return pluginDefinitions.Count == 0
? registry
: registry.RegisterComponents(pluginDefinitions);
}
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
ComponentRegistry componentRegistry,
PluginRuntimeService? pluginRuntimeService)
{
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
var registeredIds = new HashSet<string>(
registrations.Select(registration => registration.ComponentId),
StringComparer.OrdinalIgnoreCase);
if (pluginRuntimeService is not null)
{
foreach (var contribution in pluginRuntimeService.DesktopComponents)
{
var registration = contribution.Registration;
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
{
continue;
}
if (!registeredIds.Add(registration.ComponentId))
{
Debug.WriteLine(
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
continue;
}
registrations.Add(new DesktopComponentRuntimeRegistration(
registration.ComponentId,
registration.DisplayNameLocalizationKey,
factoryContext => CreatePluginControl(contribution, factoryContext),
registration.CornerRadiusResolver));
}
}
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
}
private static List<DesktopComponentDefinition> GetPluginDefinitions(
ComponentRegistry baseRegistry,
PluginRuntimeService? pluginRuntimeService)
{
var definitions = new List<DesktopComponentDefinition>();
if (pluginRuntimeService is null)
{
return definitions;
}
var knownIds = new HashSet<string>(
baseRegistry.GetAll().Select(definition => definition.Id),
StringComparer.OrdinalIgnoreCase);
foreach (var contribution in pluginRuntimeService.DesktopComponents)
{
var registration = contribution.Registration;
if (!knownIds.Add(registration.ComponentId))
{
Debug.WriteLine(
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
continue;
}
definitions.Add(new DesktopComponentDefinition(
registration.ComponentId,
registration.DisplayName,
registration.IconKey,
registration.Category,
registration.MinWidthCells,
registration.MinHeightCells,
registration.AllowStatusBarPlacement,
registration.AllowDesktopPlacement,
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
? DesktopComponentResizeMode.Free
: DesktopComponentResizeMode.Proportional));
}
return definitions;
}
private static Control CreatePluginControl(
PluginDesktopComponentContribution contribution,
DesktopComponentControlFactoryContext context)
{
try
{
var pluginContext = new PluginDesktopComponentContext(
contribution.Plugin.Manifest,
contribution.Plugin.Context.PluginDirectory,
contribution.Plugin.Context.DataDirectory,
contribution.Plugin.Context.Services,
contribution.Plugin.Context.Properties,
contribution.Registration.ComponentId,
context.PlacementId,
context.CellSize);
return contribution.Registration.ControlFactory(pluginContext);
}
catch (Exception ex)
{
Debug.WriteLine(
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
return CreatePluginErrorControl(contribution, ex);
}
}
private static Control CreatePluginErrorControl(
PluginDesktopComponentContribution contribution,
Exception exception)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(16),
Padding = new Thickness(12),
Child = new StackPanel
{
Spacing = 6,
Children =
{
new TextBlock
{
Text = contribution.Registration.DisplayName,
FontSize = 14,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = exception.Message,
TextWrapping = TextWrapping.Wrap
}
}
}
};
}
}

View File

@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using LanMountainDesktop.Models;
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");
}
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();
}
}
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; }
}
}

View File

@@ -0,0 +1,98 @@
using System;
using System.IO;
using System.Threading;
namespace LanMountainDesktop.Services;
internal static class FileOperationRetryHelper
{
private static readonly TimeSpan[] RetryDelays =
[
TimeSpan.FromMilliseconds(120),
TimeSpan.FromMilliseconds(250),
TimeSpan.FromMilliseconds(500)
];
public static void CopyWithRetry(string sourceFilePath, string destinationFilePath, bool overwrite, string category)
{
Retry(
() => File.Copy(sourceFilePath, destinationFilePath, overwrite),
category,
$"Copy '{sourceFilePath}' -> '{destinationFilePath}'");
}
public static void MoveWithOverwriteRetry(string sourceFilePath, string destinationFilePath, string category)
{
Retry(
() => File.Move(sourceFilePath, destinationFilePath, overwrite: true),
category,
$"Move '{sourceFilePath}' -> '{destinationFilePath}'");
}
public static void DeleteFileWithRetry(string filePath, string category)
{
Retry(
() =>
{
if (File.Exists(filePath))
{
File.Delete(filePath);
}
},
category,
$"Delete file '{filePath}'");
}
public static void DeleteDirectoryWithRetry(string directoryPath, bool recursive, string category)
{
Retry(
() =>
{
if (Directory.Exists(directoryPath))
{
Directory.Delete(directoryPath, recursive);
}
},
category,
$"Delete directory '{directoryPath}'");
}
private static void Retry(Action action, string category, string operationDescription)
{
Exception? lastException = null;
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
{
try
{
action();
return;
}
catch (Exception ex) when (IsRetriable(ex))
{
lastException = ex;
if (attempt >= RetryDelays.Length)
{
break;
}
var delay = RetryDelays[attempt];
AppLogger.Warn(
category,
$"{operationDescription} failed on attempt {attempt + 1}. Retrying after {delay.TotalMilliseconds:0} ms.",
ex);
Thread.Sleep(delay);
}
}
if (lastException is not null)
{
throw lastException;
}
}
private static bool IsRetriable(Exception exception)
{
return exception is IOException or UnauthorizedAccessException;
}
}

View File

@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
private readonly string _owner;
private readonly string _repo;
private readonly HttpClient _httpClient;
private readonly ResumableDownloadService _downloadService;
private readonly bool _ownsHttpClient;
public GitHubReleaseUpdateService(
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
_ownsHttpClient = false;
}
_downloadService = new ResumableDownloadService(_httpClient);
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
@@ -187,59 +190,37 @@ public sealed class GitHubReleaseUpdateService : IDisposable
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
}
try
var progressAdapter = progress is null
? null
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
var result = await _downloadService.DownloadAsync(
asset.BrowserDownloadUrl,
destinationFilePath,
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
progressAdapter,
cancellationToken);
return result.Success
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
: new UpdateDownloadResult(false, null, result.ErrorMessage);
}
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
string tagName,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(tagName))
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
using var response = await _httpClient.GetAsync(
asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return new UpdateDownloadResult(
false,
null,
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
}
var contentLength = response.Content.Headers.ContentLength ??
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(destinationFilePath);
var buffer = new byte[81920];
long totalRead = 0;
int read;
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
{
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
totalRead += read;
if (contentLength > 0)
{
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
}
}
progress?.Report(1d);
return new UpdateDownloadResult(true, destinationFilePath, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new UpdateDownloadResult(false, null, ex.Message);
return null;
}
var url =
$"https://api.github.com/repos/{_owner}/{_repo}/releases/tags/{Uri.EscapeDataString(tagName.Trim())}";
var responseText = await GetResponseTextAsync(url, cancellationToken);
using var document = JsonDocument.Parse(responseText);
return ParseRelease(document.RootElement);
}
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)

View File

@@ -0,0 +1,75 @@
using System;
using System.Diagnostics;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
{
public bool TryExit(HostApplicationLifecycleRequest? request = null)
{
try
{
AppLogger.Info(
"HostLifecycle",
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
return false;
}
if (Dispatcher.UIThread.CheckAccess())
{
desktop.Shutdown();
}
else
{
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
}
return true;
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex);
return false;
}
}
public bool TryRestart(HostApplicationLifecycleRequest? request = null)
{
try
{
var startInfo = AppRestartService.CreateRestartStartInfo();
if (startInfo is null)
{
AppLogger.Warn(
"HostLifecycle",
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
return false;
}
Process.Start(startInfo);
var exitRequest = request is null
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
: request with
{
Reason = string.IsNullOrWhiteSpace(request.Reason)
? "Restart accepted."
: request.Reason
};
return TryExit(exitRequest);
}
catch (Exception ex)
{
AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex);
return false;
}
}
}

View File

@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
{
return CreateRecorder();
}
public static void DisposeSharedServices()
{
if (SharedRecorderService.IsValueCreated)
{
SharedRecorderService.Value.Dispose();
}
if (SharedStudyMonitoringService.IsValueCreated)
{
SharedStudyMonitoringService.Value.Dispose();
}
}
}
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService

View File

@@ -0,0 +1,22 @@
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public interface IComponentInstanceSettingsStore
{
ComponentSettingsSnapshot Load();
void Save(ComponentSettingsSnapshot snapshot);
ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId);
void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
void DeleteForComponent(string componentId, string? placementId);
T LoadPluginSettings<T>(string componentId, string? placementId) where T : new();
void SavePluginSettings<T>(string componentId, string? placementId, T settings);
void DeletePluginSettings(string componentId, string? placementId);
}

View File

@@ -0,0 +1,245 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class LauncherSettingsService
{
public static event Action<string>? SettingsSaved;
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 LauncherSettingsSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
public LauncherSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public LauncherSettingsSnapshot 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;
}
LauncherSettingsSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = migratedSnapshot;
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new LauncherSettingsSnapshot();
}
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
}
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot();
}
}
public void Save(LauncherSettingsSnapshot snapshot)
{
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
lock (CacheGate)
{
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
}
SettingsSaved?.Invoke(InstanceId);
}
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex);
}
}
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out LauncherSettingsSnapshot 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 LauncherSettingsSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private LauncherSettingsSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
return NormalizeSnapshot(snapshot);
}
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
return new LauncherSettingsSnapshot();
}
}
private bool TryLoadLegacySnapshot(out LauncherSettingsSnapshot snapshot)
{
snapshot = new LauncherSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyLauncherSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new LauncherSettingsSnapshot
{
HiddenLauncherFolderPaths = legacy.HiddenLauncherFolderPaths ?? [],
HiddenLauncherAppPaths = legacy.HiddenLauncherAppPaths ?? []
};
return true;
}
catch (Exception ex)
{
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
return false;
}
}
private DateTime PersistSnapshotToDisk(LauncherSettingsSnapshot 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 LauncherSettingsSnapshot NormalizeSnapshot(LauncherSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new LauncherSettingsSnapshot();
normalized.HiddenLauncherFolderPaths = NormalizeKeys(normalized.HiddenLauncherFolderPaths);
normalized.HiddenLauncherAppPaths = NormalizeKeys(normalized.HiddenLauncherAppPaths);
return normalized;
}
private static List<string> NormalizeKeys(IReadOnlyList<string>? values)
{
if (values is null || values.Count == 0)
{
return [];
}
return values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Select(value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private void UpdateCache(LauncherSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class LegacyLauncherSettingsSnapshot
{
public List<string>? HiddenLauncherFolderPaths { get; set; }
public List<string>? HiddenLauncherAppPaths { get; set; }
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Services;
public static class PendingRestartStateService
{
public const string RenderModeReason = "RenderMode";
public const string PluginCatalogReason = "PluginCatalog";
private static readonly object Gate = new();
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
public static event Action? StateChanged;
public static bool HasPendingRestart
{
get
{
lock (Gate)
{
return PendingReasons.Count > 0;
}
}
}
public static bool HasPendingReason(string reason)
{
lock (Gate)
{
return PendingReasons.Contains(reason);
}
}
public static void SetPending(string reason, bool pending)
{
if (string.IsNullOrWhiteSpace(reason))
{
return;
}
var changed = false;
lock (Gate)
{
changed = pending
? PendingReasons.Add(reason)
: PendingReasons.Remove(reason);
}
if (changed)
{
StateChanged?.Invoke();
}
}
}

View File

@@ -0,0 +1,186 @@
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
internal sealed class PluginsInstallHelperClient
{
private const int UserCanceledUacErrorCode = 1223;
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
string packagePath,
string pluginsDirectory,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
if (!OperatingSystem.IsWindows())
{
return new PluginsInstallHelperResult(
false,
null,
"Elevated helper install is only supported on Windows.");
}
var helperPath = ResolveHelperPath();
if (!File.Exists(helperPath))
{
return new PluginsInstallHelperResult(
false,
null,
$"Plugins install helper was not found at '{helperPath}'.");
}
var resultPath = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop",
"PluginInstallResults",
$"{Guid.NewGuid():N}.json");
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
try
{
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
if (process is null)
{
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
}
await process.WaitForExitAsync(cancellationToken);
var result = await ReadResultAsync(resultPath, cancellationToken);
if (result is not null)
{
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
}
if (process.ExitCode == 0)
{
return new PluginsInstallHelperResult(
false,
null,
"Plugins install helper exited without producing a result file.");
}
return new PluginsInstallHelperResult(
false,
null,
string.Format(
CultureInfo.InvariantCulture,
"Plugins install helper exited with code {0}.",
process.ExitCode));
}
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
{
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
}
finally
{
TryDeleteFile(resultPath);
}
}
private static Process? StartHelperProcess(
string helperPath,
string packagePath,
string pluginsDirectory,
string resultPath)
{
var startInfo = new ProcessStartInfo
{
FileName = helperPath,
Verb = "runas",
UseShellExecute = true,
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
Arguments = string.Create(
CultureInfo.InvariantCulture,
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
};
return Process.Start(startInfo);
}
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
{
if (!File.Exists(resultPath))
{
return null;
}
await using var stream = File.OpenRead(resultPath);
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
}
private static string ResolveHelperPath()
{
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
}
private static string QuoteArgument(string value)
{
if (string.IsNullOrEmpty(value))
{
return "\"\"";
}
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
{
return value;
}
var builder = new StringBuilder();
builder.Append('"');
foreach (var ch in value)
{
if (ch == '"')
{
builder.Append("\\\"");
}
else
{
builder.Append(ch);
}
}
builder.Append('"');
return builder.ToString();
}
private static void TryDeleteFile(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// Ignore temp file cleanup failures.
}
}
private sealed class HelperResultFile
{
public bool Success { get; init; }
public string? InstalledPackagePath { get; init; }
public string? ErrorMessage { get; init; }
}
}
internal sealed record PluginsInstallHelperResult(
bool Success,
string? InstalledPackagePath,
string? ErrorMessage);

View File

@@ -0,0 +1,412 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Downloader;
namespace LanMountainDesktop.Services;
public sealed record DownloadProgressInfo(
long DownloadedBytes,
long? TotalBytes,
double Progress,
bool IsResuming,
bool IsParallel);
public sealed record DownloadOptions(
long? ExpectedSizeBytes = null,
int MaxParallelSegments = 4,
int ParallelThresholdBytes = 8 * 1024 * 1024,
int BufferSize = 128 * 1024);
public sealed record DownloadResult(
bool Success,
string? FilePath,
string? ErrorMessage,
bool UsedResume,
bool UsedParallelDownload);
public sealed class ResumableDownloadService
{
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DestinationGates =
new(StringComparer.OrdinalIgnoreCase);
public ResumableDownloadService(System.Net.Http.HttpClient httpClient)
{
}
public async Task<DownloadResult> DownloadAsync(
string source,
string destinationFilePath,
DownloadOptions? options = null,
IProgress<DownloadProgressInfo>? progress = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(source);
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
var normalizedOptions = NormalizeOptions(options);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var destinationGate = DestinationGates.GetOrAdd(
fullDestinationPath,
static _ => new SemaphoreSlim(1, 1));
await destinationGate.WaitAsync(cancellationToken);
try
{
if (File.Exists(source))
{
return await CopyLocalFileAsync(
source,
fullDestinationPath,
normalizedOptions,
progress,
cancellationToken);
}
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) ||
(sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps))
{
return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false);
}
return await DownloadRemoteFileAsync(
sourceUri,
fullDestinationPath,
normalizedOptions,
progress,
cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AppLogger.Warn(
"Downloader",
$"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.",
ex);
return new DownloadResult(false, null, ex.Message, false, false);
}
finally
{
destinationGate.Release();
}
}
private async Task<DownloadResult> CopyLocalFileAsync(
string sourceFilePath,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var fullSourcePath = Path.GetFullPath(sourceFilePath);
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
var totalBytes = new FileInfo(fullSourcePath).Length;
var tempFilePath = BuildTempFilePath(fullDestinationPath);
PrepareDestination(fullDestinationPath);
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
{
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
CleanupLocalPartialArtifacts(tempFilePath);
return new DownloadResult(true, fullDestinationPath, null, false, false);
}
long existingBytes = 0;
if (File.Exists(tempFilePath))
{
existingBytes = new FileInfo(tempFilePath).Length;
if (existingBytes > totalBytes)
{
CleanupLocalPartialArtifacts(tempFilePath);
existingBytes = 0;
}
}
if (!File.Exists(tempFilePath))
{
await using var tempCreateStream = new FileStream(
tempFilePath,
FileMode.Create,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
}
if (existingBytes >= totalBytes)
{
CompleteLocalCopy(tempFilePath, fullDestinationPath);
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
await using var sourceStream = new FileStream(
fullSourcePath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
await using var destinationStream = new FileStream(
tempFilePath,
FileMode.Open,
FileAccess.Write,
FileShare.Read,
options.BufferSize,
FileOptions.Asynchronous | FileOptions.SequentialScan);
if (existingBytes > 0)
{
sourceStream.Seek(existingBytes, SeekOrigin.Begin);
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
}
await CopyStreamAsync(
sourceStream,
destinationStream,
existingBytes,
totalBytes,
progress,
cancellationToken);
CompleteLocalCopy(tempFilePath, fullDestinationPath);
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
}
private async Task<DownloadResult> DownloadRemoteFileAsync(
Uri sourceUri,
string destinationFilePath,
DownloadOptions options,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
PrepareDestination(destinationFilePath);
if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes))
{
var existingLength = new FileInfo(destinationFilePath).Length;
progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false));
CleanupDownloaderArtifacts(destinationFilePath);
return new DownloadResult(true, destinationFilePath, null, false, false);
}
var usedResume = HasDownloaderResumeArtifacts(destinationFilePath);
var usedParallelDownload = ShouldUseParallelDownload(options);
var configuration = CreateConfiguration(options, usedParallelDownload);
using var downloader = new DownloadService(configuration);
downloader.DownloadProgressChanged += (_, args) =>
{
progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload));
};
using var cancellationRegistration = cancellationToken.Register(() =>
{
try
{
downloader.CancelAsync();
}
catch (Exception ex)
{
AppLogger.Warn(
"Downloader",
$"Failed to cancel Downloader request for '{destinationFilePath}'.",
ex);
}
});
AppLogger.Info(
"Downloader",
$"Starting remote download. Source='{sourceUri}'; Destination='{destinationFilePath}'; Parallel={usedParallelDownload}; ChunkCount={configuration.ChunkCount}; Resume={usedResume}.");
await downloader.DownloadFileTaskAsync(sourceUri.AbsoluteUri, destinationFilePath);
if (!File.Exists(destinationFilePath))
{
throw new FileNotFoundException(
$"Downloader completed without producing '{destinationFilePath}'.",
destinationFilePath);
}
var finalLength = new FileInfo(destinationFilePath).Length;
progress?.Report(new DownloadProgressInfo(
finalLength,
options.ExpectedSizeBytes ?? finalLength,
1d,
usedResume,
usedParallelDownload));
AppLogger.Info(
"Downloader",
$"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}.");
return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload);
}
private static async Task CopyStreamAsync(
Stream sourceStream,
Stream destinationStream,
long initialDownloadedBytes,
long totalBytes,
IProgress<DownloadProgressInfo>? progress,
CancellationToken cancellationToken)
{
var buffer = new byte[128 * 1024];
var downloadedBytes = initialDownloadedBytes;
progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, downloadedBytes / (double)totalBytes, initialDownloadedBytes > 0, false));
while (true)
{
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
if (read <= 0)
{
break;
}
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
downloadedBytes += read;
progress?.Report(new DownloadProgressInfo(
downloadedBytes,
totalBytes,
Math.Clamp(downloadedBytes / (double)totalBytes, 0d, 1d),
initialDownloadedBytes > 0,
false));
}
await destinationStream.FlushAsync(cancellationToken);
}
private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload)
{
return new DownloadConfiguration
{
BufferBlockSize = options.BufferSize,
ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1,
ParallelCount = useParallelDownload ? options.MaxParallelSegments : 1,
ParallelDownload = useParallelDownload,
MinimumSizeOfChunking = options.ParallelThresholdBytes,
MaxTryAgainOnFailure = 3,
ResumeDownloadIfCan = true,
ClearPackageOnCompletionWithFailure = false,
FileExistPolicy = FileExistPolicy.Delete,
DownloadFileExtension = ".part"
};
}
private static DownloadProgressInfo MapProgress(
DownloadProgressChangedEventArgs args,
long? expectedSizeBytes,
bool isResuming,
bool isParallel)
{
var totalBytes = args.TotalBytesToReceive > 0
? args.TotalBytesToReceive
: expectedSizeBytes;
var downloadedBytes = Math.Max(0L, args.ReceivedBytesSize);
var normalizedProgress = args.ProgressPercentage > 1d
? args.ProgressPercentage / 100d
: args.ProgressPercentage;
if (totalBytes is > 0 && normalizedProgress <= 0d)
{
normalizedProgress = downloadedBytes / (double)totalBytes.Value;
}
return new DownloadProgressInfo(
downloadedBytes,
totalBytes,
Math.Clamp(normalizedProgress, 0d, 1d),
isResuming,
isParallel);
}
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
{
if (!File.Exists(destinationFilePath))
{
return false;
}
if (expectedSizeBytes is not > 0)
{
return false;
}
return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value;
}
private static void PrepareDestination(string destinationFilePath)
{
var directory = Path.GetDirectoryName(destinationFilePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
}
private static void CleanupLocalPartialArtifacts(string tempFilePath)
{
if (File.Exists(tempFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(tempFilePath, "Downloader");
}
}
private static void CompleteLocalCopy(string tempFilePath, string destinationFilePath)
{
if (!File.Exists(tempFilePath))
{
return;
}
FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader");
}
private static void CleanupDownloaderArtifacts(string destinationFilePath)
{
var transientFilePath = BuildTempFilePath(destinationFilePath);
var metadataFilePath = BuildPackageFilePath(destinationFilePath);
if (File.Exists(transientFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(transientFilePath, "Downloader");
}
if (File.Exists(metadataFilePath))
{
FileOperationRetryHelper.DeleteFileWithRetry(metadataFilePath, "Downloader");
}
}
private static bool HasDownloaderResumeArtifacts(string destinationFilePath)
{
return File.Exists(BuildTempFilePath(destinationFilePath)) ||
File.Exists(BuildPackageFilePath(destinationFilePath));
}
private static bool ShouldUseParallelDownload(DownloadOptions options)
{
return options.MaxParallelSegments > 1;
}
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
{
var normalized = options ?? new DownloadOptions();
return normalized with
{
MaxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8),
ParallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes),
BufferSize = Math.Max(16 * 1024, normalized.BufferSize)
};
}
private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
private static string BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download";
}

View File

@@ -0,0 +1,151 @@
using System;
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.Services;
public sealed class SingleInstanceService : IDisposable
{
private readonly Mutex _mutex;
private readonly string _pipeName;
private readonly CancellationTokenSource _listenCts = new();
private bool _ownsMutex;
private bool _disposed;
private Task? _listenTask;
private SingleInstanceService(string mutexName, string pipeName)
{
_mutex = new Mutex(initiallyOwned: false, mutexName);
_pipeName = pipeName;
try
{
_ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
}
catch (AbandonedMutexException)
{
_ownsMutex = true;
}
}
public bool IsPrimaryInstance => _ownsMutex;
public static SingleInstanceService CreateDefault()
{
const string appId = "LanMountainDesktop";
var userName = Environment.UserName;
var scopeSeed = $"{appId}:{userName}";
var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
var suffix = scopeHash[..16];
var mutexName = OperatingSystem.IsWindows()
? $"Local\\{appId}.SingleInstance.{suffix}"
: $"{appId}.SingleInstance.{suffix}";
return new SingleInstanceService(
mutexName,
$"{appId}.Activate.{suffix}");
}
public void StartActivationListener(Action onActivationRequested)
{
ArgumentNullException.ThrowIfNull(onActivationRequested);
if (!_ownsMutex || _disposed || _listenTask is not null)
{
return;
}
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
}
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
{
if (_ownsMutex || _disposed)
{
return false;
}
try
{
using var client = new NamedPipeClientStream(
serverName: ".",
pipeName: _pipeName,
direction: PipeDirection.Out,
options: PipeOptions.Asynchronous);
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
client.WriteByte(1);
client.Flush();
return true;
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
return false;
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_listenCts.Cancel();
try
{
_listenTask?.Wait(TimeSpan.FromSeconds(1));
}
catch
{
// Ignore listener shutdown races during process exit.
}
_listenCts.Dispose();
if (_ownsMutex)
{
try
{
_mutex.ReleaseMutex();
}
catch (ApplicationException)
{
// Ownership may already be lost during shutdown.
}
}
_mutex.Dispose();
}
private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
_pipeName,
PipeDirection.In,
1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
onActivationRequested();
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
namespace LanMountainDesktop.Services;
public sealed class StartupDiagnosticsResult
{
public string ExecutablePath { get; init; } = string.Empty;
public string BaseDirectory { get; init; } = string.Empty;
public string ExecutableName { get; init; } = string.Empty;
public bool IsLegacyExecutableLaunch { get; init; }
public IReadOnlyList<string> FoundLegacyArtifacts { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> DeletedLegacyArtifacts { get; init; } = Array.Empty<string>();
public IReadOnlyList<string> FailedLegacyArtifacts { get; init; } = Array.Empty<string>();
}
public static class StartupDiagnosticsService
{
private const string CurrentExecutableName = "LanMountainDesktop.exe";
private static readonly string[] LegacyArtifactNames =
[
"LanMontainDesktop.exe",
"LanMontainDesktop.dll",
"LanMontainDesktop.deps.json",
"LanMontainDesktop.runtimeconfig.json",
"LanMontainDesktop.pdb",
"LanMontainDesktop.exe.WebView2"
];
public static StartupDiagnosticsResult Run(string[] args)
{
var executablePath = ResolveExecutablePath();
var baseDirectory = AppContext.BaseDirectory;
var executableName = Path.GetFileName(executablePath);
var assemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
var fileVersion = string.Empty;
try
{
if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath))
{
fileVersion = FileVersionInfo.GetVersionInfo(executablePath).FileVersion ?? string.Empty;
}
}
catch
{
// Keep diagnostics best-effort.
}
AppLogger.Info(
"Startup",
$"Application starting. ExecutablePath={executablePath}; BaseDirectory={baseDirectory}; ExecutableName={executableName}; AssemblyVersion={assemblyVersion}; FileVersion={fileVersion}; Args=[{string.Join(", ", args)}]");
var foundLegacyArtifacts = LegacyArtifactNames
.Select(name => Path.Combine(baseDirectory, name))
.Where(path => File.Exists(path) || Directory.Exists(path))
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.ToList();
var deletedLegacyArtifacts = new List<string>();
var failedLegacyArtifacts = new List<string>();
foreach (var legacyArtifact in foundLegacyArtifacts)
{
if (string.Equals(legacyArtifact, executablePath, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (TryDeleteLegacyArtifact(legacyArtifact, out var error))
{
deletedLegacyArtifacts.Add(legacyArtifact);
}
else if (!string.IsNullOrWhiteSpace(error))
{
failedLegacyArtifacts.Add($"{legacyArtifact} ({error})");
}
}
if (foundLegacyArtifacts.Count > 0)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Found legacy artifacts: {string.Join("; ", foundLegacyArtifacts)}");
}
if (deletedLegacyArtifacts.Count > 0)
{
AppLogger.Info(
"StartupDiagnostics",
$"Deleted legacy artifacts: {string.Join("; ", deletedLegacyArtifacts)}");
}
if (failedLegacyArtifacts.Count > 0)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Failed to delete legacy artifacts: {string.Join("; ", failedLegacyArtifacts)}");
}
var isLegacyExecutableLaunch = string.Equals(
executableName,
"LanMontainDesktop.exe",
StringComparison.OrdinalIgnoreCase);
if (isLegacyExecutableLaunch)
{
AppLogger.Warn(
"StartupDiagnostics",
$"Legacy executable launch detected. Current executable should be '{CurrentExecutableName}', but actual executable is '{executableName}'.");
}
return new StartupDiagnosticsResult
{
ExecutablePath = executablePath,
BaseDirectory = baseDirectory,
ExecutableName = executableName,
IsLegacyExecutableLaunch = isLegacyExecutableLaunch,
FoundLegacyArtifacts = foundLegacyArtifacts,
DeletedLegacyArtifacts = deletedLegacyArtifacts,
FailedLegacyArtifacts = failedLegacyArtifacts
};
}
public static void ShowLegacyExecutableWarningIfNeeded(StartupDiagnosticsResult diagnostics)
{
if (!diagnostics.IsLegacyExecutableLaunch)
{
return;
}
var message =
"检测到当前是从旧残留可执行文件启动的。\r\n\r\n" +
$"当前文件: {diagnostics.ExecutableName}\r\n" +
$"当前路径: {diagnostics.ExecutablePath}\r\n\r\n" +
$"请改用 {CurrentExecutableName} 启动,以免继续读取旧残留文件。\r\n" +
$"日志目录: {AppLogger.LogDirectory}";
WindowsNativeDialogService.ShowWarning("LanMountainDesktop 启动诊断", message);
}
private static string ResolveExecutablePath()
{
try
{
return Environment.ProcessPath ??
Process.GetCurrentProcess().MainModule?.FileName ??
string.Empty;
}
catch
{
return string.Empty;
}
}
private static bool TryDeleteLegacyArtifact(string path, out string? error)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
error = null;
return true;
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
error = null;
return true;
}
error = null;
return false;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
}

View File

@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
{
return SharedService.Value;
}
public static void DisposeSharedService()
{
if (SharedService.IsValueCreated)
{
SharedService.Value.Dispose();
}
}
}
public sealed class StudyAnalyticsService : IStudyAnalyticsService
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_disposed = true;
StopTimerLocked();
_samplingTimer.Dispose();
_audioRecorderService.Dispose();
}
}
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
_lastSessionReport = null;
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.Runtime.InteropServices;
namespace LanMountainDesktop.Services;
internal static class WindowsNativeDialogService
{
private const uint Ok = 0x00000000;
private const uint IconInformation = 0x00000040;
private const uint IconWarning = 0x00000030;
public static void ShowInformation(string caption, string message)
{
Show(caption, message, Ok | IconInformation, "NativeDialog");
}
public static void ShowWarning(string caption, string message)
{
Show(caption, message, Ok | IconWarning, "StartupDiagnostics");
}
private static void Show(string caption, string message, uint type, string logCategory)
{
if (!OperatingSystem.IsWindows())
{
return;
}
try
{
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
}
catch (Exception ex)
{
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
}
}
[DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
}

View File

@@ -30,8 +30,9 @@ public sealed class WindowsStartupService
return runKey?.GetValue(ValueName) is string value &&
!string.IsNullOrWhiteSpace(value);
}
catch
catch (Exception ex)
{
AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex);
return false;
}
}
@@ -67,8 +68,9 @@ public sealed class WindowsStartupService
return IsEnabled() == enabled;
}
catch
catch (Exception ex)
{
AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex);
return false;
}
}

View File

@@ -7,11 +7,12 @@ using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -54,9 +55,11 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private const double DialSize = 258;
private const double Center = DialSize / 2;
private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
@@ -112,6 +115,21 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
UpdateClock();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClock
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshFromSettings();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
InitializeDialIfNeeded();
@@ -359,7 +377,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private void LoadClockSettings()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)

View File

@@ -4,11 +4,12 @@ using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidgetSettingsWindow : UserControl
public partial class AnalogClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -28,11 +29,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly TimeZoneService _timeZoneService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClock;
private string _placementId = string.Empty;
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
private string _selectedTimeZoneId = string.Empty;
private string _secondHandMode = ClockSecondHandMode.Tick;
@@ -47,10 +50,29 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
PopulateTimeZoneComboBox();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClock
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
LoadState();
ApplyLocalization();
PopulateTimeZoneComboBox();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
@@ -149,10 +171,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
_selectedTimeZoneId = normalizedId;
_secondHandMode = GetSelectedSecondHandMode();
var snapshot = _componentSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.DesktopClockTimeZoneId = normalizedId;
snapshot.DesktopClockSecondHandMode = _secondHandMode;
_componentSettingsService.Save(snapshot);
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
@@ -12,7 +12,7 @@ using WebViewCore.Events;
namespace LanMountainDesktop.Views.Components;
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
, IDesktopPageVisibilityAwareComponentWidget
, IDesktopPageVisibilityAwareComponentWidget, IDisposable
{
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
private double _currentCellSize = 48;
@@ -22,6 +22,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
private bool _isEditMode;
private bool _isWebViewActive = true;
private readonly WebView2RuntimeAvailability _runtimeAvailability;
private bool _isDisposed;
public BrowserWidget()
{
@@ -48,6 +49,26 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
NavigateTo(DefaultHomeUri);
}
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
SizeChanged -= OnSizeChanged;
ActualThemeVariantChanged -= OnActualThemeVariantChanged;
AttachedToVisualTree -= OnAttachedToVisualTree;
DetachedFromVisualTree -= OnDetachedFromVisualTree;
if (_runtimeAvailability.IsAvailable)
{
BrowserWebView.NavigationStarting -= OnBrowserWebViewNavigationStarting;
}
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);

View File

@@ -10,19 +10,22 @@ using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ClassScheduleSettingsWindow : UserControl
public partial class ClassScheduleSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
private string _activeScheduleId = string.Empty;
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
private string _placementId = string.Empty;
public event EventHandler? SettingsChanged;
@@ -34,10 +37,29 @@ public partial class ClassScheduleSettingsWindow : UserControl
RenderImportedSchedules();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClassSchedule
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
LoadState();
ApplyLocalization();
RenderImportedSchedules();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
LoadState();
ApplyLocalization();
RenderImportedSchedules();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_importedSchedules.Clear();
@@ -299,7 +321,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
private void SaveState()
{
var snapshot = _componentSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.ImportedClassSchedules = _importedSchedules
.Select(item => new ImportedClassScheduleSnapshot
{
@@ -309,7 +331,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
})
.ToList();
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
_componentSettingsService.Save(snapshot);
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -7,12 +7,13 @@ using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private sealed record CourseItemViewModel(
string Name,
@@ -26,7 +27,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
@@ -35,6 +36,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
private IReadOnlyList<CourseItemViewModel> _courseItems = Array.Empty<CourseItemViewModel>();
private bool _isNightVisual = true;
private string _languageCode = "zh-CN";
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
private string _placementId = string.Empty;
public ClassScheduleWidget()
{
@@ -113,10 +116,25 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
RefreshSchedule();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopClassSchedule
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshSchedule();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshSchedule();
}
private void RefreshSchedule()
{
var appSettings = _appSettingsService.Load();
var componentSettings = _componentSettingsService.Load();
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
UpdateHeader(now);

View File

@@ -2,18 +2,21 @@ using System;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyArtworkSettingsWindow : UserControl
public partial class DailyArtworkSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private string _languageCode = "zh-CN";
private bool _suppressEvents;
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
private string _placementId = string.Empty;
public event EventHandler? SettingsChanged;
@@ -24,10 +27,27 @@ public partial class DailyArtworkSettingsWindow : UserControl
ApplyLocalization();
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopDailyArtwork
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
LoadState();
ApplyLocalization();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource);
@@ -59,9 +79,9 @@ public partial class DailyArtworkSettingsWindow : UserControl
}
var source = GetSelectedSource();
var snapshot = _componentSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.DailyArtworkMirrorSource = source;
_componentSettingsService.Save(snapshot);
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
UpdateSourceStatus(source);
SettingsChanged?.Invoke(this, EventArgs.Empty);

View File

@@ -13,12 +13,13 @@ using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
new Dictionary<DayOfWeek, string>
@@ -58,6 +59,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
};
private readonly AppSettingsService _settingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
@@ -67,6 +69,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
private string _placementId = string.Empty;
private string? _currentArtworkSourceUrl;
private string? _currentArtworkImageUrl;
@@ -136,6 +140,21 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopDailyArtwork
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshFromSettings();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
@@ -203,6 +222,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
{
var query = new DailyArtworkQuery(
Locale: _languageCode,
MirrorSource: ResolveMirrorSource(),
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
@@ -640,6 +660,19 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
}
}
private string ResolveMirrorSource()
{
try
{
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
}
catch
{
return DailyArtworkMirrorSources.Overseas;
}
}
private void UpdateDateLabels()
{
var now = DateTime.Now;

View File

@@ -1,4 +1,4 @@
<UserControl xmlns="https://github.com/avaloniaui"
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
@@ -14,9 +14,9 @@
BorderThickness="0"
Background="#C20A0A"
Padding="20,16,20,14">
<Grid RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="*,Auto">
<Canvas x:Name="DayDecorationCanvas"
Grid.RowSpan="3"
Grid.RowSpan="2"
IsVisible="False"
Width="212"
Height="148"
@@ -43,34 +43,32 @@
Data="M8,54 L38,24 L64,52 L8,54 Z" />
</Canvas>
<TextBlock x:Name="QuoteMarkTextBlock"
Text="&#8220;"
Foreground="#5CFAD0B7"
FontSize="96"
FontWeight="SemiBold"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="1,0,0,0"
LineHeight="86" />
<Grid Grid.Row="0" RowDefinitions="Auto,*">
<TextBlock x:Name="QuoteMarkTextBlock"
Text="&#8220;"
Foreground="#5CFAD0B7"
FontSize="72"
FontWeight="SemiBold"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Margin="1,0,0,0"
LineHeight="65" />
<TextBlock x:Name="PoetryContentTextBlock"
Grid.Row="1"
Text="芳草年年惹恨幽。想前事悠悠。"
Foreground="#F8D8A8"
FontSize="54"
FontWeight="Medium"
LineHeight="60"
TextWrapping="Wrap"
VerticalAlignment="Top"
Margin="8,2,0,0" />
<TextBlock x:Name="PoetryContentTextBlock"
Grid.Row="1"
Text="芳草年年惹恨幽。想前事悠悠。"
Foreground="#F8D8A8"
FontSize="54"
FontWeight="Medium"
LineHeight="60"
TextWrapping="Wrap"
MaxLines="2"
VerticalAlignment="Top"
HorizontalAlignment="Left"
Margin="8,2,0,0" />
</Grid>
<Grid x:Name="AuthorPanel"
Grid.Row="2"
ColumnDefinitions="Auto,*"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,6,4,0"
IsHitTestVisible="False">
<Grid Grid.Row="1" ColumnDefinitions="Auto,*" VerticalAlignment="Bottom" Margin="0,6,0,0">
<Border x:Name="AuthorAccent"
Grid.Column="0"
Width="6"
@@ -92,6 +90,7 @@
</Grid>
<TextBlock x:Name="StatusTextBlock"
Grid.Row="0"
Text="Loading..."
IsVisible="False"
Foreground="#D9FFFFFF"
@@ -100,10 +99,10 @@
VerticalAlignment="Top" />
<Button x:Name="RefreshButton"
Grid.RowSpan="3"
Grid.Row="1"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,12,16,0"
VerticalAlignment="Bottom"
Margin="0,8,4,0"
Width="42"
Height="42"
CornerRadius="21"
@@ -113,13 +112,12 @@
Padding="0"
Focusable="False">
<TextBlock x:Name="RefreshGlyphTextBlock"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Text="&#8635;"
Text="&#xE72C;"
FontFamily="{StaticResource SymbolFontFamily}"
FontSize="22"
Foreground="#8C9097"
FontSize="26"
FontWeight="SemiLight"
LineHeight="26" />
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
</Border>

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -45,8 +45,8 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const double MinPoetryFontSize = 12;
private const double MinAuthorFontSize = 10.5;
private const double MinPoetryFontSize = 8;
private const double MinAuthorFontSize = 7;
private readonly record struct TextFitResult(double FontSize, FontWeight FontWeight, double LineHeight);
@@ -109,7 +109,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
0,
0);
AuthorPanel.Margin = new Thickness(0, Math.Clamp(5 * scale, 2, 10), Math.Clamp(4 * scale, 2, 8), 0);
AuthorAccent.Width = Math.Clamp(6 * scale, 3.2, 9.5);
AuthorAccent.Height = Math.Clamp(24 * scale, 12, 34);
AuthorAccent.Margin = new Thickness(0, 0, Math.Clamp(8 * scale, 4, 13), 0);
@@ -351,11 +350,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
AuthorTextBlock.Foreground = CreateBrush("#F4D7A7");
AuthorAccent.Background = CreateBrush("#63F2AF90");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(1 * scale, 0, 3));
DayDecorationCanvas.IsVisible = false;
RefreshButton.IsVisible = true;
@@ -380,11 +374,6 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
AuthorTextBlock.Foreground = CreateBrush("#272D38");
AuthorAccent.Background = CreateBrush("#C8090D");
AuthorPanel.Margin = new Thickness(
0,
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(6 * scale, 2, 10),
Math.Clamp(2 * scale, 0, 4));
DayDecorationCanvas.IsVisible = true;
RefreshButton.IsVisible = true;
@@ -475,83 +464,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
DayDecorationCanvas.IsVisible = showDayDecorations;
RefreshButton.IsVisible = true;
var refreshReservedWidth = RefreshButton.Width + Math.Clamp(8 * scale, 5, 14);
var decorationReservedWidth = showDayDecorations
? Math.Clamp(innerWidth * 0.24, 34, 96)
: 0;
var quoteReservedWidth = QuoteMarkTextBlock.IsVisible
? Math.Clamp(10 * scale, 5, 16)
: 0;
var poemReservedRight = Math.Max(refreshReservedWidth, decorationReservedWidth);
var poemWidth = innerWidth - poemReservedRight - quoteReservedWidth;
var poemMinWidth = Math.Max(66, innerWidth * 0.56);
if (poemWidth < poemMinWidth)
{
poemWidth = poemMinWidth;
}
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth);
var refreshButtonWidth = 42 + Math.Clamp(8 * scale, 5, 14);
var quoteMarkWidth = QuoteMarkTextBlock.IsVisible ? Math.Clamp(10 * scale, 5, 16) : 0;
var poemWidth = innerWidth - quoteMarkWidth - Math.Clamp(12 * scale, 6, 20);
poemWidth = Math.Min(Math.Max(64, poemWidth), innerWidth - Math.Clamp(16 * scale, 8, 24));
var authorMaxLines = innerWidth < Math.Max(_currentCellSize * 5.2, 252) ? 2 : 1;
var authorUnitsTarget = authorMaxLines == 1 ? 20 : 12;
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, authorMaxLines);
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 12, 34);
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.72, MinAuthorFontSize, authorPreferredFontSize);
var authorMinWeight = isNightMode ? 500 : 470;
var authorMaxWeight = isNightMode ? 650 : 600;
authorPrepared = EnsureTextFitsAtMinSize(
preparedText: authorPrepared,
sourceText: _authorRawText,
targetUnits: authorUnitsTarget,
maxLines: authorMaxLines,
maxWidth: authorWidth,
maxHeight: Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
minFontWeight: ToVariableWeight(authorMinWeight),
lineHeightFactor: 1.12);
var authorFit = FitTextStable(
authorPrepared,
authorWidth,
Math.Max(20, innerHeight * (authorMaxLines > 1 ? 0.38 : 0.28)),
minFontSize: authorMinFontSize,
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
maxLines: authorMaxLines,
lineHeightFactor: 1.12,
minWeight: authorMinWeight,
maxWeight: authorMaxWeight);
AuthorTextBlock.Text = authorPrepared;
AuthorTextBlock.TextWrapping = authorMaxLines > 1 ? TextWrapping.Wrap : TextWrapping.NoWrap;
AuthorTextBlock.MaxLines = authorMaxLines;
AuthorTextBlock.MaxWidth = authorWidth;
AuthorTextBlock.FontSize = authorFit.FontSize;
AuthorTextBlock.LineHeight = authorFit.LineHeight;
AuthorTextBlock.FontWeight = authorFit.FontWeight;
AuthorPanel.MaxWidth = authorWidth + AuthorAccent.Width + AuthorAccent.Margin.Right + Math.Clamp(4 * scale, 2, 8);
var authorMeasured = MeasureTextSize(
authorPrepared,
authorFit.FontSize,
authorFit.FontWeight,
authorWidth,
authorFit.LineHeight);
var authorHeight = Math.Min(authorMeasured.Height, authorFit.LineHeight * authorMaxLines);
var authorBlockHeight = Math.Max(authorHeight, AuthorAccent.Height) +
AuthorPanel.Margin.Top +
AuthorPanel.Margin.Bottom +
Math.Clamp(4 * scale, 2, 8);
var poemMaxLines = innerHeight < _currentCellSize * 1.58
? 4
: innerHeight < _currentCellSize * 2.05
? 3
: 2;
var poemMaxLines = 2;
var poemUnitsTarget = EstimateTargetUnitsPerLine(poemWidth, scale, isNightMode);
var poemPrepared = PreparePoetryText(_poetryRawText, poemUnitsTarget, poemMaxLines);
var poemHeight = Math.Max(30, innerHeight - authorBlockHeight);
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 16, 56);
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.72, MinPoetryFontSize, poemPreferredFontSize);
var availablePoemHeight = innerHeight * 0.72;
var poemPreferredFontSize = Math.Clamp((isNightMode ? 34 : 32) * scale, 14, 56);
var poemMinFontSize = Math.Clamp(poemPreferredFontSize * 0.65, MinPoetryFontSize, poemPreferredFontSize);
var poemMinWeight = isNightMode ? 540 : 500;
var poemMaxWeight = isNightMode ? 760 : 680;
poemPrepared = EnsureTextFitsAtMinSize(
@@ -560,19 +485,19 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
targetUnits: poemUnitsTarget,
maxLines: poemMaxLines,
maxWidth: poemWidth,
maxHeight: poemHeight,
maxHeight: availablePoemHeight,
minFontSize: poemMinFontSize,
minFontWeight: ToVariableWeight(poemMinWeight),
lineHeightFactor: 1.1);
lineHeightFactor: 1.12);
var poemFit = FitTextStable(
poemPrepared,
poemWidth,
poemHeight,
availablePoemHeight,
minFontSize: poemMinFontSize,
maxFontSize: Math.Clamp(poemPreferredFontSize * 1.20, poemMinFontSize, 62),
maxLines: poemMaxLines,
lineHeightFactor: 1.10,
lineHeightFactor: 1.12,
minWeight: poemMinWeight,
maxWeight: poemMaxWeight);
@@ -582,6 +507,43 @@ public partial class DailyPoetryWidget : UserControl, IDesktopComponentWidget, I
PoetryContentTextBlock.FontSize = poemFit.FontSize;
PoetryContentTextBlock.LineHeight = poemFit.LineHeight;
PoetryContentTextBlock.FontWeight = poemFit.FontWeight;
var authorWidth = Math.Max(72, Math.Min(innerWidth * (isNightMode ? 0.5 : 0.56), innerWidth - 8));
var authorUnitsTarget = 20;
var authorPrepared = PrepareAuthorText(_authorRawText, authorUnitsTarget, 1);
var authorPreferredFontSize = Math.Clamp((isNightMode ? 25 : 23) * scale, 10, 34);
var authorMinFontSize = Math.Clamp(authorPreferredFontSize * 0.65, MinAuthorFontSize, authorPreferredFontSize);
var authorMinWeight = isNightMode ? 500 : 470;
var authorMaxWeight = isNightMode ? 650 : 600;
authorPrepared = EnsureTextFitsAtMinSize(
preparedText: authorPrepared,
sourceText: _authorRawText,
targetUnits: authorUnitsTarget,
maxLines: 1,
maxWidth: authorWidth,
maxHeight: AuthorAccent.Height,
minFontSize: authorMinFontSize,
minFontWeight: ToVariableWeight(authorMinWeight),
lineHeightFactor: 1.12);
var authorFit = FitTextStable(
authorPrepared,
authorWidth,
AuthorAccent.Height,
minFontSize: authorMinFontSize,
maxFontSize: Math.Clamp(authorPreferredFontSize * 1.15, authorMinFontSize, 42),
maxLines: 1,
lineHeightFactor: 1.12,
minWeight: authorMinWeight,
maxWeight: authorMaxWeight);
AuthorTextBlock.Text = authorPrepared;
AuthorTextBlock.TextWrapping = TextWrapping.NoWrap;
AuthorTextBlock.MaxLines = 1;
AuthorTextBlock.MaxWidth = authorWidth;
AuthorTextBlock.FontSize = authorFit.FontSize;
AuthorTextBlock.LineHeight = authorFit.LineHeight;
AuthorTextBlock.FontWeight = authorFit.FontWeight;
}
private void UpdateRefreshButtonState()

View File

@@ -7,24 +7,65 @@ using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public sealed record DesktopComponentRuntimeRegistration(
string ComponentId,
string DisplayNameLocalizationKey,
Func<Control> ControlFactory,
Func<double, double>? CornerRadiusResolver = null);
public sealed record DesktopComponentControlFactoryContext(
DesktopComponentDefinition Definition,
double CellSize,
TimeZoneService TimeZoneService,
IWeatherInfoService WeatherInfoService,
IRecommendationInfoService RecommendationInfoService,
ICalculatorDataService CalculatorDataService,
IComponentInstanceSettingsStore ComponentSettingsStore,
string? PlacementId = null);
public sealed class DesktopComponentRuntimeRegistration
{
public DesktopComponentRuntimeRegistration(
string componentId,
string? displayNameLocalizationKey,
Func<Control> controlFactory,
Func<double, double>? cornerRadiusResolver = null)
: this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver)
{
}
public DesktopComponentRuntimeRegistration(
string componentId,
string? displayNameLocalizationKey,
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
Func<double, double>? cornerRadiusResolver = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
ArgumentNullException.ThrowIfNull(controlFactory);
ComponentId = componentId.Trim();
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
? null
: displayNameLocalizationKey.Trim();
ControlFactory = controlFactory;
CornerRadiusResolver = cornerRadiusResolver;
}
public string ComponentId { get; }
public string? DisplayNameLocalizationKey { get; }
public Func<DesktopComponentControlFactoryContext, Control> ControlFactory { get; }
public Func<double, double>? CornerRadiusResolver { get; }
}
public sealed class DesktopComponentRuntimeDescriptor
{
private static readonly Func<double, double> DefaultCornerRadiusResolver =
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
private readonly Func<Control> _controlFactory;
private readonly Func<DesktopComponentControlFactoryContext, Control> _controlFactory;
private readonly Func<double, double> _cornerRadiusResolver;
internal DesktopComponentRuntimeDescriptor(
DesktopComponentDefinition definition,
string displayNameLocalizationKey,
Func<Control> controlFactory,
string? displayNameLocalizationKey,
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
Func<double, double>? cornerRadiusResolver)
{
Definition = definition;
@@ -35,16 +76,48 @@ public sealed class DesktopComponentRuntimeDescriptor
public DesktopComponentDefinition Definition { get; }
public string DisplayNameLocalizationKey { get; }
public string? DisplayNameLocalizationKey { get; }
public Control CreateControl(
double cellSize,
TimeZoneService timeZoneService,
IWeatherInfoService weatherInfoService,
IRecommendationInfoService recommendationInfoService,
ICalculatorDataService calculatorDataService)
ICalculatorDataService calculatorDataService,
IComponentInstanceSettingsStore componentSettingsStore,
string? placementId = null)
{
var control = _controlFactory();
var control = _controlFactory(new DesktopComponentControlFactoryContext(
Definition,
cellSize,
timeZoneService,
weatherInfoService,
recommendationInfoService,
calculatorDataService,
componentSettingsStore,
placementId));
var runtimeContext = new DesktopComponentRuntimeContext(
Definition.Id,
placementId,
componentSettingsStore);
if (control is IComponentRuntimeContextAware runtimeContextAwareComponent)
{
runtimeContextAwareComponent.SetComponentRuntimeContext(runtimeContext);
}
if (control is IComponentPlacementContextAware placementAwareComponent)
{
placementAwareComponent.SetComponentPlacementContext(Definition.Id, placementId);
}
if (control is IComponentSettingsStoreAware settingsStoreAwareComponent)
{
settingsStoreAwareComponent.SetComponentSettingsStore(componentSettingsStore);
}
ComponentSettingsService.ApplyScopedContextToTarget(control, Definition.Id, placementId);
if (control is IDesktopComponentWidget sizedComponent)
{
sizedComponent.ApplyCellSize(cellSize);
@@ -109,12 +182,10 @@ public sealed class DesktopComponentRuntimeRegistry
StringComparer.OrdinalIgnoreCase);
}
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
public static IReadOnlyList<DesktopComponentRuntimeRegistration> GetDefaultRegistrations()
{
return new DesktopComponentRuntimeRegistry(
componentRegistry,
new[]
{
return
[
new DesktopComponentRuntimeRegistration(
BuiltInComponentIds.Date,
"component.date",
@@ -295,7 +366,12 @@ public sealed class DesktopComponentRuntimeRegistry
"component.holiday_calendar",
() => new HolidayCalendarWidget(),
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
});
];
}
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
{
return new DesktopComponentRuntimeRegistry(componentRegistry, GetDefaultRegistrations());
}
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)

View File

@@ -9,13 +9,14 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
@@ -25,7 +26,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
private readonly AppSettingsService _settingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
@@ -39,6 +40,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
private bool _autoRefreshEnabled = true;
private string _languageCode = "zh-CN";
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay;
private string _componentId = BuiltInComponentIds.DesktopExtendedWeather;
private string _placementId = string.Empty;
private readonly TextBlock[] _hourlyTempBlocks;
private readonly TextBlock[] _hourlyTimeBlocks;
private readonly Image[] _hourlyIconBlocks;
@@ -173,6 +176,21 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopExtendedWeather
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshFromSettings();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
@@ -935,7 +953,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
try
{
var snapshot = _componentSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
enabled = snapshot.WeatherAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
}

View File

@@ -11,13 +11,14 @@ using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Theme;
namespace LanMountainDesktop.Views.Components;
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private enum WeatherVisualKind
{
@@ -95,7 +96,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
};
private readonly AppSettingsService _settingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
@@ -118,6 +119,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
private bool _isOnActivePage = true;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private string _componentId = BuiltInComponentIds.DesktopHourlyWeather;
private string _placementId = string.Empty;
private readonly TextBlock[] _hourlyTimeBlocks;
private readonly Image[] _hourlyIconBlocks;
private readonly TextBlock[] _hourlyTempBlocks;
@@ -224,6 +227,21 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
}
}
public void SetComponentPlacementContext(string componentId, string? placementId)
{
_componentId = string.IsNullOrWhiteSpace(componentId)
? BuiltInComponentIds.DesktopHourlyWeather
: componentId.Trim();
_placementId = placementId?.Trim() ?? string.Empty;
RefreshFromSettings();
}
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
{
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
RefreshFromSettings();
}
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
{
_ = isEditMode;
@@ -1424,7 +1442,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
try
{
var snapshot = _componentSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
enabled = snapshot.WeatherAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
}

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