Compare commits

..

6 Commits

Author SHA1 Message Date
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
68 changed files with 3692 additions and 740 deletions

4
.gitignore vendored
View File

@@ -488,3 +488,7 @@ nul
/_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

26
LanAirApp/README.md Normal file
View File

@@ -0,0 +1,26 @@
# LanAirApp
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
这里集中放置:
- 插件开发标准
- 插件打包与构建工具
- 插件开发与打包文档
- 示例插件
目录结构:
- `docs/`:插件开发文档、打包文档
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
- `standards/`:插件标准文件与模板
- `tools/`:插件打包与构建工具
面向用户的安装流程:
1. 将插件构建或打包为 `.laapp` 文件。
2. 打开 `设置 -> 插件`
3. 点击 `打开 .laapp 插件包`
4. 选择插件包完成安装。
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin``IPluginContext`、清单模型与扩展注册接口。

View File

@@ -0,0 +1,41 @@
# 插件开发文档
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
## 必需文件
- `plugin.json`
- `plugin.json` 中声明的入口程序集
- 使用插件入口特性标记的入口类型
## 推荐开发流程
1.`LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
3. 实现 `IPlugin` 或继承 `PluginBase`
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
5. 将输出内容打包为 `.laapp` 文件。
## 运行时能力
- 插件可以注册自己的设置页。
- 插件可以注册自己的桌面组件。
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
## 多语言建议
- 插件应当内置 `Localization/zh-CN.json``Localization/en-US.json`
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
## 目录建议
一个标准插件项目建议至少包含:
- `plugin.json`
- `Localization/zh-CN.json`
- `Localization/en-US.json`
- 插件程序集与依赖文件
## 示例项目与工具
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
- 标准模板:`LanAirApp/standards/plugin.template.json`

View File

@@ -0,0 +1,34 @@
# 插件打包文档
LanMountainDesktop 插件的安装包格式固定为 `.laapp`
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
## `.laapp` 格式说明
- 本质上是一个标准 zip 压缩包
- 包根目录必须包含 `plugin.json`
- 包根目录还必须包含入口程序集及其依赖
## 建议打包内容
- `plugin.json`
- `YourPlugin.dll`
- 依赖程序集
- `Localization/zh-CN.json`
- `Localization/en-US.json`
- 插件运行所需的其他资源文件
## 使用打包工具
```powershell
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
```
## 应用内安装流程
1. 打开 `设置 -> 插件`
2. 点击 `打开 .laapp 插件包`
3. 选择要安装的插件包
4. 如果插件注册了设置页或组件,安装后重启应用
## 注意事项
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
- 包内应尽量避免无关开发产物。
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。

View File

@@ -9,14 +9,15 @@
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<PluginPackageOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
<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">

View File

@@ -0,0 +1,79 @@
{
"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.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,16 @@
# LanMountainDesktop.SamplePlugin
这是阑山桌面的**示例开发插件**。
它用于演示以下能力:
- 插件入口与 `plugin.json` 清单
- 插件服务注册
- 插件设置页注册
- 插件桌面组件注册
- 插件内通信与状态更新
- `.laapp` 打包与安装流程
- 插件多语言资源组织方式
如果你要开发自己的插件,建议以这个目录为模板开始。
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。

View File

@@ -0,0 +1,92 @@
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 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)));
}
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,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

@@ -9,35 +9,52 @@ namespace LanMountainDesktop.SamplePlugin;
internal sealed class SamplePluginStatusClockWidget : Border
{
private readonly DispatcherTimer _timer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
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 _titleTextBlock;
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
};
_titleTextBlock = new TextBlock
_subtitleTextBlock = new TextBlock
{
Text = "Plugin Status",
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
HorizontalAlignment = HorizontalAlignment.Left
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
{
@@ -67,55 +84,61 @@ internal sealed class SamplePluginStatusClockWidget : Border
Children =
{
_timeTextBlock,
_titleTextBlock
_subtitleTextBlock
}
},
new Border
{
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(18),
Padding = new Thickness(12),
Child = _statusPanel
}
_statusHost
}
};
Grid.SetRow(((Grid)Child).Children[1], 1);
_timer.Tick += OnTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
var placementText = string.IsNullOrWhiteSpace(context.PlacementId)
? "Preview instance created."
: $"Widget created for placement {context.PlacementId}.";
SamplePluginRuntimeStatus.MarkFrontendReady("Widget frontend surface rendered successfully.");
SamplePluginRuntimeStatus.MarkComponentCreated($"{placementText} Baseline footprint: 4x4.");
RefreshClock();
RefreshClock(_clockService.CurrentTime);
UpdateSubtitle();
RefreshStatusPanel();
ApplyScale();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshClock();
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();
_timer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_timer.Stop();
}
foreach (var subscription in _subscriptions)
{
subscription.Dispose();
}
private void OnTimerTick(object? sender, EventArgs e)
{
RefreshClock();
RefreshStatusPanel();
_subscriptions.Clear();
if (string.IsNullOrWhiteSpace(_instanceId))
{
return;
}
_stateService.UnregisterComponentInstance(_instanceId);
_instanceId = null;
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
@@ -124,24 +147,49 @@ internal sealed class SamplePluginStatusClockWidget : Border
RefreshStatusPanel();
}
private void RefreshClock()
private void SubscribeToPluginBus()
{
_timeTextBlock.Text = DateTime.Now.ToString("HH:mm:ss");
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.072, 11, 16);
var detailSize = Math.Clamp(basis * 0.055, 10, 13);
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
foreach (var entry in snapshot.StatusEntries)
{
var palette = GetPalette(entry.State);
var summaryText = $"{entry.Summary} - {entry.UpdatedAt.LocalDateTime:HH:mm:ss}";
_statusPanel.Children.Add(new Border
{
Background = new SolidColorBrush(palette.Background),
@@ -151,6 +199,7 @@ internal sealed class SamplePluginStatusClockWidget : Border
Padding = new Thickness(10, 8),
Child = new Grid
{
RowDefinitions = new RowDefinitions("Auto,Auto"),
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
@@ -173,12 +222,19 @@ internal sealed class SamplePluginStatusClockWidget : Border
},
new TextBlock
{
Text = summaryText,
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
}
}
}
@@ -187,6 +243,8 @@ internal sealed class SamplePluginStatusClockWidget : Border
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);
}
}
@@ -196,7 +254,10 @@ internal sealed class SamplePluginStatusClockWidget : Border
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);
_titleTextBlock.FontSize = Math.Clamp(basis * 0.07, 12, 18);
_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()
@@ -224,4 +285,14 @@ internal sealed class SamplePluginStatusClockWidget : Border
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,11 @@
# 示例插件
本目录用于存放阑山桌面的示例开发插件。
当前示例:
- `LanMountainDesktop.SamplePlugin`
说明:
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`

View File

@@ -0,0 +1,11 @@
# 插件标准文件
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
当前标准:
- 安装包扩展名:`.laapp`
- 插件清单文件名:`plugin.json`
- 多语言资源目录:`Localization/`
- 建议内置语言文件:`zh-CN.json``en-US.json`
创建新插件时,建议优先参考本目录中的模板文件。

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

@@ -18,6 +18,9 @@ public interface IPluginContext
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

@@ -8,6 +8,7 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="_build_verify_*\**\*.cs" />
<PackageReference Include="Avalonia" Version="11.3.12" />
</ItemGroup>

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

@@ -1,66 +0,0 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginHeartbeatService? _heartbeatService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var hostName = context.TryGetProperty<string>("HostApplicationName", out var configuredHostName) &&
!string.IsNullOrWhiteSpace(configuredHostName)
? configuredHostName
: "UnknownHost";
var version = context.Manifest.Version ?? "dev";
SamplePluginRuntimeStatus.Reset(hostName, version, context.DataDirectory);
var message =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {version}).";
try
{
File.AppendAllText(
Path.Combine(context.DataDirectory, "sample-plugin.log"),
message + Environment.NewLine);
SamplePluginRuntimeStatus.MarkBackendReady(
$"Plugin entry initialized successfully. Host: {hostName}; Version: {version}");
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
throw;
}
_heartbeatService = new SamplePluginHeartbeatService(context.DataDirectory);
_heartbeatService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
"Plugin Status",
() => new SamplePluginSettingsView(context)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
"Sample Plugin Status Clock",
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: "Plugins",
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
}
public void Dispose()
{
_heartbeatService?.Dispose();
_heartbeatService = null;
}
}

View File

@@ -1,251 +0,0 @@
using System.Globalization;
using System.IO;
using System.Threading;
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 static class SamplePluginRuntimeStatus
{
private static readonly object Gate = new();
private static SamplePluginStatusEntry _frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Frontend surfaces have not been created yet.");
private static SamplePluginStatusEntry _component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
private static SamplePluginStatusEntry _backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Pending,
"Pending",
"Plugin initialization has not finished yet.");
private static SamplePluginStatusEntry _service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service has not started yet.");
public static void Reset(string hostName, string version, string dataDirectory)
{
lock (Gate)
{
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Pending,
"Pending",
"Waiting for the settings page or widget surface to render.");
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Pending,
"Pending",
"The 4x4 component has not been created yet.");
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
$"Plugin initialized. Host: {hostName}; Version: {version}; Data: {dataDirectory}");
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Pending,
"Pending",
"Heartbeat service is starting.");
}
}
public static void MarkFrontendReady(string detail)
{
lock (Gate)
{
_frontend = CreateEntry(
"frontend",
"Frontend",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
}
}
public static void MarkComponentCreated(string detail)
{
lock (Gate)
{
_component = CreateEntry(
"component",
"Component",
SamplePluginHealthState.Healthy,
"Created",
detail);
}
}
public static void MarkBackendReady(string detail)
{
lock (Gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Healthy,
"Healthy",
detail);
}
}
public static void MarkBackendFaulted(string detail)
{
lock (Gate)
{
_backend = CreateEntry(
"backend",
"Backend",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static void MarkServiceHeartbeat(DateTimeOffset timestamp)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Healthy,
"Healthy",
$"Heartbeat service is running. Last heartbeat: {timestamp.LocalDateTime:HH:mm:ss}");
}
}
public static void MarkServiceFaulted(string detail)
{
lock (Gate)
{
_service = CreateEntry(
"service",
"Service",
SamplePluginHealthState.Faulted,
"Faulted",
detail);
}
}
public static IReadOnlyList<SamplePluginStatusEntry> GetSnapshot()
{
lock (Gate)
{
return
[
_frontend,
_component,
_backend,
_service
];
}
}
private static SamplePluginStatusEntry CreateEntry(
string key,
string title,
SamplePluginHealthState state,
string summary,
string detail)
{
return new SamplePluginStatusEntry(
key,
title,
state,
summary,
detail,
DateTimeOffset.Now);
}
}
internal sealed class SamplePluginHeartbeatService : IDisposable
{
private readonly string _heartbeatFilePath;
private readonly Timer _timer;
private int _disposed;
public SamplePluginHeartbeatService(string dataDirectory)
{
Directory.CreateDirectory(dataDirectory);
_heartbeatFilePath = Path.Combine(dataDirectory, "service-heartbeat.txt");
_timer = new Timer(OnTimerTick);
}
public void Start()
{
PublishHeartbeat();
_timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_timer.Dispose();
}
private void OnTimerTick(object? state)
{
PublishHeartbeat();
}
private void PublishHeartbeat()
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
var now = DateTimeOffset.Now;
try
{
File.WriteAllText(
_heartbeatFilePath,
now.ToString("O", CultureInfo.InvariantCulture));
SamplePluginRuntimeStatus.MarkServiceHeartbeat(now);
}
catch (Exception ex)
{
SamplePluginRuntimeStatus.MarkServiceFaulted($"Heartbeat write failed: {ex.Message}");
}
}
}

View File

@@ -1,190 +0,0 @@
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 DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromSeconds(1)
};
private readonly IPluginContext _context;
private readonly TextBlock _summaryTextBlock;
private readonly StackPanel _statusPanel;
public SamplePluginSettingsView(IPluginContext context)
{
_context = context;
_summaryTextBlock = new TextBlock
{
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
TextWrapping = TextWrapping.Wrap
};
_statusPanel = new StackPanel
{
Spacing = 10
};
SamplePluginRuntimeStatus.MarkFrontendReady("Settings page rendered successfully.");
_refreshTimer.Tick += OnRefreshTimerTick;
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 = "Sample Plugin Runtime Status",
FontSize = 22,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
_summaryTextBlock,
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 = _statusPanel
}
}
}
};
RefreshStatuses();
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
RefreshStatuses();
_refreshTimer.Start();
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_refreshTimer.Stop();
}
private void OnRefreshTimerTick(object? sender, EventArgs e)
{
RefreshStatuses();
}
private void RefreshStatuses()
{
_summaryTextBlock.Text =
$"Plugin Id: {_context.Manifest.Id}\nVersion: {_context.Manifest.Version ?? "dev"}\nData Path: {_context.DataDirectory}";
_statusPanel.Children.Clear();
foreach (var entry in SamplePluginRuntimeStatus.GetSnapshot())
{
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 =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
ColumnSpacing = 8,
Children =
{
new Border
{
Width = 10,
Height = 10,
CornerRadius = new CornerRadius(999),
Background = new SolidColorBrush(palette.Dot),
VerticalAlignment = VerticalAlignment.Center
},
new TextBlock
{
Text = entry.Title,
FontSize = 15,
FontWeight = FontWeight.SemiBold,
Foreground = Brushes.White
},
new TextBlock
{
Text = entry.Summary,
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
HorizontalAlignment = HorizontalAlignment.Right
}
}
},
new TextBlock
{
Text = entry.Detail,
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}",
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
}
}
}
});
var row = (Grid)((StackPanel)((Border)_statusPanel.Children[^1]).Child!).Children[0];
Grid.SetColumn(row.Children[1], 1);
Grid.SetColumn(row.Children[2], 2);
}
}
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"))
};
}
}

View File

@@ -5,7 +5,9 @@ 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
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
EndProject
@@ -23,6 +25,10 @@ Global
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU

View File

@@ -96,45 +96,7 @@ public partial class App : Application
private void OnTrayRestartClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
{
return;
}
if (TryStartCurrentProcess())
{
desktop.Shutdown();
}
}
private static bool TryStartCurrentProcess()
{
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;
}
AppRestartService.TryRestartApplication();
}
private void DisableAvaloniaDataAnnotationValidation()

View File

@@ -237,6 +237,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",
@@ -288,6 +307,15 @@
"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.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.source_package": ".laapp package",
"settings.plugins.source_manifest": "Loose manifest",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",

View File

@@ -237,6 +237,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": "图片文件",
@@ -288,6 +307,15 @@
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
"settings.plugins.toggle_state_enabled": "启用",
"settings.plugins.toggle_state_disabled": "禁用",
"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.source_package": ".laapp 包",
"settings.plugins.source_manifest": "散装清单",
"settings.plugins.subtitle_format": "{0} | {1} | {2}",

View File

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

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
using System;
namespace LanMountainDesktop;
@@ -10,14 +11,42 @@ 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()
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
.StartWithClassicDesktopLifetime(args);
// 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 string LoadConfiguredRenderMode()
{
try
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
}
catch
{
return AppRenderingModeHelper.Default;
}
}
}

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,171 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Services;
public static class AppRestartService
{
public static bool TryRestartApplication()
{
if (!TryRestartCurrentProcess())
{
return false;
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
return 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;
}
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);
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);
return startInfo;
}
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
startInfo.ArgumentList.Add(commandLineArgs[i]);
}
}
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

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

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using FluentIcons.Avalonia;
using FluentIcons.Common;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -276,26 +278,7 @@ public partial class MainWindow
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
PluginSystemSettingsExpander.Description = L(
"settings.plugins.runtime_desc",
"Review plugin runtime state and load results.");
PluginSystemDescriptionTextBlock.Text = L(
"settings.plugins.runtime_hint",
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L(
"settings.plugins.runtime_status",
"Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L(
"settings.plugins.installed_desc",
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
PluginRestartHintTextBlock.Text = L(
"settings.plugins.restart_hint",
"Plugin enable state changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
ApplyPluginSettingsLocalization();
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
@@ -315,6 +298,17 @@ public partial class MainWindow
AboutStartupSettingsExpander.Description = L(
"settings.about.startup_desc",
"Launch the app automatically when signing in to Windows.");
AboutRenderModeSettingsExpander.Header = L("settings.about.render_mode_header", "Rendering Mode");
AboutRenderModeSettingsExpander.Description = L(
"settings.about.render_mode_desc",
"Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.");
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Default, L("settings.about.render_mode.default", "Default"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Software, L("settings.about.render_mode.software", "Software"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
UpdateCurrentRenderBackendStatus();
UpdatePendingRestartDock();
if (WallpaperPlacementComboBox?.ItemCount >= 5)
{
@@ -341,6 +335,19 @@ public partial class MainWindow
UpdateWallpaperDisplay();
}
private void SetAppRenderModeComboItemContent(string tag, string content)
{
var item = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(candidate =>
string.Equals(candidate.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
item.Content = content;
}
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -0,0 +1,42 @@
using System;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private void UpdateCurrentRenderBackendStatus()
{
var backendInfo = AppRenderBackendDiagnostics.Detect();
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
CurrentRenderBackendLabelTextBlock.Text = L(
"settings.about.render_mode.current_label",
"Current actual backend");
CurrentRenderBackendValueTextBlock.Text = Lf(
"settings.about.render_mode.current_format",
"Current backend: {0}",
localizedBackend);
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
? L(
"settings.about.render_mode.impl_unavailable",
"Runtime implementation is unavailable.")
: Lf(
"settings.about.render_mode.impl_format",
"Runtime implementation: {0}",
backendInfo.ImplementationTypeName);
}
private string GetLocalizedRenderBackendName(string renderBackend)
{
return renderBackend switch
{
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.unknown", "Unknown")
};
}
}

View File

@@ -0,0 +1,112 @@
using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private bool _isRestartPromptVisible;
private void OnPendingRestartStateChanged()
{
if (Dispatcher.UIThread.CheckAccess())
{
UpdatePendingRestartDock();
return;
}
Dispatcher.UIThread.Post(UpdatePendingRestartDock);
}
private void UpdatePendingRestartDock()
{
PendingRestartDock.IsVisible = PendingRestartStateService.HasPendingRestart;
PendingRestartDockTitleTextBlock.Text = L("settings.restart_dock.title", "Restart required");
PendingRestartDockDescriptionTextBlock.Text = L(
"settings.restart_dock.description",
"Some changes will take effect after restarting the app.");
PendingRestartDockButtonTextBlock.Text = L("settings.restart_dock.button", "Restart app");
}
private async void OnPendingRestartDockButtonClick(object? sender, RoutedEventArgs e)
{
await ShowGenericRestartPromptAsync();
}
private Task ShowRenderModeRestartPromptAsync(string selectedMode)
{
var message = Lf(
"settings.restart_dialog.render_mode_message",
"Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
GetLocalizedAppRenderModeDisplayName(_runningAppRenderMode),
GetLocalizedAppRenderModeDisplayName(selectedMode));
return ShowRestartPromptCoreAsync(message);
}
private Task ShowGenericRestartPromptAsync()
{
return ShowRestartPromptCoreAsync(L(
"settings.restart_dock.description",
"Some changes will take effect after restarting the app."));
}
private async Task ShowRestartPromptCoreAsync(string message)
{
if (_isRestartPromptVisible)
{
return;
}
_isRestartPromptVisible = true;
try
{
var dialog = new ContentDialog
{
Title = L("settings.restart_dialog.title", "Restart required"),
Content = message,
PrimaryButtonText = L("settings.restart_dialog.restart", "Restart now"),
CloseButtonText = L("settings.restart_dialog.cancel", "Cancel"),
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary)
{
if (!AppRestartService.TryRestartApplication())
{
UpdatePendingRestartDock();
}
return;
}
UpdatePendingRestartDock();
}
finally
{
_isRestartPromptVisible = false;
}
}
private string GetLocalizedAppRenderModeDisplayName(string renderMode)
{
if (renderMode == AppRenderBackendDiagnostics.Unknown)
{
return L("settings.about.render_mode.unknown", "Unknown");
}
return AppRenderingModeHelper.Normalize(renderMode) switch
{
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.default", "Default")
};
}
}

View File

@@ -978,6 +978,7 @@ public partial class MainWindow
InitializeWeatherSettings(snapshot);
_ = _componentSettingsService.Load();
InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot);
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot);
@@ -1040,6 +1041,7 @@ public partial class MainWindow
snapshot.WeatherIconPackId = _weatherIconPackId;
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
snapshot.AutoStartWithWindows = _autoStartWithWindows;
snapshot.AppRenderMode = _selectedAppRenderMode;
snapshot.AutoCheckUpdates = _autoCheckUpdates;
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;
@@ -1220,6 +1222,61 @@ public partial class MainWindow
}
}
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
{
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
_runningAppRenderMode = ResolveActiveAppRenderModeForUi(_selectedAppRenderMode);
var renderModeForUi = PendingRestartStateService.HasPendingReason(PendingRestartStateService.RenderModeReason)
? _selectedAppRenderMode
: _runningAppRenderMode;
if (AppRenderModeComboBox is null)
{
return;
}
_suppressAppRenderModeSelectionEvents = true;
try
{
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
SelectAppRenderModeInUi(renderModeForUi);
}
finally
{
_suppressAppRenderModeSelectionEvents = false;
}
}
private void SelectAppRenderModeInUi(string renderMode)
{
if (AppRenderModeComboBox is null)
{
return;
}
AppRenderModeComboBox.SelectedIndex = GetAppRenderModeComboBoxIndex(renderMode);
}
private static int GetAppRenderModeComboBoxIndex(string renderMode)
{
return AppRenderingModeHelper.Normalize(renderMode) switch
{
AppRenderingModeHelper.Software => 1,
AppRenderingModeHelper.AngleEgl => 2,
AppRenderingModeHelper.Wgl => 3,
AppRenderingModeHelper.Vulkan => 4,
_ => 0
};
}
private static string ResolveActiveAppRenderModeForUi(string configuredRenderMode)
{
var detectedRenderMode = AppRenderBackendDiagnostics.Detect().ActualBackend;
return string.Equals(detectedRenderMode, AppRenderBackendDiagnostics.Unknown, StringComparison.Ordinal)
? configuredRenderMode
: AppRenderingModeHelper.Normalize(detectedRenderMode);
}
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
{
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -1487,6 +1544,33 @@ public partial class MainWindow
PersistSettings();
}
private void OnAppRenderModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressAppRenderModeSelectionEvents || AppRenderModeComboBox is null)
{
return;
}
var selectedMode = AppRenderingModeHelper.Normalize(
TryGetSelectedComboBoxTag(AppRenderModeComboBox));
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
{
return;
}
_selectedAppRenderMode = selectedMode;
PersistSettings();
var requiresRestart = !string.Equals(_runningAppRenderMode, selectedMode, StringComparison.Ordinal);
PendingRestartStateService.SetPending(PendingRestartStateService.RenderModeReason, requiresRestart);
UpdatePendingRestartDock();
if (requiresRestart)
{
_ = ShowRenderModeRestartPromptAsync(selectedMode);
}
}
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
{
if (_isWeatherSearchInProgress || WeatherCitySearchTextBox is null || WeatherCityResultsComboBox is null)
@@ -2640,7 +2724,12 @@ public partial class MainWindow
// --- AboutSettingsPage ---
internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;
@@ -2652,14 +2741,6 @@ public partial class MainWindow
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
// --- PluginSettingsPage (Added for completeness) ---
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -104,7 +104,7 @@
ClipToBounds="True"
BorderThickness="0"
PointerWheelChanged="OnDesktopPagesPointerWheelChanged">
<Grid x:Name="SettingsContentPagesHost">
<Grid>
<Grid x:Name="DesktopPagesHost"
HorizontalAlignment="Left"
VerticalAlignment="Top">
@@ -377,88 +377,138 @@
<Border Classes="mica-strong"
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
Padding="18">
<ui:NavigationView x:Name="SettingsNavView"
PaneDisplayMode="Left"
IsSettingsVisible="False"
OpenPaneLength="220"
SelectionChanged="OnSettingsNavSelectionChanged">
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper" ToolTip.Tip="壁纸">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid" ToolTip.Tip="网格">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color" ToolTip.Tip="颜色">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar" ToolTip.Tip="状态栏">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather" ToolTip.Tip="天气">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region" ToolTip.Tip="地区">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update" ToolTip.Tip="更新">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About" ToolTip.Tip="关于">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher" ToolTip.Tip="应用启动台">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins" ToolTip.Tip="插件">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<Grid RowDefinitions="*,Auto"
RowSpacing="14">
<ui:NavigationView x:Name="SettingsNavView"
Grid.Row="0"
PaneDisplayMode="Left"
IsSettingsVisible="False"
OpenPaneLength="220"
SelectionChanged="OnSettingsNavSelectionChanged">
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper" ToolTip.Tip="壁纸">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid" ToolTip.Tip="网格">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color" ToolTip.Tip="颜色">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar" ToolTip.Tip="状态栏">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather" ToolTip.Tip="天气">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region" ToolTip.Tip="地区">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update" ToolTip.Tip="更新">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About" ToolTip.Tip="关于">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher" ToolTip.Tip="应用启动台">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins" ToolTip.Tip="插件">
<ui:NavigationViewItem.IconSource>
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
</ui:NavigationViewItem.IconSource>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<ScrollViewer x:Name="SettingsContentScrollViewer"
Padding="0,0,16,0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid>
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
<ScrollViewer x:Name="SettingsContentScrollViewer"
Padding="0,0,16,0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<Grid x:Name="SettingsContentPagesHost">
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
<pages:StatusBarSettingsPage x:Name="StatusBarSettingsPanel" IsVisible="False" />
<pages:WeatherSettingsPage x:Name="WeatherSettingsPanel" IsVisible="False" />
<pages:RegionSettingsPage x:Name="RegionSettingsPanel" IsVisible="False" />
<pages:StatusBarSettingsPage x:Name="StatusBarSettingsPanel" IsVisible="False" />
<pages:WeatherSettingsPage x:Name="WeatherSettingsPanel" IsVisible="False" />
<pages:RegionSettingsPage x:Name="RegionSettingsPanel" IsVisible="False" />
<pages:UpdateSettingsPage x:Name="UpdateSettingsPanel" IsVisible="False" />
<pages:UpdateSettingsPage x:Name="UpdateSettingsPanel" IsVisible="False" />
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
</Grid>
</ScrollViewer>
</ui:NavigationView>
<Border x:Name="PendingRestartDock"
Grid.Row="1"
IsVisible="False"
Classes="glass-panel"
CornerRadius="18"
Padding="14,12">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Border Width="34"
Height="34"
CornerRadius="17"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="16"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
FontSize="13"
FontWeight="SemiBold"
Text="Restart required" />
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Your changes will apply after restarting the app." />
</StackPanel>
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel>
</Button>
</Grid>
</ScrollViewer>
</ui:NavigationView>
</Border>
</Grid>
</Border>
</Border>
</Grid>

View File

@@ -167,6 +167,9 @@ public partial class MainWindow : Window
private bool _weatherNoTlsRequests;
private bool _autoStartWithWindows;
private bool _suppressAutoStartToggleEvents;
private bool _suppressAppRenderModeSelectionEvents;
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
private string _runningAppRenderMode = AppRenderingModeHelper.Default;
private string _weatherSearchKeyword = string.Empty;
private bool _isWeatherSearchInProgress;
private bool _isWeatherPreviewInProgress;
@@ -188,6 +191,7 @@ public partial class MainWindow : Window
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved;
LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved;
PendingRestartStateService.StateChanged += OnPendingRestartStateChanged;
PropertyChanged += OnWindowPropertyChanged;
InitializeDesktopSurfaceSwipeHandlers();
InitializeDesktopComponentDragHandlers();
@@ -248,6 +252,7 @@ public partial class MainWindow : Window
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
}
protected override void OnOpened(EventArgs e)
@@ -311,6 +316,7 @@ public partial class MainWindow : Window
InitializeWeatherSettings(snapshot);
_ = _componentSettingsService.Load();
InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot);
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot);
@@ -376,6 +382,7 @@ public partial class MainWindow : Window
_wallpaperBitmap = null;
AppSettingsService.SettingsSaved -= OnExternalAppSettingsSaved;
LauncherSettingsService.SettingsSaved -= OnExternalLauncherSettingsSaved;
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
PropertyChanged -= OnWindowPropertyChanged;
DesktopHost.SizeChanged -= OnDesktopHostSizeChanged;
WallpaperPreviewHost.SizeChanged -= OnWallpaperPreviewHostSizeChanged;

View File

@@ -34,5 +34,46 @@
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
<Border Classes="settings-expander-shell">
<ui:SettingsExpander x:Name="AboutRenderModeSettingsExpander"
Header="Rendering Mode"
Description="Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software."
IsExpanded="True">
<ui:SettingsExpander.IconSource>
<fi:SymbolIconSource Symbol="Window" />
</ui:SettingsExpander.IconSource>
<StackPanel Spacing="4"
Margin="0,4,0,0">
<TextBlock x:Name="CurrentRenderBackendLabelTextBlock"
Text="Current actual backend"
FontSize="12"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CurrentRenderBackendValueTextBlock"
Text="Current backend: Software"
FontSize="13"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="CurrentRenderBackendImplementationTextBlock"
Text="Runtime implementation is unavailable."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
</StackPanel>
<ui:SettingsExpander.Footer>
<ComboBox x:Name="AppRenderModeComboBox"
MinWidth="180"
SelectedIndex="0"
HorizontalAlignment="Right">
<ComboBoxItem Content="Default" Tag="Default" />
<ComboBoxItem Content="Software" Tag="Software" />
<ComboBoxItem Content="angleEgl" Tag="AngleEgl" />
<ComboBoxItem Content="WGL" Tag="Wgl" />
<ComboBoxItem Content="Vulkan" Tag="Vulkan" />
</ComboBox>
</ui:SettingsExpander.Footer>
</ui:SettingsExpander>
</Border>
</StackPanel>
</UserControl>

View File

@@ -189,7 +189,12 @@ public partial class SettingsWindow
// --- AboutSettingsPage ---
internal TextBlock AboutPanelTitleTextBlock => AboutSettingsPanel.FindControl<TextBlock>("AboutPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutStartupSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutStartupSettingsExpander")!;
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;
@@ -201,13 +206,5 @@ public partial class SettingsWindow
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
// --- PluginSettingsPage (Added for completeness) ---
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -12,6 +12,7 @@ using FluentIcons.Avalonia.Fluent;
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
using LanMountainDesktop.Views.Components;
namespace LanMountainDesktop.Views;
@@ -43,6 +44,7 @@ public partial class SettingsWindow
}
_launcherIconCache.Clear();
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
base.OnClosed(e);
}
@@ -128,6 +130,7 @@ public partial class SettingsWindow
snapshot.WeatherIconPackId = _weatherIconPackId;
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
snapshot.AutoStartWithWindows = _autoStartWithWindows;
snapshot.AppRenderMode = _selectedAppRenderMode;
snapshot.AutoCheckUpdates = _autoCheckUpdates;
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;

View File

@@ -1,6 +1,7 @@
using System;
using System.Linq;
using Avalonia.Controls;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
@@ -109,16 +110,7 @@ public partial class SettingsWindow
LauncherHiddenItemsDescriptionTextBlock.Text = L("settings.launcher.hidden_hint", "Right-click an icon in launcher to hide it. Hidden entries appear here.");
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
ApplyPluginSettingsLocalization();
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
@@ -126,6 +118,17 @@ public partial class SettingsWindow
FontInfoTextBlock.Text = Lf("settings.about.font_format", "Font: {0}", AppFontName);
AboutStartupSettingsExpander.Header = L("settings.about.startup_header", "Windows Startup");
AboutStartupSettingsExpander.Description = L("settings.about.startup_desc", "Launch the app automatically when signing in to Windows.");
AboutRenderModeSettingsExpander.Header = L("settings.about.render_mode_header", "Rendering Mode");
AboutRenderModeSettingsExpander.Description = L(
"settings.about.render_mode_desc",
"Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.");
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Default, L("settings.about.render_mode.default", "Default"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Software, L("settings.about.render_mode.software", "Software"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
UpdateCurrentRenderBackendStatus();
UpdatePendingRestartDock();
var placementItems = WallpaperPlacementComboBox.Items.OfType<ComboBoxItem>().ToList();
if (placementItems.Count >= 5)
@@ -142,6 +145,19 @@ public partial class SettingsWindow
RenderLauncherHiddenItemsList();
}
private void SetAppRenderModeComboItemContent(string tag, string content)
{
var item = AppRenderModeComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(candidate =>
string.Equals(candidate.Tag?.ToString(), tag, StringComparison.OrdinalIgnoreCase));
if (item is not null)
{
item.Content = content;
}
}
private string GetLocalizedTimeZoneDisplayName(TimeZoneInfo timeZone)
{
var offset = timeZone.GetUtcOffset(DateTime.UtcNow);

View File

@@ -0,0 +1,42 @@
using System;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private void UpdateCurrentRenderBackendStatus()
{
var backendInfo = AppRenderBackendDiagnostics.Detect();
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
CurrentRenderBackendLabelTextBlock.Text = L(
"settings.about.render_mode.current_label",
"Current actual backend");
CurrentRenderBackendValueTextBlock.Text = Lf(
"settings.about.render_mode.current_format",
"Current backend: {0}",
localizedBackend);
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
? L(
"settings.about.render_mode.impl_unavailable",
"Runtime implementation is unavailable.")
: Lf(
"settings.about.render_mode.impl_format",
"Runtime implementation: {0}",
backendInfo.ImplementationTypeName);
}
private string GetLocalizedRenderBackendName(string renderBackend)
{
return renderBackend switch
{
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.unknown", "Unknown")
};
}
}

View File

@@ -0,0 +1,112 @@
using System.Threading.Tasks;
using Avalonia.Interactivity;
using Avalonia.Threading;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private bool _isRestartPromptVisible;
private void OnPendingRestartStateChanged()
{
if (Dispatcher.UIThread.CheckAccess())
{
UpdatePendingRestartDock();
return;
}
Dispatcher.UIThread.Post(UpdatePendingRestartDock);
}
private void UpdatePendingRestartDock()
{
PendingRestartDock.IsVisible = PendingRestartStateService.HasPendingRestart;
PendingRestartDockTitleTextBlock.Text = L("settings.restart_dock.title", "Restart required");
PendingRestartDockDescriptionTextBlock.Text = L(
"settings.restart_dock.description",
"Some changes will take effect after restarting the app.");
PendingRestartDockButtonTextBlock.Text = L("settings.restart_dock.button", "Restart app");
}
private async void OnPendingRestartDockButtonClick(object? sender, RoutedEventArgs e)
{
await ShowGenericRestartPromptAsync();
}
private Task ShowRenderModeRestartPromptAsync(string selectedMode)
{
var message = Lf(
"settings.restart_dialog.render_mode_message",
"Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
GetLocalizedAppRenderModeDisplayName(_runningAppRenderMode),
GetLocalizedAppRenderModeDisplayName(selectedMode));
return ShowRestartPromptCoreAsync(message);
}
private Task ShowGenericRestartPromptAsync()
{
return ShowRestartPromptCoreAsync(L(
"settings.restart_dock.description",
"Some changes will take effect after restarting the app."));
}
private async Task ShowRestartPromptCoreAsync(string message)
{
if (_isRestartPromptVisible)
{
return;
}
_isRestartPromptVisible = true;
try
{
var dialog = new ContentDialog
{
Title = L("settings.restart_dialog.title", "Restart required"),
Content = message,
PrimaryButtonText = L("settings.restart_dialog.restart", "Restart now"),
CloseButtonText = L("settings.restart_dialog.cancel", "Cancel"),
DefaultButton = ContentDialogButton.Primary
};
var result = await dialog.ShowAsync(this);
if (result == ContentDialogResult.Primary)
{
if (!AppRestartService.TryRestartApplication())
{
UpdatePendingRestartDock();
}
return;
}
UpdatePendingRestartDock();
}
finally
{
_isRestartPromptVisible = false;
}
}
private string GetLocalizedAppRenderModeDisplayName(string renderMode)
{
if (renderMode == AppRenderBackendDiagnostics.Unknown)
{
return L("settings.about.render_mode.unknown", "Unknown");
}
return AppRenderingModeHelper.Normalize(renderMode) switch
{
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
_ => L("settings.about.render_mode.default", "Default")
};
}
}

View File

@@ -89,6 +89,51 @@ public partial class SettingsWindow
}
}
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
{
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
_runningAppRenderMode = ResolveActiveAppRenderModeForUi(_selectedAppRenderMode);
var renderModeForUi = PendingRestartStateService.HasPendingReason(PendingRestartStateService.RenderModeReason)
? _selectedAppRenderMode
: _runningAppRenderMode;
_suppressAppRenderModeSelectionEvents = true;
try
{
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
SelectAppRenderModeInUi(renderModeForUi);
}
finally
{
_suppressAppRenderModeSelectionEvents = false;
}
}
private void SelectAppRenderModeInUi(string renderMode)
{
AppRenderModeComboBox.SelectedIndex = GetAppRenderModeComboBoxIndex(renderMode);
}
private static int GetAppRenderModeComboBoxIndex(string renderMode)
{
return AppRenderingModeHelper.Normalize(renderMode) switch
{
AppRenderingModeHelper.Software => 1,
AppRenderingModeHelper.AngleEgl => 2,
AppRenderingModeHelper.Wgl => 3,
AppRenderingModeHelper.Vulkan => 4,
_ => 0
};
}
private static string ResolveActiveAppRenderModeForUi(string configuredRenderMode)
{
var detectedRenderMode = AppRenderBackendDiagnostics.Detect().ActualBackend;
return string.Equals(detectedRenderMode, AppRenderBackendDiagnostics.Unknown, StringComparison.Ordinal)
? configuredRenderMode
: AppRenderingModeHelper.Normalize(detectedRenderMode);
}
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
{
return string.Equals(value, "Coordinates", StringComparison.OrdinalIgnoreCase)
@@ -319,6 +364,33 @@ public partial class SettingsWindow
PersistSettings();
}
private void OnAppRenderModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressAppRenderModeSelectionEvents)
{
return;
}
var selectedMode = AppRenderingModeHelper.Normalize(
TryGetSelectedComboBoxTag(AppRenderModeComboBox));
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
{
return;
}
_selectedAppRenderMode = selectedMode;
PersistSettings();
var requiresRestart = !string.Equals(_runningAppRenderMode, selectedMode, StringComparison.Ordinal);
PendingRestartStateService.SetPending(PendingRestartStateService.RenderModeReason, requiresRestart);
UpdatePendingRestartDock();
if (requiresRestart)
{
_ = ShowRenderModeRestartPromptAsync(selectedMode);
}
}
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
{
if (_isWeatherSearchInProgress)

View File

@@ -79,7 +79,10 @@
Classes="mica-strong"
CornerRadius="0,0,24,24"
Padding="18">
<Grid RowDefinitions="*,Auto"
RowSpacing="14">
<ui:NavigationView x:Name="SettingsNavView"
Grid.Row="0"
PaneDisplayMode="Left"
IsSettingsVisible="False"
OpenPaneLength="240"
@@ -155,6 +158,53 @@
</Grid>
</ScrollViewer>
</ui:NavigationView>
<Border x:Name="PendingRestartDock"
Grid.Row="1"
IsVisible="False"
Classes="glass-panel"
CornerRadius="18"
Padding="14,12">
<Grid ColumnDefinitions="Auto,*,Auto"
ColumnSpacing="12">
<Border Width="34"
Height="34"
CornerRadius="17"
Background="{DynamicResource AdaptiveAccentBrush}">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular"
FontSize="16"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Spacing="2"
VerticalAlignment="Center">
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
FontSize="13"
FontWeight="SemiBold"
Text="Restart required" />
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
Text="Your changes will apply after restarting the app." />
</StackPanel>
<Button x:Name="PendingRestartDockButton"
Grid.Column="2"
Padding="14,8"
Click="OnPendingRestartDockButtonClick">
<StackPanel Orientation="Horizontal" Spacing="8">
<fi:FluentIcon Icon="ArrowSync"
IconVariant="Regular" />
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
VerticalAlignment="Center"
Text="Restart app" />
</StackPanel>
</Button>
</Grid>
</Border>
</Grid>
</Border>
</Grid>
</Border>

View File

@@ -120,6 +120,7 @@ public partial class SettingsWindow : Window
private bool _suppressGridInsetEvents;
private bool _suppressStatusBarSpacingEvents;
private bool _suppressAutoStartToggleEvents;
private bool _suppressAppRenderModeSelectionEvents;
private bool _isUpdatingWallpaperPreviewLayout;
private IBrush? _defaultDesktopBackground;
private Bitmap? _wallpaperBitmap;
@@ -140,6 +141,8 @@ public partial class SettingsWindow : Window
private string _statusBarSpacingMode = "Relaxed";
private int _statusBarCustomSpacingPercent = 12;
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
private string _runningAppRenderMode = AppRenderingModeHelper.Default;
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
private string _languageCode = "zh-CN";
private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch;
@@ -163,6 +166,7 @@ public partial class SettingsWindow : Window
InitializePluginSettingsNavigation();
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default;
PendingRestartStateService.StateChanged += OnPendingRestartStateChanged;
HookEvents();
}
@@ -222,6 +226,7 @@ public partial class SettingsWindow : Window
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
Opened += OnWindowOpened;
}
@@ -260,6 +265,7 @@ public partial class SettingsWindow : Window
InitializeLocalization(snapshot.LanguageCode);
InitializeWeatherSettings(snapshot);
InitializeAutoStartWithWindowsSetting(snapshot);
InitializeAppRenderModeSetting(snapshot);
InitializeUpdateSettings(snapshot);
InitializeLauncherVisibilitySettings(launcherSnapshot);
InitializeSettingsIcons();

View File

@@ -1,7 +1,12 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.PluginSdk;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
{
@@ -68,6 +73,15 @@ public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
disposable.Dispose();
}
if (Context is IAsyncDisposable asyncContext)
{
await asyncContext.DisposeAsync();
}
else if (Context is IDisposable disposableContext)
{
disposableContext.Dispose();
}
LoadContext.Unload();
GC.SuppressFinalize(this);
}

View File

@@ -0,0 +1,14 @@
using Avalonia.Controls;
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -139,6 +139,30 @@ public partial class MainWindow
}
}
internal void RefreshPluginSettingsNavigation()
{
if (SettingsNavView?.MenuItems is null)
{
return;
}
foreach (var pair in _pluginSettingsPageHosts.ToArray())
{
var navItem = SettingsNavView.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
if (navItem is not null)
{
SettingsNavView.MenuItems.Remove(navItem);
}
SettingsContentPagesHost.Children.Remove(pair.Value);
}
_pluginSettingsPageHosts.Clear();
InitializePluginSettingsNavigation();
}
private string? GetSelectedSettingsTabTag()
{
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();

View File

@@ -0,0 +1,28 @@
namespace LanMountainDesktop.Views;
public partial class MainWindow
{
private void ApplyPluginSettingsLocalization()
{
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
PluginSystemSettingsExpander.Description = L(
"settings.plugins.runtime_desc",
"Review plugin runtime state and load results.");
PluginSystemDescriptionTextBlock.Text = L(
"settings.plugins.runtime_hint",
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L(
"settings.plugins.runtime_status",
"Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L(
"settings.plugins.installed_desc",
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
PluginRestartHintTextBlock.Text = L(
"settings.plugins.restart_hint",
"Plugin enable state changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
}
}

View File

@@ -1,3 +1,4 @@
using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;

View File

@@ -1,7 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
namespace LanMountainDesktop.PluginSdk;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
public sealed class PluginLoadContext : AssemblyLoadContext
{

View File

@@ -1,4 +1,8 @@
namespace LanMountainDesktop.PluginSdk;
using System;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
public sealed record PluginLoadResult(
string SourcePath,

View File

@@ -1,10 +1,18 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace LanMountainDesktop.PluginSdk;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
public sealed class PluginLoader
{
@@ -125,14 +133,16 @@ public sealed class PluginLoader
IReadOnlyDictionary<string, object?>? properties)
{
PluginLoadContext? loadContext = null;
IPlugin? plugin = null;
PluginContext? context = null;
try
{
loadContext = new PluginLoadContext(assemblyPath, _options.SharedAssemblyNames);
var assembly = loadContext.LoadFromAssemblyPath(assemblyPath);
var pluginType = ResolvePluginType(assembly);
var plugin = CreatePluginInstance(pluginType);
var context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
plugin = CreatePluginInstance(pluginType);
context = CreateContext(manifest, pluginDirectory, dataDirectory, services, properties);
plugin.Initialize(context);
var settingsPages = context.GetSettingsPagesSnapshot();
@@ -153,6 +163,8 @@ public sealed class PluginLoader
}
catch (Exception ex)
{
DisposeInstance(plugin);
DisposeInstance(context);
loadContext?.Unload();
return PluginLoadResult.Failure(sourcePath, manifest, ex);
}
@@ -477,6 +489,33 @@ public sealed class PluginLoader
return plugin;
}
private static void DisposeInstance(object? instance)
{
if (instance is null)
{
return;
}
try
{
if (instance is IAsyncDisposable asyncDisposable)
{
asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult();
return;
}
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
catch (Exception disposeError)
{
System.Diagnostics.Debug.WriteLine(
$"[PluginLoader] Disposal of '{instance.GetType().FullName}' failed: {disposeError}");
}
}
private static Type[] GetLoadableTypes(Assembly assembly)
{
try
@@ -500,12 +539,17 @@ public sealed class PluginLoader
}
}
private sealed class PluginContext : IPluginContext
private sealed class PluginContext : IPluginContext, IDisposable, IAsyncDisposable
{
private readonly Dictionary<string, PluginSettingsPageRegistration> _settingsPages =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, PluginDesktopComponentRegistration> _desktopComponents =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, object> _registeredServices = [];
private readonly List<object> _serviceRegistrationOrder = [];
private readonly object _serviceGate = new();
private readonly IServiceProvider _hostServices;
private int _disposed;
public PluginContext(
PluginManifest manifest,
@@ -517,8 +561,12 @@ public sealed class PluginLoader
Manifest = manifest;
PluginDirectory = pluginDirectory;
DataDirectory = dataDirectory;
Services = services;
_hostServices = services;
Services = new PluginCompositeServiceProvider(this);
Properties = properties;
RegisterBuiltInService<IPluginContext>(this);
RegisterBuiltInService<IPluginMessageBus>(new PluginMessageBus());
}
public PluginManifest Manifest { get; }
@@ -550,9 +598,16 @@ public sealed class PluginLoader
return false;
}
public void RegisterService<TService>(TService service)
where TService : class
{
RegisterServiceCore(typeof(TService), service, allowOverride: false);
}
public void RegisterSettingsPage(PluginSettingsPageRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
ThrowIfDisposed();
if (!_settingsPages.TryAdd(registration.Id, registration))
{
@@ -564,6 +619,7 @@ public sealed class PluginLoader
public void RegisterDesktopComponent(PluginDesktopComponentRegistration registration)
{
ArgumentNullException.ThrowIfNull(registration);
ThrowIfDisposed();
if (!_desktopComponents.TryAdd(registration.ComponentId, registration))
{
@@ -574,6 +630,7 @@ public sealed class PluginLoader
public IReadOnlyList<PluginSettingsPageRegistration> GetSettingsPagesSnapshot()
{
ThrowIfDisposed();
return _settingsPages.Values
.OrderBy(page => page.SortOrder)
.ThenBy(page => page.Title, StringComparer.OrdinalIgnoreCase)
@@ -582,11 +639,270 @@ public sealed class PluginLoader
public IReadOnlyList<PluginDesktopComponentRegistration> GetDesktopComponentsSnapshot()
{
ThrowIfDisposed();
return _desktopComponents.Values
.OrderBy(component => component.Category, StringComparer.OrdinalIgnoreCase)
.ThenBy(component => component.DisplayName, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
internal object? ResolveService(Type serviceType)
{
if (Volatile.Read(ref _disposed) != 0)
{
return null;
}
if (serviceType == typeof(IServiceProvider))
{
return Services;
}
lock (_serviceGate)
{
if (_registeredServices.TryGetValue(serviceType, out var service))
{
return service;
}
foreach (var registeredService in _registeredServices.Values)
{
if (serviceType.IsInstanceOfType(registeredService))
{
return registeredService;
}
}
}
return _hostServices.GetService(serviceType);
}
private void RegisterBuiltInService<TService>(TService service)
where TService : class
{
RegisterServiceCore(typeof(TService), service, allowOverride: true);
}
public void Dispose()
{
DisposeAsync().AsTask().GetAwaiter().GetResult();
}
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
object[] services;
lock (_serviceGate)
{
services = _serviceRegistrationOrder.ToArray();
_registeredServices.Clear();
_serviceRegistrationOrder.Clear();
}
_settingsPages.Clear();
_desktopComponents.Clear();
var disposedServices = new HashSet<object>(ReferenceEqualityComparer.Instance);
for (var i = services.Length - 1; i >= 0; i--)
{
var service = services[i];
if (ReferenceEquals(service, this) || !disposedServices.Add(service))
{
continue;
}
if (service is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync();
}
else if (service is IDisposable disposable)
{
disposable.Dispose();
}
}
}
private void RegisterServiceCore(Type serviceType, object service, bool allowOverride)
{
ArgumentNullException.ThrowIfNull(serviceType);
ArgumentNullException.ThrowIfNull(service);
ThrowIfDisposed();
if (!serviceType.IsInstanceOfType(service))
{
throw new InvalidOperationException(
$"Service instance '{service.GetType().FullName}' is not assignable to '{serviceType.FullName}'.");
}
lock (_serviceGate)
{
if (!allowOverride && _registeredServices.ContainsKey(serviceType))
{
throw new InvalidOperationException(
$"Plugin '{Manifest.Id}' already registered a service for '{serviceType.FullName}'.");
}
_registeredServices[serviceType] = service;
_serviceRegistrationOrder.Add(service);
}
}
private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(PluginContext));
}
}
}
private sealed class PluginCompositeServiceProvider : IServiceProvider
{
private readonly PluginContext _context;
public PluginCompositeServiceProvider(PluginContext context)
{
_context = context;
}
public object? GetService(Type serviceType)
{
ArgumentNullException.ThrowIfNull(serviceType);
return _context.ResolveService(serviceType);
}
}
private sealed class PluginMessageBus : IPluginMessageBus, IDisposable
{
private readonly Dictionary<Type, List<Subscription>> _subscriptions = [];
private readonly object _gate = new();
private int _disposed;
public IDisposable Subscribe<TMessage>(Action<TMessage> handler)
{
ArgumentNullException.ThrowIfNull(handler);
if (Volatile.Read(ref _disposed) != 0)
{
throw new ObjectDisposedException(nameof(PluginMessageBus));
}
var subscription = new Subscription(this, typeof(TMessage), message => handler((TMessage)message!));
lock (_gate)
{
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
{
handlers = [];
_subscriptions[subscription.MessageType] = handlers;
}
handlers.Add(subscription);
}
return subscription;
}
public void Publish<TMessage>(TMessage message)
{
if (Volatile.Read(ref _disposed) != 0)
{
return;
}
Subscription[] handlers;
lock (_gate)
{
if (!_subscriptions.TryGetValue(typeof(TMessage), out var subscriptions) || subscriptions.Count == 0)
{
return;
}
handlers = subscriptions.ToArray();
}
foreach (var handler in handlers)
{
try
{
handler.Invoke(message);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(
$"[PluginMessageBus] Handler for '{typeof(TMessage).FullName}' failed: {ex}");
}
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
lock (_gate)
{
_subscriptions.Clear();
}
}
private void Unsubscribe(Subscription subscription)
{
lock (_gate)
{
if (!_subscriptions.TryGetValue(subscription.MessageType, out var handlers))
{
return;
}
handlers.Remove(subscription);
if (handlers.Count == 0)
{
_subscriptions.Remove(subscription.MessageType);
}
}
}
private sealed class Subscription : IDisposable
{
private readonly PluginMessageBus _owner;
private int _disposed;
public Subscription(PluginMessageBus owner, Type messageType, Action<object?> handler)
{
_owner = owner;
MessageType = messageType;
Handler = handler;
}
public Type MessageType { get; }
public Action<object?> Handler { get; }
public void Invoke(object? message)
{
if (_disposed != 0)
{
return;
}
Handler(message);
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
{
return;
}
_owner.Unsubscribe(this);
}
}
}
private sealed class NullServiceProvider : IServiceProvider

View File

@@ -1,4 +1,9 @@
namespace LanMountainDesktop.PluginSdk;
using System;
using System.Collections.Generic;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Plugins;
public sealed class PluginLoaderOptions
{

View File

@@ -9,6 +9,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using LanMountainDesktop.Models;
using LanMountainDesktop.Plugins;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Services;
@@ -47,11 +48,14 @@ public sealed class PluginRuntimeService : IDisposable
UnloadInstalledPlugins();
var disabledPluginIds = GetDisabledPluginIds();
var settingsSnapshot = _appSettingsService.Load();
var hostLanguageCode = PluginLocalizer.NormalizeLanguageCode(settingsSnapshot.LanguageCode);
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
{
["HostApplicationName"] = "LanMountainDesktop",
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
[PluginHostPropertyKeys.HostApplicationName] = "LanMountainDesktop",
[PluginHostPropertyKeys.HostVersion] = typeof(App).Assembly.GetName().Version?.ToString(),
[PluginHostPropertyKeys.PluginSdkApiVersion] = PluginSdkInfo.ApiVersion,
[PluginHostPropertyKeys.HostLanguageCode] = hostLanguageCode
};
var discoveryFailures = new List<PluginLoadResult>();
@@ -174,6 +178,36 @@ public sealed class PluginRuntimeService : IDisposable
return true;
}
public PluginManifest InstallPluginPackage(string packagePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
var fullPackagePath = Path.GetFullPath(packagePath);
if (!File.Exists(fullPackagePath))
{
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
}
if (!string.Equals(Path.GetExtension(fullPackagePath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Plugin package must use the '{PluginSdkInfo.PackageFileExtension}' extension.");
}
Directory.CreateDirectory(PluginsDirectory);
var manifest = ReadManifestFromPackage(fullPackagePath);
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
{
File.Copy(fullPackagePath, destinationPath, overwrite: true);
}
return manifest;
}
public void Dispose()
{
UnloadInstalledPlugins();
@@ -269,6 +303,42 @@ public sealed class PluginRuntimeService : IDisposable
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
}
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
{
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
{
if (string.Equals(
Path.GetFullPath(existingPackagePath),
Path.GetFullPath(packagePathToKeep),
StringComparison.OrdinalIgnoreCase))
{
continue;
}
try
{
var existingManifest = ReadManifestFromPackage(existingPackagePath);
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
{
continue;
}
File.Delete(existingPackagePath);
}
catch
{
// Ignore unrelated or invalid packages during replacement.
}
}
}
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)
@@ -280,9 +350,22 @@ public sealed class PluginRuntimeService : IDisposable
{
var options = new PluginLoaderOptions();
AddSharedAssembly(options, typeof(App).Assembly);
AddSharedAssembly(options, typeof(Application).Assembly);
AddSharedAssembly(options, typeof(Control).Assembly);
AddSharedAssembly(options, typeof(AvaloniaXamlLoader).Assembly);
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
{
var assemblyName = assembly.GetName().Name;
if (string.IsNullOrWhiteSpace(assemblyName))
{
continue;
}
if (assemblyName.StartsWith("Avalonia", StringComparison.OrdinalIgnoreCase) ||
string.Equals(assemblyName, "MicroCom.Runtime", StringComparison.OrdinalIgnoreCase))
{
AddSharedAssembly(options, assembly);
}
}
return options;
}

View File

@@ -1,19 +1,28 @@
using System;
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using FluentAvalonia.UI.Controls;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
public partial class PluginSettingsPage : UserControl
{
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private string? _packageImportStatusMessage;
private bool _packageImportStatusIsError;
public PluginSettingsPage()
{
@@ -24,6 +33,7 @@ public partial class PluginSettingsPage : UserControl
public void RefreshFromRuntime()
{
var runtime = (Application.Current as App)?.PluginRuntimeService;
UpdateInstallerUi(runtime);
if (runtime is null)
{
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
@@ -37,6 +47,24 @@ public partial class PluginSettingsPage : UserControl
BuildPluginCatalog(runtime);
}
private void UpdateInstallerUi(PluginRuntimeService? runtime)
{
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
InstallPluginPackageButton.IsEnabled = runtime is not null;
PluginPackageImportHintTextBlock.Text = runtime is null
? L(
"settings.plugins.install_unavailable",
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now.")
: F(
"settings.plugins.install_hint_format",
"Open a .laapp package to install it into: {0}",
runtime.PluginsDirectory);
PluginPackageImportStatusTextBlock.IsVisible = !string.IsNullOrWhiteSpace(_packageImportStatusMessage);
PluginPackageImportStatusTextBlock.Text = _packageImportStatusMessage ?? string.Empty;
PluginPackageImportStatusTextBlock.Foreground = _packageImportStatusIsError ? ErrorBrush : SuccessBrush;
}
private void BuildRuntimeSummary(PluginRuntimeService runtime)
{
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
@@ -165,6 +193,116 @@ public partial class PluginSettingsPage : UserControl
: L("settings.plugins.toggle_state_disabled", "disabled"));
}
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var runtime = (Application.Current as App)?.PluginRuntimeService;
if (runtime is null)
{
SetPackageImportStatus(
L(
"settings.plugins.install_unavailable",
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now."),
isError: true);
return;
}
var topLevel = TopLevel.GetTopLevel(this);
var storageProvider = topLevel?.StorageProvider;
if (storageProvider is null)
{
SetPackageImportStatus(
L("settings.plugins.install_picker_unavailable", "Storage provider is unavailable."),
isError: true);
return;
}
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = L("settings.plugins.install_picker_title", "Select plugin package"),
AllowMultiple = false,
FileTypeFilter =
[
new FilePickerFileType(L("settings.plugins.install_file_type", ".laapp plugin package"))
{
Patterns = [$"*{PluginSdkInfo.PackageFileExtension}"]
}
]
});
if (files.Count == 0)
{
return;
}
string? temporaryPackagePath = null;
try
{
temporaryPackagePath = await CopyPackageToTemporaryFileAsync(files[0]);
if (string.IsNullOrWhiteSpace(temporaryPackagePath))
{
SetPackageImportStatus(
L("settings.plugins.install_copy_failed", "Failed to copy the selected .laapp package."),
isError: true);
return;
}
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
runtime.LoadInstalledPlugins();
RefreshPluginNavigation(topLevel);
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
RefreshFromRuntime();
SetPackageImportStatus(
F(
"settings.plugins.install_success_format",
"Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
manifest.Name),
isError: false);
}
catch (Exception ex)
{
SetPackageImportStatus(
F(
"settings.plugins.install_failed_format",
"Failed to install plugin package: {0}",
ex.Message),
isError: true);
}
finally
{
if (!string.IsNullOrWhiteSpace(temporaryPackagePath))
{
try
{
File.Delete(temporaryPackagePath);
}
catch
{
// Ignore temporary file cleanup errors.
}
}
}
}
private void RefreshPluginNavigation(TopLevel? topLevel)
{
switch (topLevel)
{
case MainWindow mainWindow:
mainWindow.RefreshPluginSettingsNavigation();
break;
case SettingsWindow settingsWindow:
settingsWindow.RefreshPluginSettingsNavigation();
break;
}
}
private void SetPackageImportStatus(string message, bool isError)
{
_packageImportStatusMessage = string.IsNullOrWhiteSpace(message) ? null : message;
_packageImportStatusIsError = isError;
UpdateInstallerUi((Application.Current as App)?.PluginRuntimeService);
}
private string BuildPluginSubtitle(PluginCatalogEntry entry)
{
var source = entry.IsPackage
@@ -215,8 +353,35 @@ public partial class PluginSettingsPage : UserControl
{
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
}
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
{
try
{
var extension = Path.GetExtension(file.Name);
if (string.IsNullOrWhiteSpace(extension))
{
extension = PluginSdkInfo.PackageFileExtension;
}
var temporaryDirectory = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop",
"PluginImports");
Directory.CreateDirectory(temporaryDirectory);
var temporaryPackagePath = Path.Combine(
temporaryDirectory,
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
await using var sourceStream = await file.OpenReadAsync();
await using var destinationStream = File.Create(temporaryPackagePath);
await sourceStream.CopyToAsync(destinationStream);
return temporaryPackagePath;
}
catch
{
return null;
}
}
}

View File

@@ -51,6 +51,24 @@
</ui:SettingsExpander.IconSource>
<ui:SettingsExpander.Footer>
<StackPanel Spacing="10">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
Padding="14">
<StackPanel Spacing="10">
<Button x:Name="InstallPluginPackageButton"
HorizontalAlignment="Left"
Click="OnInstallPluginPackageClick"
Content="Open .laapp package" />
<TextBlock x:Name="PluginPackageImportHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
Text="Open a .laapp package to install it into the local plugin directory." />
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"
IsVisible="False" />
</StackPanel>
</Border>
<TextBlock x:Name="PluginRestartHintTextBlock"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
TextWrapping="Wrap"

View File

@@ -0,0 +1,33 @@
# 宿主侧插件运行时
这个目录用于归档阑山桌面宿主侧的插件相关实现。
职责范围:
- 已安装插件的发现
- `.laapp` 安装包安装与替换
- 插件运行时加载
- 插件贡献的设置页与桌面组件接入
- 宿主侧插件设置页的安装、显示与刷新
当前宿主侧核心文件:
- `PluginLoader.cs`
- `PluginLoadContext.cs`
- `PluginLoaderOptions.cs`
- `PluginLoadResult.cs`
- `LoadedPlugin.cs`
- `PluginRuntimeService.cs`
- `PluginContributions.cs`
- `PluginCatalogEntry.cs`
- `PluginSettingsPage.axaml`
- `PluginSettingsPage.Host.cs`
- `MainWindow.PluginSettingsHost.cs`
- `SettingsWindow.PluginSettingsHost.cs`
- `MainWindow.PluginSettingsLocalization.cs`
- `SettingsWindow.PluginSettingsLocalization.cs`
- `MainWindow.PluginSettingsControls.cs`
- `SettingsWindow.PluginSettingsControls.cs`
说明:
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口

View File

@@ -0,0 +1,14 @@
using Avalonia.Controls;
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
}

View File

@@ -139,6 +139,30 @@ public partial class SettingsWindow
}
}
internal void RefreshPluginSettingsNavigation()
{
if (SettingsNavView?.MenuItems is null)
{
return;
}
foreach (var pair in _pluginSettingsPageHosts.ToArray())
{
var navItem = SettingsNavView.MenuItems
.OfType<NavigationViewItem>()
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
if (navItem is not null)
{
SettingsNavView.MenuItems.Remove(navItem);
}
SettingsContentPagesHost.Children.Remove(pair.Value);
}
_pluginSettingsPageHosts.Clear();
InitializePluginSettingsNavigation();
}
private string? GetSelectedSettingsTabTag()
{
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();

View File

@@ -0,0 +1,18 @@
namespace LanMountainDesktop.Views;
public partial class SettingsWindow
{
private void ApplyPluginSettingsLocalization()
{
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
PluginSettingsPanel.RefreshFromRuntime();
}
}