mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 00:46:34 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4679ee006f | ||
|
|
6952cb2c3e | ||
|
|
d3356f3319 | ||
|
|
57c5e41a5c | ||
|
|
ce2b218dfa | ||
|
|
efdfa68dab | ||
|
|
87110f1d69 | ||
|
|
e7a03404ce | ||
|
|
2781d7e0d9 | ||
|
|
5003ff1be2 | ||
|
|
e1be072b97 | ||
|
|
4df740e3df | ||
|
|
85f7a18cbc |
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)
|
||||
|
||||
10
.github/workflows/build.yml
vendored
10
.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:
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore -c Release -v minimal
|
||||
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -101,10 +101,10 @@ jobs:
|
||||
dotnet-version: ${{ env.DOTNET_VERSION }}
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
run: dotnet restore ${{ env.Solution_Name }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore -c Release -v minimal
|
||||
run: dotnet build ${{ env.Solution_Name }} --no-restore -c Release -v minimal
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
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:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.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:
|
||||
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
@@ -492,3 +492,23 @@ nul
|
||||
/_build_verify_plugin_services
|
||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||
/_build_obj
|
||||
|
||||
# LanMountainDesktop local workspace files
|
||||
/.arts/
|
||||
/.knox/
|
||||
/.lingma/
|
||||
/.tmp/
|
||||
/publish-test/
|
||||
/validator-restore.log
|
||||
/temp_old_main.axaml
|
||||
/temp_old_main_utf8.axaml
|
||||
|
||||
# LanMountainDesktop local packaging outputs
|
||||
/build-installer/
|
||||
/build-deb/
|
||||
/dmg-temp/
|
||||
/release-files/
|
||||
/LanMountainDesktop.app/
|
||||
/*.deb
|
||||
/*.dmg
|
||||
/*.AppImage
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
# LanAirApp
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
|
||||
## 中文
|
||||
|
||||
这里集中放置:
|
||||
- 插件开发标准
|
||||
- 插件打包与构建工具
|
||||
- 插件开发与打包文档
|
||||
- 示例插件
|
||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
|
||||
目录结构:
|
||||
- `docs/`:插件开发文档、打包文档
|
||||
- `plugins/`:第一方插件项目,例如插件市场插件
|
||||
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
|
||||
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
|
||||
- `standards/`:插件标准文件与模板
|
||||
- `tools/`:插件打包与构建工具
|
||||
### 目录说明
|
||||
|
||||
面向用户的安装流程:
|
||||
1. 将插件构建或打包为 `.laapp` 文件。
|
||||
2. 打开 `设置 -> 插件`。
|
||||
3. 点击 `打开 .laapp 插件包`。
|
||||
4. 选择插件包完成安装。
|
||||
- `docs/`:插件开发与打包文档。
|
||||
- `samples/`:示例插件与参考项目。
|
||||
- `standards/`:插件清单和目录结构约定。
|
||||
- `tools/`:插件打包与辅助工具。
|
||||
|
||||
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`。
|
||||
### 与宿主的关系
|
||||
|
||||
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin`、`IPluginContext`、清单模型与扩展注册接口。
|
||||
- 宿主程序只连接独立 `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.
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
# 插件开发文档
|
||||
# 插件开发指南
|
||||
|
||||
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
|
||||
## 中文
|
||||
|
||||
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`。
|
||||
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
|
||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||
|
||||
## 必需文件
|
||||
- `plugin.json`
|
||||
- `plugin.json` 中声明的入口程序集
|
||||
- 使用插件入口特性标记的入口类型
|
||||
- 插件入口程序集
|
||||
- 入口类
|
||||
- 本地化资源
|
||||
|
||||
## 推荐开发流程
|
||||
1. 以 `LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
|
||||
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
|
||||
3. 实现 `IPlugin` 或继承 `PluginBase`。
|
||||
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
|
||||
5. 将输出内容打包为 `.laapp` 文件。
|
||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||
|
||||
## 运行时能力
|
||||
- 插件可以注册自己的设置页。
|
||||
- 插件可以注册自己的桌面组件。
|
||||
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
|
||||
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
|
||||
## English
|
||||
|
||||
## 多语言建议
|
||||
- 插件应当内置 `Localization/zh-CN.json` 与 `Localization/en-US.json`。
|
||||
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
|
||||
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
|
||||
|
||||
## 目录建议
|
||||
一个标准插件项目建议至少包含:
|
||||
- `plugin.json`
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件程序集与依赖文件
|
||||
|
||||
## 示例项目与工具
|
||||
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
|
||||
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
|
||||
- 标准模板:`LanAirApp/standards/plugin.template.json`
|
||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||
|
||||
@@ -1,34 +1,14 @@
|
||||
# 插件打包文档
|
||||
# 插件打包指南
|
||||
|
||||
LanMountainDesktop 插件的安装包格式固定为 `.laapp`。
|
||||
## 中文
|
||||
|
||||
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
|
||||
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
||||
|
||||
## `.laapp` 格式说明
|
||||
- 本质上是一个标准 zip 压缩包
|
||||
- 包根目录必须包含 `plugin.json`
|
||||
- 包根目录还必须包含入口程序集及其依赖
|
||||
- `.laapp` 安装包
|
||||
- `README.md`
|
||||
|
||||
## 建议打包内容
|
||||
- `plugin.json`
|
||||
- `YourPlugin.dll`
|
||||
- 依赖程序集
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件运行所需的其他资源文件
|
||||
官方市场索引只负责记录链接和校验信息。
|
||||
|
||||
## 使用打包工具
|
||||
```powershell
|
||||
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
|
||||
```
|
||||
## English
|
||||
|
||||
## 应用内安装流程
|
||||
1. 打开 `设置 -> 插件`
|
||||
2. 点击 `打开 .laapp 插件包`
|
||||
3. 选择要安装的插件包
|
||||
4. 如果插件注册了设置页或组件,安装后重启应用
|
||||
|
||||
## 注意事项
|
||||
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
|
||||
- 包内应尽量避免无关开发产物。
|
||||
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
|
||||
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.
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
internal sealed class AirAppMarketCacheService
|
||||
{
|
||||
private readonly string _cacheDirectory;
|
||||
|
||||
public AirAppMarketCacheService(string dataDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
_cacheDirectory = Path.Combine(dataDirectory, "cache");
|
||||
}
|
||||
|
||||
public string CacheFilePath => Path.Combine(_cacheDirectory, "index.json");
|
||||
|
||||
public void SaveIndexJson(string json)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
Directory.CreateDirectory(_cacheDirectory);
|
||||
File.WriteAllText(CacheFilePath, json);
|
||||
}
|
||||
|
||||
public bool TryReadIndexJson(out string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(CacheFilePath))
|
||||
{
|
||||
json = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
json = File.ReadAllText(CacheFilePath);
|
||||
return !string.IsNullOrWhiteSpace(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
json = string.Empty;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
internal sealed class AirAppMarketIndexService : IDisposable
|
||||
{
|
||||
private readonly AirAppMarketCacheService _cacheService;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketIndexService(AirAppMarketCacheService cacheService)
|
||||
{
|
||||
_cacheService = cacheService;
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketLoadResult> LoadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Exception? networkError = null;
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(
|
||||
AirAppMarketDefaults.DefaultIndexUrl,
|
||||
cancellationToken);
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var document = AirAppMarketIndexDocument.Load(json, AirAppMarketDefaults.DefaultIndexUrl);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(true, document, AirAppMarketLoadSource.Network, null, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
networkError = ex;
|
||||
}
|
||||
|
||||
if (_cacheService.TryReadIndexJson(out var cachedJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cachedDocument = AirAppMarketIndexDocument.Load(cachedJson, _cacheService.CacheFilePath);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
cachedDocument,
|
||||
AirAppMarketLoadSource.Cache,
|
||||
networkError?.Message,
|
||||
null);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
return new AirAppMarketLoadResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppMarketLoadResult(false, null, null, null, networkError?.Message ?? "Unknown network error");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _downloadsDirectory;
|
||||
|
||||
public AirAppMarketInstallService(IPluginPackageManager packageManager, string dataDirectory)
|
||||
{
|
||||
_packageManager = packageManager;
|
||||
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
}
|
||||
|
||||
public async Task<AirAppMarketInstallResult> InstallAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
Directory.CreateDirectory(_downloadsDirectory);
|
||||
var downloadPath = Path.Combine(
|
||||
_downloadsDirectory,
|
||||
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(
|
||||
plugin.DownloadUrl,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
await using (var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken))
|
||||
await using (var destinationStream = File.Create(downloadPath))
|
||||
{
|
||||
await responseStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
|
||||
await using var hashStream = File.OpenRead(downloadPath);
|
||||
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
|
||||
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Delete(downloadPath);
|
||||
return new AirAppMarketInstallResult(
|
||||
false,
|
||||
null,
|
||||
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
|
||||
}
|
||||
|
||||
var installResult = _packageManager.InstallPackage(downloadPath);
|
||||
return new AirAppMarketInstallResult(true, installResult, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new AirAppMarketInstallResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string value)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
}
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
internal static class AirAppMarketDefaults
|
||||
{
|
||||
public const string DefaultIndexUrl =
|
||||
"https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json";
|
||||
}
|
||||
|
||||
internal enum AirAppMarketLoadSource
|
||||
{
|
||||
Network = 0,
|
||||
Cache = 1
|
||||
}
|
||||
|
||||
internal enum AirAppMarketInstallState
|
||||
{
|
||||
NotInstalled = 0,
|
||||
UpdateAvailable = 1,
|
||||
Installed = 2
|
||||
}
|
||||
|
||||
internal sealed record AirAppMarketLoadResult(
|
||||
bool Success,
|
||||
AirAppMarketIndexDocument? Document,
|
||||
AirAppMarketLoadSource? Source,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage);
|
||||
|
||||
internal sealed record AirAppMarketInstallResult(
|
||||
bool Success,
|
||||
PluginPackageInstallResult? InstallResult,
|
||||
string? ErrorMessage);
|
||||
|
||||
internal sealed class AirAppMarketIndexDocument
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public string SchemaVersion { get; init; } = string.Empty;
|
||||
|
||||
public string SourceId { get; init; } = string.Empty;
|
||||
|
||||
public string SourceName { get; init; } = string.Empty;
|
||||
|
||||
public DateTimeOffset GeneratedAt { get; init; }
|
||||
|
||||
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
|
||||
|
||||
public static AirAppMarketIndexDocument Load(string json, string sourceName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(json);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
|
||||
|
||||
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
|
||||
json.TrimStart('\uFEFF'),
|
||||
SerializerOptions);
|
||||
|
||||
if (document is null)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
|
||||
}
|
||||
|
||||
return document.ValidateAndNormalize(sourceName);
|
||||
}
|
||||
|
||||
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var plugins = Plugins ?? [];
|
||||
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
|
||||
if (!seenIds.Add(normalizedPlugin.Id))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
|
||||
}
|
||||
|
||||
normalizedPlugins.Add(normalizedPlugin);
|
||||
}
|
||||
|
||||
return new AirAppMarketIndexDocument
|
||||
{
|
||||
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
|
||||
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
|
||||
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
|
||||
GeneratedAt = GeneratedAt == default
|
||||
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
|
||||
: GeneratedAt,
|
||||
Plugins = normalizedPlugins
|
||||
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = NormalizeValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static string? NormalizeValue(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = RequireValue(value, propertyName, sourceName);
|
||||
if (!TryParseVersion(normalized, out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
internal static void EnsureUrl(string url, string propertyName, string sourceName)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool TryParseVersion(string? value, out Version? version)
|
||||
{
|
||||
version = null;
|
||||
var normalized = NormalizeValue(value);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized[1..];
|
||||
}
|
||||
|
||||
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
normalized = normalized[..separatorIndex];
|
||||
}
|
||||
|
||||
if (!Version.TryParse(normalized, out var parsed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new Version(
|
||||
Math.Max(0, parsed.Major),
|
||||
Math.Max(0, parsed.Minor),
|
||||
Math.Max(0, parsed.Build));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AirAppMarketPluginEntry
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
public string Author { get; init; } = string.Empty;
|
||||
|
||||
public string Version { get; init; } = string.Empty;
|
||||
|
||||
public string ApiVersion { get; init; } = string.Empty;
|
||||
|
||||
public string MinHostVersion { get; init; } = string.Empty;
|
||||
|
||||
public string DownloadUrl { get; init; } = string.Empty;
|
||||
|
||||
public string Sha256 { get; init; } = string.Empty;
|
||||
|
||||
public long PackageSizeBytes { get; init; }
|
||||
|
||||
public string IconUrl { get; init; } = string.Empty;
|
||||
|
||||
public string HomepageUrl { get; init; } = string.Empty;
|
||||
|
||||
public string RepositoryUrl { get; init; } = string.Empty;
|
||||
|
||||
public List<string> Tags { get; init; } = [];
|
||||
|
||||
public DateTimeOffset PublishedAt { get; init; }
|
||||
|
||||
public DateTimeOffset UpdatedAt { get; init; }
|
||||
|
||||
public string ReleaseNotes { get; init; } = string.Empty;
|
||||
|
||||
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedTags = (Tags ?? [])
|
||||
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
|
||||
|
||||
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
|
||||
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
|
||||
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
|
||||
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
|
||||
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
|
||||
|
||||
if (PackageSizeBytes <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
if (PublishedAt == default || UpdatedAt == default)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
return new AirAppMarketPluginEntry
|
||||
{
|
||||
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
|
||||
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
|
||||
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
|
||||
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
|
||||
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
|
||||
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
|
||||
DownloadUrl = normalizedDownloadUrl,
|
||||
Sha256 = normalizedSha,
|
||||
PackageSizeBytes = PackageSizeBytes,
|
||||
IconUrl = normalizedIconUrl,
|
||||
HomepageUrl = normalizedHomepageUrl,
|
||||
RepositoryUrl = normalizedRepositoryUrl,
|
||||
Tags = normalizedTags,
|
||||
PublishedAt = PublishedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
|
||||
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
|
||||
};
|
||||
}
|
||||
|
||||
public string GetVersionSummary()
|
||||
{
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"v{0} | API {1} | Host >= {2}",
|
||||
Version,
|
||||
ApiVersion,
|
||||
MinHostVersion);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
<PluginReleaseOutputDirectory>..\..\releases\</PluginReleaseOutputDirectory>
|
||||
<PluginReleasePackagePath>$(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp</PluginReleasePackagePath>
|
||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\</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)" />
|
||||
<MakeDir Directories="$(PluginReleaseOutputDirectory)" />
|
||||
<RemoveDir Directories="$(LegacyLoosePluginOutputDirectory)" />
|
||||
<Delete Files="$(PluginPackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<Delete Files="$(PluginReleasePackagePath)" TreatErrorsAsWarnings="true" />
|
||||
<ZipDirectory SourceDirectory="$(OutputPath)" DestinationFile="$(PluginPackagePath)" />
|
||||
<Copy SourceFiles="$(PluginPackagePath)" DestinationFiles="$(PluginReleasePackagePath)" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"market.page_title": "Plugin Marketplace",
|
||||
"market.toolbar.search_placeholder": "Search plugins",
|
||||
"market.toolbar.refresh": "Refresh",
|
||||
"market.status.loading": "Loading the official plugin marketplace...",
|
||||
"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 plugin marketplace: {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 marketplace 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 from 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.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..."
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"market.page_title": "插件市场",
|
||||
"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.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": "安装中…"
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
[PluginEntrance]
|
||||
public sealed class PluginMarketplacePlugin : PluginBase, IDisposable
|
||||
{
|
||||
private AirAppMarketIndexService? _indexService;
|
||||
private AirAppMarketInstallService? _installService;
|
||||
|
||||
public override void Initialize(IPluginContext context)
|
||||
{
|
||||
Directory.CreateDirectory(context.DataDirectory);
|
||||
|
||||
var localizer = PluginLocalizer.Create(context);
|
||||
var packageManager = context.GetService<IPluginPackageManager>()
|
||||
?? throw new InvalidOperationException(
|
||||
"The host does not expose IPluginPackageManager. LanMountainDesktop.PluginMarketplace requires a newer host build.");
|
||||
|
||||
var cacheService = new AirAppMarketCacheService(context.DataDirectory);
|
||||
_indexService = new AirAppMarketIndexService(cacheService);
|
||||
_installService = new AirAppMarketInstallService(packageManager, context.DataDirectory);
|
||||
|
||||
context.RegisterService(cacheService);
|
||||
context.RegisterService(_indexService);
|
||||
context.RegisterService(_installService);
|
||||
|
||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||
"marketplace",
|
||||
localizer.GetString("market.page_title", "插件市场"),
|
||||
() => new PluginMarketplaceSettingsView(
|
||||
context,
|
||||
localizer,
|
||||
packageManager,
|
||||
_indexService,
|
||||
_installService),
|
||||
sortOrder: -100));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_installService?.Dispose();
|
||||
_indexService?.Dispose();
|
||||
_installService = null;
|
||||
_indexService = null;
|
||||
}
|
||||
}
|
||||
@@ -1,727 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.PluginMarketplace;
|
||||
|
||||
internal sealed class PluginMarketplaceSettingsView : UserControl
|
||||
{
|
||||
private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000"));
|
||||
private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1F0EA5E9"));
|
||||
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
|
||||
private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700"));
|
||||
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
|
||||
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly AirAppMarketIndexService _indexService;
|
||||
private readonly AirAppMarketInstallService _installService;
|
||||
private readonly Version? _hostVersion;
|
||||
|
||||
private readonly TextBox _searchTextBox;
|
||||
private readonly Button _refreshButton;
|
||||
private readonly TextBlock _statusTextBlock;
|
||||
private readonly StackPanel _pluginListHost;
|
||||
private readonly Border _detailBorder;
|
||||
|
||||
private AirAppMarketIndexDocument? _document;
|
||||
private AirAppMarketPluginEntry? _selectedPlugin;
|
||||
private Dictionary<string, InstalledPluginInfo> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private bool _isRefreshing;
|
||||
private bool _isInstalling;
|
||||
private bool _hasLoadedOnce;
|
||||
|
||||
public PluginMarketplaceSettingsView(
|
||||
IPluginContext context,
|
||||
PluginLocalizer localizer,
|
||||
IPluginPackageManager packageManager,
|
||||
AirAppMarketIndexService indexService,
|
||||
AirAppMarketInstallService installService)
|
||||
{
|
||||
_localizer = localizer;
|
||||
_packageManager = packageManager;
|
||||
_indexService = indexService;
|
||||
_installService = installService;
|
||||
_hostVersion = context.TryGetProperty<string>(PluginHostPropertyKeys.HostVersion, out var hostVersionText) &&
|
||||
AirAppMarketIndexDocument.TryParseVersion(hostVersionText, out var parsedHostVersion)
|
||||
? parsedHostVersion
|
||||
: null;
|
||||
|
||||
_searchTextBox = new TextBox
|
||||
{
|
||||
MinWidth = 240,
|
||||
Watermark = T("market.toolbar.search_placeholder", "搜索插件")
|
||||
};
|
||||
_searchTextBox.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.Property == TextBox.TextProperty)
|
||||
{
|
||||
RebuildSurface();
|
||||
}
|
||||
};
|
||||
|
||||
_refreshButton = new Button
|
||||
{
|
||||
Content = T("market.toolbar.refresh", "刷新"),
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
_refreshButton.Click += OnRefreshClick;
|
||||
|
||||
_statusTextBlock = new TextBlock
|
||||
{
|
||||
Text = T("market.status.loading", "正在加载官方插件市场…"),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = WarningBrush
|
||||
};
|
||||
|
||||
_pluginListHost = new StackPanel
|
||||
{
|
||||
Spacing = 10
|
||||
};
|
||||
|
||||
_detailBorder = CreatePanelShell();
|
||||
|
||||
Content = BuildLayout();
|
||||
AttachedToVisualTree += async (_, _) =>
|
||||
{
|
||||
if (_hasLoadedOnce)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_hasLoadedOnce = true;
|
||||
await RefreshAsync();
|
||||
};
|
||||
}
|
||||
|
||||
private Control BuildLayout()
|
||||
{
|
||||
var root = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("Auto,*"),
|
||||
RowSpacing = 16
|
||||
};
|
||||
|
||||
var toolbar = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 12
|
||||
};
|
||||
|
||||
toolbar.Children.Add(new StackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
_searchTextBox,
|
||||
_refreshButton
|
||||
}
|
||||
},
|
||||
_statusTextBlock
|
||||
}
|
||||
});
|
||||
|
||||
var contentGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("360,*"),
|
||||
ColumnSpacing = 16
|
||||
};
|
||||
|
||||
var listShell = CreatePanelShell();
|
||||
listShell.Child = new ScrollViewer
|
||||
{
|
||||
Content = _pluginListHost
|
||||
};
|
||||
|
||||
contentGrid.Children.Add(listShell);
|
||||
contentGrid.Children.Add(_detailBorder);
|
||||
Grid.SetColumn(_detailBorder, 1);
|
||||
|
||||
root.Children.Add(toolbar);
|
||||
root.Children.Add(contentGrid);
|
||||
Grid.SetRow(contentGrid, 1);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private async Task RefreshAsync()
|
||||
{
|
||||
if (_isRefreshing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRefreshing = true;
|
||||
_refreshButton.IsEnabled = false;
|
||||
SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush);
|
||||
|
||||
try
|
||||
{
|
||||
_installedPlugins = _packageManager
|
||||
.GetInstalledPlugins()
|
||||
.ToDictionary(plugin => plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var result = await _indexService.LoadAsync();
|
||||
if (!result.Success || result.Document is null)
|
||||
{
|
||||
_document = null;
|
||||
_selectedPlugin = null;
|
||||
SetStatus(
|
||||
F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
|
||||
ErrorBrush);
|
||||
RebuildSurface();
|
||||
return;
|
||||
}
|
||||
|
||||
_document = result.Document;
|
||||
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
|
||||
|
||||
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
|
||||
? F(
|
||||
"market.status.loaded_cache_format",
|
||||
"官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
result.Document.Plugins.Count,
|
||||
result.WarningMessage ?? T("market.detail.unknown", "未知错误"))
|
||||
: F(
|
||||
"market.status.loaded_network_format",
|
||||
"已从官方源加载 {0} 个插件。",
|
||||
result.Document.Plugins.Count);
|
||||
|
||||
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
|
||||
RebuildSurface();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRefreshing = false;
|
||||
_refreshButton.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildSurface()
|
||||
{
|
||||
var filteredPlugins = GetFilteredPlugins();
|
||||
if (filteredPlugins.Count > 0)
|
||||
{
|
||||
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins);
|
||||
}
|
||||
else
|
||||
{
|
||||
_selectedPlugin = null;
|
||||
}
|
||||
|
||||
BuildPluginList(filteredPlugins);
|
||||
BuildDetailPanel();
|
||||
}
|
||||
|
||||
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
|
||||
{
|
||||
if (_document is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var query = (_searchTextBox.Text ?? string.Empty).Trim();
|
||||
var source = _document.Plugins;
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return source.ToList();
|
||||
}
|
||||
|
||||
return source
|
||||
.Where(plugin =>
|
||||
plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void BuildPluginList(IReadOnlyList<AirAppMarketPluginEntry> plugins)
|
||||
{
|
||||
_pluginListHost.Children.Clear();
|
||||
|
||||
if (_document is null)
|
||||
{
|
||||
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugins.Count == 0)
|
||||
{
|
||||
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。")));
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
_pluginListHost.Children.Add(CreatePluginCard(plugin));
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreatePluginCard(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
var installState = ResolveInstallState(plugin, out var installedPlugin);
|
||||
var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
Padding = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Content = new Border
|
||||
{
|
||||
Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 10,
|
||||
Children =
|
||||
{
|
||||
new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 12,
|
||||
Children =
|
||||
{
|
||||
CreateMonogramIcon(plugin.Name, 42),
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = plugin.Name,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version),
|
||||
Foreground = Brushes.Gray,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = plugin.Description,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxHeight = 56
|
||||
},
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
|
||||
CreateStateChip(installedPlugin?.IsLoaded == true
|
||||
? T("market.card.loaded", "已加载")
|
||||
: T("market.card.pending_restart", "需重启")),
|
||||
new TextBlock
|
||||
{
|
||||
Text = string.Join(" ", plugin.Tags.Take(3)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Foreground = Brushes.Gray
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
button.Click += (_, _) =>
|
||||
{
|
||||
_selectedPlugin = plugin;
|
||||
RebuildSurface();
|
||||
};
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private void BuildDetailPanel()
|
||||
{
|
||||
if (_selectedPlugin is null)
|
||||
{
|
||||
_detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。"));
|
||||
return;
|
||||
}
|
||||
|
||||
var plugin = _selectedPlugin;
|
||||
var installState = ResolveInstallState(plugin, out var installedPlugin);
|
||||
var isCompatible = IsCompatibleWithHost(plugin);
|
||||
var installButton = new Button
|
||||
{
|
||||
Content = _isInstalling
|
||||
? T("market.button.installing", "安装中…")
|
||||
: T(ButtonKey(installState), ButtonFallback(installState)),
|
||||
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
MinWidth = 120
|
||||
};
|
||||
installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin);
|
||||
|
||||
var detailPanel = new StackPanel
|
||||
{
|
||||
Spacing = 14,
|
||||
Children =
|
||||
{
|
||||
new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 14,
|
||||
Children =
|
||||
{
|
||||
CreateMonogramIcon(plugin.Name, 64),
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = plugin.Name,
|
||||
FontSize = 24,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = plugin.Description,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
Children =
|
||||
{
|
||||
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
|
||||
CreateStateChip(plugin.GetVersionSummary()),
|
||||
CreateStateChip(string.Join(", ", plugin.Tags))
|
||||
}
|
||||
},
|
||||
installButton,
|
||||
CreateInfoRow(T("market.detail.author", "作者"), plugin.Author),
|
||||
CreateInfoRow(T("market.detail.version", "版本"), plugin.Version),
|
||||
CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion),
|
||||
CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion),
|
||||
CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")),
|
||||
CreateInfoRow(T("market.detail.market_source", "市场源"), AirAppMarketDefaults.DefaultIndexUrl),
|
||||
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
|
||||
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
|
||||
new TextBlock
|
||||
{
|
||||
Text = T("market.detail.release_notes", "发布说明"),
|
||||
FontSize = 18,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
},
|
||||
new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = plugin.ReleaseNotes,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isCompatible)
|
||||
{
|
||||
detailPanel.Children.Insert(
|
||||
3,
|
||||
new TextBlock
|
||||
{
|
||||
Text = F(
|
||||
"market.status.host_incompatible_format",
|
||||
"当前宿主版本过低,至少需要 {0}。",
|
||||
plugin.MinHostVersion),
|
||||
Foreground = ErrorBrush,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
});
|
||||
}
|
||||
|
||||
_detailBorder.Child = new ScrollViewer
|
||||
{
|
||||
Content = detailPanel
|
||||
};
|
||||
}
|
||||
|
||||
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
if (_isInstalling)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isInstalling = true;
|
||||
BuildDetailPanel();
|
||||
SetStatus(
|
||||
F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name),
|
||||
WarningBrush);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _installService.InstallAsync(plugin);
|
||||
if (!result.Success || result.InstallResult is null)
|
||||
{
|
||||
SetStatus(
|
||||
F(
|
||||
"market.status.install_failed_format",
|
||||
"安装插件失败:{0}",
|
||||
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
|
||||
ErrorBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
_installedPlugins = _packageManager
|
||||
.GetInstalledPlugins()
|
||||
.ToDictionary(item => item.Manifest.Id, StringComparer.OrdinalIgnoreCase);
|
||||
SetStatus(
|
||||
F(
|
||||
"market.status.install_success_format",
|
||||
"插件“{0}”已暂存完成,重启应用后生效。",
|
||||
result.InstallResult.Manifest.Name),
|
||||
SuccessBrush);
|
||||
RebuildSurface();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isInstalling = false;
|
||||
BuildDetailPanel();
|
||||
}
|
||||
}
|
||||
|
||||
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
|
||||
string? selectedPluginId,
|
||||
IReadOnlyList<AirAppMarketPluginEntry> plugins)
|
||||
{
|
||||
if (plugins.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(selectedPluginId))
|
||||
{
|
||||
var existing = plugins.FirstOrDefault(plugin =>
|
||||
string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is not null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
return plugins[0];
|
||||
}
|
||||
|
||||
private AirAppMarketInstallState ResolveInstallState(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
out InstalledPluginInfo? installedPlugin)
|
||||
{
|
||||
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
|
||||
{
|
||||
return AirAppMarketInstallState.NotInstalled;
|
||||
}
|
||||
|
||||
return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0
|
||||
? AirAppMarketInstallState.UpdateAvailable
|
||||
: AirAppMarketInstallState.Installed;
|
||||
}
|
||||
|
||||
private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
if (_hostVersion is null ||
|
||||
!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
|
||||
minHostVersion is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return _hostVersion >= minHostVersion;
|
||||
}
|
||||
|
||||
private void SetStatus(string message, IBrush foreground)
|
||||
{
|
||||
_statusTextBlock.Text = message;
|
||||
_statusTextBlock.Foreground = foreground;
|
||||
}
|
||||
|
||||
private static int CompareVersions(string? left, string? right)
|
||||
{
|
||||
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
|
||||
{
|
||||
leftVersion = new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
|
||||
{
|
||||
rightVersion = new Version(0, 0, 0);
|
||||
}
|
||||
|
||||
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));
|
||||
}
|
||||
|
||||
private Border CreatePanelShell()
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(18),
|
||||
Padding = new Thickness(16)
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateEmptyState(string text)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(18),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Border CreateMonogramIcon(string text, double size)
|
||||
{
|
||||
var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant();
|
||||
return new Border
|
||||
{
|
||||
Width = size,
|
||||
Height = size,
|
||||
CornerRadius = new CornerRadius(size / 2),
|
||||
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = glyph,
|
||||
FontSize = Math.Max(16, size * 0.36),
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
TextAlignment = TextAlignment.Center
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Border CreateStateChip(string text)
|
||||
{
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#22000000")),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Padding = new Thickness(10, 4),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontSize = 12
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Control CreateInfoRow(string label, string value)
|
||||
{
|
||||
return new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = label,
|
||||
FontSize = 12,
|
||||
Foreground = Brushes.Gray
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string F(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args);
|
||||
}
|
||||
|
||||
private static string StateKey(AirAppMarketInstallState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
|
||||
AirAppMarketInstallState.Installed => "market.detail.state.installed",
|
||||
_ => "market.detail.state.not_installed"
|
||||
};
|
||||
}
|
||||
|
||||
private static string StateFallback(AirAppMarketInstallState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AirAppMarketInstallState.UpdateAvailable => "可更新",
|
||||
AirAppMarketInstallState.Installed => "已安装",
|
||||
_ => "未安装"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ButtonKey(AirAppMarketInstallState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AirAppMarketInstallState.UpdateAvailable => "market.button.update",
|
||||
AirAppMarketInstallState.Installed => "market.button.installed",
|
||||
_ => "market.button.install"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ButtonFallback(AirAppMarketInstallState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AirAppMarketInstallState.UpdateAvailable => "更新",
|
||||
AirAppMarketInstallState.Installed => "已安装",
|
||||
_ => "安装"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.PluginMarketplace",
|
||||
"name": "LanMountain Plugin Marketplace",
|
||||
"description": "Official plugin marketplace for browsing and installing LanMountainDesktop plugins.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.PluginMarketplace.dll"
|
||||
}
|
||||
@@ -67,6 +67,11 @@
|
||||
"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",
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
这是阑山桌面的**示例开发插件**。
|
||||
## 中文
|
||||
|
||||
它用于演示以下能力:
|
||||
- 插件入口与 `plugin.json` 清单
|
||||
- 插件服务注册
|
||||
- 插件设置页注册
|
||||
- 插件桌面组件注册
|
||||
- 插件内通信与状态更新
|
||||
- `.laapp` 打包与安装流程
|
||||
- 插件多语言资源组织方式
|
||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||
|
||||
如果你要开发自己的插件,建议以这个目录为模板开始。
|
||||
## English
|
||||
|
||||
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
|
||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
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.");
|
||||
|
||||
@@ -74,6 +75,19 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
# 示例插件
|
||||
# 示例插件目录
|
||||
|
||||
本目录用于存放阑山桌面的示例开发插件。
|
||||
## 中文
|
||||
|
||||
当前示例:
|
||||
- `LanMountainDesktop.SamplePlugin`
|
||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||
|
||||
说明:
|
||||
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
|
||||
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
|
||||
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。
|
||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# 插件标准文件
|
||||
# 插件标准说明
|
||||
|
||||
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
|
||||
## 中文
|
||||
|
||||
当前标准:
|
||||
- 安装包扩展名:`.laapp`
|
||||
- 插件清单文件名:`plugin.json`
|
||||
- 多语言资源目录:`Localization/`
|
||||
- 建议内置语言文件:`zh-CN.json`、`en-US.json`
|
||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||
|
||||
创建新插件时,建议优先参考本目录中的模板文件。
|
||||
## English
|
||||
|
||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPlugin
|
||||
{
|
||||
void Initialize(IPluginContext context);
|
||||
void Initialize(HostBuilderContext context, IServiceCollection services);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginContext
|
||||
[Obsolete("Plugin API 2.0.0 uses IPluginRuntimeContext and IServiceCollection-based initialization.")]
|
||||
public interface IPluginContext : IPluginRuntimeContext
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
13
LanMountainDesktop.PluginSdk/IPluginExportRegistry.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginExportRegistry
|
||||
{
|
||||
IReadOnlyList<PluginServiceExportDescriptor> GetExports();
|
||||
|
||||
IReadOnlyList<PluginServiceExportDescriptor> GetExports(Type contractType);
|
||||
|
||||
PluginServiceExportDescriptor? GetExport(Type contractType, string providerPluginId);
|
||||
|
||||
TContract? GetExport<TContract>(string providerPluginId)
|
||||
where TContract : class;
|
||||
}
|
||||
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
18
LanMountainDesktop.PluginSdk/IPluginRuntimeContext.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginRuntimeContext
|
||||
{
|
||||
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);
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
<Version>2.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public virtual void Initialize(IPluginContext context)
|
||||
public virtual void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public sealed class PluginDesktopComponentRegistration
|
||||
public PluginDesktopComponentRegistration(
|
||||
string componentId,
|
||||
string displayName,
|
||||
Func<PluginDesktopComponentContext, Control> controlFactory,
|
||||
Func<IServiceProvider, PluginDesktopComponentContext, Control> controlFactory,
|
||||
string iconKey = "PuzzlePiece",
|
||||
string category = "Plugins",
|
||||
int minWidthCells = 2,
|
||||
@@ -40,13 +40,42 @@ public sealed class PluginDesktopComponentRegistration
|
||||
CornerRadiusResolver = cornerRadiusResolver;
|
||||
}
|
||||
|
||||
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)
|
||||
: this(
|
||||
componentId,
|
||||
displayName,
|
||||
(_, context) => controlFactory(context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver)
|
||||
{
|
||||
}
|
||||
|
||||
public string ComponentId { get; }
|
||||
|
||||
public string DisplayName { get; }
|
||||
|
||||
public string? DisplayNameLocalizationKey { get; }
|
||||
|
||||
public Func<PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
public Func<IServiceProvider, PluginDesktopComponentContext, Control> ControlFactory { get; }
|
||||
|
||||
public string IconKey { get; }
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ public sealed class PluginLocalizer
|
||||
|
||||
public string LanguageCode { get; }
|
||||
|
||||
public static PluginLocalizer Create(IPluginContext context)
|
||||
public static PluginLocalizer Create(IPluginRuntimeContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
|
||||
@@ -9,7 +9,8 @@ public sealed record PluginManifest(
|
||||
string? Description = null,
|
||||
string? Author = null,
|
||||
string? Version = null,
|
||||
string? ApiVersion = null)
|
||||
string? ApiVersion = null,
|
||||
IReadOnlyList<PluginSharedContractReference>? SharedContracts = null)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -57,6 +58,7 @@ public sealed record PluginManifest(
|
||||
|
||||
private PluginManifest NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalizedSharedContracts = NormalizeSharedContracts(manifestPath, SharedContracts);
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
@@ -65,7 +67,8 @@ public sealed record PluginManifest(
|
||||
Description = NormalizeOptionalValue(Description),
|
||||
Author = NormalizeOptionalValue(Author),
|
||||
Version = NormalizeOptionalValue(Version),
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion
|
||||
ApiVersion = NormalizeOptionalValue(ApiVersion) ?? PluginSdkInfo.ApiVersion,
|
||||
SharedContracts = normalizedSharedContracts
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.ApiVersion, out var requestedVersion))
|
||||
@@ -82,7 +85,41 @@ public sealed record PluginManifest(
|
||||
if (requestedVersion.Major != currentVersion.Major)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'.");
|
||||
$"Plugin '{normalized.Id}' targets API version '{normalized.ApiVersion}', but the host provides '{PluginSdkInfo.ApiVersion}'. Upgrade the plugin to API {PluginSdkInfo.ApiVersion}.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PluginSharedContractReference> NormalizeSharedContracts(
|
||||
string manifestPath,
|
||||
IReadOnlyList<PluginSharedContractReference>? sharedContracts)
|
||||
{
|
||||
if (sharedContracts is null || sharedContracts.Count == 0)
|
||||
{
|
||||
return Array.Empty<PluginSharedContractReference>();
|
||||
}
|
||||
|
||||
var normalized = new List<PluginSharedContractReference>(sharedContracts.Count);
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var contract in sharedContracts)
|
||||
{
|
||||
if (contract is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' contains a null shared contract declaration.");
|
||||
}
|
||||
|
||||
var normalizedContract = contract.NormalizeAndValidate(manifestPath);
|
||||
var contractKey = $"{normalizedContract.Id}@{normalizedContract.Version}";
|
||||
if (!seenIds.Add(contractKey))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares duplicate shared contract '{contractKey}'.");
|
||||
}
|
||||
|
||||
normalized.Add(normalizedContract);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginSdkInfo
|
||||
{
|
||||
public const string ApiVersion = "1.0.0";
|
||||
public const string ApiVersion = "2.0.0";
|
||||
public const string ManifestFileName = "plugin.json";
|
||||
public const string PackageFileExtension = ".laapp";
|
||||
public const string DataDirectoryName = "Data";
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using Avalonia.Controls;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddPluginSettingsPage<TControl>(
|
||||
this IServiceCollection services,
|
||||
string id,
|
||||
string title,
|
||||
int sortOrder = 0)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginSettingsPageRegistration(
|
||||
id,
|
||||
title,
|
||||
provider => ActivatorUtilities.CreateInstance<TControl>(provider),
|
||||
sortOrder));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginDesktopComponent<TControl>(
|
||||
this IServiceCollection services,
|
||||
string componentId,
|
||||
string displayName,
|
||||
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)
|
||||
where TControl : Control
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton(new PluginDesktopComponentRegistration(
|
||||
componentId,
|
||||
displayName,
|
||||
(provider, context) => ActivatorUtilities.CreateInstance<TControl>(provider, context),
|
||||
iconKey,
|
||||
category,
|
||||
minWidthCells,
|
||||
minHeightCells,
|
||||
allowDesktopPlacement,
|
||||
allowStatusBarPlacement,
|
||||
resizeMode,
|
||||
displayNameLocalizationKey,
|
||||
cornerRadiusResolver));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddPluginExport<TContract, TImplementation>(this IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
EnsureSingletonRegistration<TContract, TImplementation>(services);
|
||||
|
||||
if (!services.Any(descriptor =>
|
||||
descriptor.ServiceType == typeof(PluginServiceExportRegistration) &&
|
||||
descriptor.ImplementationInstance is PluginServiceExportRegistration existing &&
|
||||
existing.ContractType == typeof(TContract) &&
|
||||
existing.ImplementationType == typeof(TImplementation)))
|
||||
{
|
||||
services.AddSingleton(new PluginServiceExportRegistration(typeof(TContract), typeof(TImplementation)));
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void EnsureSingletonRegistration<TContract, TImplementation>(IServiceCollection services)
|
||||
where TContract : class
|
||||
where TImplementation : class, TContract
|
||||
{
|
||||
var contractDescriptor = services.LastOrDefault(descriptor => descriptor.ServiceType == typeof(TContract));
|
||||
if (contractDescriptor is null)
|
||||
{
|
||||
services.AddSingleton<TContract, TImplementation>();
|
||||
return;
|
||||
}
|
||||
|
||||
if (contractDescriptor.Lifetime != ServiceLifetime.Singleton)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Exported contract '{typeof(TContract).FullName}' must be registered as Singleton.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginServiceExportDescriptor(
|
||||
string ProviderPluginId,
|
||||
Type ContractType,
|
||||
object ServiceInstance);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginServiceExportRegistration
|
||||
{
|
||||
public PluginServiceExportRegistration(Type contractType, Type implementationType)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contractType);
|
||||
ArgumentNullException.ThrowIfNull(implementationType);
|
||||
|
||||
ContractType = contractType;
|
||||
ImplementationType = implementationType;
|
||||
}
|
||||
|
||||
public Type ContractType { get; }
|
||||
|
||||
public Type ImplementationType { get; }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ public sealed class PluginSettingsPageRegistration
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
Func<IServiceProvider, Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
@@ -20,11 +20,20 @@ public sealed class PluginSettingsPageRegistration
|
||||
SortOrder = sortOrder;
|
||||
}
|
||||
|
||||
public PluginSettingsPageRegistration(
|
||||
string id,
|
||||
string title,
|
||||
Func<Control> contentFactory,
|
||||
int sortOrder = 0)
|
||||
: this(id, title, _ => contentFactory(), sortOrder)
|
||||
{
|
||||
}
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
public string Title { get; }
|
||||
|
||||
public int SortOrder { get; }
|
||||
|
||||
public Func<Control> ContentFactory { get; }
|
||||
public Func<IServiceProvider, Control> ContentFactory { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginSharedContractReference(
|
||||
string Id,
|
||||
string Version,
|
||||
string AssemblyName)
|
||||
{
|
||||
[JsonIgnore]
|
||||
public string NormalizedId => Id.Trim();
|
||||
|
||||
[JsonIgnore]
|
||||
public string NormalizedVersion => Version.Trim();
|
||||
|
||||
[JsonIgnore]
|
||||
public string NormalizedAssemblyName => AssemblyName.Trim();
|
||||
|
||||
internal PluginSharedContractReference NormalizeAndValidate(string manifestPath)
|
||||
{
|
||||
var normalized = this with
|
||||
{
|
||||
Id = RequireValue(Id, nameof(Id), manifestPath),
|
||||
Version = RequireValue(Version, nameof(Version), manifestPath),
|
||||
AssemblyName = RequireValue(AssemblyName, nameof(AssemblyName), manifestPath)
|
||||
};
|
||||
|
||||
if (!System.Version.TryParse(normalized.Version, out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' declares invalid shared contract version '{normalized.Version}' for '{normalized.Id}'.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RequireValue(string? value, string propertyName, string manifestPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin manifest '{manifestPath}' is missing required shared contract property '{propertyName}'.");
|
||||
}
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
187
LanMountainDesktop.Tests/ComponentSettingsServiceTests.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentSettingsServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Load_MigratesLegacySnapshotFileToCanonicalDocument()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
File.WriteAllText(
|
||||
sandbox.SettingsPath,
|
||||
"""
|
||||
{
|
||||
"DesktopClockSecondHandMode": "Sweep",
|
||||
"ImportedClassSchedules": [
|
||||
{
|
||||
"Id": "spring-2026",
|
||||
"DisplayName": "Spring 2026",
|
||||
"FilePath": "C:\\Schedules\\spring-2026.yaml"
|
||||
}
|
||||
],
|
||||
"ActiveImportedClassScheduleId": "spring-2026"
|
||||
}
|
||||
""");
|
||||
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var snapshot = service.Load();
|
||||
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(snapshot.ImportedClassSchedules);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("defaultSettings", out var defaultSettings));
|
||||
Assert.Equal("Sweep", defaultSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("DesktopClockSecondHandMode", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ReadsPascalCaseDocumentAndRewritesToCanonicalDocument()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
File.WriteAllText(
|
||||
sandbox.SettingsPath,
|
||||
"""
|
||||
{
|
||||
"DefaultSettings": {
|
||||
"DesktopClockSecondHandMode": "Tick"
|
||||
},
|
||||
"InstanceSettings": {
|
||||
"DesktopClock::clock-2x2": {
|
||||
"DesktopClockSecondHandMode": "Sweep"
|
||||
}
|
||||
},
|
||||
"PluginSettings": {
|
||||
"DesktopClock::clock-2x2": {
|
||||
"SampleFlag": true
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var snapshot = service.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var pluginSettings = service.LoadPluginSettings<SamplePluginSettings>("DesktopClock", "clock-2x2");
|
||||
|
||||
Assert.Equal("Sweep", snapshot.DesktopClockSecondHandMode);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out var clockSettings));
|
||||
Assert.Equal("Sweep", clockSettings.GetProperty("desktopClockSecondHandMode").GetString());
|
||||
Assert.False(document.RootElement.TryGetProperty("InstanceSettings", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveForComponent_RoundTripsInstanceAndPluginSettingsAcrossNewService()
|
||||
{
|
||||
using var sandbox = new ComponentSettingsSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
service.SaveForComponent(
|
||||
"DesktopClock",
|
||||
"clock-2x2",
|
||||
new ComponentSettingsSnapshot
|
||||
{
|
||||
DesktopClockSecondHandMode = "Sweep"
|
||||
});
|
||||
service.SaveForComponent(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2",
|
||||
new ComponentSettingsSnapshot
|
||||
{
|
||||
ImportedClassSchedules =
|
||||
[
|
||||
new ImportedClassScheduleSnapshot
|
||||
{
|
||||
Id = "spring-2026",
|
||||
DisplayName = "Spring 2026",
|
||||
FilePath = "C:\\Schedules\\spring-2026.yaml"
|
||||
}
|
||||
],
|
||||
ActiveImportedClassScheduleId = "spring-2026"
|
||||
});
|
||||
service.SavePluginSettings(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2",
|
||||
new SamplePluginSettings
|
||||
{
|
||||
SampleFlag = true,
|
||||
Title = "schedule-settings"
|
||||
});
|
||||
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
var reloadedService = sandbox.CreateService();
|
||||
|
||||
var clockSnapshot = reloadedService.LoadForComponent("DesktopClock", "clock-2x2");
|
||||
var classScheduleSnapshot = reloadedService.LoadForComponent("DesktopClassSchedule", "class-schedule-2x2");
|
||||
var pluginSettings = reloadedService.LoadPluginSettings<SamplePluginSettings>(
|
||||
"DesktopClassSchedule",
|
||||
"class-schedule-2x2");
|
||||
|
||||
Assert.Equal("Sweep", clockSnapshot.DesktopClockSecondHandMode);
|
||||
Assert.Single(classScheduleSnapshot.ImportedClassSchedules);
|
||||
Assert.Equal("spring-2026", classScheduleSnapshot.ActiveImportedClassScheduleId);
|
||||
Assert.True(pluginSettings.SampleFlag);
|
||||
Assert.Equal("schedule-settings", pluginSettings.Title);
|
||||
|
||||
using var document = JsonDocument.Parse(File.ReadAllText(sandbox.SettingsPath));
|
||||
Assert.True(document.RootElement.TryGetProperty("instanceSettings", out var instanceSettings));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClock::clock-2x2", out _));
|
||||
Assert.True(instanceSettings.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
Assert.True(document.RootElement.TryGetProperty("pluginSettings", out var pluginSettingsNode));
|
||||
Assert.True(pluginSettingsNode.TryGetProperty("DesktopClassSchedule::class-schedule-2x2", out _));
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsSandbox : IDisposable
|
||||
{
|
||||
private readonly string _directoryPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop.ComponentSettingsTests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public ComponentSettingsSandbox()
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
}
|
||||
|
||||
public string SettingsPath => Path.Combine(_directoryPath, "component-settings.json");
|
||||
|
||||
public ComponentSettingsService CreateService()
|
||||
{
|
||||
return new ComponentSettingsService(_directoryPath);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ComponentSettingsService.ResetCacheForTests();
|
||||
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_directoryPath))
|
||||
{
|
||||
Directory.Delete(_directoryPath, true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Temporary test directories are best-effort cleanup.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SamplePluginSettings
|
||||
{
|
||||
public bool SampleFlag { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
21
LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj
Normal file
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop\LanMountainDesktop.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
43
LanMountainDesktop.Tests/UiExceptionGuardTests.cs
Normal file
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class UiExceptionGuardTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunGuardedUiActionAsync_SwallowsNonFatalException_AndInvokesHandler()
|
||||
{
|
||||
var handlerCalled = false;
|
||||
|
||||
await UiExceptionGuard.RunGuardedUiActionAsync(
|
||||
() => throw new InvalidOperationException("boom"),
|
||||
"UnitTest.NonFatal",
|
||||
onHandledException: ex =>
|
||||
{
|
||||
handlerCalled = ex is InvalidOperationException;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
Assert.True(handlerCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunGuardedUiActionAsync_RethrowsFatalException()
|
||||
{
|
||||
await Assert.ThrowsAsync<OutOfMemoryException>(() =>
|
||||
UiExceptionGuard.RunGuardedUiActionAsync(
|
||||
() => throw new OutOfMemoryException("fatal"),
|
||||
"UnitTest.Fatal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFatalException_ReturnsExpectedClassification()
|
||||
{
|
||||
Assert.True(UiExceptionGuard.IsFatalException(new OutOfMemoryException()));
|
||||
Assert.True(UiExceptionGuard.IsFatalException(new AccessViolationException()));
|
||||
Assert.False(UiExceptionGuard.IsFatalException(new InvalidOperationException()));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
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
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
7
LanMountainDesktop.slnx
Normal file
7
LanMountainDesktop.slnx
Normal file
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<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" />
|
||||
<Project Path="LanMountainDesktop.Tests/LanMountainDesktop.Tests.csproj" />
|
||||
</Solution>
|
||||
@@ -16,23 +16,6 @@
|
||||
<local:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
<TrayIcon.Icons>
|
||||
<TrayIcons>
|
||||
<TrayIcon Icon="/Assets/avalonia-logo.ico"
|
||||
ToolTipText="LanMountainDesktop">
|
||||
<TrayIcon.Menu>
|
||||
<NativeMenu>
|
||||
<NativeMenuItem Header="设置" Click="OnTraySettingsClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="重启应用" Click="OnTrayRestartClick" />
|
||||
<NativeMenuItemSeparator />
|
||||
<NativeMenuItem Header="退出应用" Click="OnTrayExitClick" />
|
||||
</NativeMenu>
|
||||
</TrayIcon.Menu>
|
||||
</TrayIcon>
|
||||
</TrayIcons>
|
||||
</TrayIcon.Icons>
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme />
|
||||
<mi:MaterialIconStyles />
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
using Avalonia;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Data.Core.Plugins;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
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);
|
||||
@@ -30,19 +45,30 @@ 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}");
|
||||
LogBrowserStartupDiagnostics();
|
||||
CurrentSingleInstanceService?.StartActivationListener(ActivateMainWindow);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
@@ -50,10 +76,9 @@ public partial class App : Application
|
||||
|
||||
private void OnTrayExitClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
_ = _hostApplicationLifecycle.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Exit App from the tray menu."));
|
||||
}
|
||||
|
||||
private void OnTraySettingsClick(object? sender, EventArgs e)
|
||||
@@ -89,14 +114,16 @@ public partial class App : Application
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[TraySettings] Failed to open settings window: {ex}");
|
||||
AppLogger.Warn("TraySettings", "Failed to open settings window.", ex);
|
||||
}
|
||||
}, DispatcherPriority.Normal);
|
||||
}
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
AppRestartService.TryRestartApplication();
|
||||
_ = _hostApplicationLifecycle.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: "TrayMenu",
|
||||
Reason: "User selected Restart App from the tray menu."));
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
@@ -133,9 +160,10 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +177,203 @@ public partial class App : Application
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[PluginRuntime] Failed to initialize plugin runtime: {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;
|
||||
}
|
||||
|
||||
if (mainWindow.WindowState != WindowState.FullScreen)
|
||||
{
|
||||
mainWindow.WindowState = WindowState.FullScreen;
|
||||
}
|
||||
|
||||
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 void LogBrowserStartupDiagnostics()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = new DesktopLayoutSettingsService().Load();
|
||||
var browserPlacements = snapshot.DesktopComponentPlacements
|
||||
.Where(placement => string.Equals(
|
||||
placement.ComponentId,
|
||||
BuiltInComponentIds.DesktopBrowser,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
var runtimeAvailability = WebView2RuntimeProbe.GetAvailability();
|
||||
|
||||
AppLogger.Info(
|
||||
"StartupDiagnostics",
|
||||
$"Browser component diagnostics. HasBrowserPlacement={browserPlacements.Count > 0}; " +
|
||||
$"ActivePageHasBrowser={browserPlacements.Any(item => item.PageIndex == snapshot.CurrentDesktopSurfaceIndex)}; " +
|
||||
$"CurrentDesktopSurfaceIndex={snapshot.CurrentDesktopSurfaceIndex}; " +
|
||||
$"WebViewRuntimeAvailable={runtimeAvailability.IsAvailable}; " +
|
||||
$"WebViewRuntimeVersion={runtimeAvailability.Version ?? string.Empty}; " +
|
||||
$"WebViewRuntimeMessage={runtimeAvailability.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("StartupDiagnostics", "Failed to log browser component diagnostics.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginsInstallHelper\LanMountainDesktop.PluginsInstallHelper.csproj"
|
||||
ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -40,10 +42,13 @@
|
||||
</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" />
|
||||
<PackageReference Include="Material.Icons.Avalonia" Version="2.4.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.0" />
|
||||
<PackageReference Include="LibVLCSharp.Avalonia" Version="3.9.5" />
|
||||
<PackageReference Include="PortAudioSharp2" Version="1.0.6" />
|
||||
@@ -55,4 +60,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,5 +1,9 @@
|
||||
{
|
||||
"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",
|
||||
@@ -247,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}",
|
||||
@@ -302,15 +307,17 @@
|
||||
"settings.launcher.hidden_empty": "No hidden items.",
|
||||
"settings.launcher.hidden_type_folder": "Folder",
|
||||
"settings.launcher.hidden_type_shortcut": "Shortcut",
|
||||
"settings.launcher.restore_button": "Show Again",
|
||||
"settings.launcher.restore_button": "Unhide",
|
||||
"settings.plugins.title": "Plugins",
|
||||
"settings.plugins.runtime_header": "Plugin Runtime",
|
||||
"settings.plugins.runtime_desc": "Review plugin runtime state and load results.",
|
||||
"settings.plugins.runtime_hint": "This page shows discovery status, load results, and runtime diagnostics for installed plugins.",
|
||||
"settings.plugins.runtime_status": "Plugin runtime status will appear here after plugin discovery completes.",
|
||||
"settings.plugins.installed_header": "Installed Plugins",
|
||||
"settings.plugins.installed_desc": "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.",
|
||||
"settings.plugins.restart_hint": "Plugin enable state changes take effect after restarting the app.",
|
||||
"settings.plugins.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}.",
|
||||
@@ -325,6 +332,7 @@
|
||||
"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}",
|
||||
@@ -334,14 +342,20 @@
|
||||
"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.plugins.market_header": "Official Market",
|
||||
"settings.plugins.market_desc": "Browse plugins from the official LanAirApp source and stage installs.",
|
||||
"settings.plugins.market_hint": "Use the official market source hosted in LanAirApp to discover and stage plugin installs.",
|
||||
"settings.plugins.market_unavailable": "Plugin runtime is not available, so the official market cannot be opened right now.",
|
||||
"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...",
|
||||
@@ -364,6 +378,15 @@
|
||||
"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",
|
||||
@@ -720,7 +743,10 @@
|
||||
"placement.fit": "Fit",
|
||||
"placement.stretch": "Stretch",
|
||||
"placement.center": "Center",
|
||||
"placement.tile": "Tile"
|
||||
}
|
||||
"placement.tile": "Tile",
|
||||
"single_instance.notice.title": "App already running",
|
||||
"single_instance.notice.description": "The app is already running. There is no need to click multiple times to open it.",
|
||||
"single_instance.notice.button": "OK"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"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": "设置",
|
||||
@@ -247,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}",
|
||||
@@ -302,15 +307,17 @@
|
||||
"settings.launcher.hidden_empty": "暂无隐藏项目。",
|
||||
"settings.launcher.hidden_type_folder": "文件夹",
|
||||
"settings.launcher.hidden_type_shortcut": "快捷方式",
|
||||
"settings.launcher.restore_button": "重新显示",
|
||||
"settings.launcher.restore_button": "取消隐藏",
|
||||
"settings.plugins.title": "插件",
|
||||
"settings.plugins.runtime_header": "插件运行时",
|
||||
"settings.plugins.runtime_desc": "查看插件运行时状态、加载结果与诊断信息。",
|
||||
"settings.plugins.runtime_hint": "这里展示已安装插件的发现结果、加载状态和运行时诊断信息。",
|
||||
"settings.plugins.runtime_status": "插件扫描完成后,运行时状态会显示在这里。",
|
||||
"settings.plugins.installed_header": "已安装插件",
|
||||
"settings.plugins.installed_desc": "在这里启用或禁用插件。插件自己的详细设置会作为独立设置页出现。",
|
||||
"settings.plugins.restart_hint": "插件启用状态变更会在重启应用后生效。",
|
||||
"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} 个。",
|
||||
@@ -325,6 +332,7 @@
|
||||
"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}",
|
||||
@@ -334,14 +342,20 @@
|
||||
"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.plugins.market_header": "官方市场",
|
||||
"settings.plugins.market_desc": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugins.market_hint": "这里使用托管在 LanAirApp 仓库中的官方市场索引来发现插件并暂存安装。",
|
||||
"settings.plugins.market_unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"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": "正在加载官方插件市场...",
|
||||
@@ -364,6 +378,15 @@
|
||||
"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": "仓库",
|
||||
@@ -720,7 +743,10 @@
|
||||
"placement.fit": "适应",
|
||||
"placement.stretch": "拉伸",
|
||||
"placement.center": "居中",
|
||||
"placement.tile": "平铺"
|
||||
}
|
||||
"placement.tile": "平铺",
|
||||
"single_instance.notice.title": "应用已经运行",
|
||||
"single_instance.notice.description": "应用已经运行,无需多次点击打开。",
|
||||
"single_instance.notice.button": "确定"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +1,10 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.WebView.Desktop;
|
||||
using LanMountainDesktop.Services;
|
||||
using System;
|
||||
|
||||
namespace LanMountainDesktop;
|
||||
|
||||
@@ -11,8 +14,49 @@ 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(LoadConfiguredRenderMode())
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
AppLogger.Initialize();
|
||||
RegisterGlobalExceptionLogging();
|
||||
var restartParentProcessId = AppRestartService.TryGetRestartParentProcessId(args);
|
||||
|
||||
using var singleInstance = AcquireSingleInstance(restartParentProcessId);
|
||||
if (!singleInstance.IsPrimaryInstance)
|
||||
{
|
||||
if (restartParentProcessId is not null)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Startup",
|
||||
$"Restart relaunch could not acquire the single-instance lock. pid={restartParentProcessId.Value}. Suppressing multi-open activation prompt.");
|
||||
return;
|
||||
}
|
||||
|
||||
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(string renderMode = AppRenderingModeHelper.Default)
|
||||
@@ -38,15 +82,89 @@ sealed class Program
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static SingleInstanceService AcquireSingleInstance(int? restartParentProcessId)
|
||||
{
|
||||
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
|
||||
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);
|
||||
eventArgs.SetObserved();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
3
LanMountainDesktop/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("LanMountainDesktop.Tests")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,26 +3,19 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
private const string RestartParentPidArgumentPrefix = "--restart-parent-pid=";
|
||||
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
if (!TryRestartCurrentProcess())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
return true;
|
||||
return App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(AppRestartService),
|
||||
Reason: "Legacy restart entry point invoked.")) == true;
|
||||
}
|
||||
|
||||
public static bool TryRestartCurrentProcess()
|
||||
@@ -84,6 +77,21 @@ public static class AppRestartService
|
||||
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,
|
||||
@@ -97,6 +105,7 @@ public static class AppRestartService
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ public static class AppRestartService
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
AppendRestartParentProcessArgument(startInfo);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
@@ -126,10 +136,34 @@ public static class AppRestartService
|
||||
{
|
||||
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))
|
||||
|
||||
@@ -63,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();
|
||||
}
|
||||
}
|
||||
@@ -95,9 +96,9 @@ public sealed class AppSettingsService
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
@@ -29,9 +31,19 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
private string _scopedPlacementId = string.Empty;
|
||||
|
||||
public ComponentSettingsService()
|
||||
: this(Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"LanMountainDesktop"))
|
||||
{
|
||||
var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var settingsDirectory = Path.Combine(appData, "LanMountainDesktop");
|
||||
}
|
||||
|
||||
internal ComponentSettingsService(string settingsDirectory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settingsDirectory))
|
||||
{
|
||||
throw new ArgumentException("Settings directory cannot be null or whitespace.", nameof(settingsDirectory));
|
||||
}
|
||||
|
||||
_settingsPath = Path.Combine(settingsDirectory, "component-settings.json");
|
||||
_legacyAppSettingsPath = Path.Combine(settingsDirectory, "settings.json");
|
||||
}
|
||||
@@ -51,8 +63,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to load component settings from '{_settingsPath}'.", ex);
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -76,9 +89,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to save default component settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +112,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
return document.DefaultSettings.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to load component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
return new ComponentSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -124,9 +141,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to save component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +171,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to delete component settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,8 +197,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
return JsonSerializer.Deserialize<T>(settingsElement.GetRawText(), SerializerOptions) ?? new T();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to load plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
return new T();
|
||||
}
|
||||
}
|
||||
@@ -197,9 +224,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
PersistDocumentLocked(document);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to save plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,9 +252,12 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn(
|
||||
"ComponentSettings",
|
||||
$"Failed to delete plugin settings. ComponentId={componentId}; PlacementId={placementId}; Path={_settingsPath}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,10 +357,11 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
}
|
||||
|
||||
ComponentSettingsDocumentSnapshot loadedSnapshot;
|
||||
var loadedFromLegacy = false;
|
||||
var loadDetails = ComponentSettingsLoadDetails.Empty;
|
||||
if (hasFile)
|
||||
{
|
||||
loadedSnapshot = LoadSnapshotFromDisk();
|
||||
loadDetails = LoadSnapshotFromDisk();
|
||||
loadedSnapshot = loadDetails.Snapshot;
|
||||
}
|
||||
else if (TryLoadLegacySnapshot(out var migratedSnapshot))
|
||||
{
|
||||
@@ -335,7 +369,10 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(migratedSnapshot)
|
||||
};
|
||||
loadedFromLegacy = true;
|
||||
loadDetails = new ComponentSettingsLoadDetails(
|
||||
loadedSnapshot,
|
||||
ComponentSettingsDocumentFormat.LegacySnapshot,
|
||||
true);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -343,39 +380,44 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
}
|
||||
|
||||
var normalizedSnapshot = NormalizeDocument(loadedSnapshot);
|
||||
if (loadedFromLegacy)
|
||||
if (loadDetails.ShouldRewriteToCanonical)
|
||||
{
|
||||
writeTimeUtc = PersistSnapshotToDisk(normalizedSnapshot);
|
||||
}
|
||||
|
||||
LogLoadDetails(loadDetails.Format, loadDetails.ShouldRewriteToCanonical, normalizedSnapshot);
|
||||
UpdateCache(normalizedSnapshot, writeTimeUtc, nowUtc);
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
|
||||
private ComponentSettingsDocumentSnapshot LoadSnapshotFromDisk()
|
||||
private ComponentSettingsLoadDetails 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 _)))
|
||||
if (TryGetDocumentFormat(document.RootElement, out var format))
|
||||
{
|
||||
var snapshot = JsonSerializer.Deserialize<ComponentSettingsDocumentSnapshot>(json, SerializerOptions);
|
||||
return NormalizeDocument(snapshot);
|
||||
return new ComponentSettingsLoadDetails(
|
||||
snapshot ?? new ComponentSettingsDocumentSnapshot(),
|
||||
format,
|
||||
format == ComponentSettingsDocumentFormat.PascalCaseDocument);
|
||||
}
|
||||
|
||||
var legacySnapshot = JsonSerializer.Deserialize<ComponentSettingsSnapshot>(json, SerializerOptions);
|
||||
return new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||
};
|
||||
return new ComponentSettingsLoadDetails(
|
||||
new ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
DefaultSettings = NormalizeSnapshot(legacySnapshot)
|
||||
},
|
||||
ComponentSettingsDocumentFormat.LegacySnapshot,
|
||||
true);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ComponentSettingsDocumentSnapshot();
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to deserialize component settings from '{_settingsPath}'.", ex);
|
||||
return ComponentSettingsLoadDetails.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,8 +470,9 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("ComponentSettings", $"Failed to migrate legacy component settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -661,6 +704,79 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
_lastProbeUtc = probeTimeUtc;
|
||||
}
|
||||
|
||||
internal static void ResetCacheForTests()
|
||||
{
|
||||
lock (CacheGate)
|
||||
{
|
||||
_cachedPath = null;
|
||||
_cachedSnapshot = null;
|
||||
_cachedWriteTimeUtc = DateTime.MinValue;
|
||||
_lastProbeUtc = DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogLoadDetails(
|
||||
ComponentSettingsDocumentFormat format,
|
||||
bool rewroteToCanonical,
|
||||
ComponentSettingsDocumentSnapshot snapshot)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"ComponentSettings",
|
||||
$"Loaded component settings document. Format={format}; RewroteToCanonical={rewroteToCanonical}; " +
|
||||
$"InstanceSettings={snapshot.InstanceSettings.Count}; PluginSettings={snapshot.PluginSettings.Count}; Path={_settingsPath}");
|
||||
}
|
||||
|
||||
private static bool TryGetDocumentFormat(
|
||||
JsonElement rootElement,
|
||||
out ComponentSettingsDocumentFormat format)
|
||||
{
|
||||
format = ComponentSettingsDocumentFormat.EmptyDocument;
|
||||
if (rootElement.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasDocumentProperties = false;
|
||||
var requiresCanonicalRewrite = false;
|
||||
foreach (var property in rootElement.EnumerateObject())
|
||||
{
|
||||
if (!IsDocumentPropertyName(property.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hasDocumentProperties = true;
|
||||
if (!IsCanonicalDocumentPropertyName(property.Name))
|
||||
{
|
||||
requiresCanonicalRewrite = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasDocumentProperties)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
format = requiresCanonicalRewrite
|
||||
? ComponentSettingsDocumentFormat.PascalCaseDocument
|
||||
: ComponentSettingsDocumentFormat.CanonicalDocument;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsDocumentPropertyName(string propertyName)
|
||||
{
|
||||
return string.Equals(propertyName, "defaultSettings", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(propertyName, "instanceSettings", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(propertyName, "pluginSettings", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsCanonicalDocumentPropertyName(string propertyName)
|
||||
{
|
||||
return string.Equals(propertyName, "defaultSettings", StringComparison.Ordinal) ||
|
||||
string.Equals(propertyName, "instanceSettings", StringComparison.Ordinal) ||
|
||||
string.Equals(propertyName, "pluginSettings", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class ComponentSettingsDocumentSnapshot
|
||||
{
|
||||
public ComponentSettingsSnapshot DefaultSettings { get; set; } = new();
|
||||
@@ -748,4 +864,23 @@ public sealed class ComponentSettingsService : IComponentInstanceSettingsStore
|
||||
|
||||
public string Stcn24ForumSourceType { get; set; } = Stcn24ForumSourceTypes.LatestCreated;
|
||||
}
|
||||
|
||||
private readonly record struct ComponentSettingsLoadDetails(
|
||||
ComponentSettingsDocumentSnapshot Snapshot,
|
||||
ComponentSettingsDocumentFormat Format,
|
||||
bool ShouldRewriteToCanonical)
|
||||
{
|
||||
public static ComponentSettingsLoadDetails Empty { get; } = new(
|
||||
new ComponentSettingsDocumentSnapshot(),
|
||||
ComponentSettingsDocumentFormat.EmptyDocument,
|
||||
false);
|
||||
}
|
||||
|
||||
private enum ComponentSettingsDocumentFormat
|
||||
{
|
||||
EmptyDocument,
|
||||
CanonicalDocument,
|
||||
PascalCaseDocument,
|
||||
LegacySnapshot
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,13 +118,13 @@ public static class DesktopComponentRegistryFactory
|
||||
contribution.Plugin.Manifest,
|
||||
contribution.Plugin.Context.PluginDirectory,
|
||||
contribution.Plugin.Context.DataDirectory,
|
||||
contribution.Plugin.Context.Services,
|
||||
contribution.Plugin.Services,
|
||||
contribution.Plugin.Context.Properties,
|
||||
contribution.Registration.ComponentId,
|
||||
context.PlacementId,
|
||||
context.CellSize);
|
||||
|
||||
return contribution.Registration.ControlFactory(pluginContext);
|
||||
return contribution.Registration.ControlFactory(contribution.Plugin.Services, pluginContext);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -80,8 +80,9 @@ public sealed class DesktopLayoutSettingsService
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to load desktop layout settings from '{_settingsPath}'.", ex);
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -99,9 +100,9 @@ public sealed class DesktopLayoutSettingsService
|
||||
UpdateCache(snapshotToPersist, writeTimeUtc, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to save desktop layout settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,8 +142,9 @@ public sealed class DesktopLayoutSettingsService
|
||||
var snapshot = JsonSerializer.Deserialize<DesktopLayoutSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to deserialize desktop layout settings from '{_settingsPath}'.", ex);
|
||||
return new DesktopLayoutSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -174,8 +176,9 @@ public sealed class DesktopLayoutSettingsService
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("DesktopLayout", $"Failed to migrate legacy desktop layout settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -85,8 +85,9 @@ public sealed class LauncherSettingsService
|
||||
return normalizedSnapshot.Clone();
|
||||
}
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to load launcher settings from '{_settingsPath}'.", ex);
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -106,9 +107,9 @@ public sealed class LauncherSettingsService
|
||||
|
||||
SettingsSaved?.Invoke(InstanceId);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Swallow persistence errors to keep UI interactions uninterrupted.
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to save launcher settings to '{_settingsPath}'.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,8 +149,9 @@ public sealed class LauncherSettingsService
|
||||
var snapshot = JsonSerializer.Deserialize<LauncherSettingsSnapshot>(json, SerializerOptions);
|
||||
return NormalizeSnapshot(snapshot);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to deserialize launcher settings from '{_settingsPath}'.", ex);
|
||||
return new LauncherSettingsSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -180,8 +182,9 @@ public sealed class LauncherSettingsService
|
||||
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("LauncherSettings", $"Failed to migrate legacy launcher settings from '{_legacyAppSettingsPath}'.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
412
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
412
LanMountainDesktop/Services/ResumableDownloadService.cs
Normal file
@@ -0,0 +1,412 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Downloader;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed record DownloadProgressInfo(
|
||||
long DownloadedBytes,
|
||||
long? TotalBytes,
|
||||
double Progress,
|
||||
bool IsResuming,
|
||||
bool IsParallel);
|
||||
|
||||
public sealed record DownloadOptions(
|
||||
long? ExpectedSizeBytes = null,
|
||||
int MaxParallelSegments = 4,
|
||||
int ParallelThresholdBytes = 8 * 1024 * 1024,
|
||||
int BufferSize = 128 * 1024);
|
||||
|
||||
public sealed record DownloadResult(
|
||||
bool Success,
|
||||
string? FilePath,
|
||||
string? ErrorMessage,
|
||||
bool UsedResume,
|
||||
bool UsedParallelDownload);
|
||||
|
||||
public sealed class ResumableDownloadService
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> DestinationGates =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public ResumableDownloadService(System.Net.Http.HttpClient httpClient)
|
||||
{
|
||||
}
|
||||
|
||||
public async Task<DownloadResult> DownloadAsync(
|
||||
string source,
|
||||
string destinationFilePath,
|
||||
DownloadOptions? options = null,
|
||||
IProgress<DownloadProgressInfo>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(source);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath);
|
||||
|
||||
var normalizedOptions = NormalizeOptions(options);
|
||||
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
||||
var destinationGate = DestinationGates.GetOrAdd(
|
||||
fullDestinationPath,
|
||||
static _ => new SemaphoreSlim(1, 1));
|
||||
|
||||
await destinationGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (File.Exists(source))
|
||||
{
|
||||
return await CopyLocalFileAsync(
|
||||
source,
|
||||
fullDestinationPath,
|
||||
normalizedOptions,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(source, UriKind.Absolute, out var sourceUri) ||
|
||||
(sourceUri.Scheme != Uri.UriSchemeHttp && sourceUri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
return new DownloadResult(false, null, $"Unsupported download source '{source}'.", false, false);
|
||||
}
|
||||
|
||||
return await DownloadRemoteFileAsync(
|
||||
sourceUri,
|
||||
fullDestinationPath,
|
||||
normalizedOptions,
|
||||
progress,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Downloader",
|
||||
$"Download failed. Source='{source}'; Destination='{fullDestinationPath}'.",
|
||||
ex);
|
||||
return new DownloadResult(false, null, ex.Message, false, false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
destinationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> CopyLocalFileAsync(
|
||||
string sourceFilePath,
|
||||
string destinationFilePath,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var fullSourcePath = Path.GetFullPath(sourceFilePath);
|
||||
var fullDestinationPath = Path.GetFullPath(destinationFilePath);
|
||||
var totalBytes = new FileInfo(fullSourcePath).Length;
|
||||
|
||||
var tempFilePath = BuildTempFilePath(fullDestinationPath);
|
||||
PrepareDestination(fullDestinationPath);
|
||||
|
||||
if (CanReuseCompletedDestination(fullDestinationPath, totalBytes))
|
||||
{
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, false, false));
|
||||
CleanupLocalPartialArtifacts(tempFilePath);
|
||||
return new DownloadResult(true, fullDestinationPath, null, false, false);
|
||||
}
|
||||
|
||||
long existingBytes = 0;
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
existingBytes = new FileInfo(tempFilePath).Length;
|
||||
if (existingBytes > totalBytes)
|
||||
{
|
||||
CleanupLocalPartialArtifacts(tempFilePath);
|
||||
existingBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(tempFilePath))
|
||||
{
|
||||
await using var tempCreateStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
}
|
||||
|
||||
if (existingBytes >= totalBytes)
|
||||
{
|
||||
CompleteLocalCopy(tempFilePath, fullDestinationPath);
|
||||
progress?.Report(new DownloadProgressInfo(totalBytes, totalBytes, 1d, existingBytes > 0, false));
|
||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
await using var sourceStream = new FileStream(
|
||||
fullSourcePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await using var destinationStream = new FileStream(
|
||||
tempFilePath,
|
||||
FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
options.BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
|
||||
if (existingBytes > 0)
|
||||
{
|
||||
sourceStream.Seek(existingBytes, SeekOrigin.Begin);
|
||||
destinationStream.Seek(existingBytes, SeekOrigin.Begin);
|
||||
}
|
||||
|
||||
await CopyStreamAsync(
|
||||
sourceStream,
|
||||
destinationStream,
|
||||
existingBytes,
|
||||
totalBytes,
|
||||
progress,
|
||||
cancellationToken);
|
||||
|
||||
CompleteLocalCopy(tempFilePath, fullDestinationPath);
|
||||
return new DownloadResult(true, fullDestinationPath, null, existingBytes > 0, false);
|
||||
}
|
||||
|
||||
private async Task<DownloadResult> DownloadRemoteFileAsync(
|
||||
Uri sourceUri,
|
||||
string destinationFilePath,
|
||||
DownloadOptions options,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PrepareDestination(destinationFilePath);
|
||||
|
||||
if (CanReuseCompletedDestination(destinationFilePath, options.ExpectedSizeBytes))
|
||||
{
|
||||
var existingLength = new FileInfo(destinationFilePath).Length;
|
||||
progress?.Report(new DownloadProgressInfo(existingLength, options.ExpectedSizeBytes, 1d, false, false));
|
||||
CleanupDownloaderArtifacts(destinationFilePath);
|
||||
return new DownloadResult(true, destinationFilePath, null, false, false);
|
||||
}
|
||||
|
||||
var usedResume = HasDownloaderResumeArtifacts(destinationFilePath);
|
||||
var usedParallelDownload = ShouldUseParallelDownload(options);
|
||||
var configuration = CreateConfiguration(options, usedParallelDownload);
|
||||
using var downloader = new DownloadService(configuration);
|
||||
|
||||
downloader.DownloadProgressChanged += (_, args) =>
|
||||
{
|
||||
progress?.Report(MapProgress(args, options.ExpectedSizeBytes, usedResume, usedParallelDownload));
|
||||
};
|
||||
|
||||
using var cancellationRegistration = cancellationToken.Register(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
downloader.CancelAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"Downloader",
|
||||
$"Failed to cancel Downloader request for '{destinationFilePath}'.",
|
||||
ex);
|
||||
}
|
||||
});
|
||||
|
||||
AppLogger.Info(
|
||||
"Downloader",
|
||||
$"Starting remote download. Source='{sourceUri}'; Destination='{destinationFilePath}'; Parallel={usedParallelDownload}; ChunkCount={configuration.ChunkCount}; Resume={usedResume}.");
|
||||
|
||||
await downloader.DownloadFileTaskAsync(sourceUri.AbsoluteUri, destinationFilePath);
|
||||
|
||||
if (!File.Exists(destinationFilePath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Downloader completed without producing '{destinationFilePath}'.",
|
||||
destinationFilePath);
|
||||
}
|
||||
|
||||
var finalLength = new FileInfo(destinationFilePath).Length;
|
||||
progress?.Report(new DownloadProgressInfo(
|
||||
finalLength,
|
||||
options.ExpectedSizeBytes ?? finalLength,
|
||||
1d,
|
||||
usedResume,
|
||||
usedParallelDownload));
|
||||
|
||||
AppLogger.Info(
|
||||
"Downloader",
|
||||
$"Remote download completed. Source='{sourceUri}'; Destination='{destinationFilePath}'; Size={finalLength}; Parallel={usedParallelDownload}; Resume={usedResume}.");
|
||||
|
||||
return new DownloadResult(true, destinationFilePath, null, usedResume, usedParallelDownload);
|
||||
}
|
||||
|
||||
private static async Task CopyStreamAsync(
|
||||
Stream sourceStream,
|
||||
Stream destinationStream,
|
||||
long initialDownloadedBytes,
|
||||
long totalBytes,
|
||||
IProgress<DownloadProgressInfo>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[128 * 1024];
|
||||
var downloadedBytes = initialDownloadedBytes;
|
||||
progress?.Report(new DownloadProgressInfo(downloadedBytes, totalBytes, downloadedBytes / (double)totalBytes, initialDownloadedBytes > 0, false));
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await sourceStream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken);
|
||||
if (read <= 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
|
||||
downloadedBytes += read;
|
||||
progress?.Report(new DownloadProgressInfo(
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
Math.Clamp(downloadedBytes / (double)totalBytes, 0d, 1d),
|
||||
initialDownloadedBytes > 0,
|
||||
false));
|
||||
}
|
||||
|
||||
await destinationStream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static DownloadConfiguration CreateConfiguration(DownloadOptions options, bool useParallelDownload)
|
||||
{
|
||||
return new DownloadConfiguration
|
||||
{
|
||||
BufferBlockSize = options.BufferSize,
|
||||
ChunkCount = useParallelDownload ? options.MaxParallelSegments : 1,
|
||||
ParallelCount = useParallelDownload ? options.MaxParallelSegments : 1,
|
||||
ParallelDownload = useParallelDownload,
|
||||
MinimumSizeOfChunking = options.ParallelThresholdBytes,
|
||||
MaxTryAgainOnFailure = 3,
|
||||
ResumeDownloadIfCan = true,
|
||||
ClearPackageOnCompletionWithFailure = false,
|
||||
FileExistPolicy = FileExistPolicy.Delete,
|
||||
DownloadFileExtension = ".part"
|
||||
};
|
||||
}
|
||||
|
||||
private static DownloadProgressInfo MapProgress(
|
||||
DownloadProgressChangedEventArgs args,
|
||||
long? expectedSizeBytes,
|
||||
bool isResuming,
|
||||
bool isParallel)
|
||||
{
|
||||
var totalBytes = args.TotalBytesToReceive > 0
|
||||
? args.TotalBytesToReceive
|
||||
: expectedSizeBytes;
|
||||
var downloadedBytes = Math.Max(0L, args.ReceivedBytesSize);
|
||||
var normalizedProgress = args.ProgressPercentage > 1d
|
||||
? args.ProgressPercentage / 100d
|
||||
: args.ProgressPercentage;
|
||||
|
||||
if (totalBytes is > 0 && normalizedProgress <= 0d)
|
||||
{
|
||||
normalizedProgress = downloadedBytes / (double)totalBytes.Value;
|
||||
}
|
||||
|
||||
return new DownloadProgressInfo(
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
Math.Clamp(normalizedProgress, 0d, 1d),
|
||||
isResuming,
|
||||
isParallel);
|
||||
}
|
||||
|
||||
private static bool CanReuseCompletedDestination(string destinationFilePath, long? expectedSizeBytes)
|
||||
{
|
||||
if (!File.Exists(destinationFilePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedSizeBytes is not > 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return new FileInfo(destinationFilePath).Length == expectedSizeBytes.Value;
|
||||
}
|
||||
|
||||
private static void PrepareDestination(string destinationFilePath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(destinationFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupLocalPartialArtifacts(string tempFilePath)
|
||||
{
|
||||
if (File.Exists(tempFilePath))
|
||||
{
|
||||
FileOperationRetryHelper.DeleteFileWithRetry(tempFilePath, "Downloader");
|
||||
}
|
||||
}
|
||||
|
||||
private static void CompleteLocalCopy(string tempFilePath, string destinationFilePath)
|
||||
{
|
||||
if (!File.Exists(tempFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FileOperationRetryHelper.MoveWithOverwriteRetry(tempFilePath, destinationFilePath, "Downloader");
|
||||
}
|
||||
|
||||
private static void CleanupDownloaderArtifacts(string destinationFilePath)
|
||||
{
|
||||
var transientFilePath = BuildTempFilePath(destinationFilePath);
|
||||
var metadataFilePath = BuildPackageFilePath(destinationFilePath);
|
||||
|
||||
if (File.Exists(transientFilePath))
|
||||
{
|
||||
FileOperationRetryHelper.DeleteFileWithRetry(transientFilePath, "Downloader");
|
||||
}
|
||||
|
||||
if (File.Exists(metadataFilePath))
|
||||
{
|
||||
FileOperationRetryHelper.DeleteFileWithRetry(metadataFilePath, "Downloader");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasDownloaderResumeArtifacts(string destinationFilePath)
|
||||
{
|
||||
return File.Exists(BuildTempFilePath(destinationFilePath)) ||
|
||||
File.Exists(BuildPackageFilePath(destinationFilePath));
|
||||
}
|
||||
|
||||
private static bool ShouldUseParallelDownload(DownloadOptions options)
|
||||
{
|
||||
return options.MaxParallelSegments > 1;
|
||||
}
|
||||
|
||||
private static DownloadOptions NormalizeOptions(DownloadOptions? options)
|
||||
{
|
||||
var normalized = options ?? new DownloadOptions();
|
||||
return normalized with
|
||||
{
|
||||
MaxParallelSegments = Math.Clamp(normalized.MaxParallelSegments, 1, 8),
|
||||
ParallelThresholdBytes = Math.Max(1_048_576, normalized.ParallelThresholdBytes),
|
||||
BufferSize = Math.Max(16 * 1024, normalized.BufferSize)
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildTempFilePath(string destinationFilePath) => destinationFilePath + ".part";
|
||||
|
||||
private static string BuildPackageFilePath(string destinationFilePath) => destinationFilePath + ".download";
|
||||
}
|
||||
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal file
151
LanMountainDesktop/Services/SingleInstanceService.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class SingleInstanceService : IDisposable
|
||||
{
|
||||
private readonly Mutex _mutex;
|
||||
private readonly string _pipeName;
|
||||
private readonly CancellationTokenSource _listenCts = new();
|
||||
private bool _ownsMutex;
|
||||
private bool _disposed;
|
||||
private Task? _listenTask;
|
||||
|
||||
private SingleInstanceService(string mutexName, string pipeName)
|
||||
{
|
||||
_mutex = new Mutex(initiallyOwned: false, mutexName);
|
||||
_pipeName = pipeName;
|
||||
try
|
||||
{
|
||||
_ownsMutex = _mutex.WaitOne(TimeSpan.Zero, exitContext: false);
|
||||
}
|
||||
catch (AbandonedMutexException)
|
||||
{
|
||||
_ownsMutex = true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsPrimaryInstance => _ownsMutex;
|
||||
|
||||
public static SingleInstanceService CreateDefault()
|
||||
{
|
||||
const string appId = "LanMountainDesktop";
|
||||
var userName = Environment.UserName;
|
||||
var scopeSeed = $"{appId}:{userName}";
|
||||
var scopeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(scopeSeed)));
|
||||
var suffix = scopeHash[..16];
|
||||
var mutexName = OperatingSystem.IsWindows()
|
||||
? $"Local\\{appId}.SingleInstance.{suffix}"
|
||||
: $"{appId}.SingleInstance.{suffix}";
|
||||
return new SingleInstanceService(
|
||||
mutexName,
|
||||
$"{appId}.Activate.{suffix}");
|
||||
}
|
||||
|
||||
public void StartActivationListener(Action onActivationRequested)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onActivationRequested);
|
||||
|
||||
if (!_ownsMutex || _disposed || _listenTask is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_listenTask = Task.Run(() => ListenForActivationAsync(onActivationRequested, _listenCts.Token));
|
||||
}
|
||||
|
||||
public bool TryNotifyPrimaryInstance(TimeSpan timeout)
|
||||
{
|
||||
if (_ownsMutex || _disposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var client = new NamedPipeClientStream(
|
||||
serverName: ".",
|
||||
pipeName: _pipeName,
|
||||
direction: PipeDirection.Out,
|
||||
options: PipeOptions.Asynchronous);
|
||||
|
||||
client.Connect((int)Math.Max(1, timeout.TotalMilliseconds));
|
||||
client.WriteByte(1);
|
||||
client.Flush();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Failed to notify the primary instance.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_listenCts.Cancel();
|
||||
try
|
||||
{
|
||||
_listenTask?.Wait(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore listener shutdown races during process exit.
|
||||
}
|
||||
|
||||
_listenCts.Dispose();
|
||||
if (_ownsMutex)
|
||||
{
|
||||
try
|
||||
{
|
||||
_mutex.ReleaseMutex();
|
||||
}
|
||||
catch (ApplicationException)
|
||||
{
|
||||
// Ownership may already be lost during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
_mutex.Dispose();
|
||||
}
|
||||
|
||||
private async Task ListenForActivationAsync(Action onActivationRequested, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var server = new NamedPipeServerStream(
|
||||
_pipeName,
|
||||
PipeDirection.In,
|
||||
1,
|
||||
PipeTransmissionMode.Byte,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
await server.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await server.ReadAsync(new byte[1], cancellationToken).ConfigureAwait(false);
|
||||
onActivationRequested();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("SingleInstance", "Activation listener failed.", ex);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
193
LanMountainDesktop/Services/StartupDiagnosticsService.cs
Normal file
193
LanMountainDesktop/Services/StartupDiagnosticsService.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public sealed class StartupDiagnosticsResult
|
||||
{
|
||||
public string ExecutablePath { get; init; } = string.Empty;
|
||||
|
||||
public string BaseDirectory { get; init; } = string.Empty;
|
||||
|
||||
public string ExecutableName { get; init; } = string.Empty;
|
||||
|
||||
public bool IsLegacyExecutableLaunch { get; init; }
|
||||
|
||||
public IReadOnlyList<string> FoundLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> DeletedLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||
|
||||
public IReadOnlyList<string> FailedLegacyArtifacts { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
public static class StartupDiagnosticsService
|
||||
{
|
||||
private const string CurrentExecutableName = "LanMountainDesktop.exe";
|
||||
|
||||
private static readonly string[] LegacyArtifactNames =
|
||||
[
|
||||
"LanMontainDesktop.exe",
|
||||
"LanMontainDesktop.dll",
|
||||
"LanMontainDesktop.deps.json",
|
||||
"LanMontainDesktop.runtimeconfig.json",
|
||||
"LanMontainDesktop.pdb",
|
||||
"LanMontainDesktop.exe.WebView2"
|
||||
];
|
||||
|
||||
public static StartupDiagnosticsResult Run(string[] args)
|
||||
{
|
||||
var executablePath = ResolveExecutablePath();
|
||||
var baseDirectory = AppContext.BaseDirectory;
|
||||
var executableName = Path.GetFileName(executablePath);
|
||||
var assemblyVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
|
||||
var fileVersion = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(executablePath) && File.Exists(executablePath))
|
||||
{
|
||||
fileVersion = FileVersionInfo.GetVersionInfo(executablePath).FileVersion ?? string.Empty;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep diagnostics best-effort.
|
||||
}
|
||||
|
||||
AppLogger.Info(
|
||||
"Startup",
|
||||
$"Application starting. ExecutablePath={executablePath}; BaseDirectory={baseDirectory}; ExecutableName={executableName}; AssemblyVersion={assemblyVersion}; FileVersion={fileVersion}; Args=[{string.Join(", ", args)}]");
|
||||
|
||||
var foundLegacyArtifacts = LegacyArtifactNames
|
||||
.Select(name => Path.Combine(baseDirectory, name))
|
||||
.Where(path => File.Exists(path) || Directory.Exists(path))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var deletedLegacyArtifacts = new List<string>();
|
||||
var failedLegacyArtifacts = new List<string>();
|
||||
foreach (var legacyArtifact in foundLegacyArtifacts)
|
||||
{
|
||||
if (string.Equals(legacyArtifact, executablePath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryDeleteLegacyArtifact(legacyArtifact, out var error))
|
||||
{
|
||||
deletedLegacyArtifacts.Add(legacyArtifact);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(error))
|
||||
{
|
||||
failedLegacyArtifacts.Add($"{legacyArtifact} ({error})");
|
||||
}
|
||||
}
|
||||
|
||||
if (foundLegacyArtifacts.Count > 0)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"StartupDiagnostics",
|
||||
$"Found legacy artifacts: {string.Join("; ", foundLegacyArtifacts)}");
|
||||
}
|
||||
|
||||
if (deletedLegacyArtifacts.Count > 0)
|
||||
{
|
||||
AppLogger.Info(
|
||||
"StartupDiagnostics",
|
||||
$"Deleted legacy artifacts: {string.Join("; ", deletedLegacyArtifacts)}");
|
||||
}
|
||||
|
||||
if (failedLegacyArtifacts.Count > 0)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"StartupDiagnostics",
|
||||
$"Failed to delete legacy artifacts: {string.Join("; ", failedLegacyArtifacts)}");
|
||||
}
|
||||
|
||||
var isLegacyExecutableLaunch = string.Equals(
|
||||
executableName,
|
||||
"LanMontainDesktop.exe",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isLegacyExecutableLaunch)
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"StartupDiagnostics",
|
||||
$"Legacy executable launch detected. Current executable should be '{CurrentExecutableName}', but actual executable is '{executableName}'.");
|
||||
}
|
||||
|
||||
return new StartupDiagnosticsResult
|
||||
{
|
||||
ExecutablePath = executablePath,
|
||||
BaseDirectory = baseDirectory,
|
||||
ExecutableName = executableName,
|
||||
IsLegacyExecutableLaunch = isLegacyExecutableLaunch,
|
||||
FoundLegacyArtifacts = foundLegacyArtifacts,
|
||||
DeletedLegacyArtifacts = deletedLegacyArtifacts,
|
||||
FailedLegacyArtifacts = failedLegacyArtifacts
|
||||
};
|
||||
}
|
||||
|
||||
public static void ShowLegacyExecutableWarningIfNeeded(StartupDiagnosticsResult diagnostics)
|
||||
{
|
||||
if (!diagnostics.IsLegacyExecutableLaunch)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var message =
|
||||
"检测到当前是从旧残留可执行文件启动的。\r\n\r\n" +
|
||||
$"当前文件: {diagnostics.ExecutableName}\r\n" +
|
||||
$"当前路径: {diagnostics.ExecutablePath}\r\n\r\n" +
|
||||
$"请改用 {CurrentExecutableName} 启动,以免继续读取旧残留文件。\r\n" +
|
||||
$"日志目录: {AppLogger.LogDirectory}";
|
||||
|
||||
WindowsNativeDialogService.ShowWarning("LanMountainDesktop 启动诊断", message);
|
||||
}
|
||||
|
||||
private static string ResolveExecutablePath()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Environment.ProcessPath ??
|
||||
Process.GetCurrentProcess().MainModule?.FileName ??
|
||||
string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDeleteLegacyArtifact(string path, out string? error)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
Directory.Delete(path, recursive: true);
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = null;
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public static class StudyAnalyticsServiceFactory
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
|
||||
public static void DisposeSharedService()
|
||||
{
|
||||
if (SharedService.IsValueCreated)
|
||||
{
|
||||
SharedService.Value.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
@@ -446,6 +454,7 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
_disposed = true;
|
||||
StopTimerLocked();
|
||||
_samplingTimer.Dispose();
|
||||
_audioRecorderService.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,4 +768,3 @@ public sealed class StudyAnalyticsService : IStudyAnalyticsService
|
||||
_lastSessionReport = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
80
LanMountainDesktop/Services/UiExceptionGuard.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class UiExceptionGuard
|
||||
{
|
||||
public static bool IsFatalException(Exception? exception)
|
||||
{
|
||||
return exception is OutOfMemoryException or AccessViolationException or StackOverflowException;
|
||||
}
|
||||
|
||||
public static void FireAndForgetGuarded(
|
||||
Func<Task> action,
|
||||
string actionName,
|
||||
string? context = null,
|
||||
Func<Exception, Task>? onHandledException = null)
|
||||
{
|
||||
_ = RunGuardedUiActionAsync(action, actionName, context, onHandledException);
|
||||
}
|
||||
|
||||
public static async Task RunGuardedUiActionAsync(
|
||||
Func<Task> action,
|
||||
string actionName,
|
||||
string? context = null,
|
||||
Func<Exception, Task>? onHandledException = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
try
|
||||
{
|
||||
await action();
|
||||
}
|
||||
catch (Exception ex) when (!IsFatalException(ex))
|
||||
{
|
||||
LogHandledException("GuardedUiAction", actionName, ex, context, isFatal: false);
|
||||
if (onHandledException is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await onHandledException(ex);
|
||||
}
|
||||
catch (Exception handlerEx) when (!IsFatalException(handlerEx))
|
||||
{
|
||||
LogHandledException("GuardedUiActionHandler", actionName, handlerEx, context, isFatal: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildContext(params (string Key, object? Value)[] parts)
|
||||
{
|
||||
if (parts is null || parts.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return string.Join(
|
||||
"; ",
|
||||
Array.ConvertAll(parts, part => $"{part.Key}={part.Value ?? "<null>"}"));
|
||||
}
|
||||
|
||||
private static void LogHandledException(
|
||||
string category,
|
||||
string actionName,
|
||||
Exception exception,
|
||||
string? context,
|
||||
bool isFatal)
|
||||
{
|
||||
var message =
|
||||
$"Action={actionName}; ExceptionType={exception.GetType().FullName}; IsFatal={isFatal}; Context={context ?? string.Empty}";
|
||||
if (isFatal)
|
||||
{
|
||||
AppLogger.Critical(category, message, exception);
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.Warn(category, message, exception);
|
||||
}
|
||||
}
|
||||
41
LanMountainDesktop/Services/WindowsNativeDialogService.cs
Normal file
41
LanMountainDesktop/Services/WindowsNativeDialogService.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
internal static class WindowsNativeDialogService
|
||||
{
|
||||
private const uint Ok = 0x00000000;
|
||||
private const uint IconInformation = 0x00000040;
|
||||
private const uint IconWarning = 0x00000030;
|
||||
|
||||
public static void ShowInformation(string caption, string message)
|
||||
{
|
||||
Show(caption, message, Ok | IconInformation, "NativeDialog");
|
||||
}
|
||||
|
||||
public static void ShowWarning(string caption, string message)
|
||||
{
|
||||
Show(caption, message, Ok | IconWarning, "StartupDiagnostics");
|
||||
}
|
||||
|
||||
private static void Show(string caption, string message, uint type, string logCategory)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = MessageBoxW(IntPtr.Zero, message, caption, type);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn(logCategory, "Failed to show native dialog.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", EntryPoint = "MessageBoxW", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
|
||||
}
|
||||
@@ -30,8 +30,9 @@ public sealed class WindowsStartupService
|
||||
return runKey?.GetValue(ValueName) is string value &&
|
||||
!string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("WindowsStartup", "Failed to query startup registry state.", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -67,8 +68,9 @@ public sealed class WindowsStartupService
|
||||
|
||||
return IsEnabled() == enabled;
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLogger.Warn("WindowsStartup", $"Failed to set startup registry state. Enabled={enabled}", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,21 +6,27 @@ using Avalonia.Interactivity;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using AvaloniaWebView;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
using WebViewCore.Events;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
, IDesktopPageVisibilityAwareComponentWidget, IDisposable
|
||||
public partial class BrowserWidget : UserControl, IDesktopComponentWidget,
|
||||
IDesktopPageVisibilityAwareComponentWidget, IComponentPlacementContextAware, IDisposable
|
||||
{
|
||||
private static readonly Uri DefaultHomeUri = new("https://www.bing.com");
|
||||
|
||||
private double _currentCellSize = 48;
|
||||
private string _componentId = BuiltInComponentIds.DesktopBrowser;
|
||||
private string _placementId = string.Empty;
|
||||
private bool? _isNightModeApplied;
|
||||
private Uri _lastKnownUri = DefaultHomeUri;
|
||||
private bool _isOnActiveDesktopPage;
|
||||
private bool _isAttachedToVisualTree;
|
||||
private bool _isEditMode;
|
||||
private bool _isWebViewActive = true;
|
||||
private bool _isWebViewFaulted;
|
||||
private readonly WebView2RuntimeAvailability _runtimeAvailability;
|
||||
private bool _isDisposed;
|
||||
|
||||
@@ -45,8 +51,8 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
ApplyRuntimeUnavailableState();
|
||||
}
|
||||
|
||||
AddressTextBox.Text = DefaultHomeUri.ToString();
|
||||
UpdateWebViewActiveState();
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -74,17 +80,15 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
_currentCellSize = Math.Max(1, cellSize);
|
||||
|
||||
RootBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.34, 12, 28));
|
||||
RootBorder.Padding = new Thickness(
|
||||
Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||
RootBorder.Padding = new Thickness(Math.Clamp(_currentCellSize * 0.20, 8, 18));
|
||||
|
||||
WebViewHostBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.24, 10, 22));
|
||||
AddressBarBorder.CornerRadius = new CornerRadius(Math.Clamp(_currentCellSize * 0.22, 10, 20));
|
||||
AddressBarBorder.Padding = new Thickness(8, 6);
|
||||
|
||||
var rowSpacing = 8d;
|
||||
if (RootBorder.Child is Grid rootGrid)
|
||||
{
|
||||
rootGrid.RowSpacing = rowSpacing;
|
||||
rootGrid.RowSpacing = 8d;
|
||||
}
|
||||
|
||||
var buttonSize = Math.Clamp(_currentCellSize * 0.72, 30, 36);
|
||||
@@ -111,16 +115,33 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
AddressTextBox.Height = buttonSize;
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActiveDesktopPage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
public void SetComponentPlacementContext(string componentId, string? placementId)
|
||||
{
|
||||
_componentId = string.IsNullOrWhiteSpace(componentId)
|
||||
? BuiltInComponentIds.DesktopBrowser
|
||||
: componentId.Trim();
|
||||
_placementId = placementId?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttachedToVisualTree = true;
|
||||
ApplyTheme(force: true);
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e)
|
||||
{
|
||||
_isAttachedToVisualTree = false;
|
||||
_isOnActiveDesktopPage = false;
|
||||
UpdateWebViewActiveState();
|
||||
DeactivateWebView(clearUrl: false);
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object? sender, SizeChangedEventArgs e)
|
||||
@@ -202,28 +223,20 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnRefreshButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
if (!CanUseWebView())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isWebViewActive)
|
||||
if (!TryReloadWebView("Refresh"))
|
||||
{
|
||||
return;
|
||||
TryNavigate(DefaultHomeUri, "RefreshFallback");
|
||||
}
|
||||
|
||||
if (BrowserWebView.Url is not null)
|
||||
{
|
||||
BrowserWebView.Reload();
|
||||
return;
|
||||
}
|
||||
|
||||
NavigateTo(DefaultHomeUri);
|
||||
}
|
||||
|
||||
private void OnGoButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
if (!CanUseWebView())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +246,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void OnAddressTextBoxKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
if (!CanUseWebView())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -249,7 +262,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
|
||||
private void NavigateFromAddressBar()
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
if (!CanUseWebView())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -269,7 +282,7 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
AddressTextBox.Text = uri.ToString();
|
||||
if (_isWebViewActive)
|
||||
{
|
||||
BrowserWebView.Url = uri;
|
||||
TryNavigate(uri, "NavigateTo");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,25 +297,16 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
AddressTextBox.Text = e.Url.ToString();
|
||||
}
|
||||
|
||||
public void SetDesktopPageContext(bool isOnActivePage, bool isEditMode)
|
||||
{
|
||||
_isOnActiveDesktopPage = isOnActivePage;
|
||||
_isEditMode = isEditMode;
|
||||
UpdateWebViewActiveState();
|
||||
}
|
||||
|
||||
private void UpdateWebViewActiveState()
|
||||
{
|
||||
if (!_runtimeAvailability.IsAvailable)
|
||||
if (!_runtimeAvailability.IsAvailable || _isWebViewFaulted)
|
||||
{
|
||||
_isWebViewActive = false;
|
||||
BrowserWebView.Url = null;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
ApplyRuntimeUnavailableState();
|
||||
return;
|
||||
}
|
||||
|
||||
var shouldBeActive = _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||
var shouldBeActive = _isAttachedToVisualTree && _isOnActiveDesktopPage && !_isEditMode && IsVisible;
|
||||
if (_isWebViewActive == shouldBeActive)
|
||||
{
|
||||
return;
|
||||
@@ -311,40 +315,118 @@ public partial class BrowserWidget : UserControl, IDesktopComponentWidget
|
||||
_isWebViewActive = shouldBeActive;
|
||||
if (!_isWebViewActive)
|
||||
{
|
||||
if (BrowserWebView.Url is Uri currentUri)
|
||||
{
|
||||
_lastKnownUri = currentUri;
|
||||
}
|
||||
DeactivateWebView(clearUrl: false);
|
||||
return;
|
||||
}
|
||||
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.Url = null;
|
||||
ActivateWebView();
|
||||
}
|
||||
|
||||
private void ActivateWebView()
|
||||
{
|
||||
if (_isWebViewFaulted || !_runtimeAvailability.IsAvailable)
|
||||
{
|
||||
ApplyRuntimeUnavailableState();
|
||||
return;
|
||||
}
|
||||
|
||||
BrowserWebView.IsVisible = true;
|
||||
BrowserWebView.IsHitTestVisible = true;
|
||||
BrowserWebView.Url = _lastKnownUri;
|
||||
RefreshButton.IsEnabled = true;
|
||||
GoButton.IsEnabled = true;
|
||||
AddressTextBox.IsEnabled = true;
|
||||
UnavailableOverlay.IsVisible = false;
|
||||
|
||||
TryNavigate(_lastKnownUri, "Activate");
|
||||
}
|
||||
|
||||
private void DeactivateWebView(bool clearUrl)
|
||||
{
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
BrowserWebView.IsVisible = false;
|
||||
|
||||
if (clearUrl)
|
||||
{
|
||||
TryClearWebViewUrl();
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryReloadWebView(string action)
|
||||
{
|
||||
try
|
||||
{
|
||||
BrowserWebView.Reload();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
EnterFaultedState(action, ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryNavigate(Uri uri, string action)
|
||||
{
|
||||
try
|
||||
{
|
||||
BrowserWebView.Url = uri;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
EnterFaultedState(action, ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void TryClearWebViewUrl()
|
||||
{
|
||||
try
|
||||
{
|
||||
BrowserWebView.Url = null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
private bool CanUseWebView()
|
||||
{
|
||||
return _runtimeAvailability.IsAvailable && !_isWebViewFaulted && _isWebViewActive;
|
||||
}
|
||||
|
||||
private void ApplyRuntimeUnavailableState()
|
||||
{
|
||||
_isWebViewActive = false;
|
||||
BrowserWebView.Url = null;
|
||||
BrowserWebView.IsVisible = false;
|
||||
BrowserWebView.IsHitTestVisible = false;
|
||||
|
||||
RefreshButton.IsEnabled = false;
|
||||
GoButton.IsEnabled = false;
|
||||
AddressTextBox.IsEnabled = false;
|
||||
AddressTextBox.Text = string.Empty;
|
||||
AddressTextBox.Text = _lastKnownUri.ToString();
|
||||
|
||||
UnavailableMessageTextBlock.Text = string.IsNullOrWhiteSpace(_runtimeAvailability.Message)
|
||||
? "WebView runtime unavailable."
|
||||
: _runtimeAvailability.Message;
|
||||
UnavailableMessageTextBlock.Text = _isWebViewFaulted
|
||||
? "The browser component is temporarily unavailable. Restart the app to retry."
|
||||
: string.IsNullOrWhiteSpace(_runtimeAvailability.Message)
|
||||
? "WebView runtime unavailable."
|
||||
: _runtimeAvailability.Message;
|
||||
UnavailableOverlay.IsVisible = true;
|
||||
}
|
||||
|
||||
private void EnterFaultedState(string action, Exception ex)
|
||||
{
|
||||
_isWebViewFaulted = true;
|
||||
_isWebViewActive = false;
|
||||
AppLogger.Warn(
|
||||
"BrowserWidget",
|
||||
$"Browser component faulted. Action={action}; ComponentId={_componentId}; PlacementId={_placementId}; RuntimeAvailability={_runtimeAvailability.IsAvailable}; RuntimeVersion={_runtimeAvailability.Version ?? string.Empty}; CurrentUrl={_lastKnownUri}",
|
||||
ex);
|
||||
TryClearWebViewUrl();
|
||||
ApplyRuntimeUnavailableState();
|
||||
}
|
||||
|
||||
private static Uri? TryNormalizeUri(string? rawText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawText))
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.Components;
|
||||
|
||||
internal sealed class DesktopComponentFailureView : UserControl, IDesktopComponentWidget
|
||||
{
|
||||
private readonly Border _rootBorder;
|
||||
private readonly TextBlock _titleBlock;
|
||||
private readonly TextBlock _summaryBlock;
|
||||
private readonly TextBlock _statusBlock;
|
||||
private readonly Button _toggleDetailsButton;
|
||||
private readonly Button _copyReportButton;
|
||||
private readonly Border _detailsBorder;
|
||||
private readonly TextBox _reportTextBox;
|
||||
private readonly string _componentId;
|
||||
private readonly string? _placementId;
|
||||
private readonly string _reportText;
|
||||
private bool _detailsVisible;
|
||||
|
||||
public DesktopComponentFailureView(
|
||||
string componentName,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? pageIndex,
|
||||
string action,
|
||||
Exception exception)
|
||||
{
|
||||
_componentId = componentId;
|
||||
_placementId = placementId;
|
||||
_reportText = BuildReport(componentName, componentId, placementId, pageIndex, action, exception);
|
||||
|
||||
_titleBlock = new TextBlock
|
||||
{
|
||||
Text = string.IsNullOrWhiteSpace(componentName) ? "组件暂时不可用" : componentName,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
_summaryBlock = new TextBlock
|
||||
{
|
||||
Text = "该组件已临时停用,并由信息占位保留原位置。你可以展开详情或复制错误报告。",
|
||||
Foreground = CreateBrush("#FFD6DEE9"),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
_statusBlock = new TextBlock
|
||||
{
|
||||
IsVisible = false,
|
||||
Foreground = CreateBrush("#FF93C5FD"),
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
_toggleDetailsButton = CreateButton("查看错误信息", OnToggleDetailsClick);
|
||||
_copyReportButton = CreateButton("复制错误报告", OnCopyReportClick);
|
||||
|
||||
_reportTextBox = new TextBox
|
||||
{
|
||||
Text = _reportText,
|
||||
IsReadOnly = true,
|
||||
AcceptsReturn = true,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MinHeight = 96,
|
||||
MaxHeight = 220,
|
||||
Background = CreateBrush("#CC0F172A"),
|
||||
Foreground = CreateBrush("#FFE2E8F0"),
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(8)
|
||||
};
|
||||
|
||||
_detailsBorder = new Border
|
||||
{
|
||||
IsVisible = false,
|
||||
Background = CreateBrush("#660F172A"),
|
||||
BorderBrush = CreateBrush("#33475569"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Child = _reportTextBox
|
||||
};
|
||||
|
||||
_rootBorder = new Border
|
||||
{
|
||||
Background = CreateBrush("#D91E293B"),
|
||||
BorderBrush = CreateBrush("#336B7280"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(18),
|
||||
Padding = new Thickness(14),
|
||||
ClipToBounds = true,
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 8,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Children =
|
||||
{
|
||||
_titleBlock,
|
||||
_summaryBlock,
|
||||
new WrapPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
ItemSpacing = 8,
|
||||
LineSpacing = 8,
|
||||
Children =
|
||||
{
|
||||
_toggleDetailsButton,
|
||||
_copyReportButton
|
||||
}
|
||||
},
|
||||
_statusBlock,
|
||||
_detailsBorder
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Content = _rootBorder;
|
||||
ApplyCellSize(48);
|
||||
}
|
||||
|
||||
public void ApplyCellSize(double cellSize)
|
||||
{
|
||||
var normalized = Math.Max(1, cellSize);
|
||||
_rootBorder.CornerRadius = new CornerRadius(Math.Clamp(normalized * 0.24, 12, 24));
|
||||
_rootBorder.Padding = new Thickness(Math.Clamp(normalized * 0.24, 10, 18));
|
||||
_titleBlock.FontSize = Math.Clamp(normalized * 0.36, 14, 22);
|
||||
_summaryBlock.FontSize = Math.Clamp(normalized * 0.24, 11, 15);
|
||||
_statusBlock.FontSize = Math.Clamp(normalized * 0.22, 10, 13);
|
||||
_toggleDetailsButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14);
|
||||
_copyReportButton.FontSize = Math.Clamp(normalized * 0.22, 10, 14);
|
||||
_toggleDetailsButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6);
|
||||
_copyReportButton.Padding = new Thickness(Math.Clamp(normalized * 0.18, 8, 12), 6);
|
||||
_reportTextBox.FontSize = Math.Clamp(normalized * 0.2, 10, 13);
|
||||
_reportTextBox.MaxHeight = Math.Clamp(normalized * 5.2, 120, 260);
|
||||
}
|
||||
|
||||
private static Button CreateButton(string text, EventHandler<RoutedEventArgs> clickHandler)
|
||||
{
|
||||
var button = new Button
|
||||
{
|
||||
Content = text,
|
||||
Background = CreateBrush("#80334155"),
|
||||
Foreground = Brushes.White,
|
||||
BorderBrush = CreateBrush("#335B6575"),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(999),
|
||||
HorizontalAlignment = HorizontalAlignment.Left
|
||||
};
|
||||
button.Click += clickHandler;
|
||||
return button;
|
||||
}
|
||||
|
||||
private void OnToggleDetailsClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
_detailsVisible = !_detailsVisible;
|
||||
_detailsBorder.IsVisible = _detailsVisible;
|
||||
_toggleDetailsButton.Content = _detailsVisible ? "隐藏错误信息" : "查看错误信息";
|
||||
UpdateStatus(null);
|
||||
}
|
||||
|
||||
private void OnCopyReportClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
UiExceptionGuard.FireAndForgetGuarded(
|
||||
CopyReportAsync,
|
||||
"DesktopComponentFailureView.CopyReport",
|
||||
UiExceptionGuard.BuildContext(
|
||||
("ComponentId", _componentId),
|
||||
("PlacementId", _placementId)));
|
||||
}
|
||||
|
||||
private async Task CopyReportAsync()
|
||||
{
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
var clipboard = topLevel?.Clipboard;
|
||||
if (clipboard is null)
|
||||
{
|
||||
UpdateStatus("当前环境不支持复制错误报告。");
|
||||
return;
|
||||
}
|
||||
|
||||
await clipboard.SetTextAsync(_reportText);
|
||||
UpdateStatus("错误报告已复制到剪贴板。");
|
||||
}
|
||||
|
||||
private void UpdateStatus(string? message)
|
||||
{
|
||||
_statusBlock.Text = message ?? string.Empty;
|
||||
_statusBlock.IsVisible = !string.IsNullOrWhiteSpace(message);
|
||||
}
|
||||
|
||||
private static string BuildReport(
|
||||
string componentName,
|
||||
string componentId,
|
||||
string? placementId,
|
||||
int? pageIndex,
|
||||
string action,
|
||||
Exception exception)
|
||||
{
|
||||
var version = Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? "unknown";
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine("LanMountainDesktop Component Failure Report");
|
||||
builder.AppendLine($"GeneratedAt: {DateTimeOffset.Now:O}");
|
||||
builder.AppendLine($"AppVersion: {version}");
|
||||
builder.AppendLine($"Action: {action}");
|
||||
builder.AppendLine($"ComponentName: {componentName}");
|
||||
builder.AppendLine($"ComponentId: {componentId}");
|
||||
builder.AppendLine($"PlacementId: {placementId ?? string.Empty}");
|
||||
builder.AppendLine($"PageIndex: {pageIndex?.ToString() ?? string.Empty}");
|
||||
builder.AppendLine($"ExceptionType: {exception.GetType().FullName}");
|
||||
builder.AppendLine($"ExceptionMessage: {exception.Message}");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(exception.ToString());
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static IBrush CreateBrush(string colorHex)
|
||||
{
|
||||
return new SolidColorBrush(Color.Parse(colorHex));
|
||||
}
|
||||
}
|
||||
@@ -1522,7 +1522,7 @@ public partial class MainWindow
|
||||
placement.PlacementId = Guid.NewGuid().ToString("N");
|
||||
}
|
||||
|
||||
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId);
|
||||
var component = CreateDesktopComponentControl(placement.ComponentId, placement.PlacementId, placement.PageIndex);
|
||||
if (component is null)
|
||||
{
|
||||
return null;
|
||||
@@ -1956,23 +1956,54 @@ public partial class MainWindow
|
||||
return onLeft || onRight || onTop || onBottom;
|
||||
}
|
||||
|
||||
private Control? CreateDesktopComponentControl(string componentId, string? placementId = null)
|
||||
private Control? CreateDesktopComponentControl(string componentId, string? placementId = null, int? pageIndex = null)
|
||||
{
|
||||
if (!_componentRuntimeRegistry.TryGetDescriptor(componentId, out var runtimeDescriptor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var component = runtimeDescriptor.CreateControl(
|
||||
_currentDesktopCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_componentSettingsService,
|
||||
placementId);
|
||||
component.Classes.Add(DesktopComponentClass);
|
||||
return component;
|
||||
return CreateDesktopComponentControl(runtimeDescriptor, _currentDesktopCellSize, placementId, pageIndex, "DesktopSurface");
|
||||
}
|
||||
|
||||
private Control? CreateDesktopComponentControl(
|
||||
DesktopComponentRuntimeDescriptor runtimeDescriptor,
|
||||
double cellSize,
|
||||
string? placementId,
|
||||
int? pageIndex,
|
||||
string action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var component = runtimeDescriptor.CreateControl(
|
||||
cellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_componentSettingsService,
|
||||
placementId);
|
||||
component.Classes.Add(DesktopComponentClass);
|
||||
return component;
|
||||
}
|
||||
catch (Exception ex) when (!UiExceptionGuard.IsFatalException(ex))
|
||||
{
|
||||
AppLogger.Warn(
|
||||
"ComponentRuntime",
|
||||
$"Action={action}; ComponentId={runtimeDescriptor.Definition.Id}; PlacementId={placementId ?? string.Empty}; PageIndex={pageIndex?.ToString() ?? string.Empty}; ExceptionType={ex.GetType().FullName}; IsFatal=false",
|
||||
ex);
|
||||
|
||||
var failureView = new DesktopComponentFailureView(
|
||||
runtimeDescriptor.Definition.DisplayName,
|
||||
runtimeDescriptor.Definition.Id,
|
||||
placementId,
|
||||
pageIndex,
|
||||
action,
|
||||
ex);
|
||||
failureView.ApplyCellSize(cellSize);
|
||||
failureView.Classes.Add(DesktopComponentClass);
|
||||
return failureView;
|
||||
}
|
||||
}
|
||||
|
||||
private void CollapseComponentLibraryPanel()
|
||||
@@ -3113,13 +3144,16 @@ public partial class MainWindow
|
||||
var previewHeight = previewSpan.HeightCells * previewCellSize;
|
||||
var renderCellSize = Math.Clamp(previewCellSize * 1.15, 26, 110);
|
||||
|
||||
var previewControl = descriptor.CreateControl(
|
||||
var previewControl = CreateDesktopComponentControl(
|
||||
descriptor,
|
||||
renderCellSize,
|
||||
_timeZoneService,
|
||||
_weatherDataService,
|
||||
_recommendationInfoService,
|
||||
_calculatorDataService,
|
||||
_componentSettingsService);
|
||||
placementId: null,
|
||||
pageIndex: null,
|
||||
action: "ComponentLibraryPreview");
|
||||
if (previewControl is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Component library previews must stay non-interactive so drag gesture is reliable.
|
||||
previewControl.IsHitTestVisible = false;
|
||||
previewControl.Focusable = false;
|
||||
|
||||
@@ -13,6 +13,7 @@ using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
@@ -1069,12 +1070,12 @@ public partial class MainWindow
|
||||
|
||||
private void RenderLauncherHiddenItemsList()
|
||||
{
|
||||
if (LauncherHiddenItemsListPanel is null || LauncherHiddenItemsEmptyTextBlock is null)
|
||||
if (LauncherHiddenItemsSettingsExpander is null || LauncherHiddenItemsEmptyTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LauncherHiddenItemsListPanel.Children.Clear();
|
||||
LauncherHiddenItemsSettingsExpander.Items.Clear();
|
||||
var hiddenItems = BuildLauncherHiddenItems();
|
||||
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
||||
if (hiddenItems.Count == 0)
|
||||
@@ -1084,7 +1085,7 @@ public partial class MainWindow
|
||||
|
||||
foreach (var hiddenItem in hiddenItems)
|
||||
{
|
||||
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1186,92 +1187,58 @@ public partial class MainWindow
|
||||
: fileName;
|
||||
}
|
||||
|
||||
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
|
||||
private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
|
||||
{
|
||||
Control icon = hiddenItem.IconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = hiddenItem.IconBitmap,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = hiddenItem.Monogram,
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
||||
|
||||
var infoPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 10,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
infoPanel.Children.Add(icon);
|
||||
infoPanel.Children.Add(new StackPanel
|
||||
{
|
||||
Spacing = 2,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = hiddenItem.DisplayName,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxLines = 1
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = typeText,
|
||||
FontSize = 11,
|
||||
Opacity = 0.7
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var restoreButton = new Button
|
||||
{
|
||||
Content = L("settings.launcher.restore_button", "Show Again"),
|
||||
MinWidth = 110,
|
||||
Padding = new Thickness(12, 6),
|
||||
Width = 36,
|
||||
Height = 36,
|
||||
Padding = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||
};
|
||||
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||
FontSize = 18,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||
|
||||
var row = new Grid
|
||||
return new SettingsExpanderItem
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 10
|
||||
Content = hiddenItem.DisplayName,
|
||||
Description = typeText,
|
||||
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
|
||||
IsClickEnabled = false,
|
||||
Footer = restoreButton
|
||||
};
|
||||
row.Children.Add(infoPanel);
|
||||
Grid.SetColumn(infoPanel, 0);
|
||||
row.Children.Add(restoreButton);
|
||||
Grid.SetColumn(restoreButton, 1);
|
||||
}
|
||||
|
||||
return new Border
|
||||
private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
|
||||
{
|
||||
if (hiddenItem.IconBitmap is not null)
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = row
|
||||
return new ImageIconSource
|
||||
{
|
||||
Source = hiddenItem.IconBitmap
|
||||
};
|
||||
}
|
||||
|
||||
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||
? FluentIcons.Common.Symbol.Folder
|
||||
: FluentIcons.Common.Symbol.Apps,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,7 @@ public partial class MainWindow
|
||||
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
||||
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
|
||||
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
|
||||
SettingsNavPluginMarketItem.Content = L("settings.nav.plugin_market", "Plugin Market");
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||
@@ -283,6 +284,7 @@ public partial class MainWindow
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
ApplyPluginSettingsLocalization();
|
||||
ApplyPluginMarketSettingsLocalization();
|
||||
|
||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -77,7 +78,9 @@ public partial class MainWindow
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(MainWindow),
|
||||
Reason: "User confirmed a pending restart prompt.")) != true)
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ public partial class MainWindow
|
||||
UpdateSettingsPanel is null ||
|
||||
LauncherSettingsPanel is null ||
|
||||
AboutSettingsPanel is null ||
|
||||
PluginSettingsPanel is null)
|
||||
PluginSettingsPanel is null ||
|
||||
PluginMarketSettingsPanel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -133,6 +134,7 @@ public partial class MainWindow
|
||||
AboutSettingsPanel.IsVisible = tag == "About";
|
||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
|
||||
UpdatePluginSettingsPageVisibility(tag);
|
||||
|
||||
if (tag == "Launcher")
|
||||
@@ -140,12 +142,23 @@ public partial class MainWindow
|
||||
RenderLauncherHiddenItemsList();
|
||||
}
|
||||
|
||||
if (tag == "Plugins")
|
||||
{
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "PluginMarket")
|
||||
{
|
||||
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "Grid")
|
||||
{
|
||||
UpdateGridPreviewLayout();
|
||||
}
|
||||
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
}
|
||||
|
||||
private void OnNightModeChecked(object? sender, RoutedEventArgs e)
|
||||
@@ -332,6 +345,7 @@ public partial class MainWindow
|
||||
var placement = GetSelectedWallpaperPlacement();
|
||||
DesktopWallpaperLayer.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, false);
|
||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
}
|
||||
|
||||
private void UpdateWallpaperDisplay()
|
||||
@@ -616,12 +630,99 @@ public partial class MainWindow
|
||||
EnableHardwareDecoding = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null)
|
||||
private void EnsureDesktopVideoFrameRefreshTimer()
|
||||
{
|
||||
if (_desktopVideoFrameRefreshTimer is not null)
|
||||
{
|
||||
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc);
|
||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
||||
return;
|
||||
}
|
||||
|
||||
_desktopVideoFrameRefreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(33)
|
||||
};
|
||||
_desktopVideoFrameRefreshTimer.Tick += OnDesktopVideoFrameRefreshTimerTick;
|
||||
}
|
||||
|
||||
private void StartDesktopVideoFrameRefreshTimer()
|
||||
{
|
||||
EnsureDesktopVideoFrameRefreshTimer();
|
||||
if (_desktopVideoFrameRefreshTimer?.IsEnabled == false)
|
||||
{
|
||||
_desktopVideoFrameRefreshTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopDesktopVideoFrameRefreshTimer()
|
||||
{
|
||||
if (_desktopVideoFrameRefreshTimer?.IsEnabled == true)
|
||||
{
|
||||
_desktopVideoFrameRefreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDesktopVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
PushDesktopVideoFrameToWallpaperImage();
|
||||
}
|
||||
|
||||
private void UpdateVideoWallpaperPreviewVisibility()
|
||||
{
|
||||
var shouldShowPreview =
|
||||
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||
_isSettingsOpen &&
|
||||
SettingsPage.IsVisible &&
|
||||
WallpaperSettingsPanel.IsVisible &&
|
||||
_wallpaperPreviewSnapshotBitmap is not null;
|
||||
|
||||
WallpaperPreviewVideoImage.IsVisible = shouldShowPreview;
|
||||
if (shouldShowPreview && !ReferenceEquals(WallpaperPreviewVideoImage.Source, _wallpaperPreviewSnapshotBitmap))
|
||||
{
|
||||
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||
}
|
||||
}
|
||||
|
||||
private void InvalidateVideoWallpaperPreviewSnapshot()
|
||||
{
|
||||
_wallpaperPreviewSnapshotPending = true;
|
||||
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||
_wallpaperPreviewSnapshotBitmap = null;
|
||||
WallpaperPreviewVideoImage.Source = null;
|
||||
}
|
||||
|
||||
private void CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer()
|
||||
{
|
||||
if (!_wallpaperPreviewSnapshotPending ||
|
||||
_desktopVideoStagingBuffer is null ||
|
||||
_desktopVideoFrameWidth <= 0 ||
|
||||
_desktopVideoFrameHeight <= 0 ||
|
||||
_desktopVideoFramePitch <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||
_wallpaperPreviewSnapshotBitmap = new WriteableBitmap(
|
||||
new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight),
|
||||
new Vector(96, 96),
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Opaque);
|
||||
|
||||
using var framebuffer = _wallpaperPreviewSnapshotBitmap.Lock();
|
||||
var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight);
|
||||
var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch);
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
var sourceOffset = row * _desktopVideoFramePitch;
|
||||
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||
Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||
}
|
||||
|
||||
_wallpaperPreviewSnapshotPending = false;
|
||||
WallpaperPreviewVideoImage.Source = _wallpaperPreviewSnapshotBitmap;
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
}
|
||||
|
||||
private bool ConfigureDesktopVideoRenderer()
|
||||
@@ -673,6 +774,7 @@ public partial class MainWindow
|
||||
(uint)_desktopVideoFrameHeight,
|
||||
(uint)_desktopVideoFramePitch);
|
||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||
InvalidateVideoWallpaperPreviewSnapshot();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
@@ -733,31 +835,6 @@ public partial class MainWindow
|
||||
private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||
{
|
||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1);
|
||||
ScheduleDesktopVideoFrameUiRefresh();
|
||||
}
|
||||
|
||||
private void ScheduleDesktopVideoFrameUiRefresh()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
PushDesktopVideoFrameToWallpaperImage();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
||||
if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1)
|
||||
{
|
||||
ScheduleDesktopVideoFrameUiRefresh();
|
||||
}
|
||||
}
|
||||
}, DispatcherPriority.Render);
|
||||
}
|
||||
|
||||
private void PushDesktopVideoFrameToWallpaperImage()
|
||||
@@ -800,18 +877,22 @@ public partial class MainWindow
|
||||
{
|
||||
DesktopVideoWallpaperImage.Source = _desktopVideoBitmap;
|
||||
}
|
||||
|
||||
CaptureVideoWallpaperPreviewSnapshotFromStagingBuffer();
|
||||
}
|
||||
|
||||
private void ReleaseDesktopVideoRendererResources()
|
||||
{
|
||||
Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0);
|
||||
Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0);
|
||||
|
||||
if (DesktopVideoWallpaperImage is not null)
|
||||
{
|
||||
DesktopVideoWallpaperImage.Source = null;
|
||||
}
|
||||
|
||||
InvalidateVideoWallpaperPreviewSnapshot();
|
||||
WallpaperPreviewVideoImage.Source = null;
|
||||
|
||||
_desktopVideoBitmap?.Dispose();
|
||||
_desktopVideoBitmap = null;
|
||||
_desktopVideoStagingBuffer = null;
|
||||
@@ -843,10 +924,8 @@ public partial class MainWindow
|
||||
{
|
||||
EnsureVideoWallpaperPlayers();
|
||||
if (_videoWallpaperPlayer is null ||
|
||||
_previewVideoWallpaperPlayer is null ||
|
||||
_libVlc is null ||
|
||||
DesktopVideoWallpaperImage is null ||
|
||||
WallpaperPreviewVideoView is null)
|
||||
DesktopVideoWallpaperImage is null)
|
||||
{
|
||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||
StopVideoWallpaper();
|
||||
@@ -861,15 +940,13 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||
_videoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||
InvalidateVideoWallpaperPreviewSnapshot();
|
||||
_videoWallpaperPlayer.Play(_videoWallpaperMedia);
|
||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
||||
StartDesktopVideoFrameRefreshTimer();
|
||||
DesktopVideoWallpaperImage.IsVisible = true;
|
||||
WallpaperPreviewVideoView.IsVisible = true;
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -885,26 +962,17 @@ public partial class MainWindow
|
||||
DesktopVideoWallpaperImage.IsVisible = false;
|
||||
}
|
||||
|
||||
if (WallpaperPreviewVideoView is not null)
|
||||
{
|
||||
WallpaperPreviewVideoView.IsVisible = false;
|
||||
}
|
||||
WallpaperPreviewVideoImage.IsVisible = false;
|
||||
|
||||
if (_videoWallpaperPlayer is not null)
|
||||
{
|
||||
_videoWallpaperPlayer.Stop();
|
||||
}
|
||||
|
||||
if (_previewVideoWallpaperPlayer is not null)
|
||||
{
|
||||
_previewVideoWallpaperPlayer.Stop();
|
||||
}
|
||||
|
||||
StopDesktopVideoFrameRefreshTimer();
|
||||
ReleaseDesktopVideoRendererResources();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = null;
|
||||
}
|
||||
|
||||
private void PersistSettings()
|
||||
@@ -2367,7 +2435,14 @@ public partial class MainWindow
|
||||
_isSettingsOpen = true;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
UpdateAdaptiveTextSystem();
|
||||
ApplyWallpaperBrush();
|
||||
if (_wallpaperMediaType == WallpaperMediaType.Video)
|
||||
{
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
if (_settingsContentPanelTransform is not null)
|
||||
{
|
||||
@@ -2375,6 +2450,7 @@ public partial class MainWindow
|
||||
}
|
||||
SettingsPage.IsVisible = true;
|
||||
SettingsPage.Opacity = 0;
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
UpdateSettingsViewportInsets(Math.Max(1, _currentDesktopCellSize));
|
||||
|
||||
UpdateWallpaperPreviewLayout();
|
||||
@@ -2404,7 +2480,11 @@ public partial class MainWindow
|
||||
_isSettingsOpen = false;
|
||||
UpdateDesktopPageAwareComponentContext();
|
||||
UpdateAdaptiveTextSystem();
|
||||
ApplyWallpaperBrush();
|
||||
UpdateVideoWallpaperPreviewVisibility();
|
||||
if (_wallpaperMediaType != WallpaperMediaType.Video)
|
||||
{
|
||||
ApplyWallpaperBrush();
|
||||
}
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
|
||||
if (immediate)
|
||||
@@ -2596,7 +2676,7 @@ public partial class MainWindow
|
||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
||||
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||
@@ -2793,7 +2873,6 @@ public partial class MainWindow
|
||||
// --- LauncherSettingsPage ---
|
||||
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
||||
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
|
||||
58
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal file
58
LanMountainDesktop/Views/MainWindow.SingleInstanceNotice.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private bool _isSingleInstancePromptVisible;
|
||||
|
||||
internal void ShowSingleInstanceNotice()
|
||||
{
|
||||
void ShowPrompt()
|
||||
{
|
||||
UiExceptionGuard.FireAndForgetGuarded(
|
||||
ShowSingleInstanceNoticeCoreAsync,
|
||||
"MainWindow.ShowSingleInstanceNotice");
|
||||
}
|
||||
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
ShowPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(ShowPrompt, DispatcherPriority.Send);
|
||||
}
|
||||
|
||||
private async Task ShowSingleInstanceNoticeCoreAsync()
|
||||
{
|
||||
if (_isSingleInstancePromptVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isSingleInstancePromptVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = L("single_instance.notice.title", "应用已经运行"),
|
||||
Content = L(
|
||||
"single_instance.notice.description",
|
||||
"应用已经运行,无需多次点击打开。"),
|
||||
PrimaryButtonText = L("single_instance.notice.button", "确定"),
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
await dialog.ShowAsync(this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isSingleInstancePromptVisible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -357,7 +358,8 @@ public partial class MainWindow
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = true
|
||||
UseShellExecute = true,
|
||||
Verb = "runas"
|
||||
});
|
||||
|
||||
_updateStatusText = L(
|
||||
@@ -365,7 +367,16 @@ public partial class MainWindow
|
||||
"Installer started. The app will close for update.");
|
||||
UpdateUpdatePanelState();
|
||||
|
||||
Dispatcher.UIThread.Post(Close, DispatcherPriority.Background);
|
||||
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(MainWindow),
|
||||
Reason: "Update installer started successfully."));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
_updateStatusText = L(
|
||||
"settings.update.status_elevation_cancelled",
|
||||
"Administrator permission was not granted. Update was cancelled.");
|
||||
UpdateUpdatePanelState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -436,6 +436,11 @@
|
||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavPluginMarketItem" Content="插件市场" Tag="PluginMarket" ToolTip.Tip="插件市场">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
</ui:NavigationView.MenuItems>
|
||||
|
||||
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
||||
@@ -459,55 +464,58 @@
|
||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
Grid.Row="1"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
CornerRadius="18"
|
||||
Padding="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="Restart required" />
|
||||
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Your changes will apply after restarting the app." />
|
||||
</StackPanel>
|
||||
<Button x:Name="PendingRestartDockButton"
|
||||
Grid.Column="2"
|
||||
Padding="14,8"
|
||||
Click="OnPendingRestartDockButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<StackPanel Grid.Row="1"
|
||||
Spacing="12">
|
||||
<Border x:Name="PendingRestartDock"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
CornerRadius="18"
|
||||
Padding="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Restart app" />
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="Restart required" />
|
||||
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Your changes will apply after restarting the app." />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Button x:Name="PendingRestartDockButton"
|
||||
Grid.Column="2"
|
||||
Padding="14,8"
|
||||
Click="OnPendingRestartDockButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Restart app" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
@@ -124,21 +124,21 @@ public partial class MainWindow : Window
|
||||
private LibVLC? _libVlc;
|
||||
private MediaPlayer? _videoWallpaperPlayer;
|
||||
private Media? _videoWallpaperMedia;
|
||||
private MediaPlayer? _previewVideoWallpaperPlayer;
|
||||
private Media? _previewVideoWallpaperMedia;
|
||||
private readonly object _desktopVideoFrameSync = new();
|
||||
private MediaPlayer.LibVLCVideoLockCb? _desktopVideoLockCallback;
|
||||
private MediaPlayer.LibVLCVideoUnlockCb? _desktopVideoUnlockCallback;
|
||||
private MediaPlayer.LibVLCVideoDisplayCb? _desktopVideoDisplayCallback;
|
||||
private DispatcherTimer? _desktopVideoFrameRefreshTimer;
|
||||
private IntPtr _desktopVideoFrameBufferPtr;
|
||||
private byte[]? _desktopVideoStagingBuffer;
|
||||
private WriteableBitmap? _desktopVideoBitmap;
|
||||
private WriteableBitmap? _wallpaperPreviewSnapshotBitmap;
|
||||
private int _desktopVideoFrameWidth;
|
||||
private int _desktopVideoFrameHeight;
|
||||
private int _desktopVideoFramePitch;
|
||||
private int _desktopVideoFrameBufferSize;
|
||||
private int _desktopVideoFrameDirtyFlag;
|
||||
private int _desktopVideoFrameUiRefreshScheduledFlag;
|
||||
private bool _wallpaperPreviewSnapshotPending;
|
||||
private string? _wallpaperPath;
|
||||
private string _wallpaperStatus = "Current background uses solid color.";
|
||||
private IReadOnlyList<Color> _recommendedColors = Array.Empty<Color>();
|
||||
@@ -384,15 +384,15 @@ public partial class MainWindow : Window
|
||||
{
|
||||
PersistSettings();
|
||||
StopVideoWallpaper();
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = null;
|
||||
_previewVideoWallpaperPlayer?.Dispose();
|
||||
_previewVideoWallpaperPlayer = null;
|
||||
DisposeLauncherResources();
|
||||
_videoWallpaperMedia?.Dispose();
|
||||
_videoWallpaperMedia = null;
|
||||
_videoWallpaperPlayer?.Dispose();
|
||||
_videoWallpaperPlayer = null;
|
||||
_desktopVideoFrameRefreshTimer?.Stop();
|
||||
_desktopVideoFrameRefreshTimer = null;
|
||||
_wallpaperPreviewSnapshotBitmap?.Dispose();
|
||||
_wallpaperPreviewSnapshotBitmap = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
if (_weatherDataService is IDisposable weatherServiceDisposable)
|
||||
|
||||
@@ -31,12 +31,6 @@
|
||||
IsVisible="False"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No hidden items." />
|
||||
<ScrollViewer MaxHeight="420"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
HorizontalScrollBarVisibility="Disabled">
|
||||
<StackPanel x:Name="LauncherHiddenItemsListPanel"
|
||||
Spacing="8" />
|
||||
</ScrollViewer>
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:comp="using:LanMountainDesktop.Views.Components"
|
||||
xmlns:vlc="clr-namespace:LibVLCSharp.Avalonia;assembly=LibVLCSharp.Avalonia"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="600"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.WallpaperSettingsPage">
|
||||
<Grid x:Name="WallpaperSettingsPanel"
|
||||
@@ -37,11 +36,12 @@
|
||||
CornerRadius="12"
|
||||
Background="#30111827">
|
||||
<Grid>
|
||||
<vlc:VideoView x:Name="WallpaperPreviewVideoView"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
<Image x:Name="WallpaperPreviewVideoImage"
|
||||
IsVisible="False"
|
||||
IsHitTestVisible="False"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
|
||||
<Grid x:Name="WallpaperPreviewGrid"
|
||||
HorizontalAlignment="Center"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using LibVLCSharp.Avalonia;
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
@@ -14,7 +13,7 @@ public partial class SettingsWindow
|
||||
internal Border WallpaperPreviewHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewHost")!;
|
||||
internal Border WallpaperPreviewFrame => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewFrame")!;
|
||||
internal Border WallpaperPreviewViewport => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewViewport")!;
|
||||
internal LibVLCSharp.Avalonia.VideoView? WallpaperPreviewVideoView => WallpaperSettingsPanel.FindControl<LibVLCSharp.Avalonia.VideoView>("WallpaperPreviewVideoView");
|
||||
internal Image WallpaperPreviewVideoImage => WallpaperSettingsPanel.FindControl<Image>("WallpaperPreviewVideoImage")!;
|
||||
internal Grid WallpaperPreviewGrid => WallpaperSettingsPanel.FindControl<Grid>("WallpaperPreviewGrid")!;
|
||||
internal Border WallpaperPreviewTopStatusBarHost => WallpaperSettingsPanel.FindControl<Border>("WallpaperPreviewTopStatusBarHost")!;
|
||||
internal StackPanel WallpaperPreviewTopStatusComponentsPanel => WallpaperSettingsPanel.FindControl<StackPanel>("WallpaperPreviewTopStatusComponentsPanel")!;
|
||||
@@ -211,7 +210,6 @@ public partial class SettingsWindow
|
||||
// --- LauncherSettingsPage ---
|
||||
internal TextBlock LauncherSettingsPanelTitleTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander LauncherHiddenItemsSettingsExpander => LauncherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("LauncherHiddenItemsSettingsExpander")!;
|
||||
internal StackPanel LauncherHiddenItemsListPanel => LauncherSettingsPanel.FindControl<StackPanel>("LauncherHiddenItemsListPanel")!;
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ public partial class SettingsWindow
|
||||
_previewVideoWallpaperPlayer = null;
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = null;
|
||||
_previewVideoFrameRefreshTimer?.Stop();
|
||||
_previewVideoFrameRefreshTimer = null;
|
||||
_libVlc?.Dispose();
|
||||
_libVlc = null;
|
||||
|
||||
@@ -69,6 +71,7 @@ public partial class SettingsWindow
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "PluginMarket", Symbol.PuzzlePiece, "Plugin Market");
|
||||
}
|
||||
|
||||
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
|
||||
@@ -229,6 +232,7 @@ public partial class SettingsWindow
|
||||
AboutSettingsPanel.IsVisible = tag == "About";
|
||||
LauncherSettingsPanel.IsVisible = tag == "Launcher";
|
||||
PluginSettingsPanel.IsVisible = tag == "Plugins";
|
||||
PluginMarketSettingsPanel.IsVisible = tag == "PluginMarket";
|
||||
UpdatePluginSettingsPageVisibility(tag);
|
||||
|
||||
if (tag == "Launcher")
|
||||
@@ -236,12 +240,23 @@ public partial class SettingsWindow
|
||||
RenderLauncherHiddenItemsList();
|
||||
}
|
||||
|
||||
if (tag == "Plugins")
|
||||
{
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "PluginMarket")
|
||||
{
|
||||
PluginMarketSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
|
||||
if (tag == "Grid")
|
||||
{
|
||||
UpdateGridPreviewLayout();
|
||||
}
|
||||
|
||||
ApplyTaskbarActionVisibility(GetCurrentTaskbarContext());
|
||||
SyncVideoWallpaperPreviewPlayback();
|
||||
}
|
||||
|
||||
private void PersistSettings()
|
||||
|
||||
@@ -74,6 +74,7 @@ public partial class SettingsWindow
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("About"), L("settings.nav.about", "About"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Launcher"), L("settings.nav.launcher", "App Launcher"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Plugins"), L("settings.nav.plugins", "Plugins"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("PluginMarket"), L("settings.nav.plugin_market", "Plugin Market"));
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||
@@ -177,6 +178,7 @@ public partial class SettingsWindow
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
ApplyPluginSettingsLocalization();
|
||||
ApplyPluginMarketSettingsLocalization();
|
||||
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -77,7 +78,9 @@ public partial class SettingsWindow
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
if (App.CurrentHostApplicationLifecycle?.TryRestart(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(SettingsWindow),
|
||||
Reason: "User confirmed a pending restart prompt from settings.")) != true)
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -250,23 +249,23 @@ public partial class SettingsWindow
|
||||
{
|
||||
FileName = installerPath,
|
||||
WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory,
|
||||
UseShellExecute = true
|
||||
UseShellExecute = true,
|
||||
Verb = "runas"
|
||||
});
|
||||
|
||||
_updateStatusText = L("settings.update.status_installer_started", "Installer started. The app will close for update.");
|
||||
UpdateUpdatePanelState();
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
else
|
||||
{
|
||||
Close();
|
||||
}
|
||||
}, DispatcherPriority.Background);
|
||||
_ = App.CurrentHostApplicationLifecycle?.TryExit(new HostApplicationLifecycleRequest(
|
||||
Source: nameof(SettingsWindow),
|
||||
Reason: "Update installer started successfully from settings."));
|
||||
}
|
||||
catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
|
||||
{
|
||||
_updateStatusText = L(
|
||||
"settings.update.status_elevation_cancelled",
|
||||
"Administrator permission was not granted. Update was cancelled.");
|
||||
UpdateUpdatePanelState();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
@@ -12,6 +13,7 @@ using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using Avalonia.Styling;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Threading;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Theme;
|
||||
using LibVLCSharp.Shared;
|
||||
@@ -157,7 +159,7 @@ public partial class SettingsWindow
|
||||
{
|
||||
DesktopWallpaperLayer.Background = Brushes.Transparent;
|
||||
WallpaperPreviewViewport.Background = GetThemeDefaultDesktopBackground();
|
||||
PlayVideoWallpaper(_wallpaperVideoPath);
|
||||
SyncVideoWallpaperPreviewPlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -175,6 +177,36 @@ public partial class SettingsWindow
|
||||
WallpaperPreviewViewport.Background = CreateWallpaperBrush(_wallpaperBitmap, placement, true);
|
||||
}
|
||||
|
||||
private void SyncVideoWallpaperPreviewPlayback()
|
||||
{
|
||||
var shouldPlay =
|
||||
_wallpaperMediaType == WallpaperMediaType.Video &&
|
||||
!string.IsNullOrWhiteSpace(_wallpaperVideoPath) &&
|
||||
WallpaperSettingsPanel.IsVisible;
|
||||
|
||||
if (!shouldPlay)
|
||||
{
|
||||
if (_previewVideoWallpaperPlayer?.IsPlaying == true)
|
||||
{
|
||||
StopPreviewVideoCapture(clearSnapshot: false);
|
||||
}
|
||||
|
||||
WallpaperPreviewVideoImage.IsVisible = WallpaperPreviewVideoImage.Source is not null && WallpaperSettingsPanel.IsVisible;
|
||||
return;
|
||||
}
|
||||
|
||||
if (WallpaperPreviewVideoImage.Source is not null)
|
||||
{
|
||||
WallpaperPreviewVideoImage.IsVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewVideoWallpaperMedia is null || _previewVideoSnapshotPending)
|
||||
{
|
||||
PlayVideoWallpaper(_wallpaperVideoPath!);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateWallpaperDisplay()
|
||||
{
|
||||
WallpaperPathTextBlock.Text = string.IsNullOrWhiteSpace(_wallpaperPath)
|
||||
@@ -417,11 +449,239 @@ public partial class SettingsWindow
|
||||
private void EnsureVideoWallpaperPlayers()
|
||||
{
|
||||
Core.Initialize();
|
||||
_libVlc ??= new LibVLC();
|
||||
_previewVideoWallpaperPlayer ??= new MediaPlayer(_libVlc);
|
||||
if (WallpaperPreviewVideoView is not null)
|
||||
_libVlc ??= new LibVLC("--quiet");
|
||||
if (_previewVideoWallpaperPlayer is null)
|
||||
{
|
||||
WallpaperPreviewVideoView.MediaPlayer = _previewVideoWallpaperPlayer;
|
||||
_previewVideoWallpaperPlayer = new MediaPlayer(_libVlc)
|
||||
{
|
||||
EnableHardwareDecoding = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePreviewVideoFrameRefreshTimer()
|
||||
{
|
||||
if (_previewVideoFrameRefreshTimer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_previewVideoFrameRefreshTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(33)
|
||||
};
|
||||
_previewVideoFrameRefreshTimer.Tick += OnPreviewVideoFrameRefreshTimerTick;
|
||||
}
|
||||
|
||||
private void StartPreviewVideoFrameRefreshTimer()
|
||||
{
|
||||
EnsurePreviewVideoFrameRefreshTimer();
|
||||
if (_previewVideoFrameRefreshTimer?.IsEnabled == false)
|
||||
{
|
||||
_previewVideoFrameRefreshTimer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
private void StopPreviewVideoFrameRefreshTimer()
|
||||
{
|
||||
if (_previewVideoFrameRefreshTimer?.IsEnabled == true)
|
||||
{
|
||||
_previewVideoFrameRefreshTimer.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPreviewVideoFrameRefreshTimerTick(object? sender, EventArgs e)
|
||||
{
|
||||
PushPreviewVideoFrameToWallpaperImage();
|
||||
}
|
||||
|
||||
private void StopPreviewVideoCapture(bool clearSnapshot)
|
||||
{
|
||||
WallpaperPreviewVideoImage.IsVisible = false;
|
||||
_previewVideoWallpaperPlayer?.Stop();
|
||||
StopPreviewVideoFrameRefreshTimer();
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = null;
|
||||
_previewVideoSnapshotPending = false;
|
||||
|
||||
if (clearSnapshot)
|
||||
{
|
||||
ReleasePreviewVideoRendererResources();
|
||||
}
|
||||
}
|
||||
|
||||
private bool ConfigurePreviewVideoRenderer()
|
||||
{
|
||||
if (_previewVideoWallpaperPlayer is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hostWidth = Math.Max(1, WallpaperPreviewViewport.Bounds.Width);
|
||||
var hostHeight = Math.Max(1, WallpaperPreviewViewport.Bounds.Height);
|
||||
var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling));
|
||||
var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling));
|
||||
const int maxPixelCount = 1280 * 720;
|
||||
var pixelCount = (long)pixelWidth * pixelHeight;
|
||||
if (pixelCount > maxPixelCount)
|
||||
{
|
||||
var scale = Math.Sqrt((double)maxPixelCount / pixelCount);
|
||||
pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale));
|
||||
pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale));
|
||||
}
|
||||
|
||||
var pitch = pixelWidth * 4;
|
||||
var bufferSize = pitch * pixelHeight;
|
||||
if (bufferSize <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pixelWidth == _previewVideoFrameWidth &&
|
||||
pixelHeight == _previewVideoFrameHeight &&
|
||||
_previewVideoFrameBufferPtr != IntPtr.Zero &&
|
||||
_previewVideoBitmap is not null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
ReleasePreviewVideoRendererResources();
|
||||
|
||||
try
|
||||
{
|
||||
_previewVideoFrameWidth = pixelWidth;
|
||||
_previewVideoFrameHeight = pixelHeight;
|
||||
_previewVideoFramePitch = pitch;
|
||||
_previewVideoFrameBufferSize = bufferSize;
|
||||
_previewVideoFrameBufferPtr = Marshal.AllocHGlobal(_previewVideoFrameBufferSize);
|
||||
_previewVideoStagingBuffer = new byte[_previewVideoFrameBufferSize];
|
||||
_previewVideoBitmap = new WriteableBitmap(
|
||||
new PixelSize(_previewVideoFrameWidth, _previewVideoFrameHeight),
|
||||
new Vector(96, 96),
|
||||
PixelFormat.Bgra8888,
|
||||
AlphaFormat.Opaque);
|
||||
EnsurePreviewVideoCallbacks();
|
||||
_previewVideoWallpaperPlayer.SetVideoCallbacks(
|
||||
_previewVideoLockCallback!,
|
||||
_previewVideoUnlockCallback!,
|
||||
_previewVideoDisplayCallback!);
|
||||
_previewVideoWallpaperPlayer.SetVideoFormat(
|
||||
"RV32",
|
||||
(uint)_previewVideoFrameWidth,
|
||||
(uint)_previewVideoFrameHeight,
|
||||
(uint)_previewVideoFramePitch);
|
||||
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ReleasePreviewVideoRendererResources();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePreviewVideoCallbacks()
|
||||
{
|
||||
_previewVideoLockCallback ??= OnPreviewVideoFrameLock;
|
||||
_previewVideoUnlockCallback ??= OnPreviewVideoFrameUnlock;
|
||||
_previewVideoDisplayCallback ??= OnPreviewVideoFrameDisplay;
|
||||
}
|
||||
|
||||
private IntPtr OnPreviewVideoFrameLock(IntPtr opaque, IntPtr planes)
|
||||
{
|
||||
Monitor.Enter(_previewVideoFrameSync);
|
||||
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||
{
|
||||
Marshal.WriteIntPtr(planes, IntPtr.Zero);
|
||||
Monitor.Exit(_previewVideoFrameSync);
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
Marshal.WriteIntPtr(planes, _previewVideoFrameBufferPtr);
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
private void OnPreviewVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes)
|
||||
{
|
||||
if (Monitor.IsEntered(_previewVideoFrameSync))
|
||||
{
|
||||
Monitor.Exit(_previewVideoFrameSync);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPreviewVideoFrameDisplay(IntPtr opaque, IntPtr picture)
|
||||
{
|
||||
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 1);
|
||||
}
|
||||
|
||||
private void PushPreviewVideoFrameToWallpaperImage()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0) == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previewVideoBitmap is null ||
|
||||
_previewVideoStagingBuffer is null ||
|
||||
_previewVideoFrameBufferPtr == IntPtr.Zero ||
|
||||
_previewVideoFrameBufferSize <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_previewVideoFrameSync)
|
||||
{
|
||||
if (_previewVideoFrameBufferPtr == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Marshal.Copy(_previewVideoFrameBufferPtr, _previewVideoStagingBuffer, 0, _previewVideoFrameBufferSize);
|
||||
}
|
||||
|
||||
using var framebuffer = _previewVideoBitmap.Lock();
|
||||
var rows = Math.Min(framebuffer.Size.Height, _previewVideoFrameHeight);
|
||||
var bytesPerRow = Math.Min(framebuffer.RowBytes, _previewVideoFramePitch);
|
||||
for (var row = 0; row < rows; row++)
|
||||
{
|
||||
var sourceOffset = row * _previewVideoFramePitch;
|
||||
var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes);
|
||||
Marshal.Copy(_previewVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow);
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(WallpaperPreviewVideoImage.Source, _previewVideoBitmap))
|
||||
{
|
||||
WallpaperPreviewVideoImage.Source = _previewVideoBitmap;
|
||||
}
|
||||
|
||||
if (_previewVideoSnapshotPending)
|
||||
{
|
||||
_previewVideoSnapshotPending = false;
|
||||
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||
StopPreviewVideoCapture(clearSnapshot: false);
|
||||
WallpaperPreviewVideoImage.IsVisible = WallpaperSettingsPanel.IsVisible;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReleasePreviewVideoRendererResources()
|
||||
{
|
||||
Interlocked.Exchange(ref _previewVideoFrameDirtyFlag, 0);
|
||||
WallpaperPreviewVideoImage.Source = null;
|
||||
_previewVideoBitmap?.Dispose();
|
||||
_previewVideoBitmap = null;
|
||||
_previewVideoStagingBuffer = null;
|
||||
_previewVideoFrameWidth = 0;
|
||||
_previewVideoFrameHeight = 0;
|
||||
_previewVideoFramePitch = 0;
|
||||
_previewVideoFrameBufferSize = 0;
|
||||
|
||||
lock (_previewVideoFrameSync)
|
||||
{
|
||||
if (_previewVideoFrameBufferPtr != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(_previewVideoFrameBufferPtr);
|
||||
_previewVideoFrameBufferPtr = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,7 +697,14 @@ public partial class SettingsWindow
|
||||
try
|
||||
{
|
||||
EnsureVideoWallpaperPlayers();
|
||||
if (_previewVideoWallpaperPlayer is null || _libVlc is null || WallpaperPreviewVideoView is null)
|
||||
if (_previewVideoWallpaperPlayer is null || _libVlc is null)
|
||||
{
|
||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||
StopVideoWallpaper();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ConfigurePreviewVideoRenderer())
|
||||
{
|
||||
_wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable.");
|
||||
StopVideoWallpaper();
|
||||
@@ -447,8 +714,10 @@ public partial class SettingsWindow
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = new Media(_libVlc, new Uri(videoPath));
|
||||
_previewVideoWallpaperMedia.AddOption(":input-repeat=65535");
|
||||
_previewVideoSnapshotPending = true;
|
||||
WallpaperPreviewVideoImage.IsVisible = false;
|
||||
_previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia);
|
||||
WallpaperPreviewVideoView.IsVisible = true;
|
||||
StartPreviewVideoFrameRefreshTimer();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -459,14 +728,7 @@ public partial class SettingsWindow
|
||||
|
||||
private void StopVideoWallpaper()
|
||||
{
|
||||
if (WallpaperPreviewVideoView is not null)
|
||||
{
|
||||
WallpaperPreviewVideoView.IsVisible = false;
|
||||
}
|
||||
|
||||
_previewVideoWallpaperPlayer?.Stop();
|
||||
_previewVideoWallpaperMedia?.Dispose();
|
||||
_previewVideoWallpaperMedia = null;
|
||||
StopPreviewVideoCapture(clearSnapshot: true);
|
||||
}
|
||||
|
||||
private void OnRecommendedColorClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -11,6 +11,7 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -626,18 +627,18 @@ public partial class SettingsWindow
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
|
||||
}
|
||||
|
||||
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
|
||||
private static FluentIcons.Common.Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
|
||||
{
|
||||
return weatherCode switch
|
||||
{
|
||||
0 => isNight ? Symbol.WeatherMoon : Symbol.WeatherSunny,
|
||||
1 or 2 => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay,
|
||||
3 or 7 => Symbol.WeatherRainShowersDay,
|
||||
8 or 9 => Symbol.WeatherRain,
|
||||
4 => Symbol.WeatherThunderstorm,
|
||||
13 or 14 or 15 or 16 => Symbol.WeatherSnow,
|
||||
18 or 32 => Symbol.WeatherFog,
|
||||
_ => isNight ? Symbol.WeatherPartlyCloudyNight : Symbol.WeatherPartlyCloudyDay
|
||||
0 => isNight ? FluentIcons.Common.Symbol.WeatherMoon : FluentIcons.Common.Symbol.WeatherSunny,
|
||||
1 or 2 => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay,
|
||||
3 or 7 => FluentIcons.Common.Symbol.WeatherRainShowersDay,
|
||||
8 or 9 => FluentIcons.Common.Symbol.WeatherRain,
|
||||
4 => FluentIcons.Common.Symbol.WeatherThunderstorm,
|
||||
13 or 14 or 15 or 16 => FluentIcons.Common.Symbol.WeatherSnow,
|
||||
18 or 32 => FluentIcons.Common.Symbol.WeatherFog,
|
||||
_ => isNight ? FluentIcons.Common.Symbol.WeatherPartlyCloudyNight : FluentIcons.Common.Symbol.WeatherPartlyCloudyDay
|
||||
};
|
||||
}
|
||||
|
||||
@@ -782,7 +783,7 @@ public partial class SettingsWindow
|
||||
|
||||
private void RenderLauncherHiddenItemsList()
|
||||
{
|
||||
LauncherHiddenItemsListPanel.Children.Clear();
|
||||
LauncherHiddenItemsSettingsExpander.Items.Clear();
|
||||
var hiddenItems = BuildLauncherHiddenItems();
|
||||
LauncherHiddenItemsEmptyTextBlock.IsVisible = hiddenItems.Count == 0;
|
||||
if (hiddenItems.Count == 0)
|
||||
@@ -792,7 +793,7 @@ public partial class SettingsWindow
|
||||
|
||||
foreach (var hiddenItem in hiddenItems)
|
||||
{
|
||||
LauncherHiddenItemsListPanel.Children.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||
LauncherHiddenItemsSettingsExpander.Items.Add(CreateLauncherHiddenItemRow(hiddenItem));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,82 +865,58 @@ public partial class SettingsWindow
|
||||
return string.IsNullOrWhiteSpace(fileName) ? key : fileName;
|
||||
}
|
||||
|
||||
private Control CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
|
||||
private SettingsExpanderItem CreateLauncherHiddenItemRow(LauncherHiddenItemView hiddenItem)
|
||||
{
|
||||
Control icon = hiddenItem.IconBitmap is not null
|
||||
? new Image
|
||||
{
|
||||
Source = hiddenItem.IconBitmap,
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
Stretch = Stretch.Uniform
|
||||
}
|
||||
: new Border
|
||||
{
|
||||
Width = 24,
|
||||
Height = 24,
|
||||
CornerRadius = new CornerRadius(999),
|
||||
Background = GetThemeBrush("AdaptiveButtonBackgroundBrush"),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = hiddenItem.Monogram,
|
||||
FontSize = 10,
|
||||
FontWeight = FontWeight.Bold,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
}
|
||||
};
|
||||
|
||||
var typeText = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||
? L("settings.launcher.hidden_type_folder", "Folder")
|
||||
: L("settings.launcher.hidden_type_shortcut", "Shortcut");
|
||||
|
||||
var infoPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 10,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch
|
||||
};
|
||||
infoPanel.Children.Add(icon);
|
||||
infoPanel.Children.Add(new StackPanel
|
||||
{
|
||||
Spacing = 2,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = hiddenItem.DisplayName, TextTrimming = TextTrimming.CharacterEllipsis, MaxLines = 1 },
|
||||
new TextBlock { Text = typeText, FontSize = 11, Opacity = 0.7 }
|
||||
}
|
||||
});
|
||||
|
||||
var restoreButton = new Button
|
||||
{
|
||||
Content = L("settings.launcher.restore_button", "Show Again"),
|
||||
MinWidth = 110,
|
||||
Padding = new Thickness(12, 6),
|
||||
Width = 36,
|
||||
Height = 36,
|
||||
Padding = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Tag = new LauncherHiddenItemToken(hiddenItem.Kind, hiddenItem.Key)
|
||||
};
|
||||
restoreButton.Content = new FluentIcons.Avalonia.Fluent.SymbolIcon
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.Eye,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular,
|
||||
FontSize = 18,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
ToolTip.SetTip(restoreButton, L("settings.launcher.restore_button", "Unhide"));
|
||||
restoreButton.Click += OnRestoreLauncherHiddenItemClick;
|
||||
|
||||
var row = new Grid
|
||||
return new SettingsExpanderItem
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 10
|
||||
Content = hiddenItem.DisplayName,
|
||||
Description = typeText,
|
||||
IconSource = CreateLauncherHiddenItemIconSource(hiddenItem),
|
||||
IsClickEnabled = false,
|
||||
Footer = restoreButton
|
||||
};
|
||||
row.Children.Add(infoPanel);
|
||||
Grid.SetColumn(infoPanel, 0);
|
||||
row.Children.Add(restoreButton);
|
||||
Grid.SetColumn(restoreButton, 1);
|
||||
}
|
||||
|
||||
return new Border
|
||||
private IconSource CreateLauncherHiddenItemIconSource(LauncherHiddenItemView hiddenItem)
|
||||
{
|
||||
if (hiddenItem.IconBitmap is not null)
|
||||
{
|
||||
Classes = { "glass-panel" },
|
||||
BorderThickness = new Thickness(0),
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Padding = new Thickness(10, 8),
|
||||
Child = row
|
||||
return new ImageIconSource
|
||||
{
|
||||
Source = hiddenItem.IconBitmap
|
||||
};
|
||||
}
|
||||
|
||||
return new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = hiddenItem.Kind == LauncherEntryKind.Folder
|
||||
? FluentIcons.Common.Symbol.Folder
|
||||
: FluentIcons.Common.Symbol.Apps,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginMarketSettingsPage x:Name="PluginMarketSettingsPanel" IsVisible="False" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user