Compare commits

...

26 Commits

Author SHA1 Message Date
lincube
e1be072b97 0.5.11
插件市场UI优化
2026-03-10 16:35:43 +08:00
lincube
4df740e3df 0.5.10
多线程
2026-03-10 14:56:05 +08:00
lincube
85f7a18cbc 0.5.9
中文与插件市场
2026-03-10 12:14:49 +08:00
lincube
cdffaa16eb 0.5.8
插件市场
2026-03-10 09:55:49 +08:00
lincube
d33d8d3391 0.5.7
示例插件
2026-03-10 00:40:26 +08:00
lincube
9c89c08448 0.5.6
插件系统再进化
2026-03-10 00:04:33 +08:00
lincube
ec7b78bc63 0.5.5
现已支持更改关键设置时提醒重启功能
2026-03-09 22:26:42 +08:00
lincube
e97db00999 0.5.4
项目重启优化。
2026-03-09 17:54:49 +08:00
lincube
8bb6b01236 0.5.3
试验性引入渲染模式切换
2026-03-09 15:11:48 +08:00
lincube
103b215e35 0.5.2
后端服务支持
2026-03-09 14:14:50 +08:00
lincube
cab35f4c22 0.5.1
插件系统试验
2026-03-09 12:27:33 +08:00
lincube
c9f92a4755 0.5.0
设置优化
2026-03-08 14:00:13 +08:00
lincube
854deae801 0.4.12
模块化解耦
2026-03-08 04:22:19 +08:00
lincube
d72cd42483 0.4.11 2026-03-08 03:23:03 +08:00
lincube
3aee31c6c0 0.4.10
自习数据采样优化
2026-03-07 22:44:00 +08:00
lincube
435b96c50c 0.4.9.3
内存泄露问题解决
2026-03-07 22:05:18 +08:00
lincube
49b18d6af1 0.4.9.2 2026-03-07 19:59:28 +08:00
lincube
d6ec159af4 0.4.9.1
小修复
2026-03-07 17:25:29 +08:00
lincube
0d14675cc0 0.4.9
Linux相关版本适配
2026-03-07 00:58:52 +08:00
lincube
1f509959a9 0.4.8
百度热搜组件、凤凰新闻组件。
2026-03-06 22:24:59 +08:00
lincube
382d1baaf1 0.4.7
2×2英语单词组件,修复了stcn组件
2026-03-06 18:38:20 +08:00
lincube
72a0be16b3 0.4.6
引入了托盘菜单,提供了应用启动台隐藏应用功能,优化了自动刷新功能,为STCN 24组件提供了更多信息选项。
2026-03-06 10:32:02 +08:00
lincube
de40471af6 0.4.5
新增STCN 24组件,优化应用启动台,允许用户隐藏应用启动台图标。优化组件拖动排放。
2026-03-06 08:53:45 +08:00
lincube
5d35e0d21c 0.4.4
bilibili热搜组件
2026-03-06 00:29:40 +08:00
lincube
e917a1e4af 0.4.3fixed 2026-03-05 21:21:03 +08:00
lincube
b8643a2959 0.4.3
新增英语句子组件。优化央广网新闻组件,优化每日单词组件
2026-03-05 20:17:28 +08:00
230 changed files with 33519 additions and 3120 deletions

View File

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

View File

@@ -275,6 +275,8 @@ jobs:
package_name="LanMountainDesktop"
package_version="${version}"
arch="amd64"
desktop_template="LanMountainDesktop/packaging/linux/LanMountainDesktop.desktop"
icon_source="LanMountainDesktop/packaging/linux/lanmountaindesktop.png"
# Verify source directory exists
if [ ! -d "$source" ]; then
@@ -288,6 +290,7 @@ jobs:
mkdir -p "build-deb/usr/local/bin"
mkdir -p "build-deb/usr/share/applications"
mkdir -p "build-deb/usr/share/pixmaps"
mkdir -p "build-deb/usr/share/icons/hicolor/256x256/apps"
# Copy application files
cp -r "$source"/* "build-deb/usr/local/bin/"
@@ -300,6 +303,31 @@ jobs:
echo "Error: DEB package is empty after copy"
exit 1
fi
if [ ! -f "$desktop_template" ] || [ ! -f "$icon_source" ]; then
echo "Error: Linux desktop resources are missing"
ls -la "LanMountainDesktop/packaging/linux" || true
exit 1
fi
sed \
-e "s|@@EXEC@@|/usr/local/bin/LanMountainDesktop|g" \
-e "s|@@ICON@@|lanmountaindesktop|g" \
"$desktop_template" > "build-deb/usr/share/applications/LanMountainDesktop.desktop"
cp "$icon_source" "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
cp "$icon_source" "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
{
printf '%s\n' '#!/bin/sh'
printf '%s\n' 'set -e'
printf '%s\n' 'if command -v update-desktop-database >/dev/null 2>&1; then'
printf '%s\n' ' update-desktop-database /usr/share/applications >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
printf '%s\n' 'if command -v gtk-update-icon-cache >/dev/null 2>&1; then'
printf '%s\n' ' gtk-update-icon-cache /usr/share/icons/hicolor >/dev/null 2>&1 || true'
printf '%s\n' 'fi'
} > "build-deb/DEBIAN/postinst"
# Create control file (NOTE: No leading spaces in control file)
{
@@ -313,6 +341,10 @@ jobs:
# Set proper permissions
chmod 755 "build-deb/usr/local/bin/LanMountainDesktop" || chmod 755 "build-deb/usr/local/bin"/*
chmod 644 "build-deb/usr/share/applications/LanMountainDesktop.desktop"
chmod 644 "build-deb/usr/share/pixmaps/lanmountaindesktop.png"
chmod 644 "build-deb/usr/share/icons/hicolor/256x256/apps/lanmountaindesktop.png"
chmod 755 "build-deb/DEBIAN/postinst"
# Create DEB file
if dpkg-deb --build "build-deb" "${package_name}_${package_version}_${arch}.deb"; then

10
.gitignore vendored
View File

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

21
LanAirApp/README.md Normal file
View File

@@ -0,0 +1,21 @@
# LanAirApp
## 中文
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
### 目录说明
- `docs/`:插件开发与打包文档。
- `samples/`:示例插件与参考项目。
- `standards/`:插件清单和目录结构约定。
- `tools/`:插件打包与辅助工具。
### 与宿主的关系
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
- 每个插件项目应在仓库根目录提供 `.laapp``README.md`
## English
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.SamplePlugin;
[PluginEntrance]
public sealed class SamplePlugin : PluginBase, IDisposable
{
private SamplePluginRuntimeStateService? _stateService;
private SamplePluginClockService? _clockService;
public override void Initialize(IPluginContext context)
{
Directory.CreateDirectory(context.DataDirectory);
var localizer = PluginLocalizer.Create(context);
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
var messageBus = context.GetService<IPluginMessageBus>()
?? throw new InvalidOperationException("Plugin message bus is not available.");
_stateService = new SamplePluginRuntimeStateService(
context.Manifest,
context.PluginDirectory,
context.DataDirectory,
hostName,
hostVersion,
sdkApiVersion,
messageBus,
localizer);
context.RegisterService(_stateService);
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
context.RegisterService(_clockService);
_stateService.AttachClockService(_clockService);
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
var initMessage =
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
try
{
File.AppendAllText(logPath, initMessage + Environment.NewLine);
_stateService.MarkBackendReady(localizer.Format(
"status.backend.detail.log_written",
"初始化日志已写入:{0}",
logPath));
}
catch (Exception ex)
{
_stateService.MarkBackendFaulted(localizer.Format(
"status.backend.detail.log_write_failed",
"初始化日志写入失败:{0}",
ex.Message));
throw;
}
_clockService.Start();
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
"status",
localizer.GetString("settings.page_title", "插件状态"),
() => new SamplePluginSettingsView(context)));
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
"LanMountainDesktop.SamplePlugin.StatusClock",
localizer.GetString("widget.display_name", "示例插件状态时钟"),
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
iconKey: "PuzzlePiece",
category: localizer.GetString("widget.category", "插件"),
minWidthCells: 4,
minHeightCells: 4,
allowDesktopPlacement: true,
allowStatusBarPlacement: false,
resizeMode: PluginDesktopComponentResizeMode.Proportional,
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
}
public void Dispose()
{
_clockService?.Dispose();
_clockService = null;
_stateService = null;
}
private static string GetHostProperty(IPluginContext context, string key, string fallback)
{
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
? value
: fallback;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<sty:FluentAvaloniaTheme />
<mi:MaterialIconStyles />

View File

@@ -1,10 +1,14 @@
using Avalonia;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using System;
using System.Diagnostics;
using System.Linq;
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Threading;
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using LanMountainDesktop.Views;
@@ -14,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();
@@ -23,11 +36,22 @@ 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(),
@@ -37,6 +61,70 @@ public partial class App : Application
base.OnFrameworkInitializationCompleted();
}
private void OnTrayExitClick(object? sender, EventArgs e)
{
DisposeTrayIcon();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
}
private void OnTraySettingsClick(object? sender, EventArgs e)
{
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime)
{
return;
}
Dispatcher.UIThread.Post(() =>
{
try
{
if (_traySettingsWindow is { } existingWindow && existingWindow.IsVisible)
{
existingWindow.WindowState = Avalonia.Controls.WindowState.Normal;
existingWindow.Activate();
return;
}
var settingsWindow = new SettingsWindow();
settingsWindow.Closed += (_, _) =>
{
if (ReferenceEquals(_traySettingsWindow, settingsWindow))
{
_traySettingsWindow = null;
}
};
_traySettingsWindow = settingsWindow;
settingsWindow.Show();
settingsWindow.Activate();
}
catch (Exception ex)
{
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
}
}, DispatcherPriority.Normal);
}
private void OnTrayRestartClick(object? sender, EventArgs e)
{
AppRestartService.TryRestartApplication();
}
private void OnAppSettingsSaved(string _)
{
Dispatcher.UIThread.Post(() =>
{
if (_trayIcons is not null)
{
InitializeTrayIcon();
}
}, DispatcherPriority.Background);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -76,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);
}
}

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,13 @@ public static class BuiltInComponentIds
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
public const string DesktopDailyWord = "DesktopDailyWord";
public const string DesktopDailyWord2x2 = "DesktopDailyWord2x2";
public const string DesktopCnrDailyNews = "DesktopCnrDailyNews";
public const string DesktopIfengNews = "DesktopIfengNews";
public const string DesktopBilibiliHotSearch = "DesktopBilibiliHotSearch";
public const string DesktopBaiduHotSearch = "DesktopBaiduHotSearch";
public const string DesktopStcn24Forum = "DesktopStcn24Forum";
public const string DesktopExchangeRateCalculator = "DesktopExchangeRateCalculator";
public const string DesktopWhiteboard = "DesktopWhiteboard";
public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape";
public const string DesktopBrowser = "DesktopBrowser";

View File

@@ -234,6 +234,15 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopDailyWord2x2,
"Daily Word 2x2",
"Book",
"Info",
MinWidthCells: 2,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopCnrDailyNews,
"CNR Daily News",
@@ -243,6 +252,51 @@ public sealed class ComponentRegistry
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopIfengNews,
"iFeng News",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBilibiliHotSearch,
"Bilibili Hot Search",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopBaiduHotSearch,
"Baidu Hot Search",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 2,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopStcn24Forum,
"STCN 24",
"News",
"Info",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopExchangeRateCalculator,
"Exchange Rate Converter",
"Calculator",
"Calculator",
MinWidthCells: 4,
MinHeightCells: 4,
AllowStatusBarPlacement: false,
AllowDesktopPlacement: true),
new DesktopComponentDefinition(
BuiltInComponentIds.DesktopWhiteboard,
"Blackboard Portrait",
@@ -331,6 +385,13 @@ public sealed class ComponentRegistry
return new ComponentRegistry(merged);
}
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
{
var merged = _definitions.Values.ToList();
merged.AddRange(definitions);
return new ComponentRegistry(merged);
}
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
{
return _definitions.TryGetValue(componentId, out definition!);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,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" />

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "Settings",
"tray.menu.restart": "Restart App",
"tray.menu.exit": "Exit App",
"button.back_to_windows": "Back to Windows",
"tooltip.back_to_windows": "Back to Windows",
"tooltip.open_settings": "Settings",
"settings.title": "Settings",
"settings.shell.title": "Application Settings",
"settings.shell.subtitle": "LanMountainDesktop standalone preferences",
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
"settings.shell.footer_hint": "Tray-opened settings are managed in this standalone window.",
"settings.back_to_desktop": "Back to Desktop",
"settings.nav_header": "Settings",
"settings.nav.group_desktop": "Desktop",
"settings.nav.group_system": "System",
"settings.nav.group_extensions": "Extensions",
"settings.nav.wallpaper": "Wallpaper",
"settings.nav.grid": "Grid",
"settings.nav.color": "Color",
@@ -13,6 +24,8 @@
"settings.nav.weather": "Weather",
"settings.nav.region": "Region",
"settings.nav.update": "Update",
"settings.nav.launcher": "App Launcher",
"settings.nav.plugins": "Plugins",
"settings.nav.about": "About",
"settings.wallpaper.title": "Wallpaper",
"settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.",
@@ -107,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",
@@ -127,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",
@@ -235,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",
@@ -249,9 +292,103 @@
"desktop.page_index_format": "Desktop {0}",
"launcher.title": "App Launcher",
"launcher.subtitle": "Apps and folders from Windows Start Menu",
"launcher.subtitle_linux": "Installed apps discovered from Linux desktop entries",
"launcher.empty": "No Start Menu entries found.",
"launcher.empty_linux": "No Linux desktop entries were found.",
"launcher.empty_folder": "This folder is empty.",
"launcher.folder_items_format": "{0} apps",
"launcher.context.hide_icon": "Hide Icon",
"launcher.action.hide": "Hide",
"settings.launcher.title": "App Launcher",
"settings.launcher.hidden_header": "Hidden Items",
"settings.launcher.hidden_desc": "Review hidden launcher entries and show them again.",
"settings.launcher.hidden_hint": "In desktop edit mode, select a launcher icon and click Hide. Hidden entries appear here.",
"settings.launcher.hidden_empty": "No hidden items.",
"settings.launcher.hidden_type_folder": "Folder",
"settings.launcher.hidden_type_shortcut": "Shortcut",
"settings.launcher.restore_button": "Show Again",
"settings.plugins.title": "Plugins",
"settings.plugins.runtime_header": "Plugin Runtime",
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
"settings.plugins.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",
@@ -265,6 +402,7 @@
"component_category.board": "Board",
"component_category.media": "Media",
"component_category.info": "Info",
"component_category.calculator": "Calculator",
"component_category.study": "Study",
"component.date": "Calendar",
"component.month_calendar": "Month Calendar",
@@ -283,7 +421,13 @@
"component.daily_poetry": "Daily Poetry",
"component.daily_artwork": "Daily Artwork",
"component.daily_word": "Daily Word",
"component.daily_word_2x2": "Daily Word 2x2",
"component.cnr_daily_news": "CNR Headlines",
"component.ifeng_news": "iFeng News",
"component.bilibili_hot_search": "Bilibili Hot Search",
"component.baidu_hot_search": "Baidu Hot Search",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "Exchange Rate Converter",
"component.whiteboard": "Blackboard (Portrait)",
"component.blackboard_landscape": "Blackboard (Landscape)",
"component.browser": "Browser",
@@ -328,12 +472,122 @@
"dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.",
"dailyword.widget.fallback_example": "Tap the refresh button and try again.",
"dailyword.widget.fallback_example_translation": "It will retry when network recovers.",
"dailyword2x2.widget.tap_to_show": "Tap to reveal meaning",
"cnrnews.widget.loading": "Loading...",
"cnrnews.widget.loading_title": "Fetching CNR headlines",
"cnrnews.widget.loading_subtitle": "Please wait",
"cnrnews.widget.fetch_failed": "News fetch failed",
"cnrnews.widget.fallback_title": "CNR news is temporarily unavailable",
"cnrnews.widget.fallback_subtitle": "Tap refresh and try again",
"cnrnews.widget.hot_label": "Hot",
"bilihot.widget.brand": "bilibili hot search",
"bilihot.widget.top_right_label": "bilibili热搜",
"bilihot.widget.search_entry": "Search",
"bilihot.widget.search_placeholder": "Search trending topics",
"bilihot.widget.loading": "Loading...",
"bilihot.widget.loading_item": "Loading...",
"bilihot.widget.fetch_failed": "Hot search fetch failed",
"bilihot.widget.fallback_item": "No hot search data",
"bilihot.widget.more_hot": "More hot search",
"baiduhot.widget.brand": "Baidu Hot Search",
"baiduhot.widget.loading": "Loading...",
"baiduhot.widget.loading_item": "Loading...",
"baiduhot.widget.fetch_failed": "Hot search fetch failed",
"baiduhot.widget.fallback_item": "No hot search data",
"baiduhot.widget.refresh_tooltip": "Refresh",
"ifeng.widget.brand": "iFeng News",
"ifeng.widget.loading": "Loading...",
"ifeng.widget.loading_item": "Loading...",
"ifeng.widget.fetch_failed": "News fetch failed",
"ifeng.widget.fallback_item": "No news data",
"ifeng.widget.refresh_tooltip": "Refresh",
"dailyword.settings.title": "Daily word settings",
"dailyword.settings.desc": "Configure auto refresh and refresh interval.",
"dailyword.settings.auto_refresh_label": "Auto refresh",
"dailyword.settings.auto_refresh_enabled": "Enable auto refresh",
"dailyword.settings.frequency_label": "Refresh interval",
"bilihot.settings.title": "Bilibili hot search settings",
"bilihot.settings.desc": "Configure auto refresh and refresh interval.",
"bilihot.settings.auto_refresh_label": "Auto refresh",
"bilihot.settings.auto_refresh_enabled": "Enable auto refresh",
"bilihot.settings.frequency_label": "Refresh interval",
"baiduhot.settings.title": "Baidu hot search settings",
"baiduhot.settings.desc": "Configure source, auto refresh and refresh interval.",
"baiduhot.settings.source_label": "Data source",
"baiduhot.settings.source_official": "Official Source",
"baiduhot.settings.source_rss": "Third-party RSS",
"baiduhot.settings.auto_refresh_label": "Auto refresh",
"baiduhot.settings.auto_refresh_enabled": "Enable auto refresh",
"baiduhot.settings.frequency_label": "Refresh interval",
"ifeng.settings.title": "iFeng news settings",
"ifeng.settings.desc": "Configure channel, auto refresh and refresh interval.",
"ifeng.settings.channel_label": "News channel",
"ifeng.settings.channel_comprehensive": "Comprehensive",
"ifeng.settings.channel_mainland": "China Mainland",
"ifeng.settings.channel_taiwan": "Taiwan",
"ifeng.settings.auto_refresh_label": "Auto refresh",
"ifeng.settings.auto_refresh_enabled": "Enable auto refresh",
"ifeng.settings.frequency_label": "Refresh interval",
"refresh.frequency.5m": "5 minutes",
"refresh.frequency.10m": "10 minutes",
"refresh.frequency.12m": "12 minutes",
"refresh.frequency.15m": "15 minutes",
"refresh.frequency.20m": "20 minutes",
"refresh.frequency.30m": "30 minutes",
"refresh.frequency.40m": "40 minutes",
"refresh.frequency.1h": "1 hour",
"refresh.frequency.3h": "3 hours",
"refresh.frequency.6h": "6 hours",
"refresh.frequency.12h": "12 hours",
"refresh.frequency.24h": "24 hours",
"weather.widget.settings.title": "Weather widget settings",
"weather.widget.settings.desc": "Configure auto refresh and refresh interval for all weather widgets.",
"weather.widget.settings.auto_refresh_label": "Auto refresh",
"weather.widget.settings.auto_refresh_enabled": "Enable auto refresh",
"weather.widget.settings.frequency_label": "Refresh interval",
"weather.widget.settings.frequency_10m": "10 minutes",
"weather.widget.settings.frequency_12m": "12 minutes",
"weather.widget.settings.frequency_15m": "15 minutes",
"weather.widget.settings.frequency_30m": "30 minutes",
"weather.widget.settings.frequency_1h": "1 hour",
"weather.widget.settings.frequency_3h": "3 hours",
"stcn24.widget.loading": "Loading...",
"stcn24.widget.loading_item": "Loading...",
"stcn24.widget.fetch_failed": "Forum posts fetch failed",
"stcn24.widget.fallback_item": "No posts",
"stcn24.settings.title": "STCN 24 settings",
"stcn24.settings.desc": "Configure information source, auto refresh and refresh interval.",
"stcn24.settings.source_label": "Information source",
"stcn24.settings.source_latest_created": "Latest posts",
"stcn24.settings.source_latest_activity": "Latest activity",
"stcn24.settings.source_most_replies": "Most replies",
"stcn24.settings.source_earliest_created": "Earliest posts",
"stcn24.settings.source_earliest_activity": "Earliest activity",
"stcn24.settings.source_least_replies": "Least replies",
"stcn24.settings.source_frontpage_latest": "Frontpage latest",
"stcn24.settings.source_frontpage_earliest": "Frontpage earliest",
"stcn24.settings.auto_refresh_label": "Auto refresh",
"stcn24.settings.auto_refresh_enabled": "Enable auto refresh",
"stcn24.settings.frequency_label": "Refresh interval",
"stcn24.settings.frequency_5m": "5 minutes",
"stcn24.settings.frequency_10m": "10 minutes",
"stcn24.settings.frequency_20m": "20 minutes",
"stcn24.settings.frequency_30m": "30 minutes",
"stcn24.settings.frequency_1h": "1 hour",
"stcn24.settings.frequency_3h": "3 hours",
"exchange.widget.loading": "Loading exchange rates...",
"exchange.widget.fetch_failed": "Exchange rate fetch failed",
"cnrnews.settings.title": "CNR Settings",
"cnrnews.settings.desc": "Configure auto-rotation and refresh interval.",
"cnrnews.settings.auto_rotate_label": "Auto-rotation",
"cnrnews.settings.auto_rotate_enabled": "Enable auto-rotation",
"cnrnews.settings.frequency_label": "Rotation interval",
"cnrnews.settings.frequency_5m": "5 minutes",
"cnrnews.settings.frequency_10m": "10 minutes",
"cnrnews.settings.frequency_40m": "40 minutes",
"cnrnews.settings.frequency_1h": "1 hour",
"cnrnews.settings.frequency_12h": "12 hours",
"cnrnews.settings.frequency_24h": "24 hours",
"artwork.settings.title": "Daily Artwork Settings",
"artwork.settings.desc": "Switch the data source used by Daily Artwork.",
"artwork.settings.source_label": "Mirror Source",
@@ -481,3 +735,5 @@
"placement.center": "Center",
"placement.tile": "Tile"
}

View File

@@ -1,11 +1,22 @@
{
"app.title": "LanMountainDesktop",
"tray.tooltip": "LanMountainDesktop",
"tray.menu.settings": "设置",
"tray.menu.restart": "重启应用",
"tray.menu.exit": "退出应用",
"button.back_to_windows": "回到Windows",
"tooltip.back_to_windows": "回到Windows",
"tooltip.open_settings": "设置",
"settings.title": "设置",
"settings.shell.title": "应用设置",
"settings.shell.subtitle": "LanMountainDesktop 独立设置窗口",
"settings.shell.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立窗口中管理。",
"settings.back_to_desktop": "返回桌面",
"settings.nav_header": "设置选项",
"settings.nav.group_desktop": "桌面",
"settings.nav.group_system": "系统",
"settings.nav.group_extensions": "扩展",
"settings.nav.wallpaper": "壁纸",
"settings.nav.grid": "网格",
"settings.nav.color": "颜色",
@@ -13,6 +24,8 @@
"settings.nav.weather": "天气",
"settings.nav.region": "地区",
"settings.nav.update": "更新",
"settings.nav.launcher": "应用启动台",
"settings.nav.plugins": "插件",
"settings.nav.about": "关于",
"settings.wallpaper.title": "壁纸",
"settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。",
@@ -107,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": "刷新",
@@ -127,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": "例如:北京",
@@ -235,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": "图片文件",
@@ -249,9 +292,103 @@
"desktop.page_index_format": "桌面 {0}",
"launcher.title": "应用启动台",
"launcher.subtitle": "按 Windows 开始菜单结构显示所有应用与文件夹",
"launcher.subtitle_linux": "显示从 Linux .desktop 条目扫描到的已安装应用",
"launcher.empty": "未找到开始菜单条目。",
"launcher.empty_linux": "未找到 Linux .desktop 应用条目。",
"launcher.empty_folder": "此文件夹为空。",
"launcher.folder_items_format": "{0} 个应用",
"launcher.context.hide_icon": "隐藏图标",
"launcher.action.hide": "隐藏",
"settings.launcher.title": "应用启动台",
"settings.launcher.hidden_header": "已隐藏项目",
"settings.launcher.hidden_desc": "查看已隐藏的启动台项目并重新显示。",
"settings.launcher.hidden_hint": "进入桌面编辑模式后,在启动台选中图标并点击“隐藏”,隐藏后的项目会显示在这里。",
"settings.launcher.hidden_empty": "暂无隐藏项目。",
"settings.launcher.hidden_type_folder": "文件夹",
"settings.launcher.hidden_type_shortcut": "快捷方式",
"settings.launcher.restore_button": "重新显示",
"settings.plugins.title": "插件",
"settings.plugins.runtime_header": "插件运行时",
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
"settings.plugins.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": "桌面编辑",
@@ -265,6 +402,7 @@
"component_category.board": "白板",
"component_category.media": "媒体",
"component_category.info": "信息推荐",
"component_category.calculator": "计算器",
"component_category.study": "自习",
"component.date": "日历",
"component.month_calendar": "月历",
@@ -283,7 +421,13 @@
"component.daily_poetry": "每日诗词",
"component.daily_artwork": "每日名画",
"component.daily_word": "每日单词",
"component.daily_word_2x2": "每日单词 2x2",
"component.cnr_daily_news": "央广网头条",
"component.ifeng_news": "凤凰网新闻",
"component.bilibili_hot_search": "B站热搜",
"component.baidu_hot_search": "百度热搜",
"component.stcn24_forum": "STCN 24",
"component.exchange_rate_converter": "汇率换算",
"component.whiteboard": "竖向小黑板",
"component.blackboard_landscape": "横向小黑板",
"component.browser": "浏览器",
@@ -328,12 +472,122 @@
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
"dailyword2x2.widget.tap_to_show": "点击查看释义",
"cnrnews.widget.loading": "加载中...",
"cnrnews.widget.loading_title": "正在获取新闻热点",
"cnrnews.widget.loading_subtitle": "请稍候",
"cnrnews.widget.fetch_failed": "新闻获取失败",
"cnrnews.widget.fallback_title": "央广网新闻暂不可用",
"cnrnews.widget.fallback_subtitle": "点击右上角稍后重试",
"cnrnews.widget.hot_label": "热点",
"bilihot.widget.brand": "bilibili 热搜",
"bilihot.widget.top_right_label": "bilibili热搜",
"bilihot.widget.search_entry": "搜索",
"bilihot.widget.search_placeholder": "搜索热词",
"bilihot.widget.loading": "加载中...",
"bilihot.widget.loading_item": "加载中...",
"bilihot.widget.fetch_failed": "热搜获取失败",
"bilihot.widget.fallback_item": "暂无热搜",
"bilihot.widget.more_hot": "更多热搜",
"baiduhot.widget.brand": "百度热搜",
"baiduhot.widget.loading": "加载中...",
"baiduhot.widget.loading_item": "加载中...",
"baiduhot.widget.fetch_failed": "热搜获取失败",
"baiduhot.widget.fallback_item": "暂无热搜",
"baiduhot.widget.refresh_tooltip": "刷新",
"ifeng.widget.brand": "凤凰网新闻",
"ifeng.widget.loading": "加载中...",
"ifeng.widget.loading_item": "加载中...",
"ifeng.widget.fetch_failed": "新闻获取失败",
"ifeng.widget.fallback_item": "暂无新闻",
"ifeng.widget.refresh_tooltip": "刷新",
"dailyword.settings.title": "每日单词设置",
"dailyword.settings.desc": "配置自动刷新开关与刷新频率。",
"dailyword.settings.auto_refresh_label": "自动刷新",
"dailyword.settings.auto_refresh_enabled": "启用自动刷新",
"dailyword.settings.frequency_label": "刷新频率",
"bilihot.settings.title": "B站热搜设置",
"bilihot.settings.desc": "配置自动刷新开关与刷新频率。",
"bilihot.settings.auto_refresh_label": "自动刷新",
"bilihot.settings.auto_refresh_enabled": "启用自动刷新",
"bilihot.settings.frequency_label": "刷新频率",
"baiduhot.settings.title": "百度热搜设置",
"baiduhot.settings.desc": "配置数据源、自动刷新开关与刷新频率。",
"baiduhot.settings.source_label": "数据源",
"baiduhot.settings.source_official": "百度官方源",
"baiduhot.settings.source_rss": "第三方 RSS 源",
"baiduhot.settings.auto_refresh_label": "自动刷新",
"baiduhot.settings.auto_refresh_enabled": "启用自动刷新",
"baiduhot.settings.frequency_label": "刷新频率",
"ifeng.settings.title": "凤凰网新闻设置",
"ifeng.settings.desc": "配置频道、自动刷新开关与刷新频率。",
"ifeng.settings.channel_label": "新闻频道",
"ifeng.settings.channel_comprehensive": "综合",
"ifeng.settings.channel_mainland": "中国大陆",
"ifeng.settings.channel_taiwan": "台湾",
"ifeng.settings.auto_refresh_label": "自动刷新",
"ifeng.settings.auto_refresh_enabled": "启用自动刷新",
"ifeng.settings.frequency_label": "刷新频率",
"refresh.frequency.5m": "5 分钟",
"refresh.frequency.10m": "10 分钟",
"refresh.frequency.12m": "12 分钟",
"refresh.frequency.15m": "15 分钟",
"refresh.frequency.20m": "20 分钟",
"refresh.frequency.30m": "30 分钟",
"refresh.frequency.40m": "40 分钟",
"refresh.frequency.1h": "1 小时",
"refresh.frequency.3h": "3 小时",
"refresh.frequency.6h": "6 小时",
"refresh.frequency.12h": "12 小时",
"refresh.frequency.24h": "24 小时",
"weather.widget.settings.title": "天气组件设置",
"weather.widget.settings.desc": "配置全部天气组件的自动刷新开关与刷新频率。",
"weather.widget.settings.auto_refresh_label": "自动刷新",
"weather.widget.settings.auto_refresh_enabled": "启用自动刷新",
"weather.widget.settings.frequency_label": "刷新频率",
"weather.widget.settings.frequency_10m": "10 分钟",
"weather.widget.settings.frequency_12m": "12 分钟",
"weather.widget.settings.frequency_15m": "15 分钟",
"weather.widget.settings.frequency_30m": "30 分钟",
"weather.widget.settings.frequency_1h": "1 小时",
"weather.widget.settings.frequency_3h": "3 小时",
"stcn24.widget.loading": "加载中...",
"stcn24.widget.loading_item": "加载中...",
"stcn24.widget.fetch_failed": "帖子获取失败",
"stcn24.widget.fallback_item": "暂无帖子",
"stcn24.settings.title": "STCN 24 设置",
"stcn24.settings.desc": "配置信息源、自动刷新开关与刷新频率。",
"stcn24.settings.source_label": "信息源",
"stcn24.settings.source_latest_created": "最新发布",
"stcn24.settings.source_latest_activity": "最新回复",
"stcn24.settings.source_most_replies": "回复最多",
"stcn24.settings.source_earliest_created": "最早发布",
"stcn24.settings.source_earliest_activity": "最早回复",
"stcn24.settings.source_least_replies": "回复最少",
"stcn24.settings.source_frontpage_latest": "前台推荐(新)",
"stcn24.settings.source_frontpage_earliest": "前台推荐(旧)",
"stcn24.settings.auto_refresh_label": "自动刷新",
"stcn24.settings.auto_refresh_enabled": "启用自动刷新",
"stcn24.settings.frequency_label": "刷新频率",
"stcn24.settings.frequency_5m": "5 分钟",
"stcn24.settings.frequency_10m": "10 分钟",
"stcn24.settings.frequency_20m": "20 分钟",
"stcn24.settings.frequency_30m": "30 分钟",
"stcn24.settings.frequency_1h": "1 小时",
"stcn24.settings.frequency_3h": "3 小时",
"exchange.widget.loading": "正在加载汇率...",
"exchange.widget.fetch_failed": "汇率获取失败",
"cnrnews.settings.title": "央广网设置",
"cnrnews.settings.desc": "配置新闻自动轮换与刷新频率。",
"cnrnews.settings.auto_rotate_label": "自动轮换",
"cnrnews.settings.auto_rotate_enabled": "启用自动轮换",
"cnrnews.settings.frequency_label": "轮换频率",
"cnrnews.settings.frequency_5m": "5 分钟",
"cnrnews.settings.frequency_10m": "10 分钟",
"cnrnews.settings.frequency_40m": "40 分钟",
"cnrnews.settings.frequency_1h": "1 小时",
"cnrnews.settings.frequency_12h": "12 小时",
"cnrnews.settings.frequency_24h": "24 小时",
"artwork.settings.title": "每日图片设置",
"artwork.settings.desc": "切换每日图片的数据源。",
"artwork.settings.source_label": "镜像源",
@@ -481,3 +735,5 @@
"placement.center": "居中",
"placement.tile": "平铺"
}

View File

@@ -20,6 +20,8 @@ public sealed class AppSettingsSnapshot
public int SettingsTabIndex { get; set; } = 0;
public string? SettingsTabTag { get; set; }
public string LanguageCode { get; set; } = "zh-CN";
public string? TimeZoneId { get; set; }
@@ -44,10 +46,10 @@ public sealed class AppSettingsSnapshot
public bool WeatherNoTlsRequests { get; set; }
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public bool AutoStartWithWindows { get; set; }
public string AppRenderMode { get; set; } = "Default";
public bool AutoCheckUpdates { get; set; } = true;
public bool IncludePrereleaseUpdates { get; set; }
@@ -72,31 +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<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string> WorldClockTimeZoneIds { get; set; } =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
public string WorldClockSecondHandMode { get; set; } = "Tick";
public List<string> DisabledPluginIds { get; set; } = [];
public AppSettingsSnapshot Clone()
{
@@ -108,53 +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;
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
if (ImportedClassSchedules is not null)
{
foreach (var schedule in ImportedClassSchedules)
{
if (schedule is null)
{
continue;
}
schedules.Add(new ImportedClassScheduleSnapshot
{
Id = schedule.Id,
DisplayName = schedule.DisplayName,
FilePath = schedule.FilePath
});
}
}
clone.ImportedClassSchedules = schedules;
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
clone.DisabledPluginIds = DisabledPluginIds is { Count: > 0 }
? new List<string>(DisabledPluginIds)
: [];
return clone;

View File

@@ -0,0 +1,19 @@
using System;
namespace LanMountainDesktop.Models;
public static class BaiduHotSearchSourceTypes
{
public const string Official = "Official";
public const string ThirdPartyRss = "ThirdPartyRss";
public static string Normalize(string? sourceType)
{
if (string.Equals(sourceType, ThirdPartyRss, StringComparison.OrdinalIgnoreCase))
{
return ThirdPartyRss;
}
return Official;
}
}

View File

@@ -0,0 +1,95 @@
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class ComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot> ImportedClassSchedules { get; set; } = [];
public string ActiveImportedClassScheduleId { get; set; } = string.Empty;
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string> WorldClockTimeZoneIds { get; set; } =
[
"China Standard Time",
"GMT Standard Time",
"AUS Eastern Standard Time",
"Eastern Standard Time"
];
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
public ComponentSettingsSnapshot Clone()
{
var clone = (ComponentSettingsSnapshot)MemberwiseClone();
var schedules = new List<ImportedClassScheduleSnapshot>(ImportedClassSchedules?.Count ?? 0);
if (ImportedClassSchedules is not null)
{
foreach (var schedule in ImportedClassSchedules)
{
if (schedule is null)
{
continue;
}
schedules.Add(new ImportedClassScheduleSnapshot
{
Id = schedule.Id,
DisplayName = schedule.DisplayName,
FilePath = schedule.FilePath
});
}
}
clone.ImportedClassSchedules = schedules;
clone.WorldClockTimeZoneIds = WorldClockTimeZoneIds is { Count: > 0 }
? new List<string>(WorldClockTimeZoneIds)
: [];
return clone;
}
}

View File

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

View File

@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public static class IfengNewsChannelTypes
{
public const string Comprehensive = "Comprehensive";
public const string Mainland = "Mainland";
public const string Taiwan = "Taiwan";
public static IReadOnlyList<string> SupportedValues { get; } =
[
Comprehensive,
Mainland,
Taiwan
];
public static string Normalize(string? value)
{
var candidate = value?.Trim() ?? string.Empty;
foreach (var supported in SupportedValues)
{
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
{
return supported;
}
}
return Comprehensive;
}
}

View File

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

View File

@@ -35,6 +35,35 @@ public sealed record DailyNewsSnapshot(
IReadOnlyList<DailyNewsItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record BilibiliHotSearchItemSnapshot(
string Title,
string Keyword,
string Url,
long? HeatScore,
bool HasHotTag,
string? IconUrl);
public sealed record BilibiliHotSearchSnapshot(
string Provider,
string Source,
string SearchPlaceholder,
string SearchUrl,
string MoreHotUrl,
IReadOnlyList<BilibiliHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record BaiduHotSearchItemSnapshot(
string Title,
string Url,
long? HeatScore);
public sealed record BaiduHotSearchSnapshot(
string Provider,
string Source,
string BoardUrl,
IReadOnlyList<BaiduHotSearchItemSnapshot> Items,
DateTimeOffset FetchedAt);
public sealed record DailyWordSnapshot(
string Provider,
string Word,
@@ -45,3 +74,24 @@ public sealed record DailyWordSnapshot(
string? ExampleTranslation,
string? SourceUrl,
DateTimeOffset FetchedAt);
public sealed record ExchangeRateSnapshot(
string Provider,
string Source,
string BaseCurrency,
string TargetCurrency,
decimal Rate,
DateTimeOffset FetchedAt);
public sealed record Stcn24ForumPostItemSnapshot(
string Title,
string Url,
string? AuthorDisplayName,
string? AuthorAvatarUrl,
DateTimeOffset? CreatedAt);
public sealed record Stcn24ForumPostsSnapshot(
string Provider,
string Source,
IReadOnlyList<Stcn24ForumPostItemSnapshot> Items,
DateTimeOffset FetchedAt);

View File

@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace LanMountainDesktop.Models;
public static class RefreshIntervalCatalog
{
public static IReadOnlyList<int> SupportedIntervalsMinutes { get; } =
[
5,
10,
12,
15,
20,
30,
40,
60,
180,
360,
720,
1440
];
public static int Normalize(int minutes, int fallbackMinutes)
{
if (minutes <= 0)
{
return fallbackMinutes;
}
if (SupportedIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(fallbackMinutes);
}
public static string ToLocalizationKeySuffix(int minutes)
{
return minutes switch
{
5 => "5m",
10 => "10m",
12 => "12m",
15 => "15m",
20 => "20m",
30 => "30m",
40 => "40m",
60 => "1h",
180 => "3h",
360 => "6h",
720 => "12h",
1440 => "24h",
_ => $"{minutes}m"
};
}
public static string ToEnglishFallbackLabel(int minutes)
{
return minutes switch
{
60 => "1 hour",
180 => "3 hours",
360 => "6 hours",
720 => "12 hours",
1440 => "24 hours",
_ => $"{minutes} min"
};
}
}

View File

@@ -1,4 +1,6 @@
namespace LanMountainDesktop.Models;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public sealed class StartMenuAppEntry
{
@@ -9,4 +11,10 @@ public sealed class StartMenuAppEntry
public required string RelativePath { get; init; }
public byte[]? IconPngBytes { get; init; }
public string? LaunchExecutable { get; init; }
public IReadOnlyList<string> LaunchArguments { get; init; } = [];
public string? WorkingDirectory { get; init; }
}

View File

@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
namespace LanMountainDesktop.Models;
public static class Stcn24ForumSourceTypes
{
public const string LatestCreated = "LatestCreated";
public const string LatestActivity = "LatestActivity";
public const string MostReplies = "MostReplies";
public const string EarliestCreated = "EarliestCreated";
public const string EarliestActivity = "EarliestActivity";
public const string LeastReplies = "LeastReplies";
public const string FrontpageLatest = "FrontpageLatest";
public const string FrontpageEarliest = "FrontpageEarliest";
public static IReadOnlyList<string> SupportedValues { get; } =
[
LatestCreated,
LatestActivity,
MostReplies,
EarliestCreated,
EarliestActivity,
LeastReplies,
FrontpageLatest,
FrontpageEarliest
];
public static string Normalize(string? value)
{
var candidate = value?.Trim() ?? string.Empty;
foreach (var supported in SupportedValues)
{
if (string.Equals(candidate, supported, StringComparison.OrdinalIgnoreCase))
{
return supported;
}
}
return LatestCreated;
}
}

View File

@@ -7,5 +7,6 @@ public enum TaskbarActionId
AddDesktopPage,
DeleteDesktopPage,
DeleteComponent,
EditComponent
EditComponent,
HideLauncherEntry
}

View File

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

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia;
using Avalonia.WebView.Desktop;
using LanMountainDesktop.Services;
using System;
namespace LanMountainDesktop;
@@ -10,14 +11,42 @@ sealed class Program
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
public static void Main(string[] args) => BuildAvaloniaApp(LoadConfiguredRenderMode())
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.UseDesktopWebView()
.WithInterFont()
.LogToTrace();
if (OperatingSystem.IsWindows())
{
var configuredModes = AppRenderingModeHelper.GetWin32RenderingModes(renderMode);
if (configuredModes is { Length: > 0 })
{
builder = builder.With(new Win32PlatformOptions
{
RenderingMode = configuredModes
});
}
}
return builder;
}
private static string LoadConfiguredRenderMode()
{
try
{
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
}
catch
{
return AppRenderingModeHelper.Default;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,171 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
namespace LanMountainDesktop.Services;
public static class AppRestartService
{
public static bool TryRestartApplication()
{
if (!TryRestartCurrentProcess())
{
return false;
}
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.Shutdown();
}
return true;
}
public static bool TryRestartCurrentProcess()
{
try
{
var startInfo = CreateRestartStartInfo();
if (startInfo is null)
{
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
return false;
}
Process.Start(startInfo);
return true;
}
catch (Exception ex)
{
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
return false;
}
}
public static ProcessStartInfo? CreateRestartStartInfo(
string[]? commandLineArgs = null,
string? processPath = null,
string? entryAssemblyLocation = null)
{
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
var resolvedEntryAssemblyPath = NormalizeExistingPath(
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
if (IsDotnetHost(resolvedProcessPath))
{
return CreateDotnetStartInfo(
resolvedProcessPath!,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
{
return CreateExecutableStartInfo(
resolvedProcessPath,
resolvedEntryAssemblyPath,
args);
}
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
{
return CreateDotnetStartInfo(
"dotnet",
resolvedEntryAssemblyPath,
args);
}
return null;
}
private static ProcessStartInfo CreateExecutableStartInfo(
string executablePath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
var startInfo = new ProcessStartInfo
{
FileName = executablePath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
};
AppendArguments(startInfo, commandLineArgs);
return startInfo;
}
private static ProcessStartInfo? CreateDotnetStartInfo(
string dotnetHostPath,
string? entryAssemblyPath,
IReadOnlyList<string> commandLineArgs)
{
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
{
return null;
}
var startInfo = new ProcessStartInfo
{
FileName = dotnetHostPath,
UseShellExecute = false,
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
};
startInfo.ArgumentList.Add(entryAssemblyPath);
AppendArguments(startInfo, commandLineArgs);
return startInfo;
}
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
{
for (var i = 1; i < commandLineArgs.Count; i++)
{
startInfo.ArgumentList.Add(commandLineArgs[i]);
}
}
private static string? NormalizeExistingPath(string? path)
{
if (string.IsNullOrWhiteSpace(path))
{
return null;
}
try
{
var fullPath = Path.GetFullPath(path);
return File.Exists(fullPath) ? fullPath : null;
}
catch
{
return null;
}
}
private static bool IsDotnetHost(string? processPath)
{
if (string.IsNullOrWhiteSpace(processPath))
{
return false;
}
var fileName = Path.GetFileName(processPath);
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
}
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
{
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
? entryAssemblyPath
: launchPath;
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
}
}

View File

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

View File

@@ -0,0 +1,123 @@
using System;
using System.Globalization;
namespace LanMountainDesktop.Services;
public sealed class CalculatorDataService : ICalculatorDataService
{
private const int MaxInputLength = 18;
public string ApplyInputToken(string currentInput, string token)
{
var normalized = NormalizeInput(currentInput);
if (string.IsNullOrWhiteSpace(token))
{
return normalized;
}
if (string.Equals(token, CalculatorInputTokens.Clear, StringComparison.OrdinalIgnoreCase))
{
return "0";
}
if (string.Equals(token, CalculatorInputTokens.Backspace, StringComparison.OrdinalIgnoreCase))
{
if (normalized.Length <= 1)
{
return "0";
}
var trimmed = normalized[..^1];
if (trimmed is "-" or "" or "-0")
{
return "0";
}
return trimmed;
}
if (string.Equals(token, CalculatorInputTokens.DecimalPoint, StringComparison.Ordinal))
{
if (normalized.Contains('.', StringComparison.Ordinal))
{
return normalized;
}
if (normalized.Length >= MaxInputLength)
{
return normalized;
}
return $"{normalized}.";
}
if (token is "00")
{
if (normalized == "0")
{
return "0";
}
if (normalized.Length + 2 > MaxInputLength)
{
return normalized;
}
return normalized + "00";
}
if (token.Length == 1 && char.IsDigit(token[0]))
{
if (normalized == "0")
{
return token;
}
if (normalized.Length >= MaxInputLength)
{
return normalized;
}
return normalized + token;
}
return normalized;
}
public decimal ParseAmountOrZero(string? inputText)
{
var normalized = NormalizeInput(inputText);
if (decimal.TryParse(
normalized,
NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint,
CultureInfo.InvariantCulture,
out var amount))
{
return amount;
}
return 0m;
}
public string FormatAmount(decimal amount, int maxFractionDigits = 4)
{
var safeDigits = Math.Clamp(maxFractionDigits, 0, 8);
var pattern = safeDigits == 0 ? "0" : $"0.{new string('#', safeDigits)}";
return amount.ToString(pattern, CultureInfo.InvariantCulture);
}
private static string NormalizeInput(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return "0";
}
var trimmed = input.Trim();
return trimmed switch
{
"-" or "-0" => "0",
_ => trimmed
};
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
@@ -39,7 +39,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
};
private static readonly IDeserializer CsesDeserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
@@ -50,7 +50,7 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
{
if (string.IsNullOrWhiteSpace(inputPath))
{
inputPath = ResolveImportedSchedulePathFromAppSettings();
inputPath = ResolveImportedSchedulePathFromComponentSettings();
}
var source = ResolveSource(inputPath, profileFileName, warnings);
@@ -180,11 +180,11 @@ public sealed class ClassIslandScheduleDataService : IClassIslandScheduleDataSer
return result;
}
private static string? ResolveImportedSchedulePathFromAppSettings()
private static string? ResolveImportedSchedulePathFromComponentSettings()
{
try
{
var snapshot = new AppSettingsService().Load();
var snapshot = new ComponentSettingsService().Load();
if (snapshot.ImportedClassSchedules.Count == 0)
{
return null;

View File

@@ -0,0 +1,751 @@
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 : IComponentInstanceSettingsStore
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
WriteIndented = true
};
private static readonly object CacheGate = new();
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
private static string? _cachedPath;
private static ComponentSettingsDocumentSnapshot? _cachedSnapshot;
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
private static DateTime _lastProbeUtc = DateTime.MinValue;
private readonly string _settingsPath;
private readonly string _legacyAppSettingsPath;
private string _scopedComponentId = string.Empty;
private string _scopedPlacementId = string.Empty;
public ComponentSettingsService()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
}
public ComponentSettingsSnapshot Load()
{
if (HasScopedComponentContext())
{
return LoadForComponent(_scopedComponentId, _scopedPlacementId);
}
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
return document.DefaultSettings.Clone();
}
}
catch
{
return new ComponentSettingsSnapshot();
}
}
public void Save(ComponentSettingsSnapshot snapshot)
{
if (HasScopedComponentContext())
{
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
return;
}
var snapshotToPersist = NormalizeSnapshot(snapshot);
try
{
lock (CacheGate)
{
var document = LoadDocumentLocked();
document.DefaultSettings = snapshotToPersist;
PersistDocumentLocked(document);
}
}
catch
{
// Swallow persistence errors to keep UI interactions uninterrupted.
}
}
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 &&
nowUtc - _lastProbeUtc < CacheProbeInterval)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
{
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
_cachedSnapshot is not null &&
writeTimeUtc == _cachedWriteTimeUtc)
{
snapshot = _cachedSnapshot.Clone();
return true;
}
snapshot = null!;
return false;
}
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
{
var nowUtc = DateTime.UtcNow;
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
{
return cached;
}
var hasFile = File.Exists(_settingsPath);
var writeTimeUtc = hasFile
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.MinValue;
_lastProbeUtc = nowUtc;
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
{
return cached;
}
ComponentSettingsDocumentSnapshot loadedSnapshot;
var loadedFromLegacy = false;
if (hasFile)
{
loadedSnapshot = LoadSnapshotFromDisk();
}
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
};
loadedFromLegacy = true;
}
else
{
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
}
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
if (loadedFromLegacy)
{
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
}
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
return normalizedSnapshot.Clone();
}
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
{
try
{
var json = File.ReadAllText(_settingsPath);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object &&
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
document.RootElement.TryGetProperty("pluginSettings", out _)))
{
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
return NormalizeDocument(snapshot);
}
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
return new ComponentSettingsDocumentSnapshot
{
DefaultSettings = NormalizeSnapshot(legacySnapshot)
};
}
catch
{
return new ComponentSettingsDocumentSnapshot();
}
}
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
{
snapshot = new ComponentSettingsSnapshot();
try
{
if (!File.Exists(_legacyAppSettingsPath))
{
return false;
}
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
if (legacy is null)
{
return false;
}
snapshot = new ComponentSettingsSnapshot
{
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
IfengNewsChannelType = legacy.IfengNewsChannelType,
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
};
return true;
}
catch
{
return false;
}
}
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
{
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
}
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
{
var directory = Path.GetDirectoryName(_settingsPath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
File.WriteAllText(_settingsPath, json);
return File.Exists(_settingsPath)
? File.GetLastWriteTimeUtc(_settingsPath)
: DateTime.UtcNow;
}
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
normalized.ActiveImportedClassScheduleId,
normalized.ImportedClassSchedules);
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
{
normalized.StudyEnvironmentShowDisplayDb = true;
}
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
.ToList();
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
return normalized;
}
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
{
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.InstanceSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
instanceSettings[key] = NormalizeSnapshot(pair.Value);
}
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in normalized.PluginSettings)
{
var key = NormalizeInstanceKey(pair.Key);
if (string.IsNullOrWhiteSpace(key))
{
continue;
}
pluginSettings[key] = pair.Value.Clone();
}
normalized.InstanceSettings = instanceSettings;
normalized.PluginSettings = pluginSettings;
return normalized;
}
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
{
if (schedules is null || schedules.Count == 0)
{
return [];
}
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var schedule in schedules)
{
if (schedule is null)
{
continue;
}
var id = schedule.Id?.Trim() ?? string.Empty;
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
{
continue;
}
if (!seenIds.Add(id))
{
continue;
}
result.Add(new ImportedClassScheduleSnapshot
{
Id = id,
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
FilePath = filePath
});
}
return result;
}
private static string NormalizeActiveScheduleId(
string? activeScheduleId,
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
{
var activeId = activeScheduleId?.Trim() ?? string.Empty;
if (schedules.Count == 0)
{
return string.Empty;
}
if (string.IsNullOrWhiteSpace(activeId))
{
return schedules[0].Id;
}
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
? activeId
: schedules[0].Id;
}
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
{
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
? "China Standard Time"
: timeZoneId.Trim();
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
}
private static int NormalizeCnrInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 60);
}
private static int NormalizeDailyWordInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 360);
}
private static int NormalizeIfengNewsInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static int NormalizeBilibiliHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeBaiduHotSearchInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private static int NormalizeWeatherInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 12);
}
private static int NormalizeStcn24ForumInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 20);
}
private static string BuildInstanceKey(string componentId, string? placementId)
{
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
{
return string.Empty;
}
return $"{normalizedComponentId}::{normalizedPlacementId}";
}
private static string NormalizeInstanceKey(string? key)
{
return key?.Trim() ?? string.Empty;
}
private bool HasScopedComponentContext()
{
return !string.IsNullOrWhiteSpace(_scopedComponentId) &&
!string.IsNullOrWhiteSpace(_scopedPlacementId);
}
private void UpdateCache(ComponentSettingsDocumentSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
{
_cachedPath = _settingsPath;
_cachedSnapshot = snapshot.Clone();
_cachedWriteTimeUtc = writeTimeUtc;
_lastProbeUtc = probeTimeUtc;
}
private sealed class ComponentSettingsDocumentSnapshot
{
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
public Dictionary<string, ComponentSettingsSnapshot> InstanceSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, JsonElement> PluginSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public ComponentSettingsDocumentSnapshot Clone()
{
var clone = new ComponentSettingsDocumentSnapshot
{
DefaultSettings = DefaultSettings?.Clone() ?? new ComponentSettingsSnapshot(),
InstanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase),
PluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase)
};
foreach (var pair in InstanceSettings)
{
clone.InstanceSettings[pair.Key] = pair.Value?.Clone() ?? new ComponentSettingsSnapshot();
}
foreach (var pair in PluginSettings)
{
clone.PluginSettings[pair.Key] = pair.Value.Clone();
}
return clone;
}
}
private sealed class LegacyComponentSettingsSnapshot
{
public string DailyArtworkMirrorSource { get; set; } = DailyArtworkMirrorSources.Overseas;
public List<ImportedClassScheduleSnapshot>? ImportedClassSchedules { get; set; }
public string? ActiveImportedClassScheduleId { get; set; }
public bool StudyEnvironmentShowDisplayDb { get; set; } = true;
public bool StudyEnvironmentShowDbfs { get; set; }
public string DesktopClockTimeZoneId { get; set; } = "China Standard Time";
public string DesktopClockSecondHandMode { get; set; } = "Tick";
public List<string>? WorldClockTimeZoneIds { get; set; }
public string WorldClockSecondHandMode { get; set; } = "Tick";
public bool CnrDailyNewsAutoRotateEnabled { get; set; } = true;
public int CnrDailyNewsAutoRotateIntervalMinutes { get; set; } = 60;
public bool IfengNewsAutoRefreshEnabled { get; set; } = true;
public int IfengNewsAutoRefreshIntervalMinutes { get; set; } = 20;
public string IfengNewsChannelType { get; set; } = IfengNewsChannelTypes.Comprehensive;
public bool DailyWordAutoRefreshEnabled { get; set; } = true;
public int DailyWordAutoRefreshIntervalMinutes { get; set; } = 360;
public bool BilibiliHotSearchAutoRefreshEnabled { get; set; } = true;
public int BilibiliHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public bool BaiduHotSearchAutoRefreshEnabled { get; set; } = true;
public int BaiduHotSearchAutoRefreshIntervalMinutes { get; set; } = 15;
public string BaiduHotSearchSourceType { get; set; } = BaiduHotSearchSourceTypes.Official;
public bool WeatherAutoRefreshEnabled { get; set; } = true;
public int WeatherAutoRefreshIntervalMinutes { get; set; } = 12;
public bool Stcn24ForumAutoRefreshEnabled { get; set; } = true;
public int Stcn24ForumAutoRefreshIntervalMinutes { get; set; } = 20;
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
namespace LanMountainDesktop.Services;
public interface ICalculatorDataService
{
string ApplyInputToken(string currentInput, string token);
decimal ParseAmountOrZero(string? inputText);
string FormatAmount(decimal amount, int maxFractionDigits = 4);
}
public static class CalculatorInputTokens
{
public const string Clear = "AC";
public const string Backspace = "BACK";
public const string DecimalPoint = ".";
}

View File

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

View File

@@ -17,12 +17,41 @@ public sealed record DailyPoetryQuery(
public sealed record DailyNewsQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record IfengNewsQuery(
string? Locale = null,
int? ItemCount = null,
string? ChannelType = null,
bool ForceRefresh = false);
public sealed record BilibiliHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
bool ForceRefresh = false);
public sealed record BaiduHotSearchQuery(
string? Locale = null,
int? ItemCount = null,
string? SourceType = null,
bool ForceRefresh = false);
public sealed record DailyWordQuery(
string? Locale = null,
bool ForceRefresh = false);
public sealed record Stcn24ForumPostsQuery(
string? Locale = null,
int? ItemCount = null,
string? SourceType = null,
bool ForceRefresh = false);
public sealed record ExchangeRateQuery(
string? BaseCurrency = null,
string? TargetCurrency = null,
bool ForceRefresh = false);
public sealed record RecommendationQueryResult<T>(
bool Success,
T? Data,
@@ -65,10 +94,55 @@ public sealed record RecommendationApiOptions
"https://news.cnr.cn/native/gd/rss.xml"
];
public IReadOnlyList<string> IfengNewsComprehensiveRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news",
"https://rsshub.shuaizheng.org/ifeng/news"
];
public IReadOnlyList<string> IfengNewsMainlandRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35197-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35197-/"
];
public IReadOnlyList<string> IfengNewsTaiwanRssFeedUrls { get; init; } =
[
"https://rss.injahow.cn/ifeng/news/shanklist/3-35199-/",
"https://rsshub.shuaizheng.org/ifeng/news/shanklist/3-35199-/"
];
public string IfengNewsComprehensiveListPageUrl { get; init; } = "https://news.ifeng.com/";
public string IfengNewsMainlandListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35197-/";
public string IfengNewsTaiwanListPageUrl { get; init; } = "https://news.ifeng.com/shanklist/3-35199-/";
public string BilibiliHotSearchApiTemplate { get; init; } =
"https://api.bilibili.com/x/web-interface/search/square?limit={0}";
public string BilibiliSearchDefaultApiUrl { get; init; } =
"https://api.bilibili.com/x/web-interface/search/default";
public string BilibiliSearchPageUrl { get; init; } = "https://search.bilibili.com/all";
public string BaiduHotSearchRssFeedUrl { get; init; } = "https://rss.aishort.top/?type=baidu";
public string BaiduHotSearchBoardUrl { get; init; } = "https://top.baidu.com/board?tab=realtime";
public string SmartTeachForumApiTemplate { get; init; } =
"https://forum.smart-teach.cn/api/discussions?filter[q]={0}&sort=-createdAt&page[limit]={1}&include=user";
public string SmartTeachForumBaseUrl { get; init; } = "https://forum.smart-teach.cn";
public string SmartTeachStcnKeyword { get; init; } = "STCN";
public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}";
public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/";
public string ExchangeRateApiTemplate { get; init; } = "https://open.er-api.com/v6/latest/{0}";
public IReadOnlyList<string> YoudaoDailyWordCandidates { get; init; } =
[
"illustrate",
@@ -203,6 +277,14 @@ public sealed record RecommendationApiOptions
public int DefaultArtworkCandidateCount { get; init; } = 50;
public int DefaultDailyNewsCount { get; init; } = 2;
public int DefaultIfengNewsCount { get; init; } = 4;
public int DefaultBilibiliHotSearchCount { get; init; } = 5;
public int DefaultBaiduHotSearchCount { get; init; } = 4;
public int DefaultStcn24ForumPostCount { get; init; } = 4;
}
public interface IRecommendationInfoService
@@ -219,9 +301,29 @@ public interface IRecommendationInfoService
DailyNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyNewsSnapshot>> GetIfengNewsAsync(
IfengNewsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BilibiliHotSearchSnapshot>> GetBilibiliHotSearchAsync(
BilibiliHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<BaiduHotSearchSnapshot>> GetBaiduHotSearchAsync(
BaiduHotSearchQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<DailyWordSnapshot>> GetDailyWordAsync(
DailyWordQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<Stcn24ForumPostsSnapshot>> GetStcn24ForumPostsAsync(
Stcn24ForumPostsQuery query,
CancellationToken cancellationToken = default);
Task<RecommendationQueryResult<ExchangeRateSnapshot>> GetExchangeRateAsync(
ExchangeRateQuery query,
CancellationToken cancellationToken = default);
void ClearCache();
}

View 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; }
}
}

View File

@@ -0,0 +1,192 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace LanMountainDesktop.Services;
internal static class LinuxDesktopEntryInstaller
{
private const string DesktopFileName = "LanMountainDesktop.desktop";
private const string IconFileName = "lanmountaindesktop.png";
private const string IconName = "lanmountaindesktop";
public static void EnsureInstalled()
{
if (!OperatingSystem.IsLinux())
{
return;
}
try
{
var executablePath = ResolveExecutablePath();
if (string.IsNullOrWhiteSpace(executablePath))
{
return;
}
var dataHome = ResolveDataHome();
if (string.IsNullOrWhiteSpace(dataHome))
{
return;
}
var applicationsDir = Path.Combine(dataHome, "applications");
var iconDir = Path.Combine(dataHome, "icons", "hicolor", "256x256", "apps");
Directory.CreateDirectory(applicationsDir);
Directory.CreateDirectory(iconDir);
var desktopTargetPath = Path.Combine(applicationsDir, DesktopFileName);
var iconTargetPath = Path.Combine(iconDir, IconFileName);
TryCopyBundledIcon(iconTargetPath);
var desktopEntryContent = BuildDesktopEntryContent(executablePath);
WriteFileIfChanged(desktopTargetPath, desktopEntryContent);
TryRunCommand("chmod", "+x", executablePath);
TryRunCommand("chmod", "+x", desktopTargetPath);
TryRunCommand("update-desktop-database", applicationsDir);
TryRunCommand("gtk-update-icon-cache", Path.Combine(dataHome, "icons", "hicolor"));
}
catch
{
// Keep startup resilient if desktop integration fails.
}
}
private static string ResolveExecutablePath()
{
var processPath = Environment.ProcessPath;
if (!string.IsNullOrWhiteSpace(processPath))
{
return processPath;
}
var commandLineArgs = Environment.GetCommandLineArgs();
if (commandLineArgs.Length > 0 && !string.IsNullOrWhiteSpace(commandLineArgs[0]))
{
return commandLineArgs[0];
}
return string.Empty;
}
private static string ResolveDataHome()
{
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (!string.IsNullOrWhiteSpace(dataHome))
{
return dataHome.Trim();
}
var homePath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homePath))
{
return string.Empty;
}
return Path.Combine(homePath, ".local", "share");
}
private static void TryCopyBundledIcon(string iconTargetPath)
{
foreach (var candidatePath in EnumerateIconSourceCandidates())
{
try
{
if (!File.Exists(candidatePath))
{
continue;
}
File.Copy(candidatePath, iconTargetPath, overwrite: true);
return;
}
catch
{
// Ignore failures and continue trying fallbacks.
}
}
}
private static string[] EnumerateIconSourceCandidates()
{
var baseDirectory = AppContext.BaseDirectory;
return
[
Path.Combine(baseDirectory, "share", "icons", "hicolor", "256x256", "apps", IconFileName),
Path.Combine(baseDirectory, IconFileName)
];
}
private static string BuildDesktopEntryContent(string executablePath)
{
var escapedExecutablePath = executablePath.Replace("\"", "\\\"", StringComparison.Ordinal);
return
"[Desktop Entry]\n" +
"Type=Application\n" +
"Version=1.0\n" +
"Name=LanMountainDesktop\n" +
"Comment=LanMountainDesktop desktop shell\n" +
$"Exec=\"{escapedExecutablePath}\" %U\n" +
$"Icon={IconName}\n" +
"Terminal=false\n" +
"Categories=Utility;Education;\n" +
"StartupWMClass=LanMountainDesktop\n";
}
private static void WriteFileIfChanged(string filePath, string content)
{
try
{
if (File.Exists(filePath))
{
var existing = File.ReadAllText(filePath);
if (string.Equals(existing, content, StringComparison.Ordinal))
{
return;
}
}
}
catch
{
// Fall through to attempt writing the content.
}
File.WriteAllText(filePath, content, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
private static void TryRunCommand(string fileName, params string[] arguments)
{
try
{
var startInfo = new ProcessStartInfo
{
FileName = fileName,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
using var process = Process.Start(startInfo);
if (process is null)
{
return;
}
_ = process.WaitForExit(2_500);
}
catch
{
// Ignore missing command or update failures.
}
}
}

View File

@@ -0,0 +1,371 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using LanMountainDesktop.Models;
namespace LanMountainDesktop.Services;
public sealed class LinuxDesktopEntryService
{
private static readonly Regex FieldCodeRegex =
new(@"%[fFuUdDnNickvm]", RegexOptions.Compiled);
public StartMenuFolderNode Load()
{
var root = new StartMenuFolderNode("All Apps", string.Empty);
if (!OperatingSystem.IsLinux())
{
return root;
}
var seenDesktopIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var applicationsRoot in EnumerateApplicationsRoots())
{
foreach (var desktopFilePath in EnumerateDesktopFilesSafe(applicationsRoot))
{
if (!TryParseDesktopEntry(desktopFilePath, applicationsRoot, out var appEntry))
{
continue;
}
if (seenDesktopIds.Add(appEntry.RelativePath))
{
root.Apps.Add(appEntry);
}
}
}
root.Apps.Sort((left, right) =>
string.Compare(left.DisplayName, right.DisplayName, CultureInfo.CurrentCulture, CompareOptions.IgnoreCase));
return root;
}
private static IEnumerable<string> EnumerateApplicationsRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "applications"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "applications"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "applications"));
}
candidates.Add("/var/lib/flatpak/exports/share/applications");
candidates.Add("/var/lib/snapd/desktop/applications");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateDesktopFilesSafe(string applicationsRoot)
{
try
{
return Directory.EnumerateFiles(applicationsRoot, "*.desktop", SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryParseDesktopEntry(string desktopFilePath, string applicationsRoot, out StartMenuAppEntry appEntry)
{
appEntry = null!;
Dictionary<string, string> fields;
try
{
fields = ReadDesktopEntryFields(desktopFilePath);
}
catch
{
return false;
}
if (!fields.TryGetValue("Type", out var entryType) ||
!string.Equals(entryType, "Application", StringComparison.OrdinalIgnoreCase) ||
GetBooleanField(fields, "NoDisplay") ||
GetBooleanField(fields, "Hidden"))
{
return false;
}
var displayName = GetPreferredName(fields);
if (string.IsNullOrWhiteSpace(displayName))
{
return false;
}
if (!fields.TryGetValue("Exec", out var execValue) ||
!TryParseExec(execValue, out var launchExecutable, out var launchArguments))
{
return false;
}
if (fields.TryGetValue("TryExec", out var tryExecValue) &&
!string.IsNullOrWhiteSpace(tryExecValue) &&
!CommandExists(tryExecValue))
{
return false;
}
var desktopFileId = BuildDesktopFileId(desktopFilePath, applicationsRoot);
var iconValue = fields.TryGetValue("Icon", out var iconFieldValue)
? iconFieldValue
: string.Empty;
var workingDirectory = Path.IsPathRooted(launchExecutable)
? Path.GetDirectoryName(launchExecutable)
: null;
appEntry = new StartMenuAppEntry
{
DisplayName = displayName.Trim(),
FilePath = desktopFilePath,
RelativePath = desktopFileId,
IconPngBytes = LinuxIconService.TryGetIconPngBytes(iconValue, Path.GetDirectoryName(desktopFilePath)),
LaunchExecutable = launchExecutable,
LaunchArguments = launchArguments,
WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory
};
return true;
}
private static Dictionary<string, string> ReadDesktopEntryFields(string desktopFilePath)
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var inDesktopEntrySection = false;
foreach (var rawLine in File.ReadLines(desktopFilePath))
{
var line = rawLine.Trim();
if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#'))
{
continue;
}
if (line.StartsWith('[') && line.EndsWith(']'))
{
inDesktopEntrySection = string.Equals(line, "[Desktop Entry]", StringComparison.OrdinalIgnoreCase);
continue;
}
if (!inDesktopEntrySection)
{
continue;
}
var separatorIndex = line.IndexOf('=');
if (separatorIndex <= 0 || separatorIndex >= line.Length - 1)
{
continue;
}
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
fields[key] = value;
}
return fields;
}
private static bool GetBooleanField(IReadOnlyDictionary<string, string> fields, string key)
{
return fields.TryGetValue(key, out var value) &&
bool.TryParse(value, out var result) &&
result;
}
private static string GetPreferredName(IReadOnlyDictionary<string, string> fields)
{
if (TryGetLocalizedField(fields, "Name", out var localizedName))
{
return localizedName;
}
return fields.TryGetValue("Name", out var fallbackName)
? fallbackName
: string.Empty;
}
private static bool TryGetLocalizedField(IReadOnlyDictionary<string, string> fields, string baseKey, out string value)
{
value = string.Empty;
var uiCulture = CultureInfo.CurrentUICulture;
var candidates = new[]
{
$"{baseKey}[{uiCulture.Name}]",
$"{baseKey}[{uiCulture.TwoLetterISOLanguageName}]"
};
foreach (var key in candidates)
{
if (fields.TryGetValue(key, out var localizedValue) &&
!string.IsNullOrWhiteSpace(localizedValue))
{
value = localizedValue;
return true;
}
}
return false;
}
private static string BuildDesktopFileId(string desktopFilePath, string applicationsRoot)
{
var relativePath = Path.GetRelativePath(applicationsRoot, desktopFilePath)
.Replace(Path.DirectorySeparatorChar, '-')
.Replace(Path.AltDirectorySeparatorChar, '-');
return relativePath.Trim();
}
private static bool TryParseExec(string execValue, out string launchExecutable, out List<string> launchArguments)
{
launchExecutable = string.Empty;
launchArguments = [];
var tokens = TokenizeExec(execValue);
if (tokens.Count == 0)
{
return false;
}
var cleanedTokens = new List<string>(tokens.Count);
foreach (var token in tokens)
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
var normalizedToken = token.Replace("%%", "%", StringComparison.Ordinal);
if (normalizedToken.Length == 2 && normalizedToken[0] == '%')
{
continue;
}
normalizedToken = FieldCodeRegex.Replace(normalizedToken, string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedToken))
{
continue;
}
cleanedTokens.Add(normalizedToken);
}
if (cleanedTokens.Count == 0)
{
return false;
}
launchExecutable = cleanedTokens[0];
launchArguments = cleanedTokens.Skip(1).ToList();
return true;
}
private static List<string> TokenizeExec(string execValue)
{
var tokens = new List<string>();
var current = new StringBuilder();
var inQuotes = false;
char quoteChar = '\0';
foreach (var c in execValue)
{
if ((c == '"' || c == '\'') &&
(!inQuotes || quoteChar == c))
{
if (inQuotes)
{
inQuotes = false;
quoteChar = '\0';
}
else
{
inQuotes = true;
quoteChar = c;
}
continue;
}
if (char.IsWhiteSpace(c) && !inQuotes)
{
if (current.Length > 0)
{
tokens.Add(current.ToString());
current.Clear();
}
continue;
}
current.Append(c);
}
if (current.Length > 0)
{
tokens.Add(current.ToString());
}
return tokens;
}
private static bool CommandExists(string command)
{
var trimmedCommand = command.Trim();
if (string.IsNullOrWhiteSpace(trimmedCommand))
{
return false;
}
if (Path.IsPathRooted(trimmedCommand))
{
return File.Exists(trimmedCommand);
}
var pathEntries = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var pathEntry in pathEntries)
{
try
{
var candidate = Path.Combine(pathEntry, trimmedCommand);
if (File.Exists(candidate))
{
return true;
}
}
catch
{
// Ignore malformed PATH entries.
}
}
return false;
}
}

View File

@@ -0,0 +1,214 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace LanMountainDesktop.Services;
internal static class LinuxIconService
{
private static readonly string[] SupportedRasterExtensions =
[
".png",
".ico"
];
private static readonly Regex SizeDirectoryRegex =
new(@"(?<size>\d{1,4})x\d{1,4}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly ConcurrentDictionary<string, string?> IconPathCache = new(StringComparer.OrdinalIgnoreCase);
public static byte[]? TryGetIconPngBytes(string? iconKey, string? desktopFileDirectory = null)
{
if (!OperatingSystem.IsLinux() || string.IsNullOrWhiteSpace(iconKey))
{
return null;
}
foreach (var candidatePath in ResolveIconCandidates(iconKey.Trim(), desktopFileDirectory))
{
if (TryReadIconBytes(candidatePath, out var bytes))
{
return bytes;
}
}
return null;
}
private static IEnumerable<string> ResolveIconCandidates(string iconKey, string? desktopFileDirectory)
{
if (Path.HasExtension(iconKey))
{
var directPath = ExpandHome(iconKey);
if (Path.IsPathRooted(directPath))
{
yield return directPath;
}
else if (!string.IsNullOrWhiteSpace(desktopFileDirectory))
{
yield return Path.GetFullPath(Path.Combine(desktopFileDirectory, directPath));
}
yield break;
}
var resolvedThemePath = ResolveThemedIconPath(iconKey);
if (!string.IsNullOrWhiteSpace(resolvedThemePath))
{
yield return resolvedThemePath;
}
}
private static string? ResolveThemedIconPath(string iconName)
{
return IconPathCache.GetOrAdd(iconName, static key => FindBestMatchingIconPath(key));
}
private static string? FindBestMatchingIconPath(string iconName)
{
var candidates = new List<(string Path, int Score)>();
foreach (var iconRoot in EnumerateIconRoots())
{
foreach (var extension in SupportedRasterExtensions)
{
foreach (var candidatePath in EnumerateFilesSafe(iconRoot, iconName + extension))
{
candidates.Add((candidatePath, ScoreIconPath(candidatePath)));
}
}
}
return candidates
.OrderByDescending(candidate => candidate.Score)
.ThenBy(candidate => candidate.Path.Length)
.Select(candidate => candidate.Path)
.FirstOrDefault();
}
private static IEnumerable<string> EnumerateIconRoots()
{
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
var dataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME");
if (string.IsNullOrWhiteSpace(dataHome) && !string.IsNullOrWhiteSpace(homeDirectory))
{
dataHome = Path.Combine(homeDirectory, ".local", "share");
}
var dataDirs = (Environment.GetEnvironmentVariable("XDG_DATA_DIRS") ?? "/usr/local/share:/usr/share")
.Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var candidates = new List<string>();
if (!string.IsNullOrWhiteSpace(dataHome))
{
candidates.Add(Path.Combine(dataHome, "icons"));
candidates.Add(Path.Combine(dataHome, "pixmaps"));
}
foreach (var dataDir in dataDirs)
{
candidates.Add(Path.Combine(dataDir, "icons"));
candidates.Add(Path.Combine(dataDir, "pixmaps"));
}
if (!string.IsNullOrWhiteSpace(homeDirectory))
{
candidates.Add(Path.Combine(homeDirectory, ".icons"));
candidates.Add(Path.Combine(homeDirectory, ".local", "share", "flatpak", "exports", "share", "icons"));
}
candidates.Add("/var/lib/flatpak/exports/share/icons");
candidates.Add("/var/lib/snapd/desktop/icons");
return candidates
.Where(path => !string.IsNullOrWhiteSpace(path) && Directory.Exists(path))
.Distinct(StringComparer.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateFilesSafe(string rootPath, string fileName)
{
try
{
return Directory.EnumerateFiles(rootPath, fileName, SearchOption.AllDirectories);
}
catch
{
return Array.Empty<string>();
}
}
private static bool TryReadIconBytes(string filePath, out byte[] bytes)
{
bytes = [];
try
{
var extension = Path.GetExtension(filePath);
if (!SupportedRasterExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) ||
!File.Exists(filePath))
{
return false;
}
bytes = File.ReadAllBytes(filePath);
return bytes.Length > 0;
}
catch
{
return false;
}
}
private static int ScoreIconPath(string filePath)
{
var score = 0;
var extension = Path.GetExtension(filePath);
if (extension.Equals(".png", StringComparison.OrdinalIgnoreCase))
{
score += 4_000;
}
else if (extension.Equals(".ico", StringComparison.OrdinalIgnoreCase))
{
score += 2_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}hicolor{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 8_000;
}
if (filePath.Contains($"{Path.DirectorySeparatorChar}apps{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
{
score += 1_000;
}
var match = SizeDirectoryRegex.Match(filePath);
if (match.Success &&
int.TryParse(match.Groups["size"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var size))
{
score += Math.Min(size, 512);
}
return score;
}
private static string ExpandHome(string path)
{
if (!path.StartsWith("~", StringComparison.Ordinal))
{
return path;
}
var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrWhiteSpace(homeDirectory))
{
return path;
}
return path.Length == 1
? homeDirectory
: Path.Combine(homeDirectory, path[2..]);
}
}

View File

@@ -46,6 +46,8 @@ public sealed class LocalizationService
if (File.Exists(filePath))
{
var json = File.ReadAllText(filePath);
// Defensive: tolerate accidentally duplicated UTF-8 BOM characters at file start.
json = json.TrimStart('\uFEFF');
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
if (data is not null)
{
@@ -62,4 +64,3 @@ public sealed class LocalizationService
return result;
}
}

View File

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

File diff suppressed because it is too large Load Diff

View 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;
}
}
}

View File

@@ -7,11 +7,12 @@ using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhCityNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -54,8 +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 _settingsService = new();
private readonly AppSettingsService _appSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private TimeZoneService? _timeZoneService;
private double _currentCellSize = 48;
@@ -111,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();
@@ -357,15 +376,16 @@ public partial class AnalogClockWidget : UserControl, IDesktopComponentWidget, I
private void LoadClockSettings()
{
var snapshot = _settingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var configuredTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
var configuredTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: snapshot.DesktopClockTimeZoneId.Trim();
: componentSnapshot.DesktopClockTimeZoneId.Trim();
_clockTimeZone = WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(configuredTimeZoneId);
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
}
private void ApplySecondHandTimerInterval()

View File

@@ -4,11 +4,12 @@ using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class AnalogClockWidgetSettingsWindow : UserControl
public partial class AnalogClockWidgetSettingsWindow : UserControl, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private static readonly IReadOnlyDictionary<string, string> ZhTimeZoneNames =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -28,10 +29,13 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
};
private readonly AppSettingsService _appSettingsService = 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;
@@ -46,14 +50,34 @@ 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 snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
_selectedTimeZoneId = string.IsNullOrWhiteSpace(snapshot.DesktopClockTimeZoneId)
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_selectedTimeZoneId = string.IsNullOrWhiteSpace(componentSnapshot.DesktopClockTimeZoneId)
? "China Standard Time"
: snapshot.DesktopClockTimeZoneId.Trim();
_secondHandMode = ClockSecondHandMode.Normalize(snapshot.DesktopClockSecondHandMode);
: componentSnapshot.DesktopClockTimeZoneId.Trim();
_secondHandMode = ClockSecondHandMode.Normalize(componentSnapshot.DesktopClockSecondHandMode);
_allTimeZones = _timeZoneService
.GetAllTimeZones()
@@ -147,10 +171,10 @@ public partial class AnalogClockWidgetSettingsWindow : UserControl
_selectedTimeZoneId = normalizedId;
_secondHandMode = GetSelectedSecondHandMode();
var snapshot = _appSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.DesktopClockTimeZoneId = normalizedId;
snapshot.DesktopClockSecondHandMode = _secondHandMode;
_appSettingsService.Save(snapshot);
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,110 @@
<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"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Baidu hot search settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure source, auto refresh and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="SourceLabelTextBlock"
Text="Data source"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="SourceComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnSourceSelectionChanged">
<ComboBoxItem x:Name="SourceOfficialItem"
Tag="Official"
Content="Official Source" />
<ComboBoxItem x:Name="SourceThirdPartyRssItem"
Tag="ThirdPartyRss"
Content="Third-party RSS" />
</ComboBox>
</StackPanel>
</Border>
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRefreshLabelTextBlock"
Text="Auto refresh"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRefreshCheckBox"
Content="Enable auto refresh"
Checked="OnAutoRefreshChanged"
Unchecked="OnAutoRefreshChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Refresh interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency15mItem"
Tag="15"
Content="15 min" />
<ComboBoxItem x:Name="Frequency30mItem"
Tag="30"
Content="30 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency3hItem"
Tag="180"
Content="3 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BaiduHotSearchSettingsWindow : UserControl
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
public event EventHandler? SettingsChanged;
public BaiduHotSearchSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var sourceType = BaiduHotSearchSourceTypes.Normalize(componentSnapshot.BaiduHotSearchSourceType);
var enabled = componentSnapshot.BaiduHotSearchAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
_suppressEvents = true;
SelectSourceType(sourceType);
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("baiduhot.settings.title", "Baidu hot search settings");
DescriptionTextBlock.Text = L("baiduhot.settings.desc", "Configure source, auto refresh and refresh interval.");
SourceLabelTextBlock.Text = L("baiduhot.settings.source_label", "Data source");
SourceOfficialItem.Content = L("baiduhot.settings.source_official", "Official Source");
SourceThirdPartyRssItem.Content = L("baiduhot.settings.source_rss", "Third-party RSS");
AutoRefreshLabelTextBlock.Text = L("baiduhot.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("baiduhot.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("baiduhot.settings.frequency_label", "Refresh interval");
ApplyFrequencyLocalization();
}
private void OnSourceSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var enabled = AutoRefreshCheckBox.IsChecked == true;
FrequencyCardBorder.IsVisible = enabled;
SaveState();
}
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = _componentSettingsService.Load();
snapshot.BaiduHotSearchSourceType = GetSelectedSourceType();
snapshot.BaiduHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.BaiduHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private string GetSelectedSourceType()
{
if (SourceComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string sourceTag)
{
return BaiduHotSearchSourceTypes.Normalize(sourceTag);
}
return BaiduHotSearchSourceTypes.Official;
}
private int GetSelectedInterval()
{
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes))
{
return NormalizeInterval(minutes);
}
return 15;
}
private void SelectSourceType(string sourceType)
{
var normalizedSourceType = BaiduHotSearchSourceTypes.Normalize(sourceType);
var selected = SourceComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string sourceTag &&
string.Equals(BaiduHotSearchSourceTypes.Normalize(sourceTag), normalizedSourceType, StringComparison.OrdinalIgnoreCase));
SourceComboBox.SelectedItem = selected ?? SourceComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private void SelectInterval(int intervalMinutes)
{
var selected = FrequencyComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private void InitializeFrequencyOptions()
{
FrequencyComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
FrequencyComboBox.Items.Add(new ComboBoxItem
{
Tag = minutes.ToString(),
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
});
}
}
private void ApplyFrequencyLocalization()
{
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
{
if (item.Tag is not string tagText ||
!int.TryParse(tagText, out var minutes))
{
continue;
}
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
}
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,189 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.BaiduHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="*,Auto"
ColumnSpacing="10">
<TextBlock x:Name="BrandTextBlock"
Text="百度热搜"
Foreground="#2932E1"
FontSize="24"
FontWeight="Bold"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
<Button x:Name="RefreshButton"
Grid.Column="1"
Width="34"
Height="34"
CornerRadius="17"
Background="#EFF1F5"
BorderBrush="Transparent"
BorderThickness="0"
Padding="0"
Focusable="False"
ToolTip.Tip="刷新"
Click="OnRefreshButtonClick">
<fi:SymbolIcon x:Name="RefreshGlyphIcon"
Symbol="ArrowClockwise"
IconVariant="Regular"
Foreground="#5E6671"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Button>
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#2932E1"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextAlignment="Right" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="热搜内容"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,623 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BaiduHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const int MaxDisplayItemCount = 4;
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(15)
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly List<BaiduHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _hotItemVisuals = [];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private string _sourceType = BaiduHotSearchSourceTypes.Official;
private bool _isNightVisual = true;
private sealed record HotItemVisual(
Border Host,
Grid RowGrid,
TextBlock IndexTextBlock,
TextBlock TitleTextBlock);
public BaiduHotSearchWidget()
{
InitializeComponent();
BrandTextBlock.FontFamily = MiSansFontFamily;
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem1TextBlock.FontFamily = MiSansFontFamily;
HotItem2TextBlock.FontFamily = MiSansFontFamily;
HotItem3TextBlock.FontFamily = MiSansFontFamily;
HotItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRefreshSettings();
ApplyLoadingState();
UpdateRefreshButtonState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
UpdateRefreshButtonState();
_ = RefreshHotSearchAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
UpdateRefreshButtonState();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
BrandTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
RefreshButton.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#EFF1F5"));
RefreshGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#5D93FF") : Color.Parse("#2932E1"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: true);
}
private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
{
_ = sender;
await RefreshHotSearchAsync(forceRefresh: true);
e.Handled = true;
}
private async Task RefreshHotSearchAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
UpdateRefreshButtonState();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new BaiduHotSearchQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
SourceType: _sourceType,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetBaiduHotSearchAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
UpdateRefreshButtonState();
}
}
private void ApplySnapshot(BaiduHotSearchSnapshot snapshot)
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
foreach (var item in snapshot.Items)
{
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
{
continue;
}
_activeItems.Add(item);
if (_activeItems.Count >= MaxDisplayItemCount)
{
break;
}
}
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var loadingText = L("baiduhot.widget.loading_item", "加载中...");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = loadingText;
}
StatusTextBlock.Text = L("baiduhot.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
BrandTextBlock.Text = L("baiduhot.widget.brand", "百度热搜");
ToolTip.SetTip(RefreshButton, L("baiduhot.widget.refresh_tooltip", "刷新"));
_activeItems.Clear();
var fallbackText = L("baiduhot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = fallbackText;
}
StatusTextBlock.Text = L("baiduhot.widget.fetch_failed", "热搜获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Border host ||
host.Tag is null ||
!int.TryParse(host.Tag.ToString(), out var index) ||
index < 0 ||
index >= _activeItems.Count)
{
return;
}
TryOpenUrl(_activeItems[index].Url);
e.Handled = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.84, 1.26);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
var minTopRowHeight = Math.Clamp(22 * softScale, 18, 34);
var topRowHeight = Math.Clamp(availableRowsHeight * 0.30, minTopRowHeight, 54);
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
if (lineRowHeight < minLineRowHeight)
{
lineRowHeight = minLineRowHeight;
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
}
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
}
}
BrandTextBlock.FontSize = Math.Clamp(topRowHeight * 0.48, 12, 24);
BrandTextBlock.MaxWidth = Math.Max(80, innerWidth - Math.Clamp(topRowHeight * 0.84, 20, 46));
var refreshButtonSize = Math.Clamp(topRowHeight * 0.84, 20, 46);
RefreshButton.Width = refreshButtonSize;
RefreshButton.Height = refreshButtonSize;
RefreshButton.CornerRadius = new CornerRadius(refreshButtonSize / 2d);
RefreshGlyphIcon.FontSize = Math.Clamp(refreshButtonSize * 0.46, 10, 20);
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
foreach (var visual in _hotItemVisuals)
{
visual.RowGrid.ColumnSpacing = lineColumnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 0)
{
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
}
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.IndexTextBlock.FontSize = indexFont;
visual.IndexTextBlock.MaxWidth = indexWidth;
visual.TitleTextBlock.FontSize = itemFont;
visual.TitleTextBlock.MaxWidth = itemTextWidth;
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
{
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
visual.Host.IsHitTestVisible = enabled;
visual.Host.Opacity = enabled ? 1.0 : 0.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
}
private void UpdateRefreshButtonState()
{
var enabled = _isAttached && !_isRefreshing;
RefreshButton.IsEnabled = enabled;
RefreshButton.Opacity = enabled ? 1.0 : 0.65;
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void ApplyAutoRefreshSettings()
{
var enabled = true;
var intervalMinutes = 15;
var sourceType = BaiduHotSearchSourceTypes.Official;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.BaiduHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BaiduHotSearchAutoRefreshIntervalMinutes);
sourceType = BaiduHotSearchSourceTypes.Normalize(snapshot.BaiduHotSearchSourceType);
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
_sourceType = sourceType;
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
if (!_isAttached)
{
return;
}
if (_autoRefreshEnabled)
{
if (!_refreshTimer.IsEnabled)
{
_refreshTimer.Start();
}
}
else if (_refreshTimer.IsEnabled)
{
_refreshTimer.Stop();
}
}
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
{
if (minutes <= 0)
{
return 15;
}
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedAutoRefreshIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(15);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void TryOpenUrl(string? rawUrl)
{
var normalizedUrl = NormalizeHttpUrl(rawUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

@@ -0,0 +1,87 @@
<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"
mc:Ignorable="d"
d:DesignWidth="420"
d:DesignHeight="300"
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchSettingsWindow">
<Border Background="{DynamicResource AdaptiveBackgroundBrush}"
Padding="16">
<Grid RowDefinitions="Auto,Auto,*"
RowSpacing="10">
<TextBlock x:Name="TitleTextBlock"
Text="Bilibili hot search settings"
FontSize="18"
FontWeight="SemiBold"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<TextBlock x:Name="DescriptionTextBlock"
Grid.Row="1"
Text="Configure auto refresh and refresh interval."
FontSize="12"
TextWrapping="Wrap"
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
<ScrollViewer Grid.Row="2"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="10"
Margin="0,0,6,0">
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12">
<StackPanel Spacing="6">
<TextBlock x:Name="AutoRefreshLabelTextBlock"
Text="Auto refresh"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<CheckBox x:Name="AutoRefreshCheckBox"
Content="Enable auto refresh"
Checked="OnAutoRefreshChanged"
Unchecked="OnAutoRefreshChanged" />
</StackPanel>
</Border>
<Border x:Name="FrequencyCardBorder"
Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
BorderThickness="1"
CornerRadius="12"
Padding="12"
IsVisible="False">
<StackPanel Spacing="6">
<TextBlock x:Name="FrequencyLabelTextBlock"
Text="Refresh interval"
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
<ComboBox x:Name="FrequencyComboBox"
HorizontalAlignment="Stretch"
MinWidth="0"
SelectionChanged="OnFrequencySelectionChanged">
<ComboBoxItem x:Name="Frequency5mItem"
Tag="5"
Content="5 min" />
<ComboBoxItem x:Name="Frequency10mItem"
Tag="10"
Content="10 min" />
<ComboBoxItem x:Name="Frequency15mItem"
Tag="15"
Content="15 min" />
<ComboBoxItem x:Name="Frequency30mItem"
Tag="30"
Content="30 min" />
<ComboBoxItem x:Name="Frequency1hItem"
Tag="60"
Content="1 hour" />
<ComboBoxItem x:Name="Frequency3hItem"
Tag="180"
Content="3 hours" />
</ComboBox>
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BilibiliHotSearchSettingsWindow : UserControl
{
private static readonly IReadOnlyList<int> SupportedIntervals = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private bool _suppressEvents;
private string _languageCode = "zh-CN";
public event EventHandler? SettingsChanged;
public BilibiliHotSearchSettingsWindow()
{
InitializeComponent();
InitializeFrequencyOptions();
LoadState();
ApplyLocalization();
}
private void LoadState()
{
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
var enabled = componentSnapshot.BilibiliHotSearchAutoRefreshEnabled;
var interval = NormalizeInterval(componentSnapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
_suppressEvents = true;
AutoRefreshCheckBox.IsChecked = enabled;
SelectInterval(interval);
FrequencyCardBorder.IsVisible = enabled;
_suppressEvents = false;
}
private void ApplyLocalization()
{
TitleTextBlock.Text = L("bilihot.settings.title", "Bilibili hot search settings");
DescriptionTextBlock.Text = L("bilihot.settings.desc", "Configure auto refresh and refresh interval.");
AutoRefreshLabelTextBlock.Text = L("bilihot.settings.auto_refresh_label", "Auto refresh");
AutoRefreshCheckBox.Content = L("bilihot.settings.auto_refresh_enabled", "Enable auto refresh");
FrequencyLabelTextBlock.Text = L("bilihot.settings.frequency_label", "Refresh interval");
ApplyFrequencyLocalization();
}
private void OnAutoRefreshChanged(object? sender, RoutedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
var enabled = AutoRefreshCheckBox.IsChecked == true;
FrequencyCardBorder.IsVisible = enabled;
SaveState();
}
private void OnFrequencySelectionChanged(object? sender, SelectionChangedEventArgs e)
{
_ = sender;
_ = e;
if (_suppressEvents)
{
return;
}
SaveState();
}
private void SaveState()
{
var snapshot = _componentSettingsService.Load();
snapshot.BilibiliHotSearchAutoRefreshEnabled = AutoRefreshCheckBox.IsChecked == true;
snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes = GetSelectedInterval();
_componentSettingsService.Save(snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}
private int GetSelectedInterval()
{
if (FrequencyComboBox.SelectedItem is ComboBoxItem item &&
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes))
{
return NormalizeInterval(minutes);
}
return 15;
}
private void SelectInterval(int intervalMinutes)
{
var selected = FrequencyComboBox.Items
.OfType<ComboBoxItem>()
.FirstOrDefault(item =>
item.Tag is string tagText &&
int.TryParse(tagText, out var minutes) &&
minutes == intervalMinutes);
FrequencyComboBox.SelectedItem = selected ?? FrequencyComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
}
private static int NormalizeInterval(int minutes)
{
return RefreshIntervalCatalog.Normalize(minutes, 15);
}
private void InitializeFrequencyOptions()
{
FrequencyComboBox.Items.Clear();
foreach (var minutes in SupportedIntervals)
{
FrequencyComboBox.Items.Add(new ComboBoxItem
{
Tag = minutes.ToString(),
Content = RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes)
});
}
}
private void ApplyFrequencyLocalization()
{
foreach (var item in FrequencyComboBox.Items.OfType<ComboBoxItem>())
{
if (item.Tag is not string tagText ||
!int.TryParse(tagText, out var minutes))
{
continue;
}
var key = $"refresh.frequency.{RefreshIntervalCatalog.ToLocalizationKeySuffix(minutes)}";
item.Content = L(key, RefreshIntervalCatalog.ToEnglishFallbackLabel(minutes));
}
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
}

View File

@@ -0,0 +1,196 @@
<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:fi="using:FluentIcons.Avalonia"
mc:Ignorable="d"
d:DesignWidth="640"
d:DesignHeight="320"
x:Class="LanMountainDesktop.Views.Components.BilibiliHotSearchWidget">
<Border x:Name="RootBorder"
CornerRadius="34"
Background="Transparent"
ClipToBounds="True"
BorderThickness="0"
Padding="0">
<Grid>
<Border x:Name="CardBorder"
Background="#FCFCFD"
CornerRadius="34"
BorderBrush="Transparent"
BorderThickness="0"
Padding="16,14,16,14">
<Grid x:Name="ContentGrid"
RowDefinitions="Auto,Auto,Auto,Auto,Auto"
RowSpacing="6">
<Grid x:Name="HeaderGrid"
Grid.Row="0"
ColumnDefinitions="Auto,*"
ColumnSpacing="10">
<Border x:Name="SearchBoxBorder"
Height="38"
CornerRadius="19"
Background="#F1F2F4"
BorderBrush="Transparent"
BorderThickness="0"
Padding="10,0"
HorizontalAlignment="Left"
PointerPressed="OnSearchBoxPointerPressed">
<Grid ColumnDefinitions="Auto,Auto"
ColumnSpacing="6"
VerticalAlignment="Center">
<fi:SymbolIcon x:Name="SearchGlyphIcon"
Symbol="Search"
IconVariant="Regular"
Foreground="#7A8088"
FontSize="17"
VerticalAlignment="Center" />
<TextBlock x:Name="SearchEntryTextBlock"
Grid.Column="1"
Text="Search"
Foreground="#7A8088"
FontSize="18"
FontWeight="Medium"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
</Border>
<TextBlock x:Name="TopRightTitleTextBlock"
Grid.Column="1"
Text="bilibili热搜"
Foreground="#F44C9F"
FontSize="24"
FontWeight="Bold"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
TextTrimming="CharacterEllipsis" />
</Grid>
<Border x:Name="HotItem1Host"
Grid.Row="1"
Tag="0"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem1Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem1IndexTextBlock"
Text="1"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem1TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem2Host"
Grid.Row="2"
Tag="1"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem2Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem2IndexTextBlock"
Text="2"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem2TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem3Host"
Grid.Row="3"
Tag="2"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem3Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem3IndexTextBlock"
Text="3"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem3TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
<Border x:Name="HotItem4Host"
Grid.Row="4"
Tag="3"
Background="Transparent"
Padding="0,2"
PointerPressed="OnHotItemPointerPressed">
<Grid x:Name="HotItem4Grid"
ColumnDefinitions="Auto,*"
ColumnSpacing="8">
<TextBlock x:Name="HotItem4IndexTextBlock"
Text="4"
Foreground="#F44C9F"
FontSize="18"
FontWeight="Bold"
VerticalAlignment="Center"
HorizontalAlignment="Left" />
<TextBlock x:Name="HotItem4TextBlock"
Grid.Column="1"
Text="Trending Topic"
Foreground="#202327"
FontSize="28"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
MaxLines="1"
VerticalAlignment="Center" />
</Grid>
</Border>
</Grid>
</Border>
<TextBlock x:Name="StatusTextBlock"
IsVisible="False"
Text="Loading"
Foreground="#6A6F77"
FontSize="16"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Grid>
</Border>
</UserControl>

View File

@@ -0,0 +1,648 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class BilibiliHotSearchWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget
{
private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled);
private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans");
private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService();
private const double BaseCellSize = 48d;
private const int BaseWidthCells = 4;
private const int BaseHeightCells = 2;
private const int MaxDisplayItemCount = 4;
private static readonly IReadOnlyList<int> SupportedAutoRefreshIntervalsMinutes = RefreshIntervalCatalog.SupportedIntervalsMinutes;
private readonly DispatcherTimer _refreshTimer = new()
{
Interval = TimeSpan.FromMinutes(15)
};
private readonly AppSettingsService _appSettingsService = new();
private readonly ComponentSettingsService _componentSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly List<BilibiliHotSearchItemSnapshot> _activeItems = [];
private readonly List<HotItemVisual> _hotItemVisuals = [];
private IRecommendationInfoService _recommendationService = DefaultRecommendationService;
private CancellationTokenSource? _refreshCts;
private string _languageCode = "zh-CN";
private string? _searchPageUrl;
private double _currentCellSize = BaseCellSize;
private bool _isAttached;
private bool _isRefreshing;
private bool _autoRefreshEnabled = true;
private bool _isNightVisual = true;
private sealed record HotItemVisual(
Border Host,
Grid RowGrid,
TextBlock IndexTextBlock,
TextBlock TitleTextBlock);
public BilibiliHotSearchWidget()
{
InitializeComponent();
SearchEntryTextBlock.FontFamily = MiSansFontFamily;
TopRightTitleTextBlock.FontFamily = MiSansFontFamily;
HotItem1IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem2IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem3IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem4IndexTextBlock.FontFamily = MiSansFontFamily;
HotItem1TextBlock.FontFamily = MiSansFontFamily;
HotItem2TextBlock.FontFamily = MiSansFontFamily;
HotItem3TextBlock.FontFamily = MiSansFontFamily;
HotItem4TextBlock.FontFamily = MiSansFontFamily;
StatusTextBlock.FontFamily = MiSansFontFamily;
_hotItemVisuals.Add(new HotItemVisual(HotItem1Host, HotItem1Grid, HotItem1IndexTextBlock, HotItem1TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem2Host, HotItem2Grid, HotItem2IndexTextBlock, HotItem2TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem3Host, HotItem3Grid, HotItem3IndexTextBlock, HotItem3TextBlock));
_hotItemVisuals.Add(new HotItemVisual(HotItem4Host, HotItem4Grid, HotItem4IndexTextBlock, HotItem4TextBlock));
_refreshTimer.Tick += OnRefreshTimerTick;
AttachedToVisualTree += OnAttachedToVisualTree;
DetachedFromVisualTree += OnDetachedFromVisualTree;
SizeChanged += OnSizeChanged;
ActualThemeVariantChanged += OnActualThemeVariantChanged;
ApplyCellSize(_currentCellSize);
UpdateLanguageCode();
ApplyAutoRefreshSettings();
ApplyLoadingState();
}
public void ApplyCellSize(double cellSize)
{
_currentCellSize = Math.Max(1, cellSize);
UpdateAdaptiveLayout();
}
public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService)
{
_recommendationService = recommendationInfoService ?? DefaultRecommendationService;
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: false);
}
}
public void RefreshFromSettings()
{
_recommendationService.ClearCache();
ApplyAutoRefreshSettings();
if (_isAttached)
{
_ = RefreshHotSearchAsync(forceRefresh: true);
}
}
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = true;
ApplyAutoRefreshSettings();
_ = RefreshHotSearchAsync(forceRefresh: false);
}
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
{
_isAttached = false;
_refreshTimer.Stop();
CancelRefreshRequest();
}
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
{
ApplyCellSize(_currentCellSize);
}
private void OnActualThemeVariantChanged(object? sender, EventArgs e)
{
_isNightVisual = ResolveNightMode();
UpdateAdaptiveLayout();
}
private bool ResolveNightMode()
{
if (ActualThemeVariant == ThemeVariant.Dark)
{
return true;
}
if (ActualThemeVariant == ThemeVariant.Light)
{
return false;
}
if (this.TryFindResource("AdaptiveSurfaceBaseBrush", out var value) &&
value is ISolidColorBrush brush)
{
return CalculateRelativeLuminance(brush.Color) < 0.45;
}
return true;
}
private static double CalculateRelativeLuminance(Color color)
{
static double ToLinear(double channel)
{
return channel <= 0.03928
? channel / 12.92
: Math.Pow((channel + 0.055) / 1.055, 2.4);
}
var r = ToLinear(color.R / 255d);
var g = ToLinear(color.G / 255d);
var b = ToLinear(color.B / 255d);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
private void ApplyNightModeVisual()
{
CardBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#1B2129") : Color.Parse("#FCFCFD"));
RootBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#33FFFFFF") : Color.Parse("#00000000"));
SearchBoxBorder.Background = new SolidColorBrush(_isNightVisual ? Color.Parse("#2D3440") : Color.Parse("#ECF2FA"));
SearchBoxBorder.BorderBrush = new SolidColorBrush(_isNightVisual ? Color.Parse("#3FFFFFFF") : Color.Parse("#22000000"));
SearchEntryTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
SearchGlyphIcon.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#A8B1C2") : Color.Parse("#5E6671"));
TopRightTitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
foreach (var visual in _hotItemVisuals)
{
visual.IndexTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#F472C4") : Color.Parse("#F44C9F"));
visual.TitleTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#E8EAED") : Color.Parse("#202327"));
}
StatusTextBlock.Foreground = new SolidColorBrush(_isNightVisual ? Color.Parse("#8B95A5") : Color.Parse("#6A6F77"));
}
private async void OnRefreshTimerTick(object? sender, EventArgs e)
{
await RefreshHotSearchAsync(forceRefresh: false);
}
private async Task RefreshHotSearchAsync(bool forceRefresh)
{
if (!_isAttached || _isRefreshing)
{
return;
}
_isRefreshing = true;
UpdateLanguageCode();
var cts = new CancellationTokenSource();
var previous = Interlocked.Exchange(ref _refreshCts, cts);
previous?.Cancel();
previous?.Dispose();
try
{
var query = new BilibiliHotSearchQuery(
Locale: _languageCode,
ItemCount: MaxDisplayItemCount,
ForceRefresh: forceRefresh);
var result = await _recommendationService.GetBilibiliHotSearchAsync(query, cts.Token);
if (!_isAttached || cts.IsCancellationRequested)
{
return;
}
if (!result.Success || result.Data is null)
{
ApplyFailedState();
return;
}
ApplySnapshot(result.Data);
}
catch (OperationCanceledException)
{
// Ignore canceled requests.
}
catch
{
if (_isAttached && !cts.IsCancellationRequested)
{
ApplyFailedState();
}
}
finally
{
if (ReferenceEquals(_refreshCts, cts))
{
_refreshCts = null;
}
cts.Dispose();
_isRefreshing = false;
}
}
private void ApplySnapshot(BilibiliHotSearchSnapshot snapshot)
{
SearchEntryTextBlock.Text = ResolveSearchEntryText(snapshot.SearchPlaceholder);
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = NormalizeHttpUrl(snapshot.SearchUrl) ?? BuildDefaultSearchPageUrl();
_activeItems.Clear();
foreach (var item in snapshot.Items)
{
if (string.IsNullOrWhiteSpace(item.Title) || string.IsNullOrWhiteSpace(item.Url))
{
continue;
}
_activeItems.Add(item);
if (_activeItems.Count >= MaxDisplayItemCount)
{
break;
}
}
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = i < _activeItems.Count
? NormalizeCompactText(_activeItems[i].Title)
: fallbackText;
}
StatusTextBlock.IsVisible = false;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyLoadingState()
{
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = BuildDefaultSearchPageUrl();
_activeItems.Clear();
var loadingText = L("bilihot.widget.loading_item", "加载中...");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = loadingText;
}
StatusTextBlock.Text = L("bilihot.widget.loading", "加载中...");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private void ApplyFailedState()
{
SearchEntryTextBlock.Text = L("bilihot.widget.search_entry", "搜索");
TopRightTitleTextBlock.Text = L("bilihot.widget.top_right_label", "bilibili热搜");
_searchPageUrl = BuildDefaultSearchPageUrl();
_activeItems.Clear();
var fallbackText = L("bilihot.widget.fallback_item", "暂无热搜");
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
visual.Host.IsVisible = true;
visual.IndexTextBlock.Text = (i + 1).ToString();
visual.TitleTextBlock.Text = fallbackText;
}
StatusTextBlock.Text = L("bilihot.widget.fetch_failed", "热搜获取失败");
StatusTextBlock.IsVisible = true;
UpdateInteractionState();
UpdateAdaptiveLayout();
}
private string ResolveSearchEntryText(string? placeholder)
{
var compact = NormalizeCompactText(placeholder);
if (string.IsNullOrWhiteSpace(compact))
{
return L("bilihot.widget.search_entry", "搜索");
}
return compact;
}
private void OnSearchBoxPointerPressed(object? sender, PointerPressedEventArgs e)
{
_ = sender;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
{
return;
}
TryOpenUrl(_searchPageUrl);
e.Handled = true;
}
private void OnHotItemPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed ||
sender is not Border host ||
host.Tag is null ||
!int.TryParse(host.Tag.ToString(), out var index) ||
index < 0 ||
index >= _activeItems.Count)
{
return;
}
TryOpenUrl(_activeItems[index].Url);
e.Handled = true;
}
private void UpdateAdaptiveLayout()
{
var scale = ResolveScale();
var softScale = Math.Clamp(scale, 0.84, 1.26);
var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells;
var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells;
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
RootBorder.Padding = new Thickness(0);
var horizontalPadding = Math.Clamp(16 * softScale, 8, 24);
var verticalPadding = Math.Clamp(14 * softScale, 7, 20);
CardBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * softScale, 16, 52));
CardBorder.Padding = new Thickness(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding);
var innerWidth = Math.Max(120, totalWidth - (horizontalPadding * 2d));
var innerHeight = Math.Max(72, totalHeight - (verticalPadding * 2d));
var rowSpacing = Math.Clamp(6 * softScale, 2, 9);
ContentGrid.RowSpacing = rowSpacing;
HeaderGrid.ColumnSpacing = Math.Clamp(10 * softScale, 6, 16);
var availableRowsHeight = Math.Max(40, innerHeight - rowSpacing * 4d);
var minTopRowHeight = Math.Clamp(20 * softScale, 18, 34);
var topRowHeight = Math.Clamp(availableRowsHeight * 0.27, minTopRowHeight, 52);
var lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
var minLineRowHeight = Math.Clamp(13 * softScale, 11, 24);
if (lineRowHeight < minLineRowHeight)
{
lineRowHeight = minLineRowHeight;
topRowHeight = Math.Max(minTopRowHeight, availableRowsHeight - lineRowHeight * 4d);
lineRowHeight = Math.Max(10, (availableRowsHeight - topRowHeight) / 4d);
}
if (ContentGrid.RowDefinitions.Count >= 5)
{
ContentGrid.RowDefinitions[0].Height = new GridLength(topRowHeight);
for (var i = 1; i <= 4; i++)
{
ContentGrid.RowDefinitions[i].Height = new GridLength(lineRowHeight);
}
}
var searchBoxHeight = Math.Clamp(topRowHeight * 0.84, 20, 46);
SearchBoxBorder.Height = searchBoxHeight;
SearchBoxBorder.Width = Math.Clamp(innerWidth * 0.30, 80, 180);
SearchBoxBorder.CornerRadius = new CornerRadius(searchBoxHeight / 2d);
SearchBoxBorder.Padding = new Thickness(
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
0,
Math.Clamp(searchBoxHeight * 0.24, 5, 10),
0);
SearchGlyphIcon.FontSize = Math.Clamp(searchBoxHeight * 0.45, 10, 20);
SearchEntryTextBlock.FontSize = Math.Clamp(searchBoxHeight * 0.44, 10, 18);
TopRightTitleTextBlock.MaxWidth = Math.Max(80, innerWidth - SearchBoxBorder.Width - HeaderGrid.ColumnSpacing);
TopRightTitleTextBlock.FontSize = Math.Clamp(topRowHeight * 0.46, 11, 22);
var lineColumnGap = Math.Clamp(lineRowHeight * 0.34, 5, 12);
var indexWidth = Math.Clamp(lineRowHeight * 1.02, 16, 28);
var indexFont = Math.Clamp(lineRowHeight * 0.50, 10, 16);
var itemFont = Math.Clamp(lineRowHeight * 0.62, 12, 24);
var rowPadding = Math.Clamp(lineRowHeight * 0.08, 1, 4);
var itemTextWidth = Math.Max(56, innerWidth - indexWidth - lineColumnGap);
foreach (var visual in _hotItemVisuals)
{
visual.RowGrid.ColumnSpacing = lineColumnGap;
if (visual.RowGrid.ColumnDefinitions.Count > 0)
{
visual.RowGrid.ColumnDefinitions[0].Width = new GridLength(indexWidth, GridUnitType.Pixel);
}
visual.Host.Padding = new Thickness(0, rowPadding, 0, rowPadding);
visual.IndexTextBlock.FontSize = indexFont;
visual.IndexTextBlock.MaxWidth = indexWidth;
visual.IndexTextBlock.HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right;
visual.IndexTextBlock.TextAlignment = TextAlignment.Right;
visual.TitleTextBlock.FontSize = itemFont;
visual.TitleTextBlock.MaxWidth = itemTextWidth;
visual.TitleTextBlock.TextAlignment = TextAlignment.Left;
}
StatusTextBlock.FontSize = Math.Clamp(itemFont, 10, 20);
ApplyNightModeVisual();
}
private void UpdateInteractionState()
{
for (var i = 0; i < _hotItemVisuals.Count; i++)
{
var visual = _hotItemVisuals[i];
var enabled = i < _activeItems.Count && !string.IsNullOrWhiteSpace(_activeItems[i].Url);
visual.Host.IsHitTestVisible = enabled;
visual.Host.Opacity = enabled ? 1.0 : 0.68;
visual.Host.Cursor = enabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
var searchEnabled = !string.IsNullOrWhiteSpace(_searchPageUrl);
SearchBoxBorder.IsHitTestVisible = searchEnabled;
SearchBoxBorder.Opacity = searchEnabled ? 1.0 : 0.72;
SearchBoxBorder.Cursor = searchEnabled
? new Cursor(StandardCursorType.Hand)
: new Cursor(StandardCursorType.Arrow);
}
private void UpdateLanguageCode()
{
try
{
var snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
}
catch
{
_languageCode = "zh-CN";
}
}
private void ApplyAutoRefreshSettings()
{
var enabled = true;
var intervalMinutes = 15;
try
{
var snapshot = _componentSettingsService.Load();
enabled = snapshot.BilibiliHotSearchAutoRefreshEnabled;
intervalMinutes = NormalizeAutoRefreshIntervalMinutes(snapshot.BilibiliHotSearchAutoRefreshIntervalMinutes);
}
catch
{
// Keep fallback defaults.
}
_autoRefreshEnabled = enabled;
_refreshTimer.Interval = TimeSpan.FromMinutes(intervalMinutes);
if (!_isAttached)
{
return;
}
if (_autoRefreshEnabled)
{
if (!_refreshTimer.IsEnabled)
{
_refreshTimer.Start();
}
}
else if (_refreshTimer.IsEnabled)
{
_refreshTimer.Stop();
}
}
private static int NormalizeAutoRefreshIntervalMinutes(int minutes)
{
if (minutes <= 0)
{
return 15;
}
if (SupportedAutoRefreshIntervalsMinutes.Contains(minutes))
{
return minutes;
}
return SupportedAutoRefreshIntervalsMinutes
.OrderBy(value => Math.Abs(value - minutes))
.FirstOrDefault(15);
}
private static string NormalizeCompactText(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return string.Empty;
}
return MultiWhitespaceRegex.Replace(text.Trim(), " ");
}
private static string BuildDefaultSearchPageUrl()
{
return "https://search.bilibili.com/all";
}
private static string? NormalizeHttpUrl(string? rawUrl)
{
if (string.IsNullOrWhiteSpace(rawUrl))
{
return null;
}
var candidate = rawUrl.Trim();
if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri))
{
return null;
}
if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
return null;
}
return uri.ToString();
}
private void TryOpenUrl(string? rawUrl)
{
var normalizedUrl = NormalizeHttpUrl(rawUrl);
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return;
}
try
{
var startInfo = new ProcessStartInfo
{
FileName = normalizedUrl,
UseShellExecute = true
};
Process.Start(startInfo);
}
catch
{
// Ignore malformed URLs or shell launch failures.
}
}
private double ResolveScale()
{
var expectedWidth = _currentCellSize * BaseWidthCells;
var expectedHeight = _currentCellSize * BaseHeightCells;
if (expectedWidth <= 0 || expectedHeight <= 0)
{
return 1d;
}
var actualWidth = Bounds.Width > 1 ? Bounds.Width : expectedWidth;
var actualHeight = Bounds.Height > 1 ? Bounds.Height : expectedHeight;
var scaleX = actualWidth / expectedWidth;
var scaleY = actualHeight / expectedHeight;
return Math.Clamp(Math.Min(scaleX, scaleY), 0.72, 2.8);
}
private string L(string key, string fallback)
{
return _localizationService.GetString(_languageCode, key, fallback);
}
private void CancelRefreshRequest()
{
var cts = Interlocked.Exchange(ref _refreshCts, null);
if (cts is null)
{
return;
}
cts.Cancel();
cts.Dispose();
}
}

View File

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

View File

@@ -10,18 +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 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;
@@ -33,13 +37,33 @@ 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 snapshot = _appSettingsService.Load();
_languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
var appSnapshot = _appSettingsService.Load();
var componentSnapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSnapshot.LanguageCode);
_importedSchedules.Clear();
foreach (var item in snapshot.ImportedClassSchedules)
foreach (var item in componentSnapshot.ImportedClassSchedules)
{
if (string.IsNullOrWhiteSpace(item.Id) ||
string.IsNullOrWhiteSpace(item.FilePath))
@@ -55,7 +79,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
});
}
_activeScheduleId = snapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
_activeScheduleId = componentSnapshot.ActiveImportedClassScheduleId?.Trim() ?? string.Empty;
if (_importedSchedules.Count > 0 &&
!_importedSchedules.Any(item => string.Equals(item.Id, _activeScheduleId, StringComparison.OrdinalIgnoreCase)))
{
@@ -297,7 +321,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
private void SaveState()
{
var snapshot = _appSettingsService.Load();
var snapshot = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
snapshot.ImportedClassSchedules = _importedSchedules
.Select(item => new ImportedClassScheduleSnapshot
{
@@ -307,7 +331,7 @@ public partial class ClassScheduleSettingsWindow : UserControl
})
.ToList();
snapshot.ActiveImportedClassScheduleId = _activeScheduleId ?? string.Empty;
_appSettingsService.Save(snapshot);
_componentSettingsStore.SaveForComponent(_componentId, _placementId, snapshot);
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -7,12 +7,13 @@ using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Models;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.Components;
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget
public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget, ITimeZoneAwareComponentWidget, IComponentPlacementContextAware, IComponentSettingsStoreAware
{
private sealed record CourseItemViewModel(
string Name,
@@ -26,6 +27,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
};
private readonly AppSettingsService _appSettingsService = new();
private IComponentInstanceSettingsStore _componentSettingsStore = new ComponentSettingsService();
private readonly LocalizationService _localizationService = new();
private readonly IClassIslandScheduleDataService _scheduleService = new ClassIslandScheduleDataService();
@@ -34,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()
{
@@ -112,14 +116,30 @@ 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 = _componentSettingsStore.LoadForComponent(_componentId, _placementId);
_languageCode = _localizationService.NormalizeLanguageCode(appSettings.LanguageCode);
var now = _timeZoneService?.GetCurrentTime() ?? DateTime.Now;
UpdateHeader(now);
var importedSchedulePath = ResolveImportedSchedulePath(appSettings);
var importedSchedulePath = ResolveImportedSchedulePath(componentSettings);
var readResult = _scheduleService.Load(importedSchedulePath);
if (!readResult.Success || readResult.Snapshot is null)
{
@@ -273,7 +293,7 @@ public partial class ClassScheduleWidget : UserControl, IDesktopComponentWidget,
return dayOfWeek.ToString()[..3];
}
private static string? ResolveImportedSchedulePath(AppSettingsSnapshot snapshot)
private static string? ResolveImportedSchedulePath(ComponentSettingsSnapshot snapshot)
{
if (snapshot.ImportedClassSchedules.Count == 0)
{

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