mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1be072b97 | ||
|
|
4df740e3df | ||
|
|
85f7a18cbc | ||
|
|
cdffaa16eb | ||
|
|
d33d8d3391 | ||
|
|
9c89c08448 | ||
|
|
ec7b78bc63 | ||
|
|
e97db00999 | ||
|
|
8bb6b01236 | ||
|
|
103b215e35 | ||
|
|
cab35f4c22 | ||
|
|
c9f92a4755 | ||
|
|
854deae801 | ||
|
|
d72cd42483 |
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: AirAppMarket Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Validate AirAppMarket index
|
||||
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -482,3 +482,13 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
nul
|
||||
/publish-test
|
||||
/_build_verify
|
||||
/_build_verify_tray
|
||||
/_build_verify_plugin
|
||||
/_build_verify_plugin_tabs
|
||||
/_build_verify_sample_plugin
|
||||
/_build_verify_sample_plugin_capabilities
|
||||
/_build_verify_plugin_page_host
|
||||
/_build_verify_plugin_services
|
||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||
/_build_obj
|
||||
|
||||
21
LanAirApp/README.md
Normal file
21
LanAirApp/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# LanAirApp
|
||||
|
||||
## 中文
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
|
||||
### 目录说明
|
||||
|
||||
- `docs/`:插件开发与打包文档。
|
||||
- `samples/`:示例插件与参考项目。
|
||||
- `standards/`:插件清单和目录结构约定。
|
||||
- `tools/`:插件打包与辅助工具。
|
||||
|
||||
### 与宿主的关系
|
||||
|
||||
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
|
||||
|
||||
## English
|
||||
|
||||
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
|
||||
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 插件开发指南
|
||||
|
||||
## 中文
|
||||
|
||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||
|
||||
- `plugin.json`
|
||||
- 插件入口程序集
|
||||
- 入口类
|
||||
- 本地化资源
|
||||
|
||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||
|
||||
## English
|
||||
|
||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 插件打包指南
|
||||
|
||||
## 中文
|
||||
|
||||
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
||||
|
||||
- `.laapp` 安装包
|
||||
- `README.md`
|
||||
|
||||
官方市场索引只负责记录链接和校验信息。
|
||||
|
||||
## English
|
||||
|
||||
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "否"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
## 中文
|
||||
|
||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||
|
||||
## English
|
||||
|
||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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", "否");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginStatusClockWidget : Border
|
||||
{
|
||||
private readonly PluginDesktopComponentContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly TextBlock _timeTextBlock;
|
||||
private readonly TextBlock _subtitleTextBlock;
|
||||
private readonly StackPanel _statusPanel;
|
||||
private readonly Border _statusHost;
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
private string? _instanceId;
|
||||
|
||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_timeTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
_subtitleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
_statusPanel = new StackPanel
|
||||
{
|
||||
Spacing = 8
|
||||
};
|
||||
_statusHost = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
Child = _statusPanel
|
||||
};
|
||||
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||
BorderThickness = new Thickness(1);
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalAlignment = VerticalAlignment.Stretch;
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 14,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
_timeTextBlock,
|
||||
_subtitleTextBlock
|
||||
}
|
||||
},
|
||||
_statusHost
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
_instanceId = _stateService.RegisterComponentInstance(
|
||||
_context.ComponentId,
|
||||
_context.PlacementId,
|
||||
_context.CellSize);
|
||||
}
|
||||
|
||||
_stateService.MarkFrontendReady(T(
|
||||
"status.frontend.detail.widget_connected",
|
||||
"组件界面已接入插件服务与通信。"));
|
||||
SubscribeToPluginBus();
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
foreach (var subscription in _subscriptions)
|
||||
{
|
||||
subscription.Dispose();
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stateService.UnregisterComponentInstance(_instanceId);
|
||||
_instanceId = null;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void SubscribeToPluginBus()
|
||||
{
|
||||
if (_subscriptions.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
|
||||
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
})));
|
||||
}
|
||||
|
||||
private void RefreshClock(DateTimeOffset currentTime)
|
||||
{
|
||||
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
|
||||
}
|
||||
|
||||
private void UpdateSubtitle()
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
|
||||
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
|
||||
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
|
||||
}
|
||||
|
||||
private void RefreshStatusPanel()
|
||||
{
|
||||
_statusPanel.Children.Clear();
|
||||
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
var basis = GetLayoutBasis();
|
||||
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
|
||||
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
|
||||
|
||||
foreach (var entry in snapshot.StatusEntries)
|
||||
{
|
||||
var palette = GetPalette(entry.State);
|
||||
_statusPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(palette.Background),
|
||||
BorderBrush = new SolidColorBrush(palette.Border),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||
ColumnSpacing = 8,
|
||||
Children =
|
||||
{
|
||||
new Border
|
||||
{
|
||||
Width = Math.Clamp(basis * 0.038, 8, 11),
|
||||
Height = Math.Clamp(basis * 0.038, 8, 11),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(palette.Dot),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Title,
|
||||
FontSize = titleSize,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Summary,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
TextAlignment = TextAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Detail,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
|
||||
Grid.SetColumn(row.Children[1], 1);
|
||||
Grid.SetColumn(row.Children[2], 2);
|
||||
Grid.SetColumnSpan(row.Children[3], 3);
|
||||
Grid.SetRow(row.Children[3], 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = GetLayoutBasis();
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
|
||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
||||
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
|
||||
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
|
||||
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
|
||||
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
|
||||
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
|
||||
}
|
||||
|
||||
private double GetLayoutBasis()
|
||||
{
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
|
||||
}
|
||||
|
||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SamplePluginHealthState.Healthy => (
|
||||
Color.Parse("#1F0F766E"),
|
||||
Color.Parse("#4D5EEAD4"),
|
||||
Color.Parse("#5EEAD4")),
|
||||
SamplePluginHealthState.Faulted => (
|
||||
Color.Parse("#29B91C1C"),
|
||||
Color.Parse("#66F87171"),
|
||||
Color.Parse("#F87171")),
|
||||
_ => (
|
||||
Color.Parse("#1F7C2D12"),
|
||||
Color.Parse("#66FDBA74"),
|
||||
Color.Parse("#FDBA74"))
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
|
||||
}
|
||||
11
LanAirApp/samples/README.md
Normal file
11
LanAirApp/samples/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 示例插件目录
|
||||
|
||||
## 中文
|
||||
|
||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||
|
||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||
9
LanAirApp/standards/README.md
Normal file
9
LanAirApp/standards/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 插件标准说明
|
||||
|
||||
## 中文
|
||||
|
||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||
9
LanAirApp/standards/plugin.template.json
Normal file
9
LanAirApp/standards/plugin.template.json
Normal 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"
|
||||
}
|
||||
@@ -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>
|
||||
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal 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");
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
}
|
||||
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
void RegisterService<TService>(TService service)
|
||||
where TService : class;
|
||||
|
||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginMessageBus
|
||||
{
|
||||
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||
|
||||
void Publish<TMessage>(TMessage message);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPackageManager
|
||||
{
|
||||
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||
|
||||
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record InstalledPluginInfo(
|
||||
PluginManifest Manifest,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
bool IsPackage,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
{
|
||||
public PluginDesktopComponentContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public double CellSize { get; }
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
}
|
||||
|
||||
public bool TryGetProperty<T>(string key, out T? value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||
{
|
||||
value = typedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
int minHeightCells = 2,
|
||||
bool allowDesktopPlacement = true,
|
||||
bool allowStatusBarPlacement = false,
|
||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
string? displayNameLocalizationKey = null,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = iconKey.Trim();
|
||||
Category = category.Trim();
|
||||
MinWidthCells = Math.Max(1, minWidthCells);
|
||||
MinHeightCells = Math.Max(1, minHeightCells);
|
||||
AllowDesktopPlacement = allowDesktopPlacement;
|
||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||
ResizeMode = resizeMode;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public int MinWidthCells { get; }
|
||||
|
||||
public int MinHeightCells { get; }
|
||||
|
||||
public bool AllowDesktopPlacement { get; }
|
||||
|
||||
public bool AllowStatusBarPlacement { get; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginDesktopComponentResizeMode
|
||||
{
|
||||
Proportional = 0,
|
||||
Free = 1
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal 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";
|
||||
}
|
||||
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PluginManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
public static PluginManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPackageInstallResult(
|
||||
PluginManifest Manifest,
|
||||
bool ReplacedExisting,
|
||||
bool RestartRequired);
|
||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string ExtractedPackagesDirectoryName = "packages";
|
||||
public const string PackagedDataDirectoryName = "data";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginSettingsPageRegistration
|
||||
{
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentNullException.ThrowIfNull(contentFactory);
|
||||
|
||||
Id = id.Trim();
|
||||
Title = title.Trim();
|
||||
ContentFactory = contentFactory;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
}
|
||||
@@ -5,6 +5,12 @@ 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", "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
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -15,5 +21,17 @@ Global
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{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
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -16,21 +16,6 @@
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="LanMountainDesktop">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
@@ -6,6 +7,8 @@ using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
@@ -15,6 +18,15 @@ namespace LanMountainDesktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private SettingsWindow? _traySettingsWindow;
|
||||
private TrayIcons? _trayIcons;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
|
||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
ConfigureWebViewUserDataFolder();
|
||||
@@ -25,12 +37,21 @@ public partial class App : Application
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
|
||||
InitializeTrayIcon();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
desktop.Exit += (_, _) =>
|
||||
{
|
||||
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
||||
DisposeTrayIcon();
|
||||
};
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
@@ -42,53 +63,66 @@ public partial class App : Application
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryStartCurrentProcess())
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
try
|
||||
{
|
||||
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
|
||||
{
|
||||
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
|
||||
existingWindow.Activate();
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsWindow = new SettingsWindow();
|
||||
settingsWindow.Closed += (_, _) =>
|
||||
{
|
||||
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
|
||||
{
|
||||
_traySettingsWindow = null;
|
||||
}
|
||||
};
|
||||
|
||||
_traySettingsWindow = settingsWindow;
|
||||
settingsWindow.Show();
|
||||
settingsWindow.Activate();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
|
||||
}
|
||||
}, DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
private static bool TryStartCurrentProcess()
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
try
|
||||
AppRestartService.TryRestartApplication();
|
||||
}
|
||||
|
||||
private void OnAppSettingsSaved(string _)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
return false;
|
||||
InitializeTrayIcon();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -130,4 +164,88 @@ public partial class App : Application
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService();
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
|
||||
var trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = new WindowIcon(iconStream),
|
||||
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||
Menu = BuildTrayMenu(),
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
_trayIcons = [trayIcon];
|
||||
TrayIcon.SetIcons(this, _trayIcons);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[TrayIcon] Failed to initialize tray icon: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private NativeMenu BuildTrayMenu()
|
||||
{
|
||||
var menu = new NativeMenu();
|
||||
|
||||
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "ÉèÖÃ"));
|
||||
settingsItem.Click += OnTraySettingsClick;
|
||||
menu.Items.Add(settingsItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var restartItem = new NativeMenuItem(L("tray.menu.restart", "ÖØÆôÓ¦ÓÃ"));
|
||||
restartItem.Click += OnTrayRestartClick;
|
||||
menu.Items.Add(restartItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var exitItem = new NativeMenuItem(L("tray.menu.exit", "Í˳öÓ¦ÓÃ"));
|
||||
exitItem.Click += OnTrayExitClick;
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcons is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrayIcon.SetIcons(this, null);
|
||||
foreach (var trayIcon in _trayIcons)
|
||||
{
|
||||
trayIcon.Dispose();
|
||||
}
|
||||
|
||||
_trayIcons = null;
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
# MiSans Font Notice
|
||||
# MiSans 字体说明
|
||||
|
||||
This app bundles MiSans fonts for consistent cross-device rendering.
|
||||
## 中文
|
||||
|
||||
## Included files
|
||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
||||
|
||||
### 包含文件
|
||||
|
||||
- `MiSans-Regular.ttf`
|
||||
- `MiSans-Semibold.ttf`
|
||||
- `MiSans-Bold.ttf`
|
||||
|
||||
## Source
|
||||
### 来源
|
||||
|
||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
||||
|
||||
### 许可与使用说明
|
||||
|
||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
||||
|
||||
## English
|
||||
|
||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
||||
|
||||
### Sources
|
||||
|
||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
||||
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
|
||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
||||
|
||||
## License and usage notes
|
||||
|
||||
- Script/package license in upstream repository: Apache-2.0
|
||||
- MiSans font copyright and additional usage terms:
|
||||
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
Please review and comply with the MiSans font terms when distributing this app.
|
||||
Please review and comply with the MiSans font terms before redistributing this application.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Weather Background Assets
|
||||
# 天气背景资源署名
|
||||
|
||||
Weather card background images are sourced from **Pexels** and used under the Pexels license:
|
||||
https://www.pexels.com/license/
|
||||
## 中文
|
||||
|
||||
## Sources
|
||||
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
### 原始来源
|
||||
|
||||
- `clear_sky.jpg`
|
||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
|
||||
- `storm.jpg`
|
||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||
|
||||
## Derived Variants (for widget scene mapping)
|
||||
### 派生资源
|
||||
|
||||
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
|
||||
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||
|
||||
- `clear_day.jpg` (from `clear_sky.jpg`)
|
||||
- `clear_night.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_day.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_night.jpg` (from `clear_sky.jpg`)
|
||||
- `rain_light.jpg` (from `rain.jpg`)
|
||||
- `rain_heavy.jpg` (from `rain.jpg`)
|
||||
- `storm_dark.jpg` (from `storm.jpg`)
|
||||
- `fog_haze.jpg` (from `storm.jpg`)
|
||||
- `snow_soft.jpg` (from `snow.jpg`)
|
||||
- `clear_day.jpg`
|
||||
- `clear_night.jpg`
|
||||
- `cloudy_day.jpg`
|
||||
- `cloudy_night.jpg`
|
||||
- `rain_light.jpg`
|
||||
- `rain_heavy.jpg`
|
||||
- `storm_dark.jpg`
|
||||
- `fog_haze.jpg`
|
||||
- `snow_soft.jpg`
|
||||
|
||||
## English
|
||||
|
||||
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
||||
# HyperOS3 天气资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||
|
||||
### 提取来源
|
||||
|
||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2` (Mi Weather)
|
||||
- Extraction date: 2026-03-03
|
||||
- Package: `com.miui.weather2`
|
||||
- Extraction date: `2026-03-03`
|
||||
|
||||
Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
||||
### 用途说明
|
||||
|
||||
Extracted weather icon paths inside APK (`res/*.webp`):
|
||||
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
|
||||
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
|
||||
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
|
||||
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
|
||||
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
|
||||
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
|
||||
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
|
||||
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
|
||||
- `res/mH1.webp` -> `Icons/icon_snow.webp`
|
||||
- `res/jB.webp` -> `Icons/icon_sleet.webp`
|
||||
- `res/Wl.webp` -> `Icons/icon_haze.webp`
|
||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
||||
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||
- 使用时应遵守小米相关许可与使用条款。
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
### 额外派生资源
|
||||
|
||||
## Soft Widget Icon Set (2026-03-05)
|
||||
|
||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
||||
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
||||
## English
|
||||
|
||||
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||
|
||||
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||
|
||||
@@ -385,6 +385,13 @@ public sealed class ComponentRegistry
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
|
||||
{
|
||||
var merged = _definitions.Values.ToList();
|
||||
merged.AddRange(definitions);
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
||||
{
|
||||
return _definitions.TryGetValue(componentId, out definition!);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public sealed record DesktopComponentRuntimeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentPlacementContextAware
|
||||
{
|
||||
void SetComponentPlacementContext(string componentId, string? placementId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentRuntimeContextAware
|
||||
{
|
||||
void SetComponentRuntimeContext(DesktopComponentRuntimeContext context);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentSettingsStoreAware
|
||||
{
|
||||
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
|
||||
}
|
||||
@@ -1,77 +1,38 @@
|
||||
# 组件系统模块(Component System Module)
|
||||
# 组件系统说明
|
||||
|
||||
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
|
||||
This directory provides the modular foundation for built-in component management and third-party extension integration.
|
||||
## 中文
|
||||
|
||||
## 核心文件职责(Core Files)
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
|
||||
Built-in component ID constants (for example `Clock`).
|
||||
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
|
||||
Component metadata model (name, category, minimum size, placement permissions).
|
||||
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
|
||||
Placement rules (minimum size, status-bar height rule, grid clamping).
|
||||
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
|
||||
Registry that merges built-in and extension components.
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
|
||||
Extension provider interface contract.
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
|
||||
JSON-based extension loader.
|
||||
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
|
||||
|
||||
## 第三方扩展契约(Extension Contract)
|
||||
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
|
||||
Third parties can provide component definitions via `IComponentExtensionProvider`.
|
||||
- 当前内置了 JSON 提供者,运行时扫描目录:
|
||||
Built-in JSON provider scans at runtime:
|
||||
- `Extensions/Components/*.json`(相对应用输出目录)
|
||||
`Extensions/Components/*.json` (relative to app output directory)
|
||||
### 主要职责
|
||||
|
||||
## 加载流程(Load Flow)
|
||||
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
|
||||
Register built-in components first via `ComponentRegistry.CreateDefault()`.
|
||||
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
|
||||
Merge extension components via `.RegisterExtensions(...)`.
|
||||
3. 主窗口通过注册中心校验组件合法性与放置权限。
|
||||
Main window validates component identity and placement permission through the registry.
|
||||
- 管理内置组件 ID 和元数据
|
||||
- 约束组件最小尺寸与可放置区域
|
||||
- 合并内置组件与扩展组件
|
||||
- 通过 JSON 或扩展提供者接入第三方组件
|
||||
|
||||
## JSON 清单格式(Manifest Schema)
|
||||
JSON 文件为数组,每一项代表一个组件定义。
|
||||
The JSON file is an array, where each item represents one component definition.
|
||||
### 关键文件
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "Weather",
|
||||
"displayName": "Weather",
|
||||
"iconKey": "WeatherSunny",
|
||||
"category": "Status",
|
||||
"minWidthCells": 1,
|
||||
"minHeightCells": 1,
|
||||
"allowStatusBarPlacement": true,
|
||||
"allowDesktopPlacement": true
|
||||
}
|
||||
]
|
||||
```
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量
|
||||
- `DesktopComponentDefinition.cs`:组件元数据模型
|
||||
- `ComponentPlacementRules.cs`:放置规则
|
||||
- `ComponentRegistry.cs`:组件注册中心
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:JSON 扩展加载器
|
||||
|
||||
字段说明(Field notes):
|
||||
- `id`:组件唯一 ID(建议英文、稳定不变)。
|
||||
Unique component ID (prefer stable English key).
|
||||
- `displayName`:显示名。
|
||||
Display name.
|
||||
- `iconKey`:图标键(由上层 UI 解释)。
|
||||
Icon key resolved by UI layer.
|
||||
- `category`:组件分类。
|
||||
Component category.
|
||||
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`。
|
||||
Minimum cell size, must satisfy `>= 1`.
|
||||
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
|
||||
Whether placing in top status bar is allowed.
|
||||
- `allowDesktopPlacement`:是否允许放到桌面区域。
|
||||
Whether placing in desktop area is allowed.
|
||||
### 扩展方式
|
||||
|
||||
## 放置规则摘要(Placement Rules Summary)
|
||||
- 最小尺寸约束:`minWidthCells >= 1` 且 `minHeightCells >= 1`。
|
||||
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
|
||||
- 状态栏约束:状态栏组件高度必须为 `1` 格。
|
||||
Status bar constraint: component height must be exactly `1` cell.
|
||||
- 越界约束:所有组件坐标会被网格边界钳制(clamp)。
|
||||
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
|
||||
- 当前默认扫描 `Extensions/Components/*.json`
|
||||
- 组件清单定义显示名、分类、最小尺寸和可放置区域
|
||||
- 主程序通过注册中心统一验证组件是否合法
|
||||
|
||||
## English
|
||||
|
||||
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- manage built-in component IDs and metadata
|
||||
- enforce placement rules
|
||||
- merge built-in and extension components
|
||||
- support third-party registration through JSON or provider contracts
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"app.title": "LanMountainDesktop",
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.settings": "Settings",
|
||||
"tray.menu.restart": "Restart App",
|
||||
"tray.menu.exit": "Exit App",
|
||||
"button.back_to_windows": "Back to Windows",
|
||||
"tooltip.back_to_windows": "Back to Windows",
|
||||
"tooltip.open_settings": "Settings",
|
||||
"settings.title": "Settings",
|
||||
"settings.shell.title": "Application Settings",
|
||||
"settings.shell.subtitle": "LanMountainDesktop standalone preferences",
|
||||
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
|
||||
"settings.shell.footer_hint": "Tray-opened settings are managed in this standalone window.",
|
||||
"settings.back_to_desktop": "Back to Desktop",
|
||||
"settings.nav_header": "Settings",
|
||||
"settings.nav.group_desktop": "Desktop",
|
||||
"settings.nav.group_system": "System",
|
||||
"settings.nav.group_extensions": "Extensions",
|
||||
"settings.nav.wallpaper": "Wallpaper",
|
||||
"settings.nav.grid": "Grid",
|
||||
"settings.nav.color": "Color",
|
||||
@@ -109,6 +120,8 @@
|
||||
"settings.weather.preview_header": "Connection Test",
|
||||
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
||||
"settings.weather.preview_button": "Test Fetch",
|
||||
"settings.weather.preview_section": "Weather Preview",
|
||||
"settings.weather.settings_section": "Settings",
|
||||
"settings.weather.preview_panel_header": "Weather Preview",
|
||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||
"settings.weather.refresh_button": "Refresh",
|
||||
@@ -129,6 +142,15 @@
|
||||
"settings.weather.status_city_empty": "No city location is configured.",
|
||||
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
|
||||
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
|
||||
"settings.weather.city_selection_label": "City Selection",
|
||||
"settings.weather.coordinates_selection_label": "Coordinate Location",
|
||||
"settings.weather.location_city_summary_desc": "Select the current city used for weather queries.",
|
||||
"settings.weather.location_coordinates_summary_desc": "Set latitude/longitude and optional location name used for weather queries.",
|
||||
"settings.weather.location_not_selected": "No location selected",
|
||||
"settings.weather.alert_list_label": "Exclude List",
|
||||
"settings.weather.alert_list_desc": "One exclusion rule per line.",
|
||||
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
|
||||
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
|
||||
"settings.weather.location_header": "Weather Location",
|
||||
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
||||
"settings.weather.location_placeholder": "e.g. Beijing",
|
||||
@@ -237,6 +259,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",
|
||||
@@ -268,9 +309,86 @@
|
||||
"settings.launcher.restore_button": "Show Again",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||
"settings.plugins.runtime_desc": "Manage plugin loading and backend isolation.",
|
||||
"settings.plugins.runtime_hint": "This page will host installed plugin management, permission review, and sandboxed backend runtime controls.",
|
||||
"settings.plugins.runtime_status": "Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.",
|
||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||
"settings.plugins.installed_header": "Installed Plugins",
|
||||
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
|
||||
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
|
||||
"settings.plugins.empty": "No plugins found.",
|
||||
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
||||
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "Enabled",
|
||||
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
|
||||
"settings.plugins.state.disabled": "Disabled",
|
||||
"settings.plugins.state.loaded": "Loaded",
|
||||
"settings.plugins.state.load_failed": "Load failed",
|
||||
"settings.plugins.toggle_on": "Enabled",
|
||||
"settings.plugins.toggle_off": "Disabled",
|
||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
"settings.plugins.toggle_state_enabled": "enabled",
|
||||
"settings.plugins.toggle_state_disabled": "disabled",
|
||||
"settings.plugins.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}",
|
||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||
"settings.nav.plugin_market": "Plugin Market",
|
||||
"settings.plugin_market.title": "Plugin Market",
|
||||
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||
"market.toolbar.search_placeholder": "Search plugins",
|
||||
"market.toolbar.refresh": "Refresh",
|
||||
"market.status.loading": "Loading the official plugin market...",
|
||||
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
|
||||
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
|
||||
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
|
||||
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
|
||||
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
|
||||
"market.status.install_failed_format": "Failed to install plugin: {0}",
|
||||
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
|
||||
"market.list.empty": "The plugin market has not been loaded yet.",
|
||||
"market.list.no_results": "No plugins match the current search.",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "Loaded",
|
||||
"market.card.pending_restart": "Restart required",
|
||||
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
||||
"market.detail.author": "Author",
|
||||
"market.detail.version": "Version",
|
||||
"market.detail.api_version": "API Version",
|
||||
"market.detail.min_host_version": "Minimum Host Version",
|
||||
"market.detail.installed_version": "Installed Version",
|
||||
"market.detail.not_installed": "Not installed",
|
||||
"market.detail.readme": "README",
|
||||
"market.detail.plugin_information": "Plugin Information",
|
||||
"market.detail.author_subtitle_format": "By {0}",
|
||||
"market.detail.package_size": "Package Size",
|
||||
"market.detail.published_at": "Published At",
|
||||
"market.detail.updated_at": "Updated At",
|
||||
"market.detail.tags": "Tags",
|
||||
"market.detail.project": "Project",
|
||||
"market.detail.state": "Install State",
|
||||
"market.detail.market_source": "Market Source",
|
||||
"market.detail.homepage": "Homepage",
|
||||
"market.detail.repository": "Repository",
|
||||
"market.detail.release_notes": "Release Notes",
|
||||
"market.detail.state.not_installed": "Not installed",
|
||||
"market.detail.state.update_available": "Update available",
|
||||
"market.detail.state.installed": "Installed",
|
||||
"market.detail.unknown": "Unknown",
|
||||
"market.button.install": "Install",
|
||||
"market.button.update": "Update",
|
||||
"market.button.installed": "Installed",
|
||||
"market.button.installing": "Installing...",
|
||||
"button.component_library": "Edit Desktop",
|
||||
"tooltip.component_library": "Edit Desktop",
|
||||
"component_library.title": "Widgets",
|
||||
@@ -617,3 +735,5 @@
|
||||
"placement.center": "Center",
|
||||
"placement.tile": "Tile"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
{
|
||||
"app.title": "LanMountainDesktop",
|
||||
"tray.tooltip": "LanMountainDesktop",
|
||||
"tray.menu.settings": "设置",
|
||||
"tray.menu.restart": "重启应用",
|
||||
"tray.menu.exit": "退出应用",
|
||||
"button.back_to_windows": "回到Windows",
|
||||
"tooltip.back_to_windows": "回到Windows",
|
||||
"tooltip.open_settings": "设置",
|
||||
"settings.title": "设置",
|
||||
"settings.shell.title": "应用设置",
|
||||
"settings.shell.subtitle": "LanMountainDesktop 独立设置窗口",
|
||||
"settings.shell.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
|
||||
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立窗口中管理。",
|
||||
"settings.back_to_desktop": "返回桌面",
|
||||
"settings.nav_header": "设置选项",
|
||||
"settings.nav.group_desktop": "桌面",
|
||||
"settings.nav.group_system": "系统",
|
||||
"settings.nav.group_extensions": "扩展",
|
||||
"settings.nav.wallpaper": "壁纸",
|
||||
"settings.nav.grid": "网格",
|
||||
"settings.nav.color": "颜色",
|
||||
@@ -109,6 +120,8 @@
|
||||
"settings.weather.preview_header": "连接测试",
|
||||
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||
"settings.weather.preview_button": "测试获取",
|
||||
"settings.weather.preview_section": "天气预览",
|
||||
"settings.weather.settings_section": "设置",
|
||||
"settings.weather.preview_panel_header": "天气预览",
|
||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||
"settings.weather.refresh_button": "刷新",
|
||||
@@ -129,6 +142,15 @@
|
||||
"settings.weather.status_city_empty": "尚未配置城市位置。",
|
||||
"settings.weather.status_city_format": "模式:{0}|{1}|Key:{2}",
|
||||
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}|Key:{3}",
|
||||
"settings.weather.city_selection_label": "城市选择",
|
||||
"settings.weather.coordinates_selection_label": "坐标定位",
|
||||
"settings.weather.location_city_summary_desc": "选择当前所在的城市,用于天气查询。",
|
||||
"settings.weather.location_coordinates_summary_desc": "设置经纬度与可选的位置名称,用于天气查询。",
|
||||
"settings.weather.location_not_selected": "未选择位置",
|
||||
"settings.weather.alert_list_label": "排除列表",
|
||||
"settings.weather.alert_list_desc": "一行一条排除项。",
|
||||
"settings.weather.no_tls_toggle": "允许在兼容性较差的网络环境下回退到非 TLS 请求",
|
||||
"settings.weather.footer_hint": "桌面上的天气组件会共享这里配置的天气位置与预警排除规则。",
|
||||
"settings.weather.location_header": "天气位置",
|
||||
"settings.weather.location_desc": "设置天气组件使用的位置。",
|
||||
"settings.weather.location_placeholder": "例如:北京",
|
||||
@@ -237,6 +259,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": "图片文件",
|
||||
@@ -268,9 +309,86 @@
|
||||
"settings.launcher.restore_button": "重新显示",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.runtime_header": "插件运行时",
|
||||
"settings.plugins.runtime_desc": "管理插件加载与后端隔离运行。",
|
||||
"settings.plugins.runtime_hint": "这里将承载已安装插件、权限审查和沙盒后端运行时控制。",
|
||||
"settings.plugins.runtime_status": "插件管理界面尚未接入实际数据。下一步是把加载器、权限和 worker 隔离状态接到这里。",
|
||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||
"settings.plugins.installed_header": "已安装插件",
|
||||
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
|
||||
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
|
||||
"settings.plugins.empty": "未找到插件。",
|
||||
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "已启用",
|
||||
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
|
||||
"settings.plugins.state.disabled": "已禁用",
|
||||
"settings.plugins.state.loaded": "已加载",
|
||||
"settings.plugins.state.load_failed": "加载失败",
|
||||
"settings.plugins.toggle_on": "启用",
|
||||
"settings.plugins.toggle_off": "禁用",
|
||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||
"settings.plugins.toggle_state_enabled": "启用",
|
||||
"settings.plugins.toggle_state_disabled": "禁用",
|
||||
"settings.plugins.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}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"settings.nav.plugin_market": "插件市场",
|
||||
"settings.plugin_market.title": "插件市场",
|
||||
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
"market.status.loading": "正在加载官方插件市场...",
|
||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||
"market.list.empty": "插件市场尚未加载。",
|
||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "已加载",
|
||||
"market.card.pending_restart": "需要重启",
|
||||
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
||||
"market.detail.author": "作者",
|
||||
"market.detail.version": "版本",
|
||||
"market.detail.api_version": "API 版本",
|
||||
"market.detail.min_host_version": "最低宿主版本",
|
||||
"market.detail.installed_version": "已安装版本",
|
||||
"market.detail.not_installed": "未安装",
|
||||
"market.detail.readme": "README",
|
||||
"market.detail.plugin_information": "插件信息",
|
||||
"market.detail.author_subtitle_format": "作者:{0}",
|
||||
"market.detail.package_size": "包大小",
|
||||
"market.detail.published_at": "首次发布",
|
||||
"market.detail.updated_at": "最近更新",
|
||||
"market.detail.tags": "标签",
|
||||
"market.detail.project": "项目",
|
||||
"market.detail.state": "安装状态",
|
||||
"market.detail.market_source": "市场源",
|
||||
"market.detail.homepage": "主页",
|
||||
"market.detail.repository": "仓库",
|
||||
"market.detail.release_notes": "发布说明",
|
||||
"market.detail.state.not_installed": "未安装",
|
||||
"market.detail.state.update_available": "可更新",
|
||||
"market.detail.state.installed": "已安装",
|
||||
"market.detail.unknown": "未知",
|
||||
"market.button.install": "安装",
|
||||
"market.button.update": "更新",
|
||||
"market.button.installed": "已安装",
|
||||
"market.button.installing": "安装中...",
|
||||
"button.component_library": "桌面编辑",
|
||||
"tooltip.component_library": "桌面编辑",
|
||||
"component_library.title": "桌面编辑",
|
||||
@@ -617,3 +735,5 @@
|
||||
"placement.center": "居中",
|
||||
"placement.tile": "平铺"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public int SettingsTabIndex { get; set; } = 0;
|
||||
|
||||
public string? SettingsTabTag { get; set; }
|
||||
|
||||
public string LanguageCode { get; set; } = "zh-CN";
|
||||
|
||||
public string? TimeZoneId { get; set; }
|
||||
@@ -46,6 +48,8 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public bool AutoStartWithWindows { get; set; }
|
||||
|
||||
public string AppRenderMode { get; set; } = "Default";
|
||||
|
||||
public bool AutoCheckUpdates { get; set; } = true;
|
||||
|
||||
public bool IncludePrereleaseUpdates { get; set; }
|
||||
@@ -70,15 +74,7 @@ public sealed class AppSettingsSnapshot
|
||||
|
||||
public int StatusBarCustomSpacingPercent { get; set; } = 12;
|
||||
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; } = 0;
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||
|
||||
public List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
public List<string> DisabledPluginIds { get; set; } = [];
|
||||
|
||||
public AppSettingsSnapshot Clone()
|
||||
{
|
||||
@@ -90,35 +86,8 @@ public sealed class AppSettingsSnapshot
|
||||
clone.PinnedTaskbarActions = PinnedTaskbarActions is { Count: > 0 }
|
||||
? new List<string>(PinnedTaskbarActions)
|
||||
: [];
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherFolderPaths)
|
||||
: [];
|
||||
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherAppPaths)
|
||||
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
|
||||
? new List<string>(DisabledPluginIds)
|
||||
: [];
|
||||
|
||||
return clone;
|
||||
|
||||
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (DesktopLayoutSettingsSnapshot)MemberwiseClone();
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class LauncherSettingsSnapshot
|
||||
{
|
||||
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||
|
||||
public List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
|
||||
public LauncherSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
|
||||
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherFolderPaths)
|
||||
: [];
|
||||
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherAppPaths)
|
||||
: [];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,51 @@
|
||||
# Desktop Packaging Guide
|
||||
# 桌面端打包指南
|
||||
|
||||
## Prerequisites
|
||||
- Install `.NET SDK 10`
|
||||
- Windows installer build only:
|
||||
- Install `Inno Setup 6` (`ISCC.exe`)
|
||||
## 中文
|
||||
|
||||
## Local packaging commands
|
||||
本指南说明阑山桌面的本地打包和 CI 打包流程。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 安装 .NET SDK 10
|
||||
- Windows 安装包需要 Inno Setup 6(`ISCC.exe`)
|
||||
|
||||
### 本地打包命令
|
||||
|
||||
#### Windows 安装包
|
||||
|
||||
### Windows installer (`win-x64`)
|
||||
```powershell
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/win-x64`
|
||||
- Installer: `artifacts/installer`
|
||||
#### Linux 包
|
||||
|
||||
### Linux package (`linux-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/linux-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-linux-x64.zip`
|
||||
#### macOS 包
|
||||
|
||||
### macOS package (`osx-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/osx-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-osx-x64.zip`
|
||||
### 产物位置
|
||||
|
||||
## Optional script flags
|
||||
```powershell
|
||||
# Publish only (skip Windows installer step)
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
|
||||
- 发布目录:`artifacts/publish/<rid>`
|
||||
- 安装包或压缩包:`artifacts/installer` 或 `artifacts/packages`
|
||||
|
||||
# Publish only (skip Linux/macOS zip package step)
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
|
||||
```
|
||||
### CI 流程
|
||||
|
||||
## Runtime dependency notes
|
||||
- Linux build does not bundle a native `libvlc` package from NuGet.
|
||||
- Install VLC runtime on target machine, for example:
|
||||
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
|
||||
- macOS packaging target in CI is currently `osx-x64`.
|
||||
- 工作流文件:`.github/workflows/windows-ci.yml`
|
||||
- 日常构建会验证桌面端可编译
|
||||
- 手动触发或 `v*` 标签可生成正式包并上传到 Release
|
||||
|
||||
## CI workflow
|
||||
- Workflow file: `.github/workflows/windows-ci.yml`
|
||||
- Workflow name: `Desktop CI`
|
||||
## English
|
||||
|
||||
Jobs:
|
||||
- `Validate Build (Windows)` runs on every push and pull request.
|
||||
- Package flow runs on manual trigger or `v*` tag push:
|
||||
- `Resolve Package Version` (single shared version source)
|
||||
- `Package (Windows)` (`win-x64` installer)
|
||||
- `Package (Linux)` (`linux-x64` zip)
|
||||
- `Package (macOS)` (`osx-x64` zip)
|
||||
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
|
||||
This guide covers local packaging and CI packaging for LanMountainDesktop.
|
||||
|
||||
### Trigger manual packaging
|
||||
1. Open GitHub Actions.
|
||||
2. Choose `Desktop CI`.
|
||||
3. Click `Run workflow`.
|
||||
4. Optional: set `version` input, for example `1.0.1`.
|
||||
### Key points
|
||||
|
||||
### Trigger by tag
|
||||
```powershell
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
```
|
||||
- use `scripts/package.ps1` with the target runtime identifier
|
||||
- Windows installer requires Inno Setup
|
||||
- CI can publish artifacts and attach them to GitHub Releases
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
171
LanMountainDesktop/Services/AppRestartService.cs
Normal file
171
LanMountainDesktop/Services/AppRestartService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class AppSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
@@ -21,6 +23,8 @@ public sealed class AppSettingsService
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
@@ -88,6 +92,8 @@ public sealed class AppSettingsService
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -2,12 +2,13 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class ComponentSettingsService
|
||||
public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -18,12 +19,14 @@ public sealed class ComponentSettingsService
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static ComponentSettingsSnapshot? _cachedSnapshot;
|
||||
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
private string _scopedComponentId = string.Empty;
|
||||
private string _scopedPlacementId = string.Empty;
|
||||
|
||||
public ComponentSettingsService()
|
||||
{
|
||||
@@ -35,51 +38,17 @@ public sealed class ComponentSettingsService
|
||||
|
||||
public ComponentSettingsSnapshot Load()
|
||||
{
|
||||
if (HasScopedComponentContext())
|
||||
{
|
||||
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
var document = LoadDocumentLocked();
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -90,15 +59,21 @@ public sealed class ComponentSettingsService
|
||||
|
||||
public void Save(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (HasScopedComponentContext())
|
||||
{
|
||||
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
var document = LoadDocumentLocked();
|
||||
document.DefaultSettings = snapshotToPersist;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -107,7 +82,201 @@ public sealed class ComponentSettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsSnapshot snapshot)
|
||||
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (!string.IsNullOrWhiteSpace(instanceKey) &&
|
||||
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
|
||||
{
|
||||
return snapshot.Clone();
|
||||
}
|
||||
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var normalizedSnapshot = NormalizeSnapshot(snapshot);
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
Save(normalizedSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.InstanceSettings[instanceKey] = normalizedSnapshot;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteForComponent(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var changed = document.InstanceSettings.Remove(instanceKey);
|
||||
changed |= document.PluginSettings.Remove(instanceKey);
|
||||
if (changed)
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey) ||
|
||||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
|
||||
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void DeletePluginSettings(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
if (document.PluginSettings.Remove(instanceKey))
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
public void SetScopedComponentContext(string componentId, string? placementId)
|
||||
{
|
||||
_scopedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
_scopedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void ClearScopedComponentContext()
|
||||
{
|
||||
_scopedComponentId = string.Empty;
|
||||
_scopedPlacementId = string.Empty;
|
||||
}
|
||||
|
||||
public static void ApplyScopedContextToTarget(object? target, string componentId, string? placementId)
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
foreach (var field in target.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.FieldType != typeof(ComponentSettingsService))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in target.GetType().GetProperties(flags))
|
||||
{
|
||||
if (property.PropertyType != typeof(ComponentSettingsService) ||
|
||||
!property.CanRead)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
@@ -121,7 +290,7 @@ public sealed class ComponentSettingsService
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsSnapshot snapshot)
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
@@ -135,17 +304,78 @@ public sealed class ComponentSettingsService
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComponentSettingsSnapshot LoadSnapshotFromDisk()
|
||||
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
||||
};
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("pluginSettings", out _)))
|
||||
{
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||
return NormalizeDocument(snapshot);
|
||||
}
|
||||
|
||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new ComponentSettingsSnapshot();
|
||||
return new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +434,13 @@ public sealed class ComponentSettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsSnapshot snapshot)
|
||||
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
|
||||
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
@@ -257,6 +493,40 @@ public sealed class ComponentSettingsService
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
|
||||
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
|
||||
|
||||
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.InstanceSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceSettings[key] = NormalizeSnapshot(pair.Value);
|
||||
}
|
||||
|
||||
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.PluginSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pluginSettings[key] = pair.Value.Clone();
|
||||
}
|
||||
|
||||
normalized.InstanceSettings = instanceSettings;
|
||||
normalized.PluginSettings = pluginSettings;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||
{
|
||||
@@ -360,7 +630,30 @@ public sealed class ComponentSettingsService
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private void UpdateCache(ComponentSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
private static string BuildInstanceKey(string componentId, string? placementId)
|
||||
{
|
||||
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{normalizedComponentId}::{normalizedPlacementId}";
|
||||
}
|
||||
|
||||
private static string NormalizeInstanceKey(string? key)
|
||||
{
|
||||
return key?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private bool HasScopedComponentContext()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
|
||||
!string.IsNullOrWhiteSpace(_scopedPlacementId);
|
||||
}
|
||||
|
||||
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
@@ -368,6 +661,39 @@ public sealed class ComponentSettingsService
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
|
||||
|
||||
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ComponentSettingsDocumentSnapshot Clone()
|
||||
{
|
||||
var clone = new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
|
||||
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
|
||||
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var pair in InstanceSettings)
|
||||
{
|
||||
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
}
|
||||
|
||||
foreach (var pair in PluginSettings)
|
||||
{
|
||||
clone.PluginSettings[pair.Key] = pair.Value.Clone();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LegacyComponentSettingsSnapshot
|
||||
{
|
||||
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
|
||||
|
||||
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class DesktopComponentRegistryFactory
|
||||
{
|
||||
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||
|
||||
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
|
||||
return pluginDefinitions.Count == 0
|
||||
? registry
|
||||
: registry.RegisterComponents(pluginDefinitions);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
|
||||
ComponentRegistry componentRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
|
||||
var registeredIds = new HashSet<string>(
|
||||
registrations.Select(registration => registration.ComponentId),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (pluginRuntimeService is not null)
|
||||
{
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!registeredIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations.Add(new DesktopComponentRuntimeRegistration(
|
||||
registration.ComponentId,
|
||||
registration.DisplayNameLocalizationKey,
|
||||
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||
registration.CornerRadiusResolver));
|
||||
}
|
||||
}
|
||||
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
|
||||
}
|
||||
|
||||
private static List<DesktopComponentDefinition> GetPluginDefinitions(
|
||||
ComponentRegistry baseRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var definitions = new List<DesktopComponentDefinition>();
|
||||
if (pluginRuntimeService is null)
|
||||
{
|
||||
return definitions;
|
||||
}
|
||||
|
||||
var knownIds = new HashSet<string>(
|
||||
baseRegistry.GetAll().Select(definition => definition.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!knownIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.Add(new DesktopComponentDefinition(
|
||||
registration.ComponentId,
|
||||
registration.DisplayName,
|
||||
registration.IconKey,
|
||||
registration.Category,
|
||||
registration.MinWidthCells,
|
||||
registration.MinHeightCells,
|
||||
registration.AllowStatusBarPlacement,
|
||||
registration.AllowDesktopPlacement,
|
||||
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
|
||||
? DesktopComponentResizeMode.Free
|
||||
: DesktopComponentResizeMode.Proportional));
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static Control CreatePluginControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
DesktopComponentControlFactoryContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
|
||||
return CreatePluginErrorControl(contribution, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Control CreatePluginErrorControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
Exception exception)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(12),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = contribution.Registration.DisplayName,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = exception.Message,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
248
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
248
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class DesktopLayoutSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public DesktopLayoutSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
DesktopLayoutSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new DesktopLayoutSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
DesktopPageCount = legacy.DesktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
|
||||
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
|
||||
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
|
||||
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
|
||||
if (normalized.DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in normalized.DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
|
||||
PageIndex = Math.Max(0, placement.PageIndex),
|
||||
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
|
||||
Row = Math.Max(0, placement.Row),
|
||||
Column = Math.Max(0, placement.Column),
|
||||
WidthCells = Math.Max(1, placement.WidthCells),
|
||||
HeightCells = Math.Max(1, placement.HeightCells)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
normalized.DesktopComponentPlacements = placements;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyDesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public GitHubReleaseUpdateService(
|
||||
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
_downloadService = new ResumableDownloadService(_httpClient);
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
@@ -187,59 +190,37 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
|
||||
}
|
||||
|
||||
try
|
||||
var progressAdapter = progress is null
|
||||
? null
|
||||
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
|
||||
|
||||
var result = await _downloadService.DownloadAsync(
|
||||
asset.BrowserDownloadUrl,
|
||||
destinationFilePath,
|
||||
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
|
||||
progressAdapter,
|
||||
cancellationToken);
|
||||
|
||||
return result.Success
|
||||
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
||||
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||
string tagName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.GetAsync(
|
||||
asset.BrowserDownloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new UpdateDownloadResult(
|
||||
false,
|
||||
null,
|
||||
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ??
|
||||
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
|
||||
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var destinationStream = File.Create(destinationFilePath);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long totalRead = 0;
|
||||
int read;
|
||||
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
totalRead += read;
|
||||
|
||||
if (contentLength > 0)
|
||||
{
|
||||
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(1d);
|
||||
|
||||
return new UpdateDownloadResult(true, destinationFilePath, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, ex.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
var url =
|
||||
$"https://api.github.com/repos/{_owner}/{_repo}/releases/tags/{Uri.EscapeDataString(tagName.Trim())}";
|
||||
var responseText = await GetResponseTextAsync(url, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
return ParseRelease(document.RootElement);
|
||||
}
|
||||
|
||||
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public interface IComponentInstanceSettingsStore
|
||||
{
|
||||
ComponentSettingsSnapshot Load();
|
||||
|
||||
void Save(ComponentSettingsSnapshot snapshot);
|
||||
|
||||
ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId);
|
||||
|
||||
void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot);
|
||||
|
||||
void DeleteForComponent(string componentId, string? placementId);
|
||||
|
||||
T LoadPluginSettings<T>(string componentId, string? placementId) where T : new();
|
||||
|
||||
void SavePluginSettings<T>(string componentId, string? placementId, T settings);
|
||||
|
||||
void DeletePluginSettings(string componentId, string? placementId);
|
||||
}
|
||||
242
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
242
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class LauncherSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static LauncherSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public LauncherSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public LauncherSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
LauncherSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new LauncherSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private LauncherSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new LauncherSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyLauncherSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new LauncherSettingsSnapshot
|
||||
{
|
||||
HiddenLauncherFolderPaths = legacy.HiddenLauncherFolderPaths ?? [],
|
||||
HiddenLauncherAppPaths = legacy.HiddenLauncherAppPaths ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static LauncherSettingsSnapshot NormalizeSnapshot(LauncherSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new LauncherSettingsSnapshot();
|
||||
normalized.HiddenLauncherFolderPaths = NormalizeKeys(normalized.HiddenLauncherFolderPaths);
|
||||
normalized.HiddenLauncherAppPaths = NormalizeKeys(normalized.HiddenLauncherAppPaths);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeKeys(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UpdateCache(LauncherSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyLauncherSettingsSnapshot
|
||||
{
|
||||
public List<string>? HiddenLauncherFolderPaths { get; set; }
|
||||
|
||||
public List<string>? HiddenLauncherAppPaths { get; set; }
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal file
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
940
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
940
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
@@ -0,0 +1,940 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record DownloadProgressInfo(
|
||||
long DownloadedBytes,
|
||||
long? TotalBytes,
|
||||
double Progress,
|
||||
bool IsResuming,
|
||||
bool IsParallel);
|
||||
|
||||
public sealed record DownloadOptions(
|
||||
long? ExpectedSizeBytes = null,
|
||||
int MaxParallelSegments = 4,
|
||||
int ParallelThresholdBytes = 8 * 1024 * 1024,
|
||||
int BufferSize = 128 * 1024);
|
||||
|
||||
public sealed record DownloadResult(
|
||||
bool Success,
|
||||
string? FilePath,
|
||||
string? ErrorMessage,
|
||||
bool UsedResume,
|
||||
bool UsedParallelDownload);
|
||||
|
||||
public sealed class ResumableDownloadService
|
||||
{
|
||||
private static readonly JsonSerializerOptions MetadataSerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public ResumableDownloadService(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadAsync(
|
||||
string source,
|
||||
string destinationFilePath,
|
||||
DownloadOptions? options = null,
|
||||
IProgress<DownloadProgressInfo>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
|
||||
|
||||
var normalizedOptions = NormalizeOptions(options);
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
return await CopyLocalFileAsync(
|
||||
source,
|
||||
destinationFilePath,
|
||||
normalizedOptions,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) ||
|
||||
(sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false);
|
||||
}
|
||||
|
||||
return await DownloadRemoteFileAsync(
|
||||
sourceUri,
|
||||
destinationFilePath,
|
||||
normalizedOptions,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new DownloadResult(false, null, ex.Message, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> CopyLocalFileAsync(
|
||||
string sourceFilePath,
|
||||
string destinationFilePath,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourceFilePath);
|
||||
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
||||
var totalBytes = new FileInfo(fullSourcePath).Length;
|
||||
|
||||
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
||||
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
|
||||
PrepareDestination(fullDestinationPath);
|
||||
|
||||
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
||||
{
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
|
||||
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
||||
}
|
||||
|
||||
long existingBytes = 0;
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
existingBytes = new FileInfo(tempFilePath).Length;
|
||||
if (existingBytes > totalBytes)
|
||||
{
|
||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
existingBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(tempFilePath))
|
||||
{
|
||||
await using var tempCreateStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
|
||||
if (existingBytes >= totalBytes)
|
||||
{
|
||||
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
|
||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
await using var sourceStream = new FileStream(
|
||||
fullSourcePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var destinationStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
if (existingBytes > 0)
|
||||
{
|
||||
sourceStream.Seek(existingBytes, SeekOrigin.Begin);
|
||||
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
await CopyStreamAsync(
|
||||
sourceStream,
|
||||
destinationStream,
|
||||
existingBytes,
|
||||
totalBytes,
|
||||
isResuming: existingBytes > 0,
|
||||
isParallel: false,
|
||||
options.BufferSize,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
CompleteDownload(tempFilePath, fullDestinationPath, metadataFilePath);
|
||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> DownloadRemoteFileAsync(
|
||||
Uri sourceUri,
|
||||
string destinationFilePath,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
||||
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
||||
var metadataFilePath = BuildMetadataFilePath(fullDestinationPath);
|
||||
PrepareDestination(fullDestinationPath);
|
||||
|
||||
var probe = await ProbeRemoteFileAsync(sourceUri, cancellationToken);
|
||||
var totalBytes = probe.TotalBytes ?? options.ExpectedSizeBytes;
|
||||
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
||||
{
|
||||
progress?.Report(new DownloadProgressInfo(
|
||||
totalBytes ?? new FileInfo(fullDestinationPath).Length,
|
||||
totalBytes,
|
||||
1d,
|
||||
false,
|
||||
false));
|
||||
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
||||
}
|
||||
|
||||
var canUseParallel = probe.SupportsRanges &&
|
||||
totalBytes is > 0 &&
|
||||
totalBytes.Value >= options.ParallelThresholdBytes &&
|
||||
options.MaxParallelSegments > 1;
|
||||
|
||||
try
|
||||
{
|
||||
var result = canUseParallel
|
||||
? await DownloadRemoteInParallelAsync(
|
||||
sourceUri,
|
||||
fullDestinationPath,
|
||||
tempFilePath,
|
||||
metadataFilePath,
|
||||
totalBytes!.Value,
|
||||
options,
|
||||
progress,
|
||||
cancellationToken)
|
||||
: await DownloadRemoteSequentiallyAsync(
|
||||
sourceUri,
|
||||
fullDestinationPath,
|
||||
tempFilePath,
|
||||
metadataFilePath,
|
||||
totalBytes,
|
||||
probe.SupportsRanges,
|
||||
options,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (RangeRequestNotSupportedException)
|
||||
{
|
||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
return await DownloadRemoteSequentiallyAsync(
|
||||
sourceUri,
|
||||
fullDestinationPath,
|
||||
tempFilePath,
|
||||
metadataFilePath,
|
||||
totalBytes,
|
||||
allowResume: false,
|
||||
options,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> DownloadRemoteSequentiallyAsync(
|
||||
Uri sourceUri,
|
||||
string destinationFilePath,
|
||||
string tempFilePath,
|
||||
string metadataFilePath,
|
||||
long? totalBytes,
|
||||
bool allowResume,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
long existingBytes = 0;
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
existingBytes = new FileInfo(tempFilePath).Length;
|
||||
if (totalBytes is > 0 && existingBytes > totalBytes.Value)
|
||||
{
|
||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
existingBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allowResume && existingBytes > 0)
|
||||
{
|
||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
existingBytes = 0;
|
||||
}
|
||||
|
||||
if (totalBytes is > 0 && existingBytes >= totalBytes.Value)
|
||||
{
|
||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, existingBytes > 0, false));
|
||||
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
|
||||
if (allowResume && existingBytes > 0)
|
||||
{
|
||||
request.Headers.Range = new RangeHeaderValue(existingBytes, null);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (allowResume && existingBytes > 0)
|
||||
{
|
||||
if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && totalBytes is > 0 && existingBytes == totalBytes)
|
||||
{
|
||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes.Value, totalBytes, 1d, true, false));
|
||||
return new DownloadResult(true, destinationFilePath, null, true, false);
|
||||
}
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
{
|
||||
throw new RangeRequestNotSupportedException("The server did not honor the resume range request.");
|
||||
}
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var destinationStream = new FileStream(
|
||||
tempFilePath,
|
||||
existingBytes > 0 ? FileMode.Open : FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
if (existingBytes > 0)
|
||||
{
|
||||
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
var effectiveTotalBytes = totalBytes;
|
||||
if (effectiveTotalBytes is null && response.Content.Headers.ContentLength is > 0)
|
||||
{
|
||||
effectiveTotalBytes = existingBytes + response.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
await CopyStreamAsync(
|
||||
sourceStream,
|
||||
destinationStream,
|
||||
existingBytes,
|
||||
effectiveTotalBytes,
|
||||
isResuming: existingBytes > 0,
|
||||
isParallel: false,
|
||||
options.BufferSize,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
||||
return new DownloadResult(true, destinationFilePath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> DownloadRemoteInParallelAsync(
|
||||
Uri sourceUri,
|
||||
string destinationFilePath,
|
||||
string tempFilePath,
|
||||
string metadataFilePath,
|
||||
long totalBytes,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestedSegments = Math.Min(options.MaxParallelSegments, CalculateRecommendedSegments(totalBytes));
|
||||
var metadata = await LoadOrCreateMetadataAsync(
|
||||
sourceUri,
|
||||
tempFilePath,
|
||||
metadataFilePath,
|
||||
totalBytes,
|
||||
requestedSegments,
|
||||
cancellationToken);
|
||||
|
||||
await using (var tempStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.RandomAccess))
|
||||
{
|
||||
if (tempStream.Length != totalBytes)
|
||||
{
|
||||
tempStream.SetLength(totalBytes);
|
||||
}
|
||||
}
|
||||
|
||||
var initialDownloadedBytes = metadata.Segments.Sum(segment => segment.CompletedBytes);
|
||||
ReportProgress(progress, initialDownloadedBytes, totalBytes, initialDownloadedBytes > 0, true);
|
||||
|
||||
if (initialDownloadedBytes >= totalBytes)
|
||||
{
|
||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
||||
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
|
||||
}
|
||||
|
||||
long downloadedBytes = initialDownloadedBytes;
|
||||
var metadataWriter = new MetadataWriter(metadataFilePath, metadata);
|
||||
|
||||
try
|
||||
{
|
||||
var tasks = metadata.Segments
|
||||
.Where(segment => segment.CompletedBytes < segment.Length)
|
||||
.Select(segment => DownloadSegmentAsync(
|
||||
sourceUri,
|
||||
tempFilePath,
|
||||
segment,
|
||||
options.BufferSize,
|
||||
delta =>
|
||||
{
|
||||
var currentDownloaded = Interlocked.Add(ref downloadedBytes, delta);
|
||||
ReportProgress(progress, currentDownloaded, totalBytes, initialDownloadedBytes > 0, true);
|
||||
},
|
||||
metadataWriter,
|
||||
cancellationToken))
|
||||
.ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
await metadataWriter.FlushAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await metadataWriter.FlushAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
CompleteDownload(tempFilePath, destinationFilePath, metadataFilePath);
|
||||
ReportProgress(progress, totalBytes, totalBytes, initialDownloadedBytes > 0, true);
|
||||
return new DownloadResult(true, destinationFilePath, null, initialDownloadedBytes > 0, true);
|
||||
}
|
||||
|
||||
private async Task DownloadSegmentAsync(
|
||||
Uri sourceUri,
|
||||
string tempFilePath,
|
||||
DownloadSegmentState segment,
|
||||
int bufferSize,
|
||||
Action<int> reportDownloadedBytes,
|
||||
MetadataWriter metadataWriter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rangeStart = segment.Start + segment.CompletedBytes;
|
||||
if (rangeStart > segment.EndInclusive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
|
||||
request.Headers.Range = new RangeHeaderValue(rangeStart, segment.EndInclusive);
|
||||
|
||||
using var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (response.StatusCode != HttpStatusCode.PartialContent)
|
||||
{
|
||||
throw new RangeRequestNotSupportedException(
|
||||
$"The server returned HTTP {(int)response.StatusCode} for range {rangeStart}-{segment.EndInclusive}.");
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var contentRange = response.Content.Headers.ContentRange;
|
||||
if (contentRange?.From != rangeStart || contentRange.To != segment.EndInclusive)
|
||||
{
|
||||
throw new RangeRequestNotSupportedException("The server returned an unexpected content range.");
|
||||
}
|
||||
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var destinationStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.ReadWrite,
|
||||
bufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.RandomAccess);
|
||||
destinationStream.Seek(rangeStart, SeekOrigin.Begin);
|
||||
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
|
||||
try
|
||||
{
|
||||
while (segment.CompletedBytes < segment.Length)
|
||||
{
|
||||
var remainingBytes = segment.Length - segment.CompletedBytes;
|
||||
var readSize = (int)Math.Min(buffer.Length, remainingBytes);
|
||||
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, readSize), cancellationToken);
|
||||
if (read <= 0)
|
||||
{
|
||||
throw new EndOfStreamException(
|
||||
$"Unexpected end of stream while downloading range {segment.Start}-{segment.EndInclusive}.");
|
||||
}
|
||||
|
||||
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
segment.CompletedBytes += read;
|
||||
reportDownloadedBytes(read);
|
||||
metadataWriter.MarkDirty();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<RemoteProbeResult> ProbeRemoteFileAsync(Uri sourceUri, CancellationToken cancellationToken)
|
||||
{
|
||||
long? totalBytes = null;
|
||||
var supportsRanges = false;
|
||||
|
||||
try
|
||||
{
|
||||
using var headRequest = new HttpRequestMessage(HttpMethod.Head, sourceUri);
|
||||
using var headResponse = await _httpClient.SendAsync(
|
||||
headRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (headResponse.IsSuccessStatusCode)
|
||||
{
|
||||
totalBytes = headResponse.Content.Headers.ContentLength;
|
||||
supportsRanges = headResponse.Headers.AcceptRanges.Any(
|
||||
value => string.Equals(value, "bytes", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to a small range probe when HEAD is unsupported or blocked.
|
||||
}
|
||||
|
||||
if (supportsRanges && totalBytes is > 0)
|
||||
{
|
||||
return new RemoteProbeResult(totalBytes, true);
|
||||
}
|
||||
|
||||
using var rangeRequest = new HttpRequestMessage(HttpMethod.Get, sourceUri);
|
||||
rangeRequest.Headers.Range = new RangeHeaderValue(0, 0);
|
||||
|
||||
using var rangeResponse = await _httpClient.SendAsync(
|
||||
rangeRequest,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (rangeResponse.StatusCode == HttpStatusCode.PartialContent)
|
||||
{
|
||||
totalBytes = rangeResponse.Content.Headers.ContentRange?.Length ?? totalBytes;
|
||||
return new RemoteProbeResult(totalBytes, true);
|
||||
}
|
||||
|
||||
rangeResponse.EnsureSuccessStatusCode();
|
||||
totalBytes ??= rangeResponse.Content.Headers.ContentLength;
|
||||
return new RemoteProbeResult(totalBytes, false);
|
||||
}
|
||||
|
||||
private static async Task CopyStreamAsync(
|
||||
Stream sourceStream,
|
||||
Stream destinationStream,
|
||||
long initialDownloadedBytes,
|
||||
long? totalBytes,
|
||||
bool isResuming,
|
||||
bool isParallel,
|
||||
int bufferSize,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
|
||||
var downloadedBytes = initialDownloadedBytes;
|
||||
try
|
||||
{
|
||||
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
|
||||
while (true)
|
||||
{
|
||||
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
downloadedBytes += read;
|
||||
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
|
||||
}
|
||||
|
||||
await destinationStream.FlushAsync(cancellationToken);
|
||||
ReportProgress(progress, downloadedBytes, totalBytes, isResuming, isParallel);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ReportProgress(
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
long downloadedBytes,
|
||||
long? totalBytes,
|
||||
bool isResuming,
|
||||
bool isParallel)
|
||||
{
|
||||
if (progress is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
double normalizedProgress;
|
||||
if (totalBytes is > 0)
|
||||
{
|
||||
normalizedProgress = Math.Clamp(downloadedBytes / (double)totalBytes.Value, 0d, 1d);
|
||||
}
|
||||
else
|
||||
{
|
||||
normalizedProgress = 0d;
|
||||
}
|
||||
|
||||
progress.Report(new DownloadProgressInfo(
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
normalizedProgress,
|
||||
isResuming,
|
||||
isParallel));
|
||||
}
|
||||
|
||||
private static async Task<DownloadMetadata> LoadOrCreateMetadataAsync(
|
||||
Uri sourceUri,
|
||||
string tempFilePath,
|
||||
string metadataFilePath,
|
||||
long totalBytes,
|
||||
int segmentCount,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (File.Exists(metadataFilePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(metadataFilePath, cancellationToken);
|
||||
var metadata = JsonSerializer.Deserialize<SerializableDownloadMetadata>(json);
|
||||
if (metadata is not null)
|
||||
{
|
||||
var normalizedMetadata = metadata.ToRuntime();
|
||||
if (string.Equals(normalizedMetadata.Source, sourceUri.ToString(), StringComparison.OrdinalIgnoreCase) &&
|
||||
normalizedMetadata.TotalBytes == totalBytes &&
|
||||
normalizedMetadata.Segments.Count > 0)
|
||||
{
|
||||
return normalizedMetadata.Normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reset invalid metadata below.
|
||||
}
|
||||
}
|
||||
|
||||
ResetPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
var createdMetadata = DownloadMetadata.Create(sourceUri.ToString(), totalBytes, segmentCount);
|
||||
var serialized = JsonSerializer.Serialize(createdMetadata.ToSerializable(), MetadataSerializerOptions);
|
||||
await File.WriteAllTextAsync(metadataFilePath, serialized, cancellationToken);
|
||||
return createdMetadata;
|
||||
}
|
||||
|
||||
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
|
||||
{
|
||||
var normalized = options ?? new DownloadOptions();
|
||||
var maxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8);
|
||||
var parallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes);
|
||||
var bufferSize = Math.Max(16 * 1024, normalized.BufferSize);
|
||||
return normalized with
|
||||
{
|
||||
MaxParallelSegments = maxParallelSegments,
|
||||
ParallelThresholdBytes = parallelThresholdBytes,
|
||||
BufferSize = bufferSize
|
||||
};
|
||||
}
|
||||
|
||||
private static int CalculateRecommendedSegments(long totalBytes)
|
||||
{
|
||||
if (totalBytes < 16 * 1024 * 1024)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (totalBytes < 64 * 1024 * 1024)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
return 6;
|
||||
}
|
||||
|
||||
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
|
||||
{
|
||||
if (!File.Exists(destinationFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedSizeBytes is not > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value;
|
||||
}
|
||||
|
||||
private static void PrepareDestination(string destinationFilePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteDownload(string tempFilePath, string destinationFilePath, string metadataFilePath)
|
||||
{
|
||||
if (!File.Exists(tempFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
File.Move(tempFilePath, destinationFilePath, overwrite: true);
|
||||
if (File.Exists(metadataFilePath))
|
||||
{
|
||||
File.Delete(metadataFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupPartialArtifacts(string tempFilePath, string metadataFilePath)
|
||||
{
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
File.Delete(tempFilePath);
|
||||
}
|
||||
|
||||
if (File.Exists(metadataFilePath))
|
||||
{
|
||||
File.Delete(metadataFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResetPartialArtifacts(string tempFilePath, string metadataFilePath)
|
||||
{
|
||||
CleanupPartialArtifacts(tempFilePath, metadataFilePath);
|
||||
}
|
||||
|
||||
private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
|
||||
|
||||
private static string BuildMetadataFilePath(string destinationFilePath) => destinationFilePath + ".part.json";
|
||||
|
||||
private sealed record RemoteProbeResult(long? TotalBytes, bool SupportsRanges);
|
||||
|
||||
private sealed class RangeRequestNotSupportedException : InvalidOperationException
|
||||
{
|
||||
public RangeRequestNotSupportedException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MetadataWriter
|
||||
{
|
||||
private readonly string _metadataFilePath;
|
||||
private readonly DownloadMetadata _metadata;
|
||||
private readonly SemaphoreSlim _writeGate = new(1, 1);
|
||||
private long _lastPersistedTickCount;
|
||||
private int _dirty;
|
||||
|
||||
public MetadataWriter(string metadataFilePath, DownloadMetadata metadata)
|
||||
{
|
||||
_metadataFilePath = metadataFilePath;
|
||||
_metadata = metadata;
|
||||
_lastPersistedTickCount = Environment.TickCount64;
|
||||
}
|
||||
|
||||
public void MarkDirty()
|
||||
{
|
||||
Interlocked.Exchange(ref _dirty, 1);
|
||||
var now = Environment.TickCount64;
|
||||
if (now - Interlocked.Read(ref _lastPersistedTickCount) < 750)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await FlushAsync(CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The final flush still runs on completion/cancellation.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task FlushAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Interlocked.Exchange(ref _dirty, 0) == 0 && File.Exists(_metadataFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _writeGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_metadata.ToSerializable(), MetadataSerializerOptions);
|
||||
await File.WriteAllTextAsync(_metadataFilePath, json, cancellationToken);
|
||||
Interlocked.Exchange(ref _lastPersistedTickCount, Environment.TickCount64);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DownloadMetadata
|
||||
{
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
public List<DownloadSegmentState> Segments { get; init; } = [];
|
||||
|
||||
public static DownloadMetadata Create(string source, long totalBytes, int segmentCount)
|
||||
{
|
||||
var segments = SplitIntoSegments(totalBytes, segmentCount)
|
||||
.Select(range => new DownloadSegmentState(range.Start, range.EndInclusive, 0))
|
||||
.ToList();
|
||||
|
||||
return new DownloadMetadata
|
||||
{
|
||||
Source = source,
|
||||
TotalBytes = totalBytes,
|
||||
Segments = segments
|
||||
};
|
||||
}
|
||||
|
||||
public DownloadMetadata Normalize()
|
||||
{
|
||||
foreach (var segment in Segments)
|
||||
{
|
||||
segment.CompletedBytes = Math.Clamp(segment.CompletedBytes, 0, segment.Length);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public SerializableDownloadMetadata ToSerializable()
|
||||
{
|
||||
return new SerializableDownloadMetadata
|
||||
{
|
||||
Source = Source,
|
||||
TotalBytes = TotalBytes,
|
||||
Segments = Segments
|
||||
.Select(segment => new SerializableDownloadSegment
|
||||
{
|
||||
Start = segment.Start,
|
||||
EndInclusive = segment.EndInclusive,
|
||||
CompletedBytes = segment.CompletedBytes
|
||||
})
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DownloadSegmentState
|
||||
{
|
||||
public DownloadSegmentState(long start, long endInclusive, long completedBytes)
|
||||
{
|
||||
Start = start;
|
||||
EndInclusive = endInclusive;
|
||||
CompletedBytes = completedBytes;
|
||||
}
|
||||
|
||||
public long Start { get; }
|
||||
|
||||
public long EndInclusive { get; }
|
||||
|
||||
public long Length => EndInclusive - Start + 1;
|
||||
|
||||
public long CompletedBytes { get; set; }
|
||||
}
|
||||
|
||||
private sealed class SerializableDownloadMetadata
|
||||
{
|
||||
public string Source { get; init; } = string.Empty;
|
||||
|
||||
public long TotalBytes { get; init; }
|
||||
|
||||
public List<SerializableDownloadSegment> Segments { get; init; } = [];
|
||||
|
||||
public DownloadMetadata ToRuntime()
|
||||
{
|
||||
return new DownloadMetadata
|
||||
{
|
||||
Source = Source,
|
||||
TotalBytes = TotalBytes,
|
||||
Segments = Segments
|
||||
.Select(segment => new DownloadSegmentState(
|
||||
segment.Start,
|
||||
segment.EndInclusive,
|
||||
segment.CompletedBytes))
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SerializableDownloadSegment
|
||||
{
|
||||
public long Start { get; init; }
|
||||
|
||||
public long EndInclusive { get; init; }
|
||||
|
||||
public long CompletedBytes { get; init; }
|
||||
}
|
||||
|
||||
private static IEnumerable<(long Start, long EndInclusive)> SplitIntoSegments(long totalBytes, int segmentCount)
|
||||
{
|
||||
if (totalBytes <= 0)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
var normalizedSegmentCount = Math.Max(1, segmentCount);
|
||||
var segmentSize = totalBytes / normalizedSegmentCount;
|
||||
var remainder = totalBytes % normalizedSegmentCount;
|
||||
long start = 0;
|
||||
|
||||
for (var index = 0; index < normalizedSegmentCount; index++)
|
||||
{
|
||||
var currentSegmentSize = segmentSize + (index < remainder ? 1 : 0);
|
||||
if (currentSegmentSize <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var endInclusive = start + currentSegmentSize - 1;
|
||||
yield return (start, endInclusive);
|
||||
start = endInclusive + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,12 @@ using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -54,9 +55,11 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
|
||||
private const double DialSize = 258;
|
||||
private const double Center = DialSize / 2;
|
||||
private string _componentId = BuiltInComponentIds.DesktopClock;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private TimeZoneService? _timeZoneService;
|
||||
private double _currentCellSize = 48;
|
||||
@@ -112,6 +115,21 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
UpdateClock();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
InitializeDialIfNeeded();
|
||||
@@ -359,7 +377,7 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
|
||||
private void LoadClockSettings()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
|
||||
@@ -4,11 +4,12 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
public partial class AnalogClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -28,11 +29,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClock;
|
||||
private string _placementId = string.Empty;
|
||||
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private string _selectedTimeZoneId = string.Empty;
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
@@ -47,10 +50,29 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBox();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
|
||||
? "China Standard Time"
|
||||
@@ -149,10 +171,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
|
||||
_selectedTimeZoneId = normalizedId;
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.DesktopClockTimeZoneId = normalizedId;
|
||||
snapshot.DesktopClockSecondHandMode = _secondHandMode;
|
||||
_componentSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -10,19 +10,22 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ClassScheduleSettingsWindow : UserControl
|
||||
public partial class ClassScheduleSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly List<ImportedClassScheduleSnapshot> _importedSchedules = [];
|
||||
private string _activeScheduleId = string.Empty;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
@@ -34,10 +37,29 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClassSchedule
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
RenderImportedSchedules();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
_importedSchedules.Clear();
|
||||
@@ -299,7 +321,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.ImportedClassSchedules = _importedSchedules
|
||||
.Select(item => new ImportedClassScheduleSnapshot
|
||||
{
|
||||
@@ -309,7 +331,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
|
||||
})
|
||||
.ToList();
|
||||
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
|
||||
_componentSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,13 @@ using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private sealed record CourseItemViewModel(
|
||||
string Name,
|
||||
@@ -26,7 +27,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
|
||||
|
||||
@@ -35,6 +36,8 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
private IReadOnlyList<CourseItemViewModel> _courseItems = Array.Empty<CourseItemViewModel>();
|
||||
private bool _isNightVisual = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopClassSchedule;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public ClassScheduleWidget()
|
||||
{
|
||||
@@ -113,10 +116,25 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopClassSchedule
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshSchedule();
|
||||
}
|
||||
|
||||
private void RefreshSchedule()
|
||||
{
|
||||
var appSettings = _appSettingsService.Load();
|
||||
var componentSettings = _componentSettingsService.Load();
|
||||
var componentSettings = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
|
||||
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
|
||||
UpdateHeader(now);
|
||||
|
||||
@@ -2,18 +2,21 @@ using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkSettingsWindow : UserControl
|
||||
public partial class DailyArtworkSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private string _languageCode = "zh-CN";
|
||||
private bool _suppressEvents;
|
||||
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
@@ -24,10 +27,27 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopDailyArtwork
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var source = DailyArtworkMirrorSources.Normalize(componentSnapshot.DailyArtworkMirrorSource);
|
||||
@@ -59,9 +79,9 @@ public partial class DailyArtworkSettingsWindow : UserControl
|
||||
}
|
||||
|
||||
var source = GetSelectedSource();
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.DailyArtworkMirrorSource = source;
|
||||
_componentSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
|
||||
UpdateSourceStatus(source);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -13,12 +13,13 @@ using Avalonia.Input;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
|
||||
public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<DayOfWeek, string> ZhWeekdays =
|
||||
new Dictionary<DayOfWeek, string>
|
||||
@@ -58,6 +59,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
|
||||
@@ -67,6 +69,8 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
private double _currentCellSize = BaseCellSize;
|
||||
private bool _isAttached;
|
||||
private bool _isRefreshing;
|
||||
private string _componentId = BuiltInComponentIds.DesktopDailyArtwork;
|
||||
private string _placementId = string.Empty;
|
||||
private string? _currentArtworkSourceUrl;
|
||||
private string? _currentArtworkImageUrl;
|
||||
|
||||
@@ -136,6 +140,21 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopDailyArtwork
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttached = true;
|
||||
@@ -203,6 +222,7 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
{
|
||||
var query = new DailyArtworkQuery(
|
||||
Locale: _languageCode,
|
||||
MirrorSource: ResolveMirrorSource(),
|
||||
ForceRefresh: forceRefresh);
|
||||
var result = await _recommendationService.GetDailyArtworkAsync(query, cts.Token);
|
||||
if (!_isAttached || cts.IsCancellationRequested)
|
||||
@@ -640,6 +660,19 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
private string ResolveMirrorSource()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DailyArtworkMirrorSources.Overseas;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateDateLabels()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
|
||||
@@ -7,24 +7,65 @@ using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public sealed record DesktopComponentRuntimeRegistration(
|
||||
string ComponentId,
|
||||
string DisplayNameLocalizationKey,
|
||||
Func<Control> ControlFactory,
|
||||
Func<double, double>? CornerRadiusResolver = null);
|
||||
public sealed record DesktopComponentControlFactoryContext(
|
||||
DesktopComponentDefinition Definition,
|
||||
double CellSize,
|
||||
TimeZoneService TimeZoneService,
|
||||
IWeatherInfoService WeatherInfoService,
|
||||
IRecommendationInfoService RecommendationInfoService,
|
||||
ICalculatorDataService CalculatorDataService,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore,
|
||||
string? PlacementId = null);
|
||||
|
||||
public sealed class DesktopComponentRuntimeRegistration
|
||||
{
|
||||
public DesktopComponentRuntimeRegistration(
|
||||
string componentId,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
: this(componentId, displayNameLocalizationKey, _ => controlFactory(), cornerRadiusResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public DesktopComponentRuntimeRegistration(
|
||||
string componentId,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<DesktopComponentControlFactoryContext, Control> ControlFactory { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
|
||||
public sealed class DesktopComponentRuntimeDescriptor
|
||||
{
|
||||
private static readonly Func<double, double> DefaultCornerRadiusResolver =
|
||||
cellSize => Math.Clamp(cellSize * 0.22, 8, 18);
|
||||
|
||||
private readonly Func<Control> _controlFactory;
|
||||
private readonly Func<DesktopComponentControlFactoryContext, Control> _controlFactory;
|
||||
private readonly Func<double, double> _cornerRadiusResolver;
|
||||
|
||||
internal DesktopComponentRuntimeDescriptor(
|
||||
DesktopComponentDefinition definition,
|
||||
string displayNameLocalizationKey,
|
||||
Func<Control> controlFactory,
|
||||
string? displayNameLocalizationKey,
|
||||
Func<DesktopComponentControlFactoryContext, Control> controlFactory,
|
||||
Func<double, double>? cornerRadiusResolver)
|
||||
{
|
||||
Definition = definition;
|
||||
@@ -35,16 +76,48 @@ public sealed class DesktopComponentRuntimeDescriptor
|
||||
|
||||
public DesktopComponentDefinition Definition { get; }
|
||||
|
||||
public string DisplayNameLocalizationKey { get; }
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Control CreateControl(
|
||||
double cellSize,
|
||||
TimeZoneService timeZoneService,
|
||||
IWeatherInfoService weatherInfoService,
|
||||
IRecommendationInfoService recommendationInfoService,
|
||||
ICalculatorDataService calculatorDataService)
|
||||
ICalculatorDataService calculatorDataService,
|
||||
IComponentInstanceSettingsStore componentSettingsStore,
|
||||
string? placementId = null)
|
||||
{
|
||||
var control = _controlFactory();
|
||||
var control = _controlFactory(new DesktopComponentControlFactoryContext(
|
||||
Definition,
|
||||
cellSize,
|
||||
timeZoneService,
|
||||
weatherInfoService,
|
||||
recommendationInfoService,
|
||||
calculatorDataService,
|
||||
componentSettingsStore,
|
||||
placementId));
|
||||
var runtimeContext = new DesktopComponentRuntimeContext(
|
||||
Definition.Id,
|
||||
placementId,
|
||||
componentSettingsStore);
|
||||
|
||||
if (control is IComponentRuntimeContextAware runtimeContextAwareComponent)
|
||||
{
|
||||
runtimeContextAwareComponent.SetComponentRuntimeContext(runtimeContext);
|
||||
}
|
||||
|
||||
if (control is IComponentPlacementContextAware placementAwareComponent)
|
||||
{
|
||||
placementAwareComponent.SetComponentPlacementContext(Definition.Id, placementId);
|
||||
}
|
||||
|
||||
if (control is IComponentSettingsStoreAware settingsStoreAwareComponent)
|
||||
{
|
||||
settingsStoreAwareComponent.SetComponentSettingsStore(componentSettingsStore);
|
||||
}
|
||||
|
||||
ComponentSettingsService.ApplyScopedContextToTarget(control, Definition.Id, placementId);
|
||||
|
||||
if (control is IDesktopComponentWidget sizedComponent)
|
||||
{
|
||||
sizedComponent.ApplyCellSize(cellSize);
|
||||
@@ -109,12 +182,10 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
||||
public static IReadOnlyList<DesktopComponentRuntimeRegistration> GetDefaultRegistrations()
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(
|
||||
componentRegistry,
|
||||
new[]
|
||||
{
|
||||
return
|
||||
[
|
||||
new DesktopComponentRuntimeRegistration(
|
||||
BuiltInComponentIds.Date,
|
||||
"component.date",
|
||||
@@ -295,7 +366,12 @@ public sealed class DesktopComponentRuntimeRegistry
|
||||
"component.holiday_calendar",
|
||||
() => new HolidayCalendarWidget(),
|
||||
cellSize => Math.Clamp(cellSize * 0.32, 12, 28))
|
||||
});
|
||||
];
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateDefault(ComponentRegistry componentRegistry)
|
||||
{
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, GetDefaultRegistrations());
|
||||
}
|
||||
|
||||
public bool TryGetDescriptor(string componentId, out DesktopComponentRuntimeDescriptor descriptor)
|
||||
|
||||
@@ -9,13 +9,14 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IWeatherInfoService DefaultWeatherInfoService = new XiaomiWeatherService();
|
||||
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
@@ -25,7 +26,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private readonly ScaleTransform _backgroundMotionScaleTransform = new(1, 1);
|
||||
private readonly TranslateTransform _backgroundMotionTranslateTransform = new();
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
private IWeatherInfoService _weatherInfoService = DefaultWeatherInfoService;
|
||||
@@ -39,6 +40,8 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.ClearDay;
|
||||
private string _componentId = BuiltInComponentIds.DesktopExtendedWeather;
|
||||
private string _placementId = string.Empty;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
@@ -173,6 +176,21 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopExtendedWeather
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -935,7 +953,7 @@ public partial class ExtendedWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -95,7 +96,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -118,6 +119,8 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _componentId = BuiltInComponentIds.DesktopHourlyWeather;
|
||||
private string _placementId = string.Empty;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
@@ -224,6 +227,21 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopHourlyWeather
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -1424,7 +1442,7 @@ public partial class HourlyWeatherWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -93,7 +94,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -116,6 +117,8 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _componentId = BuiltInComponentIds.DesktopMultiDayWeather;
|
||||
private string _placementId = string.Empty;
|
||||
private readonly TextBlock[] _hourlyTimeBlocks;
|
||||
private readonly Image[] _hourlyIconBlocks;
|
||||
private readonly TextBlock[] _hourlyTempBlocks;
|
||||
@@ -222,6 +225,21 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopMultiDayWeather
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -1274,7 +1292,7 @@ public partial class MultiDayWeatherWidget : UserControl, IDesktopComponentWidge
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,13 @@ using Avalonia.Controls.Shapes;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private sealed record WeatherClockConfig(
|
||||
string LanguageCode,
|
||||
@@ -41,7 +42,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Line _hourHandLine = CreateHandLine("#232938", 4.0);
|
||||
private readonly Line _minuteHandLine = CreateHandLine("#2F3749", 2.8);
|
||||
@@ -59,6 +60,8 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
private bool? _isNightModeApplied;
|
||||
private string _languageCode = "zh-CN";
|
||||
private HyperOS3WeatherVisualKind _activeVisualKind = HyperOS3WeatherVisualKind.CloudyDay;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWeatherClock;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public WeatherClockWidget()
|
||||
{
|
||||
@@ -116,6 +119,21 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWeatherClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -659,7 +677,7 @@ public partial class WeatherClockWidget : UserControl, IDesktopComponentWidget,
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,14 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget
|
||||
public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesktopPageVisibilityAwareComponentWidget, ITimeZoneAwareComponentWidget, IWeatherInfoAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private enum WeatherVisualKind
|
||||
{
|
||||
@@ -89,7 +90,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _settingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly Dictionary<WeatherVisualKind, IBrush> _backgroundBrushCache = new();
|
||||
private readonly Dictionary<HyperOS3WeatherVisualKind, IBrush> _particleBrushCache = new();
|
||||
@@ -112,6 +113,8 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
private bool _isOnActivePage = true;
|
||||
private bool _isRefreshing;
|
||||
private bool _autoRefreshEnabled = true;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWeather;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public WeatherWidget()
|
||||
{
|
||||
@@ -167,6 +170,21 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
}
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWeather
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_ = isEditMode;
|
||||
@@ -1063,7 +1081,7 @@ public partial class WeatherWidget : UserControl, IDesktopComponentWidget, IDesk
|
||||
|
||||
try
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
enabled = snapshot.WeatherAutoRefreshEnabled;
|
||||
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.WeatherAutoRefreshIntervalMinutes);
|
||||
}
|
||||
|
||||
@@ -4,20 +4,23 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WeatherWidgetSettingsWindow : UserControl
|
||||
public partial class WeatherWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopWeather;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public event EventHandler? SettingsChanged;
|
||||
|
||||
@@ -29,10 +32,27 @@ public partial class WeatherWidgetSettingsWindow : UserControl
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWeather
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var enabled = componentSnapshot.WeatherAutoRefreshEnabled;
|
||||
@@ -83,10 +103,10 @@ public partial class WeatherWidgetSettingsWindow : UserControl
|
||||
|
||||
private void SaveState()
|
||||
{
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.WeatherAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
|
||||
snapshot.WeatherAutoRefreshIntervalMinutes = GetSelectedInterval();
|
||||
_componentSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,12 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
|
||||
public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private const int BaseWidthCells = 4;
|
||||
private const int BaseHeightCells = 2;
|
||||
@@ -92,7 +93,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly ClockEntryVisual[] _entryVisuals = new ClockEntryVisual[WorldClockTimeZoneCatalog.ClockCount];
|
||||
private readonly TimeZoneInfo[] _entryTimeZones = new TimeZoneInfo[WorldClockTimeZoneCatalog.ClockCount];
|
||||
@@ -103,6 +104,8 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
private DateTime _nextLanguageProbeUtc = DateTime.MinValue;
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
private bool _isNightVisual = true;
|
||||
private string _componentId = BuiltInComponentIds.DesktopWorldClock;
|
||||
private string _placementId = string.Empty;
|
||||
|
||||
public WorldClockWidget()
|
||||
{
|
||||
@@ -147,6 +150,21 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
UpdateClockVisuals();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWorldClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
RefreshFromSettings();
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
@@ -525,7 +543,7 @@ public partial class WorldClockWidget : UserControl, IDesktopComponentWidget, IT
|
||||
private void LoadFromSettings()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
var ids = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(componentSnapshot.WorldClockTimeZoneIds);
|
||||
|
||||
@@ -4,11 +4,12 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Interactivity;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
public partial class WorldClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -28,12 +29,14 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
};
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
private readonly ComboBox[] _timeZoneComboBoxes;
|
||||
private bool _suppressEvents;
|
||||
private string _languageCode = "zh-CN";
|
||||
private string _componentId = BuiltInComponentIds.DesktopWorldClock;
|
||||
private string _placementId = string.Empty;
|
||||
private IReadOnlyList<TimeZoneInfo> _allTimeZones = Array.Empty<TimeZoneInfo>();
|
||||
private IReadOnlyList<string> _selectedTimeZoneIds = Array.Empty<string>();
|
||||
private string _secondHandMode = ClockSecondHandMode.Tick;
|
||||
@@ -57,10 +60,29 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
PopulateTimeZoneComboBoxes();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopWorldClock
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBoxes();
|
||||
}
|
||||
|
||||
public void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore)
|
||||
{
|
||||
_componentSettingsStore = settingsStore ?? new ComponentSettingsService();
|
||||
LoadState();
|
||||
ApplyLocalization();
|
||||
PopulateTimeZoneComboBoxes();
|
||||
}
|
||||
|
||||
private void LoadState()
|
||||
{
|
||||
var appSnapshot = _appSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsService.Load();
|
||||
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
|
||||
|
||||
_allTimeZones = _timeZoneService
|
||||
@@ -167,10 +189,10 @@ public partial class WorldClockWidgetSettingsWindow : UserControl
|
||||
var normalizedIds = WorldClockTimeZoneCatalog.NormalizeTimeZoneIds(selectedIds, _allTimeZones);
|
||||
_secondHandMode = GetSelectedSecondHandMode();
|
||||
|
||||
var snapshot = _componentSettingsService.Load();
|
||||
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
|
||||
snapshot.WorldClockTimeZoneIds = normalizedIds.ToList();
|
||||
snapshot.WorldClockSecondHandMode = _secondHandMode;
|
||||
_componentSettingsService.Save(snapshot);
|
||||
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
|
||||
|
||||
_selectedTimeZoneIds = normalizedIds;
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
@@ -14,6 +14,7 @@ using FluentIcons.Avalonia;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
@@ -255,14 +256,15 @@ public partial class MainWindow
|
||||
return TaskbarContext.Desktop;
|
||||
}
|
||||
|
||||
return SettingsNavListBox?.SelectedIndex switch
|
||||
var selectedItem = SettingsNavView?.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
|
||||
return selectedItem?.Tag?.ToString() switch
|
||||
{
|
||||
0 => TaskbarContext.SettingsWallpaper,
|
||||
1 => TaskbarContext.SettingsGrid,
|
||||
2 => TaskbarContext.SettingsColor,
|
||||
3 => TaskbarContext.SettingsStatusBar,
|
||||
4 => TaskbarContext.SettingsWeather,
|
||||
5 => TaskbarContext.SettingsRegion,
|
||||
"Wallpaper" => TaskbarContext.SettingsWallpaper,
|
||||
"Grid" => TaskbarContext.SettingsGrid,
|
||||
"Color" => TaskbarContext.SettingsColor,
|
||||
"StatusBar" => TaskbarContext.SettingsStatusBar,
|
||||
"Weather" => TaskbarContext.SettingsWeather,
|
||||
"Region" => TaskbarContext.SettingsRegion,
|
||||
_ => TaskbarContext.Desktop
|
||||
};
|
||||
}
|
||||
@@ -689,6 +691,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_desktopComponentPlacements.Remove(placement);
|
||||
_componentSettingsService.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||
|
||||
ClearDesktopComponentSelection();
|
||||
|
||||
@@ -724,80 +727,80 @@ public partial class MainWindow
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.Date)
|
||||
{
|
||||
OpenDateComponentSettings();
|
||||
OpenDateComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopClock)
|
||||
{
|
||||
OpenDesktopClockComponentSettings();
|
||||
OpenDesktopClockComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopClassSchedule)
|
||||
{
|
||||
OpenClassScheduleComponentSettings();
|
||||
OpenClassScheduleComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopWorldClock)
|
||||
{
|
||||
OpenWorldClockComponentSettings();
|
||||
OpenWorldClockComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsWeatherComponentId(placement.ComponentId))
|
||||
{
|
||||
OpenWeatherComponentSettings();
|
||||
OpenWeatherComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyArtwork)
|
||||
{
|
||||
OpenDailyArtworkComponentSettings();
|
||||
OpenDailyArtworkComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopCnrDailyNews)
|
||||
{
|
||||
OpenCnrDailyNewsComponentSettings();
|
||||
OpenCnrDailyNewsComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopIfengNews)
|
||||
{
|
||||
OpenIfengNewsComponentSettings();
|
||||
OpenIfengNewsComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopDailyWord ||
|
||||
placement.ComponentId == BuiltInComponentIds.DesktopDailyWord2x2)
|
||||
{
|
||||
OpenDailyWordComponentSettings();
|
||||
OpenDailyWordComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopBilibiliHotSearch)
|
||||
{
|
||||
OpenBilibiliHotSearchComponentSettings();
|
||||
OpenBilibiliHotSearchComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopBaiduHotSearch)
|
||||
{
|
||||
OpenBaiduHotSearchComponentSettings();
|
||||
OpenBaiduHotSearchComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStcn24Forum)
|
||||
{
|
||||
OpenStcn24ForumComponentSettings();
|
||||
OpenStcn24ForumComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
|
||||
if (placement.ComponentId == BuiltInComponentIds.DesktopStudyEnvironment)
|
||||
{
|
||||
OpenStudyEnvironmentComponentSettings();
|
||||
OpenStudyEnvironmentComponentSettings(placement);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -811,212 +814,129 @@ public partial class MainWindow
|
||||
string.Equals(componentId, BuiltInComponentIds.DesktopExtendedWeather, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void OpenDateComponentSettings()
|
||||
private void ShowComponentSettings(Control settingsContent, DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new DateWidgetSettingsWindow();
|
||||
var runtimeContext = new DesktopComponentRuntimeContext(
|
||||
placement.ComponentId,
|
||||
placement.PlacementId,
|
||||
_componentSettingsService);
|
||||
|
||||
if (settingsContent is IComponentRuntimeContextAware runtimeContextAwareComponent)
|
||||
{
|
||||
runtimeContextAwareComponent.SetComponentRuntimeContext(runtimeContext);
|
||||
}
|
||||
|
||||
if (settingsContent is IComponentPlacementContextAware placementAwareComponent)
|
||||
{
|
||||
placementAwareComponent.SetComponentPlacementContext(placement.ComponentId, placement.PlacementId);
|
||||
}
|
||||
|
||||
if (settingsContent is IComponentSettingsStoreAware settingsStoreAwareComponent)
|
||||
{
|
||||
settingsStoreAwareComponent.SetComponentSettingsStore(_componentSettingsService);
|
||||
}
|
||||
|
||||
ComponentSettingsService.ApplyScopedContextToTarget(settingsContent, placement.ComponentId, placement.PlacementId);
|
||||
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
}
|
||||
|
||||
private void OpenClassScheduleComponentSettings()
|
||||
private void OpenDateComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var settingsContent = new DateWidgetSettingsWindow();
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenClassScheduleComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
var settingsContent = new ClassScheduleSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnClassScheduleSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenDesktopClockComponentSettings()
|
||||
private void OpenDesktopClockComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new AnalogClockWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDesktopClockSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenWorldClockComponentSettings()
|
||||
private void OpenWorldClockComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new WorldClockWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnWorldClockSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenWeatherComponentSettings()
|
||||
private void OpenWeatherComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new WeatherWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnWeatherSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenStudyEnvironmentComponentSettings()
|
||||
private void OpenStudyEnvironmentComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new StudyEnvironmentWidgetSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnStudyEnvironmentSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenDailyArtworkComponentSettings()
|
||||
private void OpenDailyArtworkComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new DailyArtworkSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDailyArtworkSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenCnrDailyNewsComponentSettings()
|
||||
private void OpenCnrDailyNewsComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new CnrDailyNewsSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnCnrDailyNewsSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenIfengNewsComponentSettings()
|
||||
private void OpenIfengNewsComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new IfengNewsSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnIfengNewsSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenDailyWordComponentSettings()
|
||||
private void OpenDailyWordComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new DailyWordSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnDailyWordSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenBilibiliHotSearchComponentSettings()
|
||||
private void OpenBilibiliHotSearchComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new BilibiliHotSearchSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnBilibiliHotSearchSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenBaiduHotSearchComponentSettings()
|
||||
private void OpenBaiduHotSearchComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new BaiduHotSearchSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnBaiduHotSearchSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OpenStcn24ForumComponentSettings()
|
||||
private void OpenStcn24ForumComponentSettings(DesktopComponentPlacementSnapshot placement)
|
||||
{
|
||||
if (ComponentSettingsWindow is null || ComponentSettingsContentHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var settingsContent = new Stcn24ForumSettingsWindow();
|
||||
settingsContent.SettingsChanged += OnStcn24ForumSettingsChanged;
|
||||
ComponentSettingsContentHost.Content = settingsContent;
|
||||
|
||||
ComponentSettingsWindow.IsVisible = true;
|
||||
ComponentSettingsWindow.Opacity = 0;
|
||||
ComponentSettingsWindow.Opacity = 1;
|
||||
ShowComponentSettings(settingsContent, placement);
|
||||
}
|
||||
|
||||
private void OnClassScheduleSettingsChanged(object? sender, EventArgs e)
|
||||
@@ -1408,6 +1328,7 @@ public partial class MainWindow
|
||||
foreach (var placement in placementsToRemove)
|
||||
{
|
||||
_desktopComponentPlacements.Remove(placement);
|
||||
_componentSettingsService.DeleteForComponent(placement.ComponentId, placement.PlacementId);
|
||||
}
|
||||
|
||||
_desktopPageCount = Math.Clamp(_desktopPageCount - 1, MinDesktopPageCount, MaxDesktopPageCount);
|
||||
@@ -1431,7 +1352,7 @@ public partial class MainWindow
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
|
||||
private void InitializeDesktopComponentPlacements(AppSettingsSnapshot snapshot)
|
||||
private void InitializeDesktopComponentPlacements(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
_desktopComponentPlacements.Clear();
|
||||
|
||||
@@ -1601,7 +1522,7 @@ public partial class MainWindow
|
||||
placement.PlacementId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var component = CreateDesktopComponentControl(placement.ComponentId);
|
||||
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId);
|
||||
if (component is null)
|
||||
{
|
||||
return null;
|
||||
@@ -2035,7 +1956,7 @@ public partial class MainWindow
|
||||
return onLeft || onRight || onTop || onBottom;
|
||||
}
|
||||
|
||||
private Control? CreateDesktopComponentControl(string componentId)
|
||||
private Control? CreateDesktopComponentControl(string componentId, string? placementId = null)
|
||||
{
|
||||
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
|
||||
{
|
||||
@@ -2047,7 +1968,9 @@ public partial class MainWindow
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService);
|
||||
_calculatorDataService,
|
||||
_componentSettingsService,
|
||||
placementId);
|
||||
component.Classes.Add(DesktopComponentClass);
|
||||
return component;
|
||||
}
|
||||
@@ -3195,7 +3118,8 @@ public partial class MainWindow
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService);
|
||||
_calculatorDataService,
|
||||
_componentSettingsService);
|
||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
@@ -3292,7 +3216,9 @@ public partial class MainWindow
|
||||
|
||||
private string GetLocalizedComponentDisplayName(DesktopComponentRuntimeDescriptor descriptor)
|
||||
{
|
||||
return L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName);
|
||||
return string.IsNullOrWhiteSpace(descriptor.DisplayNameLocalizationKey)
|
||||
? descriptor.Definition.DisplayName
|
||||
: L(descriptor.DisplayNameLocalizationKey, descriptor.Definition.DisplayName);
|
||||
}
|
||||
|
||||
private void OnComponentLibraryComponentPreviewPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
@@ -3598,4 +3524,3 @@ public partial class MainWindow
|
||||
ApplyComponentLibraryComponentOffset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,14 +66,14 @@ public partial class MainWindow
|
||||
|
||||
private int TotalSurfaceCount => LauncherSurfaceIndex + 1;
|
||||
|
||||
private void InitializeDesktopSurfaceState(AppSettingsSnapshot snapshot)
|
||||
private void InitializeDesktopSurfaceState(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var loadedPageCount = snapshot.DesktopPageCount <= 0 ? MinDesktopPageCount : snapshot.DesktopPageCount;
|
||||
_desktopPageCount = Math.Clamp(loadedPageCount, MinDesktopPageCount, MaxDesktopPageCount);
|
||||
_currentDesktopSurfaceIndex = Math.Clamp(snapshot.CurrentDesktopSurfaceIndex, 0, LauncherSurfaceIndex);
|
||||
}
|
||||
|
||||
private void InitializeLauncherVisibilitySettings(AppSettingsSnapshot snapshot)
|
||||
private void InitializeLauncherVisibilitySettings(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
_hiddenLauncherFolderPaths.Clear();
|
||||
if (snapshot.HiddenLauncherFolderPaths is not null)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -107,16 +109,17 @@ public partial class MainWindow
|
||||
ToolTip.SetTip(LauncherFolderBackButton, L("common.back", "Back"));
|
||||
ToolTip.SetTip(LauncherFolderCloseButton, L("common.close", "Close"));
|
||||
|
||||
SettingsNavHeaderTextBlock.Text = L("settings.nav_header", "Settings");
|
||||
SettingsNavWallpaperTextBlock.Text = L("settings.nav.wallpaper", "Wallpaper");
|
||||
SettingsNavGridTextBlock.Text = L("settings.nav.grid", "Grid");
|
||||
SettingsNavColorTextBlock.Text = L("settings.nav.color", "Color");
|
||||
SettingsNavStatusBarTextBlock.Text = L("settings.nav.status_bar", "Status Bar");
|
||||
SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather");
|
||||
SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region");
|
||||
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
|
||||
SettingsNavLauncherTextBlock.Text = L("settings.nav.launcher", "App Launcher");
|
||||
SettingsNavPluginsTextBlock.Text = L("settings.nav.plugins", "Plugins");
|
||||
// SettingsNavHeaderTextBlock no longer exists
|
||||
SettingsNavWallpaperItem.Content = L("settings.nav.wallpaper", "Wallpaper");
|
||||
SettingsNavGridItem.Content = L("settings.nav.grid", "Grid");
|
||||
SettingsNavColorItem.Content = L("settings.nav.color", "Color");
|
||||
SettingsNavStatusBarItem.Content = L("settings.nav.status_bar", "Status Bar");
|
||||
SettingsNavWeatherItem.Content = L("settings.nav.weather", "Weather");
|
||||
SettingsNavRegionItem.Content = L("settings.nav.region", "Region");
|
||||
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
||||
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
|
||||
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
|
||||
SettingsNavPluginMarketItem.Content = L("settings.nav.plugin_market", "Plugin Market");
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||
@@ -127,10 +130,10 @@ public partial class MainWindow
|
||||
ClearWallpaperButton.Content = L("settings.wallpaper.clear_button", "Reset");
|
||||
|
||||
GridPanelTitleTextBlock.Text = L("settings.grid.title", "Grid Layout");
|
||||
GridSpacingPresetLabelTextBlock.Text = L("settings.grid.spacing_label", "Grid Spacing");
|
||||
GridSpacingSettingsExpander.Header = L("settings.grid.spacing_label", "Grid Spacing");
|
||||
GridSpacingRelaxedComboBoxItem.Content = L("settings.grid.spacing_relaxed", "Relaxed");
|
||||
GridSpacingCompactComboBoxItem.Content = L("settings.grid.spacing_compact", "Compact");
|
||||
GridEdgeInsetLabelTextBlock.Text = L("settings.grid.edge_inset_label", "Screen Inset");
|
||||
GridEdgeInsetSettingsExpander.Header = L("settings.grid.edge_inset_label", "Screen Inset");
|
||||
ApplyGridButton.Content = L("settings.grid.apply_button", "Apply");
|
||||
UpdateGridEdgeInsetComputedPxText(_currentDesktopCellSize);
|
||||
|
||||
@@ -175,9 +178,17 @@ public partial class MainWindow
|
||||
StatusBarSpacingModeCompactItem.Content = L("settings.status_bar.spacing_mode_compact", "Compact");
|
||||
StatusBarSpacingModeRelaxedItem.Content = L("settings.status_bar.spacing_mode_relaxed", "Relaxed");
|
||||
StatusBarSpacingModeCustomItem.Content = L("settings.status_bar.spacing_mode_custom", "Custom");
|
||||
StatusBarSpacingCustomLabelTextBlock.Text = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
StatusBarSpacingCustomPanel.Content = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
|
||||
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
|
||||
WeatherPreviewSectionTextBlock.Text = L("settings.weather.preview_section", "Weather Preview");
|
||||
WeatherSettingsSectionTextBlock.Text = L("settings.weather.settings_section", "Settings");
|
||||
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
WeatherPreviewSettingsExpander.Description = L(
|
||||
"settings.weather.preview_panel_desc",
|
||||
"Refresh and verify current weather service status.");
|
||||
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
|
||||
|
||||
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
|
||||
WeatherLocationSettingsExpander.Description = L(
|
||||
"settings.weather.location_source_desc",
|
||||
@@ -187,6 +198,10 @@ public partial class MainWindow
|
||||
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search");
|
||||
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
|
||||
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup");
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_city_summary_desc",
|
||||
"Select the current city used for weather queries.");
|
||||
|
||||
WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search");
|
||||
WeatherCitySearchSettingsExpander.Description = L(
|
||||
@@ -206,16 +221,12 @@ public partial class MainWindow
|
||||
WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)");
|
||||
WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
|
||||
|
||||
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
WeatherPreviewSettingsExpander.Description = L(
|
||||
"settings.weather.preview_panel_desc",
|
||||
"Refresh and verify current weather service status.");
|
||||
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
|
||||
|
||||
WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts");
|
||||
WeatherAlertFilterSettingsExpander.Description = L(
|
||||
"settings.weather.alert_filter_desc",
|
||||
"Alerts containing these words will not be shown. One rule per line.");
|
||||
WeatherAlertListTitleTextBlock.Text = L("settings.weather.alert_list_label", "Exclude List");
|
||||
WeatherAlertListDescriptionTextBlock.Text = L("settings.weather.alert_list_desc", "One exclusion rule per line.");
|
||||
WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line");
|
||||
|
||||
WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style");
|
||||
@@ -229,6 +240,10 @@ public partial class MainWindow
|
||||
WeatherNoTlsSettingsExpander.Description = L(
|
||||
"settings.weather.no_tls_desc",
|
||||
"Not recommended. Enable only for incompatible network environments.");
|
||||
WeatherNoTlsToggleSwitch.Content = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
|
||||
WeatherFooterHintTextBlock.Text = L(
|
||||
"settings.weather.footer_hint",
|
||||
"Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_weatherSearchKeyword))
|
||||
{
|
||||
@@ -248,6 +263,7 @@ public partial class MainWindow
|
||||
|
||||
RegionPanelTitleTextBlock.Text = L("settings.region.title", "Region");
|
||||
LanguageSettingsExpander.Header = L("settings.region.language_header", "Language");
|
||||
LanguageSettingsExpander.Description = L("settings.region.language_desc", "Select application language. Changes apply immediately.");
|
||||
LanguageChineseItem.Content = L("settings.region.language_zh", "Chinese");
|
||||
LanguageEnglishItem.Content = L("settings.region.language_en", "English");
|
||||
TimeZoneSettingsExpander.Header = L("settings.region.timezone_header", "Time Zone");
|
||||
@@ -267,19 +283,10 @@ 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",
|
||||
"Manage plugin loading and backend isolation.");
|
||||
PluginSystemDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.runtime_hint",
|
||||
"This page will host installed plugin management, permission review, and sandboxed backend runtime controls.");
|
||||
PluginSystemStatusTextBlock.Text = L(
|
||||
"settings.plugins.runtime_status",
|
||||
"Plugin management UI is not connected yet. Next step is wiring the loader, permissions, and worker isolation state into this panel.");
|
||||
ApplyPluginSettingsLocalization();
|
||||
ApplyPluginMarketSettingsLocalization();
|
||||
|
||||
SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About");
|
||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
VersionTextBlock.Text = Lf(
|
||||
"settings.about.version_format",
|
||||
@@ -297,6 +304,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)
|
||||
{
|
||||
@@ -323,6 +341,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);
|
||||
@@ -393,6 +424,7 @@ public partial class MainWindow
|
||||
WeatherLocationStatusTextBlock.Text = L(
|
||||
"settings.weather.status_city_empty",
|
||||
"No city location is configured.");
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -405,6 +437,7 @@ public partial class MainWindow
|
||||
modeText,
|
||||
locationName,
|
||||
_weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -417,5 +450,7 @@ public partial class MainWindow
|
||||
string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude)
|
||||
: _weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
LanMountainDesktop/Views/MainWindow.RenderBackend.cs
Normal file
42
LanMountainDesktop/Views/MainWindow.RenderBackend.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
112
LanMountainDesktop/Views/MainWindow.RestartPrompt.cs
Normal file
112
LanMountainDesktop/Views/MainWindow.RestartPrompt.cs
Normal 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")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using FluentIcons.Avalonia;
|
||||
using FluentIcons.Avalonia.Fluent;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
@@ -19,6 +20,7 @@ using Avalonia.Platform;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -28,6 +30,47 @@ namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void OnExternalAppSettingsSaved(string sourceInstanceId)
|
||||
{
|
||||
if (string.Equals(sourceInstanceId, _appSettingsService.InstanceId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduleReloadFromExternalSettings();
|
||||
}
|
||||
|
||||
private void OnExternalLauncherSettingsSaved(string sourceInstanceId)
|
||||
{
|
||||
if (string.Equals(sourceInstanceId, _launcherSettingsService.InstanceId, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ScheduleReloadFromExternalSettings();
|
||||
}
|
||||
|
||||
private void ScheduleReloadFromExternalSettings()
|
||||
{
|
||||
if (_externalSettingsReloadPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_externalSettingsReloadPending = true;
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_externalSettingsReloadPending = false;
|
||||
|
||||
if (!IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ReloadFromPersistedSettings();
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void OnOpenSettingsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isComponentLibraryOpen)
|
||||
@@ -49,16 +92,20 @@ public partial class MainWindow
|
||||
CloseSettingsPage();
|
||||
}
|
||||
|
||||
private void OnSettingsNavSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
private void OnSettingsNavSelectionChanged(object? sender, FluentAvalonia.UI.Controls.NavigationViewSelectionChangedEventArgs e)
|
||||
{
|
||||
UpdateSettingsTabContent();
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
private int GetSettingsTabIndex()
|
||||
{
|
||||
return ResolveSelectedSettingsTabIndex();
|
||||
}
|
||||
|
||||
private void UpdateSettingsTabContent()
|
||||
{
|
||||
// SelectionChanged can fire during XAML initialization before all named controls are assigned.
|
||||
if (SettingsNavListBox is null ||
|
||||
if (SettingsNavView is null ||
|
||||
GridSettingsPanel is null ||
|
||||
WallpaperSettingsPanel is null ||
|
||||
ColorSettingsPanel is null ||
|
||||
@@ -68,29 +115,44 @@ public partial class MainWindow
|
||||
UpdateSettingsPanel is null ||
|
||||
LauncherSettingsPanel is null ||
|
||||
AboutSettingsPanel is null ||
|
||||
PluginSettingsPanel is null)
|
||||
PluginSettingsPanel is null ||
|
||||
PluginMarketSettingsPanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedIndex = SettingsNavListBox.SelectedIndex;
|
||||
WallpaperSettingsPanel.IsVisible = selectedIndex == 0;
|
||||
GridSettingsPanel.IsVisible = selectedIndex == 1;
|
||||
ColorSettingsPanel.IsVisible = selectedIndex == 2;
|
||||
StatusBarSettingsPanel.IsVisible = selectedIndex == 3;
|
||||
WeatherSettingsPanel.IsVisible = selectedIndex == 4;
|
||||
RegionSettingsPanel.IsVisible = selectedIndex == 5;
|
||||
UpdateSettingsPanel.IsVisible = selectedIndex == 6;
|
||||
AboutSettingsPanel.IsVisible = selectedIndex == 7;
|
||||
LauncherSettingsPanel.IsVisible = selectedIndex == 8;
|
||||
PluginSettingsPanel.IsVisible = selectedIndex == 9;
|
||||
var selectedItem = SettingsNavView.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
|
||||
var tag = selectedItem?.Tag?.ToString();
|
||||
|
||||
if (selectedIndex == 8)
|
||||
WallpaperSettingsPanel.IsVisible = tag == "Wallpaper";
|
||||
GridSettingsPanel.IsVisible = tag == "Grid";
|
||||
ColorSettingsPanel.IsVisible = tag == "Color";
|
||||
StatusBarSettingsPanel.IsVisible = tag == "StatusBar";
|
||||
WeatherSettingsPanel.IsVisible = tag == "Weather";
|
||||
RegionSettingsPanel.IsVisible = tag == "Region";
|
||||
UpdateSettingsPanel.IsVisible = tag == "Update";
|
||||
AboutSettingsPanel.IsVisible = tag == "About";
|
||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
|
||||
UpdatePluginSettingsPageVisibility(tag);
|
||||
|
||||
if (tag == "Launcher")
|
||||
{
|
||||
RenderLauncherHiddenItemsList();
|
||||
}
|
||||
|
||||
if (selectedIndex == 1)
|
||||
if (tag == "Plugins")
|
||||
{
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "PluginMarket")
|
||||
{
|
||||
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "Grid")
|
||||
{
|
||||
UpdateGridPreviewLayout();
|
||||
}
|
||||
@@ -864,47 +926,162 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = new AppSettingsSnapshot
|
||||
_appSettingsService.Save(BuildAppSettingsSnapshot());
|
||||
_desktopLayoutSettingsService.Save(BuildDesktopLayoutSettingsSnapshot());
|
||||
_launcherSettingsService.Save(BuildLauncherSettingsSnapshot());
|
||||
|
||||
}
|
||||
|
||||
internal void ReloadFromPersistedSettings()
|
||||
{
|
||||
_suppressSettingsPersistence = true;
|
||||
try
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var desktopLayoutSnapshot = _desktopLayoutSettingsService.Load();
|
||||
var launcherSnapshot = _launcherSettingsService.Load();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TimeZoneId))
|
||||
{
|
||||
_timeZoneService.SetTimeZoneById(snapshot.TimeZoneId);
|
||||
}
|
||||
|
||||
_targetShortSideCells = Math.Clamp(
|
||||
snapshot.GridShortSideCells > 0 ? snapshot.GridShortSideCells : CalculateDefaultShortSideCellCountFromDpi(),
|
||||
MinShortSideCells,
|
||||
MaxShortSideCells);
|
||||
|
||||
_gridSpacingPreset = NormalizeGridSpacingPreset(snapshot.GridSpacingPreset);
|
||||
_suppressGridSpacingEvents = true;
|
||||
GridSpacingPresetComboBox.SelectedIndex =
|
||||
string.Equals(_gridSpacingPreset, "Compact", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
||||
_suppressGridSpacingEvents = false;
|
||||
|
||||
_desktopEdgeInsetPercent = Math.Clamp(snapshot.DesktopEdgeInsetPercent, MinEdgeInsetPercent, MaxEdgeInsetPercent);
|
||||
_suppressGridInsetEvents = true;
|
||||
GridEdgeInsetSlider.Value = _desktopEdgeInsetPercent;
|
||||
GridEdgeInsetNumberBox.Value = _desktopEdgeInsetPercent;
|
||||
_suppressGridInsetEvents = false;
|
||||
|
||||
_statusBarSpacingMode = NormalizeStatusBarSpacingMode(snapshot.StatusBarSpacingMode);
|
||||
_statusBarCustomSpacingPercent = Math.Clamp(snapshot.StatusBarCustomSpacingPercent, 0, 30);
|
||||
_suppressStatusBarSpacingEvents = true;
|
||||
StatusBarSpacingModeComboBox.SelectedIndex = _statusBarSpacingMode switch
|
||||
{
|
||||
"Compact" => 0,
|
||||
"Custom" => 2,
|
||||
_ => 1
|
||||
};
|
||||
StatusBarSpacingSlider.Value = _statusBarCustomSpacingPercent;
|
||||
StatusBarSpacingNumberBox.Value = _statusBarCustomSpacingPercent;
|
||||
StatusBarSpacingCustomPanel.IsVisible = string.Equals(_statusBarSpacingMode, "Custom", StringComparison.OrdinalIgnoreCase);
|
||||
_suppressStatusBarSpacingEvents = false;
|
||||
|
||||
GridSizeNumberBox.Value = _targetShortSideCells;
|
||||
GridSizeSlider.Value = _targetShortSideCells;
|
||||
|
||||
RestoreSettingsTabSelection(snapshot);
|
||||
|
||||
UpdateSettingsTabContent();
|
||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
|
||||
ApplyTaskbarSettings(snapshot);
|
||||
InitializeLocalization(snapshot.LanguageCode);
|
||||
InitializeWeatherSettings(snapshot);
|
||||
_ = _componentSettingsService.Load();
|
||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||
InitializeAppRenderModeSetting(snapshot);
|
||||
InitializeUpdateSettings(snapshot);
|
||||
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
|
||||
InitializeLauncherVisibilitySettings(launcherSnapshot);
|
||||
InitializeDesktopComponentPlacements(desktopLayoutSnapshot);
|
||||
InitializeSettingsIcons();
|
||||
|
||||
TryRestoreWallpaper(snapshot.WallpaperPath);
|
||||
ApplyWallpaperBrush();
|
||||
UpdateWallpaperDisplay();
|
||||
|
||||
if (TryParseColor(snapshot.ThemeColor, out var savedThemeColor))
|
||||
{
|
||||
_selectedThemeColor = savedThemeColor;
|
||||
}
|
||||
|
||||
_isNightMode = snapshot.IsNightMode ?? (CalculateCurrentBackgroundLuminance() < LightBackgroundLuminanceThreshold);
|
||||
ApplyNightModeState(_isNightMode, refreshPalettes: true);
|
||||
_suppressStatusBarToggleEvents = true;
|
||||
StatusBarClockToggleSwitch.IsChecked = _topStatusComponentIds.Contains(BuiltInComponentIds.Clock);
|
||||
_suppressStatusBarToggleEvents = false;
|
||||
ApplyLocalization();
|
||||
ThemeColorStatusTextBlock.Text = Lf("settings.color.theme_ready_format", "Theme color ready: {0}.", _selectedThemeColor);
|
||||
RebuildDesktopGrid();
|
||||
LoadLauncherEntriesAsync();
|
||||
InitializeTimeZoneSettings();
|
||||
ClockWidget.SetTimeZoneService(_timeZoneService);
|
||||
UpdateWallpaperPreviewLayout();
|
||||
UpdateGridPreviewLayout();
|
||||
UpdateAdaptiveTextSystem();
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressSettingsPersistence = false;
|
||||
}
|
||||
}
|
||||
|
||||
private AppSettingsSnapshot BuildAppSettingsSnapshot()
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
snapshot.GridShortSideCells = _targetShortSideCells;
|
||||
snapshot.GridSpacingPreset = _gridSpacingPreset;
|
||||
snapshot.DesktopEdgeInsetPercent = _desktopEdgeInsetPercent;
|
||||
snapshot.IsNightMode = _isNightMode;
|
||||
snapshot.ThemeColor = _selectedThemeColor.ToString();
|
||||
snapshot.WallpaperPath = _wallpaperPath;
|
||||
snapshot.WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement());
|
||||
snapshot.SettingsTabIndex = Math.Max(0, GetSettingsTabIndex());
|
||||
snapshot.SettingsTabTag = GetSelectedSettingsTabTag();
|
||||
snapshot.LanguageCode = _languageCode;
|
||||
snapshot.TimeZoneId = _timeZoneService.CurrentTimeZone.Id;
|
||||
snapshot.WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode);
|
||||
snapshot.WeatherLocationKey = _weatherLocationKey;
|
||||
snapshot.WeatherLocationName = _weatherLocationName;
|
||||
snapshot.WeatherLatitude = _weatherLatitude;
|
||||
snapshot.WeatherLongitude = _weatherLongitude;
|
||||
snapshot.WeatherAutoRefreshLocation = _weatherAutoRefreshLocation;
|
||||
snapshot.WeatherLocationQuery = BuildLegacyWeatherLocationQuery();
|
||||
snapshot.WeatherExcludedAlerts = _weatherExcludedAlertsRaw;
|
||||
snapshot.WeatherIconPackId = _weatherIconPackId;
|
||||
snapshot.WeatherNoTlsRequests = _weatherNoTlsRequests;
|
||||
snapshot.AutoStartWithWindows = _autoStartWithWindows;
|
||||
snapshot.AppRenderMode = _selectedAppRenderMode;
|
||||
snapshot.AutoCheckUpdates = _autoCheckUpdates;
|
||||
snapshot.IncludePrereleaseUpdates = IncludePrereleaseUpdates;
|
||||
snapshot.UpdateChannel = IncludePrereleaseUpdates ? UpdateChannelPreview : UpdateChannelStable;
|
||||
snapshot.TopStatusComponentIds = _topStatusComponentIds.ToList();
|
||||
snapshot.PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList();
|
||||
snapshot.EnableDynamicTaskbarActions = _enableDynamicTaskbarActions;
|
||||
snapshot.TaskbarLayoutMode = _taskbarLayoutMode;
|
||||
snapshot.ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond";
|
||||
snapshot.StatusBarSpacingMode = _statusBarSpacingMode;
|
||||
snapshot.StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent;
|
||||
return snapshot;
|
||||
}
|
||||
private DesktopLayoutSettingsSnapshot BuildDesktopLayoutSettingsSnapshot()
|
||||
{
|
||||
return new DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
GridShortSideCells = _targetShortSideCells,
|
||||
GridSpacingPreset = _gridSpacingPreset,
|
||||
DesktopEdgeInsetPercent = _desktopEdgeInsetPercent,
|
||||
IsNightMode = _isNightMode,
|
||||
ThemeColor = _selectedThemeColor.ToString(),
|
||||
WallpaperPath = _wallpaperPath,
|
||||
WallpaperPlacement = GetPlacementDisplayName(GetSelectedWallpaperPlacement()),
|
||||
SettingsTabIndex = Math.Max(0, SettingsNavListBox?.SelectedIndex ?? 0),
|
||||
LanguageCode = _languageCode,
|
||||
TimeZoneId = _timeZoneService.CurrentTimeZone.Id,
|
||||
WeatherLocationMode = ToWeatherLocationModeTag(_weatherLocationMode),
|
||||
WeatherLocationKey = _weatherLocationKey,
|
||||
WeatherLocationName = _weatherLocationName,
|
||||
WeatherLatitude = _weatherLatitude,
|
||||
WeatherLongitude = _weatherLongitude,
|
||||
WeatherAutoRefreshLocation = _weatherAutoRefreshLocation,
|
||||
WeatherLocationQuery = BuildLegacyWeatherLocationQuery(),
|
||||
WeatherExcludedAlerts = _weatherExcludedAlertsRaw,
|
||||
WeatherIconPackId = _weatherIconPackId,
|
||||
WeatherNoTlsRequests = _weatherNoTlsRequests,
|
||||
AutoStartWithWindows = _autoStartWithWindows,
|
||||
AutoCheckUpdates = _autoCheckUpdates,
|
||||
IncludePrereleaseUpdates = IncludePrereleaseUpdates,
|
||||
UpdateChannel = IncludePrereleaseUpdates ? "Preview" : "Stable",
|
||||
TopStatusComponentIds = _topStatusComponentIds.ToList(),
|
||||
PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(),
|
||||
EnableDynamicTaskbarActions = _enableDynamicTaskbarActions,
|
||||
TaskbarLayoutMode = _taskbarLayoutMode,
|
||||
ClockDisplayFormat = _clockDisplayFormat == ClockDisplayFormat.HourMinute ? "HourMinute" : "HourMinuteSecond",
|
||||
StatusBarSpacingMode = _statusBarSpacingMode,
|
||||
StatusBarCustomSpacingPercent = _statusBarCustomSpacingPercent,
|
||||
DesktopPageCount = _desktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = _currentDesktopSurfaceIndex,
|
||||
DesktopComponentPlacements = _desktopComponentPlacements.ToList(),
|
||||
DesktopComponentPlacements = _desktopComponentPlacements.ToList()
|
||||
};
|
||||
}
|
||||
private LauncherSettingsSnapshot BuildLauncherSettingsSnapshot()
|
||||
{
|
||||
return new LauncherSettingsSnapshot
|
||||
{
|
||||
HiddenLauncherFolderPaths = _hiddenLauncherFolderPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList(),
|
||||
HiddenLauncherAppPaths = _hiddenLauncherAppPaths.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
|
||||
_appSettingsService.Save(snapshot);
|
||||
}
|
||||
|
||||
private IDisposable? _persistSettingsDebounceTimer;
|
||||
@@ -1057,6 +1234,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)
|
||||
@@ -1176,6 +1408,8 @@ public partial class MainWindow
|
||||
{
|
||||
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
|
||||
}
|
||||
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
|
||||
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -1324,6 +1558,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)
|
||||
@@ -1632,7 +1893,7 @@ public partial class MainWindow
|
||||
var weather = snapshot.Current.WeatherText ??
|
||||
L("settings.weather.preview_unknown", "Unknown");
|
||||
var temperature = snapshot.Current.TemperatureC.HasValue
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C")
|
||||
? FormatWeatherPreviewTemperature(snapshot.Current.TemperatureC.Value)
|
||||
: "--";
|
||||
var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt;
|
||||
|
||||
@@ -1675,6 +1936,14 @@ public partial class MainWindow
|
||||
|
||||
private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt)
|
||||
{
|
||||
if (WeatherPreviewIconImage is not null)
|
||||
{
|
||||
var kind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, _isNightMode);
|
||||
WeatherPreviewIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(kind)) ??
|
||||
HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
|
||||
}
|
||||
|
||||
if (WeatherPreviewIconSymbol is not null)
|
||||
{
|
||||
WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode);
|
||||
@@ -1694,10 +1963,15 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue
|
||||
? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime)
|
||||
? updatedAt.Value.LocalDateTime.ToString("yyyy/M/d HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FormatWeatherPreviewTemperature(double temperatureC)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
|
||||
}
|
||||
|
||||
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
|
||||
{
|
||||
return weatherCode switch
|
||||
@@ -1713,6 +1987,38 @@ public partial class MainWindow
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateWeatherLocationSummaryCard()
|
||||
{
|
||||
if (WeatherLocationSelectionTitleTextBlock is null ||
|
||||
WeatherLocationSelectionDescriptionTextBlock is null ||
|
||||
WeatherLocationValueTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
|
||||
{
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.coordinates_selection_label", "Coordinate Location");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_coordinates_summary_desc",
|
||||
"Set latitude/longitude and optional location name used for weather queries.");
|
||||
WeatherLocationValueTextBlock.Text = string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{_weatherLatitude:F4}, {_weatherLongitude:F4}")
|
||||
: _weatherLocationName;
|
||||
return;
|
||||
}
|
||||
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_city_summary_desc",
|
||||
"Select the current city used for weather queries.");
|
||||
WeatherLocationValueTextBlock.Text = !string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? _weatherLocationName
|
||||
: !string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? _weatherLocationKey
|
||||
: L("settings.weather.location_not_selected", "No location selected");
|
||||
}
|
||||
|
||||
private void SetWeatherSearchBusy(bool isBusy)
|
||||
{
|
||||
if (WeatherSearchButton is not null)
|
||||
@@ -1753,7 +2059,7 @@ public partial class MainWindow
|
||||
: CalculateCurrentBackgroundLuminance() >= LightBackgroundLuminanceThreshold;
|
||||
var isLightNavBackground = _isSettingsOpen
|
||||
? !_isNightMode
|
||||
: CalculateBrushLuminance(SettingsNavPanelBorder?.Background) >= LightBackgroundLuminanceThreshold;
|
||||
: CalculateBrushLuminance(SettingsNavView?.Background) >= LightBackgroundLuminanceThreshold;
|
||||
var context = new ThemeColorContext(
|
||||
_selectedThemeColor,
|
||||
isLightBackground,
|
||||
@@ -2287,5 +2593,223 @@ public partial class MainWindow
|
||||
IconVariant = IconVariant.Regular
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// --- UserControl Forwarding Properties (Definitive Catch-All List) ---
|
||||
// These properties allow legacy code in MainWindow.axaml.cs and other partials
|
||||
// to access controls that have been moved into independent UserControls.
|
||||
// ========================================================================
|
||||
|
||||
// --- WallpaperSettingsPage ---
|
||||
internal TextBlock WallpaperPanelTitleTextBlock => WallpaperSettingsPanel.FindControl<TextBlock>("WallpaperPanelTitleTextBlock")!;
|
||||
internal TextBlock WallpaperPathTextBlock => WallpaperSettingsPanel.FindControl<TextBlock>("WallpaperPathTextBlock")!;
|
||||
internal TextBlock WallpaperStatusTextBlock => WallpaperSettingsPanel.FindControl<TextBlock>("WallpaperStatusTextBlock")!;
|
||||
internal ComboBox WallpaperPlacementComboBox => WallpaperSettingsPanel.FindControl<ComboBox>("WallpaperPlacementComboBox")!;
|
||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||
internal LanMountainDesktop.Views.Components.ClockWidget WallpaperPreviewClockWidget => WallpaperSettingsPanel.FindControl<LanMountainDesktop.Views.Components.ClockWidget>("WallpaperPreviewClockWidget")!;
|
||||
internal Border WallpaperPreviewBottomTaskbarContainer => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewBottomTaskbarContainer")!;
|
||||
internal Border WallpaperPreviewTaskbarFixedActionsHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTaskbarFixedActionsHost")!;
|
||||
internal StackPanel WallpaperPreviewBackButtonVisual => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewBackButtonVisual")!;
|
||||
internal TextBlock WallpaperPreviewBackButtonTextBlock => WallpaperSettingsPanel.FindControl<TextBlock>("WallpaperPreviewBackButtonTextBlock")!;
|
||||
internal StackPanel WallpaperPreviewTaskbarDynamicActionsHost => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTaskbarDynamicActionsHost")!;
|
||||
internal Border WallpaperPreviewTaskbarSettingsActionHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTaskbarSettingsActionHost")!;
|
||||
internal StackPanel WallpaperPreviewComponentLibraryVisual => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewComponentLibraryVisual")!;
|
||||
internal TextBlock WallpaperPreviewComponentLibraryTextBlock => WallpaperSettingsPanel.FindControl<TextBlock>("WallpaperPreviewComponentLibraryTextBlock")!;
|
||||
internal FluentIcons.Avalonia.SymbolIcon WallpaperPreviewSettingsButtonIcon => WallpaperSettingsPanel.FindControl<FluentIcons.Avalonia.SymbolIcon>("WallpaperPreviewSettingsButtonIcon")!;
|
||||
internal Button PickWallpaperButton => WallpaperSettingsPanel.FindControl<Button>("PickWallpaperButton")!;
|
||||
internal Button ClearWallpaperButton => WallpaperSettingsPanel.FindControl<Button>("ClearWallpaperButton")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WallpaperPlacementSettingsExpander => WallpaperSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WallpaperPlacementSettingsExpander")!;
|
||||
|
||||
// --- GridSettingsPage ---
|
||||
internal TextBlock GridPanelTitleTextBlock => GridSettingsPanel.FindControl<TextBlock>("GridPanelTitleTextBlock")!;
|
||||
internal Border GridPreviewHost => GridSettingsPanel.FindControl<Border>("GridPreviewHost")!;
|
||||
internal Border GridPreviewFrame => GridSettingsPanel.FindControl<Border>("GridPreviewFrame")!;
|
||||
internal Border GridPreviewViewport => GridSettingsPanel.FindControl<Border>("GridPreviewViewport")!;
|
||||
internal Canvas GridPreviewLinesCanvas => GridSettingsPanel.FindControl<Canvas>("GridPreviewLinesCanvas")!;
|
||||
internal Grid GridPreviewGrid => GridSettingsPanel.FindControl<Grid>("GridPreviewGrid")!;
|
||||
internal Border GridPreviewTopStatusBarHost => GridSettingsPanel.FindControl<Border>("GridPreviewTopStatusBarHost")!;
|
||||
internal StackPanel GridPreviewTopStatusComponentsPanel => GridSettingsPanel.FindControl<StackPanel>("GridPreviewTopStatusComponentsPanel")!;
|
||||
internal Border GridPreviewBottomTaskbarContainer => GridSettingsPanel.FindControl<Border>("GridPreviewBottomTaskbarContainer")!;
|
||||
internal Border GridPreviewTaskbarFixedActionsHost => GridSettingsPanel.FindControl<Border>("GridPreviewTaskbarFixedActionsHost")!;
|
||||
internal StackPanel GridPreviewBackButtonVisual => GridSettingsPanel.FindControl<StackPanel>("GridPreviewBackButtonVisual")!;
|
||||
internal TextBlock GridPreviewBackButtonTextBlock => GridSettingsPanel.FindControl<TextBlock>("GridPreviewBackButtonTextBlock")!;
|
||||
internal StackPanel GridPreviewTaskbarDynamicActionsHost => GridSettingsPanel.FindControl<StackPanel>("GridPreviewTaskbarDynamicActionsHost")!;
|
||||
internal Border GridPreviewTaskbarSettingsActionHost => GridSettingsPanel.FindControl<Border>("GridPreviewTaskbarSettingsActionHost")!;
|
||||
internal StackPanel GridPreviewComponentLibraryVisual => GridSettingsPanel.FindControl<StackPanel>("GridPreviewComponentLibraryVisual")!;
|
||||
internal FluentIcons.Avalonia.FluentIcon GridPreviewComponentLibraryIcon => GridSettingsPanel.FindControl<FluentIcons.Avalonia.FluentIcon>("GridPreviewComponentLibraryIcon")!;
|
||||
internal TextBlock GridPreviewComponentLibraryTextBlock => GridSettingsPanel.FindControl<TextBlock>("GridPreviewComponentLibraryTextBlock")!;
|
||||
internal FluentIcons.Avalonia.SymbolIcon GridPreviewSettingsButtonIcon => GridSettingsPanel.FindControl<FluentIcons.Avalonia.SymbolIcon>("GridPreviewSettingsButtonIcon")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander GridRowsSettingsExpander => GridSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("GridRowsSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander GridSpacingSettingsExpander => GridSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("GridSpacingSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander GridEdgeInsetSettingsExpander => GridSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("GridEdgeInsetSettingsExpander")!;
|
||||
internal Slider GridSizeSlider => GridSettingsPanel.FindControl<Slider>("GridSizeSlider")!;
|
||||
internal FluentAvalonia.UI.Controls.NumberBox GridSizeNumberBox => GridSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("GridSizeNumberBox")!;
|
||||
internal ComboBox GridSpacingPresetComboBox => GridSettingsPanel.FindControl<ComboBox>("GridSpacingPresetComboBox")!;
|
||||
internal ComboBoxItem GridSpacingRelaxedComboBoxItem => GridSettingsPanel.FindControl<ComboBoxItem>("GridSpacingRelaxedComboBoxItem")!;
|
||||
internal ComboBoxItem GridSpacingCompactComboBoxItem => GridSettingsPanel.FindControl<ComboBoxItem>("GridSpacingCompactComboBoxItem")!;
|
||||
internal Slider GridEdgeInsetSlider => GridSettingsPanel.FindControl<Slider>("GridEdgeInsetSlider")!;
|
||||
internal FluentAvalonia.UI.Controls.NumberBox GridEdgeInsetNumberBox => GridSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("GridEdgeInsetNumberBox")!;
|
||||
internal TextBlock GridEdgeInsetComputedPxTextBlock => GridSettingsPanel.FindControl<TextBlock>("GridEdgeInsetComputedPxTextBlock")!;
|
||||
internal Button ApplyGridButton => GridSettingsPanel.FindControl<Button>("ApplyGridButton")!;
|
||||
internal TextBlock GridInfoTextBlock => GridSettingsPanel.FindControl<TextBlock>("GridInfoTextBlock")!;
|
||||
|
||||
// --- ColorSettingsPage ---
|
||||
internal TextBlock ColorPanelTitleTextBlock => ColorSettingsPanel.FindControl<TextBlock>("ColorPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander ThemeModeSettingsExpander => ColorSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ThemeModeSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander ThemeColorSettingsExpander => ColorSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("ThemeColorSettingsExpander")!;
|
||||
internal ToggleSwitch NightModeToggleSwitch => ColorSettingsPanel.FindControl<ToggleSwitch>("NightModeToggleSwitch")!;
|
||||
internal TextBlock ThemeColorStatusTextBlock => ColorSettingsPanel.FindControl<TextBlock>("ThemeColorStatusTextBlock")!;
|
||||
internal TextBlock RecommendedColorsLabelTextBlock => ColorSettingsPanel.FindControl<TextBlock>("RecommendedColorsLabelTextBlock")!;
|
||||
internal TextBlock SystemMonetColorsLabelTextBlock => ColorSettingsPanel.FindControl<TextBlock>("SystemMonetColorsLabelTextBlock")!;
|
||||
internal Button RecommendedColorButton1 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton1")!;
|
||||
internal Button RecommendedColorButton2 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton2")!;
|
||||
internal Button RecommendedColorButton3 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton3")!;
|
||||
internal Button RecommendedColorButton4 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton4")!;
|
||||
internal Button RecommendedColorButton5 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton5")!;
|
||||
internal Button RecommendedColorButton6 => ColorSettingsPanel.FindControl<Button>("RecommendedColorButton6")!;
|
||||
internal Border RecommendedColorSwatch1 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch1")!;
|
||||
internal Border RecommendedColorSwatch2 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch2")!;
|
||||
internal Border RecommendedColorSwatch3 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch3")!;
|
||||
internal Border RecommendedColorSwatch4 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch4")!;
|
||||
internal Border RecommendedColorSwatch5 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch5")!;
|
||||
internal Border RecommendedColorSwatch6 => ColorSettingsPanel.FindControl<Border>("RecommendedColorSwatch6")!;
|
||||
internal Button RefreshMonetColorsButton => ColorSettingsPanel.FindControl<Button>("RefreshMonetColorsButton")!;
|
||||
internal Button MonetColorButton1 => ColorSettingsPanel.FindControl<Button>("MonetColorButton1")!;
|
||||
internal Button MonetColorButton2 => ColorSettingsPanel.FindControl<Button>("MonetColorButton2")!;
|
||||
internal Button MonetColorButton3 => ColorSettingsPanel.FindControl<Button>("MonetColorButton3")!;
|
||||
internal Button MonetColorButton4 => ColorSettingsPanel.FindControl<Button>("MonetColorButton4")!;
|
||||
internal Button MonetColorButton5 => ColorSettingsPanel.FindControl<Button>("MonetColorButton5")!;
|
||||
internal Button MonetColorButton6 => ColorSettingsPanel.FindControl<Button>("MonetColorButton6")!;
|
||||
internal Border MonetColorSwatch1 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch1")!;
|
||||
internal Border MonetColorSwatch2 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch2")!;
|
||||
internal Border MonetColorSwatch3 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch3")!;
|
||||
internal Border MonetColorSwatch4 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch4")!;
|
||||
internal Border MonetColorSwatch5 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch5")!;
|
||||
internal Border MonetColorSwatch6 => ColorSettingsPanel.FindControl<Border>("MonetColorSwatch6")!;
|
||||
|
||||
// --- StatusBarSettingsPage ---
|
||||
internal TextBlock StatusBarPanelTitleTextBlock => StatusBarSettingsPanel.FindControl<TextBlock>("StatusBarPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander StatusBarClockSettingsExpander => StatusBarSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("StatusBarClockSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander StatusBarSpacingSettingsExpander => StatusBarSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("StatusBarSpacingSettingsExpander")!;
|
||||
internal ToggleSwitch StatusBarClockToggleSwitch => StatusBarSettingsPanel.FindControl<ToggleSwitch>("StatusBarClockToggleSwitch")!;
|
||||
internal RadioButton ClockFormatHMSSRadio => StatusBarSettingsPanel.FindControl<RadioButton>("ClockFormatHMSSRadio")!;
|
||||
internal RadioButton ClockFormatHMRadio => StatusBarSettingsPanel.FindControl<RadioButton>("ClockFormatHMRadio")!;
|
||||
internal ComboBox StatusBarSpacingModeComboBox => StatusBarSettingsPanel.FindControl<ComboBox>("StatusBarSpacingModeComboBox")!;
|
||||
internal ComboBoxItem StatusBarSpacingModeCompactItem => StatusBarSettingsPanel.FindControl<ComboBoxItem>("StatusBarSpacingModeCompactItem")!;
|
||||
internal ComboBoxItem StatusBarSpacingModeRelaxedItem => StatusBarSettingsPanel.FindControl<ComboBoxItem>("StatusBarSpacingModeRelaxedItem")!;
|
||||
internal ComboBoxItem StatusBarSpacingModeCustomItem => StatusBarSettingsPanel.FindControl<ComboBoxItem>("StatusBarSpacingModeCustomItem")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpanderItem StatusBarSpacingCustomPanel => StatusBarSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpanderItem>("StatusBarSpacingCustomPanel")!;
|
||||
internal Slider StatusBarSpacingSlider => StatusBarSettingsPanel.FindControl<Slider>("StatusBarSpacingSlider")!;
|
||||
internal FluentAvalonia.UI.Controls.NumberBox StatusBarSpacingNumberBox => StatusBarSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("StatusBarSpacingNumberBox")!;
|
||||
internal TextBlock StatusBarSpacingComputedPxTextBlock => StatusBarSettingsPanel.FindControl<TextBlock>("StatusBarSpacingComputedPxTextBlock")!;
|
||||
|
||||
// --- RegionSettingsPage ---
|
||||
internal TextBlock RegionPanelTitleTextBlock => RegionSettingsPanel.FindControl<TextBlock>("RegionPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander LanguageSettingsExpander => RegionSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LanguageSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander TimeZoneSettingsExpander => RegionSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("TimeZoneSettingsExpander")!;
|
||||
internal ComboBox LanguageComboBox => RegionSettingsPanel.FindControl<ComboBox>("LanguageComboBox")!;
|
||||
internal ComboBoxItem LanguageChineseItem => RegionSettingsPanel.FindControl<ComboBoxItem>("LanguageChineseItem")!;
|
||||
internal ComboBoxItem LanguageEnglishItem => RegionSettingsPanel.FindControl<ComboBoxItem>("LanguageEnglishItem")!;
|
||||
internal ComboBox TimeZoneComboBox => RegionSettingsPanel.FindControl<ComboBox>("TimeZoneComboBox")!;
|
||||
|
||||
// --- WeatherSettingsPage ---
|
||||
internal TextBlock WeatherPanelTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPanelTitleTextBlock")!;
|
||||
internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewSectionTextBlock")!;
|
||||
internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSettingsSectionTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherPreviewSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherLocationSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCitySearchSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCoordinateSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCoordinateSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherAlertFilterSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherAlertFilterSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherIconPackSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherIconPackSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherNoTlsSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherNoTlsSettingsExpander")!;
|
||||
internal Button WeatherPreviewButton => WeatherSettingsPanel.FindControl<Button>("WeatherPreviewButton")!;
|
||||
internal ComboBox WeatherLocationModeComboBox => WeatherSettingsPanel.FindControl<ComboBox>("WeatherLocationModeComboBox")!;
|
||||
internal ComboBoxItem WeatherLocationModeCityItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherLocationModeCityItem")!;
|
||||
internal ComboBoxItem WeatherLocationModeCoordinatesItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherLocationModeCoordinatesItem")!;
|
||||
internal ListBoxItem WeatherLocationModeCityChipItem => WeatherSettingsPanel.FindControl<ListBoxItem>("WeatherLocationModeCityChipItem")!;
|
||||
internal ListBoxItem WeatherLocationModeCoordinatesChipItem => WeatherSettingsPanel.FindControl<ListBoxItem>("WeatherLocationModeCoordinatesChipItem")!;
|
||||
internal ListBox WeatherLocationModeChipListBox => WeatherSettingsPanel.FindControl<ListBox>("WeatherLocationModeChipListBox")!;
|
||||
internal ToggleSwitch WeatherAutoRefreshToggleSwitch => WeatherSettingsPanel.FindControl<ToggleSwitch>("WeatherAutoRefreshToggleSwitch")!;
|
||||
internal Button WeatherSearchButton => WeatherSettingsPanel.FindControl<Button>("WeatherSearchButton")!;
|
||||
internal Button WeatherApplyCityButton => WeatherSettingsPanel.FindControl<Button>("WeatherApplyCityButton")!;
|
||||
internal Button WeatherApplyCoordinatesButton => WeatherSettingsPanel.FindControl<Button>("WeatherApplyCoordinatesButton")!;
|
||||
internal TextBox WeatherExcludedAlertsTextBox => WeatherSettingsPanel.FindControl<TextBox>("WeatherExcludedAlertsTextBox")!;
|
||||
internal ComboBox WeatherIconPackComboBox => WeatherSettingsPanel.FindControl<ComboBox>("WeatherIconPackComboBox")!;
|
||||
internal ToggleSwitch WeatherNoTlsToggleSwitch => WeatherSettingsPanel.FindControl<ToggleSwitch>("WeatherNoTlsToggleSwitch")!;
|
||||
internal TextBox WeatherCitySearchTextBox => WeatherSettingsPanel.FindControl<TextBox>("WeatherCitySearchTextBox")!;
|
||||
internal ComboBox WeatherCityResultsComboBox => WeatherSettingsPanel.FindControl<ComboBox>("WeatherCityResultsComboBox")!;
|
||||
internal TextBlock WeatherSearchStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSearchStatusTextBlock")!;
|
||||
internal TextBox WeatherLocationKeyTextBox => WeatherSettingsPanel.FindControl<TextBox>("WeatherLocationKeyTextBox")!;
|
||||
internal TextBox WeatherLocationNameTextBox => WeatherSettingsPanel.FindControl<TextBox>("WeatherLocationNameTextBox")!;
|
||||
internal FluentAvalonia.UI.Controls.NumberBox WeatherLatitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLatitudeNumberBox")!;
|
||||
internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLongitudeNumberBox")!;
|
||||
internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherCoordinateStatusTextBlock")!;
|
||||
internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewResultTextBlock")!;
|
||||
internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl<Image>("WeatherPreviewIconImage")!;
|
||||
internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl<FluentIcons.Avalonia.Fluent.SymbolIcon>("WeatherPreviewIconSymbol")!;
|
||||
internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewTemperatureTextBlock")!;
|
||||
internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewUpdatedTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.ProgressRing WeatherSearchProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherSearchProgressRing")!;
|
||||
internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherPreviewProgressRing")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentRegularItem")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentFilledItem")!;
|
||||
internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionTitleTextBlock")!;
|
||||
internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationValueTextBlock")!;
|
||||
internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationStatusTextBlock")!;
|
||||
internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListTitleTextBlock")!;
|
||||
internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherFooterHintTextBlock")!;
|
||||
|
||||
// --- UpdateSettingsPage ---
|
||||
internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander UpdateOptionsSettingsExpander => UpdateSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("UpdateOptionsSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander UpdateActionsSettingsExpander => UpdateSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("UpdateActionsSettingsExpander")!;
|
||||
internal TextBlock UpdateCurrentVersionLabelTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateCurrentVersionLabelTextBlock")!;
|
||||
internal TextBlock UpdateCurrentVersionValueTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateCurrentVersionValueTextBlock")!;
|
||||
internal TextBlock UpdateLatestVersionLabelTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateLatestVersionLabelTextBlock")!;
|
||||
internal TextBlock UpdateLatestVersionValueTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateLatestVersionValueTextBlock")!;
|
||||
internal TextBlock UpdatePublishedAtLabelTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePublishedAtLabelTextBlock")!;
|
||||
internal TextBlock UpdatePublishedAtValueTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePublishedAtValueTextBlock")!;
|
||||
internal TextBlock UpdateChannelLabelTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateChannelLabelTextBlock")!;
|
||||
internal ListBoxItem UpdateChannelStableChipItem => UpdateSettingsPanel.FindControl<ListBoxItem>("UpdateChannelStableChipItem")!;
|
||||
internal ListBoxItem UpdateChannelPreviewChipItem => UpdateSettingsPanel.FindControl<ListBoxItem>("UpdateChannelPreviewChipItem")!;
|
||||
internal ToggleSwitch AutoCheckUpdatesToggleSwitch => UpdateSettingsPanel.FindControl<ToggleSwitch>("AutoCheckUpdatesToggleSwitch")! ;
|
||||
internal ListBox UpdateChannelChipListBox => UpdateSettingsPanel.FindControl<ListBox>("UpdateChannelChipListBox")!;
|
||||
internal Button CheckForUpdatesButton => UpdateSettingsPanel.FindControl<Button>("CheckForUpdatesButton")!;
|
||||
internal Button DownloadAndInstallUpdateButton => UpdateSettingsPanel.FindControl<Button>("DownloadAndInstallUpdateButton")!;
|
||||
internal ProgressBar UpdateDownloadProgressBar => UpdateSettingsPanel.FindControl<ProgressBar>("UpdateDownloadProgressBar")!;
|
||||
internal TextBlock UpdateDownloadProgressTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateDownloadProgressTextBlock")!;
|
||||
internal TextBlock UpdateStatusTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdateStatusTextBlock")!;
|
||||
|
||||
// --- 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")!;
|
||||
|
||||
// --- LauncherSettingsPage ---
|
||||
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
||||
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ public partial class MainWindow
|
||||
|
||||
private void ApplyUpdateLocalization()
|
||||
{
|
||||
SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update");
|
||||
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
||||
UpdatePanelTitleTextBlock.Text = L("settings.update.title", "Update");
|
||||
|
||||
UpdateCurrentVersionLabelTextBlock.Text = L("settings.update.current_version_label", "Current Version");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,6 @@ using Avalonia.Styling;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.Styling;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
@@ -88,6 +87,8 @@ public partial class MainWindow : Window
|
||||
}
|
||||
private readonly MonetColorService _monetColorService = new();
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly DesktopLayoutSettingsService _desktopLayoutSettingsService = new();
|
||||
private readonly LauncherSettingsService _launcherSettingsService = new();
|
||||
private readonly ComponentSettingsService _componentSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly TimeZoneService _timeZoneService = new();
|
||||
@@ -96,11 +97,7 @@ public partial class MainWindow : Window
|
||||
private readonly IWeatherDataService _weatherDataService = new XiaomiWeatherService();
|
||||
private readonly IRecommendationInfoService _recommendationInfoService = new RecommendationDataService();
|
||||
private readonly ICalculatorDataService _calculatorDataService = new CalculatorDataService();
|
||||
private readonly ComponentRegistry _componentRegistry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||
private readonly ComponentRegistry _componentRegistry;
|
||||
private readonly DesktopComponentRuntimeRegistry _componentRuntimeRegistry;
|
||||
private readonly FluentAvaloniaTheme? _fluentAvaloniaTheme;
|
||||
private readonly HashSet<string> _topStatusComponentIds = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -170,21 +167,118 @@ 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;
|
||||
private ClockDisplayFormat _clockDisplayFormat = ClockDisplayFormat.HourMinuteSecond;
|
||||
private bool _externalSettingsReloadPending;
|
||||
|
||||
private double CurrentDesktopPitch => _currentDesktopCellSize + _currentDesktopCellGap;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
var pluginRuntimeService = (Application.Current as App)?.PluginRuntimeService;
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create(pluginRuntimeService);
|
||||
|
||||
InitializeComponent();
|
||||
_componentRuntimeRegistry = DesktopComponentRuntimeRegistry.CreateDefault(_componentRegistry);
|
||||
InitializePluginSettingsNavigation();
|
||||
_componentRuntimeRegistry = DesktopComponentRegistryFactory.CreateRuntimeRegistry(
|
||||
_componentRegistry,
|
||||
pluginRuntimeService);
|
||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||
AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved;
|
||||
LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved;
|
||||
PendingRestartStateService.StateChanged += OnPendingRestartStateChanged;
|
||||
PropertyChanged += OnWindowPropertyChanged;
|
||||
InitializeDesktopSurfaceSwipeHandlers();
|
||||
InitializeDesktopComponentDragHandlers();
|
||||
|
||||
PickWallpaperButton.Click += OnPickWallpaperClick;
|
||||
ClearWallpaperButton.Click += OnClearWallpaperClick;
|
||||
WallpaperPlacementComboBox.SelectionChanged += OnWallpaperPlacementSelectionChanged;
|
||||
|
||||
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
||||
GridSpacingPresetComboBox.SelectionChanged += OnGridSpacingPresetSelectionChanged;
|
||||
GridEdgeInsetSlider.ValueChanged += OnGridEdgeInsetSliderChanged;
|
||||
ApplyGridButton.Click += OnApplyGridSizeClick;
|
||||
|
||||
NightModeToggleSwitch.IsCheckedChanged += OnNightModeIsCheckedChanged;
|
||||
RecommendedColorButton1.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton2.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton3.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton4.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton5.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton6.Click += OnRecommendedColorClick;
|
||||
RefreshMonetColorsButton.Click += OnRefreshMonetColorsClick;
|
||||
MonetColorButton1.Click += OnMonetColorClick;
|
||||
MonetColorButton2.Click += OnMonetColorClick;
|
||||
MonetColorButton3.Click += OnMonetColorClick;
|
||||
MonetColorButton4.Click += OnMonetColorClick;
|
||||
MonetColorButton5.Click += OnMonetColorClick;
|
||||
MonetColorButton6.Click += OnMonetColorClick;
|
||||
|
||||
StatusBarClockToggleSwitch.IsCheckedChanged += OnStatusBarClockIsCheckedChanged;
|
||||
ClockFormatHMSSRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
ClockFormatHMRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
StatusBarSpacingModeComboBox.SelectionChanged += OnStatusBarSpacingModeChanged;
|
||||
StatusBarSpacingSlider.ValueChanged += OnStatusBarSpacingSliderChanged;
|
||||
|
||||
WeatherPreviewButton.Click += OnTestWeatherRequestClick;
|
||||
WeatherLocationModeComboBox.SelectionChanged += OnWeatherLocationModeSelectionChanged;
|
||||
WeatherLocationModeChipListBox.SelectionChanged += OnWeatherLocationModeChipSelectionChanged;
|
||||
WeatherAutoRefreshToggleSwitch.IsCheckedChanged += OnWeatherAutoRefreshToggled;
|
||||
WeatherSearchButton.Click += OnSearchWeatherCityClick;
|
||||
WeatherApplyCityButton.Click += OnApplyWeatherCitySelectionClick;
|
||||
WeatherApplyCoordinatesButton.Click += OnApplyWeatherCoordinatesClick;
|
||||
WeatherExcludedAlertsTextBox.LostFocus += OnWeatherExcludedAlertsLostFocus;
|
||||
WeatherIconPackComboBox.SelectionChanged += OnWeatherIconPackSelectionChanged;
|
||||
WeatherNoTlsToggleSwitch.IsCheckedChanged += OnWeatherNoTlsToggled;
|
||||
|
||||
LanguageComboBox.SelectionChanged += OnLanguageSelectionChanged;
|
||||
TimeZoneComboBox.SelectionChanged += OnTimeZoneSelectionChanged;
|
||||
|
||||
AutoCheckUpdatesToggleSwitch.IsCheckedChanged += OnAutoCheckUpdatesToggled;
|
||||
UpdateChannelChipListBox.SelectionChanged += OnUpdateChannelSelectionChanged;
|
||||
CheckForUpdatesButton.Click += OnCheckForUpdatesClick;
|
||||
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
|
||||
|
||||
AutoStartWithWindowsToggleSwitch.IsCheckedChanged += OnAutoStartWithWindowsToggled;
|
||||
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnNightModeIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnNightModeChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnNightModeUnchecked(sender, e);
|
||||
}
|
||||
|
||||
private void OnStatusBarClockIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnStatusBarClockChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnStatusBarClockUnchecked(sender, e);
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
@@ -193,6 +287,8 @@ public partial class MainWindow : Window
|
||||
|
||||
_suppressSettingsPersistence = true;
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var desktopLayoutSnapshot = _desktopLayoutSettingsService.Load();
|
||||
var launcherSnapshot = _launcherSettingsService.Load();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.TimeZoneId))
|
||||
{
|
||||
@@ -236,7 +332,7 @@ public partial class MainWindow : Window
|
||||
GridSizeSlider.ValueChanged += OnGridSizeSliderChanged;
|
||||
GridSizeNumberBox.ValueChanged += OnGridSizeNumberBoxChanged;
|
||||
|
||||
SettingsNavListBox.SelectedIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, 9);
|
||||
RestoreSettingsTabSelection(snapshot);
|
||||
UpdateSettingsTabContent();
|
||||
|
||||
WallpaperPlacementComboBox.SelectedIndex = GetPlacementIndexFromSetting(snapshot.WallpaperPlacement);
|
||||
@@ -246,10 +342,11 @@ public partial class MainWindow : Window
|
||||
InitializeWeatherSettings(snapshot);
|
||||
_ = _componentSettingsService.Load();
|
||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||
InitializeAppRenderModeSetting(snapshot);
|
||||
InitializeUpdateSettings(snapshot);
|
||||
InitializeDesktopSurfaceState(snapshot);
|
||||
InitializeLauncherVisibilitySettings(snapshot);
|
||||
InitializeDesktopComponentPlacements(snapshot);
|
||||
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
|
||||
InitializeLauncherVisibilitySettings(launcherSnapshot);
|
||||
InitializeDesktopComponentPlacements(desktopLayoutSnapshot);
|
||||
InitializeSettingsIcons();
|
||||
|
||||
TryRestoreWallpaper(snapshot.WallpaperPath);
|
||||
@@ -309,6 +406,9 @@ public partial class MainWindow : Window
|
||||
_releaseUpdateService.Dispose();
|
||||
_wallpaperBitmap?.Dispose();
|
||||
_wallpaperBitmap = null;
|
||||
AppSettingsService.SettingsSaved -= OnExternalAppSettingsSaved;
|
||||
LauncherSettingsService.SettingsSaved -= OnExternalLauncherSettingsSaved;
|
||||
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
|
||||
PropertyChanged -= OnWindowPropertyChanged;
|
||||
DesktopHost.SizeChanged -= OnDesktopHostSizeChanged;
|
||||
WallpaperPreviewHost.SizeChanged -= OnWallpaperPreviewHostSizeChanged;
|
||||
@@ -713,6 +813,11 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (radioButton.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_clockDisplayFormat = formatTag == "Hm"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
@@ -1324,6 +1429,7 @@ public partial class MainWindow : Window
|
||||
_suppressTimeZoneSelectionEvents = false;
|
||||
}
|
||||
|
||||
|
||||
private void OnTimeZoneSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressTimeZoneSelectionEvents || TimeZoneComboBox.SelectedItem is not ComboBoxItem item)
|
||||
@@ -1341,4 +1447,3 @@ public partial class MainWindow : Window
|
||||
PersistSettings();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="1000"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.AboutSettingsPage">
|
||||
|
||||
<StackPanel x:Name="AboutSettingsPanel" Spacing="20">
|
||||
<TextBlock x:Name="AboutPanelTitleTextBlock" FontSize="24" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" Text="About" />
|
||||
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}" CornerRadius="{DynamicResource DesignCornerRadiusMd}" Padding="20">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Text="LanMountainDesktop" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock Text="Modern desktop shell experience." FontSize="13" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
<Separator Background="{DynamicResource AdaptiveButtonBorderBrush}" Margin="0,8" />
|
||||
<TextBlock x:Name="VersionTextBlock" Text="Version: 1.0.0" FontSize="13" Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock x:Name="CodeNameTextBlock" Text="Code Name: Administrate" FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource AdaptiveAccentBrush}" />
|
||||
<TextBlock x:Name="FontInfoTextBlock" Text="Font: MiSans" FontSize="12" Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="AboutStartupSettingsExpander"
|
||||
Header="Windows Startup"
|
||||
Description="Launch the app automatically when signing in to Windows."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Window" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="AutoStartWithWindowsToggleSwitch" />
|
||||
</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>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class AboutSettingsPage : UserControl
|
||||
{
|
||||
public AboutSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
204
LanMountainDesktop/Views/SettingsPages/ColorSettingsPage.axaml
Normal file
204
LanMountainDesktop/Views/SettingsPages/ColorSettingsPage.axaml
Normal file
@@ -0,0 +1,204 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.ColorSettingsPage">
|
||||
<StackPanel x:Name="ColorSettingsPanel"
|
||||
Spacing="16">
|
||||
<TextBlock x:Name="ColorPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Color" />
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="ThemeModeSettingsExpander"
|
||||
Header="日夜模式"
|
||||
Description="切换应用的浅色或深色主题。">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="NightModeToggleSwitch"
|
||||
OffContent="Day"
|
||||
OnContent="Night" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="ThemeColorSettingsExpander"
|
||||
Header="主题色"
|
||||
Description="选择应用的主题点缀色。">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
|
||||
</ui:SettingsExpander.IconSource>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock x:Name="RecommendedColorsLabelTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Recommended Colors" />
|
||||
<WrapPanel ItemWidth="72"
|
||||
ItemHeight="56"
|
||||
Orientation="Horizontal">
|
||||
<Button x:Name="RecommendedColorButton1"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch1"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="RecommendedColorButton2"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch2"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="RecommendedColorButton3"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch3"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="RecommendedColorButton4"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch4"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="RecommendedColorButton5"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch5"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="RecommendedColorButton6"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="RecommendedColorSwatch6"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem.Footer>
|
||||
</ui:SettingsExpanderItem>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem.Footer>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBlock x:Name="SystemMonetColorsLabelTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="System Monet Colors" />
|
||||
<Button x:Name="RefreshMonetColorsButton"
|
||||
Grid.Column="1"
|
||||
Padding="10,6"
|
||||
Content="Refresh" />
|
||||
</Grid>
|
||||
|
||||
<WrapPanel ItemWidth="72"
|
||||
ItemHeight="56"
|
||||
Orientation="Horizontal">
|
||||
<Button x:Name="MonetColorButton1"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch1"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="MonetColorButton2"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch2"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="MonetColorButton3"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch3"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="MonetColorButton4"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch4"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="MonetColorButton5"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch5"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
<Button x:Name="MonetColorButton6"
|
||||
Width="68"
|
||||
Height="50"
|
||||
Padding="8">
|
||||
<Border x:Name="MonetColorSwatch6"
|
||||
Width="26"
|
||||
Height="26"
|
||||
CornerRadius="12"
|
||||
BorderThickness="0" />
|
||||
</Button>
|
||||
</WrapPanel>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem.Footer>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="ThemeColorStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextMutedBrush}"
|
||||
Text="Theme color is ready." />
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class ColorSettingsPage : UserControl
|
||||
{
|
||||
public ColorSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
172
LanMountainDesktop/Views/SettingsPages/GridSettingsPage.axaml
Normal file
172
LanMountainDesktop/Views/SettingsPages/GridSettingsPage.axaml
Normal file
@@ -0,0 +1,172 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.GridSettingsPage">
|
||||
<Grid x:Name="GridSettingsPanel"
|
||||
ColumnDefinitions="*, *"
|
||||
RowDefinitions="Auto, *">
|
||||
<TextBlock x:Name="GridPanelTitleTextBlock"
|
||||
Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Margin="0,0,0,24"
|
||||
Text="调整网格布局" />
|
||||
|
||||
<!-- Left Column: Grid Preview -->
|
||||
<Border x:Name="GridPreviewHost"
|
||||
Grid.Row="1" Grid.Column="0"
|
||||
Margin="0,0,16,0"
|
||||
VerticalAlignment="Top"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Border x:Name="GridPreviewFrame"
|
||||
HorizontalAlignment="Stretch"
|
||||
CornerRadius="28"
|
||||
Background="#FF1A1A1A"
|
||||
Padding="12">
|
||||
<Border x:Name="GridPreviewViewport"
|
||||
ClipToBounds="True"
|
||||
CornerRadius="16"
|
||||
Background="#30111827">
|
||||
<Panel>
|
||||
<Canvas x:Name="GridPreviewLinesCanvas"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
IsHitTestVisible="False" />
|
||||
<Grid x:Name="GridPreviewGrid"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<Border x:Name="GridPreviewTopStatusBarHost"
|
||||
Grid.Row="0"
|
||||
Background="Transparent"
|
||||
Padding="2">
|
||||
<StackPanel x:Name="GridPreviewTopStatusComponentsPanel"
|
||||
Orientation="Horizontal"
|
||||
Spacing="3">
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="GridPreviewBottomTaskbarContainer"
|
||||
Classes="glass-strong"
|
||||
Grid.Row="1"
|
||||
Margin="3"
|
||||
CornerRadius="16"
|
||||
Padding="2">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="3">
|
||||
<Border x:Name="GridPreviewTaskbarFixedActionsHost" Grid.Column="0">
|
||||
<StackPanel x:Name="GridPreviewBackButtonVisual" Orientation="Horizontal" Spacing="3">
|
||||
<fi:SymbolIcon Classes="icon-s" Symbol="Window" />
|
||||
<TextBlock x:Name="GridPreviewBackButtonTextBlock" Text="回到Windows" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<StackPanel x:Name="GridPreviewTaskbarDynamicActionsHost"
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Center"
|
||||
Spacing="3" />
|
||||
<Border x:Name="GridPreviewTaskbarSettingsActionHost" Grid.Column="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||
<StackPanel x:Name="GridPreviewComponentLibraryVisual" IsVisible="False" Orientation="Horizontal" Spacing="3">
|
||||
<fi:FluentIcon x:Name="GridPreviewComponentLibraryIcon" Classes="icon-s" Icon="Apps" />
|
||||
<TextBlock x:Name="GridPreviewComponentLibraryTextBlock" Text="Widget library" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<fi:SymbolIcon x:Name="GridPreviewSettingsButtonIcon" Classes="icon-s" Symbol="Settings" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Border>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<!-- Right Column: Settings Content -->
|
||||
<StackPanel Grid.Row="1" Grid.Column="1"
|
||||
Margin="16,0,0,0"
|
||||
Spacing="16">
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="GridRowsSettingsExpander" Header="Rows" Description="Adjust the density of the grid">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12" Width="220">
|
||||
<Slider x:Name="GridSizeSlider"
|
||||
Grid.Column="0"
|
||||
Minimum="6"
|
||||
Maximum="96"
|
||||
TickFrequency="1"
|
||||
TickPlacement="None"
|
||||
Value="12" />
|
||||
<ui:NumberBox x:Name="GridSizeNumberBox"
|
||||
Grid.Column="1"
|
||||
Width="80"
|
||||
Minimum="6"
|
||||
Maximum="96"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Value="12" />
|
||||
</Grid>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="GridSpacingSettingsExpander" Header="Spacing" Description="Adjust the gap between cells">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ComboBox x:Name="GridSpacingPresetComboBox"
|
||||
Width="120">
|
||||
<ComboBoxItem x:Name="GridSpacingRelaxedComboBoxItem" Tag="Relaxed" Content="Relaxed" />
|
||||
<ComboBoxItem x:Name="GridSpacingCompactComboBoxItem" Tag="Compact" Content="Compact" />
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="GridEdgeInsetSettingsExpander" Header="Screen Inset" Description="Adjust margins around the desktop">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="12" Width="220">
|
||||
<Slider x:Name="GridEdgeInsetSlider"
|
||||
Grid.Column="0"
|
||||
Minimum="0"
|
||||
Maximum="30"
|
||||
TickFrequency="1"
|
||||
TickPlacement="None"
|
||||
Value="18" />
|
||||
<ui:NumberBox x:Name="GridEdgeInsetNumberBox"
|
||||
Grid.Column="1"
|
||||
Width="80"
|
||||
Minimum="0"
|
||||
Maximum="30"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Value="18" />
|
||||
</Grid>
|
||||
</ui:SettingsExpander.Footer>
|
||||
<ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem.Footer>
|
||||
<TextBlock x:Name="GridEdgeInsetComputedPxTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text=">= 0 px" />
|
||||
</ui:SettingsExpanderItem.Footer>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Button x:Name="ApplyGridButton"
|
||||
HorizontalAlignment="Stretch"
|
||||
Padding="0,10"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Content="应用" />
|
||||
|
||||
<TextBlock x:Name="GridInfoTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Grid: - cols x - rows (1:1)" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class GridSettingsPage : UserControl
|
||||
{
|
||||
public GridSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="1000"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.LauncherSettingsPage">
|
||||
|
||||
<StackPanel x:Name="LauncherSettingsPanel" Spacing="16">
|
||||
<TextBlock x:Name="LauncherSettingsPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="App Launcher" />
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="LauncherHiddenItemsSettingsExpander"
|
||||
Header="Hidden Items"
|
||||
Description="Review hidden launcher entries and show them again."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="LauncherHiddenItemsDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Right-click an icon in launcher to hide it. Hidden entries appear here." />
|
||||
<TextBlock x:Name="LauncherHiddenItemsEmptyTextBlock"
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No hidden items." />
|
||||
<ScrollViewer MaxHeight="420"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="LauncherHiddenItemsListPanel"
|
||||
Spacing="8" />
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class LauncherSettingsPage : UserControl
|
||||
{
|
||||
public LauncherSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user