mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efdfa68dab | ||
|
|
87110f1d69 | ||
|
|
e7a03404ce | ||
|
|
2781d7e0d9 | ||
|
|
5003ff1be2 | ||
|
|
e1be072b97 | ||
|
|
4df740e3df | ||
|
|
85f7a18cbc | ||
|
|
cdffaa16eb | ||
|
|
d33d8d3391 | ||
|
|
9c89c08448 | ||
|
|
ec7b78bc63 | ||
|
|
e97db00999 | ||
|
|
8bb6b01236 | ||
|
|
103b215e35 | ||
|
|
cab35f4c22 | ||
|
|
c9f92a4755 | ||
|
|
854deae801 | ||
|
|
d72cd42483 | ||
|
|
3aee31c6c0 | ||
|
|
435b96c50c | ||
|
|
49b18d6af1 | ||
|
|
d6ec159af4 | ||
|
|
0d14675cc0 | ||
|
|
1f509959a9 | ||
|
|
382d1baaf1 | ||
|
|
72a0be16b3 | ||
|
|
de40471af6 | ||
|
|
5d35e0d21c | ||
|
|
e917a1e4af |
17
.github/FIX_REPORT.md
vendored
17
.github/FIX_REPORT.md
vendored
@@ -8,14 +8,14 @@ MSBUILD : error MSB1003: Specify a project or solution file.
|
||||
The current working directory does not contain a project or solution file.
|
||||
```
|
||||
|
||||
**原因**: 项目中缺少 `LanMountainDesktop.sln` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
||||
**原因**: 项目中缺少 `LanMountainDesktop.slnx` 解决方案文件,但工作流尝试执行 `dotnet restore` 而没有指定项目。
|
||||
|
||||
---
|
||||
|
||||
## 🔧 已采取的修复
|
||||
|
||||
### 1. 创建解决方案文件
|
||||
✅ 创建了标准的 `LanMountainDesktop.sln` 文件,包含:
|
||||
### 1. 创建 `.slnx` 解决方案文件
|
||||
✅ 创建了标准的 `LanMountainDesktop.slnx` 文件,包含:
|
||||
- `LanMountainDesktop/LanMountainDesktop.csproj`
|
||||
|
||||
### 2. 验证本地构建工作
|
||||
@@ -35,10 +35,10 @@ The current working directory does not contain a project or solution file.
|
||||
|
||||
## 📋 解决方案文件内容
|
||||
|
||||
包含主桌面项目的标准 Visual Studio 解决方案格式:
|
||||
包含主桌面项目的标准 XML 解决方案格式:
|
||||
|
||||
```
|
||||
LanMountainDesktop.sln
|
||||
LanMountainDesktop.slnx
|
||||
└── LanMountainDesktop (Desktop UI - Avalonia)
|
||||
```
|
||||
|
||||
@@ -50,10 +50,11 @@ LanMountainDesktop.sln
|
||||
|
||||
```bash
|
||||
# 1. 添加新创建的解决方案文件
|
||||
git add LanMountainDesktop.sln
|
||||
git add LanMountainDesktop.slnx
|
||||
git add global.json
|
||||
|
||||
# 2. 提交
|
||||
git commit -m "Add solution file for desktop project"
|
||||
git commit -m "Migrate desktop solution to .slnx"
|
||||
|
||||
# 3. 推送
|
||||
git push origin main
|
||||
@@ -92,7 +93,7 @@ git push origin v1.0.1
|
||||
| `.github/workflows/code-quality.yml` | 代码质量检查 | ✅ 可用 |
|
||||
| `.github/workflows/release.yml` | 多平台发布 | ✅ 可用 |
|
||||
| `.github/workflows/issue-management.yml` | Issue自动管理 | ✅ 可用 |
|
||||
| `LanMountainDesktop.sln` | 解决方案文件 | ✅ 已修复 |
|
||||
| `LanMountainDesktop.slnx` | 解决方案文件 | ✅ 已修复 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
.github/README.md
vendored
3
.github/README.md
vendored
@@ -36,9 +36,10 @@
|
||||
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||
|
||||
## 当前状态
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 `LanMountainDesktop.slnx` 工作区中维护。
|
||||
- 配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||
- 当前体验以 Windows 为主要目标平台。
|
||||
- SDK 版本由仓库根目录 `global.json` 锁定。
|
||||
|
||||
## 运行说明
|
||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||
|
||||
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: AirAppMarket Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Validate AirAppMarket index
|
||||
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
|
||||
2
.github/workflows/code-quality.yml
vendored
2
.github/workflows/code-quality.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
34
.github/workflows/release.yml
vendored
34
.github/workflows/release.yml
vendored
@@ -18,7 +18,7 @@ on:
|
||||
|
||||
env:
|
||||
DOTNET_VERSION: '10.0.x'
|
||||
Solution_Name: LanMountainDesktop.sln
|
||||
Solution_Name: LanMountainDesktop.slnx
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
@@ -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
10
.gitignore
vendored
@@ -482,3 +482,13 @@ $RECYCLE.BIN/
|
||||
*.swp
|
||||
nul
|
||||
/publish-test
|
||||
/_build_verify
|
||||
/_build_verify_tray
|
||||
/_build_verify_plugin
|
||||
/_build_verify_plugin_tabs
|
||||
/_build_verify_sample_plugin
|
||||
/_build_verify_sample_plugin_capabilities
|
||||
/_build_verify_plugin_page_host
|
||||
/_build_verify_plugin_services
|
||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||
/_build_obj
|
||||
|
||||
21
LanAirApp/README.md
Normal file
21
LanAirApp/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# LanAirApp
|
||||
|
||||
## 中文
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
|
||||
### 目录说明
|
||||
|
||||
- `docs/`:插件开发与打包文档。
|
||||
- `samples/`:示例插件与参考项目。
|
||||
- `standards/`:插件清单和目录结构约定。
|
||||
- `tools/`:插件打包与辅助工具。
|
||||
|
||||
### 与宿主的关系
|
||||
|
||||
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
|
||||
|
||||
## English
|
||||
|
||||
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
|
||||
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
16
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 插件开发指南
|
||||
|
||||
## 中文
|
||||
|
||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||
|
||||
- `plugin.json`
|
||||
- 插件入口程序集
|
||||
- 入口类
|
||||
- 本地化资源
|
||||
|
||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||
|
||||
## English
|
||||
|
||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
14
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# 插件打包指南
|
||||
|
||||
## 中文
|
||||
|
||||
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
||||
|
||||
- `.laapp` 安装包
|
||||
- `README.md`
|
||||
|
||||
官方市场索引只负责记录链接和校验信息。
|
||||
|
||||
## English
|
||||
|
||||
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
||||
<MakeDir Directories="$(PluginPackageOutputDirectory)" />
|
||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"settings.page_title": "Plugin Status",
|
||||
"plugin.name": "LanMountain Sample Plugin",
|
||||
"plugin.description": "Example plugin used to validate PluginSdk loading, services, communication, and localization.",
|
||||
"widget.display_name": "Sample Plugin Status Clock",
|
||||
"widget.category": "Plugins",
|
||||
"settings.header.title": "Sample Plugin Capability Inspector",
|
||||
"settings.section.info": "Plugin Info",
|
||||
"settings.section.capabilities": "Accessible Capabilities",
|
||||
"settings.section.status": "Live Runtime Status",
|
||||
"settings.info.plugin_name": "Plugin Name",
|
||||
"settings.info.plugin_id": "Plugin Id",
|
||||
"settings.info.version": "Version",
|
||||
"settings.info.author": "Author",
|
||||
"settings.info.description": "Description",
|
||||
"settings.info.plugin_directory": "Plugin Directory",
|
||||
"settings.info.data_directory": "Data Directory",
|
||||
"settings.info.host_application": "Host Application",
|
||||
"settings.info.host_version": "Host Version",
|
||||
"settings.info.sdk_api_version": "SDK API Version",
|
||||
"settings.info.state_service_resolved": "State Service Resolved",
|
||||
"settings.info.clock_service_resolved": "Clock Service Resolved",
|
||||
"settings.info.message_bus_resolved": "Message Bus Resolved",
|
||||
"settings.info.component_placed": "Component Placed",
|
||||
"settings.info.placed_count": "Placed Count",
|
||||
"settings.info.preview_count": "Preview Count",
|
||||
"settings.info.placement_ids": "Placement Ids",
|
||||
"settings.info.last_component_id": "Last Component Id",
|
||||
"settings.info.last_cell_size": "Last Cell Size",
|
||||
"settings.info.clock_service_time": "Clock Service Time",
|
||||
"settings.status.updated_at": "Updated: {0}",
|
||||
"status.frontend.title": "Frontend Status",
|
||||
"status.component.title": "Component Status",
|
||||
"status.backend.title": "Backend Status",
|
||||
"status.service.title": "Clock Service",
|
||||
"status.summary.pending": "Pending",
|
||||
"status.summary.attached": "Attached",
|
||||
"status.summary.healthy": "Healthy",
|
||||
"status.summary.faulted": "Faulted",
|
||||
"status.summary.placed": "Placed",
|
||||
"status.summary.preview": "Preview",
|
||||
"status.frontend.detail.pending": "Waiting for a plugin UI surface to connect.",
|
||||
"status.frontend.detail.settings_connected": "Settings page is connected to plugin services and communication.",
|
||||
"status.frontend.detail.widget_connected": "Widget surface is connected to plugin services and communication.",
|
||||
"status.component.detail.pending": "No component instance has been created yet.",
|
||||
"status.component.detail.none": "No component instance is active.",
|
||||
"status.component.detail.preview": "Preview instances: {0}; no placed desktop instance is active yet.",
|
||||
"status.component.detail.placed": "Placed count: {0}; preview count: {1}; placements: {2}",
|
||||
"status.backend.detail.pending": "Plugin initialization is in progress.",
|
||||
"status.backend.detail.log_written": "Initialization log written to: {0}",
|
||||
"status.backend.detail.log_write_failed": "Initialization log write failed: {0}",
|
||||
"status.service.detail.pending": "Clock service is not attached yet.",
|
||||
"status.service.detail.attached": "Clock service was attached and is waiting for the first tick.",
|
||||
"status.service.detail.running": "Clock service is running. Current service time: {0}",
|
||||
"status.service.detail.write_failed": "Clock state write failed: {0}",
|
||||
"capability.manifest.title": "IPluginContext.Manifest",
|
||||
"capability.manifest.detail": "Readable. Current plugin id: {0}; version: {1}.",
|
||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
||||
"capability.directories.detail": "Readable. Plugin directory: {0}; data directory: {1}.",
|
||||
"capability.properties.title": "IPluginContext.Properties",
|
||||
"capability.properties.detail": "Readable. Host properties currently exposed: {0}.",
|
||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
||||
"capability.get_service.detail": "Callable. State service resolved: {0}; clock service resolved: {1}; message bus resolved: {2}.",
|
||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
||||
"capability.register_service.detail": "Callable during plugin initialization. This sample plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container.",
|
||||
"capability.message_bus.title": "Plugin Communication Bus",
|
||||
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
||||
"widget.close_desktop.display_name": "Close Desktop",
|
||||
"widget.close_desktop.text": "Close Desktop",
|
||||
"widget.close_desktop.hint": "Exit LanMountainDesktop on click",
|
||||
"widget.close_desktop.unavailable": "Host lifecycle API is unavailable",
|
||||
"widget.close_desktop.failed": "Host rejected the exit request",
|
||||
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
||||
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
||||
"common.dev": "dev",
|
||||
"common.none": "(none)",
|
||||
"common.unknown": "(unknown)",
|
||||
"common.true": "true",
|
||||
"common.false": "false",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"settings.page_title": "插件状态",
|
||||
"plugin.name": "阑山示例插件",
|
||||
"plugin.description": "用于验证 PluginSdk 加载、服务、通信与本地化能力的示例插件。",
|
||||
"widget.display_name": "示例插件状态时钟",
|
||||
"widget.category": "插件",
|
||||
"settings.header.title": "示例插件能力检查器",
|
||||
"settings.section.info": "插件信息",
|
||||
"settings.section.capabilities": "可访问能力",
|
||||
"settings.section.status": "实时运行状态",
|
||||
"settings.info.plugin_name": "插件名称",
|
||||
"settings.info.plugin_id": "插件 Id",
|
||||
"settings.info.version": "版本",
|
||||
"settings.info.author": "作者",
|
||||
"settings.info.description": "描述",
|
||||
"settings.info.plugin_directory": "插件目录",
|
||||
"settings.info.data_directory": "数据目录",
|
||||
"settings.info.host_application": "宿主应用",
|
||||
"settings.info.host_version": "宿主版本",
|
||||
"settings.info.sdk_api_version": "SDK API 版本",
|
||||
"settings.info.state_service_resolved": "状态服务已解析",
|
||||
"settings.info.clock_service_resolved": "时钟服务已解析",
|
||||
"settings.info.message_bus_resolved": "消息总线已解析",
|
||||
"settings.info.component_placed": "组件是否已放置",
|
||||
"settings.info.placed_count": "已放置数量",
|
||||
"settings.info.preview_count": "预览数量",
|
||||
"settings.info.placement_ids": "放置位置 Id",
|
||||
"settings.info.last_component_id": "最近组件 Id",
|
||||
"settings.info.last_cell_size": "最近单元尺寸",
|
||||
"settings.info.clock_service_time": "时钟服务时间",
|
||||
"settings.status.updated_at": "更新时间:{0}",
|
||||
"status.frontend.title": "前端状态",
|
||||
"status.component.title": "组件状态",
|
||||
"status.backend.title": "后端状态",
|
||||
"status.service.title": "时钟服务",
|
||||
"status.summary.pending": "等待中",
|
||||
"status.summary.attached": "已挂接",
|
||||
"status.summary.healthy": "正常",
|
||||
"status.summary.faulted": "异常",
|
||||
"status.summary.placed": "已放置",
|
||||
"status.summary.preview": "预览中",
|
||||
"status.frontend.detail.pending": "等待插件界面接入。",
|
||||
"status.frontend.detail.settings_connected": "设置页已接入插件服务与通信。",
|
||||
"status.frontend.detail.widget_connected": "组件界面已接入插件服务与通信。",
|
||||
"status.component.detail.pending": "当前还没有创建组件实例。",
|
||||
"status.component.detail.none": "当前没有活动中的组件实例。",
|
||||
"status.component.detail.preview": "当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
||||
"status.component.detail.placed": "已放置数量:{0};预览数量:{1};放置位置:{2}",
|
||||
"status.backend.detail.pending": "插件初始化进行中。",
|
||||
"status.backend.detail.log_written": "初始化日志已写入:{0}",
|
||||
"status.backend.detail.log_write_failed": "初始化日志写入失败:{0}",
|
||||
"status.service.detail.pending": "时钟服务尚未挂接。",
|
||||
"status.service.detail.attached": "时钟服务已挂接,正在等待第一次心跳。",
|
||||
"status.service.detail.running": "时钟服务运行中,当前服务时间:{0}",
|
||||
"status.service.detail.write_failed": "时钟状态写入失败:{0}",
|
||||
"capability.manifest.title": "IPluginContext.Manifest",
|
||||
"capability.manifest.detail": "可读取。当前插件 id:{0};版本:{1}。",
|
||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
||||
"capability.directories.detail": "可读取。插件目录:{0};数据目录:{1}。",
|
||||
"capability.properties.title": "IPluginContext.Properties",
|
||||
"capability.properties.detail": "可读取。宿主当前暴露的属性:{0}。",
|
||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
||||
"capability.get_service.detail": "可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
||||
"capability.register_service.detail": "可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。",
|
||||
"capability.message_bus.title": "插件通信总线",
|
||||
"capability.message_bus.detail": "这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。",
|
||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||
"capability.widget_context.detail": "组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。",
|
||||
"widget.subtitle.preview": "预览界面 | 已放置:{0}",
|
||||
"widget.subtitle.placement": "位置 {0} | 已放置:{1}",
|
||||
"common.dev": "开发版",
|
||||
"common.none": "(无)",
|
||||
"common.unknown": "(未知)",
|
||||
"common.true": "是",
|
||||
"common.false": "否",
|
||||
"common.yes": "是",
|
||||
"common.no": "否"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
## 中文
|
||||
|
||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||
|
||||
## English
|
||||
|
||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||
@@ -0,0 +1,106 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
[PluginEntrance]
|
||||
public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
{
|
||||
private SamplePluginRuntimeStateService? _stateService;
|
||||
private SamplePluginClockService? _clockService;
|
||||
|
||||
public override void Initialize(IPluginContext context)
|
||||
{
|
||||
Directory.CreateDirectory(context.DataDirectory);
|
||||
var localizer = PluginLocalizer.Create(context);
|
||||
|
||||
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
||||
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
||||
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
||||
var hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||
var messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||
|
||||
_stateService = new SamplePluginRuntimeStateService(
|
||||
context.Manifest,
|
||||
context.PluginDirectory,
|
||||
context.DataDirectory,
|
||||
hostName,
|
||||
hostVersion,
|
||||
sdkApiVersion,
|
||||
messageBus,
|
||||
localizer);
|
||||
context.RegisterService(_stateService);
|
||||
|
||||
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
|
||||
context.RegisterService(_clockService);
|
||||
_stateService.AttachClockService(_clockService);
|
||||
|
||||
var logPath = Path.Combine(context.DataDirectory, "sample-plugin.log");
|
||||
var initMessage =
|
||||
$"[{DateTimeOffset.UtcNow:O}] {context.Manifest.Name} initialized in {hostName} (plugin version {context.Manifest.Version ?? "dev"}).";
|
||||
|
||||
try
|
||||
{
|
||||
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
||||
_stateService.MarkBackendReady(localizer.Format(
|
||||
"status.backend.detail.log_written",
|
||||
"初始化日志已写入:{0}",
|
||||
logPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkBackendFaulted(localizer.Format(
|
||||
"status.backend.detail.log_write_failed",
|
||||
"初始化日志写入失败:{0}",
|
||||
ex.Message));
|
||||
throw;
|
||||
}
|
||||
|
||||
_clockService.Start();
|
||||
|
||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||
"status",
|
||||
localizer.GetString("settings.page_title", "插件状态"),
|
||||
() => new SamplePluginSettingsView(context)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||
localizer.GetString("widget.display_name", "示例插件状态时钟"),
|
||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||
iconKey: "PuzzlePiece",
|
||||
category: localizer.GetString("widget.category", "插件"),
|
||||
minWidthCells: 4,
|
||||
minHeightCells: 4,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Proportional,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.34, 18, 34)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.CloseDesktop",
|
||||
localizer.GetString("widget.close_desktop.display_name", "关闭桌面"),
|
||||
widgetContext => new SamplePluginCloseDesktopWidget(widgetContext),
|
||||
iconKey: "DismissCircle",
|
||||
category: localizer.GetString("widget.category", "鎻掍欢"),
|
||||
minWidthCells: 2,
|
||||
minHeightCells: 1,
|
||||
allowDesktopPlacement: true,
|
||||
allowStatusBarPlacement: false,
|
||||
resizeMode: PluginDesktopComponentResizeMode.Free,
|
||||
cornerRadiusResolver: cellSize => Math.Clamp(cellSize * 0.28, 14, 22)));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_clockService?.Dispose();
|
||||
_clockService = null;
|
||||
_stateService = null;
|
||||
}
|
||||
|
||||
private static string GetHostProperty(IPluginContext context, string key, string fallback)
|
||||
{
|
||||
return context.TryGetProperty<string>(key, out var value) && !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginCloseDesktopWidget : Border
|
||||
{
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly IHostApplicationLifecycle? _hostApplicationLifecycle;
|
||||
private readonly TextBlock _titleTextBlock;
|
||||
private readonly TextBlock _statusTextBlock;
|
||||
|
||||
public SamplePluginCloseDesktopWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_hostApplicationLifecycle = context.GetService<IHostApplicationLifecycle>();
|
||||
|
||||
_titleTextBlock = new TextBlock
|
||||
{
|
||||
Text = T("widget.close_desktop.text", "关闭桌面"),
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
_statusTextBlock = new TextBlock
|
||||
{
|
||||
Text = _hostApplicationLifecycle is null
|
||||
? T("widget.close_desktop.unavailable", "宿主未提供退出接口")
|
||||
: T("widget.close_desktop.hint", "点击后退出阑山桌面"),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD4E7F6")),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
var contentGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 14,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children =
|
||||
{
|
||||
CreateIconShell(),
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 2,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children =
|
||||
{
|
||||
_titleTextBlock,
|
||||
_statusTextBlock
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetColumn(contentGrid.Children[1], 1);
|
||||
|
||||
var actionButton = new Button
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalAlignment = VerticalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
VerticalContentAlignment = VerticalAlignment.Stretch,
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(0),
|
||||
IsEnabled = _hostApplicationLifecycle is not null,
|
||||
Content = contentGrid
|
||||
};
|
||||
actionButton.Click += OnButtonClick;
|
||||
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF0B1220"), 0),
|
||||
new GradientStop(Color.Parse("#FF172554"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF7F1D1D"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66FB7185"));
|
||||
BorderThickness = new Thickness(1);
|
||||
CornerRadius = new CornerRadius(18);
|
||||
Padding = new Thickness(14, 10);
|
||||
Child = actionButton;
|
||||
|
||||
SizeChanged += OnSizeChanged;
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private Border CreateIconShell()
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Width = 36,
|
||||
Height = 36,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(Color.Parse("#33F87171")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#88FCA5A5")),
|
||||
BorderThickness = new Thickness(1),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = "⏻",
|
||||
FontSize = 18,
|
||||
Foreground = Brushes.White,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnButtonClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (_hostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "SamplePlugin.CloseDesktopWidget",
|
||||
Reason: "User invoked the sample plugin close-desktop widget.")) == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_statusTextBlock.Text = T("widget.close_desktop.failed", "宿主未接受退出请求");
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = Bounds.Height > 1 ? Bounds.Height : 72;
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.18, 12, 18), Math.Clamp(basis * 0.14, 8, 14));
|
||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.32, 16, 24));
|
||||
|
||||
if (Child is not Button actionButton || actionButton.Content is not Grid contentGrid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (contentGrid.Children[0] is Border iconShell)
|
||||
{
|
||||
var iconSize = Math.Clamp(basis * 0.58, 28, 40);
|
||||
iconShell.Width = iconSize;
|
||||
iconShell.Height = iconSize;
|
||||
if (iconShell.Child is TextBlock iconText)
|
||||
{
|
||||
iconText.FontSize = Math.Clamp(iconSize * 0.5, 14, 20);
|
||||
}
|
||||
}
|
||||
|
||||
_titleTextBlock.FontSize = Math.Clamp(basis * 0.28, 14, 20);
|
||||
_statusTextBlock.FontSize = Math.Clamp(basis * 0.18, 10, 13);
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal enum SamplePluginHealthState
|
||||
{
|
||||
Healthy,
|
||||
Pending,
|
||||
Faulted
|
||||
}
|
||||
|
||||
internal sealed record SamplePluginStatusEntry(
|
||||
string Key,
|
||||
string Title,
|
||||
SamplePluginHealthState State,
|
||||
string Summary,
|
||||
string Detail,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
internal sealed record SamplePluginCapabilityItem(
|
||||
string Title,
|
||||
string Detail);
|
||||
|
||||
internal sealed record SamplePluginRuntimeSnapshot(
|
||||
PluginManifest Manifest,
|
||||
string PluginDirectory,
|
||||
string DataDirectory,
|
||||
string HostApplicationName,
|
||||
string HostVersion,
|
||||
string SdkApiVersion,
|
||||
IReadOnlyList<SamplePluginStatusEntry> StatusEntries,
|
||||
bool HasPlacedComponent,
|
||||
int PlacedCount,
|
||||
int PreviewCount,
|
||||
IReadOnlyList<string> PlacementIds,
|
||||
string? LastComponentId,
|
||||
double LastCellSize,
|
||||
DateTimeOffset? ServiceClockTime);
|
||||
|
||||
internal sealed record SamplePluginClockTickMessage(DateTimeOffset CurrentTime);
|
||||
|
||||
internal sealed record SamplePluginStateChangedMessage(string Reason);
|
||||
|
||||
internal sealed record SamplePluginComponentInstance(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
double CellSize)
|
||||
{
|
||||
public bool IsPlaced => !string.IsNullOrWhiteSpace(PlacementId);
|
||||
}
|
||||
|
||||
internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly Dictionary<string, SamplePluginComponentInstance> _componentInstances =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly PluginManifest _manifest;
|
||||
private readonly string _pluginDirectory;
|
||||
private readonly string _dataDirectory;
|
||||
private readonly string _hostApplicationName;
|
||||
private readonly string _hostVersion;
|
||||
private readonly string _sdkApiVersion;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
|
||||
private SamplePluginStatusEntry _frontend;
|
||||
private SamplePluginStatusEntry _component;
|
||||
private SamplePluginStatusEntry _backend;
|
||||
private SamplePluginStatusEntry _service;
|
||||
private string? _lastComponentId;
|
||||
private double _lastCellSize;
|
||||
private DateTimeOffset? _serviceClockTime;
|
||||
|
||||
public SamplePluginRuntimeStateService(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
string hostApplicationName,
|
||||
string hostVersion,
|
||||
string sdkApiVersion,
|
||||
IPluginMessageBus messageBus,
|
||||
PluginLocalizer localizer)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_pluginDirectory = pluginDirectory;
|
||||
_dataDirectory = dataDirectory;
|
||||
_hostApplicationName = hostApplicationName;
|
||||
_hostVersion = hostVersion;
|
||||
_sdkApiVersion = sdkApiVersion;
|
||||
_messageBus = messageBus;
|
||||
_localizer = localizer;
|
||||
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
T("status.frontend.title", "前端状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.frontend.detail.pending", "等待插件界面接入。"));
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.component.detail.pending", "当前还没有创建组件实例。"));
|
||||
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.backend.detail.pending", "插件初始化进行中。"));
|
||||
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.service.detail.pending", "时钟服务尚未挂接。"));
|
||||
}
|
||||
|
||||
public void AttachClockService(SamplePluginClockService clockService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(clockService);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_serviceClockTime = clockService.CurrentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.attached", "已挂接"),
|
||||
T("status.service.detail.attached", "时钟服务已挂接,正在等待第一次心跳。"));
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service attached");
|
||||
}
|
||||
|
||||
public void MarkFrontendReady(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
T("status.frontend.title", "前端状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
T("status.summary.healthy", "正常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Frontend updated");
|
||||
}
|
||||
|
||||
public void MarkBackendReady(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
T("status.summary.healthy", "正常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Backend updated");
|
||||
}
|
||||
|
||||
public void MarkBackendFaulted(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Faulted,
|
||||
T("status.summary.faulted", "异常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Backend faulted");
|
||||
}
|
||||
|
||||
public void MarkClockServiceTick(DateTimeOffset currentTime)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_serviceClockTime = currentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
T("status.summary.healthy", "正常"),
|
||||
Tf(
|
||||
"status.service.detail.running",
|
||||
"时钟服务运行中,当前服务时间:{0}",
|
||||
currentTime.LocalDateTime.ToString("HH:mm:ss")));
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service tick");
|
||||
}
|
||||
|
||||
public void MarkClockServiceFaulted(string detail)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Faulted,
|
||||
T("status.summary.faulted", "异常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service faulted");
|
||||
}
|
||||
|
||||
public string RegisterComponentInstance(string componentId, string? placementId, double cellSize)
|
||||
{
|
||||
var instanceId = Guid.NewGuid().ToString("N");
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_componentInstances[instanceId] = new SamplePluginComponentInstance(componentId, placementId, cellSize);
|
||||
_lastComponentId = componentId;
|
||||
_lastCellSize = cellSize;
|
||||
UpdateComponentStatusNoLock();
|
||||
}
|
||||
|
||||
PublishStateChanged("Component attached");
|
||||
return instanceId;
|
||||
}
|
||||
|
||||
public void UnregisterComponentInstance(string instanceId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(instanceId);
|
||||
|
||||
var removed = false;
|
||||
lock (_gate)
|
||||
{
|
||||
removed = _componentInstances.Remove(instanceId);
|
||||
if (removed)
|
||||
{
|
||||
UpdateComponentStatusNoLock();
|
||||
}
|
||||
}
|
||||
|
||||
if (removed)
|
||||
{
|
||||
PublishStateChanged("Component detached");
|
||||
}
|
||||
}
|
||||
|
||||
public SamplePluginRuntimeSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var placementIds = _componentInstances.Values
|
||||
.Where(instance => instance.IsPlaced)
|
||||
.Select(instance => instance.PlacementId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
||||
|
||||
return new SamplePluginRuntimeSnapshot(
|
||||
_manifest,
|
||||
_pluginDirectory,
|
||||
_dataDirectory,
|
||||
_hostApplicationName,
|
||||
_hostVersion,
|
||||
_sdkApiVersion,
|
||||
[_frontend, _component, _backend, _service],
|
||||
placementIds.Length > 0,
|
||||
placementIds.Length,
|
||||
previewCount,
|
||||
placementIds,
|
||||
_lastComponentId,
|
||||
_lastCellSize,
|
||||
_serviceClockTime);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SamplePluginCapabilityItem> GetCapabilities(
|
||||
IPluginContext context,
|
||||
bool hasStateService,
|
||||
bool hasClockService,
|
||||
bool hasMessageBus)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var propertyNames = context.Properties.Count == 0
|
||||
? T("common.none", "(无)")
|
||||
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
return
|
||||
[
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.manifest.title", "IPluginContext.Manifest"),
|
||||
Tf(
|
||||
"capability.manifest.detail",
|
||||
"可读取。当前插件 id:{0};版本:{1}。",
|
||||
context.Manifest.Id,
|
||||
context.Manifest.Version ?? T("common.dev", "开发版"))),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.directories.title", "IPluginContext.PluginDirectory / DataDirectory"),
|
||||
Tf(
|
||||
"capability.directories.detail",
|
||||
"可读取。插件目录:{0};数据目录:{1}。",
|
||||
context.PluginDirectory,
|
||||
context.DataDirectory)),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.properties.title", "IPluginContext.Properties"),
|
||||
Tf(
|
||||
"capability.properties.detail",
|
||||
"可读取。宿主当前暴露的属性:{0}。",
|
||||
propertyNames)),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.get_service.title", "IPluginContext.GetService<T>()"),
|
||||
Tf(
|
||||
"capability.get_service.detail",
|
||||
"可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
||||
FormatBoolean(hasStateService),
|
||||
FormatBoolean(hasClockService),
|
||||
FormatBoolean(hasMessageBus))),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.register_service.title", "IPluginContext.RegisterService<TService>()"),
|
||||
T(
|
||||
"capability.register_service.detail",
|
||||
"可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。")),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.message_bus.title", "插件通信总线"),
|
||||
T(
|
||||
"capability.message_bus.detail",
|
||||
"这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。")),
|
||||
new SamplePluginCapabilityItem(
|
||||
T("capability.widget_context.title", "PluginDesktopComponentContext"),
|
||||
T(
|
||||
"capability.widget_context.detail",
|
||||
"组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。"))
|
||||
];
|
||||
}
|
||||
|
||||
private void UpdateComponentStatusNoLock()
|
||||
{
|
||||
var placementIds = _componentInstances.Values
|
||||
.Where(instance => instance.IsPlaced)
|
||||
.Select(instance => instance.PlacementId!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(id => id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
var previewCount = _componentInstances.Values.Count(instance => !instance.IsPlaced);
|
||||
|
||||
if (placementIds.Length > 0)
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
T("status.summary.placed", "已放置"),
|
||||
Tf(
|
||||
"status.component.detail.placed",
|
||||
"已放置数量:{0};预览数量:{1};放置位置:{2}",
|
||||
placementIds.Length,
|
||||
previewCount,
|
||||
string.Join(", ", placementIds)));
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewCount > 0)
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
T("status.summary.preview", "预览中"),
|
||||
Tf(
|
||||
"status.component.detail.preview",
|
||||
"当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
||||
previewCount));
|
||||
return;
|
||||
}
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.component.detail.none", "当前没有活动中的组件实例。"));
|
||||
}
|
||||
|
||||
private void PublishStateChanged(string reason)
|
||||
{
|
||||
_messageBus.Publish(new SamplePluginStateChangedMessage(reason));
|
||||
}
|
||||
|
||||
private static SamplePluginStatusEntry CreateEntry(
|
||||
string key,
|
||||
string title,
|
||||
SamplePluginHealthState state,
|
||||
string summary,
|
||||
string detail)
|
||||
{
|
||||
return new SamplePluginStatusEntry(
|
||||
key,
|
||||
title,
|
||||
state,
|
||||
summary,
|
||||
detail,
|
||||
DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
|
||||
private string FormatBoolean(bool value)
|
||||
{
|
||||
return value
|
||||
? T("common.true", "是")
|
||||
: T("common.false", "否");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SamplePluginClockService : IDisposable
|
||||
{
|
||||
private readonly object _gate = new();
|
||||
private readonly string _clockStateFilePath;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly Timer _timer;
|
||||
private DateTimeOffset _currentTime = DateTimeOffset.Now;
|
||||
private int _disposed;
|
||||
|
||||
public SamplePluginClockService(
|
||||
string dataDirectory,
|
||||
SamplePluginRuntimeStateService stateService,
|
||||
IPluginMessageBus messageBus,
|
||||
PluginLocalizer localizer)
|
||||
{
|
||||
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
|
||||
_stateService = stateService;
|
||||
_messageBus = messageBus;
|
||||
_localizer = localizer;
|
||||
_timer = new Timer(OnTimerTick);
|
||||
}
|
||||
|
||||
public DateTimeOffset CurrentTime
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _currentTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
PublishTick();
|
||||
_timer.Change(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timer.Dispose();
|
||||
}
|
||||
|
||||
private void OnTimerTick(object? state)
|
||||
{
|
||||
PublishTick();
|
||||
}
|
||||
|
||||
private void PublishTick()
|
||||
{
|
||||
if (Volatile.Read(ref _disposed) != 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.Now;
|
||||
lock (_gate)
|
||||
{
|
||||
_currentTime = now;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(
|
||||
_clockStateFilePath,
|
||||
now.ToString("O", CultureInfo.InvariantCulture));
|
||||
_stateService.MarkClockServiceTick(now);
|
||||
_messageBus.Publish(new SamplePluginClockTickMessage(now));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkClockServiceFaulted(_localizer.Format(
|
||||
"status.service.detail.write_failed",
|
||||
"时钟状态写入失败:{0}",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginSettingsView : UserControl
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly StackPanel _pluginInfoPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _capabilityPanel = new() { Spacing = 8 };
|
||||
private readonly StackPanel _statusPanel = new() { Spacing = 10 };
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
|
||||
public SamplePluginSettingsView(IPluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_stateService.MarkFrontendReady(T(
|
||||
"status.frontend.detail.settings_connected",
|
||||
"设置页已接入插件服务与通信。"));
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
|
||||
Content = new Border
|
||||
{
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#1F0B1120"), 0),
|
||||
new GradientStop(Color.Parse("#260C4A6E"), 1)
|
||||
]
|
||||
},
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6628B2FF")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(18),
|
||||
Padding = new Thickness(18),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = T("settings.header.title", "示例插件能力检查器"),
|
||||
FontSize = 22,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
},
|
||||
CreateSection(T("settings.section.info", "插件信息"), _pluginInfoPanel),
|
||||
CreateSection(T("settings.section.capabilities", "可访问能力"), _capabilityPanel),
|
||||
CreateSection(T("settings.section.status", "实时运行状态"), _statusPanel)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
SubscribeToPluginBus();
|
||||
RefreshView();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
foreach (var subscription in _subscriptions)
|
||||
{
|
||||
subscription.Dispose();
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private void SubscribeToPluginBus()
|
||||
{
|
||||
if (_subscriptions.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(RefreshView)));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(RefreshView)));
|
||||
}
|
||||
|
||||
private void RefreshView()
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
RefreshPluginInfo(snapshot);
|
||||
RefreshCapabilities();
|
||||
RefreshStatuses(snapshot);
|
||||
}
|
||||
|
||||
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
|
||||
{
|
||||
_pluginInfoPanel.Children.Clear();
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.plugin_name", "插件名称"),
|
||||
T("plugin.name", snapshot.Manifest.Name)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_id", "插件 Id"), snapshot.Manifest.Id));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.version", "版本"), snapshot.Manifest.Version ?? T("common.dev", "开发版")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.author", "作者"), snapshot.Manifest.Author ?? T("common.none", "(无)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.description", "描述"),
|
||||
T("plugin.description", snapshot.Manifest.Description ?? T("common.none", "(无)"))));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_directory", "插件目录"), snapshot.PluginDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.data_directory", "数据目录"), snapshot.DataDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_application", "宿主应用"), snapshot.HostApplicationName));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_version", "宿主版本"), snapshot.HostVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.sdk_api_version", "SDK API 版本"), snapshot.SdkApiVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.state_service_resolved", "状态服务已解析"),
|
||||
FormatBoolean(_context.GetService<SamplePluginRuntimeStateService>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.clock_service_resolved", "时钟服务已解析"),
|
||||
FormatBoolean(_context.GetService<SamplePluginClockService>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.message_bus_resolved", "消息总线已解析"),
|
||||
FormatBoolean(_context.GetService<IPluginMessageBus>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.component_placed", "组件是否已放置"),
|
||||
snapshot.HasPlacedComponent ? T("common.yes", "是") : T("common.no", "否")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.placed_count", "已放置数量"), snapshot.PlacedCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.preview_count", "预览数量"), snapshot.PreviewCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.placement_ids", "放置位置 Id"),
|
||||
snapshot.PlacementIds.Count == 0 ? T("common.none", "(无)") : string.Join(", ", snapshot.PlacementIds)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.last_component_id", "最近组件 Id"),
|
||||
snapshot.LastComponentId ?? T("common.none", "(无)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.last_cell_size", "最近单元尺寸"),
|
||||
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : T("common.unknown", "(未知)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.clock_service_time", "时钟服务时间"),
|
||||
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
|
||||
}
|
||||
|
||||
private void RefreshCapabilities()
|
||||
{
|
||||
var capabilities = _stateService.GetCapabilities(
|
||||
_context,
|
||||
_context.GetService<SamplePluginRuntimeStateService>() is not null,
|
||||
_context.GetService<SamplePluginClockService>() is not null,
|
||||
_context.GetService<IPluginMessageBus>() is not null);
|
||||
|
||||
_capabilityPanel.Children.Clear();
|
||||
foreach (var capability in capabilities)
|
||||
{
|
||||
_capabilityPanel.Children.Add(CreateCapabilityCard(capability));
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshStatuses(SamplePluginRuntimeSnapshot snapshot)
|
||||
{
|
||||
_statusPanel.Children.Clear();
|
||||
|
||||
foreach (var entry in snapshot.StatusEntries)
|
||||
{
|
||||
var palette = GetPalette(entry.State);
|
||||
_statusPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(palette.Background),
|
||||
BorderBrush = new SolidColorBrush(palette.Border),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
CreateStatusHeader(entry, palette),
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Detail,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = Tf("settings.status.updated_at", "更新时间:{0}", entry.UpdatedAt.LocalDateTime.ToString("HH:mm:ss")),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private Border CreateSection(string title, Control content)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#3328B2FF")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(14),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 12,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
},
|
||||
content
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateInfoLine(string label, string value)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("180,*"),
|
||||
ColumnSpacing = 10
|
||||
};
|
||||
|
||||
var labelText = new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBAE6FD")),
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
var valueText = new TextBlock
|
||||
{
|
||||
Text = value,
|
||||
Foreground = Brushes.White,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
grid.Children.Add(labelText);
|
||||
grid.Children.Add(valueText);
|
||||
Grid.SetColumn(valueText, 1);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private Control CreateCapabilityCard(SamplePluginCapabilityItem item)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#0F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#3338BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.Title,
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = item.Detail,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFE0F2FE")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Control CreateStatusHeader(
|
||||
SamplePluginStatusEntry entry,
|
||||
(Color Background, Color Border, Color Dot) palette)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||
ColumnSpacing = 8
|
||||
};
|
||||
|
||||
var dot = new Border
|
||||
{
|
||||
Width = 10,
|
||||
Height = 10,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(palette.Dot),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = entry.Title,
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
};
|
||||
var summary = new TextBlock
|
||||
{
|
||||
Text = entry.Summary,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Right
|
||||
};
|
||||
|
||||
grid.Children.Add(dot);
|
||||
grid.Children.Add(title);
|
||||
grid.Children.Add(summary);
|
||||
Grid.SetColumn(title, 1);
|
||||
Grid.SetColumn(summary, 2);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SamplePluginHealthState.Healthy => (
|
||||
Color.Parse("#1F115E59"),
|
||||
Color.Parse("#665EEAD4"),
|
||||
Color.Parse("#5EEAD4")),
|
||||
SamplePluginHealthState.Faulted => (
|
||||
Color.Parse("#291B1B"),
|
||||
Color.Parse("#66F87171"),
|
||||
Color.Parse("#F87171")),
|
||||
_ => (
|
||||
Color.Parse("#2B3A2A0D"),
|
||||
Color.Parse("#66FBBF24"),
|
||||
Color.Parse("#FBBF24"))
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
|
||||
private string FormatBoolean(bool value)
|
||||
{
|
||||
return value
|
||||
? T("common.true", "是")
|
||||
: T("common.false", "否");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.SamplePlugin;
|
||||
|
||||
internal sealed class SamplePluginStatusClockWidget : Border
|
||||
{
|
||||
private readonly PluginDesktopComponentContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly TextBlock _timeTextBlock;
|
||||
private readonly TextBlock _subtitleTextBlock;
|
||||
private readonly StackPanel _statusPanel;
|
||||
private readonly Border _statusHost;
|
||||
private readonly List<IDisposable> _subscriptions = [];
|
||||
private string? _instanceId;
|
||||
|
||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
?? throw new InvalidOperationException("SamplePluginClockService is not available.");
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_timeTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = Brushes.White,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
_subtitleTextBlock = new TextBlock
|
||||
{
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFBFE9FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
_statusPanel = new StackPanel
|
||||
{
|
||||
Spacing = 8
|
||||
};
|
||||
_statusHost = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#1F082F49")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#5538BDF8")),
|
||||
BorderThickness = new Thickness(1),
|
||||
Child = _statusPanel
|
||||
};
|
||||
|
||||
Background = new LinearGradientBrush
|
||||
{
|
||||
StartPoint = new RelativePoint(0, 0, RelativeUnit.Relative),
|
||||
EndPoint = new RelativePoint(1, 1, RelativeUnit.Relative),
|
||||
GradientStops =
|
||||
[
|
||||
new GradientStop(Color.Parse("#FF07111F"), 0),
|
||||
new GradientStop(Color.Parse("#FF0C4A6E"), 0.55),
|
||||
new GradientStop(Color.Parse("#FF0EA5E9"), 1)
|
||||
]
|
||||
};
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#6648C7FF"));
|
||||
BorderThickness = new Thickness(1);
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
VerticalAlignment = VerticalAlignment.Stretch;
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 14,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
_timeTextBlock,
|
||||
_subtitleTextBlock
|
||||
}
|
||||
},
|
||||
_statusHost
|
||||
}
|
||||
};
|
||||
|
||||
Grid.SetRow(((Grid)Child).Children[1], 1);
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
SizeChanged += OnSizeChanged;
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
ApplyScale();
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
_instanceId = _stateService.RegisterComponentInstance(
|
||||
_context.ComponentId,
|
||||
_context.PlacementId,
|
||||
_context.CellSize);
|
||||
}
|
||||
|
||||
_stateService.MarkFrontendReady(T(
|
||||
"status.frontend.detail.widget_connected",
|
||||
"组件界面已接入插件服务与通信。"));
|
||||
SubscribeToPluginBus();
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
foreach (var subscription in _subscriptions)
|
||||
{
|
||||
subscription.Dispose();
|
||||
}
|
||||
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_instanceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_stateService.UnregisterComponentInstance(_instanceId);
|
||||
_instanceId = null;
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
{
|
||||
ApplyScale();
|
||||
RefreshStatusPanel();
|
||||
}
|
||||
|
||||
private void SubscribeToPluginBus()
|
||||
{
|
||||
if (_subscriptions.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginClockTickMessage>(message =>
|
||||
Dispatcher.UIThread.Post(() => RefreshClock(message.CurrentTime))));
|
||||
|
||||
_subscriptions.Add(_messageBus.Subscribe<SamplePluginStateChangedMessage>(_ =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
UpdateSubtitle();
|
||||
RefreshStatusPanel();
|
||||
})));
|
||||
}
|
||||
|
||||
private void RefreshClock(DateTimeOffset currentTime)
|
||||
{
|
||||
_timeTextBlock.Text = currentTime.LocalDateTime.ToString("HH:mm:ss");
|
||||
}
|
||||
|
||||
private void UpdateSubtitle()
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
|
||||
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
|
||||
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
|
||||
}
|
||||
|
||||
private void RefreshStatusPanel()
|
||||
{
|
||||
_statusPanel.Children.Clear();
|
||||
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
var basis = GetLayoutBasis();
|
||||
var titleSize = Math.Clamp(basis * 0.068, 11, 16);
|
||||
var detailSize = Math.Clamp(basis * 0.052, 9, 13);
|
||||
|
||||
foreach (var entry in snapshot.StatusEntries)
|
||||
{
|
||||
var palette = GetPalette(entry.State);
|
||||
_statusPanel.Children.Add(new Border
|
||||
{
|
||||
Background = new SolidColorBrush(palette.Background),
|
||||
BorderBrush = new SolidColorBrush(palette.Border),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,Auto"),
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"),
|
||||
ColumnSpacing = 8,
|
||||
Children =
|
||||
{
|
||||
new Border
|
||||
{
|
||||
Width = Math.Clamp(basis * 0.038, 8, 11),
|
||||
Height = Math.Clamp(basis * 0.038, 8, 11),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = new SolidColorBrush(palette.Dot),
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Title,
|
||||
FontSize = titleSize,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Summary,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
TextAlignment = TextAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = entry.Detail,
|
||||
FontSize = detailSize,
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FFD7F2FF")),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var row = (Grid)((Border)_statusPanel.Children[^1]).Child!;
|
||||
Grid.SetColumn(row.Children[1], 1);
|
||||
Grid.SetColumn(row.Children[2], 2);
|
||||
Grid.SetColumnSpan(row.Children[3], 3);
|
||||
Grid.SetRow(row.Children[3], 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyScale()
|
||||
{
|
||||
var basis = GetLayoutBasis();
|
||||
Padding = new Thickness(Math.Clamp(basis * 0.09, 16, 26));
|
||||
CornerRadius = new CornerRadius(Math.Clamp(basis * 0.14, 20, 34));
|
||||
_timeTextBlock.FontSize = Math.Clamp(basis * 0.22, 30, 58);
|
||||
_subtitleTextBlock.FontSize = Math.Clamp(basis * 0.062, 11, 17);
|
||||
_statusHost.Padding = new Thickness(Math.Clamp(basis * 0.045, 10, 18));
|
||||
_statusHost.CornerRadius = new CornerRadius(Math.Clamp(basis * 0.09, 14, 22));
|
||||
_statusPanel.Spacing = Math.Clamp(basis * 0.024, 6, 10);
|
||||
}
|
||||
|
||||
private double GetLayoutBasis()
|
||||
{
|
||||
var width = Bounds.Width > 1 ? Bounds.Width : _context.CellSize * 4;
|
||||
var height = Bounds.Height > 1 ? Bounds.Height : _context.CellSize * 4;
|
||||
return Math.Max(_context.CellSize * 4, Math.Min(width, height));
|
||||
}
|
||||
|
||||
private static (Color Background, Color Border, Color Dot) GetPalette(SamplePluginHealthState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SamplePluginHealthState.Healthy => (
|
||||
Color.Parse("#1F0F766E"),
|
||||
Color.Parse("#4D5EEAD4"),
|
||||
Color.Parse("#5EEAD4")),
|
||||
SamplePluginHealthState.Faulted => (
|
||||
Color.Parse("#29B91C1C"),
|
||||
Color.Parse("#66F87171"),
|
||||
Color.Parse("#F87171")),
|
||||
_ => (
|
||||
Color.Parse("#1F7C2D12"),
|
||||
Color.Parse("#66FDBA74"),
|
||||
Color.Parse("#FDBA74"))
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.SamplePlugin.dll"
|
||||
}
|
||||
11
LanAirApp/samples/README.md
Normal file
11
LanAirApp/samples/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 示例插件目录
|
||||
|
||||
## 中文
|
||||
|
||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||
|
||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||
9
LanAirApp/standards/README.md
Normal file
9
LanAirApp/standards/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 插件标准说明
|
||||
|
||||
## 中文
|
||||
|
||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||
9
LanAirApp/standards/plugin.template.json
Normal file
9
LanAirApp/standards/plugin.template.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.YourPlugin",
|
||||
"name": "Your Plugin",
|
||||
"description": "Describe what your plugin adds to LanMountainDesktop.",
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
return await RunAsync(args);
|
||||
|
||||
static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
string? inputDirectory = null;
|
||||
string? outputPath = null;
|
||||
var overwrite = false;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--input":
|
||||
inputDirectory = ReadValue(args, ref i, "--input");
|
||||
break;
|
||||
case "--output":
|
||||
outputPath = ReadValue(args, ref i, "--output");
|
||||
break;
|
||||
case "--overwrite":
|
||||
overwrite = true;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(inputDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Missing required argument '--input'.");
|
||||
}
|
||||
|
||||
var fullInputDirectory = Path.GetFullPath(inputDirectory);
|
||||
if (!Directory.Exists(fullInputDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
|
||||
}
|
||||
|
||||
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
|
||||
manifestPath);
|
||||
}
|
||||
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
|
||||
if (!File.Exists(entranceAssemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
|
||||
entranceAssemblyPath);
|
||||
}
|
||||
|
||||
outputPath ??= Path.Combine(
|
||||
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
|
||||
BuildPackageFileName(manifest.Id));
|
||||
|
||||
var fullOutputPath = Path.GetFullPath(outputPath);
|
||||
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
|
||||
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
|
||||
}
|
||||
|
||||
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
|
||||
if (string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
if (File.Exists(fullOutputPath))
|
||||
{
|
||||
if (!overwrite)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
|
||||
}
|
||||
|
||||
File.Delete(fullOutputPath);
|
||||
}
|
||||
|
||||
await Task.Run(() => ZipFile.CreateFromDirectory(
|
||||
fullInputDirectory,
|
||||
fullOutputPath,
|
||||
CompressionLevel.Optimal,
|
||||
includeBaseDirectory: false));
|
||||
|
||||
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
|
||||
{
|
||||
var nextIndex = index + 1;
|
||||
if (nextIndex >= args.Count)
|
||||
{
|
||||
throw new InvalidOperationException($"Missing value for '{optionName}'.");
|
||||
}
|
||||
|
||||
index = nextIndex;
|
||||
return args[nextIndex];
|
||||
}
|
||||
|
||||
static string BuildPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return safeName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("LanMountainDesktop.PluginPackager");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" --input <plugin build directory> Required");
|
||||
Console.WriteLine(" --output <path to .laapp> Optional");
|
||||
Console.WriteLine(" --overwrite Optional");
|
||||
}
|
||||
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
12
LanMountainDesktop.PluginSdk/IHostApplicationLifecycle.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record HostApplicationLifecycleRequest(
|
||||
string? Source = null,
|
||||
string? Reason = null);
|
||||
|
||||
public interface IHostApplicationLifecycle
|
||||
{
|
||||
bool TryExit(HostApplicationLifecycleRequest? request = null);
|
||||
|
||||
bool TryRestart(HostApplicationLifecycleRequest? request = null);
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
6
LanMountainDesktop.PluginSdk/IPlugin.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
}
|
||||
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
27
LanMountainDesktop.PluginSdk/IPluginContext.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
{
|
||||
PluginManifest Manifest { get; }
|
||||
|
||||
string PluginDirectory { get; }
|
||||
|
||||
string DataDirectory { get; }
|
||||
|
||||
IServiceProvider Services { get; }
|
||||
|
||||
IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
T? GetService<T>();
|
||||
|
||||
bool TryGetProperty<T>(string key, out T? value);
|
||||
|
||||
void RegisterService<TService>(TService service)
|
||||
where TService : class;
|
||||
|
||||
void RegisterSettingsPage(PluginSettingsPageRegistration registration);
|
||||
|
||||
void RegisterDesktopComponent(PluginDesktopComponentRegistration registration);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginMessageBus.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginMessageBus
|
||||
{
|
||||
IDisposable Subscribe<TMessage>(Action<TMessage> handler);
|
||||
|
||||
void Publish<TMessage>(TMessage message);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPackageManager
|
||||
{
|
||||
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||
|
||||
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record InstalledPluginInfo(
|
||||
PluginManifest Manifest,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
bool IsPackage,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
8
LanMountainDesktop.PluginSdk/PluginBase.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentContext
|
||||
{
|
||||
public PluginDesktopComponentContext(
|
||||
PluginManifest manifest,
|
||||
string pluginDirectory,
|
||||
string dataDirectory,
|
||||
IServiceProvider services,
|
||||
IReadOnlyDictionary<string, object?> properties,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
double cellSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
Manifest = manifest;
|
||||
PluginDirectory = pluginDirectory;
|
||||
DataDirectory = dataDirectory;
|
||||
Services = services;
|
||||
Properties = properties;
|
||||
ComponentId = componentId.Trim();
|
||||
PlacementId = string.IsNullOrWhiteSpace(placementId) ? null : placementId.Trim();
|
||||
CellSize = Math.Max(1, cellSize);
|
||||
}
|
||||
|
||||
public PluginManifest Manifest { get; }
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string DataDirectory { get; }
|
||||
|
||||
public IServiceProvider Services { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, object?> Properties { get; }
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string? PlacementId { get; }
|
||||
|
||||
public double CellSize { get; }
|
||||
|
||||
public T? GetService<T>()
|
||||
{
|
||||
return (T?)Services.GetService(typeof(T));
|
||||
}
|
||||
|
||||
public bool TryGetProperty<T>(string key, out T? value)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
if (Properties.TryGetValue(key, out var rawValue) && rawValue is T typedValue)
|
||||
{
|
||||
value = typedValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginDesktopComponentRegistration
|
||||
{
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
int minHeightCells = 2,
|
||||
bool allowDesktopPlacement = true,
|
||||
bool allowStatusBarPlacement = false,
|
||||
PluginDesktopComponentResizeMode resizeMode = PluginDesktopComponentResizeMode.Proportional,
|
||||
string? displayNameLocalizationKey = null,
|
||||
Func<double, double>? cornerRadiusResolver = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(componentId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(displayName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(iconKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(category);
|
||||
ArgumentNullException.ThrowIfNull(controlFactory);
|
||||
|
||||
ComponentId = componentId.Trim();
|
||||
DisplayName = displayName.Trim();
|
||||
DisplayNameLocalizationKey = string.IsNullOrWhiteSpace(displayNameLocalizationKey)
|
||||
? null
|
||||
: displayNameLocalizationKey.Trim();
|
||||
ControlFactory = controlFactory;
|
||||
IconKey = iconKey.Trim();
|
||||
Category = category.Trim();
|
||||
MinWidthCells = Math.Max(1, minWidthCells);
|
||||
MinHeightCells = Math.Max(1, minHeightCells);
|
||||
AllowDesktopPlacement = allowDesktopPlacement;
|
||||
AllowStatusBarPlacement = allowStatusBarPlacement;
|
||||
ResizeMode = resizeMode;
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
public string Category { get; }
|
||||
|
||||
public int MinWidthCells { get; }
|
||||
|
||||
public int MinHeightCells { get; }
|
||||
|
||||
public bool AllowDesktopPlacement { get; }
|
||||
|
||||
public bool AllowStatusBarPlacement { get; }
|
||||
|
||||
public PluginDesktopComponentResizeMode ResizeMode { get; }
|
||||
|
||||
public Func<double, double>? CornerRadiusResolver { get; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public enum PluginDesktopComponentResizeMode
|
||||
{
|
||||
Proportional = 0,
|
||||
Free = 1
|
||||
}
|
||||
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
6
LanMountainDesktop.PluginSdk/PluginEntranceAttribute.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
|
||||
public sealed class PluginEntranceAttribute : Attribute
|
||||
{
|
||||
}
|
||||
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginHostPropertyKeys
|
||||
{
|
||||
public const string HostApplicationName = "HostApplicationName";
|
||||
public const string HostVersion = "HostVersion";
|
||||
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
|
||||
public const string HostLanguageCode = "HostLanguageCode";
|
||||
}
|
||||
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLocalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginLocalizer(string pluginDirectory, string? languageCode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
|
||||
PluginDirectory = pluginDirectory;
|
||||
LanguageCode = NormalizeLanguageCode(languageCode);
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string LanguageCode { get; }
|
||||
|
||||
public static PluginLocalizer Create(IPluginContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public static PluginLocalizer Create(PluginDesktopComponentContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public string GetString(string key, string fallback)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
var primaryTable = LoadLanguageTable(LanguageCode);
|
||||
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fallbackTable = LoadLanguageTable("en-US");
|
||||
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public string Format(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
|
||||
}
|
||||
|
||||
public static string NormalizeLanguageCode(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? "en-US"
|
||||
: "zh-CN";
|
||||
}
|
||||
|
||||
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
|
||||
rawValue is string languageCode
|
||||
? NormalizeLanguageCode(languageCode)
|
||||
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> LoadLanguageTable(string languageCode)
|
||||
{
|
||||
if (_cache.TryGetValue(languageCode, out var table))
|
||||
{
|
||||
return table;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
if (data is not null)
|
||||
{
|
||||
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep empty localization table for plugin resilience.
|
||||
}
|
||||
|
||||
_cache[languageCode] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
107
LanMountainDesktop.PluginSdk/PluginManifest.cs
Normal file
@@ -0,0 +1,107 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginManifest(
|
||||
string Id,
|
||||
string Name,
|
||||
string EntranceAssembly,
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static PluginManifest Load(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
using var stream = File.OpenRead(manifestPath);
|
||||
return Load(stream, manifestPath);
|
||||
}
|
||||
|
||||
public static PluginManifest Load(Stream stream, string sourceName)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(stream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var manifest = JsonSerializer.Deserialize<PluginManifest>(stream, SerializerOptions);
|
||||
if (manifest is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to deserialize plugin manifest '{sourceName}'.");
|
||||
}
|
||||
|
||||
return manifest.NormalizeAndValidate(sourceName);
|
||||
}
|
||||
|
||||
public string ResolveEntranceAssemblyPath(string manifestPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(manifestPath);
|
||||
|
||||
if (Path.IsPathRooted(EntranceAssembly))
|
||||
{
|
||||
return Path.GetFullPath(EntranceAssembly);
|
||||
}
|
||||
|
||||
var manifestDirectory = Path.GetDirectoryName(Path.GetFullPath(manifestPath))
|
||||
?? throw new InvalidOperationException($"Failed to determine the directory of '{manifestPath}'.");
|
||||
|
||||
return Path.GetFullPath(Path.Combine(manifestDirectory, EntranceAssembly));
|
||||
}
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Name = RequireValue(Name, nameof(Name), manifestPath),
|
||||
EntranceAssembly = RequireValue(EntranceAssembly, nameof(EntranceAssembly), manifestPath),
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares an invalid API version '{normalized.ApiVersion}'.");
|
||||
}
|
||||
|
||||
if (!System.Version.TryParse(PluginSdkInfo.ApiVersion, out var currentVersion))
|
||||
{
|
||||
throw new InvalidOperationException($"Plugin SDK API version '{PluginSdkInfo.ApiVersion}' is invalid.");
|
||||
}
|
||||
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
var normalized = NormalizeOptionalValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPackageInstallResult(
|
||||
PluginManifest Manifest,
|
||||
bool ReplacedExisting,
|
||||
bool RestartRequired);
|
||||
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
12
LanMountainDesktop.PluginSdk/PluginSdkInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
public const string RuntimeDirectoryName = ".runtime";
|
||||
public const string ExtractedPackagesDirectoryName = "packages";
|
||||
public const string PackagedDataDirectoryName = "data";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginSettingsPageRegistration
|
||||
{
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(title);
|
||||
ArgumentNullException.ThrowIfNull(contentFactory);
|
||||
|
||||
Id = id.Trim();
|
||||
Title = title.Trim();
|
||||
ContentFactory = contentFactory;
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk" TreatAsLocalProperty="Version;PackageVersion;InformationalVersion;AssemblyVersion;FileVersion">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Version>1.0.0</Version>
|
||||
<PackageVersion>$(Version)</PackageVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
242
LanMountainDesktop.PluginsInstallHelper/Program.cs
Normal file
@@ -0,0 +1,242 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
private static async Task<int> Main(string[] args)
|
||||
{
|
||||
var result = new HelperResult();
|
||||
string? resultPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var parsedArgs = ParseArgs(args);
|
||||
if (!parsedArgs.TryGetValue("source", out var sourcePath) ||
|
||||
!parsedArgs.TryGetValue("plugins-dir", out var pluginsDirectory) ||
|
||||
!parsedArgs.TryGetValue("result", out resultPath) ||
|
||||
string.IsNullOrWhiteSpace(sourcePath) ||
|
||||
string.IsNullOrWhiteSpace(pluginsDirectory) ||
|
||||
string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
throw new InvalidOperationException("Required arguments: --source <path> --plugins-dir <path> --result <path>.");
|
||||
}
|
||||
|
||||
var fullSourcePath = Path.GetFullPath(sourcePath);
|
||||
var fullPluginsDirectory = Path.GetFullPath(pluginsDirectory);
|
||||
resultPath = Path.GetFullPath(resultPath);
|
||||
|
||||
if (!File.Exists(fullSourcePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullSourcePath}' was not found.", fullSourcePath);
|
||||
}
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullSourcePath);
|
||||
Directory.CreateDirectory(fullPluginsDirectory);
|
||||
var destinationPath = Path.Combine(fullPluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
var stagingPath = destinationPath + ".incoming";
|
||||
DeleteFileWithRetry(stagingPath);
|
||||
CopyWithRetry(fullSourcePath, stagingPath, overwrite: true);
|
||||
RemoveExistingPluginPackages(fullPluginsDirectory, manifest.Id, destinationPath, stagingPath);
|
||||
MoveWithOverwriteRetry(stagingPath, destinationPath);
|
||||
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = true,
|
||||
InstalledPackagePath = destinationPath,
|
||||
ManifestId = manifest.Id,
|
||||
ManifestName = manifest.Name
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new HelperResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorMessage = ex.Message
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resultPath))
|
||||
{
|
||||
var resultDirectory = Path.GetDirectoryName(resultPath);
|
||||
if (!string.IsNullOrWhiteSpace(resultDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(resultDirectory);
|
||||
}
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
resultPath,
|
||||
JsonSerializer.Serialize(result, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true
|
||||
}),
|
||||
Encoding.UTF8);
|
||||
}
|
||||
|
||||
return result.Success ? 0 : 1;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> ParseArgs(string[] args)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
var current = args[i];
|
||||
if (!current.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = current[2..];
|
||||
if (string.IsNullOrWhiteSpace(key) || i + 1 >= args.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
values[key] = args[++i];
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static PluginManifest ReadManifestFromPackage(string packagePath)
|
||||
{
|
||||
using var archive = ZipFile.OpenRead(packagePath);
|
||||
var entries = archive.Entries
|
||||
.Where(entry => string.Equals(entry.Name, PluginSdkInfo.ManifestFileName, StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
|
||||
if (entries.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' does not contain '{PluginSdkInfo.ManifestFileName}'.");
|
||||
}
|
||||
|
||||
if (entries.Length > 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package '{packagePath}' contains multiple '{PluginSdkInfo.ManifestFileName}' files.");
|
||||
}
|
||||
|
||||
using var stream = entries[0].Open();
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private static void RemoveExistingPluginPackages(string pluginsDirectory, string pluginId, string destinationPath, string stagingPath)
|
||||
{
|
||||
var runtimeRootDirectory = EnsureTrailingSeparator(Path.Combine(Path.GetFullPath(pluginsDirectory), PluginSdkInfo.RuntimeDirectoryName));
|
||||
foreach (var existingPackagePath in Directory
|
||||
.EnumerateFiles(pluginsDirectory, "*" + PluginSdkInfo.PackageFileExtension, SearchOption.AllDirectories)
|
||||
.Select(Path.GetFullPath)
|
||||
.Where(path => !path.StartsWith(runtimeRootDirectory, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.Equals(existingPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(existingPackagePath, Path.GetFullPath(stagingPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
DeleteFileWithRetry(existingPackagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or malformed packages while replacing an install target.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyWithRetry(string sourcePath, string destinationPath, bool overwrite)
|
||||
{
|
||||
Retry(() => File.Copy(sourcePath, destinationPath, overwrite));
|
||||
}
|
||||
|
||||
private static void MoveWithOverwriteRetry(string sourcePath, string destinationPath)
|
||||
{
|
||||
Retry(() => File.Move(sourcePath, destinationPath, overwrite: true));
|
||||
}
|
||||
|
||||
private static void DeleteFileWithRetry(string filePath)
|
||||
{
|
||||
Retry(() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void Retry(Action action)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
Thread.Sleep(RetryDelays[attempt]);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildInstalledPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var fileName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return fileName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
private static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
private sealed class HelperResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
public string? ManifestId { get; init; }
|
||||
|
||||
public string? ManifestName { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{00000001-0000-0000-0000-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Project Path="LanAirApp/samples/LanMountainDesktop.SamplePlugin/LanMountainDesktop.SamplePlugin.csproj" />
|
||||
<Project Path="LanAirApp/tools/LanMountainDesktop.PluginPackager/LanMountainDesktop.PluginPackager.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginSdk/LanMountainDesktop.PluginSdk.csproj" />
|
||||
<Project Path="LanMountainDesktop.PluginsInstallHelper/LanMountainDesktop.PluginsInstallHelper.csproj" />
|
||||
<Project Path="LanMountainDesktop/LanMountainDesktop.csproj" />
|
||||
</Solution>
|
||||
@@ -15,7 +15,7 @@
|
||||
<Application.DataTemplates>
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
|
||||
@@ -1,21 +1,42 @@
|
||||
using Avalonia;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using LanMountainDesktop.Views;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly IHostApplicationLifecycle _hostApplicationLifecycle = new HostApplicationLifecycleService();
|
||||
private bool _exitCleanupCompleted;
|
||||
|
||||
private SettingsWindow? _traySettingsWindow;
|
||||
private TrayIcons? _trayIcons;
|
||||
private PluginRuntimeService? _pluginRuntimeService;
|
||||
|
||||
internal static SingleInstanceService? CurrentSingleInstanceService { get; set; }
|
||||
internal static IHostApplicationLifecycle? CurrentHostApplicationLifecycle =>
|
||||
(Current as App)?._hostApplicationLifecycle;
|
||||
|
||||
public PluginRuntimeService? PluginRuntimeService => _pluginRuntimeService;
|
||||
public IHostApplicationLifecycle HostApplicationLifecycle => _hostApplicationLifecycle;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AppLogger.Info("App", "Initializing application resources.");
|
||||
ConfigureWebViewUserDataFolder();
|
||||
AvaloniaWebViewBuilder.Initialize(default);
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -23,20 +44,86 @@ public partial class App : Application
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
AppLogger.Info("App", "Framework initialization completed.");
|
||||
LinuxDesktopEntryInstaller.EnsureInstalled();
|
||||
InitializePluginRuntime();
|
||||
AppSettingsService.SettingsSaved += OnAppSettingsSaved;
|
||||
InitializeTrayIcon();
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
desktop.ShutdownMode = Avalonia.Controls.ShutdownMode.OnExplicitShutdown;
|
||||
desktop.Exit += (_, _) =>
|
||||
{
|
||||
AppLogger.Info("App", "Desktop lifetime exit triggered.");
|
||||
PerformExitCleanup();
|
||||
};
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
AppLogger.Info("App", $"Main window created. LogFile={AppLogger.LogFilePath}");
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Exit App from the tray menu."));
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Restart App from the tray menu."));
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
@@ -71,9 +158,187 @@ public partial class App : Application
|
||||
userDataFolder,
|
||||
EnvironmentVariableTarget.Process);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep startup resilient if user profile folders are unavailable.
|
||||
AppLogger.Warn("WebView2", "Failed to configure WebView2 user data folder.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializePluginRuntime()
|
||||
{
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
_pluginRuntimeService = new PluginRuntimeService();
|
||||
_pluginRuntimeService.LoadInstalledPlugins();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PluginRuntime", "Failed to initialize plugin runtime.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeTrayIcon()
|
||||
{
|
||||
try
|
||||
{
|
||||
DisposeTrayIcon();
|
||||
|
||||
using var iconStream = AssetLoader.Open(new Uri("avares://LanMountainDesktop/Assets/avalonia-logo.ico"));
|
||||
var trayIcon = new TrayIcon
|
||||
{
|
||||
Icon = new WindowIcon(iconStream),
|
||||
ToolTipText = L("tray.tooltip", "LanMountainDesktop"),
|
||||
Menu = BuildTrayMenu(),
|
||||
IsVisible = true
|
||||
};
|
||||
|
||||
_trayIcons = [trayIcon];
|
||||
TrayIcon.SetIcons(this, _trayIcons);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("TrayIcon", "Failed to initialize tray icon.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private NativeMenu BuildTrayMenu()
|
||||
{
|
||||
var menu = new NativeMenu();
|
||||
|
||||
var settingsItem = new NativeMenuItem(L("tray.menu.settings", "设置"));
|
||||
settingsItem.Click += OnTraySettingsClick;
|
||||
menu.Items.Add(settingsItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var restartItem = new NativeMenuItem(L("tray.menu.restart", "重启应用"));
|
||||
restartItem.Click += OnTrayRestartClick;
|
||||
menu.Items.Add(restartItem);
|
||||
|
||||
menu.Items.Add(new NativeMenuItemSeparator());
|
||||
|
||||
var exitItem = new NativeMenuItem(L("tray.menu.exit", "退出应用"));
|
||||
exitItem.Click += OnTrayExitClick;
|
||||
menu.Items.Add(exitItem);
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
private void DisposeTrayIcon()
|
||||
{
|
||||
if (_trayIcons is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrayIcon.SetIcons(this, null);
|
||||
foreach (var trayIcon in _trayIcons)
|
||||
{
|
||||
trayIcon.Dispose();
|
||||
}
|
||||
|
||||
_trayIcons = null;
|
||||
}
|
||||
|
||||
private void ActivateMainWindow()
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (desktop.MainWindow is not Window mainWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!mainWindow.IsVisible)
|
||||
{
|
||||
mainWindow.Show();
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState == WindowState.Minimized)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.Normal;
|
||||
}
|
||||
|
||||
mainWindow.Activate();
|
||||
mainWindow.Topmost = true;
|
||||
mainWindow.Topmost = false;
|
||||
if (mainWindow is MainWindow lanMountainMainWindow)
|
||||
{
|
||||
lanMountainMainWindow.ShowSingleInstanceNotice();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Failed to activate the existing main window.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private void OnAppSettingsSaved(string _)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (_trayIcons is not null)
|
||||
{
|
||||
InitializeTrayIcon();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void PerformExitCleanup()
|
||||
{
|
||||
if (_exitCleanupCompleted)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_exitCleanupCompleted = true;
|
||||
AppSettingsService.SettingsSaved -= OnAppSettingsSaved;
|
||||
|
||||
try
|
||||
{
|
||||
_traySettingsWindow?.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("App", "Failed to close tray-opened settings window during shutdown.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_traySettingsWindow = null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_pluginRuntimeService?.Dispose();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("PluginRuntime", "Failed to dispose plugin runtime during shutdown.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pluginRuntimeService = null;
|
||||
}
|
||||
|
||||
AudioRecorderServiceFactory.DisposeSharedServices();
|
||||
StudyAnalyticsServiceFactory.DisposeSharedService();
|
||||
DisposeTrayIcon();
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
var languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode);
|
||||
return _localizationService.GetString(languageCode, key, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
# MiSans Font Notice
|
||||
# MiSans 字体说明
|
||||
|
||||
This app bundles MiSans fonts for consistent cross-device rendering.
|
||||
## 中文
|
||||
|
||||
## Included files
|
||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
||||
|
||||
### 包含文件
|
||||
|
||||
- `MiSans-Regular.ttf`
|
||||
- `MiSans-Semibold.ttf`
|
||||
- `MiSans-Bold.ttf`
|
||||
|
||||
## Source
|
||||
### 来源
|
||||
|
||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
||||
|
||||
### 许可与使用说明
|
||||
|
||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
||||
|
||||
## English
|
||||
|
||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
||||
|
||||
### Sources
|
||||
|
||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
||||
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
|
||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
||||
|
||||
## License and usage notes
|
||||
|
||||
- Script/package license in upstream repository: Apache-2.0
|
||||
- MiSans font copyright and additional usage terms:
|
||||
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
Please review and comply with the MiSans font terms when distributing this app.
|
||||
Please review and comply with the MiSans font terms before redistributing this application.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Weather Background Assets
|
||||
# 天气背景资源署名
|
||||
|
||||
Weather card background images are sourced from **Pexels** and used under the Pexels license:
|
||||
https://www.pexels.com/license/
|
||||
## 中文
|
||||
|
||||
## Sources
|
||||
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
### 原始来源
|
||||
|
||||
- `clear_sky.jpg`
|
||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
|
||||
- `storm.jpg`
|
||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||
|
||||
## Derived Variants (for widget scene mapping)
|
||||
### 派生资源
|
||||
|
||||
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
|
||||
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||
|
||||
- `clear_day.jpg` (from `clear_sky.jpg`)
|
||||
- `clear_night.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_day.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_night.jpg` (from `clear_sky.jpg`)
|
||||
- `rain_light.jpg` (from `rain.jpg`)
|
||||
- `rain_heavy.jpg` (from `rain.jpg`)
|
||||
- `storm_dark.jpg` (from `storm.jpg`)
|
||||
- `fog_haze.jpg` (from `storm.jpg`)
|
||||
- `snow_soft.jpg` (from `snow.jpg`)
|
||||
- `clear_day.jpg`
|
||||
- `clear_night.jpg`
|
||||
- `cloudy_day.jpg`
|
||||
- `cloudy_night.jpg`
|
||||
- `rain_light.jpg`
|
||||
- `rain_heavy.jpg`
|
||||
- `storm_dark.jpg`
|
||||
- `fog_haze.jpg`
|
||||
- `snow_soft.jpg`
|
||||
|
||||
## English
|
||||
|
||||
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
||||
# HyperOS3 天气资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||
|
||||
### 提取来源
|
||||
|
||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2` (Mi Weather)
|
||||
- Extraction date: 2026-03-03
|
||||
- Package: `com.miui.weather2`
|
||||
- Extraction date: `2026-03-03`
|
||||
|
||||
Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
||||
### 用途说明
|
||||
|
||||
Extracted weather icon paths inside APK (`res/*.webp`):
|
||||
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
|
||||
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
|
||||
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
|
||||
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
|
||||
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
|
||||
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
|
||||
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
|
||||
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
|
||||
- `res/mH1.webp` -> `Icons/icon_snow.webp`
|
||||
- `res/jB.webp` -> `Icons/icon_sleet.webp`
|
||||
- `res/Wl.webp` -> `Icons/icon_haze.webp`
|
||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
||||
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||
- 使用时应遵守小米相关许可与使用条款。
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
### 额外派生资源
|
||||
|
||||
## Soft Widget Icon Set (2026-03-05)
|
||||
|
||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
||||
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
||||
## English
|
||||
|
||||
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||
|
||||
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||
|
||||
@@ -30,8 +30,13 @@ public static class BuiltInComponentIds
|
||||
public const string DesktopDailyPoetry = "DesktopDailyPoetry";
|
||||
public const string DesktopDailyArtwork = "DesktopDailyArtwork";
|
||||
public const string DesktopDailyWord = "DesktopDailyWord";
|
||||
public const string DesktopDailySentence = "DesktopDailySentence";
|
||||
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";
|
||||
|
||||
@@ -235,11 +235,11 @@ public sealed class ComponentRegistry
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
new DesktopComponentDefinition(
|
||||
BuiltInComponentIds.DesktopDailySentence,
|
||||
"Daily Sentence",
|
||||
"TextQuote",
|
||||
BuiltInComponentIds.DesktopDailyWord2x2,
|
||||
"Daily Word 2x2",
|
||||
"Book",
|
||||
"Info",
|
||||
MinWidthCells: 4,
|
||||
MinWidthCells: 2,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true),
|
||||
@@ -251,8 +251,52 @@ public sealed class ComponentRegistry
|
||||
MinWidthCells: 4,
|
||||
MinHeightCells: 2,
|
||||
AllowStatusBarPlacement: false,
|
||||
AllowDesktopPlacement: true,
|
||||
ResizeMode: DesktopComponentResizeMode.Free),
|
||||
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",
|
||||
@@ -341,6 +385,13 @@ public sealed class ComponentRegistry
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public ComponentRegistry RegisterComponents(IEnumerable<DesktopComponentDefinition> definitions)
|
||||
{
|
||||
var merged = _definitions.Values.ToList();
|
||||
merged.AddRange(definitions);
|
||||
return new ComponentRegistry(merged);
|
||||
}
|
||||
|
||||
public bool TryGetDefinition(string componentId, out DesktopComponentDefinition definition)
|
||||
{
|
||||
return _definitions.TryGetValue(componentId, out definition!);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public sealed record DesktopComponentRuntimeContext(
|
||||
string ComponentId,
|
||||
string? PlacementId,
|
||||
IComponentInstanceSettingsStore ComponentSettingsStore);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentPlacementContextAware
|
||||
{
|
||||
void SetComponentPlacementContext(string componentId, string? placementId);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentRuntimeContextAware
|
||||
{
|
||||
void SetComponentRuntimeContext(DesktopComponentRuntimeContext context);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.ComponentSystem;
|
||||
|
||||
public interface IComponentSettingsStoreAware
|
||||
{
|
||||
void SetComponentSettingsStore(IComponentInstanceSettingsStore settingsStore);
|
||||
}
|
||||
@@ -1,77 +1,38 @@
|
||||
# 组件系统模块(Component System Module)
|
||||
# 组件系统说明
|
||||
|
||||
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
|
||||
This directory provides the modular foundation for built-in component management and third-party extension integration.
|
||||
## 中文
|
||||
|
||||
## 核心文件职责(Core Files)
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
|
||||
Built-in component ID constants (for example `Clock`).
|
||||
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
|
||||
Component metadata model (name, category, minimum size, placement permissions).
|
||||
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
|
||||
Placement rules (minimum size, status-bar height rule, grid clamping).
|
||||
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
|
||||
Registry that merges built-in and extension components.
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
|
||||
Extension provider interface contract.
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
|
||||
JSON-based extension loader.
|
||||
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
|
||||
|
||||
## 第三方扩展契约(Extension Contract)
|
||||
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
|
||||
Third parties can provide component definitions via `IComponentExtensionProvider`.
|
||||
- 当前内置了 JSON 提供者,运行时扫描目录:
|
||||
Built-in JSON provider scans at runtime:
|
||||
- `Extensions/Components/*.json`(相对应用输出目录)
|
||||
`Extensions/Components/*.json` (relative to app output directory)
|
||||
### 主要职责
|
||||
|
||||
## 加载流程(Load Flow)
|
||||
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
|
||||
Register built-in components first via `ComponentRegistry.CreateDefault()`.
|
||||
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
|
||||
Merge extension components via `.RegisterExtensions(...)`.
|
||||
3. 主窗口通过注册中心校验组件合法性与放置权限。
|
||||
Main window validates component identity and placement permission through the registry.
|
||||
- 管理内置组件 ID 和元数据
|
||||
- 约束组件最小尺寸与可放置区域
|
||||
- 合并内置组件与扩展组件
|
||||
- 通过 JSON 或扩展提供者接入第三方组件
|
||||
|
||||
## JSON 清单格式(Manifest Schema)
|
||||
JSON 文件为数组,每一项代表一个组件定义。
|
||||
The JSON file is an array, where each item represents one component definition.
|
||||
### 关键文件
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "Weather",
|
||||
"displayName": "Weather",
|
||||
"iconKey": "WeatherSunny",
|
||||
"category": "Status",
|
||||
"minWidthCells": 1,
|
||||
"minHeightCells": 1,
|
||||
"allowStatusBarPlacement": true,
|
||||
"allowDesktopPlacement": true
|
||||
}
|
||||
]
|
||||
```
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量
|
||||
- `DesktopComponentDefinition.cs`:组件元数据模型
|
||||
- `ComponentPlacementRules.cs`:放置规则
|
||||
- `ComponentRegistry.cs`:组件注册中心
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:JSON 扩展加载器
|
||||
|
||||
字段说明(Field notes):
|
||||
- `id`:组件唯一 ID(建议英文、稳定不变)。
|
||||
Unique component ID (prefer stable English key).
|
||||
- `displayName`:显示名。
|
||||
Display name.
|
||||
- `iconKey`:图标键(由上层 UI 解释)。
|
||||
Icon key resolved by UI layer.
|
||||
- `category`:组件分类。
|
||||
Component category.
|
||||
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`。
|
||||
Minimum cell size, must satisfy `>= 1`.
|
||||
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
|
||||
Whether placing in top status bar is allowed.
|
||||
- `allowDesktopPlacement`:是否允许放到桌面区域。
|
||||
Whether placing in desktop area is allowed.
|
||||
### 扩展方式
|
||||
|
||||
## 放置规则摘要(Placement Rules Summary)
|
||||
- 最小尺寸约束:`minWidthCells >= 1` 且 `minHeightCells >= 1`。
|
||||
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
|
||||
- 状态栏约束:状态栏组件高度必须为 `1` 格。
|
||||
Status bar constraint: component height must be exactly `1` cell.
|
||||
- 越界约束:所有组件坐标会被网格边界钳制(clamp)。
|
||||
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
|
||||
- 当前默认扫描 `Extensions/Components/*.json`
|
||||
- 组件清单定义显示名、分类、最小尺寸和可放置区域
|
||||
- 主程序通过注册中心统一验证组件是否合法
|
||||
|
||||
## English
|
||||
|
||||
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- manage built-in component IDs and metadata
|
||||
- enforce placement rules
|
||||
- merge built-in and extension components
|
||||
- support third-party registration through JSON or provider contracts
|
||||
|
||||
@@ -24,6 +24,12 @@
|
||||
<None Include="Extensions\Components\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.12" />
|
||||
@@ -36,6 +42,7 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.1" />
|
||||
<PackageReference Include="DotNetCampus.AvaloniaInkCanvas" Version="1.0.1" />
|
||||
<PackageReference Include="Downloader" Version="4.1.1" />
|
||||
<PackageReference Include="FluentAvaloniaUI" Version="2.5.0" />
|
||||
<PackageReference Include="FluentIcons.Avalonia" Version="2.0.319" />
|
||||
<PackageReference Include="FluentIcons.Avalonia.Fluent" Version="2.0.319" />
|
||||
@@ -51,4 +58,22 @@
|
||||
<PackageReference Include="WebView.Avalonia.Desktop" Version="11.0.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToOutput" AfterTargets="Build">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperFiles)"
|
||||
DestinationFiles="@(PluginsInstallHelperFiles->'$(OutDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
|
||||
<Target Name="CopyPluginsInstallHelperToPublish" AfterTargets="Publish" Condition="'$(PublishDir)' != ''">
|
||||
<ItemGroup>
|
||||
<PluginsInstallHelperPublishFiles Include="..\LanMountainDesktop.PluginsInstallHelper\bin\$(Configuration)\net10.0\**\*.*" />
|
||||
</ItemGroup>
|
||||
<Copy SourceFiles="@(PluginsInstallHelperPublishFiles)"
|
||||
DestinationFiles="@(PluginsInstallHelperPublishFiles->'$(PublishDir)PluginsInstallHelper\%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -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",
|
||||
@@ -227,6 +251,7 @@
|
||||
"settings.update.status_launching_installer": "Download complete. Launching installer...",
|
||||
"settings.update.status_installer_missing": "Installer file was not found after download.",
|
||||
"settings.update.status_installer_started": "Installer started. The app will close for update.",
|
||||
"settings.update.status_elevation_cancelled": "Administrator permission was not granted. Update was cancelled.",
|
||||
"settings.update.status_launch_failed_format": "Failed to start installer: {0}",
|
||||
"settings.about.title": "About",
|
||||
"settings.about.version_format": "Version: {0}",
|
||||
@@ -235,6 +260,25 @@
|
||||
"settings.about.startup_header": "Windows Startup",
|
||||
"settings.about.startup_desc": "Launch the app automatically when signing in to Windows.",
|
||||
"settings.about.startup_toggle": "Launch at Windows sign-in",
|
||||
"settings.about.render_mode_header": "App Rendering Mode",
|
||||
"settings.about.render_mode_desc": "Choose the rendering backend. Restart the app after changing this option. Unsupported modes fall back to software.",
|
||||
"settings.about.render_mode.default": "Default",
|
||||
"settings.about.render_mode.software": "Software",
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "Unknown",
|
||||
"settings.about.render_mode.current_label": "Current actual backend",
|
||||
"settings.about.render_mode.current_format": "Current backend: {0}",
|
||||
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
|
||||
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
|
||||
"settings.restart_dialog.title": "Restart required",
|
||||
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
||||
"settings.restart_dialog.restart": "Restart now",
|
||||
"settings.restart_dialog.cancel": "Cancel",
|
||||
"settings.restart_dock.title": "Restart required",
|
||||
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
|
||||
"settings.restart_dock.button": "Restart app",
|
||||
"settings.footer": "LanMountainDesktop Settings",
|
||||
"filepicker.title": "Select wallpaper",
|
||||
"filepicker.image_files": "Image files",
|
||||
@@ -249,9 +293,112 @@
|
||||
"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": "Unhide",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||
"settings.plugins.installed_header": "Installed Plugins",
|
||||
"settings.plugins.installed_desc": "Review installed plugins and remove them here.",
|
||||
"settings.plugins.import_header": "Install From Package",
|
||||
"settings.plugins.import_desc": "Open a .laapp package and stage it into the local plugin directory.",
|
||||
"settings.plugins.restart_hint": "Plugin installation and deletion changes take effect after restarting the app.",
|
||||
"settings.plugins.empty": "No plugins found.",
|
||||
"settings.plugins.runtime_unavailable": "Plugin runtime is not available.",
|
||||
"settings.plugins.summary_format": "Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "Enabled",
|
||||
"settings.plugins.state.enabled_failed": "Enabled / failed to load",
|
||||
"settings.plugins.state.disabled": "Disabled",
|
||||
"settings.plugins.state.loaded": "Loaded",
|
||||
"settings.plugins.state.load_failed": "Load failed",
|
||||
"settings.plugins.toggle_on": "Enabled",
|
||||
"settings.plugins.toggle_off": "Disabled",
|
||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
"settings.plugins.toggle_state_enabled": "enabled",
|
||||
"settings.plugins.toggle_state_disabled": "disabled",
|
||||
"settings.plugins.toggle_failed_detail_format": "Failed to update plugin '{0}': {1}",
|
||||
"settings.plugins.install_button": "Open .laapp package",
|
||||
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
||||
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||
"settings.plugins.install_picker_title": "Select plugin package",
|
||||
"settings.plugins.install_file_type": ".laapp plugin package",
|
||||
"settings.plugins.install_picker_unavailable": "Storage provider is unavailable.",
|
||||
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
||||
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
||||
"settings.plugins.delete_button": "Delete plugin",
|
||||
"settings.plugins.delete_success_format": "Plugin '{0}' was staged for deletion. Restart the app to finish removing it.",
|
||||
"settings.plugins.delete_failed_format": "Failed to delete plugin: {0}",
|
||||
"settings.plugins.delete_failed_detail_format": "Failed to delete plugin '{0}': {1}",
|
||||
"settings.plugins.publisher_format": "Publisher: {0}",
|
||||
"settings.plugins.publisher_unknown": "Unknown publisher",
|
||||
"settings.plugins.source_package": ".laapp package",
|
||||
"settings.plugins.source_manifest": "Loose manifest",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||
"settings.nav.plugin_market": "Plugin Market",
|
||||
"settings.plugin_market.title": "Plugin Market",
|
||||
"settings.plugin_market.subtitle": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugin_market.unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||
"market.toolbar.search_placeholder": "Search plugins",
|
||||
"market.toolbar.refresh": "Refresh",
|
||||
"market.status.loading": "Loading the official plugin market...",
|
||||
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
|
||||
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
|
||||
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
|
||||
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
|
||||
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
|
||||
"market.status.install_failed_format": "Failed to install plugin: {0}",
|
||||
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
|
||||
"market.list.empty": "The plugin market has not been loaded yet.",
|
||||
"market.list.no_results": "No plugins match the current search.",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "Loaded",
|
||||
"market.card.pending_restart": "Restart required",
|
||||
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
||||
"market.detail.author": "Author",
|
||||
"market.detail.version": "Version",
|
||||
"market.detail.api_version": "API Version",
|
||||
"market.detail.min_host_version": "Minimum Host Version",
|
||||
"market.detail.installed_version": "Installed Version",
|
||||
"market.detail.not_installed": "Not installed",
|
||||
"market.detail.readme": "README",
|
||||
"market.detail.plugin_information": "Plugin Information",
|
||||
"market.detail.author_subtitle_format": "By {0}",
|
||||
"market.detail.package_size": "Package Size",
|
||||
"market.detail.published_at": "Published At",
|
||||
"market.detail.updated_at": "Updated At",
|
||||
"market.detail.tags": "Tags",
|
||||
"market.detail.project": "Project",
|
||||
"market.detail.state": "Install State",
|
||||
"market.detail.market_source": "Market Source",
|
||||
"market.detail.homepage": "Homepage",
|
||||
"market.detail.repository": "Repository",
|
||||
"market.detail.release_notes": "Release Notes",
|
||||
"market.detail.state.not_installed": "Not installed",
|
||||
"market.detail.state.update_available": "Update available",
|
||||
"market.detail.state.installed": "Installed",
|
||||
"market.detail.unknown": "Unknown",
|
||||
"market.button.install": "Install",
|
||||
"market.button.update": "Update",
|
||||
"market.button.installed": "Installed",
|
||||
"market.button.installing": "Installing...",
|
||||
"button.component_library": "Edit Desktop",
|
||||
"tooltip.component_library": "Edit Desktop",
|
||||
"component_library.title": "Widgets",
|
||||
@@ -265,6 +412,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,8 +431,13 @@
|
||||
"component.daily_poetry": "Daily Poetry",
|
||||
"component.daily_artwork": "Daily Artwork",
|
||||
"component.daily_word": "Daily Word",
|
||||
"component.daily_sentence": "English Sentence",
|
||||
"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",
|
||||
@@ -329,14 +482,7 @@
|
||||
"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.",
|
||||
"dailysentence.widget.loading": "Loading...",
|
||||
"dailysentence.widget.loading_sentence": "Fetching daily sentence...",
|
||||
"dailysentence.widget.loading_translation": "Fetching translation...",
|
||||
"dailysentence.widget.loading_source": "Youdao Dictionary",
|
||||
"dailysentence.widget.fetch_failed": "Sentence fetch failed",
|
||||
"dailysentence.widget.fallback_sentence": "Daily sentence is temporarily unavailable.",
|
||||
"dailysentence.widget.fallback_translation": "Tap refresh and try again.",
|
||||
"dailysentence.widget.source_default": "Youdao Dictionary",
|
||||
"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",
|
||||
@@ -344,6 +490,114 @@
|
||||
"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",
|
||||
@@ -489,5 +743,10 @@
|
||||
"placement.fit": "Fit",
|
||||
"placement.stretch": "Stretch",
|
||||
"placement.center": "Center",
|
||||
"placement.tile": "Tile"
|
||||
}
|
||||
"placement.tile": "Tile",
|
||||
"single_instance.notice.title": "App already open",
|
||||
"single_instance.notice.description": "LanMountainDesktop is already running. Switched back to the active desktop.",
|
||||
"single_instance.notice.button": "Got it"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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": "例如:北京",
|
||||
@@ -227,6 +251,7 @@
|
||||
"settings.update.status_launching_installer": "下载完成,正在启动安装程序...",
|
||||
"settings.update.status_installer_missing": "下载后未找到安装包文件。",
|
||||
"settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。",
|
||||
"settings.update.status_elevation_cancelled": "未授予管理员权限,更新已取消。",
|
||||
"settings.update.status_launch_failed_format": "启动安装程序失败:{0}",
|
||||
"settings.about.title": "关于",
|
||||
"settings.about.version_format": "版本号: {0}",
|
||||
@@ -235,6 +260,25 @@
|
||||
"settings.about.startup_header": "Windows 自启动",
|
||||
"settings.about.startup_desc": "在登录 Windows 时自动启动应用。",
|
||||
"settings.about.startup_toggle": "登录 Windows 时启动",
|
||||
"settings.about.render_mode_header": "应用渲染模式",
|
||||
"settings.about.render_mode_desc": "选择应用渲染后端。更改后需要重启应用生效。不支持的模式会回退到软件渲染。",
|
||||
"settings.about.render_mode.default": "默认",
|
||||
"settings.about.render_mode.software": "软件",
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "未知",
|
||||
"settings.about.render_mode.current_label": "当前实际渲染后端",
|
||||
"settings.about.render_mode.current_format": "当前后端:{0}",
|
||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||
"settings.restart_dialog.title": "需要重启应用",
|
||||
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
|
||||
"settings.restart_dialog.restart": "立即重启",
|
||||
"settings.restart_dialog.cancel": "取消",
|
||||
"settings.restart_dock.title": "需要重启应用",
|
||||
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
|
||||
"settings.restart_dock.button": "重启应用",
|
||||
"settings.footer": "LanMountainDesktop 设置",
|
||||
"filepicker.title": "选择壁纸",
|
||||
"filepicker.image_files": "图片文件",
|
||||
@@ -249,9 +293,112 @@
|
||||
"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.import_header": "从安装包导入",
|
||||
"settings.plugins.import_desc": "打开一个 .laapp 插件包,并将其暂存到本地插件目录。",
|
||||
"settings.plugins.restart_hint": "插件安装和删除变更会在重启应用后生效。",
|
||||
"settings.plugins.empty": "未找到插件。",
|
||||
"settings.plugins.runtime_unavailable": "插件运行时不可用。",
|
||||
"settings.plugins.summary_format": "共检测到 {0} 个插件;已启用 {1} 个;已加载 {2} 个;设置页 {3} 个;组件 {4} 个;失败 {5} 个。",
|
||||
"settings.plugins.summary_item_format": "{0} v{1} | {2}",
|
||||
"settings.plugins.state.enabled": "已启用",
|
||||
"settings.plugins.state.enabled_failed": "已启用 / 加载失败",
|
||||
"settings.plugins.state.disabled": "已禁用",
|
||||
"settings.plugins.state.loaded": "已加载",
|
||||
"settings.plugins.state.load_failed": "加载失败",
|
||||
"settings.plugins.toggle_on": "启用",
|
||||
"settings.plugins.toggle_off": "禁用",
|
||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||
"settings.plugins.toggle_state_enabled": "启用",
|
||||
"settings.plugins.toggle_state_disabled": "禁用",
|
||||
"settings.plugins.toggle_failed_detail_format": "更新插件“{0}”状态失败:{1}",
|
||||
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||
"settings.plugins.install_picker_title": "选择插件安装包",
|
||||
"settings.plugins.install_file_type": ".laapp 插件包",
|
||||
"settings.plugins.install_picker_unavailable": "文件存储提供程序不可用。",
|
||||
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
||||
"settings.plugins.delete_button": "删除插件",
|
||||
"settings.plugins.delete_success_format": "插件“{0}”已暂存删除。重启应用后会完成移除。",
|
||||
"settings.plugins.delete_failed_format": "删除插件失败:{0}",
|
||||
"settings.plugins.delete_failed_detail_format": "删除插件“{0}”失败:{1}",
|
||||
"settings.plugins.publisher_format": "发布者:{0}",
|
||||
"settings.plugins.publisher_unknown": "未知发布者",
|
||||
"settings.plugins.source_package": ".laapp 包",
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"settings.nav.plugin_market": "插件市场",
|
||||
"settings.plugin_market.title": "插件市场",
|
||||
"settings.plugin_market.subtitle": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugin_market.unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
"market.status.loading": "正在加载官方插件市场...",
|
||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||
"market.list.empty": "插件市场尚未加载。",
|
||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "已加载",
|
||||
"market.card.pending_restart": "需要重启",
|
||||
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
||||
"market.detail.author": "作者",
|
||||
"market.detail.version": "版本",
|
||||
"market.detail.api_version": "API 版本",
|
||||
"market.detail.min_host_version": "最低宿主版本",
|
||||
"market.detail.installed_version": "已安装版本",
|
||||
"market.detail.not_installed": "未安装",
|
||||
"market.detail.readme": "README",
|
||||
"market.detail.plugin_information": "插件信息",
|
||||
"market.detail.author_subtitle_format": "作者:{0}",
|
||||
"market.detail.package_size": "包大小",
|
||||
"market.detail.published_at": "首次发布",
|
||||
"market.detail.updated_at": "最近更新",
|
||||
"market.detail.tags": "标签",
|
||||
"market.detail.project": "项目",
|
||||
"market.detail.state": "安装状态",
|
||||
"market.detail.market_source": "市场源",
|
||||
"market.detail.homepage": "主页",
|
||||
"market.detail.repository": "仓库",
|
||||
"market.detail.release_notes": "发布说明",
|
||||
"market.detail.state.not_installed": "未安装",
|
||||
"market.detail.state.update_available": "可更新",
|
||||
"market.detail.state.installed": "已安装",
|
||||
"market.detail.unknown": "未知",
|
||||
"market.button.install": "安装",
|
||||
"market.button.update": "更新",
|
||||
"market.button.installed": "已安装",
|
||||
"market.button.installing": "安装中...",
|
||||
"button.component_library": "桌面编辑",
|
||||
"tooltip.component_library": "桌面编辑",
|
||||
"component_library.title": "桌面编辑",
|
||||
@@ -265,6 +412,7 @@
|
||||
"component_category.board": "白板",
|
||||
"component_category.media": "媒体",
|
||||
"component_category.info": "信息推荐",
|
||||
"component_category.calculator": "计算器",
|
||||
"component_category.study": "自习",
|
||||
"component.date": "日历",
|
||||
"component.month_calendar": "月历",
|
||||
@@ -283,8 +431,13 @@
|
||||
"component.daily_poetry": "每日诗词",
|
||||
"component.daily_artwork": "每日名画",
|
||||
"component.daily_word": "每日单词",
|
||||
"component.daily_sentence": "英语句子",
|
||||
"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": "浏览器",
|
||||
@@ -329,14 +482,7 @@
|
||||
"dailyword.widget.fallback_meaning": "有道词典暂不可用",
|
||||
"dailyword.widget.fallback_example": "请点击右上角刷新重试",
|
||||
"dailyword.widget.fallback_example_translation": "网络恢复后将自动更新",
|
||||
"dailysentence.widget.loading": "加载中...",
|
||||
"dailysentence.widget.loading_sentence": "正在获取英语句子",
|
||||
"dailysentence.widget.loading_translation": "正在获取句子译文",
|
||||
"dailysentence.widget.loading_source": "有道词典",
|
||||
"dailysentence.widget.fetch_failed": "英语句子获取失败",
|
||||
"dailysentence.widget.fallback_sentence": "今日英语句子暂不可用",
|
||||
"dailysentence.widget.fallback_translation": "请点击右上角刷新重试",
|
||||
"dailysentence.widget.source_default": "有道词典",
|
||||
"dailyword2x2.widget.tap_to_show": "点击查看释义",
|
||||
"cnrnews.widget.loading": "加载中...",
|
||||
"cnrnews.widget.loading_title": "正在获取新闻热点",
|
||||
"cnrnews.widget.loading_subtitle": "请稍候",
|
||||
@@ -344,6 +490,114 @@
|
||||
"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": "镜像源",
|
||||
@@ -489,5 +743,10 @@
|
||||
"placement.fit": "适应",
|
||||
"placement.stretch": "拉伸",
|
||||
"placement.center": "居中",
|
||||
"placement.tile": "平铺"
|
||||
}
|
||||
"placement.tile": "平铺",
|
||||
"single_instance.notice.title": "应用已打开",
|
||||
"single_instance.notice.description": "阑山桌面已经在运行,已为你切换到当前正在使用的桌面。",
|
||||
"single_instance.notice.button": "知道了"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal file
19
LanMountainDesktop/Models/BaiduHotSearchSourceTypes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
95
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal file
95
LanMountainDesktop/Models/ComponentSettingsSnapshot.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
43
LanMountainDesktop/Models/DesktopLayoutSettingsSnapshot.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot> DesktopComponentPlacements { get; set; } = [];
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (DesktopLayoutSettingsSnapshot)MemberwiseClone();
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(DesktopComponentPlacements?.Count ?? 0);
|
||||
|
||||
if (DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId,
|
||||
PageIndex = placement.PageIndex,
|
||||
ComponentId = placement.ComponentId,
|
||||
Row = placement.Row,
|
||||
Column = placement.Column,
|
||||
WidthCells = placement.WidthCells,
|
||||
HeightCells = placement.HeightCells
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
clone.DesktopComponentPlacements = placements;
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal file
32
LanMountainDesktop/Models/IfengNewsChannelTypes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
22
LanMountainDesktop/Models/LauncherSettingsSnapshot.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Models;
|
||||
|
||||
public sealed class LauncherSettingsSnapshot
|
||||
{
|
||||
public List<string> HiddenLauncherFolderPaths { get; set; } = [];
|
||||
|
||||
public List<string> HiddenLauncherAppPaths { get; set; } = [];
|
||||
|
||||
public LauncherSettingsSnapshot Clone()
|
||||
{
|
||||
var clone = (LauncherSettingsSnapshot)MemberwiseClone();
|
||||
clone.HiddenLauncherFolderPaths = HiddenLauncherFolderPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherFolderPaths)
|
||||
: [];
|
||||
clone.HiddenLauncherAppPaths = HiddenLauncherAppPaths is { Count: > 0 }
|
||||
? new List<string>(HiddenLauncherAppPaths)
|
||||
: [];
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal file
74
LanMountainDesktop/Models/RefreshIntervalCatalog.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal file
42
LanMountainDesktop/Models/Stcn24ForumSourceTypes.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -7,5 +7,6 @@ public enum TaskbarActionId
|
||||
AddDesktopPage,
|
||||
DeleteDesktopPage,
|
||||
DeleteComponent,
|
||||
EditComponent
|
||||
EditComponent,
|
||||
HideLauncherEntry
|
||||
}
|
||||
|
||||
@@ -1,75 +1,51 @@
|
||||
# Desktop Packaging Guide
|
||||
# 桌面端打包指南
|
||||
|
||||
## Prerequisites
|
||||
- Install `.NET SDK 10`
|
||||
- Windows installer build only:
|
||||
- Install `Inno Setup 6` (`ISCC.exe`)
|
||||
## 中文
|
||||
|
||||
## Local packaging commands
|
||||
本指南说明阑山桌面的本地打包和 CI 打包流程。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 安装 .NET SDK 10
|
||||
- Windows 安装包需要 Inno Setup 6(`ISCC.exe`)
|
||||
|
||||
### 本地打包命令
|
||||
|
||||
#### Windows 安装包
|
||||
|
||||
### Windows installer (`win-x64`)
|
||||
```powershell
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/win-x64`
|
||||
- Installer: `artifacts/installer`
|
||||
#### Linux 包
|
||||
|
||||
### Linux package (`linux-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/linux-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-linux-x64.zip`
|
||||
#### macOS 包
|
||||
|
||||
### macOS package (`osx-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/osx-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-osx-x64.zip`
|
||||
### 产物位置
|
||||
|
||||
## Optional script flags
|
||||
```powershell
|
||||
# Publish only (skip Windows installer step)
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
|
||||
- 发布目录:`artifacts/publish/<rid>`
|
||||
- 安装包或压缩包:`artifacts/installer` 或 `artifacts/packages`
|
||||
|
||||
# Publish only (skip Linux/macOS zip package step)
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
|
||||
```
|
||||
### CI 流程
|
||||
|
||||
## Runtime dependency notes
|
||||
- Linux build does not bundle a native `libvlc` package from NuGet.
|
||||
- Install VLC runtime on target machine, for example:
|
||||
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
|
||||
- macOS packaging target in CI is currently `osx-x64`.
|
||||
- 工作流文件:`.github/workflows/windows-ci.yml`
|
||||
- 日常构建会验证桌面端可编译
|
||||
- 手动触发或 `v*` 标签可生成正式包并上传到 Release
|
||||
|
||||
## CI workflow
|
||||
- Workflow file: `.github/workflows/windows-ci.yml`
|
||||
- Workflow name: `Desktop CI`
|
||||
## English
|
||||
|
||||
Jobs:
|
||||
- `Validate Build (Windows)` runs on every push and pull request.
|
||||
- Package flow runs on manual trigger or `v*` tag push:
|
||||
- `Resolve Package Version` (single shared version source)
|
||||
- `Package (Windows)` (`win-x64` installer)
|
||||
- `Package (Linux)` (`linux-x64` zip)
|
||||
- `Package (macOS)` (`osx-x64` zip)
|
||||
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
|
||||
This guide covers local packaging and CI packaging for LanMountainDesktop.
|
||||
|
||||
### Trigger manual packaging
|
||||
1. Open GitHub Actions.
|
||||
2. Choose `Desktop CI`.
|
||||
3. Click `Run workflow`.
|
||||
4. Optional: set `version` input, for example `1.0.1`.
|
||||
### Key points
|
||||
|
||||
### Trigger by tag
|
||||
```powershell
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
```
|
||||
- use `scripts/package.ps1` with the target runtime identifier
|
||||
- Windows installer requires Inno Setup
|
||||
- CI can publish artifacts and attach them to GitHub Releases
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -10,14 +14,148 @@ sealed class Program
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
RegisterGlobalExceptionLogging();
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(args);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
{
|
||||
AppLogger.Warn("Startup", "A secondary launch was blocked because another instance is already running.");
|
||||
_ = singleInstance.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2));
|
||||
return;
|
||||
}
|
||||
|
||||
var diagnostics = StartupDiagnosticsService.Run(args);
|
||||
StartupDiagnosticsService.ShowLegacyExecutableWarningIfNeeded(diagnostics);
|
||||
|
||||
try
|
||||
{
|
||||
var renderMode = LoadConfiguredRenderMode();
|
||||
AppLogger.Info("Startup", $"Resolved render mode '{renderMode}'.");
|
||||
App.CurrentSingleInstanceService = singleInstance;
|
||||
BuildAvaloniaApp(renderMode).StartWithClassicDesktopLifetime(args);
|
||||
AppLogger.Info("Startup", "Application exited normally.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Critical("Startup", "Application terminated during startup.", ex);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
App.CurrentSingleInstanceService = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
public static AppBuilder BuildAvaloniaApp(string renderMode = AppRenderingModeHelper.Default)
|
||||
{
|
||||
var builder = AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.UseDesktopWebView()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
var configuredModes = AppRenderingModeHelper.GetWin32RenderingModes(renderMode);
|
||||
if (configuredModes is { Length: > 0 })
|
||||
{
|
||||
builder = builder.With(new Win32PlatformOptions
|
||||
{
|
||||
RenderingMode = configuredModes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static SingleInstanceService AcquireSingleInstance(string[] args)
|
||||
{
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
var singleInstance = SingleInstanceService.CreateDefault();
|
||||
if (singleInstance.IsPrimaryInstance || restartParentProcessId is null)
|
||||
{
|
||||
return singleInstance;
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Restart relaunch detected. Waiting for previous instance pid={restartParentProcessId.Value} to exit before re-acquiring the single-instance lock.");
|
||||
singleInstance.Dispose();
|
||||
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(12);
|
||||
WaitForRestartParentExit(restartParentProcessId.Value, deadline);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
var retryInstance = SingleInstanceService.CreateDefault();
|
||||
if (retryInstance.IsPrimaryInstance)
|
||||
{
|
||||
AppLogger.Info("Startup", "Restart relaunch acquired the single-instance lock.");
|
||||
return retryInstance;
|
||||
}
|
||||
|
||||
retryInstance.Dispose();
|
||||
Thread.Sleep(150);
|
||||
}
|
||||
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch timed out while waiting for the single-instance lock. pid={restartParentProcessId.Value}.");
|
||||
return SingleInstanceService.CreateDefault();
|
||||
}
|
||||
|
||||
private static string LoadConfiguredRenderMode()
|
||||
{
|
||||
try
|
||||
{
|
||||
return AppRenderingModeHelper.Normalize(new AppSettingsService().Load().AppRenderMode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", "Failed to load configured render mode. Falling back to default.", ex);
|
||||
return AppRenderingModeHelper.Default;
|
||||
}
|
||||
}
|
||||
|
||||
private static void WaitForRestartParentExit(int processId, DateTime deadlineUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = Process.GetProcessById(processId);
|
||||
var remaining = deadlineUtc - DateTime.UtcNow;
|
||||
if (remaining > TimeSpan.Zero)
|
||||
{
|
||||
process.WaitForExit((int)Math.Ceiling(remaining.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// The previous process already exited before we started waiting.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("Startup", $"Failed while waiting for restart parent pid={processId} to exit.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterGlobalExceptionLogging()
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
|
||||
{
|
||||
AppLogger.Critical(
|
||||
"UnhandledException",
|
||||
$"Unhandled exception. IsTerminating={eventArgs.IsTerminating}",
|
||||
eventArgs.ExceptionObject as Exception);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, eventArgs) =>
|
||||
{
|
||||
AppLogger.Error("TaskScheduler", "Unobserved task exception.", eventArgs.Exception);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
169
LanMountainDesktop/Services/AppLogger.cs
Normal file
169
LanMountainDesktop/Services/AppLogger.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppLogger
|
||||
{
|
||||
private static readonly object SyncRoot = new();
|
||||
private static bool _initialized;
|
||||
private static string _logDirectory = string.Empty;
|
||||
private static string _logFilePath = string.Empty;
|
||||
|
||||
public static string LogDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _logDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
public static string LogFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureInitialized();
|
||||
return _logFilePath;
|
||||
}
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var preferredDirectory = Path.Combine(AppContext.BaseDirectory, "log");
|
||||
var fallbackDirectory = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop",
|
||||
"log");
|
||||
|
||||
var preferredReady = TryPrepareDirectory(preferredDirectory, out var preferredError);
|
||||
var fallbackReady = false;
|
||||
string? fallbackError = null;
|
||||
|
||||
if (preferredReady)
|
||||
{
|
||||
_logDirectory = preferredDirectory;
|
||||
}
|
||||
else
|
||||
{
|
||||
fallbackReady = TryPrepareDirectory(fallbackDirectory, out fallbackError);
|
||||
_logDirectory = fallbackReady ? fallbackDirectory : preferredDirectory;
|
||||
}
|
||||
|
||||
_logFilePath = Path.Combine(_logDirectory, $"app-{DateTime.Now:yyyyMMdd}.log");
|
||||
_initialized = true;
|
||||
|
||||
WriteCore(
|
||||
"INFO",
|
||||
"Logger",
|
||||
$"Initialized. Directory={_logDirectory}; File={_logFilePath}; PreferredDirectory={preferredDirectory}");
|
||||
|
||||
if (!preferredReady && !string.IsNullOrWhiteSpace(preferredError))
|
||||
{
|
||||
WriteCore(
|
||||
"WARN",
|
||||
"Logger",
|
||||
$"Failed to use program log directory '{preferredDirectory}'. Falling back to '{_logDirectory}'. Reason: {preferredError}");
|
||||
}
|
||||
|
||||
if (!preferredReady && !fallbackReady && !string.IsNullOrWhiteSpace(fallbackError))
|
||||
{
|
||||
Trace.WriteLine(
|
||||
$"[LanMountainDesktop][Logger][ERROR] Failed to initialize fallback log directory '{fallbackDirectory}': {fallbackError}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void Info(string category, string message)
|
||||
{
|
||||
Write("INFO", category, message, null);
|
||||
}
|
||||
|
||||
public static void Warn(string category, string message, Exception? exception = null)
|
||||
{
|
||||
Write("WARN", category, message, exception);
|
||||
}
|
||||
|
||||
public static void Error(string category, string message, Exception? exception = null)
|
||||
{
|
||||
Write("ERROR", category, message, exception);
|
||||
}
|
||||
|
||||
public static void Critical(string category, string message, Exception? exception = null)
|
||||
{
|
||||
Write("CRITICAL", category, message, exception);
|
||||
}
|
||||
|
||||
private static void Write(string level, string category, string message, Exception? exception)
|
||||
{
|
||||
EnsureInitialized();
|
||||
|
||||
var payload = exception is null
|
||||
? message
|
||||
: $"{message}{Environment.NewLine}{exception}";
|
||||
WriteCore(level, category, payload);
|
||||
}
|
||||
|
||||
private static void EnsureInitialized()
|
||||
{
|
||||
if (_initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private static void WriteCore(string level, string category, string message)
|
||||
{
|
||||
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff");
|
||||
var line = $"[{timestamp}] [{level}] [{category}] {message}";
|
||||
|
||||
lock (SyncRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_logFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.AppendAllText(_logFilePath, line + Environment.NewLine, Encoding.UTF8);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Trace.WriteLine($"[LanMountainDesktop][Logger][ERROR] {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
Trace.WriteLine(line);
|
||||
}
|
||||
|
||||
private static bool TryPrepareDirectory(string directory, out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
var probePath = Path.Combine(directory, $".probe-{Guid.NewGuid():N}.tmp");
|
||||
File.WriteAllText(probePath, "probe");
|
||||
File.Delete(probePath);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public readonly record struct AppRenderBackendInfo(
|
||||
string ActualBackend,
|
||||
string? ImplementationTypeName);
|
||||
|
||||
public static class AppRenderBackendDiagnostics
|
||||
{
|
||||
public const string Unknown = "Unknown";
|
||||
|
||||
public static AppRenderBackendInfo Detect()
|
||||
{
|
||||
var platformGraphics = GetPlatformGraphics();
|
||||
var implementationTypeName = platformGraphics?.GetType().FullName;
|
||||
var actualBackend = DetectBackendFromImplementationType(implementationTypeName, platformGraphics is null);
|
||||
|
||||
return new AppRenderBackendInfo(actualBackend, implementationTypeName);
|
||||
}
|
||||
|
||||
private static object? GetPlatformGraphics()
|
||||
{
|
||||
var currentResolver = typeof(AvaloniaLocator)
|
||||
.GetProperty("Current", BindingFlags.Public | BindingFlags.Static)
|
||||
?.GetValue(null);
|
||||
|
||||
var getServiceMethod = currentResolver?
|
||||
.GetType()
|
||||
.GetMethod(
|
||||
"GetService",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: [typeof(Type)],
|
||||
modifiers: null);
|
||||
|
||||
return getServiceMethod?.Invoke(currentResolver, [typeof(IPlatformGraphics)]);
|
||||
}
|
||||
|
||||
private static string DetectBackendFromImplementationType(string? implementationTypeName, bool isSoftwareFallback)
|
||||
{
|
||||
if (isSoftwareFallback)
|
||||
{
|
||||
return AppRenderingModeHelper.Software;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(implementationTypeName))
|
||||
{
|
||||
return Unknown;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Vulkan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Vulkan;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Wgl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Wgl;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Angle", StringComparison.OrdinalIgnoreCase) ||
|
||||
implementationTypeName.Contains("Egl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.AngleEgl;
|
||||
}
|
||||
|
||||
return Unknown;
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
42
LanMountainDesktop/Services/AppRenderingModeHelper.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Avalonia;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRenderingModeHelper
|
||||
{
|
||||
public const string Default = "Default";
|
||||
public const string Software = "Software";
|
||||
public const string AngleEgl = "AngleEgl";
|
||||
public const string Wgl = "Wgl";
|
||||
public const string Vulkan = "Vulkan";
|
||||
|
||||
public static string Normalize(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return Default;
|
||||
}
|
||||
|
||||
return value.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"SOFTWARE" => Software,
|
||||
"ANGLEEGL" => AngleEgl,
|
||||
"ANGLE_EGL" => AngleEgl,
|
||||
"WGL" => Wgl,
|
||||
"VULKAN" => Vulkan,
|
||||
_ => Default
|
||||
};
|
||||
}
|
||||
|
||||
public static Win32RenderingMode[]? GetWin32RenderingModes(string? value)
|
||||
{
|
||||
return Normalize(value) switch
|
||||
{
|
||||
Software => [Win32RenderingMode.Software],
|
||||
AngleEgl => [Win32RenderingMode.AngleEgl, Win32RenderingMode.Software],
|
||||
Wgl => [Win32RenderingMode.Wgl, Win32RenderingMode.Software],
|
||||
Vulkan => [Win32RenderingMode.Vulkan, Win32RenderingMode.Software],
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
205
LanMountainDesktop/Services/AppRestartService.cs
Normal file
205
LanMountainDesktop/Services/AppRestartService.cs
Normal file
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(AppRestartService),
|
||||
Reason: "Legacy restart entry point invoked.")) == true;
|
||||
}
|
||||
|
||||
public static bool TryRestartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = CreateRestartStartInfo();
|
||||
if (startInfo is null)
|
||||
{
|
||||
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
{
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int? TryGetRestartParentProcessId(IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(commandLineArgs);
|
||||
|
||||
foreach (var argument in commandLineArgs)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(argument, out var processId))
|
||||
{
|
||||
return processId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
if (TryParseRestartParentProcessId(commandLineArgs[i], out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendRestartParentProcessArgument(ProcessStartInfo startInfo)
|
||||
{
|
||||
startInfo.ArgumentList.Add($"{RestartParentPidArgumentPrefix}{Environment.ProcessId}");
|
||||
}
|
||||
|
||||
private static bool TryParseRestartParentProcessId(string? argument, out int processId)
|
||||
{
|
||||
processId = 0;
|
||||
if (string.IsNullOrWhiteSpace(argument) ||
|
||||
!argument.StartsWith(RestartParentPidArgumentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
argument[RestartParentPidArgumentPrefix.Length..],
|
||||
out processId) && processId > 0;
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(processPath);
|
||||
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
|
||||
{
|
||||
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
|
||||
? entryAssemblyPath
|
||||
: launchPath;
|
||||
|
||||
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class AppSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
@@ -21,6 +23,8 @@ public sealed class AppSettingsService
|
||||
|
||||
private readonly string _settingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public AppSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
@@ -59,8 +63,9 @@ public sealed class AppSettingsService
|
||||
return loadedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AppSettings", $"Failed to load settings from '{_settingsPath}'.", ex);
|
||||
return new AppSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -88,10 +93,12 @@ public sealed class AppSettingsService
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn("AppSettings", $"Failed to save settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +137,9 @@ public sealed class AppSettingsService
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
return JsonSerializer.Deserialize<AppSettingsSnapshot>(json, SerializerOptions) ?? new AppSettingsSnapshot();
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("AppSettings", $"Failed to deserialize settings from '{_settingsPath}'.", ex);
|
||||
return new AppSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal file
123
LanMountainDesktop/Services/CalculatorDataService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
774
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
774
LanMountainDesktop/Services/ComponentSettingsService.cs
Normal file
@@ -0,0 +1,774 @@
|
||||
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 (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
if (HasScopedComponentContext())
|
||||
{
|
||||
SaveForComponent(_scopedComponentId, _scopedPlacementId, snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.DefaultSettings = snapshotToPersist;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ComponentSettingsSnapshot LoadForComponent(string componentId, string? placementId)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (!string.IsNullOrWhiteSpace(instanceKey) &&
|
||||
document.InstanceSettings.TryGetValue(instanceKey, out var snapshot))
|
||||
{
|
||||
return snapshot.Clone();
|
||||
}
|
||||
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveForComponent(string componentId, string? placementId, ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
var normalizedSnapshot = NormalizeSnapshot(snapshot);
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
Save(normalizedSnapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.InstanceSettings[instanceKey] = normalizedSnapshot;
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeleteForComponent(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var changed = document.InstanceSettings.Remove(instanceKey);
|
||||
changed |= document.PluginSettings.Remove(instanceKey);
|
||||
if (changed)
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public T LoadPluginSettings<T>(string componentId, string? placementId) where T : new()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey) ||
|
||||
!document.PluginSettings.TryGetValue(instanceKey, out var settingsElement))
|
||||
{
|
||||
return new T();
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
|
||||
public void SavePluginSettings<T>(string componentId, string? placementId, T settings)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
document.PluginSettings[instanceKey] = JsonSerializer.SerializeToElement(settings, SerializerOptions).Clone();
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void DeletePluginSettings(string componentId, string? placementId)
|
||||
{
|
||||
var instanceKey = BuildInstanceKey(componentId, placementId);
|
||||
if (string.IsNullOrWhiteSpace(instanceKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var document = LoadDocumentLocked();
|
||||
if (document.PluginSettings.Remove(instanceKey))
|
||||
{
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetScopedComponentContext(string componentId, string? placementId)
|
||||
{
|
||||
_scopedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
_scopedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public void ClearScopedComponentContext()
|
||||
{
|
||||
_scopedComponentId = string.Empty;
|
||||
_scopedPlacementId = string.Empty;
|
||||
}
|
||||
|
||||
public static void ApplyScopedContextToTarget(object? target, string componentId, string? placementId)
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
foreach (var field in target.GetType().GetFields(flags))
|
||||
{
|
||||
if (field.FieldType != typeof(ComponentSettingsService))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var property in target.GetType().GetProperties(flags))
|
||||
{
|
||||
if (property.PropertyType != typeof(ComponentSettingsService) ||
|
||||
!property.CanRead)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (property.GetValue(target) is ComponentSettingsService settingsService)
|
||||
{
|
||||
settingsService.SetScopedComponentContext(componentId, placementId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadDocumentLocked()
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
||||
};
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
(document.RootElement.TryGetProperty("defaultSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("instanceSettings", out _) ||
|
||||
document.RootElement.TryGetProperty("pluginSettings", out _)))
|
||||
{
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||
return NormalizeDocument(snapshot);
|
||||
}
|
||||
|
||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
|
||||
return new ComponentSettingsDocumentSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out ComponentSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new ComponentSettingsSnapshot();
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyComponentSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new ComponentSettingsSnapshot
|
||||
{
|
||||
DailyArtworkMirrorSource = legacy.DailyArtworkMirrorSource,
|
||||
ImportedClassSchedules = legacy.ImportedClassSchedules ?? [],
|
||||
ActiveImportedClassScheduleId = legacy.ActiveImportedClassScheduleId ?? string.Empty,
|
||||
StudyEnvironmentShowDisplayDb = legacy.StudyEnvironmentShowDisplayDb,
|
||||
StudyEnvironmentShowDbfs = legacy.StudyEnvironmentShowDbfs,
|
||||
DesktopClockTimeZoneId = legacy.DesktopClockTimeZoneId,
|
||||
DesktopClockSecondHandMode = legacy.DesktopClockSecondHandMode,
|
||||
WorldClockTimeZoneIds = legacy.WorldClockTimeZoneIds ?? [],
|
||||
WorldClockSecondHandMode = legacy.WorldClockSecondHandMode,
|
||||
CnrDailyNewsAutoRotateEnabled = legacy.CnrDailyNewsAutoRotateEnabled,
|
||||
CnrDailyNewsAutoRotateIntervalMinutes = legacy.CnrDailyNewsAutoRotateIntervalMinutes,
|
||||
IfengNewsAutoRefreshEnabled = legacy.IfengNewsAutoRefreshEnabled,
|
||||
IfengNewsAutoRefreshIntervalMinutes = legacy.IfengNewsAutoRefreshIntervalMinutes,
|
||||
IfengNewsChannelType = legacy.IfengNewsChannelType,
|
||||
DailyWordAutoRefreshEnabled = legacy.DailyWordAutoRefreshEnabled,
|
||||
DailyWordAutoRefreshIntervalMinutes = legacy.DailyWordAutoRefreshIntervalMinutes,
|
||||
BilibiliHotSearchAutoRefreshEnabled = legacy.BilibiliHotSearchAutoRefreshEnabled,
|
||||
BilibiliHotSearchAutoRefreshIntervalMinutes = legacy.BilibiliHotSearchAutoRefreshIntervalMinutes,
|
||||
BaiduHotSearchAutoRefreshEnabled = legacy.BaiduHotSearchAutoRefreshEnabled,
|
||||
BaiduHotSearchAutoRefreshIntervalMinutes = legacy.BaiduHotSearchAutoRefreshIntervalMinutes,
|
||||
BaiduHotSearchSourceType = legacy.BaiduHotSearchSourceType,
|
||||
WeatherAutoRefreshEnabled = legacy.WeatherAutoRefreshEnabled,
|
||||
WeatherAutoRefreshIntervalMinutes = legacy.WeatherAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumAutoRefreshEnabled = legacy.Stcn24ForumAutoRefreshEnabled,
|
||||
Stcn24ForumAutoRefreshIntervalMinutes = legacy.Stcn24ForumAutoRefreshIntervalMinutes,
|
||||
Stcn24ForumSourceType = legacy.Stcn24ForumSourceType
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistDocumentLocked(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshot);
|
||||
UpdateCache(snapshot, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static ComponentSettingsSnapshot NormalizeSnapshot(ComponentSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsSnapshot();
|
||||
|
||||
normalized.DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(normalized.DailyArtworkMirrorSource);
|
||||
normalized.ImportedClassSchedules = NormalizeImportedSchedules(normalized.ImportedClassSchedules);
|
||||
normalized.ActiveImportedClassScheduleId = NormalizeActiveScheduleId(
|
||||
normalized.ActiveImportedClassScheduleId,
|
||||
normalized.ImportedClassSchedules);
|
||||
|
||||
if (!normalized.StudyEnvironmentShowDisplayDb && !normalized.StudyEnvironmentShowDbfs)
|
||||
{
|
||||
normalized.StudyEnvironmentShowDisplayDb = true;
|
||||
}
|
||||
|
||||
normalized.DesktopClockTimeZoneId = NormalizeDesktopClockTimeZoneId(normalized.DesktopClockTimeZoneId);
|
||||
normalized.DesktopClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.DesktopClockSecondHandMode);
|
||||
normalized.WorldClockTimeZoneIds = WorldClockTimeZoneCatalog
|
||||
.NormalizeTimeZoneIds(normalized.WorldClockTimeZoneIds)
|
||||
.ToList();
|
||||
normalized.WorldClockSecondHandMode = ClockSecondHandMode.Normalize(normalized.WorldClockSecondHandMode);
|
||||
normalized.CnrDailyNewsAutoRotateIntervalMinutes = NormalizeCnrInterval(normalized.CnrDailyNewsAutoRotateIntervalMinutes);
|
||||
normalized.IfengNewsAutoRefreshIntervalMinutes = NormalizeIfengNewsInterval(normalized.IfengNewsAutoRefreshIntervalMinutes);
|
||||
normalized.IfengNewsChannelType = IfengNewsChannelTypes.Normalize(normalized.IfengNewsChannelType);
|
||||
normalized.DailyWordAutoRefreshIntervalMinutes = NormalizeDailyWordInterval(normalized.DailyWordAutoRefreshIntervalMinutes);
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes = NormalizeBilibiliHotSearchInterval(
|
||||
normalized.BilibiliHotSearchAutoRefreshIntervalMinutes);
|
||||
normalized.BaiduHotSearchAutoRefreshIntervalMinutes = NormalizeBaiduHotSearchInterval(
|
||||
normalized.BaiduHotSearchAutoRefreshIntervalMinutes);
|
||||
normalized.BaiduHotSearchSourceType = BaiduHotSearchSourceTypes.Normalize(normalized.BaiduHotSearchSourceType);
|
||||
normalized.WeatherAutoRefreshIntervalMinutes = NormalizeWeatherInterval(normalized.WeatherAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumAutoRefreshIntervalMinutes = NormalizeStcn24ForumInterval(normalized.Stcn24ForumAutoRefreshIntervalMinutes);
|
||||
normalized.Stcn24ForumSourceType = Stcn24ForumSourceTypes.Normalize(normalized.Stcn24ForumSourceType);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static ComponentSettingsDocumentSnapshot NormalizeDocument(ComponentSettingsDocumentSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new ComponentSettingsDocumentSnapshot();
|
||||
normalized.DefaultSettings = NormalizeSnapshot(normalized.DefaultSettings);
|
||||
|
||||
var instanceSettings = new Dictionary<string, ComponentSettingsSnapshot>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.InstanceSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
instanceSettings[key] = NormalizeSnapshot(pair.Value);
|
||||
}
|
||||
|
||||
var pluginSettings = new Dictionary<string, JsonElement>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var pair in normalized.PluginSettings)
|
||||
{
|
||||
var key = NormalizeInstanceKey(pair.Key);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
pluginSettings[key] = pair.Value.Clone();
|
||||
}
|
||||
|
||||
normalized.InstanceSettings = instanceSettings;
|
||||
normalized.PluginSettings = pluginSettings;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<ImportedClassScheduleSnapshot> NormalizeImportedSchedules(
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot>? schedules)
|
||||
{
|
||||
if (schedules is null || schedules.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new List<ImportedClassScheduleSnapshot>(schedules.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var schedule in schedules)
|
||||
{
|
||||
if (schedule is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var id = schedule.Id?.Trim() ?? string.Empty;
|
||||
var filePath = schedule.FilePath?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!seenIds.Add(id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add(new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = schedule.DisplayName?.Trim() ?? string.Empty,
|
||||
FilePath = filePath
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string NormalizeActiveScheduleId(
|
||||
string? activeScheduleId,
|
||||
IReadOnlyList<ImportedClassScheduleSnapshot> schedules)
|
||||
{
|
||||
var activeId = activeScheduleId?.Trim() ?? string.Empty;
|
||||
if (schedules.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activeId))
|
||||
{
|
||||
return schedules[0].Id;
|
||||
}
|
||||
|
||||
return schedules.Any(item => string.Equals(item.Id, activeId, StringComparison.OrdinalIgnoreCase))
|
||||
? activeId
|
||||
: schedules[0].Id;
|
||||
}
|
||||
|
||||
private static string NormalizeDesktopClockTimeZoneId(string? timeZoneId)
|
||||
{
|
||||
var normalizedId = string.IsNullOrWhiteSpace(timeZoneId)
|
||||
? "China Standard Time"
|
||||
: timeZoneId.Trim();
|
||||
return WorldClockTimeZoneCatalog.ResolveTimeZoneOrLocal(normalizedId).Id;
|
||||
}
|
||||
|
||||
private static int NormalizeCnrInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 60);
|
||||
}
|
||||
|
||||
private static int NormalizeDailyWordInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 360);
|
||||
}
|
||||
|
||||
private static int NormalizeIfengNewsInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private static int NormalizeBilibiliHotSearchInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private static int NormalizeBaiduHotSearchInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 15);
|
||||
}
|
||||
|
||||
private static int NormalizeWeatherInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 12);
|
||||
}
|
||||
|
||||
private static int NormalizeStcn24ForumInterval(int minutes)
|
||||
{
|
||||
return RefreshIntervalCatalog.Normalize(minutes, 20);
|
||||
}
|
||||
|
||||
private static string BuildInstanceKey(string componentId, string? placementId)
|
||||
{
|
||||
var normalizedComponentId = componentId?.Trim() ?? string.Empty;
|
||||
var normalizedPlacementId = placementId?.Trim() ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(normalizedComponentId) || string.IsNullOrWhiteSpace(normalizedPlacementId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return $"{normalizedComponentId}::{normalizedPlacementId}";
|
||||
}
|
||||
|
||||
private static string NormalizeInstanceKey(string? key)
|
||||
{
|
||||
return key?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
174
LanMountainDesktop/Services/DesktopComponentRegistryFactory.cs
Normal file
@@ -0,0 +1,174 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.ComponentSystem.Extensions;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class DesktopComponentRegistryFactory
|
||||
{
|
||||
public static ComponentRegistry Create(PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registry = ComponentRegistry
|
||||
.CreateDefault()
|
||||
.RegisterExtensions(
|
||||
JsonComponentExtensionProvider.LoadProvidersFromDirectory(
|
||||
Path.Combine(AppContext.BaseDirectory, "Extensions", "Components")));
|
||||
|
||||
var pluginDefinitions = GetPluginDefinitions(registry, pluginRuntimeService);
|
||||
return pluginDefinitions.Count == 0
|
||||
? registry
|
||||
: registry.RegisterComponents(pluginDefinitions);
|
||||
}
|
||||
|
||||
public static DesktopComponentRuntimeRegistry CreateRuntimeRegistry(
|
||||
ComponentRegistry componentRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var registrations = DesktopComponentRuntimeRegistry.GetDefaultRegistrations().ToList();
|
||||
var registeredIds = new HashSet<string>(
|
||||
registrations.Select(registration => registration.ComponentId),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (pluginRuntimeService is not null)
|
||||
{
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!componentRegistry.TryGetDefinition(registration.ComponentId, out _))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!registeredIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because a runtime registration already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
registrations.Add(new DesktopComponentRuntimeRegistration(
|
||||
registration.ComponentId,
|
||||
registration.DisplayNameLocalizationKey,
|
||||
factoryContext => CreatePluginControl(contribution, factoryContext),
|
||||
registration.CornerRadiusResolver));
|
||||
}
|
||||
}
|
||||
|
||||
return new DesktopComponentRuntimeRegistry(componentRegistry, registrations);
|
||||
}
|
||||
|
||||
private static List<DesktopComponentDefinition> GetPluginDefinitions(
|
||||
ComponentRegistry baseRegistry,
|
||||
PluginRuntimeService? pluginRuntimeService)
|
||||
{
|
||||
var definitions = new List<DesktopComponentDefinition>();
|
||||
if (pluginRuntimeService is null)
|
||||
{
|
||||
return definitions;
|
||||
}
|
||||
|
||||
var knownIds = new HashSet<string>(
|
||||
baseRegistry.GetAll().Select(definition => definition.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contribution in pluginRuntimeService.DesktopComponents)
|
||||
{
|
||||
var registration = contribution.Registration;
|
||||
if (!knownIds.Add(registration.ComponentId))
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Skipped plugin widget '{registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}' because the component id already exists.");
|
||||
continue;
|
||||
}
|
||||
|
||||
definitions.Add(new DesktopComponentDefinition(
|
||||
registration.ComponentId,
|
||||
registration.DisplayName,
|
||||
registration.IconKey,
|
||||
registration.Category,
|
||||
registration.MinWidthCells,
|
||||
registration.MinHeightCells,
|
||||
registration.AllowStatusBarPlacement,
|
||||
registration.AllowDesktopPlacement,
|
||||
registration.ResizeMode == PluginDesktopComponentResizeMode.Free
|
||||
? DesktopComponentResizeMode.Free
|
||||
: DesktopComponentResizeMode.Proportional));
|
||||
}
|
||||
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private static Control CreatePluginControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
DesktopComponentControlFactoryContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var pluginContext = new PluginDesktopComponentContext(
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine(
|
||||
$"[PluginRuntime] Failed to create widget '{contribution.Registration.ComponentId}' from '{contribution.Plugin.Manifest.Id}': {ex}");
|
||||
return CreatePluginErrorControl(contribution, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Control CreatePluginErrorControl(
|
||||
PluginDesktopComponentContribution contribution,
|
||||
Exception exception)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#332B0F16")),
|
||||
BorderBrush = new SolidColorBrush(Color.Parse("#66F97316")),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(12),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 6,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = contribution.Registration.DisplayName,
|
||||
FontSize = 14,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Plugin {contribution.Plugin.Manifest.Name} failed to create this widget.",
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = exception.Message,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
251
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
251
LanMountainDesktop/Services/DesktopLayoutSettingsService.cs
Normal file
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class DesktopLayoutSettingsService
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static DesktopLayoutSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public DesktopLayoutSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "desktop-layout-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public DesktopLayoutSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
DesktopLayoutSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private DesktopLayoutSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new DesktopLayoutSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyDesktopLayoutSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new DesktopLayoutSettingsSnapshot
|
||||
{
|
||||
DesktopPageCount = legacy.DesktopPageCount,
|
||||
CurrentDesktopSurfaceIndex = legacy.CurrentDesktopSurfaceIndex,
|
||||
DesktopComponentPlacements = legacy.DesktopComponentPlacements ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(DesktopLayoutSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static DesktopLayoutSettingsSnapshot NormalizeSnapshot(DesktopLayoutSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new DesktopLayoutSettingsSnapshot();
|
||||
normalized.DesktopPageCount = Math.Max(1, normalized.DesktopPageCount);
|
||||
normalized.CurrentDesktopSurfaceIndex = Math.Max(0, normalized.CurrentDesktopSurfaceIndex);
|
||||
|
||||
var placements = new List<DesktopComponentPlacementSnapshot>(normalized.DesktopComponentPlacements?.Count ?? 0);
|
||||
if (normalized.DesktopComponentPlacements is not null)
|
||||
{
|
||||
foreach (var placement in normalized.DesktopComponentPlacements)
|
||||
{
|
||||
if (placement is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
placements.Add(new DesktopComponentPlacementSnapshot
|
||||
{
|
||||
PlacementId = placement.PlacementId?.Trim() ?? string.Empty,
|
||||
PageIndex = Math.Max(0, placement.PageIndex),
|
||||
ComponentId = placement.ComponentId?.Trim() ?? string.Empty,
|
||||
Row = Math.Max(0, placement.Row),
|
||||
Column = Math.Max(0, placement.Column),
|
||||
WidthCells = Math.Max(1, placement.WidthCells),
|
||||
HeightCells = Math.Max(1, placement.HeightCells)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
normalized.DesktopComponentPlacements = placements;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void UpdateCache(DesktopLayoutSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyDesktopLayoutSettingsSnapshot
|
||||
{
|
||||
public int DesktopPageCount { get; set; } = 1;
|
||||
|
||||
public int CurrentDesktopSurfaceIndex { get; set; }
|
||||
|
||||
public List<DesktopComponentPlacementSnapshot>? DesktopComponentPlacements { get; set; }
|
||||
}
|
||||
}
|
||||
98
LanMountainDesktop/Services/FileOperationRetryHelper.cs
Normal file
98
LanMountainDesktop/Services/FileOperationRetryHelper.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class FileOperationRetryHelper
|
||||
{
|
||||
private static readonly TimeSpan[] RetryDelays =
|
||||
[
|
||||
TimeSpan.FromMilliseconds(120),
|
||||
TimeSpan.FromMilliseconds(250),
|
||||
TimeSpan.FromMilliseconds(500)
|
||||
];
|
||||
|
||||
public static void CopyWithRetry(string sourceFilePath, string destinationFilePath, bool overwrite, string category)
|
||||
{
|
||||
Retry(
|
||||
() => File.Copy(sourceFilePath, destinationFilePath, overwrite),
|
||||
category,
|
||||
$"Copy '{sourceFilePath}' -> '{destinationFilePath}'");
|
||||
}
|
||||
|
||||
public static void MoveWithOverwriteRetry(string sourceFilePath, string destinationFilePath, string category)
|
||||
{
|
||||
Retry(
|
||||
() => File.Move(sourceFilePath, destinationFilePath, overwrite: true),
|
||||
category,
|
||||
$"Move '{sourceFilePath}' -> '{destinationFilePath}'");
|
||||
}
|
||||
|
||||
public static void DeleteFileWithRetry(string filePath, string category)
|
||||
{
|
||||
Retry(
|
||||
() =>
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
}
|
||||
},
|
||||
category,
|
||||
$"Delete file '{filePath}'");
|
||||
}
|
||||
|
||||
public static void DeleteDirectoryWithRetry(string directoryPath, bool recursive, string category)
|
||||
{
|
||||
Retry(
|
||||
() =>
|
||||
{
|
||||
if (Directory.Exists(directoryPath))
|
||||
{
|
||||
Directory.Delete(directoryPath, recursive);
|
||||
}
|
||||
},
|
||||
category,
|
||||
$"Delete directory '{directoryPath}'");
|
||||
}
|
||||
|
||||
private static void Retry(Action action, string category, string operationDescription)
|
||||
{
|
||||
Exception? lastException = null;
|
||||
|
||||
for (var attempt = 0; attempt <= RetryDelays.Length; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
catch (Exception ex) when (IsRetriable(ex))
|
||||
{
|
||||
lastException = ex;
|
||||
if (attempt >= RetryDelays.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var delay = RetryDelays[attempt];
|
||||
AppLogger.Warn(
|
||||
category,
|
||||
$"{operationDescription} failed on attempt {attempt + 1}. Retrying after {delay.TotalMilliseconds:0} ms.",
|
||||
ex);
|
||||
Thread.Sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException is not null)
|
||||
{
|
||||
throw lastException;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRetriable(Exception exception)
|
||||
{
|
||||
return exception is IOException or UnauthorizedAccessException;
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
private readonly string _owner;
|
||||
private readonly string _repo;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ResumableDownloadService _downloadService;
|
||||
private readonly bool _ownsHttpClient;
|
||||
|
||||
public GitHubReleaseUpdateService(
|
||||
@@ -69,6 +70,8 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
_ownsHttpClient = false;
|
||||
}
|
||||
|
||||
_downloadService = new ResumableDownloadService(_httpClient);
|
||||
|
||||
if (!_httpClient.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0");
|
||||
@@ -187,59 +190,37 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
return new UpdateDownloadResult(false, null, "Destination file path is empty.");
|
||||
}
|
||||
|
||||
try
|
||||
var progressAdapter = progress is null
|
||||
? null
|
||||
: new Progress<DownloadProgressInfo>(info => progress.Report(info.Progress));
|
||||
|
||||
var result = await _downloadService.DownloadAsync(
|
||||
asset.BrowserDownloadUrl,
|
||||
destinationFilePath,
|
||||
new DownloadOptions(ExpectedSizeBytes: asset.SizeBytes > 0 ? asset.SizeBytes : null),
|
||||
progressAdapter,
|
||||
cancellationToken);
|
||||
|
||||
return result.Success
|
||||
? new UpdateDownloadResult(true, result.FilePath ?? destinationFilePath, null)
|
||||
: new UpdateDownloadResult(false, null, result.ErrorMessage);
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||
string tagName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName))
|
||||
{
|
||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.GetAsync(
|
||||
asset.BrowserDownloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return new UpdateDownloadResult(
|
||||
false,
|
||||
null,
|
||||
$"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}");
|
||||
}
|
||||
|
||||
var contentLength = response.Content.Headers.ContentLength ??
|
||||
(asset.SizeBytes > 0 ? asset.SizeBytes : -1);
|
||||
|
||||
await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
await using var destinationStream = File.Create(destinationFilePath);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long totalRead = 0;
|
||||
int read;
|
||||
while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0)
|
||||
{
|
||||
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
totalRead += read;
|
||||
|
||||
if (contentLength > 0)
|
||||
{
|
||||
progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d));
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(1d);
|
||||
|
||||
return new UpdateDownloadResult(true, destinationFilePath, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateDownloadResult(false, null, ex.Message);
|
||||
return null;
|
||||
}
|
||||
|
||||
var url =
|
||||
$"https://api.github.com/repos/{_owner}/{_repo}/releases/tags/{Uri.EscapeDataString(tagName.Trim())}";
|
||||
var responseText = await GetResponseTextAsync(url, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
return ParseRelease(document.RootElement);
|
||||
}
|
||||
|
||||
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class HostApplicationLifecycleService : IHostApplicationLifecycle
|
||||
{
|
||||
public bool TryExit(HostApplicationLifecycleRequest? request = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
AppLogger.Info(
|
||||
"HostLifecycle",
|
||||
$"Exit requested. Source='{request?.Source ?? "Unknown"}'; Reason='{request?.Reason ?? string.Empty}'.");
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", "Exit request ignored because desktop lifetime is unavailable.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => desktop.Shutdown(), DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", "Failed to exit the application.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryRestart(HostApplicationLifecycleRequest? request = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = AppRestartService.CreateRestartStartInfo();
|
||||
if (startInfo is null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"HostLifecycle",
|
||||
$"Restart request rejected because restart start info could not be resolved. Source='{request?.Source ?? "Unknown"}'.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
var exitRequest = request is null
|
||||
? new HostApplicationLifecycleRequest(Reason: "Restart accepted.")
|
||||
: request with
|
||||
{
|
||||
Reason = string.IsNullOrWhiteSpace(request.Reason)
|
||||
? "Restart accepted."
|
||||
: request.Reason
|
||||
};
|
||||
|
||||
return TryExit(exitRequest);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("HostLifecycle", "Failed to restart the application.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,19 @@ public static class AudioRecorderServiceFactory
|
||||
{
|
||||
return CreateRecorder();
|
||||
}
|
||||
|
||||
public static void DisposeSharedServices()
|
||||
{
|
||||
if (SharedRecorderService.IsValueCreated)
|
||||
{
|
||||
SharedRecorderService.Value.Dispose();
|
||||
}
|
||||
|
||||
if (SharedStudyMonitoringService.IsValueCreated)
|
||||
{
|
||||
SharedStudyMonitoringService.Value.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
|
||||
|
||||
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal file
17
LanMountainDesktop/Services/ICalculatorDataService.cs
Normal 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 = ".";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -20,10 +20,38 @@ public sealed record DailyNewsQuery(
|
||||
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,
|
||||
@@ -66,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",
|
||||
@@ -204,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
|
||||
@@ -220,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();
|
||||
}
|
||||
|
||||
245
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
245
LanMountainDesktop/Services/LauncherSettingsService.cs
Normal file
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class LauncherSettingsService
|
||||
{
|
||||
public static event Action<string>? SettingsSaved;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly object CacheGate = new();
|
||||
private static readonly TimeSpan CacheProbeInterval = TimeSpan.FromMilliseconds(400);
|
||||
|
||||
private static string? _cachedPath;
|
||||
private static LauncherSettingsSnapshot? _cachedSnapshot;
|
||||
private static DateTime _cachedWriteTimeUtc = DateTime.MinValue;
|
||||
private static DateTime _lastProbeUtc = DateTime.MinValue;
|
||||
|
||||
private readonly string _settingsPath;
|
||||
private readonly string _legacyAppSettingsPath;
|
||||
|
||||
public string InstanceId { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
public LauncherSettingsService()
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
_settingsPath = Path.Combine(settingsDirectory, "launcher-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
|
||||
public LauncherSettingsSnapshot Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
if (TryGetCachedWithoutProbe(nowUtc, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var hasFile = File.Exists(_settingsPath);
|
||||
var writeTimeUtc = hasFile
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.MinValue;
|
||||
|
||||
_lastProbeUtc = nowUtc;
|
||||
if (TryGetCachedAfterProbe(writeTimeUtc, out cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
LauncherSettingsSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
loadedSnapshot = migratedSnapshot;
|
||||
loadedFromLegacy = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
loadedSnapshot = new LauncherSettingsSnapshot();
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeSnapshot(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var snapshotToPersist = NormalizeSnapshot(snapshot);
|
||||
|
||||
try
|
||||
{
|
||||
var writeTimeUtc = PersistSnapshotToDisk(snapshotToPersist);
|
||||
|
||||
lock (CacheGate)
|
||||
{
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGetCachedWithoutProbe(DateTime nowUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
nowUtc - _lastProbeUtc < CacheProbeInterval)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool TryGetCachedAfterProbe(DateTime writeTimeUtc, out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
if (string.Equals(_cachedPath, _settingsPath, StringComparison.Ordinal) &&
|
||||
_cachedSnapshot is not null &&
|
||||
writeTimeUtc == _cachedWriteTimeUtc)
|
||||
{
|
||||
snapshot = _cachedSnapshot.Clone();
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private LauncherSettingsSnapshot LoadSnapshotFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_settingsPath);
|
||||
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryLoadLegacySnapshot(out LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
snapshot = new LauncherSettingsSnapshot();
|
||||
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_legacyAppSettingsPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var legacyJson = File.ReadAllText(_legacyAppSettingsPath);
|
||||
var legacy = JsonSerializer.Deserialize<LegacyLauncherSettingsSnapshot>(legacyJson, SerializerOptions);
|
||||
if (legacy is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
snapshot = new LauncherSettingsSnapshot
|
||||
{
|
||||
HiddenLauncherFolderPaths = legacy.HiddenLauncherFolderPaths ?? [],
|
||||
HiddenLauncherAppPaths = legacy.HiddenLauncherAppPaths ?? []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DateTime PersistSnapshotToDisk(LauncherSettingsSnapshot snapshot)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_settingsPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(snapshot, SerializerOptions);
|
||||
File.WriteAllText(_settingsPath, json);
|
||||
|
||||
return File.Exists(_settingsPath)
|
||||
? File.GetLastWriteTimeUtc(_settingsPath)
|
||||
: DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private static LauncherSettingsSnapshot NormalizeSnapshot(LauncherSettingsSnapshot? snapshot)
|
||||
{
|
||||
var normalized = snapshot?.Clone() ?? new LauncherSettingsSnapshot();
|
||||
normalized.HiddenLauncherFolderPaths = NormalizeKeys(normalized.HiddenLauncherFolderPaths);
|
||||
normalized.HiddenLauncherAppPaths = NormalizeKeys(normalized.HiddenLauncherAppPaths);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static List<string> NormalizeKeys(IReadOnlyList<string>? values)
|
||||
{
|
||||
if (values is null || values.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return values
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Select(value => value.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(value => value, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UpdateCache(LauncherSettingsSnapshot snapshot, DateTime writeTimeUtc, DateTime probeTimeUtc)
|
||||
{
|
||||
_cachedPath = _settingsPath;
|
||||
_cachedSnapshot = snapshot.Clone();
|
||||
_cachedWriteTimeUtc = writeTimeUtc;
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
private sealed class LegacyLauncherSettingsSnapshot
|
||||
{
|
||||
public List<string>? HiddenLauncherFolderPaths { get; set; }
|
||||
|
||||
public List<string>? HiddenLauncherAppPaths { get; set; }
|
||||
}
|
||||
}
|
||||
192
LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs
Normal file
192
LanMountainDesktop/Services/LinuxDesktopEntryInstaller.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
371
LanMountainDesktop/Services/LinuxDesktopEntryService.cs
Normal file
371
LanMountainDesktop/Services/LinuxDesktopEntryService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
214
LanMountainDesktop/Services/LinuxIconService.cs
Normal file
214
LanMountainDesktop/Services/LinuxIconService.cs
Normal 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..]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal file
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class PendingRestartStateService
|
||||
{
|
||||
public const string RenderModeReason = "RenderMode";
|
||||
public const string PluginCatalogReason = "PluginCatalog";
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static event Action? StateChanged;
|
||||
|
||||
public static bool HasPendingRestart
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return PendingReasons.Count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasPendingReason(string reason)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return PendingReasons.Contains(reason);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPending(string reason, bool pending)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
lock (Gate)
|
||||
{
|
||||
changed = pending
|
||||
? PendingReasons.Add(reason)
|
||||
: PendingReasons.Remove(reason);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
186
LanMountainDesktop/Services/PluginsInstallHelperClient.cs
Normal file
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal sealed class PluginsInstallHelperClient
|
||||
{
|
||||
private const int UserCanceledUacErrorCode = 1223;
|
||||
private const string HelperExecutableName = "LanMountainDesktop.PluginsInstallHelper.exe";
|
||||
|
||||
public async Task<PluginsInstallHelperResult> InstallPackageAsync(
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginsDirectory);
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
false,
|
||||
null,
|
||||
"Elevated helper install is only supported on Windows.");
|
||||
}
|
||||
|
||||
var helperPath = ResolveHelperPath();
|
||||
if (!File.Exists(helperPath))
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
false,
|
||||
null,
|
||||
$"Plugins install helper was not found at '{helperPath}'.");
|
||||
}
|
||||
|
||||
var resultPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop",
|
||||
"PluginInstallResults",
|
||||
$"{Guid.NewGuid():N}.json");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(resultPath)!);
|
||||
|
||||
try
|
||||
{
|
||||
using var process = StartHelperProcess(helperPath, packagePath, pluginsDirectory, resultPath);
|
||||
if (process is null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Failed to start plugins install helper.");
|
||||
}
|
||||
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
var result = await ReadResultAsync(resultPath, cancellationToken);
|
||||
if (result is not null)
|
||||
{
|
||||
return new PluginsInstallHelperResult(result.Success, result.InstalledPackagePath, result.ErrorMessage);
|
||||
}
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
return new PluginsInstallHelperResult(
|
||||
false,
|
||||
null,
|
||||
"Plugins install helper exited without producing a result file.");
|
||||
}
|
||||
|
||||
return new PluginsInstallHelperResult(
|
||||
false,
|
||||
null,
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"Plugins install helper exited with code {0}.",
|
||||
process.ExitCode));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == UserCanceledUacErrorCode)
|
||||
{
|
||||
return new PluginsInstallHelperResult(false, null, "Administrator permission request was canceled.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteFile(resultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static Process? StartHelperProcess(
|
||||
string helperPath,
|
||||
string packagePath,
|
||||
string pluginsDirectory,
|
||||
string resultPath)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = helperPath,
|
||||
Verb = "runas",
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(helperPath) ?? AppContext.BaseDirectory,
|
||||
Arguments = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"--source {QuoteArgument(Path.GetFullPath(packagePath))} --plugins-dir {QuoteArgument(Path.GetFullPath(pluginsDirectory))} --result {QuoteArgument(Path.GetFullPath(resultPath))}")
|
||||
};
|
||||
|
||||
return Process.Start(startInfo);
|
||||
}
|
||||
|
||||
private static async Task<HelperResultFile?> ReadResultAsync(string resultPath, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(resultPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(resultPath);
|
||||
return await JsonSerializer.DeserializeAsync<HelperResultFile>(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static string ResolveHelperPath()
|
||||
{
|
||||
return Path.Combine(AppContext.BaseDirectory, "PluginsInstallHelper", HelperExecutableName);
|
||||
}
|
||||
|
||||
private static string QuoteArgument(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
if (!value.Contains('"') && !value.Contains(' ') && !value.Contains('\t'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder();
|
||||
builder.Append('"');
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
builder.Append("\\\"");
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Append(ch);
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('"');
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void TryDeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore temp file cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class HelperResultFile
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
public string? InstalledPackagePath { get; init; }
|
||||
|
||||
public string? ErrorMessage { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record PluginsInstallHelperResult(
|
||||
bool Success,
|
||||
string? InstalledPackagePath,
|
||||
string? ErrorMessage);
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user