mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-20 23:54:26 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdffaa16eb | ||
|
|
d33d8d3391 | ||
|
|
9c89c08448 | ||
|
|
ec7b78bc63 | ||
|
|
e97db00999 |
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
27
.github/workflows/airappmarket-validate.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: AirAppMarket Validate
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "airappmarket/**"
|
||||
- ".github/workflows/airappmarket-validate.yml"
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Validate AirAppMarket index
|
||||
run: dotnet run --project airappmarket/tools/AirAppMarket.Validator -- airappmarket/index.json airappmarket/schema/airappmarket-index.schema.json
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -490,4 +490,5 @@ nul
|
||||
/_build_verify_sample_plugin_capabilities
|
||||
/_build_verify_plugin_page_host
|
||||
/_build_verify_plugin_services
|
||||
/LanMountainDesktop.PluginSdk/_build_verify_*/
|
||||
/_build_obj
|
||||
|
||||
27
LanAirApp/README.md
Normal file
27
LanAirApp/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# LanAirApp
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
|
||||
|
||||
这里集中放置:
|
||||
- 插件开发标准
|
||||
- 插件打包与构建工具
|
||||
- 插件开发与打包文档
|
||||
- 示例插件
|
||||
|
||||
目录结构:
|
||||
- `docs/`:插件开发文档、打包文档
|
||||
- `plugins/`:第一方插件项目,例如插件市场插件
|
||||
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
|
||||
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
|
||||
- `standards/`:插件标准文件与模板
|
||||
- `tools/`:插件打包与构建工具
|
||||
|
||||
面向用户的安装流程:
|
||||
1. 将插件构建或打包为 `.laapp` 文件。
|
||||
2. 打开 `设置 -> 插件`。
|
||||
3. 点击 `打开 .laapp 插件包`。
|
||||
4. 选择插件包完成安装。
|
||||
|
||||
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`。
|
||||
|
||||
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin`、`IPluginContext`、清单模型与扩展注册接口。
|
||||
41
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
41
LanAirApp/docs/PLUGIN_DEVELOPMENT.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 插件开发文档
|
||||
|
||||
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
|
||||
|
||||
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`。
|
||||
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
|
||||
|
||||
## 必需文件
|
||||
- `plugin.json`
|
||||
- `plugin.json` 中声明的入口程序集
|
||||
- 使用插件入口特性标记的入口类型
|
||||
|
||||
## 推荐开发流程
|
||||
1. 以 `LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
|
||||
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
|
||||
3. 实现 `IPlugin` 或继承 `PluginBase`。
|
||||
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
|
||||
5. 将输出内容打包为 `.laapp` 文件。
|
||||
|
||||
## 运行时能力
|
||||
- 插件可以注册自己的设置页。
|
||||
- 插件可以注册自己的桌面组件。
|
||||
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
|
||||
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
|
||||
|
||||
## 多语言建议
|
||||
- 插件应当内置 `Localization/zh-CN.json` 与 `Localization/en-US.json`。
|
||||
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
|
||||
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
|
||||
|
||||
## 目录建议
|
||||
一个标准插件项目建议至少包含:
|
||||
- `plugin.json`
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件程序集与依赖文件
|
||||
|
||||
## 示例项目与工具
|
||||
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
|
||||
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
|
||||
- 标准模板:`LanAirApp/standards/plugin.template.json`
|
||||
34
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
34
LanAirApp/docs/PLUGIN_PACKAGING.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 插件打包文档
|
||||
|
||||
LanMountainDesktop 插件的安装包格式固定为 `.laapp`。
|
||||
|
||||
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
|
||||
|
||||
## `.laapp` 格式说明
|
||||
- 本质上是一个标准 zip 压缩包
|
||||
- 包根目录必须包含 `plugin.json`
|
||||
- 包根目录还必须包含入口程序集及其依赖
|
||||
|
||||
## 建议打包内容
|
||||
- `plugin.json`
|
||||
- `YourPlugin.dll`
|
||||
- 依赖程序集
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件运行所需的其他资源文件
|
||||
|
||||
## 使用打包工具
|
||||
```powershell
|
||||
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
|
||||
```
|
||||
|
||||
## 应用内安装流程
|
||||
1. 打开 `设置 -> 插件`
|
||||
2. 点击 `打开 .laapp 插件包`
|
||||
3. 选择要安装的插件包
|
||||
4. 如果插件注册了设置页或组件,安装后重启应用
|
||||
|
||||
## 注意事项
|
||||
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
|
||||
- 包内应尽量避免无关开发产物。
|
||||
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<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>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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..."
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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": "安装中…"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
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 => "已安装",
|
||||
_ => "安装"
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -9,14 +9,15 @@
|
||||
<OutputPath>bin\$(Configuration)\$(TargetFramework)\content\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<PluginPackageOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||
<PluginPackageOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\</PluginPackageOutputDirectory>
|
||||
<PluginPackagePath>$(PluginPackageOutputDirectory)$(AssemblyName).laapp</PluginPackagePath>
|
||||
<LegacyLoosePluginOutputDirectory>..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||
<LegacyLoosePluginOutputDirectory>..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\SamplePlugin\</LegacyLoosePluginOutputDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" Private="false" />
|
||||
<None Include="plugin.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="Localization\*.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="CreateLaappPackage" AfterTargets="Build">
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"settings.page_title": "Plugin Status",
|
||||
"plugin.name": "LanMountain Sample Plugin",
|
||||
"plugin.description": "Example plugin used to validate PluginSdk loading, services, communication, and localization.",
|
||||
"widget.display_name": "Sample Plugin Status Clock",
|
||||
"widget.category": "Plugins",
|
||||
"settings.header.title": "Sample Plugin Capability Inspector",
|
||||
"settings.section.info": "Plugin Info",
|
||||
"settings.section.capabilities": "Accessible Capabilities",
|
||||
"settings.section.status": "Live Runtime Status",
|
||||
"settings.info.plugin_name": "Plugin Name",
|
||||
"settings.info.plugin_id": "Plugin Id",
|
||||
"settings.info.version": "Version",
|
||||
"settings.info.author": "Author",
|
||||
"settings.info.description": "Description",
|
||||
"settings.info.plugin_directory": "Plugin Directory",
|
||||
"settings.info.data_directory": "Data Directory",
|
||||
"settings.info.host_application": "Host Application",
|
||||
"settings.info.host_version": "Host Version",
|
||||
"settings.info.sdk_api_version": "SDK API Version",
|
||||
"settings.info.state_service_resolved": "State Service Resolved",
|
||||
"settings.info.clock_service_resolved": "Clock Service Resolved",
|
||||
"settings.info.message_bus_resolved": "Message Bus Resolved",
|
||||
"settings.info.component_placed": "Component Placed",
|
||||
"settings.info.placed_count": "Placed Count",
|
||||
"settings.info.preview_count": "Preview Count",
|
||||
"settings.info.placement_ids": "Placement Ids",
|
||||
"settings.info.last_component_id": "Last Component Id",
|
||||
"settings.info.last_cell_size": "Last Cell Size",
|
||||
"settings.info.clock_service_time": "Clock Service Time",
|
||||
"settings.status.updated_at": "Updated: {0}",
|
||||
"status.frontend.title": "Frontend Status",
|
||||
"status.component.title": "Component Status",
|
||||
"status.backend.title": "Backend Status",
|
||||
"status.service.title": "Clock Service",
|
||||
"status.summary.pending": "Pending",
|
||||
"status.summary.attached": "Attached",
|
||||
"status.summary.healthy": "Healthy",
|
||||
"status.summary.faulted": "Faulted",
|
||||
"status.summary.placed": "Placed",
|
||||
"status.summary.preview": "Preview",
|
||||
"status.frontend.detail.pending": "Waiting for a plugin UI surface to connect.",
|
||||
"status.frontend.detail.settings_connected": "Settings page is connected to plugin services and communication.",
|
||||
"status.frontend.detail.widget_connected": "Widget surface is connected to plugin services and communication.",
|
||||
"status.component.detail.pending": "No component instance has been created yet.",
|
||||
"status.component.detail.none": "No component instance is active.",
|
||||
"status.component.detail.preview": "Preview instances: {0}; no placed desktop instance is active yet.",
|
||||
"status.component.detail.placed": "Placed count: {0}; preview count: {1}; placements: {2}",
|
||||
"status.backend.detail.pending": "Plugin initialization is in progress.",
|
||||
"status.backend.detail.log_written": "Initialization log written to: {0}",
|
||||
"status.backend.detail.log_write_failed": "Initialization log write failed: {0}",
|
||||
"status.service.detail.pending": "Clock service is not attached yet.",
|
||||
"status.service.detail.attached": "Clock service was attached and is waiting for the first tick.",
|
||||
"status.service.detail.running": "Clock service is running. Current service time: {0}",
|
||||
"status.service.detail.write_failed": "Clock state write failed: {0}",
|
||||
"capability.manifest.title": "IPluginContext.Manifest",
|
||||
"capability.manifest.detail": "Readable. Current plugin id: {0}; version: {1}.",
|
||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
||||
"capability.directories.detail": "Readable. Plugin directory: {0}; data directory: {1}.",
|
||||
"capability.properties.title": "IPluginContext.Properties",
|
||||
"capability.properties.detail": "Readable. Host properties currently exposed: {0}.",
|
||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
||||
"capability.get_service.detail": "Callable. State service resolved: {0}; clock service resolved: {1}; message bus resolved: {2}.",
|
||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
||||
"capability.register_service.detail": "Callable during plugin initialization. This sample plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container.",
|
||||
"capability.message_bus.title": "Plugin Communication Bus",
|
||||
"capability.message_bus.detail": "This sample plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces.",
|
||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||
"capability.widget_context.detail": "Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.",
|
||||
"widget.subtitle.preview": "Preview surface | placed: {0}",
|
||||
"widget.subtitle.placement": "Placement {0} | placed: {1}",
|
||||
"common.dev": "dev",
|
||||
"common.none": "(none)",
|
||||
"common.unknown": "(unknown)",
|
||||
"common.true": "true",
|
||||
"common.false": "false",
|
||||
"common.yes": "Yes",
|
||||
"common.no": "No"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"settings.page_title": "插件状态",
|
||||
"plugin.name": "阑山示例插件",
|
||||
"plugin.description": "用于验证 PluginSdk 加载、服务、通信与本地化能力的示例插件。",
|
||||
"widget.display_name": "示例插件状态时钟",
|
||||
"widget.category": "插件",
|
||||
"settings.header.title": "示例插件能力检查器",
|
||||
"settings.section.info": "插件信息",
|
||||
"settings.section.capabilities": "可访问能力",
|
||||
"settings.section.status": "实时运行状态",
|
||||
"settings.info.plugin_name": "插件名称",
|
||||
"settings.info.plugin_id": "插件 Id",
|
||||
"settings.info.version": "版本",
|
||||
"settings.info.author": "作者",
|
||||
"settings.info.description": "描述",
|
||||
"settings.info.plugin_directory": "插件目录",
|
||||
"settings.info.data_directory": "数据目录",
|
||||
"settings.info.host_application": "宿主应用",
|
||||
"settings.info.host_version": "宿主版本",
|
||||
"settings.info.sdk_api_version": "SDK API 版本",
|
||||
"settings.info.state_service_resolved": "状态服务已解析",
|
||||
"settings.info.clock_service_resolved": "时钟服务已解析",
|
||||
"settings.info.message_bus_resolved": "消息总线已解析",
|
||||
"settings.info.component_placed": "组件是否已放置",
|
||||
"settings.info.placed_count": "已放置数量",
|
||||
"settings.info.preview_count": "预览数量",
|
||||
"settings.info.placement_ids": "放置位置 Id",
|
||||
"settings.info.last_component_id": "最近组件 Id",
|
||||
"settings.info.last_cell_size": "最近单元尺寸",
|
||||
"settings.info.clock_service_time": "时钟服务时间",
|
||||
"settings.status.updated_at": "更新时间:{0}",
|
||||
"status.frontend.title": "前端状态",
|
||||
"status.component.title": "组件状态",
|
||||
"status.backend.title": "后端状态",
|
||||
"status.service.title": "时钟服务",
|
||||
"status.summary.pending": "等待中",
|
||||
"status.summary.attached": "已挂接",
|
||||
"status.summary.healthy": "正常",
|
||||
"status.summary.faulted": "异常",
|
||||
"status.summary.placed": "已放置",
|
||||
"status.summary.preview": "预览中",
|
||||
"status.frontend.detail.pending": "等待插件界面接入。",
|
||||
"status.frontend.detail.settings_connected": "设置页已接入插件服务与通信。",
|
||||
"status.frontend.detail.widget_connected": "组件界面已接入插件服务与通信。",
|
||||
"status.component.detail.pending": "当前还没有创建组件实例。",
|
||||
"status.component.detail.none": "当前没有活动中的组件实例。",
|
||||
"status.component.detail.preview": "当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
||||
"status.component.detail.placed": "已放置数量:{0};预览数量:{1};放置位置:{2}",
|
||||
"status.backend.detail.pending": "插件初始化进行中。",
|
||||
"status.backend.detail.log_written": "初始化日志已写入:{0}",
|
||||
"status.backend.detail.log_write_failed": "初始化日志写入失败:{0}",
|
||||
"status.service.detail.pending": "时钟服务尚未挂接。",
|
||||
"status.service.detail.attached": "时钟服务已挂接,正在等待第一次心跳。",
|
||||
"status.service.detail.running": "时钟服务运行中,当前服务时间:{0}",
|
||||
"status.service.detail.write_failed": "时钟状态写入失败:{0}",
|
||||
"capability.manifest.title": "IPluginContext.Manifest",
|
||||
"capability.manifest.detail": "可读取。当前插件 id:{0};版本:{1}。",
|
||||
"capability.directories.title": "IPluginContext.PluginDirectory / DataDirectory",
|
||||
"capability.directories.detail": "可读取。插件目录:{0};数据目录:{1}。",
|
||||
"capability.properties.title": "IPluginContext.Properties",
|
||||
"capability.properties.detail": "可读取。宿主当前暴露的属性:{0}。",
|
||||
"capability.get_service.title": "IPluginContext.GetService<T>()",
|
||||
"capability.get_service.detail": "可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
||||
"capability.register_service.title": "IPluginContext.RegisterService<TService>()",
|
||||
"capability.register_service.detail": "可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。",
|
||||
"capability.message_bus.title": "插件通信总线",
|
||||
"capability.message_bus.detail": "这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。",
|
||||
"capability.widget_context.title": "PluginDesktopComponentContext",
|
||||
"capability.widget_context.detail": "组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。",
|
||||
"widget.subtitle.preview": "预览界面 | 已放置:{0}",
|
||||
"widget.subtitle.placement": "位置 {0} | 已放置:{1}",
|
||||
"common.dev": "开发版",
|
||||
"common.none": "(无)",
|
||||
"common.unknown": "(未知)",
|
||||
"common.true": "是",
|
||||
"common.false": "否",
|
||||
"common.yes": "是",
|
||||
"common.no": "否"
|
||||
}
|
||||
16
LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md
Normal file
16
LanAirApp/samples/LanMountainDesktop.SamplePlugin/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
这是阑山桌面的**示例开发插件**。
|
||||
|
||||
它用于演示以下能力:
|
||||
- 插件入口与 `plugin.json` 清单
|
||||
- 插件服务注册
|
||||
- 插件设置页注册
|
||||
- 插件桌面组件注册
|
||||
- 插件内通信与状态更新
|
||||
- `.laapp` 打包与安装流程
|
||||
- 插件多语言资源组织方式
|
||||
|
||||
如果你要开发自己的插件,建议以这个目录为模板开始。
|
||||
|
||||
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
|
||||
@@ -11,10 +11,11 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
public override void Initialize(IPluginContext context)
|
||||
{
|
||||
Directory.CreateDirectory(context.DataDirectory);
|
||||
var localizer = PluginLocalizer.Create(context);
|
||||
|
||||
var hostName = GetHostProperty(context, "HostApplicationName", "UnknownHost");
|
||||
var hostVersion = GetHostProperty(context, "HostVersion", "UnknownVersion");
|
||||
var sdkApiVersion = GetHostProperty(context, "PluginSdkApiVersion", "UnknownApiVersion");
|
||||
var hostName = GetHostProperty(context, PluginHostPropertyKeys.HostApplicationName, "UnknownHost");
|
||||
var hostVersion = GetHostProperty(context, PluginHostPropertyKeys.HostVersion, "UnknownVersion");
|
||||
var sdkApiVersion = GetHostProperty(context, PluginHostPropertyKeys.PluginSdkApiVersion, "UnknownApiVersion");
|
||||
var messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("Plugin message bus is not available.");
|
||||
|
||||
@@ -25,10 +26,11 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
hostName,
|
||||
hostVersion,
|
||||
sdkApiVersion,
|
||||
messageBus);
|
||||
messageBus,
|
||||
localizer);
|
||||
context.RegisterService(_stateService);
|
||||
|
||||
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus);
|
||||
_clockService = new SamplePluginClockService(context.DataDirectory, _stateService, messageBus, localizer);
|
||||
context.RegisterService(_clockService);
|
||||
_stateService.AttachClockService(_clockService);
|
||||
|
||||
@@ -39,11 +41,17 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
try
|
||||
{
|
||||
File.AppendAllText(logPath, initMessage + Environment.NewLine);
|
||||
_stateService.MarkBackendReady($"Initialization log written to {logPath}.");
|
||||
_stateService.MarkBackendReady(localizer.Format(
|
||||
"status.backend.detail.log_written",
|
||||
"初始化日志已写入:{0}",
|
||||
logPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkBackendFaulted($"Initialization log write failed: {ex.Message}");
|
||||
_stateService.MarkBackendFaulted(localizer.Format(
|
||||
"status.backend.detail.log_write_failed",
|
||||
"初始化日志写入失败:{0}",
|
||||
ex.Message));
|
||||
throw;
|
||||
}
|
||||
|
||||
@@ -51,15 +59,15 @@ public sealed class SamplePlugin : PluginBase, IDisposable
|
||||
|
||||
context.RegisterSettingsPage(new PluginSettingsPageRegistration(
|
||||
"status",
|
||||
"Plugin Status",
|
||||
localizer.GetString("settings.page_title", "插件状态"),
|
||||
() => new SamplePluginSettingsView(context)));
|
||||
|
||||
context.RegisterDesktopComponent(new PluginDesktopComponentRegistration(
|
||||
"LanMountainDesktop.SamplePlugin.StatusClock",
|
||||
"Sample Plugin Status Clock",
|
||||
localizer.GetString("widget.display_name", "示例插件状态时钟"),
|
||||
widgetContext => new SamplePluginStatusClockWidget(widgetContext),
|
||||
iconKey: "PuzzlePiece",
|
||||
category: "Plugins",
|
||||
category: localizer.GetString("widget.category", "插件"),
|
||||
minWidthCells: 4,
|
||||
minHeightCells: 4,
|
||||
allowDesktopPlacement: true,
|
||||
@@ -66,6 +66,7 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
private readonly string _hostApplicationName;
|
||||
private readonly string _hostVersion;
|
||||
private readonly string _sdkApiVersion;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
|
||||
private SamplePluginStatusEntry _frontend;
|
||||
private SamplePluginStatusEntry _component;
|
||||
@@ -82,7 +83,8 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
string hostApplicationName,
|
||||
string hostVersion,
|
||||
string sdkApiVersion,
|
||||
IPluginMessageBus messageBus)
|
||||
IPluginMessageBus messageBus,
|
||||
PluginLocalizer localizer)
|
||||
{
|
||||
_manifest = manifest;
|
||||
_pluginDirectory = pluginDirectory;
|
||||
@@ -91,34 +93,35 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
_hostVersion = hostVersion;
|
||||
_sdkApiVersion = sdkApiVersion;
|
||||
_messageBus = messageBus;
|
||||
_localizer = localizer;
|
||||
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
"Frontend",
|
||||
T("status.frontend.title", "前端状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Waiting for a plugin UI surface to connect.");
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.frontend.detail.pending", "等待插件界面接入。"));
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"No component instance has been created yet.");
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.component.detail.pending", "当前还没有创建组件实例。"));
|
||||
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Plugin initialization is in progress.");
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.backend.detail.pending", "插件初始化进行中。"));
|
||||
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"Clock service is not attached yet.");
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.service.detail.pending", "时钟服务尚未挂接。"));
|
||||
}
|
||||
|
||||
public void AttachClockService(SamplePluginClockService clockService)
|
||||
@@ -130,10 +133,10 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
_serviceClockTime = clockService.CurrentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Attached",
|
||||
"Clock service was attached and is waiting for the first tick.");
|
||||
T("status.summary.attached", "已挂接"),
|
||||
T("status.service.detail.attached", "时钟服务已挂接,正在等待第一次心跳。"));
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service attached");
|
||||
@@ -145,9 +148,9 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_frontend = CreateEntry(
|
||||
"frontend",
|
||||
"Frontend",
|
||||
T("status.frontend.title", "前端状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
T("status.summary.healthy", "正常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
@@ -160,9 +163,9 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
T("status.summary.healthy", "正常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
@@ -175,9 +178,9 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_backend = CreateEntry(
|
||||
"backend",
|
||||
"Backend",
|
||||
T("status.backend.title", "后端状态"),
|
||||
SamplePluginHealthState.Faulted,
|
||||
"Faulted",
|
||||
T("status.summary.faulted", "异常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
@@ -191,10 +194,13 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
_serviceClockTime = currentTime;
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Healthy",
|
||||
$"Clock service is running. Current service time: {currentTime.LocalDateTime:HH:mm:ss}");
|
||||
T("status.summary.healthy", "正常"),
|
||||
Tf(
|
||||
"status.service.detail.running",
|
||||
"时钟服务运行中,当前服务时间:{0}",
|
||||
currentTime.LocalDateTime.ToString("HH:mm:ss")));
|
||||
}
|
||||
|
||||
PublishStateChanged("Clock service tick");
|
||||
@@ -206,9 +212,9 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_service = CreateEntry(
|
||||
"service",
|
||||
"Clock Service",
|
||||
T("status.service.title", "时钟服务"),
|
||||
SamplePluginHealthState.Faulted,
|
||||
"Faulted",
|
||||
T("status.summary.faulted", "异常"),
|
||||
detail);
|
||||
}
|
||||
|
||||
@@ -291,32 +297,54 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var propertyNames = context.Properties.Count == 0
|
||||
? "(none)"
|
||||
? T("common.none", "(无)")
|
||||
: string.Join(", ", context.Properties.Keys.OrderBy(key => key, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
return
|
||||
[
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.Manifest",
|
||||
$"Readable. Current plugin id: {context.Manifest.Id}; version: {context.Manifest.Version ?? "dev"}."),
|
||||
T("capability.manifest.title", "IPluginContext.Manifest"),
|
||||
Tf(
|
||||
"capability.manifest.detail",
|
||||
"可读取。当前插件 id:{0};版本:{1}。",
|
||||
context.Manifest.Id,
|
||||
context.Manifest.Version ?? T("common.dev", "开发版"))),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.PluginDirectory / DataDirectory",
|
||||
$"Readable. Plugin directory: {context.PluginDirectory}; data directory: {context.DataDirectory}."),
|
||||
T("capability.directories.title", "IPluginContext.PluginDirectory / DataDirectory"),
|
||||
Tf(
|
||||
"capability.directories.detail",
|
||||
"可读取。插件目录:{0};数据目录:{1}。",
|
||||
context.PluginDirectory,
|
||||
context.DataDirectory)),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.Properties",
|
||||
$"Readable. Host properties currently exposed: {propertyNames}."),
|
||||
T("capability.properties.title", "IPluginContext.Properties"),
|
||||
Tf(
|
||||
"capability.properties.detail",
|
||||
"可读取。宿主当前暴露的属性:{0}。",
|
||||
propertyNames)),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.GetService<T>()",
|
||||
$"Callable. State service resolved: {hasStateService}; clock service resolved: {hasClockService}; message bus resolved: {hasMessageBus}."),
|
||||
T("capability.get_service.title", "IPluginContext.GetService<T>()"),
|
||||
Tf(
|
||||
"capability.get_service.detail",
|
||||
"可调用。状态服务已解析:{0};时钟服务已解析:{1};消息总线已解析:{2}。",
|
||||
FormatBoolean(hasStateService),
|
||||
FormatBoolean(hasClockService),
|
||||
FormatBoolean(hasMessageBus))),
|
||||
new SamplePluginCapabilityItem(
|
||||
"IPluginContext.RegisterService<TService>()",
|
||||
"Callable during plugin initialization. This plugin registers SamplePluginRuntimeStateService and SamplePluginClockService into the plugin service container."),
|
||||
T("capability.register_service.title", "IPluginContext.RegisterService<TService>()"),
|
||||
T(
|
||||
"capability.register_service.detail",
|
||||
"可在插件初始化阶段调用。这个示例插件会把 SamplePluginRuntimeStateService 和 SamplePluginClockService 注册进插件服务容器。")),
|
||||
new SamplePluginCapabilityItem(
|
||||
"Plugin communication bus",
|
||||
"This plugin uses IPluginMessageBus to push clock ticks and state change notifications into plugin UI surfaces."),
|
||||
T("capability.message_bus.title", "插件通信总线"),
|
||||
T(
|
||||
"capability.message_bus.detail",
|
||||
"这个示例插件通过 IPluginMessageBus 向插件 UI 推送时钟心跳和状态变化通知。")),
|
||||
new SamplePluginCapabilityItem(
|
||||
"PluginDesktopComponentContext",
|
||||
"Widgets can read ComponentId, PlacementId, CellSize, and call GetService<T>() against the same plugin service container.")
|
||||
T("capability.widget_context.title", "PluginDesktopComponentContext"),
|
||||
T(
|
||||
"capability.widget_context.detail",
|
||||
"组件可以读取 ComponentId、PlacementId、CellSize,并能在同一个插件服务容器上调用 GetService<T>()。"))
|
||||
];
|
||||
}
|
||||
|
||||
@@ -335,10 +363,15 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Placed",
|
||||
$"Placed count: {placementIds.Length}; preview count: {previewCount}; placements: {string.Join(", ", placementIds)}");
|
||||
T("status.summary.placed", "已放置"),
|
||||
Tf(
|
||||
"status.component.detail.placed",
|
||||
"已放置数量:{0};预览数量:{1};放置位置:{2}",
|
||||
placementIds.Length,
|
||||
previewCount,
|
||||
string.Join(", ", placementIds)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,19 +379,22 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
{
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Healthy,
|
||||
"Preview",
|
||||
$"Preview instances: {previewCount}; no placed desktop instance is active yet.");
|
||||
T("status.summary.preview", "预览中"),
|
||||
Tf(
|
||||
"status.component.detail.preview",
|
||||
"当前预览实例数量:{0};尚未有已放置的桌面实例。",
|
||||
previewCount));
|
||||
return;
|
||||
}
|
||||
|
||||
_component = CreateEntry(
|
||||
"component",
|
||||
"Component",
|
||||
T("status.component.title", "组件状态"),
|
||||
SamplePluginHealthState.Pending,
|
||||
"Pending",
|
||||
"No component instance is active.");
|
||||
T("status.summary.pending", "等待中"),
|
||||
T("status.component.detail.none", "当前没有活动中的组件实例。"));
|
||||
}
|
||||
|
||||
private void PublishStateChanged(string reason)
|
||||
@@ -381,6 +417,23 @@ internal sealed class SamplePluginRuntimeStateService
|
||||
detail,
|
||||
DateTimeOffset.Now);
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
|
||||
private string FormatBoolean(bool value)
|
||||
{
|
||||
return value
|
||||
? T("common.true", "是")
|
||||
: T("common.false", "否");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class SamplePluginClockService : IDisposable
|
||||
@@ -389,6 +442,7 @@ internal sealed class SamplePluginClockService : IDisposable
|
||||
private readonly string _clockStateFilePath;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly Timer _timer;
|
||||
private DateTimeOffset _currentTime = DateTimeOffset.Now;
|
||||
private int _disposed;
|
||||
@@ -396,11 +450,13 @@ internal sealed class SamplePluginClockService : IDisposable
|
||||
public SamplePluginClockService(
|
||||
string dataDirectory,
|
||||
SamplePluginRuntimeStateService stateService,
|
||||
IPluginMessageBus messageBus)
|
||||
IPluginMessageBus messageBus,
|
||||
PluginLocalizer localizer)
|
||||
{
|
||||
_clockStateFilePath = Path.Combine(dataDirectory, "clock-service.txt");
|
||||
_stateService = stateService;
|
||||
_messageBus = messageBus;
|
||||
_localizer = localizer;
|
||||
_timer = new Timer(OnTimerTick);
|
||||
}
|
||||
|
||||
@@ -459,7 +515,10 @@ internal sealed class SamplePluginClockService : IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stateService.MarkClockServiceFaulted($"Clock state write failed: {ex.Message}");
|
||||
_stateService.MarkClockServiceFaulted(_localizer.Format(
|
||||
"status.service.detail.write_failed",
|
||||
"时钟状态写入失败:{0}",
|
||||
ex.Message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace LanMountainDesktop.SamplePlugin;
|
||||
internal sealed class SamplePluginSettingsView : UserControl
|
||||
{
|
||||
private readonly IPluginContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
@@ -21,6 +22,7 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
public SamplePluginSettingsView(IPluginContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
@@ -28,7 +30,9 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
_messageBus = context.GetService<IPluginMessageBus>()
|
||||
?? throw new InvalidOperationException("IPluginMessageBus is not available.");
|
||||
|
||||
_stateService.MarkFrontendReady("Settings page is connected to plugin services and communication.");
|
||||
_stateService.MarkFrontendReady(T(
|
||||
"status.frontend.detail.settings_connected",
|
||||
"设置页已接入插件服务与通信。"));
|
||||
|
||||
AttachedToVisualTree += OnAttachedToVisualTree;
|
||||
DetachedFromVisualTree += OnDetachedFromVisualTree;
|
||||
@@ -56,14 +60,14 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
Text = "Sample Plugin Capability Inspector",
|
||||
Text = T("settings.header.title", "示例插件能力检查器"),
|
||||
FontSize = 22,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
Foreground = Brushes.White
|
||||
},
|
||||
CreateSection("Plugin Info", _pluginInfoPanel),
|
||||
CreateSection("Accessible Capabilities", _capabilityPanel),
|
||||
CreateSection("Live Runtime Status", _statusPanel)
|
||||
CreateSection(T("settings.section.info", "插件信息"), _pluginInfoPanel),
|
||||
CreateSection(T("settings.section.capabilities", "可访问能力"), _capabilityPanel),
|
||||
CreateSection(T("settings.section.status", "实时运行状态"), _statusPanel)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -112,31 +116,45 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
private void RefreshPluginInfo(SamplePluginRuntimeSnapshot snapshot)
|
||||
{
|
||||
_pluginInfoPanel.Children.Clear();
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Name", snapshot.Manifest.Name));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Id", snapshot.Manifest.Id));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Version", snapshot.Manifest.Version ?? "dev"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Author", snapshot.Manifest.Author ?? "(none)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Description", snapshot.Manifest.Description ?? "(none)"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Plugin Directory", snapshot.PluginDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Data Directory", snapshot.DataDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Application", snapshot.HostApplicationName));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Host Version", snapshot.HostVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("SDK API Version", snapshot.SdkApiVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("State Service Resolved", (_context.GetService<SamplePluginRuntimeStateService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Clock Service Resolved", (_context.GetService<SamplePluginClockService>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Message Bus Resolved", (_context.GetService<IPluginMessageBus>() is not null).ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Component Placed", snapshot.HasPlacedComponent ? "Yes" : "No"));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Placed Count", snapshot.PlacedCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Preview Count", snapshot.PreviewCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Placement Ids",
|
||||
snapshot.PlacementIds.Count == 0 ? "(none)" : string.Join(", ", snapshot.PlacementIds)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine("Last Component Id", snapshot.LastComponentId ?? "(none)"));
|
||||
T("settings.info.plugin_name", "插件名称"),
|
||||
T("plugin.name", snapshot.Manifest.Name)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_id", "插件 Id"), snapshot.Manifest.Id));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.version", "版本"), snapshot.Manifest.Version ?? T("common.dev", "开发版")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.author", "作者"), snapshot.Manifest.Author ?? T("common.none", "(无)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Last Cell Size",
|
||||
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : "(unknown)"));
|
||||
T("settings.info.description", "描述"),
|
||||
T("plugin.description", snapshot.Manifest.Description ?? T("common.none", "(无)"))));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.plugin_directory", "插件目录"), snapshot.PluginDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.data_directory", "数据目录"), snapshot.DataDirectory));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_application", "宿主应用"), snapshot.HostApplicationName));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.host_version", "宿主版本"), snapshot.HostVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.sdk_api_version", "SDK API 版本"), snapshot.SdkApiVersion));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
"Clock Service Time",
|
||||
T("settings.info.state_service_resolved", "状态服务已解析"),
|
||||
FormatBoolean(_context.GetService<SamplePluginRuntimeStateService>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.clock_service_resolved", "时钟服务已解析"),
|
||||
FormatBoolean(_context.GetService<SamplePluginClockService>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.message_bus_resolved", "消息总线已解析"),
|
||||
FormatBoolean(_context.GetService<IPluginMessageBus>() is not null)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.component_placed", "组件是否已放置"),
|
||||
snapshot.HasPlacedComponent ? T("common.yes", "是") : T("common.no", "否")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.placed_count", "已放置数量"), snapshot.PlacedCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(T("settings.info.preview_count", "预览数量"), snapshot.PreviewCount.ToString()));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.placement_ids", "放置位置 Id"),
|
||||
snapshot.PlacementIds.Count == 0 ? T("common.none", "(无)") : string.Join(", ", snapshot.PlacementIds)));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.last_component_id", "最近组件 Id"),
|
||||
snapshot.LastComponentId ?? T("common.none", "(无)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.last_cell_size", "最近单元尺寸"),
|
||||
snapshot.LastCellSize > 0 ? $"{snapshot.LastCellSize:F0}px" : T("common.unknown", "(未知)")));
|
||||
_pluginInfoPanel.Children.Add(CreateInfoLine(
|
||||
T("settings.info.clock_service_time", "时钟服务时间"),
|
||||
_clockService.CurrentTime.LocalDateTime.ToString("HH:mm:ss")));
|
||||
}
|
||||
|
||||
@@ -183,7 +201,7 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
},
|
||||
new TextBlock
|
||||
{
|
||||
Text = $"Updated: {entry.UpdatedAt.LocalDateTime:HH:mm:ss}",
|
||||
Text = Tf("settings.status.updated_at", "更新时间:{0}", entry.UpdatedAt.LocalDateTime.ToString("HH:mm:ss")),
|
||||
Foreground = new SolidColorBrush(Color.Parse("#FF93C5FD"))
|
||||
}
|
||||
}
|
||||
@@ -336,4 +354,21 @@ internal sealed class SamplePluginSettingsView : UserControl
|
||||
Color.Parse("#FBBF24"))
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
|
||||
private string FormatBoolean(bool value)
|
||||
{
|
||||
return value
|
||||
? T("common.true", "是")
|
||||
: T("common.false", "否");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace LanMountainDesktop.SamplePlugin;
|
||||
internal sealed class SamplePluginStatusClockWidget : Border
|
||||
{
|
||||
private readonly PluginDesktopComponentContext _context;
|
||||
private readonly PluginLocalizer _localizer;
|
||||
private readonly SamplePluginRuntimeStateService _stateService;
|
||||
private readonly SamplePluginClockService _clockService;
|
||||
private readonly IPluginMessageBus _messageBus;
|
||||
@@ -23,6 +24,7 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
||||
public SamplePluginStatusClockWidget(PluginDesktopComponentContext context)
|
||||
{
|
||||
_context = context;
|
||||
_localizer = PluginLocalizer.Create(context);
|
||||
_stateService = context.GetService<SamplePluginRuntimeStateService>()
|
||||
?? throw new InvalidOperationException("SamplePluginRuntimeStateService is not available.");
|
||||
_clockService = context.GetService<SamplePluginClockService>()
|
||||
@@ -111,7 +113,9 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
||||
_context.CellSize);
|
||||
}
|
||||
|
||||
_stateService.MarkFrontendReady("Widget surface is connected to plugin services and communication.");
|
||||
_stateService.MarkFrontendReady(T(
|
||||
"status.frontend.detail.widget_connected",
|
||||
"组件界面已接入插件服务与通信。"));
|
||||
SubscribeToPluginBus();
|
||||
|
||||
RefreshClock(_clockService.CurrentTime);
|
||||
@@ -170,8 +174,8 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
||||
{
|
||||
var snapshot = _stateService.GetSnapshot();
|
||||
_subtitleTextBlock.Text = string.IsNullOrWhiteSpace(_context.PlacementId)
|
||||
? $"Preview surface | placed: {snapshot.PlacedCount}"
|
||||
: $"Placement {_context.PlacementId} | placed: {snapshot.PlacedCount}";
|
||||
? Tf("widget.subtitle.preview", "预览界面 | 已放置:{0}", snapshot.PlacedCount)
|
||||
: Tf("widget.subtitle.placement", "位置 {0} | 已放置:{1}", _context.PlacementId!, snapshot.PlacedCount);
|
||||
}
|
||||
|
||||
private void RefreshStatusPanel()
|
||||
@@ -281,4 +285,14 @@ internal sealed class SamplePluginStatusClockWidget : Border
|
||||
Color.Parse("#FDBA74"))
|
||||
};
|
||||
}
|
||||
|
||||
private string T(string key, string fallback)
|
||||
{
|
||||
return _localizer.GetString(key, fallback);
|
||||
}
|
||||
|
||||
private string Tf(string key, string fallback, params object[] args)
|
||||
{
|
||||
return _localizer.Format(key, fallback, args);
|
||||
}
|
||||
}
|
||||
11
LanAirApp/samples/README.md
Normal file
11
LanAirApp/samples/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 示例插件
|
||||
|
||||
本目录用于存放阑山桌面的示例开发插件。
|
||||
|
||||
当前示例:
|
||||
- `LanMountainDesktop.SamplePlugin`
|
||||
|
||||
说明:
|
||||
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
|
||||
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
|
||||
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。
|
||||
11
LanAirApp/standards/README.md
Normal file
11
LanAirApp/standards/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 插件标准文件
|
||||
|
||||
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
|
||||
|
||||
当前标准:
|
||||
- 安装包扩展名:`.laapp`
|
||||
- 插件清单文件名:`plugin.json`
|
||||
- 多语言资源目录:`Localization/`
|
||||
- 建议内置语言文件:`zh-CN.json`、`en-US.json`
|
||||
|
||||
创建新插件时,建议优先参考本目录中的模板文件。
|
||||
9
LanAirApp/standards/plugin.template.json
Normal file
9
LanAirApp/standards/plugin.template.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "LanMountainDesktop.YourPlugin",
|
||||
"name": "Your Plugin",
|
||||
"description": "Describe what your plugin adds to LanMountainDesktop.",
|
||||
"author": "Your Name",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"entranceAssembly": "LanMountainDesktop.YourPlugin.dll"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
136
LanAirApp/tools/LanMountainDesktop.PluginPackager/Program.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using System.IO.Compression;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
return await RunAsync(args);
|
||||
|
||||
static async Task<int> RunAsync(string[] args)
|
||||
{
|
||||
if (args.Length == 0 || args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
string? inputDirectory = null;
|
||||
string? outputPath = null;
|
||||
var overwrite = false;
|
||||
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
switch (args[i])
|
||||
{
|
||||
case "--input":
|
||||
inputDirectory = ReadValue(args, ref i, "--input");
|
||||
break;
|
||||
case "--output":
|
||||
outputPath = ReadValue(args, ref i, "--output");
|
||||
break;
|
||||
case "--overwrite":
|
||||
overwrite = true;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown argument '{args[i]}'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(inputDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Missing required argument '--input'.");
|
||||
}
|
||||
|
||||
var fullInputDirectory = Path.GetFullPath(inputDirectory);
|
||||
if (!Directory.Exists(fullInputDirectory))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Plugin build directory '{fullInputDirectory}' was not found.");
|
||||
}
|
||||
|
||||
var manifestPath = Path.Combine(fullInputDirectory, PluginSdkInfo.ManifestFileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"Plugin build directory '{fullInputDirectory}' does not contain '{PluginSdkInfo.ManifestFileName}'.",
|
||||
manifestPath);
|
||||
}
|
||||
|
||||
var manifest = PluginManifest.Load(manifestPath);
|
||||
var entranceAssemblyPath = manifest.ResolveEntranceAssemblyPath(manifestPath);
|
||||
if (!File.Exists(entranceAssemblyPath))
|
||||
{
|
||||
throw new FileNotFoundException(
|
||||
$"The entrance assembly declared by '{PluginSdkInfo.ManifestFileName}' was not found.",
|
||||
entranceAssemblyPath);
|
||||
}
|
||||
|
||||
outputPath ??= Path.Combine(
|
||||
Path.GetDirectoryName(fullInputDirectory) ?? fullInputDirectory,
|
||||
BuildPackageFileName(manifest.Id));
|
||||
|
||||
var fullOutputPath = Path.GetFullPath(outputPath);
|
||||
var inputDirectoryWithSeparator = EnsureTrailingSeparator(fullInputDirectory);
|
||||
if (fullOutputPath.StartsWith(inputDirectoryWithSeparator, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException("The output .laapp path cannot be placed inside the source directory.");
|
||||
}
|
||||
|
||||
var destinationDirectory = Path.GetDirectoryName(fullOutputPath);
|
||||
if (string.IsNullOrWhiteSpace(destinationDirectory))
|
||||
{
|
||||
throw new InvalidOperationException("Failed to determine the output directory for the .laapp package.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(destinationDirectory);
|
||||
if (File.Exists(fullOutputPath))
|
||||
{
|
||||
if (!overwrite)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The output package '{fullOutputPath}' already exists. Pass '--overwrite' to replace it.");
|
||||
}
|
||||
|
||||
File.Delete(fullOutputPath);
|
||||
}
|
||||
|
||||
await Task.Run(() => ZipFile.CreateFromDirectory(
|
||||
fullInputDirectory,
|
||||
fullOutputPath,
|
||||
CompressionLevel.Optimal,
|
||||
includeBaseDirectory: false));
|
||||
|
||||
Console.WriteLine($"Packaged '{manifest.Name}' to '{fullOutputPath}'.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static string ReadValue(IReadOnlyList<string> args, ref int index, string optionName)
|
||||
{
|
||||
var nextIndex = index + 1;
|
||||
if (nextIndex >= args.Count)
|
||||
{
|
||||
throw new InvalidOperationException($"Missing value for '{optionName}'.");
|
||||
}
|
||||
|
||||
index = nextIndex;
|
||||
return args[nextIndex];
|
||||
}
|
||||
|
||||
static string BuildPackageFileName(string pluginId)
|
||||
{
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safeName = new string(pluginId.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
|
||||
return safeName + PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
static string EnsureTrailingSeparator(string path)
|
||||
{
|
||||
return path.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)
|
||||
? path
|
||||
: path + Path.DirectorySeparatorChar;
|
||||
}
|
||||
|
||||
static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("LanMountainDesktop.PluginPackager");
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" --input <plugin build directory> Required");
|
||||
Console.WriteLine(" --output <path to .laapp> Optional");
|
||||
Console.WriteLine(" --overwrite Optional");
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPackageManager
|
||||
{
|
||||
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||
|
||||
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record InstalledPluginInfo(
|
||||
PluginManifest Manifest,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
bool IsPackage,
|
||||
string? ErrorMessage);
|
||||
@@ -8,6 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="_build_verify_*\**\*.cs" />
|
||||
<PackageReference Include="Avalonia" Version="11.3.12" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
9
LanMountainDesktop.PluginSdk/PluginHostPropertyKeys.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public static class PluginHostPropertyKeys
|
||||
{
|
||||
public const string HostApplicationName = "HostApplicationName";
|
||||
public const string HostVersion = "HostVersion";
|
||||
public const string PluginSdkApiVersion = "PluginSdkApiVersion";
|
||||
public const string HostLanguageCode = "HostLanguageCode";
|
||||
}
|
||||
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
114
LanMountainDesktop.PluginSdk/PluginLocalizer.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed class PluginLocalizer
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, string>> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public PluginLocalizer(string pluginDirectory, string? languageCode)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginDirectory);
|
||||
|
||||
PluginDirectory = pluginDirectory;
|
||||
LanguageCode = NormalizeLanguageCode(languageCode);
|
||||
}
|
||||
|
||||
public string PluginDirectory { get; }
|
||||
|
||||
public string LanguageCode { get; }
|
||||
|
||||
public static PluginLocalizer Create(IPluginContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public static PluginLocalizer Create(PluginDesktopComponentContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
return new PluginLocalizer(context.PluginDirectory, ResolveLanguageCode(context.Properties));
|
||||
}
|
||||
|
||||
public string GetString(string key, string fallback)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(key);
|
||||
|
||||
var primaryTable = LoadLanguageTable(LanguageCode);
|
||||
if (primaryTable.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!string.Equals(LanguageCode, "en-US", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var fallbackTable = LoadLanguageTable("en-US");
|
||||
if (fallbackTable.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
public string Format(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, GetString(key, fallback), args);
|
||||
}
|
||||
|
||||
public static string NormalizeLanguageCode(string? languageCode)
|
||||
{
|
||||
return string.Equals(languageCode, "en-US", StringComparison.OrdinalIgnoreCase)
|
||||
? "en-US"
|
||||
: "zh-CN";
|
||||
}
|
||||
|
||||
public static string ResolveLanguageCode(IReadOnlyDictionary<string, object?> properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
return properties.TryGetValue(PluginHostPropertyKeys.HostLanguageCode, out var rawValue) &&
|
||||
rawValue is string languageCode
|
||||
? NormalizeLanguageCode(languageCode)
|
||||
: NormalizeLanguageCode(CultureInfo.CurrentUICulture.Name);
|
||||
}
|
||||
|
||||
private Dictionary<string, string> LoadLanguageTable(string languageCode)
|
||||
{
|
||||
if (_cache.TryGetValue(languageCode, out var table))
|
||||
{
|
||||
return table;
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
var filePath = Path.Combine(PluginDirectory, "Localization", $"{languageCode}.json");
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = File.ReadAllText(filePath).TrimStart('\uFEFF');
|
||||
var data = JsonSerializer.Deserialize<Dictionary<string, string>>(json, JsonOptions);
|
||||
if (data is not null)
|
||||
{
|
||||
result = new Dictionary<string, string>(data, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep empty localization table for plugin resilience.
|
||||
}
|
||||
|
||||
_cache[languageCode] = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPackageInstallResult(
|
||||
PluginManifest Manifest,
|
||||
bool ReplacedExisting,
|
||||
bool RestartRequired);
|
||||
@@ -5,7 +5,9 @@ VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop", "LanMountainDesktop\LanMountainDesktop.csproj", "{00000001-0000-0000-0000-000000000001}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.SamplePlugin", "LanAirApp\samples\LanMountainDesktop.SamplePlugin\LanMountainDesktop.SamplePlugin.csproj", "{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginPackager", "LanAirApp\tools\LanMountainDesktop.PluginPackager\LanMountainDesktop.PluginPackager.csproj", "{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanMountainDesktop.PluginSdk", "LanMountainDesktop.PluginSdk\LanMountainDesktop.PluginSdk.csproj", "{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}"
|
||||
EndProject
|
||||
@@ -23,6 +25,10 @@ Global
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BDCD028D-DB6E-4BD5-994A-65889DBDEE0C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AAE8578B-1F9D-4D4F-8B2E-0A98C55B0C31}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{30A0F689-AACC-48C8-8BFE-BC7BFBA6CC55}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
||||
@@ -96,45 +96,7 @@ public partial class App : Application
|
||||
|
||||
private void OnTrayRestartClick(object? sender, EventArgs e)
|
||||
{
|
||||
if (ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (TryStartCurrentProcess())
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryStartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var args = Environment.GetCommandLineArgs();
|
||||
if (args.Length == 0 || string.IsNullOrWhiteSpace(args[0]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = args[0],
|
||||
UseShellExecute = false
|
||||
};
|
||||
|
||||
for (var i = 1; i < args.Length; i++)
|
||||
{
|
||||
startInfo.ArgumentList.Add(args[i]);
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
AppRestartService.TryRestartApplication();
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
"tooltip.back_to_windows": "Back to Windows",
|
||||
"tooltip.open_settings": "Settings",
|
||||
"settings.title": "Settings",
|
||||
"settings.shell.title": "Application Settings",
|
||||
"settings.shell.subtitle": "LanMountainDesktop standalone preferences",
|
||||
"settings.shell.sidebar_hint": "Choose a category to adjust application behavior, desktop layout, and appearance.",
|
||||
"settings.shell.footer_hint": "Tray-opened settings are managed in this standalone window.",
|
||||
"settings.back_to_desktop": "Back to Desktop",
|
||||
"settings.nav_header": "Settings",
|
||||
"settings.nav.group_desktop": "Desktop",
|
||||
"settings.nav.group_system": "System",
|
||||
"settings.nav.group_extensions": "Extensions",
|
||||
"settings.nav.wallpaper": "Wallpaper",
|
||||
"settings.nav.grid": "Grid",
|
||||
"settings.nav.color": "Color",
|
||||
@@ -109,6 +116,8 @@
|
||||
"settings.weather.preview_header": "Connection Test",
|
||||
"settings.weather.preview_desc": "Send one test request to verify current settings.",
|
||||
"settings.weather.preview_button": "Test Fetch",
|
||||
"settings.weather.preview_section": "Weather Preview",
|
||||
"settings.weather.settings_section": "Settings",
|
||||
"settings.weather.preview_panel_header": "Weather Preview",
|
||||
"settings.weather.preview_panel_desc": "Refresh and verify current weather service status.",
|
||||
"settings.weather.refresh_button": "Refresh",
|
||||
@@ -129,6 +138,15 @@
|
||||
"settings.weather.status_city_empty": "No city location is configured.",
|
||||
"settings.weather.status_city_format": "Mode: {0} | {1} | Key: {2}",
|
||||
"settings.weather.status_coordinates_format": "Mode: {0} | Lat {1:F4}, Lon {2:F4} | Key: {3}",
|
||||
"settings.weather.city_selection_label": "City Selection",
|
||||
"settings.weather.coordinates_selection_label": "Coordinate Location",
|
||||
"settings.weather.location_city_summary_desc": "Select the current city used for weather queries.",
|
||||
"settings.weather.location_coordinates_summary_desc": "Set latitude/longitude and optional location name used for weather queries.",
|
||||
"settings.weather.location_not_selected": "No location selected",
|
||||
"settings.weather.alert_list_label": "Exclude List",
|
||||
"settings.weather.alert_list_desc": "One exclusion rule per line.",
|
||||
"settings.weather.no_tls_toggle": "Allow non-TLS request fallback",
|
||||
"settings.weather.footer_hint": "Desktop weather widgets will reuse the location and alert exclusion settings configured here.",
|
||||
"settings.weather.location_header": "Weather Location",
|
||||
"settings.weather.location_desc": "Set the location used by weather widgets.",
|
||||
"settings.weather.location_placeholder": "e.g. Beijing",
|
||||
@@ -244,6 +262,18 @@
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "Unknown",
|
||||
"settings.about.render_mode.current_label": "Current actual backend",
|
||||
"settings.about.render_mode.current_format": "Current backend: {0}",
|
||||
"settings.about.render_mode.impl_format": "Runtime implementation: {0}",
|
||||
"settings.about.render_mode.impl_unavailable": "Runtime implementation details are unavailable.",
|
||||
"settings.restart_dialog.title": "Restart required",
|
||||
"settings.restart_dialog.render_mode_message": "Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
||||
"settings.restart_dialog.restart": "Restart now",
|
||||
"settings.restart_dialog.cancel": "Cancel",
|
||||
"settings.restart_dock.title": "Restart required",
|
||||
"settings.restart_dock.description": "Some changes will take effect after restarting the app.",
|
||||
"settings.restart_dock.button": "Restart app",
|
||||
"settings.footer": "LanMountainDesktop Settings",
|
||||
"filepicker.title": "Select wallpaper",
|
||||
"filepicker.image_files": "Image files",
|
||||
@@ -295,10 +325,57 @@
|
||||
"settings.plugins.toggle_result_format": "Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
"settings.plugins.toggle_state_enabled": "enabled",
|
||||
"settings.plugins.toggle_state_disabled": "disabled",
|
||||
"settings.plugins.install_button": "Open .laapp package",
|
||||
"settings.plugins.install_unavailable": "Plugin runtime is unavailable, so .laapp packages cannot be installed right now.",
|
||||
"settings.plugins.install_hint_format": "Open a .laapp package to install it into: {0}",
|
||||
"settings.plugins.install_picker_title": "Select plugin package",
|
||||
"settings.plugins.install_file_type": ".laapp plugin package",
|
||||
"settings.plugins.install_picker_unavailable": "Storage provider is unavailable.",
|
||||
"settings.plugins.install_copy_failed": "Failed to copy the selected .laapp package.",
|
||||
"settings.plugins.install_success_format": "Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||
"settings.plugins.install_failed_format": "Failed to install plugin package: {0}",
|
||||
"settings.plugins.source_package": ".laapp package",
|
||||
"settings.plugins.source_manifest": "Loose manifest",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "Settings pages: {0} | Widgets: {1}",
|
||||
"settings.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.",
|
||||
"market.toolbar.search_placeholder": "Search plugins",
|
||||
"market.toolbar.refresh": "Refresh",
|
||||
"market.status.loading": "Loading the official plugin market...",
|
||||
"market.status.loaded_network_format": "Loaded {0} plugin(s) from the official source.",
|
||||
"market.status.loaded_cache_format": "Official source unavailable. Loaded {0} plugin(s) from cache. Reason: {1}",
|
||||
"market.status.load_failed_format": "Failed to load the plugin market: {0}",
|
||||
"market.status.installing_format": "Downloading and staging plugin '{0}'...",
|
||||
"market.status.install_success_format": "Plugin '{0}' has been staged. Restart the app to apply it.",
|
||||
"market.status.install_failed_format": "Failed to install plugin: {0}",
|
||||
"market.status.host_incompatible_format": "This host is too old. Version {0} or newer is required.",
|
||||
"market.list.empty": "The plugin market has not been loaded yet.",
|
||||
"market.list.no_results": "No plugins match the current search.",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "Loaded",
|
||||
"market.card.pending_restart": "Restart required",
|
||||
"market.detail.placeholder": "Select a plugin on the left to inspect details.",
|
||||
"market.detail.author": "Author",
|
||||
"market.detail.version": "Version",
|
||||
"market.detail.api_version": "API Version",
|
||||
"market.detail.min_host_version": "Minimum Host Version",
|
||||
"market.detail.installed_version": "Installed Version",
|
||||
"market.detail.not_installed": "Not installed",
|
||||
"market.detail.market_source": "Market Source",
|
||||
"market.detail.homepage": "Homepage",
|
||||
"market.detail.repository": "Repository",
|
||||
"market.detail.release_notes": "Release Notes",
|
||||
"market.detail.state.not_installed": "Not installed",
|
||||
"market.detail.state.update_available": "Update available",
|
||||
"market.detail.state.installed": "Installed",
|
||||
"market.detail.unknown": "Unknown",
|
||||
"market.button.install": "Install",
|
||||
"market.button.update": "Update",
|
||||
"market.button.installed": "Installed",
|
||||
"market.button.installing": "Installing...",
|
||||
"button.component_library": "Edit Desktop",
|
||||
"tooltip.component_library": "Edit Desktop",
|
||||
"component_library.title": "Widgets",
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
"tooltip.back_to_windows": "回到Windows",
|
||||
"tooltip.open_settings": "设置",
|
||||
"settings.title": "设置",
|
||||
"settings.shell.title": "应用设置",
|
||||
"settings.shell.subtitle": "LanMountainDesktop 独立设置窗口",
|
||||
"settings.shell.sidebar_hint": "选择一个分类以调整应用行为、桌面布局与外观。",
|
||||
"settings.shell.footer_hint": "托盘菜单打开的设置会统一在这个独立窗口中管理。",
|
||||
"settings.back_to_desktop": "返回桌面",
|
||||
"settings.nav_header": "设置选项",
|
||||
"settings.nav.group_desktop": "桌面",
|
||||
"settings.nav.group_system": "系统",
|
||||
"settings.nav.group_extensions": "扩展",
|
||||
"settings.nav.wallpaper": "壁纸",
|
||||
"settings.nav.grid": "网格",
|
||||
"settings.nav.color": "颜色",
|
||||
@@ -109,6 +116,8 @@
|
||||
"settings.weather.preview_header": "连接测试",
|
||||
"settings.weather.preview_desc": "发送一次测试请求,验证当前配置是否可用。",
|
||||
"settings.weather.preview_button": "测试获取",
|
||||
"settings.weather.preview_section": "天气预览",
|
||||
"settings.weather.settings_section": "设置",
|
||||
"settings.weather.preview_panel_header": "天气预览",
|
||||
"settings.weather.preview_panel_desc": "刷新并验证当前天气服务状态。",
|
||||
"settings.weather.refresh_button": "刷新",
|
||||
@@ -129,6 +138,15 @@
|
||||
"settings.weather.status_city_empty": "尚未配置城市位置。",
|
||||
"settings.weather.status_city_format": "模式:{0}|{1}|Key:{2}",
|
||||
"settings.weather.status_coordinates_format": "模式:{0}|纬度 {1:F4},经度 {2:F4}|Key:{3}",
|
||||
"settings.weather.city_selection_label": "城市选择",
|
||||
"settings.weather.coordinates_selection_label": "坐标定位",
|
||||
"settings.weather.location_city_summary_desc": "选择当前所在的城市,用于天气查询。",
|
||||
"settings.weather.location_coordinates_summary_desc": "设置经纬度与可选的位置名称,用于天气查询。",
|
||||
"settings.weather.location_not_selected": "未选择位置",
|
||||
"settings.weather.alert_list_label": "排除列表",
|
||||
"settings.weather.alert_list_desc": "一行一条排除项。",
|
||||
"settings.weather.no_tls_toggle": "允许在兼容性较差的网络环境下回退到非 TLS 请求",
|
||||
"settings.weather.footer_hint": "桌面上的天气组件会共享这里配置的天气位置与预警排除规则。",
|
||||
"settings.weather.location_header": "天气位置",
|
||||
"settings.weather.location_desc": "设置天气组件使用的位置。",
|
||||
"settings.weather.location_placeholder": "例如:北京",
|
||||
@@ -244,6 +262,18 @@
|
||||
"settings.about.render_mode.angle_egl": "angleEgl",
|
||||
"settings.about.render_mode.wgl": "WGL",
|
||||
"settings.about.render_mode.vulkan": "Vulkan",
|
||||
"settings.about.render_mode.unknown": "未知",
|
||||
"settings.about.render_mode.current_label": "当前实际渲染后端",
|
||||
"settings.about.render_mode.current_format": "当前后端:{0}",
|
||||
"settings.about.render_mode.impl_format": "运行时实现:{0}",
|
||||
"settings.about.render_mode.impl_unavailable": "当前无法获取运行时实现信息。",
|
||||
"settings.restart_dialog.title": "需要重启应用",
|
||||
"settings.restart_dialog.render_mode_message": "需要重启应用,才能将渲染模式从“{0}”切换到“{1}”。是否现在重启?",
|
||||
"settings.restart_dialog.restart": "立即重启",
|
||||
"settings.restart_dialog.cancel": "取消",
|
||||
"settings.restart_dock.title": "需要重启应用",
|
||||
"settings.restart_dock.description": "部分更改需要在重启应用后才会生效。",
|
||||
"settings.restart_dock.button": "重启应用",
|
||||
"settings.footer": "LanMountainDesktop 设置",
|
||||
"filepicker.title": "选择壁纸",
|
||||
"filepicker.image_files": "图片文件",
|
||||
@@ -295,10 +325,57 @@
|
||||
"settings.plugins.toggle_result_format": "插件“{0}”已在下次启动时设为{1}。重启应用后,设置页和组件变更才会生效。",
|
||||
"settings.plugins.toggle_state_enabled": "启用",
|
||||
"settings.plugins.toggle_state_disabled": "禁用",
|
||||
"settings.plugins.install_button": "打开 .laapp 插件包",
|
||||
"settings.plugins.install_unavailable": "插件运行时不可用,暂时无法安装 .laapp 插件包。",
|
||||
"settings.plugins.install_hint_format": "打开一个 .laapp 插件包,安装到:{0}",
|
||||
"settings.plugins.install_picker_title": "选择插件安装包",
|
||||
"settings.plugins.install_file_type": ".laapp 插件包",
|
||||
"settings.plugins.install_picker_unavailable": "文件存储提供程序不可用。",
|
||||
"settings.plugins.install_copy_failed": "复制所选 .laapp 插件包失败。",
|
||||
"settings.plugins.install_success_format": "插件“{0}”安装完成。重启应用后,新增的设置页和组件才会生效。",
|
||||
"settings.plugins.install_failed_format": "安装插件包失败:{0}",
|
||||
"settings.plugins.source_package": ".laapp 包",
|
||||
"settings.plugins.source_manifest": "散装清单",
|
||||
"settings.plugins.subtitle_format": "{0} | {1} | {2}",
|
||||
"settings.plugins.detail_format": "设置页:{0} | 组件:{1}",
|
||||
"settings.plugins.market_header": "官方市场",
|
||||
"settings.plugins.market_desc": "浏览来自 LanAirApp 官方源的插件,并将安装暂存到本地。",
|
||||
"settings.plugins.market_hint": "这里使用托管在 LanAirApp 仓库中的官方市场索引来发现插件并暂存安装。",
|
||||
"settings.plugins.market_unavailable": "插件运行时不可用,暂时无法打开官方市场。",
|
||||
"market.toolbar.search_placeholder": "搜索插件",
|
||||
"market.toolbar.refresh": "刷新",
|
||||
"market.status.loading": "正在加载官方插件市场...",
|
||||
"market.status.loaded_network_format": "已从官方源加载 {0} 个插件。",
|
||||
"market.status.loaded_cache_format": "官方源暂时不可用,已从缓存加载 {0} 个插件。原因:{1}",
|
||||
"market.status.load_failed_format": "加载插件市场失败:{0}",
|
||||
"market.status.installing_format": "正在下载并暂存插件“{0}”...",
|
||||
"market.status.install_success_format": "插件“{0}”已暂存完成。重启应用后生效。",
|
||||
"market.status.install_failed_format": "安装插件失败:{0}",
|
||||
"market.status.host_incompatible_format": "当前宿主版本过低,至少需要 {0}。",
|
||||
"market.list.empty": "插件市场尚未加载。",
|
||||
"market.list.no_results": "没有匹配当前搜索的插件。",
|
||||
"market.card.subtitle_format": "{0} | v{1}",
|
||||
"market.card.loaded": "已加载",
|
||||
"market.card.pending_restart": "需要重启",
|
||||
"market.detail.placeholder": "从左侧选择一个插件以查看详情。",
|
||||
"market.detail.author": "作者",
|
||||
"market.detail.version": "版本",
|
||||
"market.detail.api_version": "API 版本",
|
||||
"market.detail.min_host_version": "最低宿主版本",
|
||||
"market.detail.installed_version": "已安装版本",
|
||||
"market.detail.not_installed": "未安装",
|
||||
"market.detail.market_source": "市场源",
|
||||
"market.detail.homepage": "主页",
|
||||
"market.detail.repository": "仓库",
|
||||
"market.detail.release_notes": "发布说明",
|
||||
"market.detail.state.not_installed": "未安装",
|
||||
"market.detail.state.update_available": "可更新",
|
||||
"market.detail.state.installed": "已安装",
|
||||
"market.detail.unknown": "未知",
|
||||
"market.button.install": "安装",
|
||||
"market.button.update": "更新",
|
||||
"market.button.installed": "已安装",
|
||||
"market.button.installing": "安装中...",
|
||||
"button.component_library": "桌面编辑",
|
||||
"tooltip.component_library": "桌面编辑",
|
||||
"component_library.title": "桌面编辑",
|
||||
|
||||
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
73
LanMountainDesktop/Services/AppRenderBackendDiagnostics.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Platform;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public readonly record struct AppRenderBackendInfo(
|
||||
string ActualBackend,
|
||||
string? ImplementationTypeName);
|
||||
|
||||
public static class AppRenderBackendDiagnostics
|
||||
{
|
||||
public const string Unknown = "Unknown";
|
||||
|
||||
public static AppRenderBackendInfo Detect()
|
||||
{
|
||||
var platformGraphics = GetPlatformGraphics();
|
||||
var implementationTypeName = platformGraphics?.GetType().FullName;
|
||||
var actualBackend = DetectBackendFromImplementationType(implementationTypeName, platformGraphics is null);
|
||||
|
||||
return new AppRenderBackendInfo(actualBackend, implementationTypeName);
|
||||
}
|
||||
|
||||
private static object? GetPlatformGraphics()
|
||||
{
|
||||
var currentResolver = typeof(AvaloniaLocator)
|
||||
.GetProperty("Current", BindingFlags.Public | BindingFlags.Static)
|
||||
?.GetValue(null);
|
||||
|
||||
var getServiceMethod = currentResolver?
|
||||
.GetType()
|
||||
.GetMethod(
|
||||
"GetService",
|
||||
BindingFlags.Public | BindingFlags.Instance,
|
||||
binder: null,
|
||||
types: [typeof(Type)],
|
||||
modifiers: null);
|
||||
|
||||
return getServiceMethod?.Invoke(currentResolver, [typeof(IPlatformGraphics)]);
|
||||
}
|
||||
|
||||
private static string DetectBackendFromImplementationType(string? implementationTypeName, bool isSoftwareFallback)
|
||||
{
|
||||
if (isSoftwareFallback)
|
||||
{
|
||||
return AppRenderingModeHelper.Software;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(implementationTypeName))
|
||||
{
|
||||
return Unknown;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Vulkan", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Vulkan;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Wgl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.Wgl;
|
||||
}
|
||||
|
||||
if (implementationTypeName.Contains("Angle", StringComparison.OrdinalIgnoreCase) ||
|
||||
implementationTypeName.Contains("Egl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return AppRenderingModeHelper.AngleEgl;
|
||||
}
|
||||
|
||||
return Unknown;
|
||||
}
|
||||
}
|
||||
171
LanMountainDesktop/Services/AppRestartService.cs
Normal file
171
LanMountainDesktop/Services/AppRestartService.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class AppRestartService
|
||||
{
|
||||
public static bool TryRestartApplication()
|
||||
{
|
||||
if (!TryRestartCurrentProcess())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryRestartCurrentProcess()
|
||||
{
|
||||
try
|
||||
{
|
||||
var startInfo = CreateRestartStartInfo();
|
||||
if (startInfo is null)
|
||||
{
|
||||
Debug.WriteLine("[AppRestart] Failed to resolve restart start info.");
|
||||
return false;
|
||||
}
|
||||
|
||||
Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[AppRestart] Failed to restart app: {ex}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static ProcessStartInfo? CreateRestartStartInfo(
|
||||
string[]? commandLineArgs = null,
|
||||
string? processPath = null,
|
||||
string? entryAssemblyLocation = null)
|
||||
{
|
||||
var args = commandLineArgs ?? Environment.GetCommandLineArgs();
|
||||
var resolvedProcessPath = NormalizeExistingPath(processPath ?? Environment.ProcessPath);
|
||||
var resolvedEntryAssemblyPath = NormalizeExistingPath(
|
||||
entryAssemblyLocation ?? Assembly.GetEntryAssembly()?.Location);
|
||||
|
||||
if (IsDotnetHost(resolvedProcessPath))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
resolvedProcessPath!,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedProcessPath))
|
||||
{
|
||||
return CreateExecutableStartInfo(
|
||||
resolvedProcessPath,
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(resolvedEntryAssemblyPath) &&
|
||||
string.Equals(Path.GetExtension(resolvedEntryAssemblyPath), ".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateDotnetStartInfo(
|
||||
"dotnet",
|
||||
resolvedEntryAssemblyPath,
|
||||
args);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateExecutableStartInfo(
|
||||
string executablePath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executablePath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(executablePath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static ProcessStartInfo? CreateDotnetStartInfo(
|
||||
string dotnetHostPath,
|
||||
string? entryAssemblyPath,
|
||||
IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(entryAssemblyPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = dotnetHostPath,
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = ResolveWorkingDirectory(dotnetHostPath, entryAssemblyPath)
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add(entryAssemblyPath);
|
||||
AppendArguments(startInfo, commandLineArgs);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void AppendArguments(ProcessStartInfo startInfo, IReadOnlyList<string> commandLineArgs)
|
||||
{
|
||||
for (var i = 1; i < commandLineArgs.Count; i++)
|
||||
{
|
||||
startInfo.ArgumentList.Add(commandLineArgs[i]);
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeExistingPath(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
return File.Exists(fullPath) ? fullPath : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDotnetHost(string? processPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(processPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(processPath);
|
||||
return string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(fileName, "dotnet.exe", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string launchPath, string? entryAssemblyPath)
|
||||
{
|
||||
var basePath = !string.IsNullOrWhiteSpace(entryAssemblyPath)
|
||||
? entryAssemblyPath
|
||||
: launchPath;
|
||||
|
||||
return Path.GetDirectoryName(basePath) ?? AppContext.BaseDirectory;
|
||||
}
|
||||
}
|
||||
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal file
55
LanMountainDesktop/Services/PendingRestartStateService.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
|
||||
public static class PendingRestartStateService
|
||||
{
|
||||
public const string RenderModeReason = "RenderMode";
|
||||
public const string PluginCatalogReason = "PluginCatalog";
|
||||
|
||||
private static readonly object Gate = new();
|
||||
private static readonly HashSet<string> PendingReasons = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static event Action? StateChanged;
|
||||
|
||||
public static bool HasPendingRestart
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return PendingReasons.Count > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool HasPendingReason(string reason)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
return PendingReasons.Contains(reason);
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetPending(string reason, bool pending)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
lock (Gate)
|
||||
{
|
||||
changed = pending
|
||||
? PendingReasons.Add(reason)
|
||||
: PendingReasons.Remove(reason);
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,14 @@ public partial class MainWindow
|
||||
StatusBarSpacingCustomPanel.Content = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
|
||||
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
|
||||
WeatherPreviewSectionTextBlock.Text = L("settings.weather.preview_section", "Weather Preview");
|
||||
WeatherSettingsSectionTextBlock.Text = L("settings.weather.settings_section", "Settings");
|
||||
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
WeatherPreviewSettingsExpander.Description = L(
|
||||
"settings.weather.preview_panel_desc",
|
||||
"Refresh and verify current weather service status.");
|
||||
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
|
||||
|
||||
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
|
||||
WeatherLocationSettingsExpander.Description = L(
|
||||
"settings.weather.location_source_desc",
|
||||
@@ -189,6 +197,10 @@ public partial class MainWindow
|
||||
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search");
|
||||
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
|
||||
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup");
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_city_summary_desc",
|
||||
"Select the current city used for weather queries.");
|
||||
|
||||
WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search");
|
||||
WeatherCitySearchSettingsExpander.Description = L(
|
||||
@@ -208,24 +220,12 @@ public partial class MainWindow
|
||||
WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)");
|
||||
WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
|
||||
|
||||
WeatherPreviewSettingsExpander.Header = L("settings.weather.preview_panel_header", "Weather Preview");
|
||||
WeatherPreviewSettingsExpander.Description = L(
|
||||
"settings.weather.preview_panel_desc",
|
||||
"Refresh and verify current weather service status.");
|
||||
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
|
||||
|
||||
WeatherLocationSettingsExpander.Header = L("settings.weather.location_msg_header", "Location Source");
|
||||
WeatherLocationSettingsExpander.Description = L(
|
||||
"settings.weather.location_msg_desc",
|
||||
"Choose how weather widgets resolve location.");
|
||||
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city", "City Search");
|
||||
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
|
||||
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_location_toggle", "Auto refresh location on startup");
|
||||
|
||||
WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts");
|
||||
WeatherAlertFilterSettingsExpander.Description = L(
|
||||
"settings.weather.alert_filter_desc",
|
||||
"Alerts containing these words will not be shown. One rule per line.");
|
||||
WeatherAlertListTitleTextBlock.Text = L("settings.weather.alert_list_label", "Exclude List");
|
||||
WeatherAlertListDescriptionTextBlock.Text = L("settings.weather.alert_list_desc", "One exclusion rule per line.");
|
||||
WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line");
|
||||
|
||||
WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style");
|
||||
@@ -239,6 +239,10 @@ public partial class MainWindow
|
||||
WeatherNoTlsSettingsExpander.Description = L(
|
||||
"settings.weather.no_tls_desc",
|
||||
"Not recommended. Enable only for incompatible network environments.");
|
||||
WeatherNoTlsToggleSwitch.Content = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
|
||||
WeatherFooterHintTextBlock.Text = L(
|
||||
"settings.weather.footer_hint",
|
||||
"Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_weatherSearchKeyword))
|
||||
{
|
||||
@@ -278,26 +282,7 @@ public partial class MainWindow
|
||||
"Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L(
|
||||
"settings.plugins.runtime_desc",
|
||||
"Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.runtime_hint",
|
||||
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L(
|
||||
"settings.plugins.runtime_status",
|
||||
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L(
|
||||
"settings.plugins.installed_desc",
|
||||
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L(
|
||||
"settings.plugins.restart_hint",
|
||||
"Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
ApplyPluginSettingsLocalization();
|
||||
|
||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
@@ -326,6 +311,8 @@ public partial class MainWindow
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
|
||||
UpdateCurrentRenderBackendStatus();
|
||||
UpdatePendingRestartDock();
|
||||
|
||||
if (WallpaperPlacementComboBox?.ItemCount >= 5)
|
||||
{
|
||||
@@ -435,6 +422,7 @@ public partial class MainWindow
|
||||
WeatherLocationStatusTextBlock.Text = L(
|
||||
"settings.weather.status_city_empty",
|
||||
"No city location is configured.");
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -447,6 +435,7 @@ public partial class MainWindow
|
||||
modeText,
|
||||
locationName,
|
||||
_weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -459,6 +448,7 @@ public partial class MainWindow
|
||||
string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude)
|
||||
: _weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
42
LanMountainDesktop/Views/MainWindow.RenderBackend.cs
Normal file
42
LanMountainDesktop/Views/MainWindow.RenderBackend.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void UpdateCurrentRenderBackendStatus()
|
||||
{
|
||||
var backendInfo = AppRenderBackendDiagnostics.Detect();
|
||||
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
|
||||
|
||||
CurrentRenderBackendLabelTextBlock.Text = L(
|
||||
"settings.about.render_mode.current_label",
|
||||
"Current actual backend");
|
||||
CurrentRenderBackendValueTextBlock.Text = Lf(
|
||||
"settings.about.render_mode.current_format",
|
||||
"Current backend: {0}",
|
||||
localizedBackend);
|
||||
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
|
||||
? L(
|
||||
"settings.about.render_mode.impl_unavailable",
|
||||
"Runtime implementation is unavailable.")
|
||||
: Lf(
|
||||
"settings.about.render_mode.impl_format",
|
||||
"Runtime implementation: {0}",
|
||||
backendInfo.ImplementationTypeName);
|
||||
}
|
||||
|
||||
private string GetLocalizedRenderBackendName(string renderBackend)
|
||||
{
|
||||
return renderBackend switch
|
||||
{
|
||||
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
|
||||
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
|
||||
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
|
||||
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
|
||||
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
|
||||
_ => L("settings.about.render_mode.unknown", "Unknown")
|
||||
};
|
||||
}
|
||||
}
|
||||
112
LanMountainDesktop/Views/MainWindow.RestartPrompt.cs
Normal file
112
LanMountainDesktop/Views/MainWindow.RestartPrompt.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private bool _isRestartPromptVisible;
|
||||
|
||||
private void OnPendingRestartStateChanged()
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(UpdatePendingRestartDock);
|
||||
}
|
||||
|
||||
private void UpdatePendingRestartDock()
|
||||
{
|
||||
PendingRestartDock.IsVisible = PendingRestartStateService.HasPendingRestart;
|
||||
PendingRestartDockTitleTextBlock.Text = L("settings.restart_dock.title", "Restart required");
|
||||
PendingRestartDockDescriptionTextBlock.Text = L(
|
||||
"settings.restart_dock.description",
|
||||
"Some changes will take effect after restarting the app.");
|
||||
PendingRestartDockButtonTextBlock.Text = L("settings.restart_dock.button", "Restart app");
|
||||
}
|
||||
|
||||
private async void OnPendingRestartDockButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ShowGenericRestartPromptAsync();
|
||||
}
|
||||
|
||||
private Task ShowRenderModeRestartPromptAsync(string selectedMode)
|
||||
{
|
||||
var message = Lf(
|
||||
"settings.restart_dialog.render_mode_message",
|
||||
"Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
||||
GetLocalizedAppRenderModeDisplayName(_runningAppRenderMode),
|
||||
GetLocalizedAppRenderModeDisplayName(selectedMode));
|
||||
|
||||
return ShowRestartPromptCoreAsync(message);
|
||||
}
|
||||
|
||||
private Task ShowGenericRestartPromptAsync()
|
||||
{
|
||||
return ShowRestartPromptCoreAsync(L(
|
||||
"settings.restart_dock.description",
|
||||
"Some changes will take effect after restarting the app."));
|
||||
}
|
||||
|
||||
private async Task ShowRestartPromptCoreAsync(string message)
|
||||
{
|
||||
if (_isRestartPromptVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRestartPromptVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = L("settings.restart_dialog.title", "Restart required"),
|
||||
Content = message,
|
||||
PrimaryButtonText = L("settings.restart_dialog.restart", "Restart now"),
|
||||
CloseButtonText = L("settings.restart_dialog.cancel", "Cancel"),
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRestartPromptVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLocalizedAppRenderModeDisplayName(string renderMode)
|
||||
{
|
||||
if (renderMode == AppRenderBackendDiagnostics.Unknown)
|
||||
{
|
||||
return L("settings.about.render_mode.unknown", "Unknown");
|
||||
}
|
||||
|
||||
return AppRenderingModeHelper.Normalize(renderMode) switch
|
||||
{
|
||||
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
|
||||
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
|
||||
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
|
||||
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
|
||||
_ => L("settings.about.render_mode.default", "Default")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1225,6 +1225,10 @@ public partial class MainWindow
|
||||
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
|
||||
_runningAppRenderMode = ResolveActiveAppRenderModeForUi(_selectedAppRenderMode);
|
||||
var renderModeForUi = PendingRestartStateService.HasPendingReason(PendingRestartStateService.RenderModeReason)
|
||||
? _selectedAppRenderMode
|
||||
: _runningAppRenderMode;
|
||||
|
||||
if (AppRenderModeComboBox is null)
|
||||
{
|
||||
@@ -1235,7 +1239,7 @@ public partial class MainWindow
|
||||
try
|
||||
{
|
||||
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
|
||||
SelectAppRenderModeInUi(_selectedAppRenderMode);
|
||||
SelectAppRenderModeInUi(renderModeForUi);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1250,13 +1254,27 @@ public partial class MainWindow
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedItem = AppRenderModeComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
string.Equals(item.Tag?.ToString(), renderMode, StringComparison.OrdinalIgnoreCase));
|
||||
AppRenderModeComboBox.SelectedIndex = GetAppRenderModeComboBoxIndex(renderMode);
|
||||
}
|
||||
|
||||
AppRenderModeComboBox.SelectedItem = selectedItem
|
||||
?? AppRenderModeComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
private static int GetAppRenderModeComboBoxIndex(string renderMode)
|
||||
{
|
||||
return AppRenderingModeHelper.Normalize(renderMode) switch
|
||||
{
|
||||
AppRenderingModeHelper.Software => 1,
|
||||
AppRenderingModeHelper.AngleEgl => 2,
|
||||
AppRenderingModeHelper.Wgl => 3,
|
||||
AppRenderingModeHelper.Vulkan => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveActiveAppRenderModeForUi(string configuredRenderMode)
|
||||
{
|
||||
var detectedRenderMode = AppRenderBackendDiagnostics.Detect().ActualBackend;
|
||||
return string.Equals(detectedRenderMode, AppRenderBackendDiagnostics.Unknown, StringComparison.Ordinal)
|
||||
? configuredRenderMode
|
||||
: AppRenderingModeHelper.Normalize(detectedRenderMode);
|
||||
}
|
||||
|
||||
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
|
||||
@@ -1378,6 +1396,8 @@ public partial class MainWindow
|
||||
{
|
||||
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
|
||||
}
|
||||
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
|
||||
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -1534,7 +1554,7 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
var selectedMode = AppRenderingModeHelper.Normalize(
|
||||
(AppRenderModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString());
|
||||
TryGetSelectedComboBoxTag(AppRenderModeComboBox));
|
||||
|
||||
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -1543,6 +1563,14 @@ public partial class MainWindow
|
||||
|
||||
_selectedAppRenderMode = selectedMode;
|
||||
PersistSettings();
|
||||
var requiresRestart = !string.Equals(_runningAppRenderMode, selectedMode, StringComparison.Ordinal);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.RenderModeReason, requiresRestart);
|
||||
UpdatePendingRestartDock();
|
||||
|
||||
if (requiresRestart)
|
||||
{
|
||||
_ = ShowRenderModeRestartPromptAsync(selectedMode);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
|
||||
@@ -1853,7 +1881,7 @@ public partial class MainWindow
|
||||
var weather = snapshot.Current.WeatherText ??
|
||||
L("settings.weather.preview_unknown", "Unknown");
|
||||
var temperature = snapshot.Current.TemperatureC.HasValue
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C")
|
||||
? FormatWeatherPreviewTemperature(snapshot.Current.TemperatureC.Value)
|
||||
: "--";
|
||||
var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt;
|
||||
|
||||
@@ -1896,6 +1924,14 @@ public partial class MainWindow
|
||||
|
||||
private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt)
|
||||
{
|
||||
if (WeatherPreviewIconImage is not null)
|
||||
{
|
||||
var kind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, _isNightMode);
|
||||
WeatherPreviewIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(kind)) ??
|
||||
HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
|
||||
}
|
||||
|
||||
if (WeatherPreviewIconSymbol is not null)
|
||||
{
|
||||
WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode);
|
||||
@@ -1915,10 +1951,15 @@ public partial class MainWindow
|
||||
}
|
||||
|
||||
WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue
|
||||
? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime)
|
||||
? updatedAt.Value.LocalDateTime.ToString("yyyy/M/d HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FormatWeatherPreviewTemperature(double temperatureC)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
|
||||
}
|
||||
|
||||
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
|
||||
{
|
||||
return weatherCode switch
|
||||
@@ -1934,6 +1975,38 @@ public partial class MainWindow
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateWeatherLocationSummaryCard()
|
||||
{
|
||||
if (WeatherLocationSelectionTitleTextBlock is null ||
|
||||
WeatherLocationSelectionDescriptionTextBlock is null ||
|
||||
WeatherLocationValueTextBlock is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
|
||||
{
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.coordinates_selection_label", "Coordinate Location");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_coordinates_summary_desc",
|
||||
"Set latitude/longitude and optional location name used for weather queries.");
|
||||
WeatherLocationValueTextBlock.Text = string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{_weatherLatitude:F4}, {_weatherLongitude:F4}")
|
||||
: _weatherLocationName;
|
||||
return;
|
||||
}
|
||||
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_city_summary_desc",
|
||||
"Select the current city used for weather queries.");
|
||||
WeatherLocationValueTextBlock.Text = !string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? _weatherLocationName
|
||||
: !string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? _weatherLocationKey
|
||||
: L("settings.weather.location_not_selected", "No location selected");
|
||||
}
|
||||
|
||||
private void SetWeatherSearchBusy(bool isBusy)
|
||||
{
|
||||
if (WeatherSearchButton is not null)
|
||||
@@ -2635,6 +2708,8 @@ public partial class MainWindow
|
||||
|
||||
// --- WeatherSettingsPage ---
|
||||
internal TextBlock WeatherPanelTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPanelTitleTextBlock")!;
|
||||
internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewSectionTextBlock")!;
|
||||
internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSettingsSectionTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherPreviewSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherLocationSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCitySearchSettingsExpander")!;
|
||||
@@ -2665,6 +2740,7 @@ public partial class MainWindow
|
||||
internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLongitudeNumberBox")!;
|
||||
internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherCoordinateStatusTextBlock")!;
|
||||
internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewResultTextBlock")!;
|
||||
internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl<Image>("WeatherPreviewIconImage")!;
|
||||
internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl<FluentIcons.Avalonia.Fluent.SymbolIcon>("WeatherPreviewIconSymbol")!;
|
||||
internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewTemperatureTextBlock")!;
|
||||
internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewUpdatedTextBlock")!;
|
||||
@@ -2672,7 +2748,13 @@ public partial class MainWindow
|
||||
internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherPreviewProgressRing")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentRegularItem")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentFilledItem")!;
|
||||
internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionTitleTextBlock")!;
|
||||
internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationValueTextBlock")!;
|
||||
internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationStatusTextBlock")!;
|
||||
internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListTitleTextBlock")!;
|
||||
internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherFooterHintTextBlock")!;
|
||||
|
||||
// --- UpdateSettingsPage ---
|
||||
internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePanelTitleTextBlock")!;
|
||||
@@ -2701,6 +2783,9 @@ public partial class MainWindow
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
|
||||
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
|
||||
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
|
||||
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
|
||||
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
|
||||
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
|
||||
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
|
||||
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
|
||||
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;
|
||||
@@ -2712,14 +2797,6 @@ public partial class MainWindow
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
// --- PluginSettingsPage (Added for completeness) ---
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -377,88 +377,138 @@
|
||||
<Border Classes="mica-strong"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXl}"
|
||||
Padding="18">
|
||||
<ui:NavigationView x:Name="SettingsNavView"
|
||||
PaneDisplayMode="Left"
|
||||
IsSettingsVisible="False"
|
||||
OpenPaneLength="220"
|
||||
SelectionChanged="OnSettingsNavSelectionChanged">
|
||||
<ui:NavigationView.MenuItems>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper" ToolTip.Tip="壁纸">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid" ToolTip.Tip="网格">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color" ToolTip.Tip="颜色">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar" ToolTip.Tip="状态栏">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather" ToolTip.Tip="天气">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region" ToolTip.Tip="地区">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update" ToolTip.Tip="更新">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About" ToolTip.Tip="关于">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher" ToolTip.Tip="应用启动台">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins" ToolTip.Tip="插件">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
</ui:NavigationView.MenuItems>
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
RowSpacing="14">
|
||||
<ui:NavigationView x:Name="SettingsNavView"
|
||||
Grid.Row="0"
|
||||
PaneDisplayMode="Left"
|
||||
IsSettingsVisible="False"
|
||||
OpenPaneLength="220"
|
||||
SelectionChanged="OnSettingsNavSelectionChanged">
|
||||
<ui:NavigationView.MenuItems>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper" ToolTip.Tip="壁纸">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid" ToolTip.Tip="网格">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color" ToolTip.Tip="颜色">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar" ToolTip.Tip="状态栏">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather" ToolTip.Tip="天气">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region" ToolTip.Tip="地区">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update" ToolTip.Tip="更新">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About" ToolTip.Tip="关于">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher" ToolTip.Tip="应用启动台">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins" ToolTip.Tip="插件">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
</ui:NavigationView.MenuItems>
|
||||
|
||||
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
||||
Padding="0,0,16,0"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<Grid x:Name="SettingsContentPagesHost">
|
||||
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
|
||||
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
||||
Padding="0,0,16,0"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<Grid x:Name="SettingsContentPagesHost">
|
||||
<pages:WallpaperSettingsPage x:Name="WallpaperSettingsPanel" IsVisible="True" />
|
||||
|
||||
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
|
||||
<pages:GridSettingsPage x:Name="GridSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
|
||||
<pages:ColorSettingsPage x:Name="ColorSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:StatusBarSettingsPage x:Name="StatusBarSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:WeatherSettingsPage x:Name="WeatherSettingsPanel" IsVisible="False" />
|
||||
<pages:RegionSettingsPage x:Name="RegionSettingsPanel" IsVisible="False" />
|
||||
<pages:StatusBarSettingsPage x:Name="StatusBarSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:WeatherSettingsPage x:Name="WeatherSettingsPanel" IsVisible="False" />
|
||||
<pages:RegionSettingsPage x:Name="RegionSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:UpdateSettingsPage x:Name="UpdateSettingsPanel" IsVisible="False" />
|
||||
<pages:UpdateSettingsPage x:Name="UpdateSettingsPanel" IsVisible="False" />
|
||||
|
||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
<pages:LauncherSettingsPage x:Name="LauncherSettingsPanel" IsVisible="False" />
|
||||
<pages:AboutSettingsPage x:Name="AboutSettingsPanel" IsVisible="False" />
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
Grid.Row="1"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
CornerRadius="18"
|
||||
Padding="14,12">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontWeight="SemiBold"
|
||||
Text="Restart required" />
|
||||
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Your changes will apply after restarting the app." />
|
||||
</StackPanel>
|
||||
<Button x:Name="PendingRestartDockButton"
|
||||
Grid.Column="2"
|
||||
Padding="14,8"
|
||||
Click="OnPendingRestartDockButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Restart app" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -169,6 +169,7 @@ public partial class MainWindow : Window
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
private bool _suppressAppRenderModeSelectionEvents;
|
||||
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
|
||||
private string _runningAppRenderMode = AppRenderingModeHelper.Default;
|
||||
private string _weatherSearchKeyword = string.Empty;
|
||||
private bool _isWeatherSearchInProgress;
|
||||
private bool _isWeatherPreviewInProgress;
|
||||
@@ -190,6 +191,7 @@ public partial class MainWindow : Window
|
||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||
AppSettingsService.SettingsSaved += OnExternalAppSettingsSaved;
|
||||
LauncherSettingsService.SettingsSaved += OnExternalLauncherSettingsSaved;
|
||||
PendingRestartStateService.StateChanged += OnPendingRestartStateChanged;
|
||||
PropertyChanged += OnWindowPropertyChanged;
|
||||
InitializeDesktopSurfaceSwipeHandlers();
|
||||
InitializeDesktopComponentDragHandlers();
|
||||
@@ -203,8 +205,7 @@ public partial class MainWindow : Window
|
||||
GridEdgeInsetSlider.ValueChanged += OnGridEdgeInsetSliderChanged;
|
||||
ApplyGridButton.Click += OnApplyGridSizeClick;
|
||||
|
||||
NightModeToggleSwitch.Checked += OnNightModeChecked;
|
||||
NightModeToggleSwitch.Unchecked += OnNightModeUnchecked;
|
||||
NightModeToggleSwitch.IsCheckedChanged += OnNightModeIsCheckedChanged;
|
||||
RecommendedColorButton1.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton2.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton3.Click += OnRecommendedColorClick;
|
||||
@@ -219,40 +220,67 @@ public partial class MainWindow : Window
|
||||
MonetColorButton5.Click += OnMonetColorClick;
|
||||
MonetColorButton6.Click += OnMonetColorClick;
|
||||
|
||||
StatusBarClockToggleSwitch.Checked += OnStatusBarClockChecked;
|
||||
StatusBarClockToggleSwitch.Unchecked += OnStatusBarClockUnchecked;
|
||||
ClockFormatHMSSRadio.Checked += OnClockFormatChanged;
|
||||
ClockFormatHMRadio.Checked += OnClockFormatChanged;
|
||||
StatusBarClockToggleSwitch.IsCheckedChanged += OnStatusBarClockIsCheckedChanged;
|
||||
ClockFormatHMSSRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
ClockFormatHMRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
StatusBarSpacingModeComboBox.SelectionChanged += OnStatusBarSpacingModeChanged;
|
||||
StatusBarSpacingSlider.ValueChanged += OnStatusBarSpacingSliderChanged;
|
||||
|
||||
WeatherPreviewButton.Click += OnTestWeatherRequestClick;
|
||||
WeatherLocationModeComboBox.SelectionChanged += OnWeatherLocationModeSelectionChanged;
|
||||
WeatherLocationModeChipListBox.SelectionChanged += OnWeatherLocationModeChipSelectionChanged;
|
||||
WeatherAutoRefreshToggleSwitch.Checked += OnWeatherAutoRefreshToggled;
|
||||
WeatherAutoRefreshToggleSwitch.Unchecked += OnWeatherAutoRefreshToggled;
|
||||
WeatherAutoRefreshToggleSwitch.IsCheckedChanged += OnWeatherAutoRefreshToggled;
|
||||
WeatherSearchButton.Click += OnSearchWeatherCityClick;
|
||||
WeatherApplyCityButton.Click += OnApplyWeatherCitySelectionClick;
|
||||
WeatherApplyCoordinatesButton.Click += OnApplyWeatherCoordinatesClick;
|
||||
WeatherExcludedAlertsTextBox.LostFocus += OnWeatherExcludedAlertsLostFocus;
|
||||
WeatherIconPackComboBox.SelectionChanged += OnWeatherIconPackSelectionChanged;
|
||||
WeatherNoTlsToggleSwitch.Checked += OnWeatherNoTlsToggled;
|
||||
WeatherNoTlsToggleSwitch.Unchecked += OnWeatherNoTlsToggled;
|
||||
WeatherNoTlsToggleSwitch.IsCheckedChanged += OnWeatherNoTlsToggled;
|
||||
|
||||
LanguageComboBox.SelectionChanged += OnLanguageSelectionChanged;
|
||||
TimeZoneComboBox.SelectionChanged += OnTimeZoneSelectionChanged;
|
||||
|
||||
AutoCheckUpdatesToggleSwitch.Checked += OnAutoCheckUpdatesToggled;
|
||||
AutoCheckUpdatesToggleSwitch.Unchecked += OnAutoCheckUpdatesToggled;
|
||||
AutoCheckUpdatesToggleSwitch.IsCheckedChanged += OnAutoCheckUpdatesToggled;
|
||||
UpdateChannelChipListBox.SelectionChanged += OnUpdateChannelSelectionChanged;
|
||||
CheckForUpdatesButton.Click += OnCheckForUpdatesClick;
|
||||
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
|
||||
|
||||
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
|
||||
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
|
||||
AutoStartWithWindowsToggleSwitch.IsCheckedChanged += OnAutoStartWithWindowsToggled;
|
||||
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
|
||||
}
|
||||
|
||||
private void OnNightModeIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnNightModeChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnNightModeUnchecked(sender, e);
|
||||
}
|
||||
|
||||
private void OnStatusBarClockIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnStatusBarClockChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnStatusBarClockUnchecked(sender, e);
|
||||
}
|
||||
|
||||
protected override void OnOpened(EventArgs e)
|
||||
{
|
||||
base.OnOpened(e);
|
||||
@@ -314,6 +342,7 @@ public partial class MainWindow : Window
|
||||
InitializeWeatherSettings(snapshot);
|
||||
_ = _componentSettingsService.Load();
|
||||
InitializeAutoStartWithWindowsSetting(snapshot);
|
||||
InitializeAppRenderModeSetting(snapshot);
|
||||
InitializeUpdateSettings(snapshot);
|
||||
InitializeDesktopSurfaceState(desktopLayoutSnapshot);
|
||||
InitializeLauncherVisibilitySettings(launcherSnapshot);
|
||||
@@ -379,6 +408,7 @@ public partial class MainWindow : Window
|
||||
_wallpaperBitmap = null;
|
||||
AppSettingsService.SettingsSaved -= OnExternalAppSettingsSaved;
|
||||
LauncherSettingsService.SettingsSaved -= OnExternalLauncherSettingsSaved;
|
||||
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
|
||||
PropertyChanged -= OnWindowPropertyChanged;
|
||||
DesktopHost.SizeChanged -= OnDesktopHostSizeChanged;
|
||||
WallpaperPreviewHost.SizeChanged -= OnWallpaperPreviewHostSizeChanged;
|
||||
@@ -783,6 +813,11 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (radioButton.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_clockDisplayFormat = formatTag == "Hm"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
|
||||
@@ -43,9 +43,28 @@
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Window" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<StackPanel Spacing="4"
|
||||
Margin="0,4,0,0">
|
||||
<TextBlock x:Name="CurrentRenderBackendLabelTextBlock"
|
||||
Text="Current actual backend"
|
||||
FontSize="12"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock x:Name="CurrentRenderBackendValueTextBlock"
|
||||
Text="Current backend: Software"
|
||||
FontSize="13"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<TextBlock x:Name="CurrentRenderBackendImplementationTextBlock"
|
||||
Text="Runtime implementation is unavailable."
|
||||
FontSize="12"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ComboBox x:Name="AppRenderModeComboBox"
|
||||
MinWidth="180"
|
||||
SelectedIndex="0"
|
||||
HorizontalAlignment="Right">
|
||||
<ComboBoxItem Content="Default" Tag="Default" />
|
||||
<ComboBoxItem Content="Software" Tag="Software" />
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PluginSettingsPage : UserControl
|
||||
{
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
|
||||
public PluginSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
AttachedToVisualTree += (_, _) => RefreshFromRuntime();
|
||||
}
|
||||
|
||||
public void RefreshFromRuntime()
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
if (runtime is null)
|
||||
{
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||
PluginRuntimeSummaryPanel.Children.Clear();
|
||||
PluginCatalogItemsHost.Children.Clear();
|
||||
PluginRestartHintTextBlock.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
BuildRuntimeSummary(runtime);
|
||||
BuildPluginCatalog(runtime);
|
||||
}
|
||||
|
||||
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
||||
{
|
||||
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
||||
var enabledCount = runtime.Catalog.Count(entry => entry.IsEnabled);
|
||||
PluginSystemStatusTextBlock.Text = F(
|
||||
"settings.plugins.summary_format",
|
||||
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||
runtime.Catalog.Count,
|
||||
enabledCount,
|
||||
runtime.LoadedPlugins.Count,
|
||||
runtime.SettingsPages.Count,
|
||||
runtime.DesktopComponents.Count,
|
||||
failures.Length);
|
||||
|
||||
PluginRuntimeSummaryPanel.Children.Clear();
|
||||
foreach (var plugin in runtime.Catalog.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var status = plugin.IsEnabled
|
||||
? plugin.IsLoaded
|
||||
? L("settings.plugins.state.enabled", "Enabled")
|
||||
: L("settings.plugins.state.enabled_failed", "Enabled / failed to load")
|
||||
: L("settings.plugins.state.disabled", "Disabled");
|
||||
PluginRuntimeSummaryPanel.Children.Add(CreateSummaryLine(
|
||||
F(
|
||||
"settings.plugins.summary_item_format",
|
||||
"{0} v{1} | {2}",
|
||||
plugin.Manifest.Name,
|
||||
plugin.Manifest.Version ?? "dev",
|
||||
status)));
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildPluginCatalog(PluginRuntimeService runtime)
|
||||
{
|
||||
PluginCatalogItemsHost.Children.Clear();
|
||||
|
||||
var plugins = runtime.Catalog
|
||||
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
PluginCatalogEmptyTextBlock.IsVisible = plugins.Count == 0;
|
||||
PluginRestartHintTextBlock.IsVisible = plugins.Count > 0;
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin));
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
|
||||
{
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = entry.Manifest.Name,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
var subtitle = new TextBlock
|
||||
{
|
||||
Text = BuildPluginSubtitle(entry),
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
var enabledToggle = new ToggleSwitch
|
||||
{
|
||||
IsChecked = entry.IsEnabled,
|
||||
OnContent = L("settings.plugins.toggle_on", "Enabled"),
|
||||
OffContent = L("settings.plugins.toggle_off", "Disabled"),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true);
|
||||
enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false);
|
||||
|
||||
var header = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 12,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children = { title, subtitle }
|
||||
},
|
||||
enabledToggle
|
||||
}
|
||||
};
|
||||
Grid.SetColumn(enabledToggle, 1);
|
||||
|
||||
var details = new TextBlock
|
||||
{
|
||||
Text = BuildPluginDetails(entry),
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 10,
|
||||
Children = { header, details }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
|
||||
{
|
||||
runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled);
|
||||
BuildRuntimeSummary(runtime);
|
||||
BuildPluginCatalog(runtime);
|
||||
PluginSystemStatusTextBlock.Text = F(
|
||||
"settings.plugins.toggle_result_format",
|
||||
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
entry.Manifest.Name,
|
||||
isEnabled
|
||||
? L("settings.plugins.toggle_state_enabled", "enabled")
|
||||
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
||||
}
|
||||
|
||||
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||
{
|
||||
var source = entry.IsPackage
|
||||
? L("settings.plugins.source_package", ".laapp package")
|
||||
: L("settings.plugins.source_manifest", "Loose manifest");
|
||||
var state = entry.IsEnabled
|
||||
? entry.IsLoaded
|
||||
? L("settings.plugins.state.loaded", "Loaded")
|
||||
: L("settings.plugins.state.load_failed", "Load failed")
|
||||
: L("settings.plugins.state.disabled", "Disabled");
|
||||
return F(
|
||||
"settings.plugins.subtitle_format",
|
||||
"{0} | {1} | {2}",
|
||||
state,
|
||||
source,
|
||||
entry.Manifest.Id);
|
||||
}
|
||||
|
||||
private string BuildPluginDetails(PluginCatalogEntry entry)
|
||||
{
|
||||
var detail = F(
|
||||
"settings.plugins.detail_format",
|
||||
"Settings pages: {0} | Widgets: {1}",
|
||||
entry.SettingsPageCount,
|
||||
entry.WidgetCount);
|
||||
return string.IsNullOrWhiteSpace(entry.ErrorMessage)
|
||||
? detail
|
||||
: detail + Environment.NewLine + entry.ErrorMessage;
|
||||
}
|
||||
|
||||
private TextBlock CreateSummaryLine(string text)
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
|
||||
}
|
||||
|
||||
private string F(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,91 +4,116 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia.Fluent"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="1200"
|
||||
mc:Ignorable="d" d:DesignWidth="860" d:DesignHeight="1200"
|
||||
x:Class="LanMountainDesktop.Views.SettingsPages.WeatherSettingsPage">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="StackPanel.weather-settings-root TextBlock.section-eyebrow">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.weather-settings-root Border.preview-icon-shell">
|
||||
<Setter Property="Width" Value="62" />
|
||||
<Setter Property="Height" Value="62" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="Padding" Value="10" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.weather-settings-root Border.settings-note-shell">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveSurfaceRaisedBrush}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource DesignCornerRadiusSm}" />
|
||||
<Setter Property="Padding" Value="14,12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.weather-settings-root Border.settings-expander-shell">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<StackPanel x:Name="WeatherSettingsContentPanel"
|
||||
Classes="settings-animated-intro weather-settings-root"
|
||||
Margin="0,0,8,0"
|
||||
Spacing="16">
|
||||
Spacing="12">
|
||||
<TextBlock x:Name="WeatherPanelTitleTextBlock"
|
||||
FontSize="24"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Foreground="{DynamicResource AdaptiveTextPrimaryBrush}"
|
||||
Text="Weather" />
|
||||
|
||||
<!-- Weather Preview Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
|
||||
Header="Weather Preview"
|
||||
Description="Refresh and verify current weather service status."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="WeatherSunny" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="WeatherPreviewButton"
|
||||
Padding="12,8"
|
||||
Content="Refresh" />
|
||||
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
|
||||
Width="20"
|
||||
Height="20"
|
||||
IsActive="True"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="Auto,*" ColumnSpacing="12">
|
||||
<Border Width="44"
|
||||
Height="44"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusXs}"
|
||||
BorderThickness="1"
|
||||
BorderBrush="{DynamicResource AdaptiveButtonBorderBrush}"
|
||||
Background="{DynamicResource AdaptiveButtonBackgroundBrush}">
|
||||
<fi:SymbolIcon x:Name="WeatherPreviewIconSymbol"
|
||||
Symbol="WeatherSunny"
|
||||
IconVariant="Regular"
|
||||
FontSize="22"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock x:Name="WeatherPreviewSectionTextBlock"
|
||||
Classes="section-eyebrow"
|
||||
Text="Weather Preview" />
|
||||
|
||||
<Border Classes="settings-expander-shell"
|
||||
Padding="18,16">
|
||||
<Grid RowDefinitions="Auto,Auto"
|
||||
RowSpacing="10">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
<Border Classes="preview-icon-shell">
|
||||
<Image x:Name="WeatherPreviewIconImage"
|
||||
Stretch="Uniform" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Spacing="2">
|
||||
Spacing="3">
|
||||
<TextBlock x:Name="WeatherPreviewTemperatureTextBlock"
|
||||
FontSize="22"
|
||||
FontSize="34"
|
||||
FontWeight="SemiBold"
|
||||
Text="--°" />
|
||||
Text="--" />
|
||||
<TextBlock x:Name="WeatherPreviewUpdatedTextBlock"
|
||||
FontSize="12"
|
||||
FontSize="13"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="-" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button x:Name="WeatherPreviewButton"
|
||||
Padding="16,8"
|
||||
Content="Refresh" />
|
||||
<ui:ProgressRing x:Name="WeatherPreviewProgressRing"
|
||||
Width="20"
|
||||
Height="20"
|
||||
IsActive="True"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<TextBlock x:Name="WeatherPreviewResultTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Grid.Row="1"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Use refresh to verify your weather configuration." />
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
|
||||
Height="1" />
|
||||
|
||||
<TextBlock x:Name="WeatherSettingsSectionTextBlock"
|
||||
Classes="section-eyebrow"
|
||||
Text="Settings" />
|
||||
|
||||
<!-- Location Source Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherLocationSettingsExpander"
|
||||
Header="Location Source"
|
||||
Description="Choose how weather widgets resolve location."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<fi:SymbolIconSource Symbol="Location" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ListBox x:Name="WeatherLocationModeChipListBox"
|
||||
Classes="settings-chip-list"
|
||||
HorizontalAlignment="Right"
|
||||
SelectionMode="Single">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -104,6 +129,39 @@
|
||||
</ListBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<Grid ColumnDefinitions="*,Auto"
|
||||
ColumnSpacing="18">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock x:Name="WeatherLocationSelectionTitleTextBlock"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
Text="City Selection" />
|
||||
<TextBlock x:Name="WeatherLocationSelectionDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Select the current city used for weather queries." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
MaxWidth="420"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="4">
|
||||
<TextBlock x:Name="WeatherLocationValueTextBlock"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
TextAlignment="Right"
|
||||
TextWrapping="Wrap"
|
||||
Text="No location selected" />
|
||||
<TextBlock x:Name="WeatherLocationStatusTextBlock"
|
||||
FontSize="12"
|
||||
TextAlignment="Right"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<ui:SettingsExpanderItem.Footer>
|
||||
<ToggleSwitch x:Name="WeatherAutoRefreshToggleSwitch"
|
||||
@@ -111,16 +169,18 @@
|
||||
</ui:SettingsExpanderItem.Footer>
|
||||
</ui:SettingsExpanderItem>
|
||||
|
||||
<!-- ComboBox hidden as in original -->
|
||||
<ComboBox x:Name="WeatherLocationModeComboBox"
|
||||
IsVisible="False">
|
||||
<ComboBoxItem x:Name="WeatherLocationModeCityItem" Tag="CitySearch" Content="City Search" />
|
||||
<ComboBoxItem x:Name="WeatherLocationModeCoordinatesItem" Tag="Coordinates" Content="Coordinates" />
|
||||
<ComboBoxItem x:Name="WeatherLocationModeCityItem"
|
||||
Tag="CitySearch"
|
||||
Content="City Search" />
|
||||
<ComboBoxItem x:Name="WeatherLocationModeCoordinatesItem"
|
||||
Tag="Coordinates"
|
||||
Content="Coordinates" />
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<!-- City Search Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherCitySearchSettingsExpander"
|
||||
Header="City Search"
|
||||
@@ -128,38 +188,42 @@
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Button x:Name="WeatherApplyCityButton"
|
||||
Padding="12,8"
|
||||
Padding="14,8"
|
||||
Content="Apply City" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
|
||||
<ui:SettingsExpanderItem Content="Advanced Filters">
|
||||
<StackPanel Spacing="10">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||
<ui:SettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto"
|
||||
ColumnSpacing="10">
|
||||
<TextBox x:Name="WeatherCitySearchTextBox"
|
||||
Watermark="e.g. Beijing" />
|
||||
<ui:ProgressRing x:Name="WeatherSearchProgressRing"
|
||||
Grid.Column="1"
|
||||
Width="24"
|
||||
Height="24"
|
||||
Width="22"
|
||||
Height="22"
|
||||
IsActive="True"
|
||||
IsVisible="False" />
|
||||
<Button x:Name="WeatherSearchButton"
|
||||
Grid.Column="2"
|
||||
Padding="12,8"
|
||||
Padding="14,8"
|
||||
Content="Search" />
|
||||
</Grid>
|
||||
|
||||
<ComboBox x:Name="WeatherCityResultsComboBox"
|
||||
Width="320" />
|
||||
HorizontalAlignment="Stretch"
|
||||
MinWidth="320" />
|
||||
|
||||
<TextBlock x:Name="WeatherSearchStatusTextBlock"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Search by city name and apply one location." />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<!-- Coordinates Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherCoordinateSettingsExpander"
|
||||
Header="Coordinates"
|
||||
@@ -168,13 +232,14 @@
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<Button x:Name="WeatherApplyCoordinatesButton"
|
||||
Padding="12,8"
|
||||
Padding="14,8"
|
||||
Content="Apply Coordinates" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
|
||||
<ui:SettingsExpanderItem>
|
||||
<StackPanel Spacing="12">
|
||||
<Grid ColumnDefinitions="*,*" ColumnSpacing="10">
|
||||
<Grid ColumnDefinitions="*,*"
|
||||
ColumnSpacing="10">
|
||||
<ui:NumberBox x:Name="WeatherLatitudeNumberBox"
|
||||
Grid.Column="0"
|
||||
Header="Latitude"
|
||||
@@ -194,65 +259,96 @@
|
||||
LargeChange="1"
|
||||
Value="116.4074" />
|
||||
</Grid>
|
||||
|
||||
<TextBox x:Name="WeatherLocationKeyTextBox"
|
||||
Watermark="Location key (optional)" />
|
||||
<TextBox x:Name="WeatherLocationNameTextBox"
|
||||
Watermark="Display name (optional)" />
|
||||
<TextBlock x:Name="WeatherCoordinateStatusTextBlock"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<!-- Excluded Alerts Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherAlertFilterSettingsExpander"
|
||||
Header="Excluded Alerts"
|
||||
Description="Alerts containing these words will not be shown. One rule per line."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpanderItem>
|
||||
<TextBox x:Name="WeatherExcludedAlertsTextBox"
|
||||
MinHeight="96"
|
||||
MaxHeight="220"
|
||||
Width="360"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="True" />
|
||||
<Grid ColumnDefinitions="Auto,*"
|
||||
ColumnSpacing="20">
|
||||
<StackPanel Width="220"
|
||||
Spacing="4">
|
||||
<TextBlock x:Name="WeatherAlertListTitleTextBlock"
|
||||
FontSize="17"
|
||||
FontWeight="SemiBold"
|
||||
Text="Exclude List" />
|
||||
<TextBlock x:Name="WeatherAlertListDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="One exclusion rule per line." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox x:Name="WeatherExcludedAlertsTextBox"
|
||||
Grid.Column="1"
|
||||
MinHeight="96"
|
||||
MaxHeight="220"
|
||||
HorizontalAlignment="Stretch"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Watermark="One keyword per line" />
|
||||
</Grid>
|
||||
</ui:SettingsExpanderItem>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<!-- Weather Style Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherIconPackSettingsExpander"
|
||||
Header="Weather Icon Style"
|
||||
Description="Choose Fluent Icon style for weather symbols."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ComboBox x:Name="WeatherIconPackComboBox"
|
||||
Width="240">
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentRegularItem" Tag="FluentRegular" Content="Fluent Regular" />
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentFilledItem" Tag="FluentFilled" Content="Fluent Filled" />
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<!-- No TLS Card -->
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherNoTlsSettingsExpander"
|
||||
Header="No TLS Weather Request"
|
||||
Description="Not recommended. Enable only for incompatible network environments."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ToggleSwitch x:Name="WeatherNoTlsToggleSwitch" />
|
||||
<ToggleSwitch x:Name="WeatherNoTlsToggleSwitch"
|
||||
Content="Allow non-TLS request fallback" />
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="WeatherLocationStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="No city location is configured." />
|
||||
<Border Classes="settings-note-shell">
|
||||
<TextBlock x:Name="WeatherFooterHintTextBlock"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="Desktop weather widgets will reuse the location and alert exclusion settings configured here." />
|
||||
</Border>
|
||||
|
||||
<Grid IsVisible="False">
|
||||
<ui:SettingsExpander x:Name="WeatherPreviewSettingsExpander"
|
||||
Header="Weather Preview"
|
||||
Description="Refresh and verify current weather service status." />
|
||||
<fi:SymbolIcon x:Name="WeatherPreviewIconSymbol"
|
||||
Symbol="WeatherSunny"
|
||||
IconVariant="Regular" />
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="WeatherIconPackSettingsExpander"
|
||||
Header="Weather Icon Style"
|
||||
Description="Choose Fluent Icon style for weather symbols.">
|
||||
<ui:SettingsExpander.Footer>
|
||||
<ComboBox x:Name="WeatherIconPackComboBox"
|
||||
Width="220">
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentRegularItem"
|
||||
Tag="FluentRegular"
|
||||
Content="Fluent Regular" />
|
||||
<ComboBoxItem x:Name="WeatherIconPackFluentFilledItem"
|
||||
Tag="FluentFilled"
|
||||
Content="Fluent Filled" />
|
||||
</ComboBox>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -126,6 +126,8 @@ public partial class SettingsWindow
|
||||
|
||||
// --- WeatherSettingsPage ---
|
||||
internal TextBlock WeatherPanelTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPanelTitleTextBlock")!;
|
||||
internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewSectionTextBlock")!;
|
||||
internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherSettingsSectionTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherPreviewSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherLocationSettingsExpander")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("WeatherCitySearchSettingsExpander")!;
|
||||
@@ -156,6 +158,7 @@ public partial class SettingsWindow
|
||||
internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.NumberBox>("WeatherLongitudeNumberBox")!;
|
||||
internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherCoordinateStatusTextBlock")!;
|
||||
internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewResultTextBlock")!;
|
||||
internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl<Image>("WeatherPreviewIconImage")!;
|
||||
internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl<FluentIcons.Avalonia.Fluent.SymbolIcon>("WeatherPreviewIconSymbol")!;
|
||||
internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewTemperatureTextBlock")!;
|
||||
internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherPreviewUpdatedTextBlock")!;
|
||||
@@ -163,7 +166,13 @@ public partial class SettingsWindow
|
||||
internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl<FluentAvalonia.UI.Controls.ProgressRing>("WeatherPreviewProgressRing")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentRegularItem")!;
|
||||
internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl<ComboBoxItem>("WeatherIconPackFluentFilledItem")!;
|
||||
internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionTitleTextBlock")!;
|
||||
internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationSelectionDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationValueTextBlock")!;
|
||||
internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherLocationStatusTextBlock")!;
|
||||
internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListTitleTextBlock")!;
|
||||
internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherAlertListDescriptionTextBlock")!;
|
||||
internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl<TextBlock>("WeatherFooterHintTextBlock")!;
|
||||
|
||||
// --- UpdateSettingsPage ---
|
||||
internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl<TextBlock>("UpdatePanelTitleTextBlock")!;
|
||||
@@ -192,6 +201,9 @@ public partial class SettingsWindow
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander AboutRenderModeSettingsExpander => AboutSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("AboutRenderModeSettingsExpander")!;
|
||||
internal ToggleSwitch AutoStartWithWindowsToggleSwitch => AboutSettingsPanel.FindControl<ToggleSwitch>("AutoStartWithWindowsToggleSwitch")!;
|
||||
internal ComboBox AppRenderModeComboBox => AboutSettingsPanel.FindControl<ComboBox>("AppRenderModeComboBox")!;
|
||||
internal TextBlock CurrentRenderBackendLabelTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendLabelTextBlock")!;
|
||||
internal TextBlock CurrentRenderBackendValueTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendValueTextBlock")!;
|
||||
internal TextBlock CurrentRenderBackendImplementationTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CurrentRenderBackendImplementationTextBlock")!;
|
||||
internal TextBlock VersionTextBlock => AboutSettingsPanel.FindControl<TextBlock>("VersionTextBlock")!;
|
||||
internal TextBlock CodeNameTextBlock => AboutSettingsPanel.FindControl<TextBlock>("CodeNameTextBlock")!;
|
||||
internal TextBlock FontInfoTextBlock => AboutSettingsPanel.FindControl<TextBlock>("FontInfoTextBlock")!;
|
||||
@@ -203,13 +215,5 @@ public partial class SettingsWindow
|
||||
internal TextBlock LauncherHiddenItemsEmptyTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsEmptyTextBlock")!;
|
||||
internal TextBlock LauncherHiddenItemsDescriptionTextBlock => LauncherSettingsPanel.FindControl<TextBlock>("LauncherHiddenItemsDescriptionTextBlock")!;
|
||||
|
||||
// --- PluginSettingsPage (Added for completeness) ---
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using FluentIcons.Avalonia.Fluent;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
@@ -43,13 +44,170 @@ public partial class SettingsWindow
|
||||
}
|
||||
|
||||
_launcherIconCache.Clear();
|
||||
PendingRestartStateService.StateChanged -= OnPendingRestartStateChanged;
|
||||
base.OnClosed(e);
|
||||
}
|
||||
|
||||
private void OnSettingsNavSelectionChanged(object? sender, FluentAvalonia.UI.Controls.NavigationViewSelectionChangedEventArgs e)
|
||||
private void InitializeSettingsNavigation()
|
||||
{
|
||||
_settingsNavItems.Clear();
|
||||
_pluginSettingsNavItems.Clear();
|
||||
|
||||
SettingsPrimaryNavHost.Children.Clear();
|
||||
SettingsSecondaryNavHost.Children.Clear();
|
||||
SettingsPluginNavHost.Children.Clear();
|
||||
SettingsPluginNavSection.IsVisible = false;
|
||||
|
||||
AddSettingsNavItem(SettingsPrimaryNavHost, "Wallpaper", Symbol.Wallpaper, "Wallpaper");
|
||||
AddSettingsNavItem(SettingsPrimaryNavHost, "Grid", Symbol.Grid, "Grid");
|
||||
AddSettingsNavItem(SettingsPrimaryNavHost, "Color", Symbol.Color, "Color");
|
||||
AddSettingsNavItem(SettingsPrimaryNavHost, "StatusBar", Symbol.Status, "Status Bar");
|
||||
AddSettingsNavItem(SettingsPrimaryNavHost, "Weather", Symbol.WeatherSunny, "Weather");
|
||||
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Region", Symbol.Globe, "Region");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Launcher", Symbol.Apps, "App Launcher");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Update", Symbol.ArrowSync, "Update");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "About", Symbol.Info, "About");
|
||||
AddSettingsNavItem(SettingsSecondaryNavHost, "Plugins", Symbol.PuzzlePiece, "Plugins");
|
||||
}
|
||||
|
||||
private void OnSettingsNavItemClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button button || button.Tag is not string tag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SelectSettingsTab(tag, persistSelection: true);
|
||||
}
|
||||
|
||||
private Button AddSettingsNavItem(Panel host, string tag, Symbol symbol, string title)
|
||||
{
|
||||
var button = CreateSettingsNavItem(tag, symbol, title);
|
||||
host.Children.Add(button);
|
||||
_settingsNavItems[tag] = button;
|
||||
return button;
|
||||
}
|
||||
|
||||
private Button CreateSettingsNavItem(string tag, Symbol symbol, string title)
|
||||
{
|
||||
var icon = new SymbolIcon
|
||||
{
|
||||
Symbol = symbol,
|
||||
IconVariant = IconVariant.Regular
|
||||
};
|
||||
icon.Classes.Add("settings-nav-icon");
|
||||
|
||||
var iconShell = new Border
|
||||
{
|
||||
Child = icon,
|
||||
Classes = { "settings-sidebar-icon-shell" }
|
||||
};
|
||||
|
||||
var label = new TextBlock
|
||||
{
|
||||
Text = title,
|
||||
Classes = { "settings-nav-label" }
|
||||
};
|
||||
|
||||
var contentGrid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
|
||||
ColumnSpacing = 12
|
||||
};
|
||||
contentGrid.Children.Add(iconShell);
|
||||
contentGrid.Children.Add(label);
|
||||
Grid.SetColumn(label, 1);
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Tag = tag,
|
||||
Content = contentGrid,
|
||||
Classes = { "settings-sidebar-item" }
|
||||
};
|
||||
button.Click += OnSettingsNavItemClick;
|
||||
return button;
|
||||
}
|
||||
|
||||
private IEnumerable<Button> EnumerateSettingsNavItems()
|
||||
{
|
||||
foreach (var button in SettingsPrimaryNavHost.Children.OfType<Button>())
|
||||
{
|
||||
yield return button;
|
||||
}
|
||||
|
||||
foreach (var button in SettingsSecondaryNavHost.Children.OfType<Button>())
|
||||
{
|
||||
yield return button;
|
||||
}
|
||||
|
||||
foreach (var button in SettingsPluginNavHost.Children.OfType<Button>())
|
||||
{
|
||||
yield return button;
|
||||
}
|
||||
}
|
||||
|
||||
private Button? GetSettingsNavItem(string tag)
|
||||
{
|
||||
if (_settingsNavItems.TryGetValue(tag, out var builtIn))
|
||||
{
|
||||
return builtIn;
|
||||
}
|
||||
|
||||
return _pluginSettingsNavItems.GetValueOrDefault(tag);
|
||||
}
|
||||
|
||||
private static void SetSettingsNavItemLabel(Button? button, string text)
|
||||
{
|
||||
if (button?.Content is Grid grid)
|
||||
{
|
||||
var label = grid.Children
|
||||
.OfType<TextBlock>()
|
||||
.FirstOrDefault(textBlock => textBlock.Classes.Contains("settings-nav-label"));
|
||||
|
||||
if (label is not null)
|
||||
{
|
||||
label.Text = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectSettingsTab(string? tag, bool persistSelection)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tag))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedButton = GetSettingsNavItem(tag);
|
||||
if (selectedButton is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_selectedSettingsTabTag = tag;
|
||||
foreach (var button in EnumerateSettingsNavItems())
|
||||
{
|
||||
var isSelected = ReferenceEquals(button, selectedButton);
|
||||
if (isSelected)
|
||||
{
|
||||
if (!button.Classes.Contains("nav-selected"))
|
||||
{
|
||||
button.Classes.Add("nav-selected");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
button.Classes.Remove("nav-selected");
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSettingsTabContent();
|
||||
PersistSettings();
|
||||
|
||||
if (persistSelection)
|
||||
{
|
||||
PersistSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private int GetSettingsTabIndex()
|
||||
@@ -59,13 +217,7 @@ public partial class SettingsWindow
|
||||
|
||||
private void UpdateSettingsTabContent()
|
||||
{
|
||||
if (SettingsNavView is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedItem = SettingsNavView.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
|
||||
var tag = selectedItem?.Tag?.ToString();
|
||||
var tag = GetSelectedSettingsTabTag();
|
||||
|
||||
WallpaperSettingsPanel.IsVisible = tag == "Wallpaper";
|
||||
GridSettingsPanel.IsVisible = tag == "Grid";
|
||||
@@ -277,6 +429,11 @@ public partial class SettingsWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (radioButton.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_clockDisplayFormat = formatTag == "Hm"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
@@ -373,8 +530,7 @@ public partial class SettingsWindow
|
||||
|
||||
private TaskbarContext GetCurrentTaskbarContext()
|
||||
{
|
||||
var selectedItem = SettingsNavView?.SelectedItem as FluentAvalonia.UI.Controls.NavigationViewItem;
|
||||
return selectedItem?.Tag?.ToString() switch
|
||||
return GetSelectedSettingsTabTag() switch
|
||||
{
|
||||
"Wallpaper" => TaskbarContext.SettingsWallpaper,
|
||||
"Grid" => TaskbarContext.SettingsGrid,
|
||||
|
||||
@@ -83,11 +83,6 @@ public partial class SettingsWindow
|
||||
|
||||
private void OnGridSpacingPresetSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (_suppressGridSpacingEvents)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
UpdateGridPreviewLayout();
|
||||
}
|
||||
|
||||
|
||||
@@ -48,20 +48,32 @@ public partial class SettingsWindow
|
||||
|
||||
private void ApplyLocalization()
|
||||
{
|
||||
Title = L("settings.title", "Settings");
|
||||
WindowTitleTextBlock.Text = L("settings.title", "Settings");
|
||||
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
|
||||
Title = L("settings.shell.title", "Application Settings");
|
||||
WindowTitleTextBlock.Text = L("settings.shell.title", "Application Settings");
|
||||
WindowSubtitleTextBlock.Text = L("settings.shell.subtitle", "LanMountainDesktop standalone preferences");
|
||||
WindowVersionBadgeTextBlock.Text = GetAppVersionText();
|
||||
WindowCodeNameBadgeTextBlock.Text = AppCodeName;
|
||||
SettingsSidebarTitleTextBlock.Text = L("settings.nav_header", "Settings");
|
||||
SettingsSidebarHintTextBlock.Text = L(
|
||||
"settings.shell.sidebar_hint",
|
||||
"Choose a category to adjust application behavior and desktop appearance.");
|
||||
SettingsPrimaryGroupTextBlock.Text = L("settings.nav.group_desktop", "Desktop");
|
||||
SettingsSecondaryGroupTextBlock.Text = L("settings.nav.group_system", "System");
|
||||
SettingsPluginGroupTextBlock.Text = L("settings.nav.group_extensions", "Extensions");
|
||||
SettingsSidebarFooterTextBlock.Text = L(
|
||||
"settings.shell.footer_hint",
|
||||
"Tray-opened settings are managed in this standalone window.");
|
||||
|
||||
SettingsNavWallpaperItem.Content = L("settings.nav.wallpaper", "Wallpaper");
|
||||
SettingsNavGridItem.Content = L("settings.nav.grid", "Grid");
|
||||
SettingsNavColorItem.Content = L("settings.nav.color", "Color");
|
||||
SettingsNavStatusBarItem.Content = L("settings.nav.status_bar", "Status Bar");
|
||||
SettingsNavWeatherItem.Content = L("settings.nav.weather", "Weather");
|
||||
SettingsNavRegionItem.Content = L("settings.nav.region", "Region");
|
||||
SettingsNavUpdateItem.Content = L("settings.nav.update", "Update");
|
||||
SettingsNavAboutItem.Content = L("settings.nav.about", "About");
|
||||
SettingsNavLauncherItem.Content = L("settings.nav.launcher", "App Launcher");
|
||||
SettingsNavPluginsItem.Content = L("settings.nav.plugins", "Plugins");
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Wallpaper"), L("settings.nav.wallpaper", "Wallpaper"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Grid"), L("settings.nav.grid", "Grid"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Color"), L("settings.nav.color", "Color"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("StatusBar"), L("settings.nav.status_bar", "Status Bar"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Weather"), L("settings.nav.weather", "Weather"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Region"), L("settings.nav.region", "Region"));
|
||||
SetSettingsNavItemLabel(GetSettingsNavItem("Update"), L("settings.nav.update", "Update"));
|
||||
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"));
|
||||
|
||||
WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper");
|
||||
WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement");
|
||||
@@ -96,6 +108,60 @@ public partial class SettingsWindow
|
||||
StatusBarSpacingModeCustomItem.Content = L("settings.status_bar.spacing_mode_custom", "Custom");
|
||||
StatusBarSpacingCustomPanel.Content = L("settings.status_bar.spacing_custom_label", "Custom spacing (%)");
|
||||
|
||||
WeatherPanelTitleTextBlock.Text = L("settings.weather.title", "Weather");
|
||||
WeatherPreviewSectionTextBlock.Text = L("settings.weather.preview_section", "Weather Preview");
|
||||
WeatherSettingsSectionTextBlock.Text = L("settings.weather.settings_section", "Settings");
|
||||
WeatherPreviewButton.Content = L("settings.weather.refresh_button", "Refresh");
|
||||
WeatherPreviewResultTextBlock.Text = L("settings.weather.preview_hint", "Use refresh to verify your weather configuration.");
|
||||
WeatherLocationSettingsExpander.Header = L("settings.weather.location_source_header", "Location Source");
|
||||
WeatherLocationSettingsExpander.Description = L(
|
||||
"settings.weather.location_source_desc",
|
||||
"Choose how weather widgets resolve location.");
|
||||
WeatherLocationModeCityItem.Content = L("settings.weather.mode_city_search", "City Search");
|
||||
WeatherLocationModeCoordinatesItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
|
||||
WeatherLocationModeCityChipItem.Content = L("settings.weather.mode_city_search", "City Search");
|
||||
WeatherLocationModeCoordinatesChipItem.Content = L("settings.weather.mode_coordinates", "Coordinates");
|
||||
WeatherAutoRefreshToggleSwitch.Content = L("settings.weather.auto_refresh", "Auto refresh location on startup");
|
||||
WeatherCitySearchSettingsExpander.Header = L("settings.weather.city_search_header", "City Search");
|
||||
WeatherCitySearchSettingsExpander.Description = L(
|
||||
"settings.weather.city_search_desc",
|
||||
"Search cities and apply one weather location.");
|
||||
WeatherCitySearchTextBox.Watermark = L("settings.weather.search_placeholder", "e.g. Beijing");
|
||||
WeatherSearchButton.Content = L("settings.weather.search_button", "Search");
|
||||
WeatherApplyCityButton.Content = L("settings.weather.apply_city_button", "Apply City");
|
||||
WeatherSearchStatusTextBlock.Text = L("settings.weather.search_hint", "Search by city name and apply one location.");
|
||||
WeatherCoordinateSettingsExpander.Header = L("settings.weather.coordinates_header", "Coordinates");
|
||||
WeatherCoordinateSettingsExpander.Description = L(
|
||||
"settings.weather.coordinates_desc",
|
||||
"Set latitude/longitude and optional key/name.");
|
||||
WeatherLatitudeNumberBox.Header = L("settings.weather.latitude_label", "Latitude");
|
||||
WeatherLongitudeNumberBox.Header = L("settings.weather.longitude_label", "Longitude");
|
||||
WeatherLocationKeyTextBox.Watermark = L("settings.weather.location_key_placeholder", "Location key (optional)");
|
||||
WeatherLocationNameTextBox.Watermark = L("settings.weather.location_name_placeholder", "Display name (optional)");
|
||||
WeatherApplyCoordinatesButton.Content = L("settings.weather.apply_coordinates_button", "Apply Coordinates");
|
||||
WeatherAlertFilterSettingsExpander.Header = L("settings.weather.alert_filter_header", "Excluded Alerts");
|
||||
WeatherAlertFilterSettingsExpander.Description = L(
|
||||
"settings.weather.alert_filter_desc",
|
||||
"Alerts containing these words will not be shown. One rule per line.");
|
||||
WeatherAlertListTitleTextBlock.Text = L("settings.weather.alert_list_label", "Exclude List");
|
||||
WeatherAlertListDescriptionTextBlock.Text = L("settings.weather.alert_list_desc", "One exclusion rule per line.");
|
||||
WeatherExcludedAlertsTextBox.Watermark = L("settings.weather.alert_filter_placeholder", "One keyword per line");
|
||||
WeatherNoTlsSettingsExpander.Header = L("settings.weather.no_tls_header", "No TLS Weather Request");
|
||||
WeatherNoTlsSettingsExpander.Description = L(
|
||||
"settings.weather.no_tls_desc",
|
||||
"Not recommended. Enable only for incompatible network environments.");
|
||||
WeatherNoTlsToggleSwitch.Content = L("settings.weather.no_tls_toggle", "Allow non-TLS request fallback");
|
||||
WeatherFooterHintTextBlock.Text = L(
|
||||
"settings.weather.footer_hint",
|
||||
"Desktop weather widgets will reuse the location and alert exclusion settings configured here.");
|
||||
WeatherIconPackSettingsExpander.Header = L("settings.weather.icon_style_header", "Weather Icon Style");
|
||||
WeatherIconPackSettingsExpander.Description = L(
|
||||
"settings.weather.icon_style_desc",
|
||||
"Choose Fluent Icon style for weather symbols.");
|
||||
WeatherIconPackFluentRegularItem.Content = L("settings.weather.icon_style_fluent_regular", "Fluent Regular");
|
||||
WeatherIconPackFluentFilledItem.Content = L("settings.weather.icon_style_fluent_filled", "Fluent Filled");
|
||||
UpdateWeatherLocationStatusText();
|
||||
|
||||
RegionPanelTitleTextBlock.Text = L("settings.region.title", "Region");
|
||||
LanguageSettingsExpander.Header = L("settings.region.language_header", "Language");
|
||||
LanguageSettingsExpander.Description = L("settings.region.language_desc", "Select application language. Changes apply immediately.");
|
||||
@@ -110,16 +176,7 @@ public partial class SettingsWindow
|
||||
LauncherHiddenItemsDescriptionTextBlock.Text = L("settings.launcher.hidden_hint", "Right-click an icon in launcher to hide it. Hidden entries appear here.");
|
||||
LauncherHiddenItemsEmptyTextBlock.Text = L("settings.launcher.hidden_empty", "No hidden items.");
|
||||
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
ApplyPluginSettingsLocalization();
|
||||
|
||||
AboutPanelTitleTextBlock.Text = L("settings.about.title", "About");
|
||||
VersionTextBlock.Text = Lf("settings.about.version_format", "Version: {0}", GetAppVersionText());
|
||||
@@ -136,6 +193,8 @@ public partial class SettingsWindow
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.AngleEgl, L("settings.about.render_mode.angle_egl", "angleEgl"));
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Wgl, L("settings.about.render_mode.wgl", "WGL"));
|
||||
SetAppRenderModeComboItemContent(AppRenderingModeHelper.Vulkan, L("settings.about.render_mode.vulkan", "Vulkan"));
|
||||
UpdateCurrentRenderBackendStatus();
|
||||
UpdatePendingRestartDock();
|
||||
|
||||
var placementItems = WallpaperPlacementComboBox.Items.OfType<ComboBoxItem>().ToList();
|
||||
if (placementItems.Count >= 5)
|
||||
|
||||
42
LanMountainDesktop/Views/SettingsWindow.RenderBackend.cs
Normal file
42
LanMountainDesktop/Views/SettingsWindow.RenderBackend.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
private void UpdateCurrentRenderBackendStatus()
|
||||
{
|
||||
var backendInfo = AppRenderBackendDiagnostics.Detect();
|
||||
var localizedBackend = GetLocalizedRenderBackendName(backendInfo.ActualBackend);
|
||||
|
||||
CurrentRenderBackendLabelTextBlock.Text = L(
|
||||
"settings.about.render_mode.current_label",
|
||||
"Current actual backend");
|
||||
CurrentRenderBackendValueTextBlock.Text = Lf(
|
||||
"settings.about.render_mode.current_format",
|
||||
"Current backend: {0}",
|
||||
localizedBackend);
|
||||
CurrentRenderBackendImplementationTextBlock.Text = string.IsNullOrWhiteSpace(backendInfo.ImplementationTypeName)
|
||||
? L(
|
||||
"settings.about.render_mode.impl_unavailable",
|
||||
"Runtime implementation is unavailable.")
|
||||
: Lf(
|
||||
"settings.about.render_mode.impl_format",
|
||||
"Runtime implementation: {0}",
|
||||
backendInfo.ImplementationTypeName);
|
||||
}
|
||||
|
||||
private string GetLocalizedRenderBackendName(string renderBackend)
|
||||
{
|
||||
return renderBackend switch
|
||||
{
|
||||
AppRenderingModeHelper.Default => L("settings.about.render_mode.default", "Default"),
|
||||
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
|
||||
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
|
||||
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
|
||||
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
|
||||
_ => L("settings.about.render_mode.unknown", "Unknown")
|
||||
};
|
||||
}
|
||||
}
|
||||
112
LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs
Normal file
112
LanMountainDesktop/Views/SettingsWindow.RestartPrompt.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
private bool _isRestartPromptVisible;
|
||||
|
||||
private void OnPendingRestartStateChanged()
|
||||
{
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(UpdatePendingRestartDock);
|
||||
}
|
||||
|
||||
private void UpdatePendingRestartDock()
|
||||
{
|
||||
PendingRestartDock.IsVisible = PendingRestartStateService.HasPendingRestart;
|
||||
PendingRestartDockTitleTextBlock.Text = L("settings.restart_dock.title", "Restart required");
|
||||
PendingRestartDockDescriptionTextBlock.Text = L(
|
||||
"settings.restart_dock.description",
|
||||
"Some changes will take effect after restarting the app.");
|
||||
PendingRestartDockButtonTextBlock.Text = L("settings.restart_dock.button", "Restart app");
|
||||
}
|
||||
|
||||
private async void OnPendingRestartDockButtonClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
await ShowGenericRestartPromptAsync();
|
||||
}
|
||||
|
||||
private Task ShowRenderModeRestartPromptAsync(string selectedMode)
|
||||
{
|
||||
var message = Lf(
|
||||
"settings.restart_dialog.render_mode_message",
|
||||
"Restart the app to switch the rendering mode from \"{0}\" to \"{1}\". Restart now?",
|
||||
GetLocalizedAppRenderModeDisplayName(_runningAppRenderMode),
|
||||
GetLocalizedAppRenderModeDisplayName(selectedMode));
|
||||
|
||||
return ShowRestartPromptCoreAsync(message);
|
||||
}
|
||||
|
||||
private Task ShowGenericRestartPromptAsync()
|
||||
{
|
||||
return ShowRestartPromptCoreAsync(L(
|
||||
"settings.restart_dock.description",
|
||||
"Some changes will take effect after restarting the app."));
|
||||
}
|
||||
|
||||
private async Task ShowRestartPromptCoreAsync(string message)
|
||||
{
|
||||
if (_isRestartPromptVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isRestartPromptVisible = true;
|
||||
|
||||
try
|
||||
{
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
Title = L("settings.restart_dialog.title", "Restart required"),
|
||||
Content = message,
|
||||
PrimaryButtonText = L("settings.restart_dialog.restart", "Restart now"),
|
||||
CloseButtonText = L("settings.restart_dialog.cancel", "Cancel"),
|
||||
DefaultButton = ContentDialogButton.Primary
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync(this);
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
if (!AppRestartService.TryRestartApplication())
|
||||
{
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
UpdatePendingRestartDock();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isRestartPromptVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private string GetLocalizedAppRenderModeDisplayName(string renderMode)
|
||||
{
|
||||
if (renderMode == AppRenderBackendDiagnostics.Unknown)
|
||||
{
|
||||
return L("settings.about.render_mode.unknown", "Unknown");
|
||||
}
|
||||
|
||||
return AppRenderingModeHelper.Normalize(renderMode) switch
|
||||
{
|
||||
AppRenderingModeHelper.Software => L("settings.about.render_mode.software", "Software"),
|
||||
AppRenderingModeHelper.AngleEgl => L("settings.about.render_mode.angle_egl", "angleEgl"),
|
||||
AppRenderingModeHelper.Wgl => L("settings.about.render_mode.wgl", "WGL"),
|
||||
AppRenderingModeHelper.Vulkan => L("settings.about.render_mode.vulkan", "Vulkan"),
|
||||
_ => L("settings.about.render_mode.default", "Default")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ using Avalonia.Threading;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -92,12 +93,16 @@ public partial class SettingsWindow
|
||||
private void InitializeAppRenderModeSetting(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
_selectedAppRenderMode = AppRenderingModeHelper.Normalize(snapshot.AppRenderMode);
|
||||
_runningAppRenderMode = ResolveActiveAppRenderModeForUi(_selectedAppRenderMode);
|
||||
var renderModeForUi = PendingRestartStateService.HasPendingReason(PendingRestartStateService.RenderModeReason)
|
||||
? _selectedAppRenderMode
|
||||
: _runningAppRenderMode;
|
||||
|
||||
_suppressAppRenderModeSelectionEvents = true;
|
||||
try
|
||||
{
|
||||
AppRenderModeComboBox.IsEnabled = OperatingSystem.IsWindows();
|
||||
SelectAppRenderModeInUi(_selectedAppRenderMode);
|
||||
SelectAppRenderModeInUi(renderModeForUi);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -107,13 +112,27 @@ public partial class SettingsWindow
|
||||
|
||||
private void SelectAppRenderModeInUi(string renderMode)
|
||||
{
|
||||
var selectedItem = AppRenderModeComboBox.Items
|
||||
.OfType<ComboBoxItem>()
|
||||
.FirstOrDefault(item =>
|
||||
string.Equals(item.Tag?.ToString(), renderMode, StringComparison.OrdinalIgnoreCase));
|
||||
AppRenderModeComboBox.SelectedIndex = GetAppRenderModeComboBoxIndex(renderMode);
|
||||
}
|
||||
|
||||
AppRenderModeComboBox.SelectedItem = selectedItem
|
||||
?? AppRenderModeComboBox.Items.OfType<ComboBoxItem>().FirstOrDefault();
|
||||
private static int GetAppRenderModeComboBoxIndex(string renderMode)
|
||||
{
|
||||
return AppRenderingModeHelper.Normalize(renderMode) switch
|
||||
{
|
||||
AppRenderingModeHelper.Software => 1,
|
||||
AppRenderingModeHelper.AngleEgl => 2,
|
||||
AppRenderingModeHelper.Wgl => 3,
|
||||
AppRenderingModeHelper.Vulkan => 4,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveActiveAppRenderModeForUi(string configuredRenderMode)
|
||||
{
|
||||
var detectedRenderMode = AppRenderBackendDiagnostics.Detect().ActualBackend;
|
||||
return string.Equals(detectedRenderMode, AppRenderBackendDiagnostics.Unknown, StringComparison.Ordinal)
|
||||
? configuredRenderMode
|
||||
: AppRenderingModeHelper.Normalize(detectedRenderMode);
|
||||
}
|
||||
|
||||
private static WeatherLocationMode ParseWeatherLocationMode(string? value)
|
||||
@@ -207,6 +226,7 @@ public partial class SettingsWindow
|
||||
{
|
||||
WeatherCitySearchSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.CitySearch;
|
||||
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
|
||||
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -354,7 +374,7 @@ public partial class SettingsWindow
|
||||
}
|
||||
|
||||
var selectedMode = AppRenderingModeHelper.Normalize(
|
||||
(AppRenderModeComboBox.SelectedItem as ComboBoxItem)?.Tag?.ToString());
|
||||
TryGetSelectedComboBoxTag(AppRenderModeComboBox));
|
||||
|
||||
if (string.Equals(_selectedAppRenderMode, selectedMode, StringComparison.Ordinal))
|
||||
{
|
||||
@@ -363,6 +383,14 @@ public partial class SettingsWindow
|
||||
|
||||
_selectedAppRenderMode = selectedMode;
|
||||
PersistSettings();
|
||||
var requiresRestart = !string.Equals(_runningAppRenderMode, selectedMode, StringComparison.Ordinal);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.RenderModeReason, requiresRestart);
|
||||
UpdatePendingRestartDock();
|
||||
|
||||
if (requiresRestart)
|
||||
{
|
||||
_ = ShowRenderModeRestartPromptAsync(selectedMode);
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnSearchWeatherCityClick(object? sender, RoutedEventArgs e)
|
||||
@@ -558,7 +586,7 @@ public partial class SettingsWindow
|
||||
: snapshot.LocationName;
|
||||
var weather = snapshot.Current.WeatherText ?? L("settings.weather.preview_unknown", "Unknown");
|
||||
var temperature = snapshot.Current.TemperatureC.HasValue
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{snapshot.Current.TemperatureC.Value:F1} C")
|
||||
? FormatWeatherPreviewTemperature(snapshot.Current.TemperatureC.Value)
|
||||
: "--";
|
||||
var updatedAt = snapshot.ObservationTime ?? snapshot.FetchedAt;
|
||||
|
||||
@@ -579,16 +607,25 @@ public partial class SettingsWindow
|
||||
|
||||
private void UpdateWeatherPreviewSummary(int? weatherCode, string temperatureText, DateTimeOffset? updatedAt)
|
||||
{
|
||||
var kind = HyperOS3WeatherTheme.ResolveVisualKind(weatherCode, _isNightMode);
|
||||
WeatherPreviewIconImage.Source = HyperOS3WeatherAssetLoader.LoadImage(
|
||||
HyperOS3WeatherTheme.ResolveIconAsset(kind)) ??
|
||||
HyperOS3WeatherAssetLoader.LoadImage(HyperOS3WeatherTheme.ResolveHeroIconAsset(kind));
|
||||
WeatherPreviewIconSymbol.Symbol = ResolveWeatherPreviewSymbol(weatherCode, _isNightMode);
|
||||
WeatherPreviewIconSymbol.IconVariant = string.Equals(_weatherIconPackId, "FluentFilled", StringComparison.OrdinalIgnoreCase)
|
||||
? IconVariant.Filled
|
||||
: IconVariant.Regular;
|
||||
WeatherPreviewTemperatureTextBlock.Text = string.IsNullOrWhiteSpace(temperatureText) ? "--" : temperatureText;
|
||||
WeatherPreviewUpdatedTextBlock.Text = updatedAt.HasValue
|
||||
? Lf("weather.widget.updated_format", "Updated {0:HH:mm}", updatedAt.Value.LocalDateTime)
|
||||
? updatedAt.Value.LocalDateTime.ToString("yyyy/M/d HH:mm:ss", CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string FormatWeatherPreviewTemperature(double temperatureC)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{temperatureC:0.#}°C");
|
||||
}
|
||||
|
||||
private static Symbol ResolveWeatherPreviewSymbol(int? weatherCode, bool isNight)
|
||||
{
|
||||
return weatherCode switch
|
||||
@@ -632,11 +669,13 @@ public partial class SettingsWindow
|
||||
if (string.IsNullOrWhiteSpace(_weatherLocationKey))
|
||||
{
|
||||
WeatherLocationStatusTextBlock.Text = L("settings.weather.status_city_empty", "No city location is configured.");
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
var locationName = string.IsNullOrWhiteSpace(_weatherLocationName) ? _weatherLocationKey : _weatherLocationName;
|
||||
WeatherLocationStatusTextBlock.Text = Lf("settings.weather.status_city_format", "Mode: {0} | {1} | Key: {2}", modeText, locationName, _weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -647,6 +686,34 @@ public partial class SettingsWindow
|
||||
_weatherLatitude,
|
||||
_weatherLongitude,
|
||||
string.IsNullOrWhiteSpace(_weatherLocationKey) ? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude) : _weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
|
||||
private void UpdateWeatherLocationSummaryCard()
|
||||
{
|
||||
if (_weatherLocationMode == WeatherLocationMode.Coordinates)
|
||||
{
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.coordinates_selection_label", "Coordinate Location");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_coordinates_summary_desc",
|
||||
"Set latitude/longitude and optional location name used for weather queries.");
|
||||
|
||||
var locationName = string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{_weatherLatitude:F4}, {_weatherLongitude:F4}")
|
||||
: _weatherLocationName;
|
||||
WeatherLocationValueTextBlock.Text = locationName;
|
||||
return;
|
||||
}
|
||||
|
||||
WeatherLocationSelectionTitleTextBlock.Text = L("settings.weather.city_selection_label", "City Selection");
|
||||
WeatherLocationSelectionDescriptionTextBlock.Text = L(
|
||||
"settings.weather.location_city_summary_desc",
|
||||
"Select the current city used for weather queries.");
|
||||
WeatherLocationValueTextBlock.Text = !string.IsNullOrWhiteSpace(_weatherLocationName)
|
||||
? _weatherLocationName
|
||||
: !string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? _weatherLocationKey
|
||||
: L("settings.weather.location_not_selected", "No location selected");
|
||||
}
|
||||
|
||||
private void InitializeLauncherVisibilitySettings(LauncherSettingsSnapshot snapshot)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:fi="using:FluentIcons.Avalonia"
|
||||
xmlns:ic="using:FluentIcons.Avalonia.Fluent"
|
||||
xmlns:pages="using:LanMountainDesktop.Views.SettingsPages"
|
||||
@@ -8,16 +7,94 @@
|
||||
x:Class="LanMountainDesktop.Views.SettingsWindow"
|
||||
Title="Settings"
|
||||
Icon="/Assets/avalonia-logo.ico"
|
||||
Width="1360"
|
||||
Height="900"
|
||||
MinWidth="1120"
|
||||
MinHeight="760"
|
||||
Width="1520"
|
||||
Height="960"
|
||||
MinWidth="1240"
|
||||
MinHeight="820"
|
||||
ShowInTaskbar="True"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaChromeHints="SystemChrome"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}">
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="Border.settings-shell-card">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveGlassPanelBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveGlassPanelBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="28" />
|
||||
<Setter Property="BoxShadow" Value="0 10 28 #12000000" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.settings-shell-eyebrow">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.settings-shell-hint">
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="18" />
|
||||
<Setter Property="Padding" Value="14,12" />
|
||||
<Setter Property="Margin" Value="0,0,0,8" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
<BrushTransition Property="BorderBrush" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="{StaticResource FluttermotionToken.Duration.Fast}" Easing="0.22,1,0.36,1" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonHoverBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="RenderTransform" Value="scale(1.01)" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="StackPanel.settings-sidebar-host Button.settings-sidebar-item.nav-selected">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveNavItemSelectedBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.settings-sidebar-icon-shell">
|
||||
<Setter Property="Width" Value="34" />
|
||||
<Setter Property="Height" Value="34" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveButtonBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.settings-sidebar-item.nav-selected Border.settings-sidebar-icon-shell">
|
||||
<Setter Property="Background" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AdaptiveAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.settings-nav-label">
|
||||
<Setter Property="FontSize" Value="16" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ic|SymbolIcon.settings-nav-icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Setter Property="FontSize" Value="18" />
|
||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.settings-sidebar-item.nav-selected ic|SymbolIcon.settings-nav-icon">
|
||||
<Setter Property="Foreground" Value="White" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<Grid x:Name="DesktopHost">
|
||||
<Border x:Name="DesktopWallpaperLayer"
|
||||
Background="{DynamicResource AdaptiveSurfaceBaseBrush}" />
|
||||
@@ -27,118 +104,142 @@
|
||||
IsVisible="True"
|
||||
Opacity="1"
|
||||
Margin="20">
|
||||
<Border x:Name="SettingsContentPanel"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Margin="0"
|
||||
Padding="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<Border Grid.Row="0"
|
||||
Classes="mica-strong"
|
||||
CornerRadius="24,24,0,0"
|
||||
Padding="20,16">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Width="40"
|
||||
Height="40"
|
||||
CornerRadius="20"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="Settings"
|
||||
IconVariant="Regular"
|
||||
Foreground="White"
|
||||
FontSize="18"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Margin="14,0,0,0"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="WindowTitleTextBlock"
|
||||
FontSize="24"
|
||||
<Grid x:Name="SettingsContentPanel"
|
||||
RowDefinitions="Auto,*"
|
||||
RowSpacing="18">
|
||||
<Border Grid.Row="0"
|
||||
Classes="settings-shell-card"
|
||||
Padding="20,18">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="18">
|
||||
<Border Width="52"
|
||||
Height="52"
|
||||
CornerRadius="18"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<TextBlock Text="LMD"
|
||||
FontSize="16"
|
||||
FontWeight="Bold"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="3"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="WindowTitleTextBlock"
|
||||
FontSize="28"
|
||||
FontWeight="SemiBold"
|
||||
Text="Application Settings" />
|
||||
<TextBlock x:Name="WindowSubtitleTextBlock"
|
||||
Classes="settings-shell-hint"
|
||||
Text="LanMountainDesktop" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
VerticalAlignment="Center">
|
||||
<Border Classes="settings-shell-card"
|
||||
Padding="12,8"
|
||||
CornerRadius="18">
|
||||
<TextBlock x:Name="WindowVersionBadgeTextBlock"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Text="1.0.0" />
|
||||
</Border>
|
||||
<Border Classes="settings-shell-card"
|
||||
Padding="12,8"
|
||||
CornerRadius="18">
|
||||
<TextBlock x:Name="WindowCodeNameBadgeTextBlock"
|
||||
Classes="settings-shell-hint"
|
||||
Text="Administrate" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1"
|
||||
ColumnDefinitions="300,20,*">
|
||||
<Border Grid.Column="0"
|
||||
Classes="settings-shell-card"
|
||||
Padding="18,18,18,16">
|
||||
<Grid RowDefinitions="Auto,*,Auto"
|
||||
RowSpacing="18">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock x:Name="SettingsSidebarTitleTextBlock"
|
||||
Classes="settings-shell-eyebrow"
|
||||
Text="Settings" />
|
||||
<TextBlock x:Name="WindowSubtitleTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
Text="LanMountainDesktop preferences" />
|
||||
<TextBlock x:Name="SettingsSidebarHintTextBlock"
|
||||
Classes="settings-shell-hint"
|
||||
TextWrapping="Wrap"
|
||||
Text="Choose a category to adjust application behavior and desktop appearance." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="2"
|
||||
Padding="10,8"
|
||||
HorizontalAlignment="Right"
|
||||
Click="OnCloseWindowClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="Dismiss" IconVariant="Regular" />
|
||||
<TextBlock Text="Close" VerticalAlignment="Center" />
|
||||
|
||||
<ScrollViewer Grid.Row="1"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel Spacing="20">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="SettingsPrimaryGroupTextBlock"
|
||||
Classes="settings-shell-eyebrow"
|
||||
Text="Desktop" />
|
||||
<StackPanel x:Name="SettingsPrimaryNavHost"
|
||||
Classes="settings-sidebar-host"
|
||||
Spacing="0" />
|
||||
</StackPanel>
|
||||
|
||||
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
|
||||
Height="1" />
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="SettingsSecondaryGroupTextBlock"
|
||||
Classes="settings-shell-eyebrow"
|
||||
Text="System" />
|
||||
<StackPanel x:Name="SettingsSecondaryNavHost"
|
||||
Classes="settings-sidebar-host"
|
||||
Spacing="0" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel x:Name="SettingsPluginNavSection"
|
||||
IsVisible="False"
|
||||
Spacing="10">
|
||||
<Border Background="{DynamicResource SurfaceStrokeColorDefaultBrush}"
|
||||
Height="1" />
|
||||
<TextBlock x:Name="SettingsPluginGroupTextBlock"
|
||||
Classes="settings-shell-eyebrow"
|
||||
Text="Extensions" />
|
||||
<StackPanel x:Name="SettingsPluginNavHost"
|
||||
Classes="settings-sidebar-host"
|
||||
Spacing="0" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</ScrollViewer>
|
||||
|
||||
<Border Grid.Row="2"
|
||||
Classes="settings-shell-card"
|
||||
Padding="14,12"
|
||||
CornerRadius="22">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="LanMountainDesktop"
|
||||
FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="SettingsSidebarFooterTextBlock"
|
||||
Classes="settings-shell-hint"
|
||||
TextWrapping="Wrap"
|
||||
Text="Tray-opened settings are managed in this standalone window." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="1"
|
||||
Classes="mica-strong"
|
||||
CornerRadius="0,0,24,24"
|
||||
Padding="18">
|
||||
<ui:NavigationView x:Name="SettingsNavView"
|
||||
PaneDisplayMode="Left"
|
||||
IsSettingsVisible="False"
|
||||
OpenPaneLength="240"
|
||||
SelectionChanged="OnSettingsNavSelectionChanged">
|
||||
<ui:NavigationView.MenuItems>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWallpaperItem" Content="壁纸" Tag="Wallpaper">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Wallpaper" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavGridItem" Content="网格" Tag="Grid">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Grid" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavColorItem" Content="颜色" Tag="Color">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Color" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavStatusBarItem" Content="状态栏" Tag="StatusBar">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Status" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavWeatherItem" Content="天气" Tag="Weather">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="WeatherSunny" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavRegionItem" Content="地区" Tag="Region">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Globe" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavUpdateItem" Content="更新" Tag="Update">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="ArrowSync" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavAboutItem" Content="关于" Tag="About">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Info" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavLauncherItem" Content="应用启动台" Tag="Launcher">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="Apps" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem x:Name="SettingsNavPluginsItem" Content="插件" Tag="Plugins">
|
||||
<ui:NavigationViewItem.IconSource>
|
||||
<ic:SymbolIconSource Symbol="PuzzlePiece" IconVariant="Regular" />
|
||||
</ui:NavigationViewItem.IconSource>
|
||||
</ui:NavigationViewItem>
|
||||
</ui:NavigationView.MenuItems>
|
||||
|
||||
<Grid Grid.Column="2"
|
||||
RowDefinitions="*,Auto"
|
||||
RowSpacing="14">
|
||||
<Border Grid.Row="0"
|
||||
Classes="settings-shell-card"
|
||||
Padding="0">
|
||||
<ScrollViewer x:Name="SettingsContentScrollViewer"
|
||||
Padding="0,0,16,0"
|
||||
Padding="30,28,30,30"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<Grid x:Name="SettingsContentPagesHost">
|
||||
@@ -154,10 +255,56 @@
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
Grid.Row="1"
|
||||
IsVisible="False"
|
||||
Classes="settings-shell-card"
|
||||
Padding="16,14"
|
||||
CornerRadius="24">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="14">
|
||||
<Border Width="38"
|
||||
Height="38"
|
||||
CornerRadius="14"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="18"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Grid.Column="1"
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
|
||||
FontSize="14"
|
||||
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="16,8"
|
||||
Click="OnPendingRestartDockButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular" />
|
||||
<TextBlock x:Name="PendingRestartDockButtonTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Text="Restart app" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid IsVisible="False">
|
||||
|
||||
@@ -102,13 +102,14 @@ public partial class SettingsWindow : Window
|
||||
private readonly HashSet<string> _hiddenLauncherFolderPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly HashSet<string> _hiddenLauncherAppPaths = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Stack<StartMenuFolderNode> _launcherFolderStack = [];
|
||||
private readonly Dictionary<string, Button> _settingsNavItems = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, Button> _pluginSettingsNavItems = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private StartMenuFolderNode _startMenuRoot = new("All Apps", string.Empty);
|
||||
private byte[]? _launcherFolderIconPngBytes;
|
||||
private Bitmap? _launcherFolderIconBitmap;
|
||||
|
||||
private int _targetShortSideCells;
|
||||
private bool _isSettingsOpen = true;
|
||||
private bool _isNightMode;
|
||||
private bool _enableDynamicTaskbarActions;
|
||||
private bool _suppressThemeToggleEvents;
|
||||
@@ -116,7 +117,6 @@ public partial class SettingsWindow : Window
|
||||
private bool _suppressTimeZoneSelectionEvents;
|
||||
private bool _suppressWeatherLocationEvents;
|
||||
private bool _suppressSettingsPersistence;
|
||||
private bool _suppressGridSpacingEvents;
|
||||
private bool _suppressGridInsetEvents;
|
||||
private bool _suppressStatusBarSpacingEvents;
|
||||
private bool _suppressAutoStartToggleEvents;
|
||||
@@ -135,13 +135,12 @@ public partial class SettingsWindow : Window
|
||||
private IReadOnlyList<Color> _monetColors = Array.Empty<Color>();
|
||||
private Color _selectedThemeColor = Color.Parse("#FF3B82F6");
|
||||
private double _currentDesktopCellSize;
|
||||
private double _currentDesktopCellGap;
|
||||
private double _currentDesktopEdgeInset;
|
||||
private string _gridSpacingPreset = "Relaxed";
|
||||
private string _statusBarSpacingMode = "Relaxed";
|
||||
private int _statusBarCustomSpacingPercent = 12;
|
||||
private int _desktopEdgeInsetPercent = DefaultEdgeInsetPercent;
|
||||
private string _selectedAppRenderMode = AppRenderingModeHelper.Default;
|
||||
private string _runningAppRenderMode = AppRenderingModeHelper.Default;
|
||||
private string _taskbarLayoutMode = TaskbarLayoutBottomFullRowMacStyle;
|
||||
private string _languageCode = "zh-CN";
|
||||
private WeatherLocationMode _weatherLocationMode = WeatherLocationMode.CitySearch;
|
||||
@@ -155,6 +154,7 @@ public partial class SettingsWindow : Window
|
||||
private bool _weatherNoTlsRequests;
|
||||
private bool _autoStartWithWindows;
|
||||
private string _weatherSearchKeyword = string.Empty;
|
||||
private string _selectedSettingsTabTag = "Wallpaper";
|
||||
private bool _isWeatherSearchInProgress;
|
||||
private bool _isWeatherPreviewInProgress;
|
||||
|
||||
@@ -162,9 +162,11 @@ public partial class SettingsWindow : Window
|
||||
{
|
||||
_componentRegistry = DesktopComponentRegistryFactory.Create((Application.Current as App)?.PluginRuntimeService);
|
||||
InitializeComponent();
|
||||
InitializeSettingsNavigation();
|
||||
InitializePluginSettingsNavigation();
|
||||
_fluentAvaloniaTheme = Application.Current?.Styles.OfType<FluentAvaloniaTheme>().FirstOrDefault();
|
||||
RequestedThemeVariant = Application.Current?.RequestedThemeVariant ?? ThemeVariant.Default;
|
||||
PendingRestartStateService.StateChanged += OnPendingRestartStateChanged;
|
||||
HookEvents();
|
||||
}
|
||||
|
||||
@@ -182,8 +184,7 @@ public partial class SettingsWindow : Window
|
||||
GridSpacingPresetComboBox.SelectionChanged += OnGridSpacingPresetSelectionChanged;
|
||||
GridEdgeInsetSlider.ValueChanged += OnGridEdgeInsetSliderChanged;
|
||||
ApplyGridButton.Click += OnApplyGridSizeClick;
|
||||
NightModeToggleSwitch.Checked += OnNightModeChecked;
|
||||
NightModeToggleSwitch.Unchecked += OnNightModeUnchecked;
|
||||
NightModeToggleSwitch.IsCheckedChanged += OnNightModeIsCheckedChanged;
|
||||
RecommendedColorButton1.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton2.Click += OnRecommendedColorClick;
|
||||
RecommendedColorButton3.Click += OnRecommendedColorClick;
|
||||
@@ -197,37 +198,64 @@ public partial class SettingsWindow : Window
|
||||
MonetColorButton4.Click += OnMonetColorClick;
|
||||
MonetColorButton5.Click += OnMonetColorClick;
|
||||
MonetColorButton6.Click += OnMonetColorClick;
|
||||
StatusBarClockToggleSwitch.Checked += OnStatusBarClockChecked;
|
||||
StatusBarClockToggleSwitch.Unchecked += OnStatusBarClockUnchecked;
|
||||
ClockFormatHMSSRadio.Checked += OnClockFormatChanged;
|
||||
ClockFormatHMRadio.Checked += OnClockFormatChanged;
|
||||
StatusBarClockToggleSwitch.IsCheckedChanged += OnStatusBarClockIsCheckedChanged;
|
||||
ClockFormatHMSSRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
ClockFormatHMRadio.IsCheckedChanged += OnClockFormatChanged;
|
||||
StatusBarSpacingModeComboBox.SelectionChanged += OnStatusBarSpacingModeChanged;
|
||||
StatusBarSpacingSlider.ValueChanged += OnStatusBarSpacingSliderChanged;
|
||||
WeatherPreviewButton.Click += OnTestWeatherRequestClick;
|
||||
WeatherLocationModeComboBox.SelectionChanged += OnWeatherLocationModeSelectionChanged;
|
||||
WeatherLocationModeChipListBox.SelectionChanged += OnWeatherLocationModeChipSelectionChanged;
|
||||
WeatherAutoRefreshToggleSwitch.Checked += OnWeatherAutoRefreshToggled;
|
||||
WeatherAutoRefreshToggleSwitch.Unchecked += OnWeatherAutoRefreshToggled;
|
||||
WeatherAutoRefreshToggleSwitch.IsCheckedChanged += OnWeatherAutoRefreshToggled;
|
||||
WeatherSearchButton.Click += OnSearchWeatherCityClick;
|
||||
WeatherApplyCityButton.Click += OnApplyWeatherCitySelectionClick;
|
||||
WeatherApplyCoordinatesButton.Click += OnApplyWeatherCoordinatesClick;
|
||||
WeatherExcludedAlertsTextBox.LostFocus += OnWeatherExcludedAlertsLostFocus;
|
||||
WeatherIconPackComboBox.SelectionChanged += OnWeatherIconPackSelectionChanged;
|
||||
WeatherNoTlsToggleSwitch.Checked += OnWeatherNoTlsToggled;
|
||||
WeatherNoTlsToggleSwitch.Unchecked += OnWeatherNoTlsToggled;
|
||||
WeatherNoTlsToggleSwitch.IsCheckedChanged += OnWeatherNoTlsToggled;
|
||||
LanguageComboBox.SelectionChanged += OnLanguageSelectionChanged;
|
||||
TimeZoneComboBox.SelectionChanged += OnTimeZoneSelectionChanged;
|
||||
AutoCheckUpdatesToggleSwitch.Checked += OnAutoCheckUpdatesToggled;
|
||||
AutoCheckUpdatesToggleSwitch.Unchecked += OnAutoCheckUpdatesToggled;
|
||||
AutoCheckUpdatesToggleSwitch.IsCheckedChanged += OnAutoCheckUpdatesToggled;
|
||||
UpdateChannelChipListBox.SelectionChanged += OnUpdateChannelSelectionChanged;
|
||||
CheckForUpdatesButton.Click += OnCheckForUpdatesClick;
|
||||
DownloadAndInstallUpdateButton.Click += OnDownloadAndInstallUpdateClick;
|
||||
AutoStartWithWindowsToggleSwitch.Checked += OnAutoStartWithWindowsToggled;
|
||||
AutoStartWithWindowsToggleSwitch.Unchecked += OnAutoStartWithWindowsToggled;
|
||||
AutoStartWithWindowsToggleSwitch.IsCheckedChanged += OnAutoStartWithWindowsToggled;
|
||||
AppRenderModeComboBox.SelectionChanged += OnAppRenderModeSelectionChanged;
|
||||
Opened += OnWindowOpened;
|
||||
}
|
||||
|
||||
private void OnNightModeIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnNightModeChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnNightModeUnchecked(sender, e);
|
||||
}
|
||||
|
||||
private void OnStatusBarClockIsCheckedChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not ToggleButton toggleButton)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (toggleButton.IsChecked == true)
|
||||
{
|
||||
OnStatusBarClockChecked(sender, e);
|
||||
return;
|
||||
}
|
||||
|
||||
OnStatusBarClockUnchecked(sender, e);
|
||||
}
|
||||
|
||||
private void OnWindowOpened(object? sender, EventArgs e)
|
||||
{
|
||||
Opened -= OnWindowOpened;
|
||||
@@ -281,8 +309,6 @@ public partial class SettingsWindow : Window
|
||||
EnsureSelectedThemeColor();
|
||||
UpdateThemeColorSelectionState();
|
||||
ThemeColorStatusTextBlock.Text = Lf("settings.color.theme_ready_format", "Theme color ready: {0}.", _selectedThemeColor);
|
||||
WindowTitleTextBlock.Text = L("settings.title", "Settings");
|
||||
WindowSubtitleTextBlock.Text = L("settings.footer", "LanMountainDesktop Settings");
|
||||
_defaultDesktopBackground = DesktopWallpaperLayer.Background;
|
||||
RestoreSettingsTabSelection(snapshot);
|
||||
UpdateSettingsTabContent();
|
||||
|
||||
@@ -24,6 +24,8 @@ AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
DefaultDirName={autopf}\{#MyAppName}
|
||||
DisableDirPage=no
|
||||
UsePreviousAppDir=no
|
||||
DefaultGroupName={#MyAppName}
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
OutputDir={#MyOutputDir}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed class LoadedPlugin : IDisposable, IAsyncDisposable
|
||||
{
|
||||
@@ -0,0 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
@@ -139,6 +139,30 @@ public partial class MainWindow
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshPluginSettingsNavigation()
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||
{
|
||||
var navItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), pair.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (navItem is not null)
|
||||
{
|
||||
SettingsNavView.MenuItems.Remove(navItem);
|
||||
}
|
||||
|
||||
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||
}
|
||||
|
||||
_pluginSettingsPageHosts.Clear();
|
||||
InitializePluginSettingsNavigation();
|
||||
}
|
||||
|
||||
private string? GetSelectedSettingsTabTag()
|
||||
{
|
||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void ApplyPluginSettingsLocalization()
|
||||
{
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L(
|
||||
"settings.plugins.runtime_desc",
|
||||
"Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.runtime_hint",
|
||||
"This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L(
|
||||
"settings.plugins.runtime_status",
|
||||
"Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L(
|
||||
"settings.plugins.installed_desc",
|
||||
"Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L(
|
||||
"settings.plugins.restart_hint",
|
||||
"Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -1,7 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed class PluginLoadContext : AssemblyLoadContext
|
||||
{
|
||||
@@ -1,4 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
using System;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed record PluginLoadResult(
|
||||
string SourcePath,
|
||||
@@ -1,10 +1,18 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed class PluginLoader
|
||||
{
|
||||
@@ -1,4 +1,9 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Plugins;
|
||||
|
||||
public sealed class PluginLoaderOptions
|
||||
{
|
||||
44
LanMountainDesktop/plugins/PluginMarketCacheService.cs
Normal file
44
LanMountainDesktop/plugins/PluginMarketCacheService.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
745
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
745
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
@@ -0,0 +1,745 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
|
||||
{
|
||||
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 AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private readonly PluginRuntimeService _runtime;
|
||||
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, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
|
||||
private bool _isRefreshing;
|
||||
private bool _isInstalling;
|
||||
private bool _hasLoadedOnce;
|
||||
|
||||
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
|
||||
{
|
||||
_runtime = runtime;
|
||||
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket");
|
||||
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
|
||||
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
|
||||
_hostVersion = typeof(App).Assembly.GetName().Version;
|
||||
|
||||
_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();
|
||||
};
|
||||
}
|
||||
|
||||
public void RefreshInstalledSnapshot()
|
||||
{
|
||||
_installedPlugins = _runtime.Catalog
|
||||
.ToDictionary(entry => entry.Manifest.Id, StringComparer.OrdinalIgnoreCase);
|
||||
RebuildSurface();
|
||||
}
|
||||
|
||||
public void RefreshLocalization()
|
||||
{
|
||||
_searchTextBox.Watermark = T("market.toolbar.search_placeholder", "Search plugins");
|
||||
_refreshButton.Content = T("market.toolbar.refresh", "Refresh");
|
||||
RebuildSurface();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_installService.Dispose();
|
||||
_indexService.Dispose();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
RefreshInstalledSnapshot();
|
||||
|
||||
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;
|
||||
_marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl;
|
||||
_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", "市场源"), _marketSourceDisplay),
|
||||
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.Manifest is null)
|
||||
{
|
||||
SetStatus(
|
||||
F(
|
||||
"market.status.install_failed_format",
|
||||
"安装插件失败:{0}",
|
||||
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
|
||||
ErrorBrush);
|
||||
return;
|
||||
}
|
||||
|
||||
RefreshInstalledSnapshot();
|
||||
SetStatus(
|
||||
F(
|
||||
"market.status.install_success_format",
|
||||
"插件“{0}”已暂存完成,重启应用后生效。",
|
||||
result.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 PluginCatalogEntry? 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)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return _localizationService.GetString(snapshot.LanguageCode, 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 => "已安装",
|
||||
_ => "安装"
|
||||
};
|
||||
}
|
||||
}
|
||||
121
LanMountainDesktop/plugins/PluginMarketIndexService.cs
Normal file
121
LanMountainDesktop/plugins/PluginMarketIndexService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
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;
|
||||
|
||||
if (AirAppMarketDefaults.TryGetWorkspaceIndexPath() is { } localIndexPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(localIndexPath, cancellationToken);
|
||||
var document = AirAppMarketIndexDocument.Load(json, localIndexPath);
|
||||
_cacheService.SaveIndexJson(json);
|
||||
return new AirAppMarketLoadResult(
|
||||
true,
|
||||
document,
|
||||
AirAppMarketLoadSource.Local,
|
||||
localIndexPath,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
networkError = ex;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
AirAppMarketDefaults.DefaultIndexUrl,
|
||||
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,
|
||||
_cacheService.CacheFilePath,
|
||||
networkError?.Message,
|
||||
null);
|
||||
}
|
||||
catch (Exception cacheEx)
|
||||
{
|
||||
return new AirAppMarketLoadResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
$"{networkError?.Message ?? "Unknown network error"} | Cached index invalid: {cacheEx.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return new AirAppMarketLoadResult(
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
networkError?.Message ?? "Unknown network error");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
97
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
97
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Security.Cryptography;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketInstallService : IDisposable
|
||||
{
|
||||
private readonly PluginRuntimeService _runtime;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly string _downloadsDirectory;
|
||||
|
||||
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
|
||||
{
|
||||
_runtime = runtime;
|
||||
_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
|
||||
{
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.DownloadUrl, out var localPackagePath))
|
||||
{
|
||||
await using var sourceStream = File.OpenRead(localPackagePath);
|
||||
await using var destinationStream = File.Create(downloadPath);
|
||||
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
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 manifest = _runtime.InstallPluginPackage(downloadPath);
|
||||
return new AirAppMarketInstallResult(true, manifest, 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());
|
||||
}
|
||||
}
|
||||
361
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
361
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal static class AirAppMarketDefaults
|
||||
{
|
||||
public const string DefaultIndexUrl =
|
||||
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
|
||||
|
||||
private const string RawGitHubLanAirAppPathPrefix = "/wwiinnddyy/LanAirApp/main/";
|
||||
|
||||
public static string? TryGetWorkspaceIndexPath()
|
||||
{
|
||||
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
|
||||
if (repositoryRoot is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidatePath = Path.Combine(repositoryRoot, "airappmarket", "index.json");
|
||||
return File.Exists(candidatePath) ? candidatePath : null;
|
||||
}
|
||||
|
||||
public static bool TryResolveWorkspaceFile(string url, out string localPath)
|
||||
{
|
||||
localPath = string.Empty;
|
||||
|
||||
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
|
||||
if (repositoryRoot is null ||
|
||||
!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
!uri.AbsolutePath.StartsWith(RawGitHubLanAirAppPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath[RawGitHubLanAirAppPathPrefix.Length..])
|
||||
.Replace('/', Path.DirectorySeparatorChar);
|
||||
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
localPath = candidatePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? TryGetWorkspaceLanAirAppRepositoryRoot()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, "LanAirApp");
|
||||
if (File.Exists(Path.Combine(candidate, "airappmarket", "index.json")))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal enum AirAppMarketLoadSource
|
||||
{
|
||||
Local = 0,
|
||||
Network = 1,
|
||||
Cache = 2
|
||||
}
|
||||
|
||||
internal enum AirAppMarketInstallState
|
||||
{
|
||||
NotInstalled = 0,
|
||||
UpdateAvailable = 1,
|
||||
Installed = 2
|
||||
}
|
||||
|
||||
internal sealed record AirAppMarketLoadResult(
|
||||
bool Success,
|
||||
AirAppMarketIndexDocument? Document,
|
||||
AirAppMarketLoadSource? Source,
|
||||
string? SourceLocation,
|
||||
string? WarningMessage,
|
||||
string? ErrorMessage);
|
||||
|
||||
internal sealed record AirAppMarketInstallResult(
|
||||
bool Success,
|
||||
PluginManifest? Manifest,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
|
||||
namespace LanMountainDesktop.Services;
|
||||
@@ -17,6 +18,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
{
|
||||
private readonly PluginLoader _loader;
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly IServiceProvider _hostServices;
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
private readonly List<LoadedPlugin> _loadedPlugins = [];
|
||||
private readonly List<PluginLoadResult> _loadResults = [];
|
||||
private readonly List<PluginCatalogEntry> _catalog = [];
|
||||
@@ -26,6 +29,8 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
public PluginRuntimeService()
|
||||
{
|
||||
PluginsDirectory = Path.Combine(AppContext.BaseDirectory, "Extensions", "Plugins");
|
||||
_packageManager = new PluginRuntimePackageManager(this);
|
||||
_hostServices = new PluginHostServiceProvider(_packageManager);
|
||||
_loader = new PluginLoader(CreateOptions());
|
||||
}
|
||||
|
||||
@@ -47,11 +52,14 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
UnloadInstalledPlugins();
|
||||
|
||||
var disabledPluginIds = GetDisabledPluginIds();
|
||||
var settingsSnapshot = _appSettingsService.Load();
|
||||
var hostLanguageCode = PluginLocalizer.NormalizeLanguageCode(settingsSnapshot.LanguageCode);
|
||||
var hostProperties = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["HostApplicationName"] = "LanMountainDesktop",
|
||||
["HostVersion"] = typeof(App).Assembly.GetName().Version?.ToString(),
|
||||
["PluginSdkApiVersion"] = PluginSdkInfo.ApiVersion
|
||||
[PluginHostPropertyKeys.HostApplicationName] = "LanMountainDesktop",
|
||||
[PluginHostPropertyKeys.HostVersion] = typeof(App).Assembly.GetName().Version?.ToString(),
|
||||
[PluginHostPropertyKeys.PluginSdkApiVersion] = PluginSdkInfo.ApiVersion,
|
||||
[PluginHostPropertyKeys.HostLanguageCode] = hostLanguageCode
|
||||
};
|
||||
|
||||
var discoveryFailures = new List<PluginLoadResult>();
|
||||
@@ -92,11 +100,11 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
PluginCatalogSourceKind.Package => _loader.LoadFromPackage(
|
||||
candidate.SourcePath,
|
||||
PluginsDirectory,
|
||||
services: null,
|
||||
services: _hostServices,
|
||||
hostProperties),
|
||||
_ => _loader.LoadFromManifest(
|
||||
candidate.SourcePath,
|
||||
services: null,
|
||||
services: _hostServices,
|
||||
hostProperties)
|
||||
};
|
||||
|
||||
@@ -174,6 +182,57 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
public PluginManifest InstallPluginPackage(string packagePath)
|
||||
{
|
||||
return InstallPluginPackageCore(packagePath).Manifest;
|
||||
}
|
||||
|
||||
internal IReadOnlyList<InstalledPluginInfo> GetInstalledPluginsSnapshot()
|
||||
{
|
||||
return _catalog
|
||||
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new InstalledPluginInfo(
|
||||
entry.Manifest,
|
||||
entry.IsEnabled,
|
||||
entry.IsLoaded,
|
||||
entry.IsPackage,
|
||||
entry.ErrorMessage))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private PluginPackageInstallResult InstallPluginPackageCore(string packagePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(packagePath);
|
||||
|
||||
var fullPackagePath = Path.GetFullPath(packagePath);
|
||||
if (!File.Exists(fullPackagePath))
|
||||
{
|
||||
throw new FileNotFoundException($"Plugin package '{fullPackagePath}' was not found.", fullPackagePath);
|
||||
}
|
||||
|
||||
if (!string.Equals(Path.GetExtension(fullPackagePath), PluginSdkInfo.PackageFileExtension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Plugin package must use the '{PluginSdkInfo.PackageFileExtension}' extension.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
File.Copy(fullPackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
|
||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
UnloadInstalledPlugins();
|
||||
@@ -269,6 +328,71 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private bool RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
{
|
||||
var replacedExisting = false;
|
||||
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
if (string.Equals(
|
||||
Path.GetFullPath(existingPackagePath),
|
||||
Path.GetFullPath(packagePathToKeep),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var existingManifest = ReadManifestFromPackage(existingPackagePath);
|
||||
if (!string.Equals(existingManifest.Id, pluginId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
File.Delete(existingPackagePath);
|
||||
replacedExisting = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore unrelated or invalid packages during replacement.
|
||||
}
|
||||
}
|
||||
|
||||
return replacedExisting;
|
||||
}
|
||||
|
||||
private void UpdateCatalogAfterPackageInstall(PluginManifest manifest, string destinationPath)
|
||||
{
|
||||
var isEnabled = !GetDisabledPluginIds().Contains(manifest.Id);
|
||||
var entry = new PluginCatalogEntry(
|
||||
manifest,
|
||||
destinationPath,
|
||||
IsPackage: true,
|
||||
IsEnabled: isEnabled,
|
||||
IsLoaded: false,
|
||||
ErrorMessage: null,
|
||||
SettingsPageCount: 0,
|
||||
WidgetCount: 0);
|
||||
|
||||
for (var i = 0; i < _catalog.Count; i++)
|
||||
{
|
||||
if (string.Equals(_catalog[i].Manifest.Id, manifest.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_catalog[i] = entry;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_catalog.Add(entry);
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -325,4 +449,41 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
string SourcePath,
|
||||
PluginManifest Manifest,
|
||||
PluginCatalogSourceKind SourceKind);
|
||||
|
||||
private sealed class PluginHostServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly IPluginPackageManager _packageManager;
|
||||
|
||||
public PluginHostServiceProvider(IPluginPackageManager packageManager)
|
||||
{
|
||||
_packageManager = packageManager;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
return serviceType == typeof(IPluginPackageManager)
|
||||
? _packageManager
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class PluginRuntimePackageManager : IPluginPackageManager
|
||||
{
|
||||
private readonly PluginRuntimeService _runtimeService;
|
||||
|
||||
public PluginRuntimePackageManager(PluginRuntimeService runtimeService)
|
||||
{
|
||||
_runtimeService = runtimeService;
|
||||
}
|
||||
|
||||
public IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins()
|
||||
{
|
||||
return _runtimeService.GetInstalledPluginsSnapshot();
|
||||
}
|
||||
|
||||
public PluginPackageInstallResult InstallPackage(string packagePath)
|
||||
{
|
||||
return _runtimeService.InstallPluginPackageCore(packagePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
411
LanMountainDesktop/plugins/PluginSettingsPage.Host.cs
Normal file
411
LanMountainDesktop/plugins/PluginSettingsPage.Host.cs
Normal file
@@ -0,0 +1,411 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
public partial class PluginSettingsPage : UserControl
|
||||
{
|
||||
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
|
||||
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private PluginMarketEmbeddedView? _pluginMarketView;
|
||||
private string? _packageImportStatusMessage;
|
||||
private bool _packageImportStatusIsError;
|
||||
|
||||
public PluginSettingsPage()
|
||||
{
|
||||
InitializeComponent();
|
||||
AttachedToVisualTree += (_, _) => RefreshFromRuntime();
|
||||
}
|
||||
|
||||
public void RefreshFromRuntime()
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
PluginMarketSettingsExpander.Header = L("settings.plugins.market_header", "Official Market");
|
||||
PluginMarketSettingsExpander.Description = L(
|
||||
"settings.plugins.market_desc",
|
||||
"Browse plugins from the official LanAirApp source and stage installs.");
|
||||
PluginMarketDescriptionTextBlock.Text = L(
|
||||
"settings.plugins.market_hint",
|
||||
"Use the official market source hosted in LanAirApp to discover and stage plugin installs.");
|
||||
UpdateInstallerUi(runtime);
|
||||
if (runtime is null)
|
||||
{
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_unavailable", "Plugin runtime is not available.");
|
||||
PluginRuntimeSummaryPanel.Children.Clear();
|
||||
PluginCatalogItemsHost.Children.Clear();
|
||||
PluginRestartHintTextBlock.IsVisible = false;
|
||||
PluginMarketContentHost.Content = CreateSummaryLine(
|
||||
L("settings.plugins.market_unavailable", "Plugin runtime is not available, so the official market cannot be opened right now."));
|
||||
return;
|
||||
}
|
||||
|
||||
EnsurePluginMarketView(runtime);
|
||||
_pluginMarketView?.RefreshLocalization();
|
||||
_pluginMarketView?.RefreshInstalledSnapshot();
|
||||
BuildRuntimeSummary(runtime);
|
||||
BuildPluginCatalog(runtime);
|
||||
}
|
||||
|
||||
private void EnsurePluginMarketView(PluginRuntimeService runtime)
|
||||
{
|
||||
if (_pluginMarketView is null)
|
||||
{
|
||||
_pluginMarketView = new PluginMarketEmbeddedView(runtime);
|
||||
PluginMarketContentHost.Content = _pluginMarketView;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(PluginMarketContentHost.Content, _pluginMarketView))
|
||||
{
|
||||
PluginMarketContentHost.Content = _pluginMarketView;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateInstallerUi(PluginRuntimeService? runtime)
|
||||
{
|
||||
InstallPluginPackageButton.Content = L("settings.plugins.install_button", "Open .laapp package");
|
||||
InstallPluginPackageButton.IsEnabled = runtime is not null;
|
||||
PluginPackageImportHintTextBlock.Text = runtime is null
|
||||
? L(
|
||||
"settings.plugins.install_unavailable",
|
||||
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now.")
|
||||
: F(
|
||||
"settings.plugins.install_hint_format",
|
||||
"Open a .laapp package to install it into: {0}",
|
||||
runtime.PluginsDirectory);
|
||||
|
||||
PluginPackageImportStatusTextBlock.IsVisible = !string.IsNullOrWhiteSpace(_packageImportStatusMessage);
|
||||
PluginPackageImportStatusTextBlock.Text = _packageImportStatusMessage ?? string.Empty;
|
||||
PluginPackageImportStatusTextBlock.Foreground = _packageImportStatusIsError ? ErrorBrush : SuccessBrush;
|
||||
}
|
||||
|
||||
private void BuildRuntimeSummary(PluginRuntimeService runtime)
|
||||
{
|
||||
var failures = runtime.LoadResults.Where(result => !result.IsSuccess).ToArray();
|
||||
var enabledCount = runtime.Catalog.Count(entry => entry.IsEnabled);
|
||||
PluginSystemStatusTextBlock.Text = F(
|
||||
"settings.plugins.summary_format",
|
||||
"Detected {0} plugin(s); enabled {1}; loaded {2}; settings pages {3}; widgets {4}; failures {5}.",
|
||||
runtime.Catalog.Count,
|
||||
enabledCount,
|
||||
runtime.LoadedPlugins.Count,
|
||||
runtime.SettingsPages.Count,
|
||||
runtime.DesktopComponents.Count,
|
||||
failures.Length);
|
||||
|
||||
PluginRuntimeSummaryPanel.Children.Clear();
|
||||
foreach (var plugin in runtime.Catalog.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var status = plugin.IsEnabled
|
||||
? plugin.IsLoaded
|
||||
? L("settings.plugins.state.enabled", "Enabled")
|
||||
: L("settings.plugins.state.enabled_failed", "Enabled / failed to load")
|
||||
: L("settings.plugins.state.disabled", "Disabled");
|
||||
PluginRuntimeSummaryPanel.Children.Add(CreateSummaryLine(
|
||||
F(
|
||||
"settings.plugins.summary_item_format",
|
||||
"{0} v{1} | {2}",
|
||||
plugin.Manifest.Name,
|
||||
plugin.Manifest.Version ?? "dev",
|
||||
status)));
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildPluginCatalog(PluginRuntimeService runtime)
|
||||
{
|
||||
PluginCatalogItemsHost.Children.Clear();
|
||||
|
||||
var plugins = runtime.Catalog
|
||||
.OrderBy(entry => entry.Manifest.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
PluginCatalogEmptyTextBlock.IsVisible = plugins.Count == 0;
|
||||
PluginRestartHintTextBlock.IsVisible = plugins.Count > 0;
|
||||
|
||||
foreach (var plugin in plugins)
|
||||
{
|
||||
PluginCatalogItemsHost.Children.Add(CreatePluginCatalogItem(runtime, plugin));
|
||||
}
|
||||
}
|
||||
|
||||
private Control CreatePluginCatalogItem(PluginRuntimeService runtime, PluginCatalogEntry entry)
|
||||
{
|
||||
var title = new TextBlock
|
||||
{
|
||||
Text = entry.Manifest.Name,
|
||||
FontSize = 16,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
var subtitle = new TextBlock
|
||||
{
|
||||
Text = BuildPluginSubtitle(entry),
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
var enabledToggle = new ToggleSwitch
|
||||
{
|
||||
IsChecked = entry.IsEnabled,
|
||||
OnContent = L("settings.plugins.toggle_on", "Enabled"),
|
||||
OffContent = L("settings.plugins.toggle_off", "Disabled"),
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
|
||||
|
||||
var header = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
ColumnSpacing = 12,
|
||||
Children =
|
||||
{
|
||||
new StackPanel
|
||||
{
|
||||
Spacing = 4,
|
||||
Children = { title, subtitle }
|
||||
},
|
||||
enabledToggle
|
||||
}
|
||||
};
|
||||
Grid.SetColumn(enabledToggle, 1);
|
||||
|
||||
var details = new TextBlock
|
||||
{
|
||||
Text = BuildPluginDetails(entry),
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground,
|
||||
TextWrapping = TextWrapping.Wrap
|
||||
};
|
||||
|
||||
return new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.Parse("#14000000")),
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new StackPanel
|
||||
{
|
||||
Spacing = 10,
|
||||
Children = { header, details }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnPluginEnableChanged(PluginRuntimeService runtime, PluginCatalogEntry entry, bool isEnabled)
|
||||
{
|
||||
runtime.SetPluginEnabled(entry.Manifest.Id, isEnabled);
|
||||
BuildRuntimeSummary(runtime);
|
||||
BuildPluginCatalog(runtime);
|
||||
PluginSystemStatusTextBlock.Text = F(
|
||||
"settings.plugins.toggle_result_format",
|
||||
"Plugin '{0}' was {1} for the next launch. Restart the app to apply page and widget changes.",
|
||||
entry.Manifest.Name,
|
||||
isEnabled
|
||||
? L("settings.plugins.toggle_state_enabled", "enabled")
|
||||
: L("settings.plugins.toggle_state_disabled", "disabled"));
|
||||
}
|
||||
|
||||
private async void OnInstallPluginPackageClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var runtime = (Application.Current as App)?.PluginRuntimeService;
|
||||
if (runtime is null)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L(
|
||||
"settings.plugins.install_unavailable",
|
||||
"Plugin runtime is unavailable, so .laapp packages cannot be installed right now."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
var storageProvider = topLevel?.StorageProvider;
|
||||
if (storageProvider is null)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L("settings.plugins.install_picker_unavailable", "Storage provider is unavailable."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var files = await storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
|
||||
{
|
||||
Title = L("settings.plugins.install_picker_title", "Select plugin package"),
|
||||
AllowMultiple = false,
|
||||
FileTypeFilter =
|
||||
[
|
||||
new FilePickerFileType(L("settings.plugins.install_file_type", ".laapp plugin package"))
|
||||
{
|
||||
Patterns = [$"*{PluginSdkInfo.PackageFileExtension}"]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? temporaryPackagePath = null;
|
||||
try
|
||||
{
|
||||
temporaryPackagePath = await CopyPackageToTemporaryFileAsync(files[0]);
|
||||
if (string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
L("settings.plugins.install_copy_failed", "Failed to copy the selected .laapp package."),
|
||||
isError: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||
RefreshFromRuntime();
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
"settings.plugins.install_success_format",
|
||||
"Installed plugin '{0}'. Restart the app to apply newly added settings pages and widgets.",
|
||||
manifest.Name),
|
||||
isError: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
"settings.plugins.install_failed_format",
|
||||
"Failed to install plugin package: {0}",
|
||||
ex.Message),
|
||||
isError: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(temporaryPackagePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(temporaryPackagePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore temporary file cleanup errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPluginNavigation(TopLevel? topLevel)
|
||||
{
|
||||
switch (topLevel)
|
||||
{
|
||||
case MainWindow mainWindow:
|
||||
mainWindow.RefreshPluginSettingsNavigation();
|
||||
break;
|
||||
case SettingsWindow settingsWindow:
|
||||
settingsWindow.RefreshPluginSettingsNavigation();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetPackageImportStatus(string message, bool isError)
|
||||
{
|
||||
_packageImportStatusMessage = string.IsNullOrWhiteSpace(message) ? null : message;
|
||||
_packageImportStatusIsError = isError;
|
||||
UpdateInstallerUi((Application.Current as App)?.PluginRuntimeService);
|
||||
}
|
||||
|
||||
private string BuildPluginSubtitle(PluginCatalogEntry entry)
|
||||
{
|
||||
var source = entry.IsPackage
|
||||
? L("settings.plugins.source_package", ".laapp package")
|
||||
: L("settings.plugins.source_manifest", "Loose manifest");
|
||||
var state = entry.IsEnabled
|
||||
? entry.IsLoaded
|
||||
? L("settings.plugins.state.loaded", "Loaded")
|
||||
: L("settings.plugins.state.load_failed", "Load failed")
|
||||
: L("settings.plugins.state.disabled", "Disabled");
|
||||
return F(
|
||||
"settings.plugins.subtitle_format",
|
||||
"{0} | {1} | {2}",
|
||||
state,
|
||||
source,
|
||||
entry.Manifest.Id);
|
||||
}
|
||||
|
||||
private string BuildPluginDetails(PluginCatalogEntry entry)
|
||||
{
|
||||
var detail = F(
|
||||
"settings.plugins.detail_format",
|
||||
"Settings pages: {0} | Widgets: {1}",
|
||||
entry.SettingsPageCount,
|
||||
entry.WidgetCount);
|
||||
return string.IsNullOrWhiteSpace(entry.ErrorMessage)
|
||||
? detail
|
||||
: detail + Environment.NewLine + entry.ErrorMessage;
|
||||
}
|
||||
|
||||
private TextBlock CreateSummaryLine(string text)
|
||||
{
|
||||
return new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Foreground = PluginSystemDescriptionTextBlock.Foreground
|
||||
};
|
||||
}
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var snapshot = _appSettingsService.Load();
|
||||
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
|
||||
}
|
||||
|
||||
private string F(string key, string fallback, params object[] args)
|
||||
{
|
||||
return string.Format(CultureInfo.CurrentCulture, L(key, fallback), args);
|
||||
}
|
||||
|
||||
private static async Task<string?> CopyPackageToTemporaryFileAsync(IStorageFile file)
|
||||
{
|
||||
try
|
||||
{
|
||||
var extension = Path.GetExtension(file.Name);
|
||||
if (string.IsNullOrWhiteSpace(extension))
|
||||
{
|
||||
extension = PluginSdkInfo.PackageFileExtension;
|
||||
}
|
||||
|
||||
var temporaryDirectory = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"LanMountainDesktop",
|
||||
"PluginImports");
|
||||
Directory.CreateDirectory(temporaryDirectory);
|
||||
|
||||
var temporaryPackagePath = Path.Combine(
|
||||
temporaryDirectory,
|
||||
$"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{extension}");
|
||||
|
||||
await using var sourceStream = await file.OpenReadAsync();
|
||||
await using var destinationStream = File.Create(temporaryPackagePath);
|
||||
await sourceStream.CopyToAsync(destinationStream);
|
||||
return temporaryPackagePath;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,24 @@
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<Border Background="{DynamicResource AdaptiveSurfaceRaisedBrush}"
|
||||
CornerRadius="{DynamicResource DesignCornerRadiusSm}"
|
||||
Padding="14">
|
||||
<StackPanel Spacing="10">
|
||||
<Button x:Name="InstallPluginPackageButton"
|
||||
HorizontalAlignment="Left"
|
||||
Click="OnInstallPluginPackageClick"
|
||||
Content="Open .laapp package" />
|
||||
<TextBlock x:Name="PluginPackageImportHintTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Open a .laapp package to install it into the local plugin directory." />
|
||||
<TextBlock x:Name="PluginPackageImportStatusTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="False" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<TextBlock x:Name="PluginRestartHintTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
@@ -64,5 +82,25 @@
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
|
||||
<Border Classes="settings-expander-shell">
|
||||
<ui:SettingsExpander x:Name="PluginMarketSettingsExpander"
|
||||
Header="Official Market"
|
||||
Description="Browse plugins from the official LanAirApp source and stage installs."
|
||||
IsExpanded="True">
|
||||
<ui:SettingsExpander.IconSource>
|
||||
<ui:FontIconSource Glyph="" FontFamily="{StaticResource SymbolThemeFontFamily}" />
|
||||
</ui:SettingsExpander.IconSource>
|
||||
<ui:SettingsExpander.Footer>
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock x:Name="PluginMarketDescriptionTextBlock"
|
||||
Foreground="{DynamicResource AdaptiveTextSecondaryBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Use the official market source hosted in LanAirApp to discover and stage plugin installs." />
|
||||
<ContentControl x:Name="PluginMarketContentHost" />
|
||||
</StackPanel>
|
||||
</ui:SettingsExpander.Footer>
|
||||
</ui:SettingsExpander>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
33
LanMountainDesktop/plugins/README.md
Normal file
33
LanMountainDesktop/plugins/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 宿主侧插件运行时
|
||||
|
||||
这个目录用于归档阑山桌面宿主侧的插件相关实现。
|
||||
|
||||
职责范围:
|
||||
- 已安装插件的发现
|
||||
- `.laapp` 安装包安装与替换
|
||||
- 插件运行时加载
|
||||
- 插件贡献的设置页与桌面组件接入
|
||||
- 宿主侧插件设置页的安装、显示与刷新
|
||||
|
||||
当前宿主侧核心文件:
|
||||
- `PluginLoader.cs`
|
||||
- `PluginLoadContext.cs`
|
||||
- `PluginLoaderOptions.cs`
|
||||
- `PluginLoadResult.cs`
|
||||
- `LoadedPlugin.cs`
|
||||
- `PluginRuntimeService.cs`
|
||||
- `PluginContributions.cs`
|
||||
- `PluginCatalogEntry.cs`
|
||||
- `PluginSettingsPage.axaml`
|
||||
- `PluginSettingsPage.Host.cs`
|
||||
- `MainWindow.PluginSettingsHost.cs`
|
||||
- `SettingsWindow.PluginSettingsHost.cs`
|
||||
- `MainWindow.PluginSettingsLocalization.cs`
|
||||
- `SettingsWindow.PluginSettingsLocalization.cs`
|
||||
- `MainWindow.PluginSettingsControls.cs`
|
||||
- `SettingsWindow.PluginSettingsControls.cs`
|
||||
|
||||
说明:
|
||||
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
|
||||
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
|
||||
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
|
||||
@@ -0,0 +1,14 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
internal TextBlock PluginSettingsPanelTitleTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSettingsPanelTitleTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander PluginSystemSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("PluginSystemSettingsExpander")!;
|
||||
internal TextBlock PluginSystemDescriptionTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemDescriptionTextBlock")!;
|
||||
internal TextBlock PluginSystemStatusTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginSystemStatusTextBlock")!;
|
||||
internal FluentAvalonia.UI.Controls.SettingsExpander InstalledPluginsSettingsExpander => PluginSettingsPanel.FindControl<FluentAvalonia.UI.Controls.SettingsExpander>("InstalledPluginsSettingsExpander")!;
|
||||
internal TextBlock PluginRestartHintTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginRestartHintTextBlock")!;
|
||||
internal TextBlock PluginCatalogEmptyTextBlock => PluginSettingsPanel.FindControl<TextBlock>("PluginCatalogEmptyTextBlock")!;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using FluentIcons.Avalonia.Fluent;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -18,7 +17,7 @@ public partial class SettingsWindow
|
||||
|
||||
private void InitializePluginSettingsNavigation()
|
||||
{
|
||||
if (_pluginSettingsPageHosts.Count > 0 || SettingsNavView?.MenuItems is null)
|
||||
if (_pluginSettingsPageHosts.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -32,6 +31,7 @@ public partial class SettingsWindow
|
||||
|
||||
if (contributions is not { Length: > 0 })
|
||||
{
|
||||
SettingsPluginNavSection.IsVisible = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,31 +39,23 @@ public partial class SettingsWindow
|
||||
.GroupBy(contribution => contribution.Plugin.Manifest.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var insertIndex = SettingsNavView.MenuItems.IndexOf(SettingsNavPluginsItem) + 1;
|
||||
foreach (var contribution in contributions)
|
||||
{
|
||||
var tag = BuildPluginSettingsTag(contribution);
|
||||
var navigationTitle = BuildPluginSettingsNavigationTitle(contribution, pageCountsByPluginId);
|
||||
var navItem = new NavigationViewItem
|
||||
{
|
||||
Content = navigationTitle,
|
||||
Tag = tag,
|
||||
IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource
|
||||
{
|
||||
Symbol = FluentIcons.Common.Symbol.PuzzlePiece,
|
||||
IconVariant = FluentIcons.Common.IconVariant.Regular
|
||||
}
|
||||
};
|
||||
|
||||
var navItem = CreateSettingsNavItem(tag, Symbol.PuzzlePiece, navigationTitle);
|
||||
ToolTip.SetTip(navItem, $"{contribution.Plugin.Manifest.Name} - {contribution.Registration.Title}");
|
||||
|
||||
SettingsNavView.MenuItems.Insert(insertIndex++, navItem);
|
||||
SettingsPluginNavHost.Children.Add(navItem);
|
||||
_pluginSettingsNavItems[tag] = navItem;
|
||||
|
||||
var pageHost = CreatePluginSettingsPageHost(contribution);
|
||||
pageHost.IsVisible = false;
|
||||
SettingsContentPagesHost.Children.Add(pageHost);
|
||||
_pluginSettingsPageHosts[tag] = pageHost;
|
||||
}
|
||||
|
||||
SettingsPluginNavSection.IsVisible = SettingsPluginNavHost.Children.Count > 0;
|
||||
}
|
||||
|
||||
private static string BuildPluginSettingsTag(PluginSettingsPageContribution contribution)
|
||||
@@ -139,21 +131,50 @@ public partial class SettingsWindow
|
||||
}
|
||||
}
|
||||
|
||||
internal void RefreshPluginSettingsNavigation()
|
||||
{
|
||||
foreach (var pair in _pluginSettingsPageHosts.ToArray())
|
||||
{
|
||||
if (_pluginSettingsNavItems.TryGetValue(pair.Key, out var navItem))
|
||||
{
|
||||
SettingsPluginNavHost.Children.Remove(navItem);
|
||||
}
|
||||
|
||||
SettingsContentPagesHost.Children.Remove(pair.Value);
|
||||
}
|
||||
|
||||
_pluginSettingsPageHosts.Clear();
|
||||
_pluginSettingsNavItems.Clear();
|
||||
SettingsPluginNavSection.IsVisible = false;
|
||||
InitializePluginSettingsNavigation();
|
||||
|
||||
if (GetSettingsNavItem(_selectedSettingsTabTag) is null)
|
||||
{
|
||||
SelectSettingsTab("Plugins", persistSelection: false);
|
||||
}
|
||||
else
|
||||
{
|
||||
SelectSettingsTab(_selectedSettingsTabTag, persistSelection: false);
|
||||
}
|
||||
}
|
||||
|
||||
private string? GetSelectedSettingsTabTag()
|
||||
{
|
||||
return (SettingsNavView?.SelectedItem as NavigationViewItem)?.Tag?.ToString();
|
||||
return _selectedSettingsTabTag;
|
||||
}
|
||||
|
||||
private int ResolveSelectedSettingsTabIndex()
|
||||
{
|
||||
if (SettingsNavView?.SelectedItem is null || SettingsNavView.MenuItems is null)
|
||||
var selectedTag = GetSelectedSettingsTabTag();
|
||||
if (string.IsNullOrWhiteSpace(selectedTag))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (var i = 0; i < SettingsNavView.MenuItems.Count; i++)
|
||||
var buttons = EnumerateSettingsNavItems().ToList();
|
||||
for (var i = 0; i < buttons.Count; i++)
|
||||
{
|
||||
if (ReferenceEquals(SettingsNavView.MenuItems[i], SettingsNavView.SelectedItem))
|
||||
if (string.Equals(buttons[i].Tag?.ToString(), selectedTag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return i;
|
||||
}
|
||||
@@ -164,30 +185,21 @@ public partial class SettingsWindow
|
||||
|
||||
private void RestoreSettingsTabSelection(AppSettingsSnapshot snapshot)
|
||||
{
|
||||
if (SettingsNavView?.MenuItems is null || SettingsNavView.MenuItems.Count == 0)
|
||||
var buttons = EnumerateSettingsNavItems().ToList();
|
||||
if (buttons.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag))
|
||||
if (!string.IsNullOrWhiteSpace(snapshot.SettingsTabTag) &&
|
||||
GetSettingsNavItem(snapshot.SettingsTabTag) is not null)
|
||||
{
|
||||
var taggedItem = SettingsNavView.MenuItems
|
||||
.OfType<NavigationViewItem>()
|
||||
.FirstOrDefault(item => string.Equals(item.Tag?.ToString(), snapshot.SettingsTabTag, StringComparison.OrdinalIgnoreCase));
|
||||
if (taggedItem is not null)
|
||||
{
|
||||
SettingsNavView.SelectedItem = taggedItem;
|
||||
return;
|
||||
}
|
||||
SelectSettingsTab(snapshot.SettingsTabTag, persistSelection: false);
|
||||
return;
|
||||
}
|
||||
|
||||
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, SettingsNavView.MenuItems.Count - 1));
|
||||
if (SettingsNavView.MenuItems[safeIndex] is NavigationViewItem navItem)
|
||||
{
|
||||
SettingsNavView.SelectedItem = navItem;
|
||||
}
|
||||
var safeIndex = Math.Clamp(snapshot.SettingsTabIndex, 0, Math.Max(0, buttons.Count - 1));
|
||||
var button = buttons[safeIndex];
|
||||
SelectSettingsTab(button.Tag?.ToString() ?? "Wallpaper", persistSelection: false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
public partial class SettingsWindow
|
||||
{
|
||||
private void ApplyPluginSettingsLocalization()
|
||||
{
|
||||
PluginSettingsPanelTitleTextBlock.Text = L("settings.plugins.title", "Plugins");
|
||||
PluginSystemSettingsExpander.Header = L("settings.plugins.runtime_header", "Plugin Runtime");
|
||||
PluginSystemSettingsExpander.Description = L("settings.plugins.runtime_desc", "Review plugin runtime state and load results.");
|
||||
PluginSystemDescriptionTextBlock.Text = L("settings.plugins.runtime_hint", "This page shows discovery status, load results, and runtime diagnostics for installed plugins.");
|
||||
PluginSystemStatusTextBlock.Text = L("settings.plugins.runtime_status", "Plugin runtime status will appear here after plugin discovery completes.");
|
||||
InstalledPluginsSettingsExpander.Header = L("settings.plugins.installed_header", "Installed Plugins");
|
||||
InstalledPluginsSettingsExpander.Description = L("settings.plugins.installed_desc", "Enable or disable plugins here. Detailed plugin settings appear as separate settings pages.");
|
||||
PluginRestartHintTextBlock.Text = L("settings.plugins.restart_hint", "Plugin enable state changes take effect after restarting the app.");
|
||||
PluginCatalogEmptyTextBlock.Text = L("settings.plugins.empty", "No plugins found.");
|
||||
PluginSettingsPanel.RefreshFromRuntime();
|
||||
}
|
||||
}
|
||||
23
airappmarket/README.md
Normal file
23
airappmarket/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# AirAppMarket
|
||||
|
||||
`airappmarket/` 是阑山桌面的官方插件市场源目录。
|
||||
|
||||
当前阶段职责:
|
||||
- 提供官方插件市场索引 `index.json`
|
||||
- 提供索引 schema
|
||||
- 提供静态图标资产
|
||||
- 提供本地与 CI 使用的索引校验工具
|
||||
|
||||
Bootstrap 方式:
|
||||
1. 用户先通过阑山桌面内置的 `设置 -> 插件 -> 打开 .laapp 插件包` 手动安装 `LanMountainDesktop.PluginMarketplace`。
|
||||
2. 市场插件启动后,会从这里的官方索引拉取插件列表。
|
||||
3. 后续插件安装与更新都通过市场插件完成。
|
||||
|
||||
官方索引地址:
|
||||
|
||||
`https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json`
|
||||
|
||||
约束:
|
||||
- 这里只维护官方市场源,不做多源聚合。
|
||||
- 第一阶段不提供独立 GitHub Pages 页面。
|
||||
- 索引中的下载链接默认指向本仓库已提交的 `.laapp` 发布包。
|
||||
13
airappmarket/assets/plugin-marketplace.svg
Normal file
13
airappmarket/assets/plugin-marketplace.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Plugin Marketplace">
|
||||
<defs>
|
||||
<linearGradient id="marketBg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#0EA5E9"/>
|
||||
<stop offset="100%" stop-color="#22C55E"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#marketBg)"/>
|
||||
<path d="M43 36h42c3.866 0 7 3.134 7 7v42c0 3.866-3.134 7-7 7H43c-3.866 0-7-3.134-7-7V43c0-3.866 3.134-7 7-7Z" fill="#FFFFFF" fill-opacity="0.16"/>
|
||||
<path d="M52 52h24a12 12 0 0 1 0 24H52a8 8 0 0 1 0-16h4" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="10"/>
|
||||
<circle cx="84" cy="84" r="14" fill="#FFFFFF"/>
|
||||
<path d="M84 76v16M76 84h16" stroke="#0EA5E9" stroke-linecap="round" stroke-width="8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 835 B |
10
airappmarket/assets/sample-plugin.svg
Normal file
10
airappmarket/assets/sample-plugin.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="Sample Plugin">
|
||||
<defs>
|
||||
<linearGradient id="sampleBg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#F59E0B"/>
|
||||
<stop offset="100%" stop-color="#EF4444"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="8" y="8" width="112" height="112" rx="28" fill="url(#sampleBg)"/>
|
||||
<path d="M52 32c0-6.627 5.373-12 12-12s12 5.373 12 12v8h8c6.627 0 12 5.373 12 12s-5.373 12-12 12h-8v32c0 6.627-5.373 12-12 12s-12-5.373-12-12V64h-8c-6.627 0-12-5.373-12-12s5.373-12 12-12h8v-8Z" fill="#FFFFFF" fill-opacity="0.92"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 618 B |
54
airappmarket/index.json
Normal file
54
airappmarket/index.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"sourceId": "official.lanmountaindesktop",
|
||||
"sourceName": "LanMountainDesktop Official Market",
|
||||
"generatedAt": "2026-03-10T01:30:00Z",
|
||||
"plugins": [
|
||||
{
|
||||
"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",
|
||||
"minHostVersion": "1.0.0",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.PluginMarketplace.1.0.0.laapp",
|
||||
"sha256": "51e73bf834c4c3f8a32bc711e9db62b954e24a3577e580d6faa4c3986ce70e0b",
|
||||
"packageSizeBytes": 1704353,
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/plugin-marketplace.svg",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace",
|
||||
"tags": [
|
||||
"official",
|
||||
"market",
|
||||
"settings"
|
||||
],
|
||||
"publishedAt": "2026-03-10T01:30:00Z",
|
||||
"updatedAt": "2026-03-10T01:30:00Z",
|
||||
"releaseNotes": "Bootstrap plugin for the official LanMountainDesktop marketplace. Install it once from a local .laapp package, then use it to discover and stage future plugin installs and updates."
|
||||
},
|
||||
{
|
||||
"id": "LanMountainDesktop.SamplePlugin",
|
||||
"name": "LanMountain Sample Plugin",
|
||||
"description": "Example plugin used to validate PluginSdk loading and isolation.",
|
||||
"author": "LanMountainDesktop",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": "1.0.0",
|
||||
"minHostVersion": "1.0.0",
|
||||
"downloadUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/LanAirApp/releases/LanMountainDesktop.SamplePlugin.1.0.0.laapp",
|
||||
"sha256": "c092f9d215ee0f1e436bc49b919dd9a75b3838e950c72c46dd7e41807557125c",
|
||||
"packageSizeBytes": 1703398,
|
||||
"iconUrl": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/assets/sample-plugin.svg",
|
||||
"homepageUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
|
||||
"repositoryUrl": "https://github.com/wwiinnddyy/LanMountainDesktop/tree/main/LanAirApp/samples/LanMountainDesktop.SamplePlugin",
|
||||
"tags": [
|
||||
"example",
|
||||
"official",
|
||||
"sdk"
|
||||
],
|
||||
"publishedAt": "2026-03-10T01:30:00Z",
|
||||
"updatedAt": "2026-03-10T01:30:00Z",
|
||||
"releaseNotes": "Reference plugin for SDK validation. Includes a settings page, a desktop widget, localization resources, service registration, and plugin message bus usage."
|
||||
}
|
||||
]
|
||||
}
|
||||
137
airappmarket/schema/airappmarket-index.schema.json
Normal file
137
airappmarket/schema/airappmarket-index.schema.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/schema/airappmarket-index.schema.json",
|
||||
"title": "AirAppMarket Index",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schemaVersion",
|
||||
"sourceId",
|
||||
"sourceName",
|
||||
"generatedAt",
|
||||
"plugins"
|
||||
],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||
},
|
||||
"sourceId": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"sourceName": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"plugins": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/plugin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"plugin": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"description",
|
||||
"author",
|
||||
"version",
|
||||
"apiVersion",
|
||||
"minHostVersion",
|
||||
"downloadUrl",
|
||||
"sha256",
|
||||
"packageSizeBytes",
|
||||
"iconUrl",
|
||||
"homepageUrl",
|
||||
"repositoryUrl",
|
||||
"tags",
|
||||
"publishedAt",
|
||||
"updatedAt",
|
||||
"releaseNotes"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"author": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
|
||||
},
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
|
||||
},
|
||||
"minHostVersion": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:[-+ ][A-Za-z0-9.-]+)?$"
|
||||
},
|
||||
"downloadUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"sha256": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-fA-F0-9]{64}$"
|
||||
},
|
||||
"packageSizeBytes": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"iconUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"homepageUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"type": "string",
|
||||
"format": "uri"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"uniqueItems": true
|
||||
},
|
||||
"publishedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"releaseNotes": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
247
airappmarket/tools/AirAppMarket.Validator/Program.cs
Normal file
247
airappmarket/tools/AirAppMarket.Validator/Program.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using System.Text.Json;
|
||||
|
||||
return await RunAsync(args);
|
||||
|
||||
static Task<int> RunAsync(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexPath = args.Length > 0
|
||||
? Path.GetFullPath(args[0])
|
||||
: Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "index.json"));
|
||||
var schemaPath = args.Length > 1
|
||||
? Path.GetFullPath(args[1])
|
||||
: Path.GetFullPath(Path.Combine(Path.GetDirectoryName(indexPath)!, "schema", "airappmarket-index.schema.json"));
|
||||
|
||||
if (!File.Exists(indexPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Market index '{indexPath}' was not found.", indexPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(schemaPath))
|
||||
{
|
||||
throw new FileNotFoundException($"Market schema '{schemaPath}' was not found.", schemaPath);
|
||||
}
|
||||
|
||||
JsonDocument.Parse(File.ReadAllText(schemaPath));
|
||||
var document = MarketIndex.Load(File.ReadAllText(indexPath), indexPath);
|
||||
|
||||
Console.WriteLine($"Validated '{indexPath}'.");
|
||||
Console.WriteLine($"Source: {document.SourceName} ({document.SourceId})");
|
||||
Console.WriteLine($"Plugins: {document.Plugins.Count}");
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
return Task.FromResult(1);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MarketIndex
|
||||
{
|
||||
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<MarketPlugin> Plugins { get; init; } = [];
|
||||
|
||||
public static MarketIndex Load(string json, string sourceName)
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<MarketIndex>(
|
||||
json.TrimStart('\uFEFF'),
|
||||
SerializerOptions) ?? throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
|
||||
|
||||
return document.ValidateAndNormalize(sourceName);
|
||||
}
|
||||
|
||||
private MarketIndex ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var normalizedPlugins = new List<MarketPlugin>(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 MarketIndex
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
internal 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 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 static void EnsureUrl(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = RequireValue(value, propertyName, sourceName);
|
||||
if (!Uri.TryCreate(normalized, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid URL '{normalized}' for '{propertyName}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class MarketPlugin
|
||||
{
|
||||
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 MarketPlugin ValidateAndNormalize(string sourceName)
|
||||
{
|
||||
var tagSource = Tags ?? [];
|
||||
var normalizedTags = tagSource
|
||||
.Select(MarketIndex.NormalizeValue)
|
||||
.Where(tag => !string.IsNullOrWhiteSpace(tag))
|
||||
.Select(tag => tag!)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalizedTags.Count != tagSource.Count(tag => !string.IsNullOrWhiteSpace(tag)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' contains duplicate or blank tags for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
var normalizedSha = MarketIndex.RequireValue(Sha256, nameof(Sha256), sourceName).ToLowerInvariant();
|
||||
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
MarketIndex.EnsureUrl(DownloadUrl, nameof(DownloadUrl), sourceName);
|
||||
MarketIndex.EnsureUrl(IconUrl, nameof(IconUrl), sourceName);
|
||||
MarketIndex.EnsureUrl(HomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
MarketIndex.EnsureUrl(RepositoryUrl, 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 MarketPlugin
|
||||
{
|
||||
Id = MarketIndex.RequireValue(Id, nameof(Id), sourceName),
|
||||
Name = MarketIndex.RequireValue(Name, nameof(Name), sourceName),
|
||||
Description = MarketIndex.RequireValue(Description, nameof(Description), sourceName),
|
||||
Author = MarketIndex.RequireValue(Author, nameof(Author), sourceName),
|
||||
Version = MarketIndex.NormalizeVersion(Version, nameof(Version), sourceName),
|
||||
ApiVersion = MarketIndex.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
|
||||
MinHostVersion = MarketIndex.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
|
||||
DownloadUrl = MarketIndex.RequireValue(DownloadUrl, nameof(DownloadUrl), sourceName),
|
||||
Sha256 = normalizedSha,
|
||||
PackageSizeBytes = PackageSizeBytes,
|
||||
IconUrl = MarketIndex.RequireValue(IconUrl, nameof(IconUrl), sourceName),
|
||||
HomepageUrl = MarketIndex.RequireValue(HomepageUrl, nameof(HomepageUrl), sourceName),
|
||||
RepositoryUrl = MarketIndex.RequireValue(RepositoryUrl, nameof(RepositoryUrl), sourceName),
|
||||
Tags = normalizedTags,
|
||||
PublishedAt = PublishedAt,
|
||||
UpdatedAt = UpdatedAt,
|
||||
ReleaseNotes = MarketIndex.RequireValue(ReleaseNotes, nameof(ReleaseNotes), sourceName)
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user