diff --git a/.github/workflows/airappmarket-validate.yml b/.github/workflows/airappmarket-validate.yml new file mode 100644 index 0000000..2451843 --- /dev/null +++ b/.github/workflows/airappmarket-validate.yml @@ -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 diff --git a/LanAirApp/README.md b/LanAirApp/README.md index 0af78fb..0e328c8 100644 --- a/LanAirApp/README.md +++ b/LanAirApp/README.md @@ -10,6 +10,7 @@ 目录结构: - `docs/`:插件开发文档、打包文档 +- `plugins/`:第一方插件项目,例如插件市场插件 - `releases/`:已经打包完成、可直接分享与安装的 `.laapp` 插件包 - `samples/`:示例插件,其中 `LanMountainDesktop.SamplePlugin` 是示例开发插件 - `standards/`:插件标准文件与模板 diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs new file mode 100644 index 0000000..ad2370a --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketCacheService.cs @@ -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; + } + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs new file mode 100644 index 0000000..39d3e66 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketIndexService.cs @@ -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 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(); + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs new file mode 100644 index 0000000..c9b1d66 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketInstallService.cs @@ -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 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()); + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs new file mode 100644 index 0000000..79a681c --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/AirAppMarketModels.cs @@ -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 Plugins { get; init; } = []; + + public static AirAppMarketIndexDocument Load(string json, string sourceName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(json); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceName); + + var document = JsonSerializer.Deserialize( + 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(plugins.Count); + var seenIds = new HashSet(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 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); + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj new file mode 100644 index 0000000..8accea2 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/LanMountainDesktop.PluginMarketplace.csproj @@ -0,0 +1,35 @@ + + + + net10.0 + enable + enable + 1.0.0 + true + bin\$(Configuration)\$(TargetFramework)\content\ + false + false + ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\ + $(PluginPackageOutputDirectory)$(AssemblyName).laapp + ..\..\releases\ + $(PluginReleaseOutputDirectory)$(AssemblyName).$(Version).laapp + ..\..\..\LanMountainDesktop\bin\$(Configuration)\$(TargetFramework)\Extensions\Plugins\PluginMarketplace\ + + + + + + + + + + + + + + + + + + + diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json new file mode 100644 index 0000000..dfc0e3f --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/en-US.json @@ -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..." +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json new file mode 100644 index 0000000..9eb4d7c --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/Localization/zh-CN.json @@ -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": "安装中…" +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs new file mode 100644 index 0000000..56e04f1 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplacePlugin.cs @@ -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() + ?? 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; + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs new file mode 100644 index 0000000..f81e544 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/PluginMarketplaceSettingsView.cs @@ -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 _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(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 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 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 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 => "已安装", + _ => "安装" + }; + } +} diff --git a/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json new file mode 100644 index 0000000..f0189d8 --- /dev/null +++ b/LanAirApp/plugins/LanMountainDesktop.PluginMarketplace/plugin.json @@ -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" +} diff --git a/LanMountainDesktop.PluginSdk/IPluginPackageManager.cs b/LanMountainDesktop.PluginSdk/IPluginPackageManager.cs new file mode 100644 index 0000000..987d6d1 --- /dev/null +++ b/LanMountainDesktop.PluginSdk/IPluginPackageManager.cs @@ -0,0 +1,8 @@ +namespace LanMountainDesktop.PluginSdk; + +public interface IPluginPackageManager +{ + IReadOnlyList GetInstalledPlugins(); + + PluginPackageInstallResult InstallPackage(string packagePath); +} diff --git a/LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs b/LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs new file mode 100644 index 0000000..804bcbb --- /dev/null +++ b/LanMountainDesktop.PluginSdk/InstalledPluginInfo.cs @@ -0,0 +1,8 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed record InstalledPluginInfo( + PluginManifest Manifest, + bool IsEnabled, + bool IsLoaded, + bool IsPackage, + string? ErrorMessage); diff --git a/LanMountainDesktop.PluginSdk/PluginPackageInstallResult.cs b/LanMountainDesktop.PluginSdk/PluginPackageInstallResult.cs new file mode 100644 index 0000000..0aee5bd --- /dev/null +++ b/LanMountainDesktop.PluginSdk/PluginPackageInstallResult.cs @@ -0,0 +1,6 @@ +namespace LanMountainDesktop.PluginSdk; + +public sealed record PluginPackageInstallResult( + PluginManifest Manifest, + bool ReplacedExisting, + bool RestartRequired); diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 97cd619..6f059da 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -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", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index f98b367..d50a3b5 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -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": "桌面编辑", diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 645c8ae..c7e56af 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -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(); } } diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 0cf18ae..46dcc14 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -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("WeatherPanelTitleTextBlock")!; + internal TextBlock WeatherPreviewSectionTextBlock => WeatherSettingsPanel.FindControl("WeatherPreviewSectionTextBlock")!; + internal TextBlock WeatherSettingsSectionTextBlock => WeatherSettingsPanel.FindControl("WeatherSettingsSectionTextBlock")!; internal FluentAvalonia.UI.Controls.SettingsExpander WeatherPreviewSettingsExpander => WeatherSettingsPanel.FindControl("WeatherPreviewSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander WeatherLocationSettingsExpander => WeatherSettingsPanel.FindControl("WeatherLocationSettingsExpander")!; internal FluentAvalonia.UI.Controls.SettingsExpander WeatherCitySearchSettingsExpander => WeatherSettingsPanel.FindControl("WeatherCitySearchSettingsExpander")!; @@ -2691,6 +2740,7 @@ public partial class MainWindow internal FluentAvalonia.UI.Controls.NumberBox WeatherLongitudeNumberBox => WeatherSettingsPanel.FindControl("WeatherLongitudeNumberBox")!; internal TextBlock WeatherCoordinateStatusTextBlock => WeatherSettingsPanel.FindControl("WeatherCoordinateStatusTextBlock")!; internal TextBlock WeatherPreviewResultTextBlock => WeatherSettingsPanel.FindControl("WeatherPreviewResultTextBlock")!; + internal Image WeatherPreviewIconImage => WeatherSettingsPanel.FindControl("WeatherPreviewIconImage")!; internal FluentIcons.Avalonia.Fluent.SymbolIcon WeatherPreviewIconSymbol => WeatherSettingsPanel.FindControl("WeatherPreviewIconSymbol")!; internal TextBlock WeatherPreviewTemperatureTextBlock => WeatherSettingsPanel.FindControl("WeatherPreviewTemperatureTextBlock")!; internal TextBlock WeatherPreviewUpdatedTextBlock => WeatherSettingsPanel.FindControl("WeatherPreviewUpdatedTextBlock")!; @@ -2698,7 +2748,13 @@ public partial class MainWindow internal FluentAvalonia.UI.Controls.ProgressRing WeatherPreviewProgressRing => WeatherSettingsPanel.FindControl("WeatherPreviewProgressRing")!; internal ComboBoxItem WeatherIconPackFluentRegularItem => WeatherSettingsPanel.FindControl("WeatherIconPackFluentRegularItem")!; internal ComboBoxItem WeatherIconPackFluentFilledItem => WeatherSettingsPanel.FindControl("WeatherIconPackFluentFilledItem")!; + internal TextBlock WeatherLocationSelectionTitleTextBlock => WeatherSettingsPanel.FindControl("WeatherLocationSelectionTitleTextBlock")!; + internal TextBlock WeatherLocationSelectionDescriptionTextBlock => WeatherSettingsPanel.FindControl("WeatherLocationSelectionDescriptionTextBlock")!; + internal TextBlock WeatherLocationValueTextBlock => WeatherSettingsPanel.FindControl("WeatherLocationValueTextBlock")!; internal TextBlock WeatherLocationStatusTextBlock => WeatherSettingsPanel.FindControl("WeatherLocationStatusTextBlock")!; + internal TextBlock WeatherAlertListTitleTextBlock => WeatherSettingsPanel.FindControl("WeatherAlertListTitleTextBlock")!; + internal TextBlock WeatherAlertListDescriptionTextBlock => WeatherSettingsPanel.FindControl("WeatherAlertListDescriptionTextBlock")!; + internal TextBlock WeatherFooterHintTextBlock => WeatherSettingsPanel.FindControl("WeatherFooterHintTextBlock")!; // --- UpdateSettingsPage --- internal TextBlock UpdatePanelTitleTextBlock => UpdateSettingsPanel.FindControl("UpdatePanelTitleTextBlock")!; diff --git a/LanMountainDesktop/Views/MainWindow.axaml.cs b/LanMountainDesktop/Views/MainWindow.axaml.cs index c94f831..2c3742d 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml.cs +++ b/LanMountainDesktop/Views/MainWindow.axaml.cs @@ -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; diff --git a/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml b/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml index 0762cb3..5fbd6ca 100644 --- a/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml +++ b/LanMountainDesktop/Views/SettingsPages/WeatherSettingsPage.axaml @@ -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"> + + + + + + + + + + + Spacing="12"> - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + @@ -157,23 +255,23 @@ - + + Classes="settings-shell-card" + Padding="16,14" + CornerRadius="24"> - + @@ -182,7 +280,7 @@ Spacing="2" VerticalAlignment="Center">