mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-21 08:04:26 +08:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f7a18cbc | ||
|
|
cdffaa16eb |
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,26 +1,21 @@
|
||||
# LanAirApp
|
||||
|
||||
`LanAirApp` 是阑山桌面插件生态的对外发布工作区。
|
||||
## 中文
|
||||
|
||||
这里集中放置:
|
||||
- 插件开发标准
|
||||
- 插件打包与构建工具
|
||||
- 插件开发与打包文档
|
||||
- 示例插件
|
||||
`LanAirApp` 是阑山桌面插件生态的对外工作区。这个目录是宿主仓库中的镜像副本,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
|
||||
目录结构:
|
||||
- `docs/`:插件开发文档、打包文档
|
||||
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
|
||||
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
|
||||
- `standards/`:插件标准文件与模板
|
||||
- `tools/`:插件打包与构建工具
|
||||
### 目录说明
|
||||
|
||||
面向用户的安装流程:
|
||||
1. 将插件构建或打包为 `.laapp` 文件。
|
||||
2. 打开 `设置 -> 插件`。
|
||||
3. 点击 `打开 .laapp 插件包`。
|
||||
4. 选择插件包完成安装。
|
||||
- `docs/`:插件开发与打包文档。
|
||||
- `samples/`:示例插件与参考项目。
|
||||
- `standards/`:插件清单和目录结构约定。
|
||||
- `tools/`:插件打包与辅助工具。
|
||||
|
||||
宿主侧的插件加载、安装、发现、解析与设置页接入逻辑,保留在 `LanMountainDesktop/plugins/`。
|
||||
### 与宿主的关系
|
||||
|
||||
`LanMountainDesktop.PluginSdk` 仅作为插件开发 SDK 使用,提供 `IPlugin`、`IPluginContext`、清单模型与扩展注册接口。
|
||||
- 宿主程序只连接独立 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 每个插件项目应在仓库根目录提供 `.laapp` 和 `README.md`。
|
||||
|
||||
## English
|
||||
|
||||
`LanAirApp` is the external-facing workspace for the LanMountainDesktop plugin ecosystem. This copy is only a mirror inside the host repository; the standalone `LanAirApp` repository remains the source of truth.
|
||||
|
||||
@@ -1,41 +1,16 @@
|
||||
# 插件开发文档
|
||||
# 插件开发指南
|
||||
|
||||
LanMountainDesktop 插件基于 `LanMountainDesktop.PluginSdk` 开发。
|
||||
## 中文
|
||||
|
||||
`LanAirApp/` 负责对外发布插件开发标准、示例插件和打包工具;宿主应用内部的插件加载与解析逻辑位于 `LanMountainDesktop/plugins/`。
|
||||
`LanMountainDesktop.PluginSdk` 只提供插件作者需要依赖的开发契约,不再承载宿主侧运行时加载实现。
|
||||
使用 `LanMountainDesktop.PluginSdk` 开发插件时,至少需要准备:
|
||||
|
||||
## 必需文件
|
||||
- `plugin.json`
|
||||
- `plugin.json` 中声明的入口程序集
|
||||
- 使用插件入口特性标记的入口类型
|
||||
- 插件入口程序集
|
||||
- 入口类
|
||||
- 本地化资源
|
||||
|
||||
## 推荐开发流程
|
||||
1. 以 `LanAirApp/samples/LanMountainDesktop.SamplePlugin` 为起点。
|
||||
2. 修改 `plugin.json`,填写你自己的插件 `id`、名称、作者、版本和入口程序集。
|
||||
3. 实现 `IPlugin` 或继承 `PluginBase`。
|
||||
4. 通过 `IPluginContext` 注册服务、设置页和桌面组件。
|
||||
5. 将输出内容打包为 `.laapp` 文件。
|
||||
推荐从示例插件开始,先完成清单、入口、设置页和桌面组件,再逐步扩展业务逻辑。
|
||||
|
||||
## 运行时能力
|
||||
- 插件可以注册自己的设置页。
|
||||
- 插件可以注册自己的桌面组件。
|
||||
- 插件可以注册自己的服务,并通过插件消息总线进行通信。
|
||||
- 宿主优先加载 `.laapp` 包,其次才是散装清单。
|
||||
## English
|
||||
|
||||
## 多语言建议
|
||||
- 插件应当内置 `Localization/zh-CN.json` 与 `Localization/en-US.json`。
|
||||
- 插件界面文案、组件文案、状态文案建议统一通过插件本地化层读取。
|
||||
- 建议优先读取宿主传入的语言代码,再回退到插件默认语言。
|
||||
|
||||
## 目录建议
|
||||
一个标准插件项目建议至少包含:
|
||||
- `plugin.json`
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件程序集与依赖文件
|
||||
|
||||
## 示例项目与工具
|
||||
- 示例插件:`LanAirApp/samples/LanMountainDesktop.SamplePlugin`
|
||||
- 打包工具:`LanAirApp/tools/LanMountainDesktop.PluginPackager`
|
||||
- 标准模板:`LanAirApp/standards/plugin.template.json`
|
||||
To build a plugin with `LanMountainDesktop.PluginSdk`, prepare the manifest, plugin assembly, entrance class, and localization resources first.
|
||||
|
||||
@@ -1,34 +1,14 @@
|
||||
# 插件打包文档
|
||||
# 插件打包指南
|
||||
|
||||
LanMountainDesktop 插件的安装包格式固定为 `.laapp`。
|
||||
## 中文
|
||||
|
||||
`LanAirApp/` 负责提供打包标准与打包工具;`.laapp` 的安装、发现和运行时加载由 `LanMountainDesktop/plugins/` 负责。
|
||||
阑山桌面插件的标准安装格式为 `.laapp`。插件项目应在仓库根目录提供:
|
||||
|
||||
## `.laapp` 格式说明
|
||||
- 本质上是一个标准 zip 压缩包
|
||||
- 包根目录必须包含 `plugin.json`
|
||||
- 包根目录还必须包含入口程序集及其依赖
|
||||
- `.laapp` 安装包
|
||||
- `README.md`
|
||||
|
||||
## 建议打包内容
|
||||
- `plugin.json`
|
||||
- `YourPlugin.dll`
|
||||
- 依赖程序集
|
||||
- `Localization/zh-CN.json`
|
||||
- `Localization/en-US.json`
|
||||
- 插件运行所需的其他资源文件
|
||||
官方市场索引只负责记录链接和校验信息。
|
||||
|
||||
## 使用打包工具
|
||||
```powershell
|
||||
dotnet run --project .\LanAirApp\tools\LanMountainDesktop.PluginPackager -- --input .\path\to\plugin-output --output .\artifacts\YourPlugin.laapp --overwrite
|
||||
```
|
||||
## English
|
||||
|
||||
## 应用内安装流程
|
||||
1. 打开 `设置 -> 插件`
|
||||
2. 点击 `打开 .laapp 插件包`
|
||||
3. 选择要安装的插件包
|
||||
4. 如果插件注册了设置页或组件,安装后重启应用
|
||||
|
||||
## 注意事项
|
||||
- `plugin.json` 中的 `entranceAssembly` 必须能在包内找到。
|
||||
- 包内应尽量避免无关开发产物。
|
||||
- `.laapp` 是标准安装格式,建议不要对外分发散装目录。
|
||||
The standard package format is `.laapp`. Plugin repositories should keep the package and `README.md` in the repository root, while the official market index stores metadata and validation data.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,16 +1,9 @@
|
||||
# LanMountainDesktop.SamplePlugin
|
||||
|
||||
这是阑山桌面的**示例开发插件**。
|
||||
## 中文
|
||||
|
||||
它用于演示以下能力:
|
||||
- 插件入口与 `plugin.json` 清单
|
||||
- 插件服务注册
|
||||
- 插件设置页注册
|
||||
- 插件桌面组件注册
|
||||
- 插件内通信与状态更新
|
||||
- `.laapp` 打包与安装流程
|
||||
- 插件多语言资源组织方式
|
||||
这是阑山桌面的标准示例插件,用于演示插件清单、设置页、桌面组件、服务注册、本地化和 `.laapp` 打包流程。
|
||||
|
||||
如果你要开发自己的插件,建议以这个目录为模板开始。
|
||||
## English
|
||||
|
||||
这个目录仅用于示例开发与打包发布,不承载宿主应用内部的插件加载逻辑。
|
||||
This is the standard sample plugin used to demonstrate manifests, settings pages, desktop components, service registration, localization, and `.laapp` packaging.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# 示例插件
|
||||
# 示例插件目录
|
||||
|
||||
本目录用于存放阑山桌面的示例开发插件。
|
||||
## 中文
|
||||
|
||||
当前示例:
|
||||
- `LanMountainDesktop.SamplePlugin`
|
||||
本目录用于存放阑山桌面的示例插件和参考实现。
|
||||
|
||||
说明:
|
||||
- 这个插件是**示例开发插件**,用于演示插件项目结构、服务注册、设置页注册、桌面组件注册、`.laapp` 打包与安装流程。
|
||||
- 开发新插件时,建议直接从这个示例插件复制一份再修改。
|
||||
- 示例插件属于 `LanAirApp/` 对外开发工作区;宿主应用里的插件运行时与解析实现位于 `LanMountainDesktop/plugins/`。
|
||||
当前标准示例为 `LanMountainDesktop.SamplePlugin`。
|
||||
|
||||
## English
|
||||
|
||||
This directory stores sample plugins and reference implementations. The current standard sample is `LanMountainDesktop.SamplePlugin`.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# 插件标准文件
|
||||
# 插件标准说明
|
||||
|
||||
这里存放 LanMountainDesktop 插件开发所使用的标准模板与约定文件。
|
||||
## 中文
|
||||
|
||||
当前标准:
|
||||
- 安装包扩展名:`.laapp`
|
||||
- 插件清单文件名:`plugin.json`
|
||||
- 多语言资源目录:`Localization/`
|
||||
- 建议内置语言文件:`zh-CN.json`、`en-US.json`
|
||||
本目录存放插件开发需要遵循的基础约定,包括 `.laapp`、`plugin.json`、`Localization/` 以及仓库根目录 README 和安装包等要求。
|
||||
|
||||
创建新插件时,建议优先参考本目录中的模板文件。
|
||||
## English
|
||||
|
||||
This directory stores the baseline conventions for plugin development, including `.laapp`, `plugin.json`, `Localization/`, and repository-root deliverables.
|
||||
|
||||
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
8
LanMountainDesktop.PluginSdk/IPluginPackageManager.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public interface IPluginPackageManager
|
||||
{
|
||||
IReadOnlyList<InstalledPluginInfo> GetInstalledPlugins();
|
||||
|
||||
PluginPackageInstallResult InstallPackage(string packagePath);
|
||||
}
|
||||
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
8
LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record InstalledPluginInfo(
|
||||
PluginManifest Manifest,
|
||||
bool IsEnabled,
|
||||
bool IsLoaded,
|
||||
bool IsPackage,
|
||||
string? ErrorMessage);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace LanMountainDesktop.PluginSdk;
|
||||
|
||||
public sealed record PluginPackageInstallResult(
|
||||
PluginManifest Manifest,
|
||||
bool ReplacedExisting,
|
||||
bool RestartRequired);
|
||||
@@ -1,22 +1,35 @@
|
||||
# MiSans Font Notice
|
||||
# MiSans 字体说明
|
||||
|
||||
This app bundles MiSans fonts for consistent cross-device rendering.
|
||||
## 中文
|
||||
|
||||
## Included files
|
||||
本项目内置 MiSans 字体,用于在不同设备上保持相对一致的文字渲染效果。
|
||||
|
||||
### 包含文件
|
||||
|
||||
- `MiSans-Regular.ttf`
|
||||
- `MiSans-Semibold.ttf`
|
||||
- `MiSans-Bold.ttf`
|
||||
|
||||
## Source
|
||||
### 来源
|
||||
|
||||
- 上游仓库:https://github.com/dsrkafuu/misans
|
||||
- 上游所引用的小米字体页面:https://hyperos.mi.com/font/zh/
|
||||
|
||||
### 许可与使用说明
|
||||
|
||||
- 上游脚本或打包仓库使用 Apache-2.0 许可。
|
||||
- MiSans 字体本身的版权和补充使用条款以小米官方说明为准:
|
||||
- https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
在重新分发本项目时,请自行确认并遵守 MiSans 字体的相关条款。
|
||||
|
||||
## English
|
||||
|
||||
This project bundles MiSans fonts for more consistent cross-device rendering.
|
||||
|
||||
### Sources
|
||||
|
||||
- Upstream package repository: https://github.com/dsrkafuu/misans
|
||||
- Original font source referenced by upstream: https://hyperos.mi.com/font/zh/
|
||||
- Xiaomi font source page: https://hyperos.mi.com/font/zh/
|
||||
|
||||
## License and usage notes
|
||||
|
||||
- Script/package license in upstream repository: Apache-2.0
|
||||
- MiSans font copyright and additional usage terms:
|
||||
https://hyperos.mi.com/font-download/MiSans%E5%AD%97%E4%BD%93%E7%9F%A5%E8%AF%86%E4%BA%A7%E6%9D%83%E8%AE%B8%E5%8F%AF%E5%8D%8F%E8%AE%AE.pdf
|
||||
|
||||
Please review and comply with the MiSans font terms when distributing this app.
|
||||
Please review and comply with the MiSans font terms before redistributing this application.
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Weather Background Assets
|
||||
# 天气背景资源署名
|
||||
|
||||
Weather card background images are sourced from **Pexels** and used under the Pexels license:
|
||||
https://www.pexels.com/license/
|
||||
## 中文
|
||||
|
||||
## Sources
|
||||
本目录中的天气背景图像主要来自 **Pexels**,并按 Pexels License 使用:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
### 原始来源
|
||||
|
||||
- `clear_sky.jpg`
|
||||
- https://www.pexels.com/photo/a-clear-blue-sky-with-few-clouds-on-a-sunny-day-29390199/
|
||||
@@ -14,16 +17,24 @@ https://www.pexels.com/license/
|
||||
- `storm.jpg`
|
||||
- https://www.pexels.com/photo/sea-under-a-stormy-sky-4609228/
|
||||
|
||||
## Derived Variants (for widget scene mapping)
|
||||
### 派生资源
|
||||
|
||||
The following files are generated from the above base assets by color grading/brightness adjustments to match the ColorOS-like weather card style:
|
||||
以下文件由上述基础图片经过色彩、亮度或风格调整后生成,用于适配阑山桌面的天气组件视觉:
|
||||
|
||||
- `clear_day.jpg` (from `clear_sky.jpg`)
|
||||
- `clear_night.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_day.jpg` (from `clear_sky.jpg`)
|
||||
- `cloudy_night.jpg` (from `clear_sky.jpg`)
|
||||
- `rain_light.jpg` (from `rain.jpg`)
|
||||
- `rain_heavy.jpg` (from `rain.jpg`)
|
||||
- `storm_dark.jpg` (from `storm.jpg`)
|
||||
- `fog_haze.jpg` (from `storm.jpg`)
|
||||
- `snow_soft.jpg` (from `snow.jpg`)
|
||||
- `clear_day.jpg`
|
||||
- `clear_night.jpg`
|
||||
- `cloudy_day.jpg`
|
||||
- `cloudy_night.jpg`
|
||||
- `rain_light.jpg`
|
||||
- `rain_heavy.jpg`
|
||||
- `storm_dark.jpg`
|
||||
- `fog_haze.jpg`
|
||||
- `snow_soft.jpg`
|
||||
|
||||
## English
|
||||
|
||||
The weather background images in this directory are primarily sourced from **Pexels** and used under the Pexels License:
|
||||
|
||||
- License: https://www.pexels.com/license/
|
||||
|
||||
Derived variants in this repository are adjusted from the listed base assets for widget presentation.
|
||||
|
||||
@@ -1,45 +1,23 @@
|
||||
# HyperOS3 Weather Assets (Official Xiaomi Package)
|
||||
# HyperOS3 天气资源署名
|
||||
|
||||
## 中文
|
||||
|
||||
本目录中的 HyperOS3 风格天气资源来自用户提供的 Xiaomi Weather 安装包提取内容,以及基于该视觉方向制作的项目内派生资源。
|
||||
|
||||
### 提取来源
|
||||
|
||||
These assets were extracted from the official Xiaomi Weather APK provided by the user:
|
||||
- Source APK: `c:\Program Files\Netease\GameViewer\Download\MI SKY 12.apk`
|
||||
- Package: `com.miui.weather2` (Mi Weather)
|
||||
- Extraction date: 2026-03-03
|
||||
- Package: `com.miui.weather2`
|
||||
- Extraction date: `2026-03-03`
|
||||
|
||||
Extracted source paths inside APK:
|
||||
- `assets/map_custom/particle/sun_0.png` -> `hyper_sun_core.png`
|
||||
- `assets/map_custom/particle/sun_1.png` -> `hyper_sun_ring.png`
|
||||
- `assets/map_custom/particle/fog.png` -> `hyper_fog.png`
|
||||
- `assets/map_custom/particle/haze.png` -> `hyper_haze.png`
|
||||
- `assets/map_custom/particle/rain.png` -> `hyper_rain_drop.png`
|
||||
- `assets/map_custom/particle/snow.png` -> `hyper_snow_flake.png`
|
||||
- `assets/map_custom/skybox/top.png` -> `hyper_sky_top.png`
|
||||
- `assets/map_custom/skybox/back.png` -> `hyper_sky_back.png`
|
||||
- `assets/map_custom/skybox/front.png` -> `hyper_sky_front.png`
|
||||
- `assets/map_custom/skybox/left.png` -> `hyper_sky_left.png`
|
||||
- `assets/map_custom/skybox/right.png` -> `hyper_sky_right.png`
|
||||
- `assets/map_custom/skybox/bottom.png` -> `hyper_sky_bottom.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_day.png` -> `hyper_cross_sky_day.png`
|
||||
- `assets/map_assets/VM3DRes/cross_sky_night.png` -> `hyper_cross_sky_night.png`
|
||||
### 用途说明
|
||||
|
||||
Extracted weather icon paths inside APK (`res/*.webp`):
|
||||
- `res/aO.webp` -> `Icons/icon_sunny_day.webp`
|
||||
- `res/k2.webp` -> `Icons/icon_moon_clear.webp`
|
||||
- `res/Ip.webp` -> `Icons/icon_partly_cloudy_day.webp`
|
||||
- `res/HI.webp` -> `Icons/icon_partly_cloudy_night.webp`
|
||||
- `res/E4.webp` -> `Icons/icon_cloudy.webp`
|
||||
- `res/5f.webp` -> `Icons/icon_rain_light.webp`
|
||||
- `res/fO.webp` -> `Icons/icon_rain_heavy.webp`
|
||||
- `res/lV1.webp` -> `Icons/icon_thunder.webp`
|
||||
- `res/mH1.webp` -> `Icons/icon_snow.webp`
|
||||
- `res/jB.webp` -> `Icons/icon_sleet.webp`
|
||||
- `res/Wl.webp` -> `Icons/icon_haze.webp`
|
||||
- `res/Mg.webp` -> `Icons/icon_windy.webp`
|
||||
- 这些资源仅用于项目内部视觉研究、原型还原和界面适配。
|
||||
- 使用时应遵守小米相关许可与使用条款。
|
||||
|
||||
Use only according to Xiaomi's applicable license and usage terms.
|
||||
### 额外派生资源
|
||||
|
||||
## Soft Widget Icon Set (2026-03-05)
|
||||
|
||||
To better match the Xiaomi weather time-card visual hierarchy, an additional local icon set was generated for this project:
|
||||
以下文件为项目内基于上述视觉方向制作的派生素材:
|
||||
|
||||
- `Icons/icon_hero_sun_soft.png`
|
||||
- `Icons/icon_hero_moon_soft.png`
|
||||
@@ -52,4 +30,8 @@ To better match the Xiaomi weather time-card visual hierarchy, an additional loc
|
||||
- `Icons/icon_mini_snow_soft.png`
|
||||
- `Icons/icon_mini_fog_soft.png`
|
||||
|
||||
These files are original derivative assets generated in-repo with local tooling, using the extracted Xiaomi package visual direction as reference (soft glow hero icon + lightweight forecast icons).
|
||||
## English
|
||||
|
||||
The HyperOS3-style weather assets in this directory were extracted from a Xiaomi Weather APK provided by the user, together with additional derivative assets created in-repo to match the same visual direction.
|
||||
|
||||
Use these resources only in accordance with Xiaomi's applicable license and usage terms.
|
||||
|
||||
@@ -1,77 +1,38 @@
|
||||
# 组件系统模块(Component System Module)
|
||||
# 组件系统说明
|
||||
|
||||
本目录提供组件系统的模块化基础,用于支持内置组件管理与第三方扩展接入。
|
||||
This directory provides the modular foundation for built-in component management and third-party extension integration.
|
||||
## 中文
|
||||
|
||||
## 核心文件职责(Core Files)
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量(例如 `Clock`)。
|
||||
Built-in component ID constants (for example `Clock`).
|
||||
- `DesktopComponentDefinition.cs`:组件元数据定义(名称、类别、最小尺寸、可放置区域等)。
|
||||
Component metadata model (name, category, minimum size, placement permissions).
|
||||
- `ComponentPlacementRules.cs`:组件放置规则(最小尺寸、状态栏高度限制、网格边界约束)。
|
||||
Placement rules (minimum size, status-bar height rule, grid clamping).
|
||||
- `ComponentRegistry.cs`:组件注册中心,负责内置组件与扩展组件合并。
|
||||
Registry that merges built-in and extension components.
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口契约。
|
||||
Extension provider interface contract.
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:基于 JSON 的扩展加载器。
|
||||
JSON-based extension loader.
|
||||
`ComponentSystem/` 提供阑山桌面组件定义、注册和扩展的基础能力。
|
||||
|
||||
## 第三方扩展契约(Extension Contract)
|
||||
- 第三方可通过实现 `IComponentExtensionProvider` 提供组件定义。
|
||||
Third parties can provide component definitions via `IComponentExtensionProvider`.
|
||||
- 当前内置了 JSON 提供者,运行时扫描目录:
|
||||
Built-in JSON provider scans at runtime:
|
||||
- `Extensions/Components/*.json`(相对应用输出目录)
|
||||
`Extensions/Components/*.json` (relative to app output directory)
|
||||
### 主要职责
|
||||
|
||||
## 加载流程(Load Flow)
|
||||
1. `ComponentRegistry.CreateDefault()` 先注册内置组件。
|
||||
Register built-in components first via `ComponentRegistry.CreateDefault()`.
|
||||
2. 调用 `.RegisterExtensions(...)` 合并扩展组件。
|
||||
Merge extension components via `.RegisterExtensions(...)`.
|
||||
3. 主窗口通过注册中心校验组件合法性与放置权限。
|
||||
Main window validates component identity and placement permission through the registry.
|
||||
- 管理内置组件 ID 和元数据
|
||||
- 约束组件最小尺寸与可放置区域
|
||||
- 合并内置组件与扩展组件
|
||||
- 通过 JSON 或扩展提供者接入第三方组件
|
||||
|
||||
## JSON 清单格式(Manifest Schema)
|
||||
JSON 文件为数组,每一项代表一个组件定义。
|
||||
The JSON file is an array, where each item represents one component definition.
|
||||
### 关键文件
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "Weather",
|
||||
"displayName": "Weather",
|
||||
"iconKey": "WeatherSunny",
|
||||
"category": "Status",
|
||||
"minWidthCells": 1,
|
||||
"minHeightCells": 1,
|
||||
"allowStatusBarPlacement": true,
|
||||
"allowDesktopPlacement": true
|
||||
}
|
||||
]
|
||||
```
|
||||
- `BuiltInComponentIds.cs`:内置组件 ID 常量
|
||||
- `DesktopComponentDefinition.cs`:组件元数据模型
|
||||
- `ComponentPlacementRules.cs`:放置规则
|
||||
- `ComponentRegistry.cs`:组件注册中心
|
||||
- `Extensions/IComponentExtensionProvider.cs`:扩展提供者接口
|
||||
- `Extensions/JsonComponentExtensionProvider.cs`:JSON 扩展加载器
|
||||
|
||||
字段说明(Field notes):
|
||||
- `id`:组件唯一 ID(建议英文、稳定不变)。
|
||||
Unique component ID (prefer stable English key).
|
||||
- `displayName`:显示名。
|
||||
Display name.
|
||||
- `iconKey`:图标键(由上层 UI 解释)。
|
||||
Icon key resolved by UI layer.
|
||||
- `category`:组件分类。
|
||||
Component category.
|
||||
- `minWidthCells` / `minHeightCells`:最小占格,必须满足 `>= 1`。
|
||||
Minimum cell size, must satisfy `>= 1`.
|
||||
- `allowStatusBarPlacement`:是否允许放到顶部状态栏。
|
||||
Whether placing in top status bar is allowed.
|
||||
- `allowDesktopPlacement`:是否允许放到桌面区域。
|
||||
Whether placing in desktop area is allowed.
|
||||
### 扩展方式
|
||||
|
||||
## 放置规则摘要(Placement Rules Summary)
|
||||
- 最小尺寸约束:`minWidthCells >= 1` 且 `minHeightCells >= 1`。
|
||||
Minimum size constraint: `minWidthCells >= 1` and `minHeightCells >= 1`.
|
||||
- 状态栏约束:状态栏组件高度必须为 `1` 格。
|
||||
Status bar constraint: component height must be exactly `1` cell.
|
||||
- 越界约束:所有组件坐标会被网格边界钳制(clamp)。
|
||||
Out-of-bounds constraint: component coordinates are clamped to grid bounds.
|
||||
- 当前默认扫描 `Extensions/Components/*.json`
|
||||
- 组件清单定义显示名、分类、最小尺寸和可放置区域
|
||||
- 主程序通过注册中心统一验证组件是否合法
|
||||
|
||||
## English
|
||||
|
||||
`ComponentSystem/` contains the foundation for component definition, registration, and extension in LanMountainDesktop.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- manage built-in component IDs and metadata
|
||||
- enforce placement rules
|
||||
- merge built-in and extension components
|
||||
- support third-party registration through JSON or provider contracts
|
||||
|
||||
@@ -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",
|
||||
@@ -320,6 +338,44 @@
|
||||
"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": "例如:北京",
|
||||
@@ -320,6 +338,44 @@
|
||||
"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": "桌面编辑",
|
||||
|
||||
@@ -1,75 +1,51 @@
|
||||
# Desktop Packaging Guide
|
||||
# 桌面端打包指南
|
||||
|
||||
## Prerequisites
|
||||
- Install `.NET SDK 10`
|
||||
- Windows installer build only:
|
||||
- Install `Inno Setup 6` (`ISCC.exe`)
|
||||
## 中文
|
||||
|
||||
## Local packaging commands
|
||||
本指南说明阑山桌面的本地打包和 CI 打包流程。
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 安装 .NET SDK 10
|
||||
- Windows 安装包需要 Inno Setup 6(`ISCC.exe`)
|
||||
|
||||
### 本地打包命令
|
||||
|
||||
#### Windows 安装包
|
||||
|
||||
### Windows installer (`win-x64`)
|
||||
```powershell
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/win-x64`
|
||||
- Installer: `artifacts/installer`
|
||||
#### Linux 包
|
||||
|
||||
### Linux package (`linux-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/linux-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-linux-x64.zip`
|
||||
#### macOS 包
|
||||
|
||||
### macOS package (`osx-x64`)
|
||||
```powershell
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier osx-x64 -Version 1.0.1
|
||||
```
|
||||
|
||||
Output:
|
||||
- Published files: `artifacts/publish/osx-x64`
|
||||
- Zip package: `artifacts/packages/LanMountainDesktop-1.0.1-osx-x64.zip`
|
||||
### 产物位置
|
||||
|
||||
## Optional script flags
|
||||
```powershell
|
||||
# Publish only (skip Windows installer step)
|
||||
.\scripts\package.ps1 -RuntimeIdentifier win-x64 -SkipInstaller
|
||||
- 发布目录:`artifacts/publish/<rid>`
|
||||
- 安装包或压缩包:`artifacts/installer` 或 `artifacts/packages`
|
||||
|
||||
# Publish only (skip Linux/macOS zip package step)
|
||||
pwsh ./scripts/package.ps1 -RuntimeIdentifier linux-x64 -SkipArchive
|
||||
```
|
||||
### CI 流程
|
||||
|
||||
## Runtime dependency notes
|
||||
- Linux build does not bundle a native `libvlc` package from NuGet.
|
||||
- Install VLC runtime on target machine, for example:
|
||||
- Ubuntu/Debian: `sudo apt install vlc libvlc-dev`
|
||||
- macOS packaging target in CI is currently `osx-x64`.
|
||||
- 工作流文件:`.github/workflows/windows-ci.yml`
|
||||
- 日常构建会验证桌面端可编译
|
||||
- 手动触发或 `v*` 标签可生成正式包并上传到 Release
|
||||
|
||||
## CI workflow
|
||||
- Workflow file: `.github/workflows/windows-ci.yml`
|
||||
- Workflow name: `Desktop CI`
|
||||
## English
|
||||
|
||||
Jobs:
|
||||
- `Validate Build (Windows)` runs on every push and pull request.
|
||||
- Package flow runs on manual trigger or `v*` tag push:
|
||||
- `Resolve Package Version` (single shared version source)
|
||||
- `Package (Windows)` (`win-x64` installer)
|
||||
- `Package (Linux)` (`linux-x64` zip)
|
||||
- `Package (macOS)` (`osx-x64` zip)
|
||||
- On `v*` tags, `Attach Artifacts to GitHub Release` uploads Windows/Linux/macOS packages to the release.
|
||||
This guide covers local packaging and CI packaging for LanMountainDesktop.
|
||||
|
||||
### Trigger manual packaging
|
||||
1. Open GitHub Actions.
|
||||
2. Choose `Desktop CI`.
|
||||
3. Click `Run workflow`.
|
||||
4. Optional: set `version` input, for example `1.0.1`.
|
||||
### Key points
|
||||
|
||||
### Trigger by tag
|
||||
```powershell
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
```
|
||||
- use `scripts/package.ps1` with the target runtime identifier
|
||||
- Windows installer requires Inno Setup
|
||||
- CI can publish artifacts and attach them to GitHub Releases
|
||||
|
||||
@@ -242,6 +242,23 @@ public sealed class GitHubReleaseUpdateService : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GitHubReleaseInfo?> GetReleaseByTagAsync(
|
||||
string tagName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var url =
|
||||
$"https://api.github.com/repos/{_owner}/{_repo}/releases/tags/{Uri.EscapeDataString(tagName.Trim())}";
|
||||
var responseText = await GetResponseTextAsync(url, cancellationToken);
|
||||
|
||||
using var document = JsonDocument.Parse(responseText);
|
||||
return ParseRelease(document.RootElement);
|
||||
}
|
||||
|
||||
private async Task<GitHubReleaseInfo?> GetLatestStableReleaseAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest";
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -418,6 +422,7 @@ public partial class MainWindow
|
||||
WeatherLocationStatusTextBlock.Text = L(
|
||||
"settings.weather.status_city_empty",
|
||||
"No city location is configured.");
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,6 +435,7 @@ public partial class MainWindow
|
||||
modeText,
|
||||
locationName,
|
||||
_weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,6 +448,7 @@ public partial class MainWindow
|
||||
string.IsNullOrWhiteSpace(_weatherLocationKey)
|
||||
? BuildCoordinateLocationKey(_weatherLatitude, _weatherLongitude)
|
||||
: _weatherLocationKey);
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1396,6 +1396,8 @@ public partial class MainWindow
|
||||
{
|
||||
WeatherCoordinateSettingsExpander.IsVisible = _weatherLocationMode == WeatherLocationMode.Coordinates;
|
||||
}
|
||||
|
||||
UpdateWeatherLocationSummaryCard();
|
||||
}
|
||||
|
||||
private void OnWeatherLocationModeSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
@@ -1879,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;
|
||||
|
||||
@@ -1922,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);
|
||||
@@ -1941,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
|
||||
@@ -1960,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)
|
||||
@@ -2661,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")!;
|
||||
@@ -2691,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")!;
|
||||
@@ -2698,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")!;
|
||||
|
||||
@@ -205,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;
|
||||
@@ -221,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);
|
||||
@@ -787,6 +813,11 @@ public partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (radioButton.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_clockDisplayFormat = formatTag == "Hm"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -48,10 +48,166 @@ public partial class SettingsWindow
|
||||
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()
|
||||
@@ -61,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";
|
||||
@@ -279,6 +429,11 @@ public partial class SettingsWindow
|
||||
return;
|
||||
}
|
||||
|
||||
if (radioButton.IsChecked != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_clockDisplayFormat = formatTag == "Hm"
|
||||
? ClockDisplayFormat.HourMinute
|
||||
: ClockDisplayFormat.HourMinuteSecond;
|
||||
@@ -375,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.");
|
||||
|
||||
@@ -14,6 +14,7 @@ using Avalonia.Threading;
|
||||
using FluentIcons.Common;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
|
||||
namespace LanMountainDesktop.Views;
|
||||
|
||||
@@ -225,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)
|
||||
@@ -584,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;
|
||||
|
||||
@@ -605,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
|
||||
@@ -658,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;
|
||||
}
|
||||
|
||||
@@ -673,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,121 +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">
|
||||
<Grid RowDefinitions="*,Auto"
|
||||
RowSpacing="14">
|
||||
<ui:NavigationView x:Name="SettingsNavView"
|
||||
Grid.Row="0"
|
||||
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">
|
||||
@@ -157,23 +255,23 @@
|
||||
<pages:PluginSettingsPage x:Name="PluginSettingsPanel" IsVisible="False" />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</ui:NavigationView>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PendingRestartDock"
|
||||
Grid.Row="1"
|
||||
IsVisible="False"
|
||||
Classes="glass-panel"
|
||||
CornerRadius="18"
|
||||
Padding="14,12">
|
||||
Classes="settings-shell-card"
|
||||
Padding="16,14"
|
||||
CornerRadius="24">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto"
|
||||
ColumnSpacing="12">
|
||||
<Border Width="34"
|
||||
Height="34"
|
||||
CornerRadius="17"
|
||||
ColumnSpacing="14">
|
||||
<Border Width="38"
|
||||
Height="38"
|
||||
CornerRadius="14"
|
||||
Background="{DynamicResource AdaptiveAccentBrush}">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
IconVariant="Regular"
|
||||
FontSize="16"
|
||||
FontSize="18"
|
||||
Foreground="White"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
@@ -182,7 +280,7 @@
|
||||
Spacing="2"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock x:Name="PendingRestartDockTitleTextBlock"
|
||||
FontSize="13"
|
||||
FontSize="14"
|
||||
FontWeight="SemiBold"
|
||||
Text="Restart required" />
|
||||
<TextBlock x:Name="PendingRestartDockDescriptionTextBlock"
|
||||
@@ -192,7 +290,7 @@
|
||||
</StackPanel>
|
||||
<Button x:Name="PendingRestartDockButton"
|
||||
Grid.Column="2"
|
||||
Padding="14,8"
|
||||
Padding="16,8"
|
||||
Click="OnPendingRestartDockButtonClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<fi:FluentIcon Icon="ArrowSync"
|
||||
@@ -204,10 +302,9 @@
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
</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,8 +135,6 @@ 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;
|
||||
@@ -156,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;
|
||||
|
||||
@@ -163,6 +162,7 @@ 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;
|
||||
@@ -184,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;
|
||||
@@ -199,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;
|
||||
@@ -283,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}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
812
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
812
LanMountainDesktop/plugins/PluginMarketEmbeddedView.cs
Normal file
@@ -0,0 +1,812 @@
|
||||
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 AirAppMarketReadmeService _readmeService;
|
||||
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 readonly Dictionary<string, string> _readmeContents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _readmeErrors = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
|
||||
private string? _loadingReadmePluginId;
|
||||
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);
|
||||
_readmeService = new AirAppMarketReadmeService();
|
||||
_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()
|
||||
{
|
||||
_readmeService.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();
|
||||
await EnsureReadmeLoadedAsync(_selectedPlugin);
|
||||
}
|
||||
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();
|
||||
_ = EnsureReadmeLoadedAsync(_selectedPlugin);
|
||||
}
|
||||
|
||||
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 += async (_, _) =>
|
||||
{
|
||||
_selectedPlugin = plugin;
|
||||
RebuildSurface();
|
||||
await EnsureReadmeLoadedAsync(plugin);
|
||||
};
|
||||
|
||||
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.project", "Project"), plugin.ProjectUrl),
|
||||
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
|
||||
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
|
||||
new TextBlock
|
||||
{
|
||||
Text = T("market.detail.readme", "README"),
|
||||
FontSize = 18,
|
||||
FontWeight = FontWeight.SemiBold
|
||||
},
|
||||
new Border
|
||||
{
|
||||
Background = SurfaceBrush,
|
||||
CornerRadius = new CornerRadius(16),
|
||||
Padding = new Thickness(14),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = GetReadmeContent(plugin),
|
||||
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 async Task EnsureReadmeLoadedAsync(AirAppMarketPluginEntry? plugin)
|
||||
{
|
||||
if (plugin is null ||
|
||||
_readmeContents.ContainsKey(plugin.Id) ||
|
||||
string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loadingReadmePluginId = plugin.Id;
|
||||
_readmeErrors.Remove(plugin.Id);
|
||||
BuildDetailPanel();
|
||||
|
||||
try
|
||||
{
|
||||
var readme = await _readmeService.LoadAsync(plugin);
|
||||
_readmeContents[plugin.Id] = string.IsNullOrWhiteSpace(readme)
|
||||
? T("market.detail.readme_empty", "README is empty.")
|
||||
: readme.Trim();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_readmeErrors[plugin.Id] = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loadingReadmePluginId = null;
|
||||
if (string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
BuildDetailPanel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string GetReadmeContent(AirAppMarketPluginEntry plugin)
|
||||
{
|
||||
if (_readmeContents.TryGetValue(plugin.Id, out var readme))
|
||||
{
|
||||
return readme;
|
||||
}
|
||||
|
||||
if (_readmeErrors.TryGetValue(plugin.Id, out var error))
|
||||
{
|
||||
return F(
|
||||
"market.detail.readme_error_format",
|
||||
"README could not be loaded: {0}",
|
||||
error);
|
||||
}
|
||||
|
||||
if (string.Equals(_loadingReadmePluginId, plugin.Id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return T("market.detail.readme_loading", "Loading README...");
|
||||
}
|
||||
|
||||
return plugin.ReleaseNotes;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
101
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
101
LanMountainDesktop/plugins/PluginMarketInstallService.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
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 AirAppMarketReleaseResolverService _releaseResolverService;
|
||||
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");
|
||||
_releaseResolverService = new AirAppMarketReleaseResolverService(_httpClient);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
var resolvedDownloadUrl = await _releaseResolverService.ResolveDownloadUrlAsync(plugin, cancellationToken);
|
||||
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(resolvedDownloadUrl, 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(
|
||||
resolvedDownloadUrl,
|
||||
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());
|
||||
}
|
||||
}
|
||||
544
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
544
LanMountainDesktop/plugins/PluginMarketModels.cs
Normal file
@@ -0,0 +1,544 @@
|
||||
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";
|
||||
|
||||
public static string BuildGitHubReleaseDownloadUrl(
|
||||
string owner,
|
||||
string repositoryName,
|
||||
string releaseTag,
|
||||
string assetName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(owner);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(repositoryName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(releaseTag);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(assetName);
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"https://github.com/{owner.Trim()}/{repositoryName.Trim()}/releases/download/{Uri.EscapeDataString(releaseTag.Trim())}/{Uri.EscapeDataString(assetName.Trim())}");
|
||||
}
|
||||
|
||||
public static string? TryGetWorkspaceIndexPath()
|
||||
{
|
||||
var repositoryRoot = TryGetWorkspaceRepositoryRoot("LanAirApp");
|
||||
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;
|
||||
|
||||
string repositoryName;
|
||||
string relativePath;
|
||||
|
||||
if (TryParseGitHubReleaseDownloadUrl(url, out repositoryName, out var releaseAssetName))
|
||||
{
|
||||
relativePath = releaseAssetName;
|
||||
}
|
||||
else if (!TryParseRawGitHubUrl(url, out repositoryName, out relativePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var repositoryRoot = TryGetWorkspaceRepositoryRoot(repositoryName);
|
||||
if (repositoryRoot is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
|
||||
if (!File.Exists(candidatePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
localPath = candidatePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryParseGitHubRepositoryUrl(
|
||||
string? url,
|
||||
out string owner,
|
||||
out string repositoryName)
|
||||
{
|
||||
owner = string.Empty;
|
||||
repositoryName = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = uri.AbsolutePath
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length != 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
owner = segments[0];
|
||||
repositoryName = segments[1];
|
||||
return !string.IsNullOrWhiteSpace(owner) && !string.IsNullOrWhiteSpace(repositoryName);
|
||||
}
|
||||
|
||||
private static string? TryGetWorkspaceRepositoryRoot(string repositoryName)
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(current.FullName, repositoryName);
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseRawGitHubUrl(
|
||||
string url,
|
||||
out string repositoryName,
|
||||
out string relativePath)
|
||||
{
|
||||
repositoryName = string.Empty;
|
||||
relativePath = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = uri.AbsolutePath
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length < 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
repositoryName = segments[1];
|
||||
relativePath = Path.Combine(segments[3..]).Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(relativePath);
|
||||
}
|
||||
|
||||
private static bool TryParseGitHubReleaseDownloadUrl(
|
||||
string url,
|
||||
out string repositoryName,
|
||||
out string assetName)
|
||||
{
|
||||
repositoryName = string.Empty;
|
||||
assetName = string.Empty;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
|
||||
!string.Equals(uri.Host, "github.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = uri.AbsolutePath
|
||||
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (segments.Length != 6 ||
|
||||
!string.Equals(segments[2], "releases", StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(segments[3], "download", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
repositoryName = segments[1];
|
||||
assetName = Uri.UnescapeDataString(segments[5]);
|
||||
return !string.IsNullOrWhiteSpace(repositoryName) && !string.IsNullOrWhiteSpace(assetName);
|
||||
}
|
||||
}
|
||||
|
||||
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 string NormalizeReleaseTag(string? value, string propertyName, string sourceName)
|
||||
{
|
||||
var normalized = RequireValue(value, propertyName, sourceName);
|
||||
if (!normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid release tag '{normalized}' for '{propertyName}'. Expected format 'v1.2.3'.");
|
||||
}
|
||||
|
||||
if (!TryParseVersion(normalized[1..], out _))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid release tag '{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 string NormalizeGitHubRepositoryUrl(
|
||||
string url,
|
||||
string propertyName,
|
||||
string sourceName)
|
||||
{
|
||||
EnsureUrl(url, propertyName, sourceName);
|
||||
|
||||
if (!AirAppMarketDefaults.TryParseGitHubRepositoryUrl(url, out var owner, out var repositoryName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' declares invalid GitHub repository url '{url}' for '{propertyName}'.");
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"https://github.com/{owner}/{repositoryName}");
|
||||
}
|
||||
|
||||
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 ReleaseTag { get; init; } = string.Empty;
|
||||
|
||||
public string ReleaseAssetName { get; init; } = string.Empty;
|
||||
|
||||
public string ProjectUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ReadmeUrl { 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 bool HasReleaseDownloadMetadata =>
|
||||
!string.IsNullOrWhiteSpace(ReleaseTag) &&
|
||||
!string.IsNullOrWhiteSpace(ReleaseAssetName);
|
||||
|
||||
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 normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeValue(ReleaseTag);
|
||||
var normalizedReleaseAssetName = AirAppMarketIndexDocument.NormalizeValue(ReleaseAssetName);
|
||||
var normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeValue(ProjectUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(ProjectUrl)}'.");
|
||||
var normalizedReadmeUrl = AirAppMarketIndexDocument.NormalizeValue(ReadmeUrl)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' is missing required property '{nameof(ReadmeUrl)}'.");
|
||||
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);
|
||||
normalizedProjectUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedProjectUrl,
|
||||
nameof(ProjectUrl),
|
||||
sourceName);
|
||||
normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeGitHubRepositoryUrl(
|
||||
normalizedRepositoryUrl,
|
||||
nameof(RepositoryUrl),
|
||||
sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedReadmeUrl, nameof(ReadmeUrl), sourceName);
|
||||
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(normalizedReleaseTag) != string.IsNullOrWhiteSpace(normalizedReleaseAssetName))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Market index '{sourceName}' must declare both '{nameof(ReleaseTag)}' and '{nameof(ReleaseAssetName)}' together for plugin '{Id}'.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(normalizedReleaseTag))
|
||||
{
|
||||
normalizedReleaseTag = AirAppMarketIndexDocument.NormalizeReleaseTag(
|
||||
normalizedReleaseTag,
|
||||
nameof(ReleaseTag),
|
||||
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,
|
||||
ReleaseTag = normalizedReleaseTag ?? string.Empty,
|
||||
ReleaseAssetName = normalizedReleaseAssetName ?? string.Empty,
|
||||
ProjectUrl = normalizedProjectUrl,
|
||||
ReadmeUrl = normalizedReadmeUrl,
|
||||
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);
|
||||
}
|
||||
}
|
||||
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal file
42
LanMountainDesktop/plugins/PluginMarketReadmeService.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketReadmeService : IDisposable
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketReadmeService()
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
|
||||
}
|
||||
|
||||
public async Task<string> LoadAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.ReadmeUrl, out var localReadmePath))
|
||||
{
|
||||
return await File.ReadAllTextAsync(localReadmePath, cancellationToken);
|
||||
}
|
||||
|
||||
using var response = await _httpClient.GetAsync(plugin.ReadmeUrl, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
|
||||
namespace LanMountainDesktop.Views.SettingsPages;
|
||||
|
||||
internal sealed class AirAppMarketReleaseResolverService
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public AirAppMarketReleaseResolverService(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<string> ResolveDownloadUrlAsync(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugin);
|
||||
|
||||
if (!plugin.HasReleaseDownloadMetadata)
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
}
|
||||
|
||||
if (!TryGetRepositoryIdentity(plugin, out var owner, out var repositoryName))
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
}
|
||||
|
||||
var releaseDownloadUrl = AirAppMarketDefaults.BuildGitHubReleaseDownloadUrl(
|
||||
owner,
|
||||
repositoryName,
|
||||
plugin.ReleaseTag,
|
||||
plugin.ReleaseAssetName);
|
||||
|
||||
if (AirAppMarketDefaults.TryResolveWorkspaceFile(releaseDownloadUrl, out _))
|
||||
{
|
||||
return releaseDownloadUrl;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var updateService = new GitHubReleaseUpdateService(owner, repositoryName, _httpClient);
|
||||
var release = await updateService.GetReleaseByTagAsync(plugin.ReleaseTag, cancellationToken);
|
||||
var asset = release?.Assets.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Name, plugin.ReleaseAssetName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return asset?.BrowserDownloadUrl ?? plugin.DownloadUrl;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return plugin.DownloadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetRepositoryIdentity(
|
||||
AirAppMarketPluginEntry plugin,
|
||||
out string owner,
|
||||
out string repositoryName)
|
||||
{
|
||||
owner = string.Empty;
|
||||
repositoryName = string.Empty;
|
||||
|
||||
return AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.RepositoryUrl, out owner, out repositoryName) ||
|
||||
AirAppMarketDefaults.TryParseGitHubRepositoryUrl(plugin.ProjectUrl, out owner, out repositoryName);
|
||||
}
|
||||
}
|
||||
@@ -18,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 = [];
|
||||
@@ -27,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());
|
||||
}
|
||||
|
||||
@@ -96,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)
|
||||
};
|
||||
|
||||
@@ -179,6 +183,24 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -197,7 +219,7 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
Directory.CreateDirectory(PluginsDirectory);
|
||||
|
||||
var manifest = ReadManifestFromPackage(fullPackagePath);
|
||||
RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
var replacedExisting = RemoveExistingPluginPackages(manifest.Id, fullPackagePath);
|
||||
|
||||
var destinationPath = Path.Combine(PluginsDirectory, BuildInstalledPackageFileName(manifest.Id));
|
||||
if (!string.Equals(fullPackagePath, Path.GetFullPath(destinationPath), StringComparison.OrdinalIgnoreCase))
|
||||
@@ -205,7 +227,10 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
File.Copy(fullPackagePath, destinationPath, overwrite: true);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
UpdateCatalogAfterPackageInstall(manifest, destinationPath);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
|
||||
return new PluginPackageInstallResult(manifest, replacedExisting, RestartRequired: true);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -303,8 +328,9 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
return PluginManifest.Load(stream, $"{packagePath}!/{entries[0].FullName}");
|
||||
}
|
||||
|
||||
private void RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
private bool RemoveExistingPluginPackages(string pluginId, string packagePathToKeep)
|
||||
{
|
||||
var replacedExisting = false;
|
||||
foreach (var existingPackagePath in EnumerateCandidatePaths($"*{PluginSdkInfo.PackageFileExtension}"))
|
||||
{
|
||||
if (string.Equals(
|
||||
@@ -324,12 +350,40 @@ public sealed class PluginRuntimeService : IDisposable
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -395,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public partial class PluginSettingsPage : UserControl
|
||||
|
||||
private readonly AppSettingsService _appSettingsService = new();
|
||||
private readonly LocalizationService _localizationService = new();
|
||||
private PluginMarketEmbeddedView? _pluginMarketView;
|
||||
private string? _packageImportStatusMessage;
|
||||
private bool _packageImportStatusIsError;
|
||||
|
||||
@@ -33,6 +34,13 @@ public partial class PluginSettingsPage : UserControl
|
||||
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)
|
||||
{
|
||||
@@ -40,13 +48,33 @@ public partial class PluginSettingsPage : UserControl
|
||||
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");
|
||||
@@ -140,8 +168,7 @@ public partial class PluginSettingsPage : UserControl
|
||||
VerticalAlignment = VerticalAlignment.Center
|
||||
};
|
||||
|
||||
enabledToggle.Checked += (_, _) => OnPluginEnableChanged(runtime, entry, true);
|
||||
enabledToggle.Unchecked += (_, _) => OnPluginEnableChanged(runtime, entry, false);
|
||||
enabledToggle.IsCheckedChanged += (_, _) => OnPluginEnableChanged(runtime, entry, enabledToggle.IsChecked == true);
|
||||
|
||||
var header = new Grid
|
||||
{
|
||||
@@ -247,9 +274,6 @@ public partial class PluginSettingsPage : UserControl
|
||||
}
|
||||
|
||||
var manifest = runtime.InstallPluginPackage(temporaryPackagePath);
|
||||
runtime.LoadInstalledPlugins();
|
||||
RefreshPluginNavigation(topLevel);
|
||||
PendingRestartStateService.SetPending(PendingRestartStateService.PluginCatalogReason, true);
|
||||
RefreshFromRuntime();
|
||||
SetPackageImportStatus(
|
||||
F(
|
||||
|
||||
@@ -82,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>
|
||||
|
||||
@@ -1,33 +1,57 @@
|
||||
# 宿主侧插件运行时
|
||||
|
||||
这个目录用于归档阑山桌面宿主侧的插件相关实现。
|
||||
## 中文
|
||||
|
||||
职责范围:
|
||||
- 已安装插件的发现
|
||||
- `.laapp` 安装包安装与替换
|
||||
- 插件运行时加载
|
||||
- 插件贡献的设置页与桌面组件接入
|
||||
- 宿主侧插件设置页的安装、显示与刷新
|
||||
本目录保存阑山桌面宿主程序中的插件运行时实现。
|
||||
|
||||
### 主要职责
|
||||
|
||||
- 发现已安装插件
|
||||
- 安装和替换 `.laapp` 插件包
|
||||
- 加载插件程序集
|
||||
- 接入插件贡献的设置页和桌面组件
|
||||
- 在宿主设置界面中展示插件与市场信息
|
||||
|
||||
### 市场安装优先级
|
||||
|
||||
1. 宿主先连接 `LanAirApp/airappmarket/index.json`。
|
||||
2. 当条目同时提供 `releaseTag` 和 `releaseAssetName` 时,宿主优先按精确标签读取插件仓库的 GitHub Release 资产。
|
||||
3. 如果 Release 不存在、资产缺失、GitHub API 失败,或当前是本地工作区测试但找不到远程资产,宿主会退回 `downloadUrl` 指向的仓库根目录 `.laapp`。
|
||||
4. 插件介绍始终读取仓库根目录 `README.md`。
|
||||
5. 安装完成后只做暂存,重启后生效,不在运行时热重载市场安装插件。
|
||||
|
||||
### 核心文件
|
||||
|
||||
当前宿主侧核心文件:
|
||||
- `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`
|
||||
- `PluginMarketIndexService.cs`
|
||||
- `PluginMarketInstallService.cs`
|
||||
|
||||
说明:
|
||||
- 插件开发标准、插件打包工具、示例插件与开发文档统一放在仓库根目录下的 `LanAirApp/`
|
||||
- 宿主本体的插件加载、解析、安装与插件设置页接入逻辑统一放在 `LanMountainDesktop/plugins/`
|
||||
- `LanMountainDesktop.PluginSdk` 只保留插件作者需要引用的契约、清单模型和扩展注册接口
|
||||
### 与 `LanAirApp` 的分工
|
||||
|
||||
- `LanAirApp` 负责插件开发文档、示例、市场索引和校验工具。
|
||||
- 宿主目录负责运行时发现、安装、加载和界面接入。
|
||||
|
||||
## English
|
||||
|
||||
This directory contains the host-side plugin runtime for LanMountainDesktop.
|
||||
|
||||
### Responsibilities
|
||||
|
||||
- discover installed plugins
|
||||
- install and replace `.laapp` packages
|
||||
- load plugin assemblies
|
||||
- integrate plugin settings pages and desktop components
|
||||
- expose market and plugin management in the host UI
|
||||
|
||||
### Market install order
|
||||
|
||||
1. The host reads `LanAirApp/airappmarket/index.json`.
|
||||
2. If an entry declares both `releaseTag` and `releaseAssetName`, the host first resolves the exact GitHub Release asset.
|
||||
3. If Release resolution fails, the host falls back to the repository root `.laapp` from `downloadUrl`.
|
||||
4. Plugin details always come from the repository root `README.md`.
|
||||
5. Market installs are staged and take effect after restart.
|
||||
|
||||
@@ -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)
|
||||
@@ -141,43 +133,48 @@ public partial class SettingsWindow
|
||||
|
||||
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)
|
||||
if (_pluginSettingsNavItems.TryGetValue(pair.Key, out var navItem))
|
||||
{
|
||||
SettingsNavView.MenuItems.Remove(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;
|
||||
}
|
||||
@@ -188,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
77
README.md
77
README.md
@@ -1,47 +1,48 @@
|
||||
# LanMountainDesktop
|
||||
# 阑山桌面(LanMountainDesktop)
|
||||
|
||||
> 你的桌面,不止一面。
|
||||
## 中文
|
||||
|
||||
`LanMountainDesktop` 是一个基于 Avalonia 的桌面壳层项目,目标不是“做一个启动器”,而是把桌面变成可编排的信息与交互空间。
|
||||
阑山桌面是一个基于 Avalonia 的桌面壳层项目。它不是单纯的启动器,而是一个可编排、可扩展、可长期演进的桌面信息空间。
|
||||
|
||||
> ⚠️ **注意**:该项目使用 Vibe Coding,介意勿用。
|
||||
## 项目定位
|
||||
- 以网格化布局组织桌面组件,支持多页桌面与组件自由摆放。
|
||||
- 提供顶部状态栏 + 底部任务栏的桌面框架,强调信息密度与可读性平衡。
|
||||
- 通过主题色、日夜模式、玻璃视觉与动画系统,形成统一的视觉语言。
|
||||
- 通过组件注册机制与 JSON 扩展入口,让桌面能力可持续扩展。
|
||||
### 核心目标
|
||||
|
||||
## 核心能力
|
||||
- 桌面组件系统:天气、时钟、计时器、课程表、日历、白板、音乐控制、学习环境等组件可组合使用。
|
||||
- 壁纸系统:支持图片与视频壁纸,并可在设置中实时预览。
|
||||
- 主题系统:支持日夜模式、主题色与调色联动(Monet 风格色板)。
|
||||
- 个性化设置:网格密度、状态栏间距、任务栏布局、语言与时区等可持久化配置。
|
||||
- 本地化:内置 `zh-CN` 与 `en-US` 资源。
|
||||
- 通过网格化布局管理桌面组件。
|
||||
- 提供状态栏、任务栏和多页桌面的统一外壳。
|
||||
- 通过主题、玻璃效果和动效塑造统一体验。
|
||||
- 通过组件系统和插件系统持续扩展能力。
|
||||
|
||||
## 工程结构
|
||||
- `LanMountainDesktop/`:桌面端主程序(Avalonia)。
|
||||
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端服务(ASP.NET Core Minimal API)。
|
||||
- `docs/`:视觉与圆角等规范文档。
|
||||
- `LanMountainDesktop/ComponentSystem/`:组件定义、注册、放置规则与扩展入口。
|
||||
### 当前工程结构
|
||||
|
||||
## 技术栈
|
||||
- .NET 10(`net10.0`)
|
||||
- Avalonia 11
|
||||
- FluentAvalonia + FluentIcons.Avalonia
|
||||
- LibVLCSharp(用于视频相关能力)
|
||||
- WebView.Avalonia(嵌入式网页组件能力)
|
||||
- `LanMountainDesktop/`:桌面主程序。
|
||||
- `LanMountainDesktop.RecommendationBackend/`:推荐内容后端。
|
||||
- `LanMountainDesktop/ComponentSystem/`:组件定义与注册系统。
|
||||
- `LanMountainDesktop/plugins/`:宿主侧插件加载、安装和设置集成。
|
||||
- `docs/`:视觉与设计规范。
|
||||
- `LanAirApp/`:插件开发资料镜像,权威版本以独立 `LanAirApp` 仓库为准。
|
||||
|
||||
## 扩展机制(摘要)
|
||||
- 组件系统通过 `ComponentRegistry` 合并内置组件与扩展组件。
|
||||
- 运行时会扫描 `Extensions/Components/*.json`(相对应用输出目录)加载第三方组件清单。
|
||||
- 扩展契约与字段说明见组件系统文档:`LanMountainDesktop/ComponentSystem/README.md`。
|
||||
### 生态关系
|
||||
|
||||
## 当前状态
|
||||
- 项目包含桌面端与推荐后端两个子项目,并在同一 solution 中维护。
|
||||
- 通用应用配置默认写入本地:`%LOCALAPPDATA%\LanMountainDesktop\settings.json`。
|
||||
- 启动台与桌面布局已拆分到独立文件:`%LOCALAPPDATA%\LanMountainDesktop\launcher-settings.json`、`%LOCALAPPDATA%\LanMountainDesktop\desktop-layout-settings.json`。
|
||||
- 组件配置统一写入:`%LOCALAPPDATA%\LanMountainDesktop\component-settings.json`;同类组件按实例 `componentId::placementId` 隔离存储,同时预留插件专属配置区。
|
||||
- 当前体验以 Windows 为主要目标平台。
|
||||
- 宿主程序只连接 `LanAirApp` 仓库中的官方市场索引。
|
||||
- 官方市场索引返回插件列表以及各插件项目根目录链接。
|
||||
- 插件项目根目录提供 `.laapp` 安装包和 `README.md`。
|
||||
|
||||
## 运行说明
|
||||
运行与环境准备已拆分到独立文档:[`run.md`](./run.md)
|
||||
### 当前状态
|
||||
|
||||
- Windows 是当前主要目标平台。
|
||||
- 已提供组件系统、插件系统、主题系统和设置系统。
|
||||
- 中文为主语言,英文为附加扩展语言。
|
||||
|
||||
### 运行说明
|
||||
|
||||
运行方法见 [run.md](./run.md)。
|
||||
|
||||
## English
|
||||
|
||||
LanMountainDesktop is an Avalonia-based desktop shell. It is designed as a composable and extensible desktop environment rather than a simple launcher.
|
||||
|
||||
### Main goals
|
||||
|
||||
- manage desktop widgets with a grid-based layout
|
||||
- provide a unified shell with status bar, taskbar, and multi-page desktop support
|
||||
- build a consistent experience through themes, glass effects, and motion
|
||||
- extend capabilities through the component and plugin systems
|
||||
|
||||
17
airappmarket/README.md
Normal file
17
airappmarket/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# AirApp Market 目录说明
|
||||
|
||||
## 中文
|
||||
|
||||
这个目录是阑山桌面仓库里遗留的市场原型目录,只用于历史参考,不再作为官方权威市场源。
|
||||
|
||||
### 当前结论
|
||||
|
||||
- 官方市场源以独立 `LanAirApp` 仓库中的 `airappmarket/index.json` 为准
|
||||
- 阑山桌面程序应连接 `LanAirApp` 仓库,而不是以本目录为权威数据源
|
||||
- 如无特殊需要,不应继续向这里添加正式市场数据
|
||||
|
||||
## English
|
||||
|
||||
This directory is a legacy market prototype kept in the LanMountainDesktop repository for historical reference only.
|
||||
|
||||
The authoritative market source now lives in the standalone `LanAirApp` repository.
|
||||
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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,177 +1,38 @@
|
||||
# 圆角设计规范 (Corner Radius Design System)
|
||||
# 圆角设计规范
|
||||
|
||||
> 基于小米澎湃OS 3 (HyperOS) 设计语言
|
||||
## 中文
|
||||
|
||||
## 设计理念
|
||||
本规范用于统一阑山桌面不同层级容器和控件的圆角尺度。
|
||||
|
||||
澎湃OS 3 采用**"生命感美学"**设计语言,强调:
|
||||
- **全局圆角设计** - 所有界面元素均采用圆角
|
||||
- **视觉舒适统一** - 柔和、现代、细腻
|
||||
- **多级渲染** - 配合模糊混色与阴影
|
||||
- **层级分明** - 大容器使用大圆角,小元素使用小圆角
|
||||
### 基础层级
|
||||
|
||||
## 圆角数值体系
|
||||
- Level 1:12px,小元素和图标容器
|
||||
- Level 2:16px,小型色块和紧凑控件
|
||||
- Level 3:20px,普通按钮
|
||||
- Level 4:24px,输入面板和小型容器
|
||||
- Level 5:28px,普通玻璃面板
|
||||
- Level 6:32px,强化容器
|
||||
- Level 7:36px,大容器、窗口、任务栏
|
||||
|
||||
### 核心数值
|
||||
### 使用建议
|
||||
|
||||
| 级别 | 圆角值 (px) | 用途 |
|
||||
|------|-------------|------|
|
||||
| **Level 0** | 0 | 特殊场景(无圆角需求) |
|
||||
| **Level 1** | 12 | 小元素、图标内边角、ListBoxItem |
|
||||
| **Level 2** | 16 | 色块按钮、小组件 |
|
||||
| **Level 3** | 20 | 普通按钮、组件预览 |
|
||||
| **Level 4** | 24 | 输入框、小型面板 |
|
||||
| **Level 5** | 28 | 面板/卡片 (glass-panel) |
|
||||
| **Level 6** | 32 | Mica 风格面板 (mica-strong) |
|
||||
| **Level 7** | 36 | 大容器 (glass-strong)、任务栏、窗口 |
|
||||
- 同层级元素保持相同圆角。
|
||||
- 大容器的圆角大于内部子面板。
|
||||
- 动态尺寸组件可按 `cellSize` 计算圆角,但仍要落在统一范围内。
|
||||
|
||||
### 动态圆角
|
||||
|
||||
动态圆角根据格子大小(cellSize)动态计算:
|
||||
### 动态圆角建议
|
||||
|
||||
```csharp
|
||||
// 小元素
|
||||
CornerRadius = Math.Clamp(cellSize * 0.35, 16, 28);
|
||||
|
||||
// 小组件
|
||||
CornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
|
||||
|
||||
// 大容器(任务栏/窗口)
|
||||
CornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
|
||||
```
|
||||
|
||||
**系数参考**:
|
||||
- 系数范围:`0.35 - 0.45`
|
||||
- 最小值限制:`12 - 24 px`
|
||||
- 最大值限制:`28 - 44 px`
|
||||
|
||||
## 组件圆角速查表
|
||||
|
||||
### 基础控件
|
||||
|
||||
| 控件 | 圆角值 | 代码位置 |
|
||||
|------|--------|---------|
|
||||
| Button | 20px | GlassModule.axaml |
|
||||
| ToggleSwitch | 继承系统 | - |
|
||||
| TextBox | 20px | glass-panel |
|
||||
| ComboBox | 20px | glass-panel |
|
||||
| NumberBox | 20px | glass-panel |
|
||||
|
||||
### 容器样式类
|
||||
|
||||
| 样式类 | 圆角值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `.glass-panel` | 28px | 普通玻璃面板 |
|
||||
| `.glass-strong` | 36px | 加强玻璃面板(任务栏) |
|
||||
| `.mica-strong` | 36px | Mica 风格面板(设置页) |
|
||||
| `.glass-overlay` | 0px | 覆盖层(无圆角) |
|
||||
|
||||
### 特殊场景
|
||||
|
||||
| 场景 | 圆角值 | 说明 |
|
||||
|------|--------|------|
|
||||
| 窗口整体 | 36px | 组件库/设置窗口 |
|
||||
| 窗口标题栏 | 36px | 仅顶部圆角 (`36,36,0,0`) |
|
||||
| 颜色选择器色块 | 12px | Monet 颜色/推荐色 |
|
||||
| 设置页 ListBoxItem | 12px | 导航项 |
|
||||
| 预览视口 | 12-16px | 壁纸/网格预览 |
|
||||
|
||||
## 圆角层级视觉示例
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ Level 7: 大容器 (36px) │
|
||||
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ Level 6: Mica 面板 (36px) │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Level 5: 玻璃面板 (28px) │ │ │
|
||||
│ │ │ ┌─────────────────────────────────────────────┐ │ │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ │ │ Level 4: 输入面板 (24px) │ │ │ │
|
||||
│ │ │ │ ┌─────────────────────────────────────┐ │ │ │ │
|
||||
│ │ │ │ │ │ │ │ │ │
|
||||
│ │ │ │ │ Level 3: 按钮 (20px) │ │ │ │ │
|
||||
│ │ │ │ │ ┌─────────────────────────────┐ │ │ │ │ │
|
||||
│ │ │ │ │ │ Level 2: 色块 (16px) │ │ │ │ │ │
|
||||
│ │ │ │ │ │ ┌─────────────────────┐ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ Level 1: 小元素 │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ │ (12px) │ │ │ │ │ │ │
|
||||
│ │ │ │ │ │ └─────────────────────┘ │ │ │ │ │ │
|
||||
│ │ │ │ │ └─────────────────────────────┘ │ │ │ │ │
|
||||
│ │ │ │ └─────────────────────────────────────┘ │ │ │ │
|
||||
│ │ │ └─────────────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 在 XAML 中使用
|
||||
|
||||
### 直接使用固定值
|
||||
|
||||
```xml
|
||||
<Border Classes="glass-strong" CornerRadius="36">
|
||||
<!-- 内容 -->
|
||||
</Border>
|
||||
|
||||
<Button CornerRadius="20">点击</Button>
|
||||
```
|
||||
|
||||
### 使用样式类
|
||||
|
||||
```xml
|
||||
<!-- 使用预定义的 glass-panel 样式 -->
|
||||
<Border Classes="glass-panel">
|
||||
<TextBlock Text="面板内容" />
|
||||
</Border>
|
||||
|
||||
<!-- 组合多个样式类 -->
|
||||
<Border Classes="glass-strong mica-strong">
|
||||
<!-- 内容 -->
|
||||
</Border>
|
||||
```
|
||||
|
||||
### 动态圆角(Code-Behind)
|
||||
|
||||
```csharp
|
||||
// 根据格子大小动态计算圆角
|
||||
var cellSize = 100; // 假设格子大小
|
||||
var cornerRadius = Math.Clamp(cellSize * 0.45, 24, 44);
|
||||
|
||||
BottomTaskbarContainer.CornerRadius = new CornerRadius(cornerRadius);
|
||||
```
|
||||
|
||||
## 新增控件时的圆角规范
|
||||
## English
|
||||
|
||||
1. **确定元素层级** - 根据容器大小选择合适的级别
|
||||
2. **遵循视觉一致性** - 同层级的元素使用相同圆角
|
||||
3. **考虑内容安全区** - 圆角不应遮挡重要内容
|
||||
4. **响应式适配** - 大屏幕使用较大圆角,小屏幕使用较小圆角
|
||||
This specification keeps corner radius usage consistent across containers and controls.
|
||||
|
||||
### 快速参考
|
||||
### Reference levels
|
||||
|
||||
```
|
||||
新控件圆角选择流程:
|
||||
|
||||
1. 是窗口/大容器? → Level 7 (36px)
|
||||
2. 是面板/卡片? → Level 5-6 (28-36px)
|
||||
3. 是按钮/输入框? → Level 3-4 (20-24px)
|
||||
4. 是小组件/色块? → Level 2 (16px)
|
||||
5. 是图标/小元素? → Level 1 (12px)
|
||||
```
|
||||
|
||||
## 附录:修改历史
|
||||
|
||||
| 日期 | 修改人 | 说明 |
|
||||
|------|--------|------|
|
||||
| 2026-03-02 | AI Assistant | 初始规范,基于澎湃OS 3 设计语言 |
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 澎湃OS 生命感美学设计
|
||||
- Xiaomi HyperOS Design Guidelines
|
||||
- 小米小部件审核规范 (dev.mi.com)
|
||||
- 12px for small elements
|
||||
- 20px for common buttons
|
||||
- 28px for normal glass panels
|
||||
- 36px for large containers and windows
|
||||
|
||||
@@ -1,126 +1,42 @@
|
||||
# LanMountainDesktop 视觉规范(主题色 + 毛玻璃)
|
||||
# 视觉规范
|
||||
|
||||
## 1. 主题色应用规范
|
||||
## 中文
|
||||
|
||||
### 1.1 颜色角色定义
|
||||
本规范用于统一阑山桌面的主题色、玻璃效果和基础视觉语义。
|
||||
|
||||
- `Primary`(主色):品牌主导色,用于主要操作、关键状态提示。
|
||||
- `Secondary`(辅助色):主色的低权重变体,用于次级强调、辅助信息。
|
||||
- `Accent`(强调色):可被用户替换的动态主题色,用于选中态、激活态、聚焦态。
|
||||
- `OnAccent`:放在强调色背景上的文本/图标颜色。
|
||||
- `SurfaceBase` / `SurfaceRaised` / `SurfaceOverlay`:基础背景、抬升层、遮罩层。
|
||||
- `TextPrimary` / `TextSecondary` / `TextMuted` / `TextAccent`:文字语义层级。
|
||||
### 颜色角色
|
||||
|
||||
### 1.2 UI 元素映射规则
|
||||
- `Primary`:品牌主色
|
||||
- `Secondary`:辅助色
|
||||
- `Accent`:强调色与选中态主色
|
||||
- `OnAccent`:强调色背景上的文字或图标
|
||||
- `SurfaceBase` / `SurfaceRaised` / `SurfaceOverlay`:背景层级
|
||||
- `TextPrimary` / `TextSecondary` / `TextMuted` / `TextAccent`:文本层级
|
||||
|
||||
- 主按钮、主导航选中态:`Accent` + `OnAccent`
|
||||
- 次级按钮/输入控件:`AdaptiveButtonBackgroundBrush` + `TextPrimary`
|
||||
- 页头标题:`TextPrimary`
|
||||
- 说明/辅助文本:`TextSecondary` / `TextMuted`
|
||||
- 设置页导航激活项:`AdaptiveNavItemSelectedBackgroundBrush` + `AdaptiveNavSelectedTextBrush`
|
||||
### 使用规则
|
||||
|
||||
### 1.3 统一资源键(单一真相源)
|
||||
- 主按钮和主要导航选中态使用 `Accent + OnAccent`
|
||||
- 次级操作和输入控件优先使用语义背景色,不直接写死颜色
|
||||
- 页面层只使用资源键和语义类名,不写业务颜色常量
|
||||
|
||||
- 主题核心:
|
||||
- `AdaptivePrimaryBrush`
|
||||
- `AdaptiveSecondaryBrush`
|
||||
- `AdaptiveAccentBrush`
|
||||
- `AdaptiveOnAccentBrush`
|
||||
- 文本:
|
||||
- `AdaptiveTextPrimaryBrush`
|
||||
- `AdaptiveTextSecondaryBrush`
|
||||
- `AdaptiveTextMutedBrush`
|
||||
- `AdaptiveTextAccentBrush`
|
||||
- 表面:
|
||||
- `AdaptiveSurfaceBaseBrush`
|
||||
- `AdaptiveSurfaceRaisedBrush`
|
||||
- `AdaptiveSurfaceOverlayBrush`
|
||||
### 玻璃效果层级
|
||||
|
||||
## 2. 毛玻璃(Glassmorphism)统一实现方案
|
||||
- `glass-overlay`:最外层遮罩
|
||||
- `glass-strong`:主要大容器
|
||||
- `glass-panel`:子区域、小面板、卡片
|
||||
|
||||
### 2.1 分层标准
|
||||
### 可访问性
|
||||
|
||||
- `glass-overlay`:最高层遮罩(设置页背板)
|
||||
- `glass-strong`:主内容容器(设置页主体)
|
||||
- `glass-panel`:子功能区、组件容器(网格卡片、按钮容器)
|
||||
- 正文对比度目标不低于 `4.5:1`
|
||||
- 大号文字和重点文字不低于 `3.0:1`
|
||||
- 主题服务负责对前景色做自动对比度修正
|
||||
|
||||
### 2.2 参数标准(模拟毛玻璃,跨平台稳定)
|
||||
## English
|
||||
|
||||
- 描边:统一去除(`BorderThickness = 0`)
|
||||
- 模糊半径资源(供样式/扩展复用):
|
||||
- `AdaptiveGlassPanelBlurRadius`(日 18 / 夜 22)
|
||||
- `AdaptiveGlassStrongBlurRadius`(日 24 / 夜 28)
|
||||
- 透明度资源:
|
||||
- `AdaptiveGlassPanelOpacity`(日 0.88 / 夜 0.92)
|
||||
- `AdaptiveGlassStrongOpacity`(日 0.92 / 夜 0.95)
|
||||
- 背景色:由 `GlassEffectService` 基于主题色动态混合,统一下发到:
|
||||
- `AdaptiveGlassPanelBackgroundBrush`
|
||||
- `AdaptiveGlassStrongBackgroundBrush`
|
||||
- `AdaptiveGlassOverlayBackgroundBrush`
|
||||
This specification defines the visual language of LanMountainDesktop, including theme roles, glass layers, and semantic color usage.
|
||||
|
||||
## 3. 视觉一致性策略
|
||||
|
||||
- 全局样式入口:`Styles/GlassModule.axaml`
|
||||
- 全局主题入口:`ThemeColorSystemService` + `GlassEffectService`
|
||||
- 页面侧仅使用语义资源键和 `glass-*` 类,不写硬编码颜色
|
||||
- `MainWindow` 只负责编排:切换模式、选择主题色、触发资源重算
|
||||
|
||||
## 4. 可访问性(WCAG)
|
||||
|
||||
### 4.1 对比度目标
|
||||
|
||||
- 正文文本:`>= 4.5:1`
|
||||
- 大号文本 / 强调文本:`>= 3.0:1`
|
||||
|
||||
### 4.2 实现方式
|
||||
|
||||
- `Theme/ColorMath.cs` 提供:
|
||||
- 相对亮度计算
|
||||
- 对比度计算
|
||||
- `EnsureContrast(...)` 自动修正文本前景色
|
||||
- `ThemeColorSystemService` 在生成 `TextPrimary/TextSecondary/TextMuted/NavText` 时强制走对比度校正
|
||||
|
||||
## 5. 跨尺寸与分辨率一致性
|
||||
|
||||
- 启用像素对齐:`UseLayoutRounding="True"` + `SnapsToDevicePixels="True"`
|
||||
- 桌面网格布局通过统一计算函数输出 `row/col/cell`,主视图与预览共用算法
|
||||
- 预览区域按窗口实际宽高比缩放,保持 Win11 风格比例一致性
|
||||
- 关键尺寸自适应(字体、内边距、圆角)随 `cellSize` 动态计算
|
||||
|
||||
## 6. 实现代码示例
|
||||
|
||||
### 6.1 主题系统应用(C#)
|
||||
|
||||
```csharp
|
||||
var context = new ThemeColorContext(
|
||||
selectedAccent,
|
||||
isLightBackground,
|
||||
isLightNavBackground,
|
||||
isNightMode);
|
||||
|
||||
ThemeColorSystemService.ApplyThemeResources(Resources, context);
|
||||
GlassEffectService.ApplyGlassResources(Resources, context);
|
||||
```
|
||||
|
||||
### 6.2 页面层使用语义资源(AXAML)
|
||||
|
||||
```xml
|
||||
<Border Classes="glass-overlay" />
|
||||
|
||||
<Border Classes="glass-strong" CornerRadius="16">
|
||||
<Border Classes="glass-panel" CornerRadius="10" Padding="14">
|
||||
<TextBlock Foreground="{DynamicResource AdaptiveTextPrimaryBrush}" />
|
||||
<Button Background="{DynamicResource AdaptiveButtonBackgroundBrush}" />
|
||||
</Border>
|
||||
</Border>
|
||||
```
|
||||
|
||||
### 6.3 无描边层级区分原则(AXAML)
|
||||
|
||||
```xml
|
||||
<Style Selector="Border.glass-panel">
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Opacity" Value="{DynamicResource AdaptiveGlassPanelOpacity}" />
|
||||
</Style>
|
||||
```
|
||||
### Key rules
|
||||
|
||||
- use semantic resource keys instead of hard-coded colors
|
||||
- keep glass layers visually distinct
|
||||
- maintain contrast targets for readability
|
||||
|
||||
796
noise.md
796
noise.md
@@ -1,771 +1,67 @@
|
||||
# 噪音计算与评分技术文档
|
||||
# 噪音监测与评分说明
|
||||
|
||||
噪音监测系统不仅仅是一个简单的分贝计,它内置了一个基于心理声学与专注力理论的评分引擎。该引擎旨在客观、多维度地量化环境噪音对学习心流的干扰程度。
|
||||
## 中文
|
||||
|
||||
本文档详细解析了该系统的计算原理、核心指标定义、评分算法及完整的技术架构。
|
||||
本文档描述阑山桌面中噪音监测能力的基本设计目标、核心指标和实现边界。
|
||||
|
||||
## 目录
|
||||
### 设计目标
|
||||
|
||||
1. [核心理念](#1-核心理念)
|
||||
2. [系统架构](#2-系统架构)
|
||||
3. [数据采集层](#3-数据采集层)
|
||||
4. [数据聚合层](#4-数据聚合层)
|
||||
5. [评分算法核心](#5-评分算法核心)
|
||||
6. [数据存储层](#6-数据存储层)
|
||||
7. [历史报告生成](#7-历史报告生成)
|
||||
8. [流服务整合](#8-流服务整合)
|
||||
9. [配置参数体系](#9-配置参数体系)
|
||||
10. [类型定义](#10-类型定义)
|
||||
- 用尽量稳定的方式度量环境噪音。
|
||||
- 区分持续噪音、超阈值时长和高频打断。
|
||||
- 将结果反馈到实时界面、历史报表和专注场景中。
|
||||
|
||||
---
|
||||
### 核心指标
|
||||
|
||||
## 1. 核心理念
|
||||
- `p50Dbfs`:持续噪音水平,反映环境底噪。
|
||||
- `overRatioDbfs`:超阈值时间占比,反映噪音覆盖时长。
|
||||
- `segmentCount`:独立噪音事件数,反映被打断频率。
|
||||
|
||||
### 1.1 设计原则
|
||||
### 评分思路
|
||||
|
||||
系统认为,并非所有"响声"都是一样的。对于专注力而言:
|
||||
评分从 100 分开始,依据三类惩罚项扣分:
|
||||
|
||||
- **持续的嗡嗡声**(如嘈杂的人群)比**偶尔的掉笔声**更具破坏性
|
||||
- **频繁的打断**(如每分钟都有人说话)比**单次的大声喧哗**更让人烦躁
|
||||
- **评分与校准分离**:评分使用原始 DBFS 数据,校准仅影响显示分贝
|
||||
- 持续噪音惩罚
|
||||
- 超阈值时长惩罚
|
||||
- 打断频率惩罚
|
||||
|
||||
因此,评分系统采用了 **多维度加权扣分制**,满分 100 分,根据环境表现进行扣分。
|
||||
常见固定参数:
|
||||
|
||||
### 1.2 评分与校准分离
|
||||
- 帧间隔:50ms
|
||||
- 切片时长:30s
|
||||
- 评分阈值:`-50 dBFS`
|
||||
- 事件合并窗口:500ms
|
||||
- 每分钟最大容忍事件数:6
|
||||
|
||||
项目通过"原始数据(用于评分)"与"显示数据(用于展示)"的**严格分层**,杜绝了校准值导致的评分偏差:
|
||||
### 数据流
|
||||
|
||||
1. **评分只依赖原始 DBFS(设备输出的相对电平)**
|
||||
- 评分的三项核心指标(`p50Dbfs`、`overRatioDbfs`、`segmentCount`)都来自原始 `dbfs` 统计
|
||||
- "超阈时长占比"判定条件固定为:`dbfs > scoreThresholdDbfs`(阈值默认 `-50 dBFS`),与校准无关
|
||||
- 这意味着即使用户把"显示分贝基准"调高/调低,评分侧的 `dbfs` 不会变化,因此得分与超阈时长也不会被"调参刷分"
|
||||
1. 麦克风采集音频。
|
||||
2. 计算 RMS 与 dBFS。
|
||||
3. 聚合为时间切片。
|
||||
4. 为每个切片计算统计值和评分。
|
||||
5. 写入本地历史数据。
|
||||
6. 在 UI 中展示实时状态与历史报告。
|
||||
|
||||
2. **校准仅影响 Display dB(UI 展示口径),不进入评分链路**
|
||||
- 校准(`baselineRms` / `baselineDb`)只用于将 `rms` 映射为 `displayDb`,用于实时显示与报告中的"噪音等级分布"等图表展示
|
||||
- 这些展示口径变化不会反向影响评分输入,也不会改变切片摘要中的 `raw.*` 字段
|
||||
### 设计边界
|
||||
|
||||
3. **统计报告中"超阈时长"取自 raw.overRatioDbfs**
|
||||
- 报告里展示的"超阈时长"是对每个切片 `raw.overRatioDbfs` 按有效采样时长加权汇总得到,仍然完全基于 DBFS
|
||||
- 相比之下,"噪音等级分布"使用的是 `display.avgDb`(校准后的显示分贝),因此它会随校准变化——这是为了更贴近用户直觉的 dB 区间划分
|
||||
- 评分使用原始统计口径,不应被显示层校准参数反向影响。
|
||||
- 历史数据只存统计值,不存原始音频。
|
||||
- 该系统用于环境质量评估,不作为专业声学测量工具。
|
||||
|
||||
---
|
||||
## English
|
||||
|
||||
## 2. 系统架构
|
||||
This document summarizes the noise monitoring and scoring model used by LanMountainDesktop.
|
||||
|
||||
### 2.1 整体架构图
|
||||
### Main metrics
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户界面层 │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 实时监控组件 │ │ 噪音报告弹窗 │ │ 噪音历史列表 │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 订阅/发布
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 流服务层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 噪音流服务 - 订阅管理、生命周期控制、设置热更新 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 帧数据流
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据聚合层 │
|
||||
│ ┌──────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ 噪音帧处理器 │ │ 噪音切片聚合器 │ │
|
||||
│ │ - RMS/dBFS 计算 │ │ - 切片聚合、统计指标、评分计算 │ │
|
||||
│ │ - 50ms/帧 │ │ - 30秒/切片 │ │
|
||||
│ └──────────────────┘ └────────────────────────────────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 实时环形缓冲区 - 保留固定时长的实时数据 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 音频流
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据采集层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 麦克风采集 - Web Audio API、滤波器、AnalyserNode │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 物理音频
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 数据存储层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 切片存储 - localStorage、时间清理、容量限制 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
↑
|
||||
│ 历史数据
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 历史报告层 │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 历史构建 - 课表关联、加权平均评分、覆盖率计算 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
- `p50Dbfs`: sustained noise level
|
||||
- `overRatioDbfs`: ratio of time above threshold
|
||||
- `segmentCount`: number of distinct interruption events
|
||||
|
||||
### 2.2 模块说明
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 类型定义 | 核心类型定义 |
|
||||
| 常量定义 | 分析参数常量、报告参数常量 |
|
||||
| 麦克风采集 | 音频采集 |
|
||||
| 帧处理器 | 帧处理 |
|
||||
| 切片聚合器 | 切片聚合 |
|
||||
| 环形缓冲区 | 实时数据 |
|
||||
| 流服务 | 流管理 |
|
||||
| 评分引擎 | 评分算法 |
|
||||
| 切片服务 | 存储服务 |
|
||||
| 历史构建 | 历史报告 |
|
||||
| 设置管理 | 设置管理 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 数据采集层
|
||||
|
||||
### 3.1 麦克风采集
|
||||
|
||||
#### 3.1.1 Web Audio API 使用
|
||||
|
||||
系统使用 Web Audio API 获取麦克风输入,构建完整的音频处理链路:
|
||||
|
||||
```
|
||||
麦克风 → MediaStream → MediaStreamAudioSourceNode
|
||||
→ 高通滤波器 (80Hz) → 低通滤波器 (8000Hz)
|
||||
→ AnalyserNode (FFT Size 2048)
|
||||
```
|
||||
|
||||
#### 3.1.2 音频滤波器配置
|
||||
|
||||
| 滤波器类型 | 截止频率 | 作用 |
|
||||
|-----------|---------|------|
|
||||
| 高通滤波器 | 80 Hz | 过滤低频噪音(如空调嗡嗡声) |
|
||||
| 低通滤波器 | 8000 Hz | 过滤高频噪音(如电子设备啸叫) |
|
||||
|
||||
#### 3.1.3 AnalyserNode 配置
|
||||
|
||||
```typescript
|
||||
analyser.fftSize = 2048; // FFT 窗口大小
|
||||
analyser.smoothingTimeConstant = 0; // 无平滑,实时响应
|
||||
```
|
||||
|
||||
#### 3.1.4 权限处理与错误处理
|
||||
|
||||
```typescript
|
||||
// 麦克风权限请求配置
|
||||
{
|
||||
audio: {
|
||||
echoCancellation: false, // 禁用回声消除
|
||||
noiseSuppression: false, // 禁用降噪
|
||||
autoGainControl: false, // 禁用自动增益
|
||||
},
|
||||
video: false
|
||||
}
|
||||
```
|
||||
|
||||
**浏览器兼容性说明:**
|
||||
- 部分浏览器/设备可能忽略上述约束设置
|
||||
- 建议在 UI 中提示用户实际生效的约束
|
||||
- 需要测试矩阵验证:Chrome/Firefox/Safari/Edge/iOS Safari/Android WebView
|
||||
|
||||
**错误处理:**
|
||||
- `NotAllowedError` / `SecurityError` → 权限拒绝
|
||||
- `AudioContext not supported` → 浏览器不支持
|
||||
|
||||
---
|
||||
|
||||
### 3.2 帧处理器
|
||||
|
||||
#### 3.2.1 采样频率
|
||||
|
||||
- **帧间隔**:50ms(约 20 fps)
|
||||
- **数据来源**:AnalyserNode.getFloatTimeDomainData()
|
||||
|
||||
#### 3.2.2 RMS(均方根)计算
|
||||
|
||||
RMS 是衡量音频信号强度的标准方法:
|
||||
|
||||
**公式:**
|
||||
$$ \text{RMS} = \sqrt{\frac{1}{N} \sum_{i=1}^{N} x_i^2} $$
|
||||
|
||||
#### 3.2.3 dBFS(分贝满刻度)转换
|
||||
|
||||
dBFS 是数字音频的标准分贝单位,范围 -100 到 0 dB:
|
||||
|
||||
**公式:**
|
||||
$$ \text{dBFS} = 20 \times \log_{10}(\text{RMS}) $$
|
||||
|
||||
**范围限制:**
|
||||
- 最小值:-100 dBFS(静音)
|
||||
- 最大值:0 dBFS(满刻度)
|
||||
|
||||
#### 3.2.4 峰值检测
|
||||
|
||||
峰值用于检测突发噪音,在 RMS 计算过程中同时记录峰值。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据聚合层
|
||||
|
||||
### 4.1 切片聚合器
|
||||
|
||||
#### 4.1.1 切片时长
|
||||
|
||||
- **默认切片时长**:30 秒
|
||||
- **可配置范围**:≥ 1 秒
|
||||
|
||||
#### 4.1.2 统计指标计算
|
||||
|
||||
切片聚合器为每个切片计算以下统计指标:
|
||||
|
||||
| 指标 | 说明 | 计算方法 |
|
||||
|------|------|---------|
|
||||
| avgDbfs | 平均分贝 | 能量平均(线性域 RMS 平均后转回 dBFS) |
|
||||
| maxDbfs | 最大分贝 | 所有帧 dBFS 的最大值 |
|
||||
| p50Dbfs | 中位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||
| p95Dbfs | 95分位数分贝 | 线性域分位数(RMS 域计算后转回 dBFS) |
|
||||
| overRatioDbfs | 超阈值比例 | 超阈值时长 / 采样时长 |
|
||||
| segmentCount | 事件段数量 | 独立噪音事件次数 |
|
||||
| sampledDurationMs | 采样时长 | 有效采样时间(排除缺口) |
|
||||
| gapCount | 缺口数量 | 数据缺口次数 |
|
||||
| maxGapMs | 最大缺口时长 | 最长数据缺口时长 |
|
||||
|
||||
#### 4.1.3 能量平均计算(avgDbfs)
|
||||
|
||||
**公式:**
|
||||
$$ \text{avgDbfs} = 20 \times \log_{10}\left(\sqrt{\frac{1}{N} \sum_{i=1}^{N} 10^{\text{dBFS}_i / 10}}\right) $$
|
||||
|
||||
**物理意义:** 在线性域(RMS)上做平均,符合能量守恒定律
|
||||
|
||||
#### 4.1.4 线性域分位数计算
|
||||
|
||||
**公式:**
|
||||
$$ \text{quantileDbfs} = 20 \times \log_{10}(Q_{\text{RMS}}(p)) $$
|
||||
|
||||
其中 $Q_{\text{RMS}}(p)$ 是 RMS 域的分位数,使用线性插值计算:
|
||||
$$ Q_{\text{RMS}}(p) = x_{\lfloor i \rfloor} \times (1 - w) + x_{\lceil i \rceil} \times w $$
|
||||
|
||||
- $i = (n-1) \times p$
|
||||
- $w = i - \lfloor i \rfloor$
|
||||
|
||||
**物理意义:** 在线性域(RMS)上计算分位数,符合能量统计的严谨性
|
||||
|
||||
#### 4.1.5 超阈值比例计算(时间加权)
|
||||
|
||||
**公式:**
|
||||
$$ \text{overRatioDbfs} = \frac{\text{超阈值时长}}{\text{采样时长}} $$
|
||||
|
||||
**物理意义:** 使用实际时长而非帧数计算比例,更精确
|
||||
|
||||
#### 4.1.6 事件段检测与合并算法
|
||||
|
||||
事件段检测用于识别独立的噪音事件:
|
||||
|
||||
**合并规则:**
|
||||
- **合并窗口**:500ms(默认)
|
||||
- 如果两次超阈值事件间隔 ≤ 500ms,合并为同一事件段
|
||||
- 否则计为新的独立事件段
|
||||
|
||||
**示例:**
|
||||
```
|
||||
时间轴: 0ms 200ms 400ms 600ms 800ms 1000ms
|
||||
状态: [噪音] [噪音] [安静] [噪音] [噪音] [安静]
|
||||
合并后: └─────── 事件段1 ───────┘ └── 事件段2 ──┘
|
||||
```
|
||||
|
||||
#### 4.1.7 显示分贝映射(校准机制)
|
||||
|
||||
显示分贝用于用户界面展示,支持校准:
|
||||
|
||||
**公式(有校准):**
|
||||
$$ \text{displayDb} = \text{baselineDb} + 20 \times \log_{10}\left(\frac{\text{rms}}{\text{baselineRms}}\right) $$
|
||||
|
||||
**公式(无校准):**
|
||||
$$ \text{displayDb} = 20 \times \log_{10}\left(\frac{\text{rms}}{10^{-3}}\right) + 60 $$
|
||||
|
||||
**范围限制:** 20 dB ~ 100 dB
|
||||
|
||||
**校准流程说明:**
|
||||
1. 使用标准声源(如 60 dB 的白噪音)
|
||||
2. 测量对应的 RMS 值
|
||||
3. 设置为 baselineRms
|
||||
4. 设置对应的显示分贝为 baselineDb
|
||||
|
||||
#### 4.1.8 缺口检测与采样时长统计
|
||||
|
||||
**缺口阈值:** `max(1000ms, frameMs × 5)` = **1000ms**(默认)
|
||||
|
||||
当检测到数据缺口时,会触发切片完成并记录缺口信息。
|
||||
|
||||
#### 4.1.9 无效帧过滤
|
||||
|
||||
低于 -90 dBFS 的帧被视为静音/无效信号,不参与统计。
|
||||
|
||||
**常量说明:**
|
||||
- `INVALID_DBFS_THRESHOLD = -90`:统计意义上的"静音"阈值
|
||||
- `DBFS_MIN_POSSIBLE = -100`:物理最小可表示值(用于 clamp)
|
||||
- `DBFS_MAX_POSSIBLE = 0`:物理最大可表示值(用于 clamp)
|
||||
|
||||
---
|
||||
|
||||
### 4.2 实时环形缓冲区
|
||||
|
||||
#### 4.2.1 数据结构设计
|
||||
|
||||
环形缓冲区使用固定容量数组实现,通过起始索引和当前长度管理数据。
|
||||
|
||||
#### 4.2.2 时间窗口裁剪策略
|
||||
|
||||
**裁剪规则:** 移除时间戳早于 `当前时间 - retentionMs` 的数据点
|
||||
|
||||
---
|
||||
|
||||
## 5. 评分算法核心
|
||||
|
||||
### 5.1 三大核心指标
|
||||
|
||||
评分引擎从以下三个维度对噪音数据进行分析:
|
||||
|
||||
#### A. 持续噪音水平 (Sustained Level)
|
||||
|
||||
- **定义**:剔除突发噪音后的环境"底噪"水平
|
||||
- **算法**:使用时段内所有帧的中位数电平 (`p50Dbfs`)
|
||||
- **意义**:反映环境本身是否安静。如果环境中有持续的风扇声或交谈声,该指标会升高
|
||||
|
||||
#### B. 超阈值时长占比 (Over Threshold Ratio)
|
||||
|
||||
- **定义**:原始 `DBFS` 超过评分阈值(`scoreThresholdDbfs`)的时间比例
|
||||
- **算法**:`超阈值时长 / 采样时长`(超标判定:`dbfs > scoreThresholdDbfs`)
|
||||
- **意义**:反映环境的"纯净度"。即使是 0.1 秒的尖叫也会被精确计入,无法被平均值掩盖
|
||||
|
||||
> **提示**:评分阈值(`scoreThresholdDbfs`,单位 dBFS)与"界面报警/提示音"使用的显示分贝阈值(`maxLevelDb`,单位 dB)不是同一个概念;前者只用于评分,后者用于判定 noisy/quiet 与提示音触发。
|
||||
|
||||
#### C. 打断次数密度 (Interruption Density)
|
||||
|
||||
- **定义**:单位时间内(每分钟)发生的独立噪音事件次数
|
||||
- **智能合并算法**:
|
||||
- 系统设有 **500ms** (默认) 的合并窗口
|
||||
- 如果两次响声间隔小于该窗口(如拉椅子的一连串声音),会被合并为 **1 次打断**
|
||||
- 只有间隔较长的响声才会被计为新的打断
|
||||
- **意义**:反映环境的干扰频率。频繁的打断(如断断续续的说话声)比连续的噪音更易打断心流
|
||||
|
||||
### 5.2 评分引擎
|
||||
|
||||
#### 5.2.1 三维度评分模型
|
||||
|
||||
评分系统从三个维度对噪音进行评估:
|
||||
|
||||
| 维度 | 权重 | 指标 | 满扣分条件 |
|
||||
|------|------|------|-----------|
|
||||
| **持续噪音** | 40% | p50Dbfs | 中位数超过阈值 6 dBFS |
|
||||
| **超阈时长** | 30% | overRatioDbfs | 超阈时间占比 30% |
|
||||
| **打断频次** | 30% | segmentCount | 6 次/分钟 |
|
||||
|
||||
#### 5.2.2 评分公式
|
||||
|
||||
**总惩罚系数:**
|
||||
$$ \text{TotalPenalty} = 0.40 \times P_{\text{sustained}} + 0.30 \times P_{\text{time}} + 0.30 \times P_{\text{segment}} $$
|
||||
|
||||
**最终得分:**
|
||||
$$ \text{Score} = 100 \times (1 - \text{TotalPenalty}) $$
|
||||
|
||||
#### 5.2.3 惩罚系数计算
|
||||
|
||||
##### A. 持续噪音惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{sustained}} = \text{clamp}_{[0,1]}\left(\frac{\text{p50Dbfs} - \text{threshold}}{6}\right) $$
|
||||
|
||||
**满扣分条件:** `p50Dbfs - threshold ≥ 6 dBFS`
|
||||
|
||||
##### B. 超阈时长惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{time}} = \text{clamp}_{[0,1]}\left(\frac{\text{overRatioDbfs}}{0.3}\right) $$
|
||||
|
||||
**满扣分条件:** `overRatioDbfs ≥ 30%`
|
||||
|
||||
##### C. 打断频次惩罚
|
||||
|
||||
**公式:**
|
||||
$$ P_{\text{segment}} = \text{clamp}_{[0,1]}\left(\frac{\text{segmentCount} / \text{minutes}}{\text{maxSegmentsPerMin}}\right) $$
|
||||
|
||||
**满扣分条件:** `segmentsPerMin ≥ 6 次/分钟`
|
||||
|
||||
#### 5.2.4 权重解读
|
||||
|
||||
- **持续噪音 (40%)**:持续底噪仍会明显拉低分数
|
||||
- **超阈时长 (30%)**:只要大部分时间安静,偶尔的噪音仍可被容忍
|
||||
- **打断频次 (30%)**:强调"被频繁打断"对心流的破坏,提升对碎片化干扰的惩罚力度
|
||||
|
||||
#### 5.2.5 边界条件处理
|
||||
|
||||
- DBFS 范围限制:-100 到 0 dB
|
||||
- 惩罚系数范围限制:0 到 1
|
||||
- 评分范围限制:0 到 100 分
|
||||
|
||||
#### 5.2.6 有效时长处理
|
||||
|
||||
优先使用采样有效时长,不存在时回退到物理时长。
|
||||
|
||||
#### 5.2.7 评分示例
|
||||
|
||||
**场景 1:安静环境**
|
||||
- p50Dbfs = -60 dBFS, threshold = -50 dBFS
|
||||
- overRatioDbfs = 0.05 (5%)
|
||||
- segmentCount = 1, duration = 30s
|
||||
|
||||
```
|
||||
sustainedPenalty = clamp01((-60 - (-50)) / 6) = clamp01(-10/6) = 0
|
||||
timePenalty = clamp01(0.05 / 0.3) = 0.167
|
||||
segmentPenalty = clamp01((1/0.5) / 6) = clamp01(2/6) = 0.333
|
||||
|
||||
TotalPenalty = 0.4×0 + 0.3×0.167 + 0.3×0.333 = 0.15
|
||||
Score = 100 × (1 - 0.15) = 85 分
|
||||
```
|
||||
|
||||
**场景 2:嘈杂环境**
|
||||
- p50Dbfs = -45 dBFS, threshold = -50 dBFS
|
||||
- overRatioDbfs = 0.40 (40%)
|
||||
- segmentCount = 8, duration = 30s
|
||||
|
||||
```
|
||||
sustainedPenalty = clamp01((-45 - (-50)) / 6) = clamp01(5/6) = 0.833
|
||||
timePenalty = clamp01(0.40 / 0.3) = 1.0
|
||||
segmentPenalty = clamp01((8/0.5) / 6) = clamp01(16/6) = 1.0
|
||||
|
||||
TotalPenalty = 0.4×0.833 + 0.3×1.0 + 0.3×1.0 = 0.933
|
||||
Score = 100 × (1 - 0.933) = 6.7 分
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据存储层
|
||||
|
||||
### 6.1 切片服务
|
||||
|
||||
#### 6.1.1 localStorage 存储策略
|
||||
|
||||
存储键:`noise-slices`
|
||||
|
||||
**隐私说明:**
|
||||
- 存储内容:时间戳、噪音统计(不包含音频数据)
|
||||
- 风险:可能泄露位置/日程信息
|
||||
- 建议:在 UI 中提供"清除历史"功能
|
||||
|
||||
#### 6.1.2 时间窗口清理
|
||||
|
||||
**默认保留时长:** 14 天
|
||||
**可配置范围:** 1 ~ 365 天
|
||||
|
||||
使用新切片的结束时间作为基准计算 cutoff,确保新切片不会被清理。
|
||||
|
||||
#### 6.1.3 容量限制
|
||||
|
||||
**容量上限:** 本地存储配额的 90%
|
||||
|
||||
#### 6.1.4 数据规范化与校验
|
||||
|
||||
**精度控制:**
|
||||
- dBFS:3 位小数
|
||||
- overRatioDbfs:4 位小数
|
||||
- 显示分贝:2 位小数
|
||||
- 评分:1 位小数
|
||||
|
||||
---
|
||||
|
||||
## 7. 历史报告生成
|
||||
|
||||
### 7.1 历史构建器
|
||||
|
||||
#### 7.1.1 与课表关联逻辑
|
||||
|
||||
**关联规则:**
|
||||
1. 按日期分组切片
|
||||
2. 对每个日期的每个课时,查找重叠的切片
|
||||
3. 计算该课时的平均评分
|
||||
|
||||
#### 7.1.2 时段平均评分计算(加权平均)
|
||||
|
||||
**公式:**
|
||||
$$ \text{avgScore} = \frac{\sum_{i} \text{score}_i \times \text{effectiveMs}_i}{\sum_{i} \text{effectiveMs}_i} $$
|
||||
|
||||
其中:
|
||||
$$ \text{effectiveMs}_i = \text{sampledDurationMs}_i \times \frac{\text{overlapMs}_i}{\text{sliceMs}_i} $$
|
||||
|
||||
#### 7.1.3 覆盖率计算
|
||||
|
||||
**公式:**
|
||||
$$ \text{coverageRatio} = \frac{\text{totalMs}}{\text{periodMs}} $$
|
||||
|
||||
**含义:** 课时内有效采样时长占课时总时长的比例
|
||||
|
||||
#### 7.1.4 日期时间处理
|
||||
|
||||
**时区说明:**
|
||||
- 使用本地时区
|
||||
- 内部存储使用 UTC 时间戳
|
||||
- 对外展示使用本地时间
|
||||
|
||||
**日期格式:** `YYYY-MM-DD`
|
||||
**时间格式:** `HH:MM`
|
||||
|
||||
#### 7.1.5 跨天课时处理
|
||||
|
||||
如果结束时间 ≤ 开始时间,则课时跨越到次日。
|
||||
|
||||
#### 7.1.6 报告中的图表
|
||||
|
||||
在噪音统计报告中,您可以直观地看到这些数据:
|
||||
|
||||
- **评分走势图**:展示了 `Score` 随时间的变化,帮助您回顾专注状态
|
||||
- **噪音等级分布**:将每一帧归类为安静/正常/吵闹/极吵,直观展示时间占比
|
||||
- **扣分归因**:直接显示上述三个维度的扣分比例,告诉您为什么分低(是因为一直吵,还是因为总被打断)
|
||||
- **打断次数密度**:展示每分钟被干扰的次数
|
||||
|
||||
---
|
||||
|
||||
## 8. 流服务整合
|
||||
|
||||
### 8.1 噪音流服务
|
||||
|
||||
#### 8.1.1 订阅/发布模式
|
||||
|
||||
**模式:** 观察者模式
|
||||
- 多个组件可同时订阅
|
||||
- 最后一个订阅者取消时自动停止采集
|
||||
|
||||
#### 8.1.2 生命周期管理
|
||||
|
||||
流服务支持启动、停止和重启操作,自动管理采集资源的生命周期。
|
||||
|
||||
#### 8.1.3 预热帧处理
|
||||
|
||||
**目的:** 丢弃麦克风启动后的不稳定数据(约 500ms)
|
||||
|
||||
#### 8.1.4 设置热更新响应
|
||||
|
||||
**需要重启的参数:**
|
||||
- frameMs
|
||||
- sliceSec
|
||||
- scoreThresholdDbfs
|
||||
- segmentMergeGapMs
|
||||
- maxSegmentsPerMin
|
||||
|
||||
**无需重启的参数:**
|
||||
- maxLevelDb
|
||||
- showRealtimeDb
|
||||
- alertSoundEnabled
|
||||
- avgWindowSec
|
||||
- baselineDb
|
||||
|
||||
#### 8.1.5 时间加权平均
|
||||
|
||||
**公式:**
|
||||
$$ \text{avg} = \frac{\sum_{i} v_i \times (t_{i+1} - t_i)}{\sum_{i} (t_{i+1} - t_i)} $$
|
||||
|
||||
---
|
||||
|
||||
## 9. 配置参数体系
|
||||
|
||||
### 9.1 常量定义
|
||||
|
||||
#### 9.1.1 分析参数
|
||||
|
||||
```typescript
|
||||
NOISE_ANALYSIS_SLICE_SEC = 30; // 切片时长 30 秒
|
||||
NOISE_ANALYSIS_FRAME_MS = 50; // 帧间隔 50ms
|
||||
NOISE_SCORE_THRESHOLD_DBFS = -50; // 评分阈值 -50dBFS
|
||||
NOISE_SCORE_SEGMENT_MERGE_GAP_MS = 500; // 事件段合并间隔 500ms
|
||||
NOISE_SCORE_MAX_SEGMENTS_PER_MIN = 6; // 每分钟最大事件段数 6
|
||||
NOISE_REALTIME_CHART_SLICE_COUNT = 1; // 实时图表切片数 1
|
||||
```
|
||||
|
||||
#### 9.1.2 报告参数
|
||||
|
||||
```typescript
|
||||
DEFAULT_NOISE_REPORT_RETENTION_DAYS = 14; // 默认保留 14 天
|
||||
MIN_NOISE_REPORT_RETENTION_DAYS = 1; // 最小保留 1 天
|
||||
MAX_NOISE_REPORT_RETENTION_DAYS_FALLBACK = 365; // 最大保留 365 天
|
||||
```
|
||||
|
||||
### 9.2 设置管理
|
||||
|
||||
#### 9.2.1 固定参数
|
||||
|
||||
为保证评分口径稳定,避免用户通过调整参数"刷分",以下参数固定为程序内常量:
|
||||
- sliceSec
|
||||
- frameMs
|
||||
- scoreThresholdDbfs
|
||||
- segmentMergeGapMs
|
||||
- maxSegmentsPerMin
|
||||
|
||||
#### 9.2.2 可配置参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| maxLevelDb | number | 55 | 最大允许噪音级别(显示分贝) |
|
||||
| baselineDb | number | 40 | 手动基准显示分贝 |
|
||||
| showRealtimeDb | boolean | true | 是否显示实时分贝 |
|
||||
| avgWindowSec | number | 1 | 噪音平均时间窗(秒) |
|
||||
| alertSoundEnabled | boolean | false | 超阈值提示音开关 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 类型定义
|
||||
|
||||
### 10.1 核心类型
|
||||
|
||||
#### 10.1.1 噪音帧采样
|
||||
|
||||
```typescript
|
||||
interface NoiseFrameSample {
|
||||
t: number; // 时间戳
|
||||
rms: number; // 均方根值
|
||||
dbfs: number; // 分贝值 (dBFS)
|
||||
peak?: number; // 峰值
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.2 噪音切片原始统计
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceRawStats {
|
||||
avgDbfs: number; // 平均分贝
|
||||
maxDbfs: number; // 最大分贝
|
||||
p50Dbfs: number; // 中位数分贝
|
||||
p95Dbfs: number; // 95分位数分贝
|
||||
overRatioDbfs: number; // 超阈值比例
|
||||
segmentCount: number; // 事件段数量
|
||||
sampledDurationMs?: number; // 采样时长
|
||||
gapCount?: number; // 缺口数量
|
||||
maxGapMs?: number; // 最大缺口时长
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.3 噪音切片显示统计
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceDisplayStats {
|
||||
avgDb: number; // 平均显示分贝
|
||||
p95Db: number; // 95分位数显示分贝
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.4 噪音评分明细
|
||||
|
||||
```typescript
|
||||
interface NoiseScoreBreakdown {
|
||||
sustainedPenalty: number; // 持续噪音惩罚
|
||||
timePenalty: number; // 时间惩罚
|
||||
segmentPenalty: number; // 事件段惩罚
|
||||
thresholdsUsed: {
|
||||
scoreThresholdDbfs: number; // 使用的评分阈值
|
||||
segmentMergeGapMs: number; // 使用的合并间隔
|
||||
maxSegmentsPerMin: number; // 使用的最大事件段数
|
||||
};
|
||||
sustainedLevelDbfs: number; // 持续电平
|
||||
overRatioDbfs: number; // 超阈值比例
|
||||
segmentCount: number; // 事件段数量
|
||||
minutes: number; // 时长(分钟)
|
||||
durationMs?: number; // 物理时长
|
||||
sampledDurationMs?: number; // 采样时长
|
||||
coverageRatio?: number; // 覆盖率
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.5 噪音切片摘要
|
||||
|
||||
```typescript
|
||||
interface NoiseSliceSummary {
|
||||
start: number; // 开始时间戳
|
||||
end: number; // 结束时间戳
|
||||
frames: number; // 帧数
|
||||
raw: NoiseSliceRawStats; // 原始统计
|
||||
display: NoiseSliceDisplayStats; // 显示统计
|
||||
score: number; // 评分
|
||||
scoreDetail: NoiseScoreBreakdown; // 评分明细
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.6 实时数据点
|
||||
|
||||
```typescript
|
||||
interface NoiseRealtimePoint {
|
||||
t: number; // 时间戳
|
||||
dbfs: number; // 分贝值 (dBFS)
|
||||
displayDb: number; // 显示分贝
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.7 噪音流快照
|
||||
|
||||
```typescript
|
||||
interface NoiseStreamSnapshot {
|
||||
status: NoiseStreamStatus; // 流状态
|
||||
realtimeDisplayDb: number; // 实时显示分贝
|
||||
realtimeDbfs: number; // 实时分贝 (dBFS)
|
||||
maxLevelDb: number; // 最大允许级别
|
||||
showRealtimeDb: boolean; // 是否显示实时分贝
|
||||
alertSoundEnabled: boolean; // 提示音开关
|
||||
ringBuffer: NoiseRealtimePoint[]; // 环形缓冲区快照
|
||||
latestSlice: NoiseSliceSummary | null; // 最新切片
|
||||
}
|
||||
```
|
||||
|
||||
#### 10.1.8 噪音流状态
|
||||
|
||||
```typescript
|
||||
type NoiseStreamStatus =
|
||||
| "initializing" // 初始化中
|
||||
| "quiet" // 安静
|
||||
| "noisy" // 嘈杂
|
||||
| "permission-denied" // 权限拒绝
|
||||
| "error"; // 错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录
|
||||
|
||||
### A. 术语表
|
||||
|
||||
| 术语 | 英文 | 说明 |
|
||||
|------|------|------|
|
||||
| 均方根 | RMS (Root Mean Square) | 衡量音频信号强度的标准方法 |
|
||||
| 分贝满刻度 | dBFS (Decibels relative to Full Scale) | 数字音频的标准分贝单位,范围 -100 到 0 dB |
|
||||
| 显示分贝 | Display dB | 用于用户界面展示的分贝值,范围 20 到 100 dB |
|
||||
| 切片 | Slice | 固定时间窗口(默认 30 秒)内的噪音数据聚合 |
|
||||
| 帧 | Frame | 单次音频采样(默认 50ms) |
|
||||
| 事件段 | Segment | 独立的噪音事件,通过合并窗口(500ms)合并 |
|
||||
|
||||
### B. 参数固定策略
|
||||
|
||||
为保证统计口径稳定,当前版本将"分析与评分"的高级参数固定为程序内常量:
|
||||
|
||||
| 参数 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| frameMs | 50ms | 约 20fps |
|
||||
| sliceSec | 30s | 切片时长 |
|
||||
| scoreThresholdDbfs | -50 dBFS | 评分阈值 |
|
||||
| segmentMergeGapMs | 500ms | 事件段合并间隔 |
|
||||
| maxSegmentsPerMin | 6 | 每分钟最大事件段数 |
|
||||
|
||||
### C. 技术栈
|
||||
|
||||
- **音频处理**:Web Audio API
|
||||
- **数据存储**:localStorage
|
||||
- **前端框架**:React 18
|
||||
- **构建工具**:Vite 5
|
||||
- **类型系统**:TypeScript 5.4
|
||||
### Pipeline
|
||||
|
||||
1. capture microphone input
|
||||
2. compute RMS and dBFS
|
||||
3. aggregate frames into slices
|
||||
4. score each slice
|
||||
5. persist historical statistics
|
||||
6. present realtime and historical views
|
||||
|
||||
63
run.md
63
run.md
@@ -1,54 +1,55 @@
|
||||
# LanMountainDesktop 运行指南
|
||||
# 运行指南
|
||||
|
||||
本文档只负责“怎么跑起来”。项目介绍请看 [README.md](./README.md)。
|
||||
## 中文
|
||||
|
||||
## 1. 环境准备
|
||||
- 安装 .NET SDK 10(`net10.0`)。
|
||||
- 建议使用 Windows 运行桌面端(当前桌面体验以 Windows 为主)。
|
||||
本文档只说明如何在本地运行阑山桌面。
|
||||
|
||||
## 2. 拉取依赖并构建
|
||||
在仓库根目录执行:
|
||||
### 环境准备
|
||||
|
||||
- 安装 .NET SDK 10。
|
||||
- 桌面端建议在 Windows 上运行。
|
||||
|
||||
### 构建
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.sln -c Debug
|
||||
```
|
||||
|
||||
## 3. 运行桌面端
|
||||
### 运行桌面端
|
||||
|
||||
```bash
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
## 4. 推荐能力说明
|
||||
桌面端已内置推荐数据服务(每日诗词 / 每日名画),默认无需额外启动本地推荐后端。
|
||||
### 常见问题
|
||||
|
||||
## 5. 常见问题
|
||||
- 启动失败提示 SDK 版本不匹配:确认 `dotnet --info` 中已安装 .NET 10 SDK。
|
||||
- 桌面端视频相关能力异常:优先在 Windows 环境下验证。
|
||||
- 配置重置:删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启应用。
|
||||
- 如果提示 SDK 版本不匹配,先检查 `dotnet --info`。
|
||||
- 如果视频能力异常,优先在 Windows 环境验证。
|
||||
- 如果要重置配置,可删除 `%LOCALAPPDATA%\LanMountainDesktop\settings.json` 后重启。
|
||||
|
||||
## 6. Linux 音频功能依赖
|
||||
### Linux 录音依赖
|
||||
|
||||
如果在 Linux 上使用录音机组件或自习监测组件,需要安装以下音频库:
|
||||
如果在 Linux 上使用录音机或自习监测相关能力,需要安装音频库:
|
||||
|
||||
- Debian/Ubuntu:`sudo apt install libportaudio2 libasound2`
|
||||
- Fedora/RHEL:`sudo dnf install portaudio-libs alsa-lib`
|
||||
- Arch Linux:`sudo pacman -S portaudio alsa-lib`
|
||||
- Alpine Linux:`sudo apk add portaudio alsa-lib`
|
||||
|
||||
## English
|
||||
|
||||
This guide explains how to run LanMountainDesktop locally.
|
||||
|
||||
### Build
|
||||
|
||||
### Debian/Ubuntu
|
||||
```bash
|
||||
sudo apt install libportaudio2 libasound2
|
||||
dotnet restore
|
||||
dotnet build LanMountainDesktop.sln -c Debug
|
||||
```
|
||||
|
||||
### Fedora/RHEL
|
||||
```bash
|
||||
sudo dnf install portaudio-libs alsa-lib
|
||||
```
|
||||
### Run
|
||||
|
||||
### Arch Linux
|
||||
```bash
|
||||
sudo pacman -S portaudio alsa-lib
|
||||
dotnet run --project LanMountainDesktop/LanMountainDesktop.csproj
|
||||
```
|
||||
|
||||
### Alpine Linux
|
||||
```bash
|
||||
sudo apk add portaudio alsa-lib
|
||||
```
|
||||
|
||||
> 注:如果未安装这些依赖,录音和自习监测功能将不可用,但应用其他功能可以正常运行。
|
||||
|
||||
Reference in New Issue
Block a user