插件市场
This commit is contained in:
lincube
2026-03-10 09:55:49 +08:00
parent d33d8d3391
commit cdffaa16eb
45 changed files with 4483 additions and 365 deletions

View File

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

View File

@@ -10,6 +10,7 @@
目录结构:
- `docs/`:插件开发文档、打包文档
- `plugins/`:第一方插件项目,例如插件市场插件
- `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包
- `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件
- `standards/`:插件标准文件与模板

View File

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

View File

@@ -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();
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}

View File

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

View File

@@ -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..."
}

View File

@@ -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": "安装中…"
}

View File

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

View File

@@ -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 => "已安装",
_ => "安装"
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "桌面编辑",

View File

@@ -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();
}
}

View File

@@ -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")!;

View File

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

View File

@@ -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>
<StackPanel Spacing="8">
<TextBlock x:Name="WeatherPreviewSectionTextBlock"
Classes="section-eyebrow"
Text="Weather Preview" />
<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" />
<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>

View File

@@ -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")!;

View File

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

View File

@@ -83,11 +83,6 @@ public partial class SettingsWindow
private void OnGridSpacingPresetSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (_suppressGridSpacingEvents)
{
return;
}
UpdateGridPreviewLayout();
}

View File

@@ -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.");

View File

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

View File

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

View File

@@ -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();

View File

@@ -24,6 +24,8 @@ AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
DefaultDirName={autopf}\{#MyAppName}
DisableDirPage=no
UsePreviousAppDir=no
DefaultGroupName={#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
OutputDir={#MyOutputDir}

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

View File

@@ -0,0 +1,745 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class PluginMarketEmbeddedView : UserControl, IDisposable
{
private static readonly IBrush SurfaceBrush = new SolidColorBrush(Color.Parse("#14000000"));
private static readonly IBrush SelectedSurfaceBrush = new SolidColorBrush(Color.Parse("#1F0EA5E9"));
private static readonly IBrush SuccessBrush = new SolidColorBrush(Color.Parse("#FF0F766E"));
private static readonly IBrush WarningBrush = new SolidColorBrush(Color.Parse("#FF9A6700"));
private static readonly IBrush ErrorBrush = new SolidColorBrush(Color.Parse("#FFC42B1C"));
private readonly AppSettingsService _appSettingsService = new();
private readonly LocalizationService _localizationService = new();
private readonly PluginRuntimeService _runtime;
private readonly AirAppMarketIndexService _indexService;
private readonly AirAppMarketInstallService _installService;
private readonly Version? _hostVersion;
private readonly TextBox _searchTextBox;
private readonly Button _refreshButton;
private readonly TextBlock _statusTextBlock;
private readonly StackPanel _pluginListHost;
private readonly Border _detailBorder;
private AirAppMarketIndexDocument? _document;
private AirAppMarketPluginEntry? _selectedPlugin;
private Dictionary<string, PluginCatalogEntry> _installedPlugins = new(StringComparer.OrdinalIgnoreCase);
private string _marketSourceDisplay = AirAppMarketDefaults.DefaultIndexUrl;
private bool _isRefreshing;
private bool _isInstalling;
private bool _hasLoadedOnce;
public PluginMarketEmbeddedView(PluginRuntimeService runtime)
{
_runtime = runtime;
var dataDirectory = Path.Combine(AppContext.BaseDirectory, "Data", "AirAppMarket");
_indexService = new AirAppMarketIndexService(new AirAppMarketCacheService(dataDirectory));
_installService = new AirAppMarketInstallService(runtime, dataDirectory);
_hostVersion = typeof(App).Assembly.GetName().Version;
_searchTextBox = new TextBox
{
MinWidth = 240,
Watermark = T("market.toolbar.search_placeholder", "搜索插件")
};
_searchTextBox.PropertyChanged += (_, e) =>
{
if (e.Property == TextBox.TextProperty)
{
RebuildSurface();
}
};
_refreshButton = new Button
{
Content = T("market.toolbar.refresh", "刷新"),
HorizontalAlignment = HorizontalAlignment.Left
};
_refreshButton.Click += OnRefreshClick;
_statusTextBlock = new TextBlock
{
Text = T("market.status.loading", "正在加载官方插件市场…"),
TextWrapping = TextWrapping.Wrap,
Foreground = WarningBrush
};
_pluginListHost = new StackPanel
{
Spacing = 10
};
_detailBorder = CreatePanelShell();
Content = BuildLayout();
AttachedToVisualTree += async (_, _) =>
{
if (_hasLoadedOnce)
{
return;
}
_hasLoadedOnce = true;
await RefreshAsync();
};
}
public void RefreshInstalledSnapshot()
{
_installedPlugins = _runtime.Catalog
.ToDictionary(entry => entry.Manifest.Id, StringComparer.OrdinalIgnoreCase);
RebuildSurface();
}
public void RefreshLocalization()
{
_searchTextBox.Watermark = T("market.toolbar.search_placeholder", "Search plugins");
_refreshButton.Content = T("market.toolbar.refresh", "Refresh");
RebuildSurface();
}
public void Dispose()
{
_installService.Dispose();
_indexService.Dispose();
}
private Control BuildLayout()
{
var root = new Grid
{
RowDefinitions = new RowDefinitions("Auto,*"),
RowSpacing = 16
};
var toolbar = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
ColumnSpacing = 12
};
toolbar.Children.Add(new StackPanel
{
Spacing = 8,
Children =
{
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
Children =
{
_searchTextBox,
_refreshButton
}
},
_statusTextBlock
}
});
var contentGrid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("360,*"),
ColumnSpacing = 16
};
var listShell = CreatePanelShell();
listShell.Child = new ScrollViewer
{
Content = _pluginListHost
};
contentGrid.Children.Add(listShell);
contentGrid.Children.Add(_detailBorder);
Grid.SetColumn(_detailBorder, 1);
root.Children.Add(toolbar);
root.Children.Add(contentGrid);
Grid.SetRow(contentGrid, 1);
return root;
}
private async void OnRefreshClick(object? sender, RoutedEventArgs e)
{
await RefreshAsync();
}
private async Task RefreshAsync()
{
if (_isRefreshing)
{
return;
}
_isRefreshing = true;
_refreshButton.IsEnabled = false;
SetStatus(T("market.status.loading", "正在加载官方插件市场…"), WarningBrush);
try
{
RefreshInstalledSnapshot();
var result = await _indexService.LoadAsync();
if (!result.Success || result.Document is null)
{
_document = null;
_selectedPlugin = null;
SetStatus(
F("market.status.load_failed_format", "加载插件市场失败:{0}", result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
RebuildSurface();
return;
}
_document = result.Document;
_marketSourceDisplay = result.SourceLocation ?? AirAppMarketDefaults.DefaultIndexUrl;
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, result.Document.Plugins);
var statusMessage = result.Source == AirAppMarketLoadSource.Cache
? F(
"market.status.loaded_cache_format",
"官方源不可用,已从缓存加载 {0} 个插件。原因:{1}",
result.Document.Plugins.Count,
result.WarningMessage ?? T("market.detail.unknown", "未知错误"))
: F(
"market.status.loaded_network_format",
"已从官方源加载 {0} 个插件。",
result.Document.Plugins.Count);
SetStatus(statusMessage, result.Source == AirAppMarketLoadSource.Cache ? WarningBrush : SuccessBrush);
RebuildSurface();
}
finally
{
_isRefreshing = false;
_refreshButton.IsEnabled = true;
}
}
private void RebuildSurface()
{
var filteredPlugins = GetFilteredPlugins();
if (filteredPlugins.Count > 0)
{
_selectedPlugin = ResolveSelectedPlugin(_selectedPlugin?.Id, filteredPlugins);
}
else
{
_selectedPlugin = null;
}
BuildPluginList(filteredPlugins);
BuildDetailPanel();
}
private List<AirAppMarketPluginEntry> GetFilteredPlugins()
{
if (_document is null)
{
return [];
}
var query = (_searchTextBox.Text ?? string.Empty).Trim();
var source = _document.Plugins;
if (string.IsNullOrWhiteSpace(query))
{
return source.ToList();
}
return source
.Where(plugin =>
plugin.Name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Description.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Author.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Id.Contains(query, StringComparison.OrdinalIgnoreCase) ||
plugin.Tags.Any(tag => tag.Contains(query, StringComparison.OrdinalIgnoreCase)))
.ToList();
}
private void BuildPluginList(IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
_pluginListHost.Children.Clear();
if (_document is null)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.empty", "插件市场尚未加载。")));
return;
}
if (plugins.Count == 0)
{
_pluginListHost.Children.Add(CreateEmptyState(T("market.list.no_results", "没有匹配的插件。")));
return;
}
foreach (var plugin in plugins)
{
_pluginListHost.Children.Add(CreatePluginCard(plugin));
}
}
private Control CreatePluginCard(AirAppMarketPluginEntry plugin)
{
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isSelected = string.Equals(_selectedPlugin?.Id, plugin.Id, StringComparison.OrdinalIgnoreCase);
var button = new Button
{
HorizontalContentAlignment = HorizontalAlignment.Stretch,
Padding = new Thickness(0),
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Content = new Border
{
Background = isSelected ? SelectedSurfaceBrush : SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new StackPanel
{
Spacing = 10,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 12,
Children =
{
CreateMonogramIcon(plugin.Name, 42),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 16,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = F("market.card.subtitle_format", "{0} · v{1}", plugin.Author, plugin.Version),
Foreground = Brushes.Gray,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap,
MaxHeight = 56
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(installedPlugin?.IsLoaded == true
? T("market.card.loaded", "已加载")
: T("market.card.pending_restart", "需重启")),
new TextBlock
{
Text = string.Join(" ", plugin.Tags.Take(3)),
VerticalAlignment = VerticalAlignment.Center,
Foreground = Brushes.Gray
}
}
}
}
}
}
};
button.Click += (_, _) =>
{
_selectedPlugin = plugin;
RebuildSurface();
};
return button;
}
private void BuildDetailPanel()
{
if (_selectedPlugin is null)
{
_detailBorder.Child = CreateEmptyState(T("market.detail.placeholder", "从左侧选择一个插件以查看详情。"));
return;
}
var plugin = _selectedPlugin;
var installState = ResolveInstallState(plugin, out var installedPlugin);
var isCompatible = IsCompatibleWithHost(plugin);
var installButton = new Button
{
Content = _isInstalling
? T("market.button.installing", "安装中…")
: T(ButtonKey(installState), ButtonFallback(installState)),
IsEnabled = !_isInstalling && isCompatible && installState != AirAppMarketInstallState.Installed,
HorizontalAlignment = HorizontalAlignment.Left,
MinWidth = 120
};
installButton.Click += async (_, _) => await InstallSelectedPluginAsync(plugin);
var detailPanel = new StackPanel
{
Spacing = 14,
Children =
{
new Grid
{
ColumnDefinitions = new ColumnDefinitions("Auto,*"),
ColumnSpacing = 14,
Children =
{
CreateMonogramIcon(plugin.Name, 64),
new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = plugin.Name,
FontSize = 24,
FontWeight = FontWeight.SemiBold,
TextWrapping = TextWrapping.Wrap
},
new TextBlock
{
Text = plugin.Description,
TextWrapping = TextWrapping.Wrap
}
}
}
}
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
Children =
{
CreateStateChip(T(StateKey(installState), StateFallback(installState))),
CreateStateChip(plugin.GetVersionSummary()),
CreateStateChip(string.Join(", ", plugin.Tags))
}
},
installButton,
CreateInfoRow(T("market.detail.author", "作者"), plugin.Author),
CreateInfoRow(T("market.detail.version", "版本"), plugin.Version),
CreateInfoRow(T("market.detail.api_version", "API 版本"), plugin.ApiVersion),
CreateInfoRow(T("market.detail.min_host_version", "最低宿主版本"), plugin.MinHostVersion),
CreateInfoRow(T("market.detail.installed_version", "当前已安装版本"), installedPlugin?.Manifest.Version ?? T("market.detail.not_installed", "未安装")),
CreateInfoRow(T("market.detail.market_source", "市场源"), _marketSourceDisplay),
CreateInfoRow(T("market.detail.homepage", "主页"), plugin.HomepageUrl),
CreateInfoRow(T("market.detail.repository", "仓库"), plugin.RepositoryUrl),
new TextBlock
{
Text = T("market.detail.release_notes", "发布说明"),
FontSize = 18,
FontWeight = FontWeight.SemiBold
},
new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(14),
Child = new TextBlock
{
Text = plugin.ReleaseNotes,
TextWrapping = TextWrapping.Wrap
}
}
}
};
if (!isCompatible)
{
detailPanel.Children.Insert(
3,
new TextBlock
{
Text = F(
"market.status.host_incompatible_format",
"当前宿主版本过低,至少需要 {0}。",
plugin.MinHostVersion),
Foreground = ErrorBrush,
TextWrapping = TextWrapping.Wrap
});
}
_detailBorder.Child = new ScrollViewer
{
Content = detailPanel
};
}
private async Task InstallSelectedPluginAsync(AirAppMarketPluginEntry plugin)
{
if (_isInstalling)
{
return;
}
_isInstalling = true;
BuildDetailPanel();
SetStatus(
F("market.status.installing_format", "正在下载并暂存插件“{0}”…", plugin.Name),
WarningBrush);
try
{
var result = await _installService.InstallAsync(plugin);
if (!result.Success || result.Manifest is null)
{
SetStatus(
F(
"market.status.install_failed_format",
"安装插件失败:{0}",
result.ErrorMessage ?? T("market.detail.unknown", "未知错误")),
ErrorBrush);
return;
}
RefreshInstalledSnapshot();
SetStatus(
F(
"market.status.install_success_format",
"插件“{0}”已暂存完成,重启应用后生效。",
result.Manifest.Name),
SuccessBrush);
RebuildSurface();
}
finally
{
_isInstalling = false;
BuildDetailPanel();
}
}
private AirAppMarketPluginEntry? ResolveSelectedPlugin(
string? selectedPluginId,
IReadOnlyList<AirAppMarketPluginEntry> plugins)
{
if (plugins.Count == 0)
{
return null;
}
if (!string.IsNullOrWhiteSpace(selectedPluginId))
{
var existing = plugins.FirstOrDefault(plugin =>
string.Equals(plugin.Id, selectedPluginId, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
return existing;
}
}
return plugins[0];
}
private AirAppMarketInstallState ResolveInstallState(
AirAppMarketPluginEntry plugin,
out PluginCatalogEntry? installedPlugin)
{
if (!_installedPlugins.TryGetValue(plugin.Id, out installedPlugin))
{
return AirAppMarketInstallState.NotInstalled;
}
return CompareVersions(plugin.Version, installedPlugin.Manifest.Version) > 0
? AirAppMarketInstallState.UpdateAvailable
: AirAppMarketInstallState.Installed;
}
private bool IsCompatibleWithHost(AirAppMarketPluginEntry plugin)
{
if (_hostVersion is null ||
!AirAppMarketIndexDocument.TryParseVersion(plugin.MinHostVersion, out var minHostVersion) ||
minHostVersion is null)
{
return true;
}
return _hostVersion >= minHostVersion;
}
private void SetStatus(string message, IBrush foreground)
{
_statusTextBlock.Text = message;
_statusTextBlock.Foreground = foreground;
}
private static int CompareVersions(string? left, string? right)
{
if (!AirAppMarketIndexDocument.TryParseVersion(left, out var leftVersion))
{
leftVersion = new Version(0, 0, 0);
}
if (!AirAppMarketIndexDocument.TryParseVersion(right, out var rightVersion))
{
rightVersion = new Version(0, 0, 0);
}
return (leftVersion ?? new Version(0, 0, 0)).CompareTo(rightVersion ?? new Version(0, 0, 0));
}
private Border CreatePanelShell()
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16)
};
}
private Control CreateEmptyState(string text)
{
return new Border
{
Background = SurfaceBrush,
CornerRadius = new CornerRadius(16),
Padding = new Thickness(18),
Child = new TextBlock
{
Text = text,
TextWrapping = TextWrapping.Wrap
}
};
}
private Border CreateMonogramIcon(string text, double size)
{
var glyph = string.IsNullOrWhiteSpace(text) ? "?" : text.Trim()[0].ToString().ToUpperInvariant();
return new Border
{
Width = size,
Height = size,
CornerRadius = new CornerRadius(size / 2),
Background = new SolidColorBrush(Color.Parse("#FF0EA5E9")),
Child = new TextBlock
{
Text = glyph,
FontSize = Math.Max(16, size * 0.36),
FontWeight = FontWeight.Bold,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
TextAlignment = TextAlignment.Center
}
};
}
private Border CreateStateChip(string text)
{
return new Border
{
Background = new SolidColorBrush(Color.Parse("#22000000")),
CornerRadius = new CornerRadius(999),
Padding = new Thickness(10, 4),
Child = new TextBlock
{
Text = text,
FontSize = 12
}
};
}
private Control CreateInfoRow(string label, string value)
{
return new StackPanel
{
Spacing = 4,
Children =
{
new TextBlock
{
Text = label,
FontSize = 12,
Foreground = Brushes.Gray
},
new TextBlock
{
Text = string.IsNullOrWhiteSpace(value) ? T("market.detail.unknown", "未知") : value,
TextWrapping = TextWrapping.Wrap
}
}
};
}
private string T(string key, string fallback)
{
var snapshot = _appSettingsService.Load();
return _localizationService.GetString(snapshot.LanguageCode, key, fallback);
}
private string F(string key, string fallback, params object[] args)
{
return string.Format(CultureInfo.CurrentCulture, T(key, fallback), args);
}
private static string StateKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.detail.state.update_available",
AirAppMarketInstallState.Installed => "market.detail.state.installed",
_ => "market.detail.state.not_installed"
};
}
private static string StateFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "可更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "未安装"
};
}
private static string ButtonKey(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "market.button.update",
AirAppMarketInstallState.Installed => "market.button.installed",
_ => "market.button.install"
};
}
private static string ButtonFallback(AirAppMarketInstallState state)
{
return state switch
{
AirAppMarketInstallState.UpdateAvailable => "更新",
AirAppMarketInstallState.Installed => "已安装",
_ => "安装"
};
}
}

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

View File

@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Security.Cryptography;
using LanMountainDesktop.PluginSdk;
using LanMountainDesktop.Services;
namespace LanMountainDesktop.Views.SettingsPages;
internal sealed class AirAppMarketInstallService : IDisposable
{
private readonly PluginRuntimeService _runtime;
private readonly HttpClient _httpClient;
private readonly string _downloadsDirectory;
public AirAppMarketInstallService(PluginRuntimeService runtime, string dataDirectory)
{
_runtime = runtime;
_downloadsDirectory = Path.Combine(dataDirectory, "downloads");
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromMinutes(2)
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-PluginMarketplace/1.0");
}
public async Task<AirAppMarketInstallResult> InstallAsync(
AirAppMarketPluginEntry plugin,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(plugin);
Directory.CreateDirectory(_downloadsDirectory);
var downloadPath = Path.Combine(
_downloadsDirectory,
$"{SanitizeFileName(plugin.Id)}-{SanitizeFileName(plugin.Version)}.laapp");
try
{
if (AirAppMarketDefaults.TryResolveWorkspaceFile(plugin.DownloadUrl, out var localPackagePath))
{
await using var sourceStream = File.OpenRead(localPackagePath);
await using var destinationStream = File.Create(downloadPath);
await sourceStream.CopyToAsync(destinationStream, cancellationToken);
}
else
{
using var response = await _httpClient.GetAsync(
plugin.DownloadUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var destinationStream = File.Create(downloadPath);
await responseStream.CopyToAsync(destinationStream, cancellationToken);
}
await using var hashStream = File.OpenRead(downloadPath);
var hashBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
var actualHash = Convert.ToHexString(hashBytes).ToLowerInvariant();
if (!string.Equals(actualHash, plugin.Sha256, StringComparison.OrdinalIgnoreCase))
{
File.Delete(downloadPath);
return new AirAppMarketInstallResult(
false,
null,
$"SHA-256 mismatch. Expected {plugin.Sha256}, actual {actualHash}.");
}
var manifest = _runtime.InstallPluginPackage(downloadPath);
return new AirAppMarketInstallResult(true, manifest, null);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return new AirAppMarketInstallResult(false, null, ex.Message);
}
}
public void Dispose()
{
_httpClient.Dispose();
}
private static string SanitizeFileName(string value)
{
var invalidChars = Path.GetInvalidFileNameChars();
return new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray());
}
}

View File

@@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using LanMountainDesktop.PluginSdk;
namespace LanMountainDesktop.Views.SettingsPages;
internal static class AirAppMarketDefaults
{
public const string DefaultIndexUrl =
"https://raw.githubusercontent.com/wwiinnddyy/LanAirApp/main/airappmarket/index.json";
private const string RawGitHubLanAirAppPathPrefix = "/wwiinnddyy/LanAirApp/main/";
public static string? TryGetWorkspaceIndexPath()
{
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
if (repositoryRoot is null)
{
return null;
}
var candidatePath = Path.Combine(repositoryRoot, "airappmarket", "index.json");
return File.Exists(candidatePath) ? candidatePath : null;
}
public static bool TryResolveWorkspaceFile(string url, out string localPath)
{
localPath = string.Empty;
var repositoryRoot = TryGetWorkspaceLanAirAppRepositoryRoot();
if (repositoryRoot is null ||
!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
!string.Equals(uri.Host, "raw.githubusercontent.com", StringComparison.OrdinalIgnoreCase) ||
!uri.AbsolutePath.StartsWith(RawGitHubLanAirAppPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var relativePath = Uri.UnescapeDataString(uri.AbsolutePath[RawGitHubLanAirAppPathPrefix.Length..])
.Replace('/', Path.DirectorySeparatorChar);
var candidatePath = Path.GetFullPath(Path.Combine(repositoryRoot, relativePath));
if (!File.Exists(candidatePath))
{
return false;
}
localPath = candidatePath;
return true;
}
private static string? TryGetWorkspaceLanAirAppRepositoryRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, "LanAirApp");
if (File.Exists(Path.Combine(candidate, "airappmarket", "index.json")))
{
return candidate;
}
current = current.Parent;
}
return null;
}
}
internal enum AirAppMarketLoadSource
{
Local = 0,
Network = 1,
Cache = 2
}
internal enum AirAppMarketInstallState
{
NotInstalled = 0,
UpdateAvailable = 1,
Installed = 2
}
internal sealed record AirAppMarketLoadResult(
bool Success,
AirAppMarketIndexDocument? Document,
AirAppMarketLoadSource? Source,
string? SourceLocation,
string? WarningMessage,
string? ErrorMessage);
internal sealed record AirAppMarketInstallResult(
bool Success,
PluginManifest? Manifest,
string? ErrorMessage);
internal sealed class AirAppMarketIndexDocument
{
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
};
public string SchemaVersion { get; init; } = string.Empty;
public string SourceId { get; init; } = string.Empty;
public string SourceName { get; init; } = string.Empty;
public DateTimeOffset GeneratedAt { get; init; }
public List<AirAppMarketPluginEntry> Plugins { get; init; } = [];
public static AirAppMarketIndexDocument Load(string json, string sourceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(json);
ArgumentException.ThrowIfNullOrWhiteSpace(sourceName);
var document = JsonSerializer.Deserialize<AirAppMarketIndexDocument>(
json.TrimStart('\uFEFF'),
SerializerOptions);
if (document is null)
{
throw new InvalidOperationException($"Failed to parse market index '{sourceName}'.");
}
return document.ValidateAndNormalize(sourceName);
}
private AirAppMarketIndexDocument ValidateAndNormalize(string sourceName)
{
var plugins = Plugins ?? [];
var normalizedPlugins = new List<AirAppMarketPluginEntry>(plugins.Count);
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var plugin in plugins)
{
var normalizedPlugin = plugin.ValidateAndNormalize(sourceName);
if (!seenIds.Add(normalizedPlugin.Id))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' contains duplicate plugin id '{normalizedPlugin.Id}'.");
}
normalizedPlugins.Add(normalizedPlugin);
}
return new AirAppMarketIndexDocument
{
SchemaVersion = RequireValue(SchemaVersion, nameof(SchemaVersion), sourceName),
SourceId = RequireValue(SourceId, nameof(SourceId), sourceName),
SourceName = RequireValue(SourceName, nameof(SourceName), sourceName),
GeneratedAt = GeneratedAt == default
? throw new InvalidOperationException($"Market index '{sourceName}' is missing a valid generatedAt timestamp.")
: GeneratedAt,
Plugins = normalizedPlugins
.OrderBy(plugin => plugin.Name, StringComparer.OrdinalIgnoreCase)
.ToList()
};
}
private static string RequireValue(string? value, string propertyName, string sourceName)
{
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
throw new InvalidOperationException($"Market index '{sourceName}' is missing required property '{propertyName}'.");
}
return normalized;
}
internal static string? NormalizeValue(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal static string NormalizeVersion(string? value, string propertyName, string sourceName)
{
var normalized = RequireValue(value, propertyName, sourceName);
if (!TryParseVersion(normalized, out _))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid version '{normalized}' for '{propertyName}'.");
}
return normalized;
}
internal static void EnsureUrl(string url, string propertyName, string sourceName)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid URL '{url}' for '{propertyName}'.");
}
}
internal static bool TryParseVersion(string? value, out Version? version)
{
version = null;
var normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
{
return false;
}
if (normalized.StartsWith("v", StringComparison.OrdinalIgnoreCase))
{
normalized = normalized[1..];
}
var separatorIndex = normalized.IndexOfAny(['-', '+', ' ']);
if (separatorIndex > 0)
{
normalized = normalized[..separatorIndex];
}
if (!Version.TryParse(normalized, out var parsed))
{
return false;
}
version = new Version(
Math.Max(0, parsed.Major),
Math.Max(0, parsed.Minor),
Math.Max(0, parsed.Build));
return true;
}
}
internal sealed class AirAppMarketPluginEntry
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Author { get; init; } = string.Empty;
public string Version { get; init; } = string.Empty;
public string ApiVersion { get; init; } = string.Empty;
public string MinHostVersion { get; init; } = string.Empty;
public string DownloadUrl { get; init; } = string.Empty;
public string Sha256 { get; init; } = string.Empty;
public long PackageSizeBytes { get; init; }
public string IconUrl { get; init; } = string.Empty;
public string HomepageUrl { get; init; } = string.Empty;
public string RepositoryUrl { get; init; } = string.Empty;
public List<string> Tags { get; init; } = [];
public DateTimeOffset PublishedAt { get; init; }
public DateTimeOffset UpdatedAt { get; init; }
public string ReleaseNotes { get; init; } = string.Empty;
public AirAppMarketPluginEntry ValidateAndNormalize(string sourceName)
{
var normalizedTags = (Tags ?? [])
.Select(tag => AirAppMarketIndexDocument.NormalizeValue(tag))
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
var normalizedSha = AirAppMarketIndexDocument.NormalizeValue(Sha256)?.ToLowerInvariant()
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(Sha256)}'.");
if (normalizedSha.Length != 64 || normalizedSha.Any(ch => !Uri.IsHexDigit(ch)))
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid SHA-256 '{normalizedSha}' for plugin '{Id}'.");
}
var normalizedDownloadUrl = AirAppMarketIndexDocument.NormalizeValue(DownloadUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(DownloadUrl)}'.");
var normalizedIconUrl = AirAppMarketIndexDocument.NormalizeValue(IconUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(IconUrl)}'.");
var normalizedHomepageUrl = AirAppMarketIndexDocument.NormalizeValue(HomepageUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(HomepageUrl)}'.");
var normalizedRepositoryUrl = AirAppMarketIndexDocument.NormalizeValue(RepositoryUrl)
?? throw new InvalidOperationException(
$"Market index '{sourceName}' is missing required property '{nameof(RepositoryUrl)}'.");
AirAppMarketIndexDocument.EnsureUrl(normalizedDownloadUrl, nameof(DownloadUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedIconUrl, nameof(IconUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedHomepageUrl, nameof(HomepageUrl), sourceName);
AirAppMarketIndexDocument.EnsureUrl(normalizedRepositoryUrl, nameof(RepositoryUrl), sourceName);
if (PackageSizeBytes <= 0)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' declares invalid packageSizeBytes '{PackageSizeBytes}' for plugin '{Id}'.");
}
if (PublishedAt == default || UpdatedAt == default)
{
throw new InvalidOperationException(
$"Market index '{sourceName}' is missing valid publish timestamps for plugin '{Id}'.");
}
return new AirAppMarketPluginEntry
{
Id = AirAppMarketIndexDocument.NormalizeValue(Id)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin id."),
Name = AirAppMarketIndexDocument.NormalizeValue(Name)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin name."),
Description = AirAppMarketIndexDocument.NormalizeValue(Description)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin description."),
Author = AirAppMarketIndexDocument.NormalizeValue(Author)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing plugin author."),
Version = AirAppMarketIndexDocument.NormalizeVersion(Version, nameof(Version), sourceName),
ApiVersion = AirAppMarketIndexDocument.NormalizeVersion(ApiVersion, nameof(ApiVersion), sourceName),
MinHostVersion = AirAppMarketIndexDocument.NormalizeVersion(MinHostVersion, nameof(MinHostVersion), sourceName),
DownloadUrl = normalizedDownloadUrl,
Sha256 = normalizedSha,
PackageSizeBytes = PackageSizeBytes,
IconUrl = normalizedIconUrl,
HomepageUrl = normalizedHomepageUrl,
RepositoryUrl = normalizedRepositoryUrl,
Tags = normalizedTags,
PublishedAt = PublishedAt,
UpdatedAt = UpdatedAt,
ReleaseNotes = AirAppMarketIndexDocument.NormalizeValue(ReleaseNotes)
?? throw new InvalidOperationException($"Market index '{sourceName}' is missing release notes for plugin '{Id}'.")
};
}
public string GetVersionSummary()
{
return string.Format(
CultureInfo.InvariantCulture,
"v{0} | API {1} | Host >= {2}",
Version,
ApiVersion,
MinHostVersion);
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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="&#xe719;" 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>

View File

@@ -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);
}
}

23
airappmarket/README.md Normal file
View File

@@ -0,0 +1,23 @@
# AirAppMarket
`airappmarket/` 是阑山桌面的官方插件市场源目录。
当前阶段职责:
- 提供官方插件市场索引 `index.json`
- 提供索引 schema
- 提供静态图标资产
- 提供本地与 CI 使用的索引校验工具
Bootstrap 方式:
1. 用户先通过阑山桌面内置的 `设置 -> 插件 -> 打开 .laapp 插件包` 手动安装 `LanMountainDesktop.PluginMarketplace`
2. 市场插件启动后,会从这里的官方索引拉取插件列表。
3. 后续插件安装与更新都通过市场插件完成。
官方索引地址:
`https://raw.githubusercontent.com/wwiinnddyy/LanMountainDesktop/main/airappmarket/index.json`
约束:
- 这里只维护官方市场源,不做多源聚合。
- 第一阶段不提供独立 GitHub Pages 页面。
- 索引中的下载链接默认指向本仓库已提交的 `.laapp` 发布包。

View 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

View 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
View 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."
}
]
}

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

View File

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

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