From 3b71486423f724194c5bbfa062dee7bc110ed30e Mon Sep 17 00:00:00 2001 From: lincube Date: Thu, 5 Mar 2026 18:46:32 +0800 Subject: [PATCH] 0.4.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复视频壁纸恶性bug,添加央广网组件。 --- .../ComponentSystem/BuiltInComponentIds.cs | 2 + .../ComponentSystem/ComponentRegistry.cs | 18 + LanMountainDesktop/Localization/en-US.json | 54 + LanMountainDesktop/Localization/zh-CN.json | 54 + .../Models/AppSettingsSnapshot.cs | 6 + .../Models/RecommendationDataModels.cs | 27 +- .../Services/GitHubReleaseUpdateService.cs | 482 ++++++++ .../Services/IRecommendationDataService.cs | 160 +++ .../Services/RecommendationDataService.cs | 1095 +++++++++++++++++ .../Views/Components/CnrDailyNewsWidget.axaml | 148 +++ .../Components/CnrDailyNewsWidget.axaml.cs | 534 ++++++++ .../Views/Components/DailyArtworkWidget.axaml | 8 +- .../Components/DailyArtworkWidget.axaml.cs | 264 +++- .../Views/Components/DailyWordWidget.axaml | 127 ++ .../Views/Components/DailyWordWidget.axaml.cs | 502 ++++++++ .../DesktopComponentRuntimeRegistry.cs | 10 + .../Views/MainWindow.Localization.cs | 3 + .../Views/MainWindow.Settings.cs | 260 +++- LanMountainDesktop/Views/MainWindow.Update.cs | 482 ++++++++ LanMountainDesktop/Views/MainWindow.axaml | 125 +- LanMountainDesktop/Views/MainWindow.axaml.cs | 20 +- 21 files changed, 4333 insertions(+), 48 deletions(-) create mode 100644 LanMountainDesktop/Services/GitHubReleaseUpdateService.cs create mode 100644 LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs create mode 100644 LanMountainDesktop/Views/Components/DailyWordWidget.axaml create mode 100644 LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs create mode 100644 LanMountainDesktop/Views/MainWindow.Update.cs diff --git a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs index 8154489..9eed904 100644 --- a/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs +++ b/LanMountainDesktop/ComponentSystem/BuiltInComponentIds.cs @@ -29,6 +29,8 @@ public static class BuiltInComponentIds public const string HolidayCalendar = "HolidayCalendar"; public const string DesktopDailyPoetry = "DesktopDailyPoetry"; public const string DesktopDailyArtwork = "DesktopDailyArtwork"; + public const string DesktopDailyWord = "DesktopDailyWord"; + public const string DesktopCnrDailyNews = "DesktopCnrDailyNews"; public const string DesktopWhiteboard = "DesktopWhiteboard"; public const string DesktopBlackboardLandscape = "DesktopBlackboardLandscape"; public const string DesktopBrowser = "DesktopBrowser"; diff --git a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs index 282215c..e525393 100644 --- a/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs +++ b/LanMountainDesktop/ComponentSystem/ComponentRegistry.cs @@ -225,6 +225,24 @@ public sealed class ComponentRegistry MinHeightCells: 2, AllowStatusBarPlacement: false, AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopDailyWord, + "Daily Word", + "Book", + "Info", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), + new DesktopComponentDefinition( + BuiltInComponentIds.DesktopCnrDailyNews, + "CNR Daily News", + "News", + "Info", + MinWidthCells: 4, + MinHeightCells: 2, + AllowStatusBarPlacement: false, + AllowDesktopPlacement: true), new DesktopComponentDefinition( BuiltInComponentIds.DesktopWhiteboard, "Blackboard Portrait", diff --git a/LanMountainDesktop/Localization/en-US.json b/LanMountainDesktop/Localization/en-US.json index 3d468eb..1b8fcab 100644 --- a/LanMountainDesktop/Localization/en-US.json +++ b/LanMountainDesktop/Localization/en-US.json @@ -12,6 +12,7 @@ "settings.nav.status_bar": "Status Bar", "settings.nav.weather": "Weather", "settings.nav.region": "Region", + "settings.nav.update": "Update", "settings.nav.about": "About", "settings.wallpaper.title": "Wallpaper", "settings.wallpaper.description": "Pick an image or video to apply as the app window wallpaper immediately.", @@ -195,6 +196,38 @@ "settings.region.timezone_header": "Time Zone", "settings.region.timezone_desc": "Select a time zone. Clock and calendar widgets will follow this zone.", "settings.region.applied_format": "Language switched to: {0}", + "settings.update.title": "Update", + "settings.update.current_version_label": "Current Version", + "settings.update.latest_version_label": "Latest Release", + "settings.update.published_at_label": "Published At", + "settings.update.options_header": "Update Options", + "settings.update.options_desc": "Configure update checks and release channel.", + "settings.update.auto_check_toggle": "Automatically check for updates on startup", + "settings.update.include_prerelease_toggle": "Include prerelease versions", + "settings.update.channel_label": "Update Channel", + "settings.update.channel_stable": "Stable", + "settings.update.channel_preview": "Preview", + "settings.update.actions_header": "Update Actions", + "settings.update.actions_desc": "Check releases, download installer, and start update.", + "settings.update.check_button": "Check for Updates", + "settings.update.download_install_button": "Download & Install", + "settings.update.download_progress_idle": "Download progress: -", + "settings.update.download_progress_format": "Download progress: {0:F0}%", + "settings.update.status_ready": "Ready to check for updates.", + "settings.update.status_channel_changed": "Update channel changed. Please check again.", + "settings.update.status_channel_changed_format": "Update channel switched to {0}. Please check again.", + "settings.update.status_windows_only": "Automatic installer update is currently available only on Windows.", + "settings.update.status_checking": "Checking GitHub releases...", + "settings.update.status_check_failed_format": "Update check failed: {0}", + "settings.update.status_up_to_date": "You are already on the latest version.", + "settings.update.status_asset_missing": "A new release is available, but no compatible installer was found.", + "settings.update.status_available_format": "New version {0} is available. Click Download & Install.", + "settings.update.status_downloading": "Downloading installer...", + "settings.update.status_download_failed_format": "Download failed: {0}", + "settings.update.status_launching_installer": "Download complete. Launching installer...", + "settings.update.status_installer_missing": "Installer file was not found after download.", + "settings.update.status_installer_started": "Installer started. The app will close for update.", + "settings.update.status_launch_failed_format": "Failed to start installer: {0}", "settings.about.title": "About", "settings.about.version_format": "Version: {0}", "settings.about.codename_format": "Code Name: {0}", @@ -210,6 +243,7 @@ "common.night": "Night", "common.back": "Back", "common.close": "Close", + "common.unknown": "Unknown error", "common.recommended": "Recommended", "common.monet": "Monet", "desktop.page_index_format": "Desktop {0}", @@ -248,6 +282,8 @@ "component.audio_recorder": "Recorder", "component.daily_poetry": "Daily Poetry", "component.daily_artwork": "Daily Artwork", + "component.daily_word": "Daily Word", + "component.cnr_daily_news": "CNR Headlines", "component.whiteboard": "Blackboard (Portrait)", "component.blackboard_landscape": "Blackboard (Landscape)", "component.browser": "Browser", @@ -280,6 +316,24 @@ "artwork.widget.fallback_artist": "Recommendation service unavailable", "artwork.widget.fallback_year": "Try again later", "artwork.widget.unknown_artist": "Unknown artist", + "dailyword.widget.loading": "Loading...", + "dailyword.widget.loading_word": "daily word", + "dailyword.widget.loading_pronunciation": "Fetching pronunciation...", + "dailyword.widget.loading_meaning": "Fetching meaning...", + "dailyword.widget.loading_example": "Fetching example sentence...", + "dailyword.widget.loading_example_translation": "Loading...", + "dailyword.widget.fetch_failed": "Daily word fetch failed", + "dailyword.widget.fallback_word": "daily word", + "dailyword.widget.fallback_pronunciation": "Pronunciation unavailable", + "dailyword.widget.fallback_meaning": "Youdao dictionary is temporarily unavailable.", + "dailyword.widget.fallback_example": "Tap the refresh button and try again.", + "dailyword.widget.fallback_example_translation": "It will retry when network recovers.", + "cnrnews.widget.loading": "Loading...", + "cnrnews.widget.loading_title": "Fetching CNR headlines", + "cnrnews.widget.loading_subtitle": "Please wait", + "cnrnews.widget.fetch_failed": "News fetch failed", + "cnrnews.widget.fallback_title": "CNR news is temporarily unavailable", + "cnrnews.widget.fallback_subtitle": "Tap refresh and try again", "artwork.settings.title": "Daily Artwork Settings", "artwork.settings.desc": "Switch the data source used by Daily Artwork.", "artwork.settings.source_label": "Mirror Source", diff --git a/LanMountainDesktop/Localization/zh-CN.json b/LanMountainDesktop/Localization/zh-CN.json index 197381b..012d9cf 100644 --- a/LanMountainDesktop/Localization/zh-CN.json +++ b/LanMountainDesktop/Localization/zh-CN.json @@ -12,6 +12,7 @@ "settings.nav.status_bar": "状态栏", "settings.nav.weather": "天气", "settings.nav.region": "地区", + "settings.nav.update": "更新", "settings.nav.about": "关于", "settings.wallpaper.title": "壁纸", "settings.wallpaper.description": "选择图片或视频后可立即设为应用窗口壁纸。", @@ -195,6 +196,38 @@ "settings.region.timezone_header": "时区", "settings.region.timezone_desc": "选择时区。时钟与日历组件会使用该时区。", "settings.region.applied_format": "语言已切换为:{0}", + "settings.update.title": "更新", + "settings.update.current_version_label": "当前版本", + "settings.update.latest_version_label": "最新发布", + "settings.update.published_at_label": "发布时间", + "settings.update.options_header": "更新选项", + "settings.update.options_desc": "配置更新检查与发布通道。", + "settings.update.auto_check_toggle": "启动时自动检查更新", + "settings.update.include_prerelease_toggle": "包含预发布版本", + "settings.update.channel_label": "更新通道", + "settings.update.channel_stable": "正式版", + "settings.update.channel_preview": "预览版", + "settings.update.actions_header": "更新操作", + "settings.update.actions_desc": "检查发布、下载安装包并启动更新。", + "settings.update.check_button": "检查更新", + "settings.update.download_install_button": "下载并安装", + "settings.update.download_progress_idle": "下载进度:-", + "settings.update.download_progress_format": "下载进度:{0:F0}%", + "settings.update.status_ready": "可开始检查更新。", + "settings.update.status_channel_changed": "更新通道已变更,请重新检查更新。", + "settings.update.status_channel_changed_format": "更新通道已切换为 {0},请重新检查更新。", + "settings.update.status_windows_only": "自动安装包更新当前仅支持 Windows。", + "settings.update.status_checking": "正在检查 GitHub Release...", + "settings.update.status_check_failed_format": "检查更新失败:{0}", + "settings.update.status_up_to_date": "当前已是最新版本。", + "settings.update.status_asset_missing": "发现新版本,但未找到兼容的安装包。", + "settings.update.status_available_format": "发现新版本 {0},点击“下载并安装”继续。", + "settings.update.status_downloading": "正在下载安装包...", + "settings.update.status_download_failed_format": "下载失败:{0}", + "settings.update.status_launching_installer": "下载完成,正在启动安装程序...", + "settings.update.status_installer_missing": "下载后未找到安装包文件。", + "settings.update.status_installer_started": "安装程序已启动,应用将关闭进行更新。", + "settings.update.status_launch_failed_format": "启动安装程序失败:{0}", "settings.about.title": "关于", "settings.about.version_format": "版本号: {0}", "settings.about.codename_format": "版本代号: {0}", @@ -210,6 +243,7 @@ "common.night": "夜间", "common.back": "返回", "common.close": "关闭", + "common.unknown": "未知错误", "common.recommended": "推荐", "common.monet": "莫奈", "desktop.page_index_format": "桌面 {0}", @@ -248,6 +282,8 @@ "component.audio_recorder": "录音", "component.daily_poetry": "每日诗词", "component.daily_artwork": "每日名画", + "component.daily_word": "每日单词", + "component.cnr_daily_news": "央广网头条", "component.whiteboard": "竖向小黑板", "component.blackboard_landscape": "横向小黑板", "component.browser": "浏览器", @@ -280,6 +316,24 @@ "artwork.widget.fallback_artist": "推荐服务不可用", "artwork.widget.fallback_year": "稍后重试", "artwork.widget.unknown_artist": "未知作者", + "dailyword.widget.loading": "加载中...", + "dailyword.widget.loading_word": "每日单词", + "dailyword.widget.loading_pronunciation": "正在获取发音", + "dailyword.widget.loading_meaning": "正在获取释义", + "dailyword.widget.loading_example": "正在获取例句", + "dailyword.widget.loading_example_translation": "加载中", + "dailyword.widget.fetch_failed": "每日单词获取失败", + "dailyword.widget.fallback_word": "每日单词", + "dailyword.widget.fallback_pronunciation": "发音暂不可用", + "dailyword.widget.fallback_meaning": "有道词典暂不可用", + "dailyword.widget.fallback_example": "请点击右上角刷新重试", + "dailyword.widget.fallback_example_translation": "网络恢复后将自动更新", + "cnrnews.widget.loading": "加载中...", + "cnrnews.widget.loading_title": "正在获取新闻热点", + "cnrnews.widget.loading_subtitle": "请稍候", + "cnrnews.widget.fetch_failed": "新闻获取失败", + "cnrnews.widget.fallback_title": "央广网新闻暂不可用", + "cnrnews.widget.fallback_subtitle": "点击右上角稍后重试", "artwork.settings.title": "每日图片设置", "artwork.settings.desc": "切换每日图片的数据源。", "artwork.settings.source_label": "镜像源", diff --git a/LanMountainDesktop/Models/AppSettingsSnapshot.cs b/LanMountainDesktop/Models/AppSettingsSnapshot.cs index b7462dc..6705237 100644 --- a/LanMountainDesktop/Models/AppSettingsSnapshot.cs +++ b/LanMountainDesktop/Models/AppSettingsSnapshot.cs @@ -48,6 +48,12 @@ public sealed class AppSettingsSnapshot public bool AutoStartWithWindows { get; set; } + public bool AutoCheckUpdates { get; set; } = true; + + public bool IncludePrereleaseUpdates { get; set; } + + public string UpdateChannel { get; set; } = string.Empty; + public List TopStatusComponentIds { get; set; } = []; public List PinnedTaskbarActions { get; set; } = diff --git a/LanMountainDesktop/Models/RecommendationDataModels.cs b/LanMountainDesktop/Models/RecommendationDataModels.cs index 40b6a7c..534bb6e 100644 --- a/LanMountainDesktop/Models/RecommendationDataModels.cs +++ b/LanMountainDesktop/Models/RecommendationDataModels.cs @@ -1,4 +1,5 @@ -using System; +using System; +using System.Collections.Generic; namespace LanMountainDesktop.Models; @@ -20,3 +21,27 @@ public sealed record DailyPoetrySnapshot( string? Author, string? Category, DateTimeOffset FetchedAt); + +public sealed record DailyNewsItemSnapshot( + string Title, + string? Summary, + string Url, + string? ImageUrl, + string? PublishTime); + +public sealed record DailyNewsSnapshot( + string Provider, + string Source, + IReadOnlyList Items, + DateTimeOffset FetchedAt); + +public sealed record DailyWordSnapshot( + string Provider, + string Word, + string? UkPronunciation, + string? UsPronunciation, + string Meaning, + string? ExampleSentence, + string? ExampleTranslation, + string? SourceUrl, + DateTimeOffset FetchedAt); diff --git a/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs new file mode 100644 index 0000000..6075aef --- /dev/null +++ b/LanMountainDesktop/Services/GitHubReleaseUpdateService.cs @@ -0,0 +1,482 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace LanMountainDesktop.Services; + +public sealed record GitHubReleaseAsset( + string Name, + string BrowserDownloadUrl, + long SizeBytes); + +public sealed record GitHubReleaseInfo( + string TagName, + string Name, + bool IsPrerelease, + bool IsDraft, + DateTimeOffset PublishedAt, + IReadOnlyList Assets); + +public sealed record UpdateCheckResult( + bool Success, + bool IsUpdateAvailable, + string CurrentVersionText, + string LatestVersionText, + GitHubReleaseInfo? Release, + GitHubReleaseAsset? PreferredAsset, + string? ErrorMessage); + +public sealed record UpdateDownloadResult( + bool Success, + string? FilePath, + string? ErrorMessage); + +public sealed class GitHubReleaseUpdateService : IDisposable +{ + private const string GithubApiVersion = "2022-11-28"; + + private readonly string _owner; + private readonly string _repo; + private readonly HttpClient _httpClient; + private readonly bool _ownsHttpClient; + + public GitHubReleaseUpdateService( + string owner, + string repo, + HttpClient? httpClient = null) + { + _owner = owner?.Trim() ?? string.Empty; + _repo = repo?.Trim() ?? string.Empty; + + if (httpClient is null) + { + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + _ownsHttpClient = true; + } + else + { + _httpClient = httpClient; + _ownsHttpClient = false; + } + + if (!_httpClient.DefaultRequestHeaders.UserAgent.Any()) + { + _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("LanMountainDesktop-Updater/1.0"); + } + + if (!_httpClient.DefaultRequestHeaders.Accept.Any()) + { + _httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/vnd.github+json"); + } + + if (!_httpClient.DefaultRequestHeaders.Contains("X-GitHub-Api-Version")) + { + _httpClient.DefaultRequestHeaders.Add("X-GitHub-Api-Version", GithubApiVersion); + } + } + + public void Dispose() + { + if (_ownsHttpClient) + { + _httpClient.Dispose(); + } + } + + public async Task CheckForUpdatesAsync( + Version currentVersion, + bool includePrerelease, + CancellationToken cancellationToken = default) + { + var normalizedCurrentVersionText = NormalizeVersion(currentVersion).ToString(3); + + if (string.IsNullOrWhiteSpace(_owner) || string.IsNullOrWhiteSpace(_repo)) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: "Repository information is not configured."); + } + + try + { + var release = includePrerelease + ? await GetLatestReleaseIncludingPrereleaseAsync(cancellationToken) + : await GetLatestStableReleaseAsync(cancellationToken); + + if (release is null) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: "No release data was returned from GitHub."); + } + + var hasParsedTagVersion = TryParseVersion(release.TagName, out var parsedTagVersion); + var latestVersionText = hasParsedTagVersion && parsedTagVersion is not null + ? parsedTagVersion.ToString(3) + : release.TagName; + + var isUpdateAvailable = parsedTagVersion is not null && parsedTagVersion > currentVersion; + var preferredAsset = isUpdateAvailable + ? SelectPreferredInstallerAsset(release.Assets) + : null; + + return new UpdateCheckResult( + Success: true, + IsUpdateAvailable: isUpdateAvailable, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: latestVersionText, + Release: release, + PreferredAsset: preferredAsset, + ErrorMessage: null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new UpdateCheckResult( + Success: false, + IsUpdateAvailable: false, + CurrentVersionText: normalizedCurrentVersionText, + LatestVersionText: "-", + Release: null, + PreferredAsset: null, + ErrorMessage: ex.Message); + } + } + + public async Task DownloadAssetAsync( + GitHubReleaseAsset asset, + string destinationFilePath, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (asset is null) + { + return new UpdateDownloadResult(false, null, "Asset is null."); + } + + if (string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) + { + return new UpdateDownloadResult(false, null, "Asset download url is empty."); + } + + if (string.IsNullOrWhiteSpace(destinationFilePath)) + { + return new UpdateDownloadResult(false, null, "Destination file path is empty."); + } + + try + { + var directory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + using var response = await _httpClient.GetAsync( + asset.BrowserDownloadUrl, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + + if (!response.IsSuccessStatusCode) + { + return new UpdateDownloadResult( + false, + null, + $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"); + } + + var contentLength = response.Content.Headers.ContentLength ?? + (asset.SizeBytes > 0 ? asset.SizeBytes : -1); + + await using var sourceStream = await response.Content.ReadAsStreamAsync(cancellationToken); + await using var destinationStream = File.Create(destinationFilePath); + + var buffer = new byte[81920]; + long totalRead = 0; + int read; + while ((read = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0) + { + await destinationStream.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + totalRead += read; + + if (contentLength > 0) + { + progress?.Report(Math.Clamp(totalRead / (double)contentLength, 0d, 1d)); + } + } + + progress?.Report(1d); + + return new UpdateDownloadResult(true, destinationFilePath, null); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new UpdateDownloadResult(false, null, ex.Message); + } + } + + private async Task GetLatestStableReleaseAsync(CancellationToken cancellationToken) + { + var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases/latest"; + var responseText = await GetResponseTextAsync(url, cancellationToken); + + using var document = JsonDocument.Parse(responseText); + return ParseRelease(document.RootElement); + } + + private async Task GetLatestReleaseIncludingPrereleaseAsync(CancellationToken cancellationToken) + { + var url = $"https://api.github.com/repos/{_owner}/{_repo}/releases?per_page=20"; + var responseText = await GetResponseTextAsync(url, cancellationToken); + + using var document = JsonDocument.Parse(responseText); + if (document.RootElement.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var item in document.RootElement.EnumerateArray()) + { + var release = ParseRelease(item); + if (release is null || release.IsDraft) + { + continue; + } + + return release; + } + + return null; + } + + private async Task GetResponseTextAsync(string url, CancellationToken cancellationToken) + { + using var response = await _httpClient.GetAsync(url, cancellationToken); + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException( + $"GitHub API request failed with HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + + return responseText; + } + + private static GitHubReleaseInfo? ParseRelease(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object) + { + return null; + } + + var tagName = element.TryGetProperty("tag_name", out var tagNode) + ? tagNode.GetString()?.Trim() + : null; + + if (string.IsNullOrWhiteSpace(tagName)) + { + return null; + } + + var name = element.TryGetProperty("name", out var nameNode) + ? nameNode.GetString()?.Trim() ?? string.Empty + : string.Empty; + + var isPrerelease = element.TryGetProperty("prerelease", out var prereleaseNode) && + prereleaseNode.ValueKind == JsonValueKind.True; + + var isDraft = element.TryGetProperty("draft", out var draftNode) && + draftNode.ValueKind == JsonValueKind.True; + + var publishedAt = DateTimeOffset.MinValue; + if (element.TryGetProperty("published_at", out var publishedAtNode) && + publishedAtNode.ValueKind == JsonValueKind.String) + { + var publishedAtText = publishedAtNode.GetString(); + if (!string.IsNullOrWhiteSpace(publishedAtText) && + DateTimeOffset.TryParse( + publishedAtText, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var parsedPublishedAt)) + { + publishedAt = parsedPublishedAt; + } + } + + var assets = new List(); + if (element.TryGetProperty("assets", out var assetsNode) && assetsNode.ValueKind == JsonValueKind.Array) + { + foreach (var assetNode in assetsNode.EnumerateArray()) + { + if (assetNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var assetName = assetNode.TryGetProperty("name", out var assetNameNode) + ? assetNameNode.GetString()?.Trim() + : null; + var browserDownloadUrl = assetNode.TryGetProperty("browser_download_url", out var urlNode) + ? urlNode.GetString()?.Trim() + : null; + var sizeBytes = assetNode.TryGetProperty("size", out var sizeNode) && sizeNode.TryGetInt64(out var size) + ? size + : 0L; + + if (string.IsNullOrWhiteSpace(assetName) || string.IsNullOrWhiteSpace(browserDownloadUrl)) + { + continue; + } + + assets.Add(new GitHubReleaseAsset(assetName, browserDownloadUrl, sizeBytes)); + } + } + + return new GitHubReleaseInfo(tagName, name, isPrerelease, isDraft, publishedAt, assets); + } + + private static GitHubReleaseAsset? SelectPreferredInstallerAsset(IReadOnlyList assets) + { + if (assets is null || assets.Count == 0 || !OperatingSystem.IsWindows()) + { + return null; + } + + var architectureToken = RuntimeInformation.OSArchitecture switch + { + Architecture.Arm64 => "arm64", + Architecture.X86 => "x86", + _ => "x64" + }; + + var ranked = assets + .Select(asset => (Asset: asset, Score: ScoreWindowsInstallerAsset(asset.Name, architectureToken))) + .OrderByDescending(x => x.Score) + .ToList(); + + return ranked.FirstOrDefault(x => x.Score > 0).Asset; + } + + private static int ScoreWindowsInstallerAsset(string assetName, string architectureToken) + { + if (string.IsNullOrWhiteSpace(assetName)) + { + return 0; + } + + var score = 0; + + if (assetName.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) + { + score += 200; + } + else if (assetName.EndsWith(".msi", StringComparison.OrdinalIgnoreCase)) + { + score += 160; + } + else + { + return 0; + } + + if (assetName.Contains("setup", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("installer", StringComparison.OrdinalIgnoreCase)) + { + score += 60; + } + + if (assetName.Contains(architectureToken, StringComparison.OrdinalIgnoreCase)) + { + score += 40; + } + else if (assetName.Contains("x64", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("x86", StringComparison.OrdinalIgnoreCase) || + assetName.Contains("arm64", StringComparison.OrdinalIgnoreCase)) + { + score -= 30; + } + + if (assetName.Contains("portable", StringComparison.OrdinalIgnoreCase)) + { + score -= 40; + } + + return score; + } + + private static bool TryParseVersion(string? value, out Version? version) + { + version = null; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var normalized = value.Trim(); + 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 = NormalizeVersion(parsed); + return true; + } + + private static Version NormalizeVersion(Version version) + { + var major = Math.Max(0, version.Major); + var minor = Math.Max(0, version.Minor); + var build = Math.Max(0, version.Build); + return new Version(major, minor, build); + } + + private static string Truncate(string value, int maxLength) + { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return value[..maxLength]; + } +} diff --git a/LanMountainDesktop/Services/IRecommendationDataService.cs b/LanMountainDesktop/Services/IRecommendationDataService.cs index 04ad90f..a0fa43a 100644 --- a/LanMountainDesktop/Services/IRecommendationDataService.cs +++ b/LanMountainDesktop/Services/IRecommendationDataService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using LanMountainDesktop.Models; @@ -14,6 +15,14 @@ public sealed record DailyPoetryQuery( string? Locale = null, bool ForceRefresh = false); +public sealed record DailyNewsQuery( + string? Locale = null, + bool ForceRefresh = false); + +public sealed record DailyWordQuery( + string? Locale = null, + bool ForceRefresh = false); + public sealed record RecommendationQueryResult( bool Success, T? Data, @@ -46,11 +55,154 @@ public sealed record RecommendationApiOptions public string DomesticArtworkHost { get; init; } = "https://cn.bing.com"; + public string CnrDailyNewsListUrl { get; init; } = "https://www.cnr.cn/newscenter/native/gd/"; + + public IReadOnlyList CnrDailyNewsRssFeedUrls { get; init; } = + [ + "https://www.cnr.cn/rss.xml", + "https://news.cnr.cn/rss.xml", + "https://www.cnr.cn/newscenter/native/gd/rss.xml", + "https://news.cnr.cn/native/gd/rss.xml" + ]; + + public string YoudaoDictionaryApiTemplate { get; init; } = "https://dict.youdao.com/jsonapi?q={0}"; + + public string YoudaoDictionaryWordPageTemplate { get; init; } = "https://dict.youdao.com/w/eng/{0}/"; + + public IReadOnlyList YoudaoDailyWordCandidates { get; init; } = + [ + "illustrate", + "resilient", + "meticulous", + "coherent", + "subtle", + "constrain", + "tangible", + "versatile", + "pragmatic", + "derive", + "intricate", + "notion", + "facilitate", + "sustain", + "clarify", + "convey", + "nuance", + "transform", + "navigate", + "align", + "elevate", + "refine", + "vivid", + "compile", + "inspect", + "aggregate", + "optimize", + "resonate", + "persist", + "adapt", + "emerge", + "concrete", + "articulate", + "validate", + "insight", + "concise", + "robust", + "reliable", + "spectrum", + "landscape", + "context", + "constraint", + "iterative", + "foundation", + "priority", + "workflow", + "synthesize", + "anchor", + "precision", + "momentum", + "integrate", + "observe", + "structure", + "essence", + "framework", + "drift", + "discern", + "compose", + "modulate", + "stability", + "trajectory", + "analyze", + "diagnose", + "mitigate", + "transparent", + "progressive", + "boundary", + "allocate", + "evaluate", + "reconcile", + "strategic", + "holistic", + "incremental", + "temporal", + "semantic", + "parallel", + "explicit", + "objective", + "capacity", + "durable", + "scalable", + "residual", + "verify", + "discover", + "curate", + "invoke", + "artistry", + "sincere", + "substantive", + "deliberate", + "dynamic", + "intentional", + "initiative", + "evidence", + "infuse", + "harmony", + "vitality", + "polish", + "portrait", + "rhythm", + "accent", + "gradient", + "palette", + "pattern", + "eclipse", + "horizon", + "luminous", + "serene", + "vantage", + "kinetic", + "refactor", + "calibrate", + "orchestrate", + "prototype", + "curiosity", + "discipline", + "inscribe", + "engage", + "spark", + "zenith", + "clarity", + "resolve", + "aptitude" + ]; + public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20); public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8); public int DefaultArtworkCandidateCount { get; init; } = 50; + + public int DefaultDailyNewsCount { get; init; } = 2; } public interface IRecommendationInfoService @@ -63,5 +215,13 @@ public interface IRecommendationInfoService DailyPoetryQuery query, CancellationToken cancellationToken = default); + Task> GetDailyNewsAsync( + DailyNewsQuery query, + CancellationToken cancellationToken = default); + + Task> GetDailyWordAsync( + DailyWordQuery query, + CancellationToken cancellationToken = default); + void ClearCache(); } diff --git a/LanMountainDesktop/Services/RecommendationDataService.cs b/LanMountainDesktop/Services/RecommendationDataService.cs index dd21425..8da45db 100644 --- a/LanMountainDesktop/Services/RecommendationDataService.cs +++ b/LanMountainDesktop/Services/RecommendationDataService.cs @@ -2,10 +2,14 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; +using System.Text; using System.Text.Json; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using System.Xml.Linq; using LanMountainDesktop.Models; namespace LanMountainDesktop.Services; @@ -13,9 +17,24 @@ namespace LanMountainDesktop.Services; public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable { private const string UserAgent = "Mozilla/5.0"; + private static readonly Regex CnrListAnchorRegex = new( + "https?://[^\"]*?t\\d+_\\d+\\.shtml(?:\\?[^\"]*)?)\"[^>]*>(?.*?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex HtmlImageTagRegex = new( + "]+(?:src|data-src)=\"(?[^\"]+)\"", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex RssAlternateLinkRegex = new( + "]+rel=\"alternate\"[^>]+type=\"(?:application/(?:rss\\+xml|atom\\+xml)|text/xml)\"[^>]+href=\"(?[^\"]+)\"", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex RssDescriptionImageRegex = new( + "]+src=\"(?[^\"]+)\"", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Singleline); + private static readonly Regex HtmlTagRegex = new("<.*?>", RegexOptions.Compiled | RegexOptions.Singleline); private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record DailyNewsCacheEntry(DailyNewsSnapshot Snapshot, DateTimeOffset ExpireAt); + private sealed record DailyWordCacheEntry(DailyWordSnapshot Snapshot, DateTimeOffset ExpireAt); private sealed record ArtworkCandidate( string Title, string? Artist, @@ -32,6 +51,13 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis private readonly Dictionary _dailyArtworkCacheBySource = new(StringComparer.OrdinalIgnoreCase); private DailyPoetryCacheEntry? _dailyPoetryCache; + private DailyNewsCacheEntry? _dailyNewsCache; + private DailyWordCacheEntry? _dailyWordCache; + + static RecommendationDataService() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } public RecommendationDataService( RecommendationApiOptions? options = null, @@ -67,6 +93,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis { _dailyArtworkCacheBySource.Clear(); _dailyPoetryCache = null; + _dailyNewsCache = null; + _dailyWordCache = null; } } @@ -148,6 +176,102 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis : await GetDailyArtworkFromOverseasSourceAsync(mirrorSource, cancellationToken); } + public async Task> GetDailyNewsAsync( + DailyNewsQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new DailyNewsQuery(); + if (!normalizedQuery.ForceRefresh && TryGetDailyNewsFromCache(out var cached)) + { + return RecommendationQueryResult.Ok(cached); + } + + try + { + var items = await FetchCnrDailyNewsItemsAsync(cancellationToken); + if (items.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_empty_result", + "No CNR news items were returned."); + } + + var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4); + var snapshot = new DailyNewsSnapshot( + Provider: "CNR", + Source: "央广网·头条", + Items: items.Take(targetCount).ToArray(), + FetchedAt: DateTimeOffset.UtcNow); + + SetDailyNewsCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (HttpRequestException ex) + { + return RecommendationQueryResult.Fail("upstream_network_error", ex.Message); + } + catch (Exception ex) + { + return RecommendationQueryResult.Fail("upstream_parse_error", ex.Message); + } + } + + public async Task> GetDailyWordAsync( + DailyWordQuery query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = query ?? new DailyWordQuery(); + if (!normalizedQuery.ForceRefresh && TryGetDailyWordFromCache(out var cached)) + { + return RecommendationQueryResult.Ok(cached); + } + + var candidates = BuildDailyWordCandidates(); + if (candidates.Count == 0) + { + return RecommendationQueryResult.Fail( + "upstream_parse_error", + "Youdao daily word candidates are empty."); + } + + var startIndex = ResolveDailyWordStartIndex(candidates.Count, normalizedQuery.ForceRefresh); + var attemptCount = Math.Min(candidates.Count, 24); + Exception? lastError = null; + + for (var offset = 0; offset < attemptCount; offset++) + { + cancellationToken.ThrowIfCancellationRequested(); + var candidate = candidates[(startIndex + offset) % candidates.Count]; + try + { + var snapshot = await TryFetchYoudaoDailyWordAsync(candidate, cancellationToken); + if (snapshot is null) + { + continue; + } + + SetDailyWordCache(snapshot); + return RecommendationQueryResult.Ok(snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + lastError = ex; + } + } + + return RecommendationQueryResult.Fail( + "upstream_empty_result", + lastError?.Message ?? "No available daily word from Youdao."); + } + private async Task> GetDailyArtworkFromOverseasSourceAsync( string mirrorSource, CancellationToken cancellationToken) @@ -363,6 +487,977 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis } } + private bool TryGetDailyNewsFromCache(out DailyNewsSnapshot snapshot) + { + lock (_cacheGate) + { + if (_dailyNewsCache is not null && _dailyNewsCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _dailyNewsCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetDailyNewsCache(DailyNewsSnapshot snapshot) + { + lock (_cacheGate) + { + _dailyNewsCache = new DailyNewsCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private bool TryGetDailyWordFromCache(out DailyWordSnapshot snapshot) + { + lock (_cacheGate) + { + if (_dailyWordCache is not null && _dailyWordCache.ExpireAt > DateTimeOffset.UtcNow) + { + snapshot = _dailyWordCache.Snapshot; + return true; + } + } + + snapshot = null!; + return false; + } + + private void SetDailyWordCache(DailyWordSnapshot snapshot) + { + lock (_cacheGate) + { + _dailyWordCache = new DailyWordCacheEntry( + snapshot, + DateTimeOffset.UtcNow.Add(_options.CacheDuration)); + } + } + + private List BuildDailyWordCandidates() + { + var values = _options.YoudaoDailyWordCandidates ?? []; + var result = new List(values.Count); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var rawValue in values) + { + var normalized = NormalizeDailyWordCandidate(rawValue); + if (string.IsNullOrWhiteSpace(normalized)) + { + continue; + } + + if (seen.Add(normalized)) + { + result.Add(normalized); + } + } + + return result; + } + + private static string? NormalizeDailyWordCandidate(string? rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + { + return null; + } + + var compact = Regex.Replace(rawValue.Trim(), "\\s+", string.Empty); + if (compact.Length < 2 || compact.Length > 48) + { + return null; + } + + foreach (var ch in compact) + { + if (char.IsLetter(ch) || ch == '-' || ch == '\'') + { + continue; + } + + return null; + } + + return compact.ToLowerInvariant(); + } + + private static int ResolveDailyWordStartIndex(int candidateCount, bool forceRefresh) + { + if (candidateCount <= 0) + { + return 0; + } + + if (forceRefresh) + { + return Random.Shared.Next(candidateCount); + } + + var localDate = GetChinaLocalDate(); + var seed = localDate.Year * 1000 + localDate.DayOfYear; + return Math.Abs(seed) % candidateCount; + } + + private async Task TryFetchYoudaoDailyWordAsync(string candidateWord, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(candidateWord)) + { + return null; + } + + var requestUrl = string.Format( + CultureInfo.InvariantCulture, + _options.YoudaoDictionaryApiTemplate, + Uri.EscapeDataString(candidateWord.Trim())); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", "application/json,text/plain,*/*"); + using var response = await _httpClient.SendAsync(request, cancellationToken); + var responseText = await response.Content.ReadAsStringAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}"); + } + + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement; + var word = ResolveYoudaoWord(root, candidateWord); + if (string.IsNullOrWhiteSpace(word)) + { + return null; + } + + var meaning = ExtractYoudaoMeaning(root); + if (string.IsNullOrWhiteSpace(meaning)) + { + return null; + } + + var (exampleSentence, exampleTranslation) = ExtractYoudaoExample(root); + var (ukPhone, usPhone) = ExtractYoudaoPronunciations(root); + return new DailyWordSnapshot( + Provider: "YoudaoDictionary", + Word: word, + UkPronunciation: ukPhone, + UsPronunciation: usPhone, + Meaning: meaning, + ExampleSentence: exampleSentence, + ExampleTranslation: exampleTranslation, + SourceUrl: BuildYoudaoWordPageUrl(word), + FetchedAt: DateTimeOffset.UtcNow); + } + + private static string? ResolveYoudaoWord(JsonElement root, string fallbackWord) + { + var candidate = + ReadString(root, "simple", "query") ?? + ReadString(root, "meta", "input") ?? + ReadString(root, "input") ?? + fallbackWord; + return NormalizeDailyWordCandidate(candidate); + } + + private static (string? UkPhone, string? UsPhone) ExtractYoudaoPronunciations(JsonElement root) + { + var simpleWord = TryGetFirstArrayObject(root, "simple", "word"); + if (!simpleWord.HasValue) + { + simpleWord = TryGetFirstArrayObject(root, "ec", "word"); + } + + if (!simpleWord.HasValue) + { + return (null, null); + } + + var ukPhone = NormalizePhoneText(ReadString(simpleWord.Value, "ukphone")); + var usPhone = NormalizePhoneText(ReadString(simpleWord.Value, "usphone")); + return (ukPhone, usPhone); + } + + private static string? ExtractYoudaoMeaning(JsonElement root) + { + var ecWord = TryGetFirstArrayObject(root, "ec", "word"); + if (!ecWord.HasValue || !ecWord.Value.TryGetProperty("trs", out var trsNode) || trsNode.ValueKind != JsonValueKind.Array) + { + return null; + } + + var lines = new List(); + foreach (var trNode in trsNode.EnumerateArray()) + { + if (trNode.ValueKind != JsonValueKind.Object || + !trNode.TryGetProperty("tr", out var trArray) || + trArray.ValueKind != JsonValueKind.Array) + { + continue; + } + + foreach (var trItem in trArray.EnumerateArray()) + { + var line = ExtractYoudaoMeaningLine(trItem); + if (!string.IsNullOrWhiteSpace(line)) + { + lines.Add(line); + } + } + } + + if (lines.Count == 0) + { + return null; + } + + return string.Join(";", lines + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(3)); + } + + private static string? ExtractYoudaoMeaningLine(JsonElement trItem) + { + if (trItem.ValueKind == JsonValueKind.String) + { + return NormalizeInlineText(trItem.GetString()); + } + + if (trItem.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (trItem.TryGetProperty("l", out var languageNode)) + { + if (languageNode.ValueKind == JsonValueKind.Object && + languageNode.TryGetProperty("i", out var textNode)) + { + if (textNode.ValueKind == JsonValueKind.Array) + { + var fragments = textNode.EnumerateArray() + .Select(item => item.ValueKind == JsonValueKind.String ? NormalizeInlineText(item.GetString()) : null) + .Where(item => !string.IsNullOrWhiteSpace(item)) + .ToArray(); + if (fragments.Length > 0) + { + return string.Join(" ", fragments); + } + } + else if (textNode.ValueKind == JsonValueKind.String) + { + return NormalizeInlineText(textNode.GetString()); + } + } + else if (languageNode.ValueKind == JsonValueKind.String) + { + return NormalizeInlineText(languageNode.GetString()); + } + } + + return null; + } + + private static (string? Sentence, string? Translation) ExtractYoudaoExample(JsonElement root) + { + if (!root.TryGetProperty("blng_sents_part", out var sentencePartNode) || + sentencePartNode.ValueKind != JsonValueKind.Object || + !sentencePartNode.TryGetProperty("sentence-pair", out var sentencePairNode) || + sentencePairNode.ValueKind != JsonValueKind.Array) + { + return (null, null); + } + + foreach (var sentenceNode in sentencePairNode.EnumerateArray()) + { + if (sentenceNode.ValueKind != JsonValueKind.Object) + { + continue; + } + + var sentence = NormalizeInlineText( + ReadString(sentenceNode, "sentence") ?? + ReadString(sentenceNode, "sentence-eng")); + if (string.IsNullOrWhiteSpace(sentence)) + { + continue; + } + + var translation = NormalizeInlineText(ReadString(sentenceNode, "sentence-translation")); + return (sentence, translation); + } + + return (null, null); + } + + private string? BuildYoudaoWordPageUrl(string? word) + { + if (string.IsNullOrWhiteSpace(word)) + { + return null; + } + + return string.Format( + CultureInfo.InvariantCulture, + _options.YoudaoDictionaryWordPageTemplate, + Uri.EscapeDataString(word.Trim())); + } + + private static string? NormalizePhoneText(string? phone) + { + if (string.IsNullOrWhiteSpace(phone)) + { + return null; + } + + var compact = Regex.Replace(phone.Trim(), "\\s+", string.Empty); + return compact.Length == 0 ? null : compact; + } + + private static JsonElement? TryGetFirstArrayObject(JsonElement node, params string[] path) + { + var arrayNode = TryGetNode(node, path); + if (!arrayNode.HasValue || arrayNode.Value.ValueKind != JsonValueKind.Array) + { + return null; + } + + foreach (var item in arrayNode.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Object) + { + return item; + } + } + + return null; + } + + private async Task> FetchCnrDailyNewsItemsAsync(CancellationToken cancellationToken) + { + var requestUrl = string.IsNullOrWhiteSpace(_options.CnrDailyNewsListUrl) + ? "https://www.cnr.cn/newscenter/native/gd/" + : _options.CnrDailyNewsListUrl.Trim(); + if (!Uri.TryCreate(requestUrl, UriKind.Absolute, out var listPageUri)) + { + throw new InvalidOperationException("CNR news list URL is invalid."); + } + + var html = await FetchHtmlWithCnrEncodingAsync(requestUrl, cancellationToken); + var targetCount = Math.Clamp(_options.DefaultDailyNewsCount, 1, 4); + var candidateLimit = Math.Max(8, targetCount * 3); + var htmlCandidates = ParseCnrDailyNewsFromListPage( + html, + listPageUri, + candidateLimit).ToList(); + + var rssCandidates = new List(); + var rssCandidateUrls = BuildCnrDailyNewsRssCandidateUrls(listPageUri, html); + foreach (var rssUrl in rssCandidateUrls) + { + var rssItems = await TryFetchRssNewsItemsAsync(rssUrl, candidateLimit, cancellationToken); + if (rssItems.Count == 0) + { + continue; + } + + rssCandidates = rssItems; + break; + } + + var candidates = rssCandidates.Count > 0 + ? SupplementRssItemsWithHtmlFallback(rssCandidates, htmlCandidates) + : htmlCandidates; + if (candidates.Count == 0) + { + return []; + } + + var hydrateCount = Math.Min(candidates.Count, Math.Max(targetCount * 2, 4)); + for (var i = 0; i < hydrateCount; i++) + { + var candidate = candidates[i]; + if (!string.IsNullOrWhiteSpace(candidate.ImageUrl)) + { + continue; + } + + var coverImage = await TryFetchArticleCoverImageAsync(candidate.Url, cancellationToken); + if (!string.IsNullOrWhiteSpace(coverImage)) + { + candidates[i] = candidate with { ImageUrl = coverImage }; + } + } + + return candidates; + } + + private List BuildCnrDailyNewsRssCandidateUrls(Uri listPageUri, string listPageHtml) + { + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (_options.CnrDailyNewsRssFeedUrls is { Count: > 0 }) + { + foreach (var configured in _options.CnrDailyNewsRssFeedUrls) + { + var normalized = ResolveAbsoluteUrl(configured, listPageUri); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + { + results.Add(normalized); + } + } + } + + foreach (Match match in RssAlternateLinkRegex.Matches(listPageHtml)) + { + var normalized = ResolveAbsoluteUrl(match.Groups["url"].Value, listPageUri); + if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized)) + { + results.Add(normalized); + } + } + + var fallbackGuesses = new[] + { + "https://www.cnr.cn/rss.xml", + "https://news.cnr.cn/rss.xml", + "https://www.cnr.cn/newscenter/native/gd/rss.xml", + "https://news.cnr.cn/native/gd/rss.xml" + }; + foreach (var guess in fallbackGuesses) + { + if (seen.Add(guess)) + { + results.Add(guess); + } + } + + return results; + } + + private async Task> TryFetchRssNewsItemsAsync( + string rssUrl, + int maxItems, + CancellationToken cancellationToken) + { + try + { + var xml = await FetchTextWithCnrEncodingAsync( + rssUrl, + "application/rss+xml,application/atom+xml,text/xml,application/xml;q=0.9,*/*;q=0.8", + cancellationToken); + + var document = XDocument.Parse(xml, LoadOptions.None); + if (!Uri.TryCreate(rssUrl, UriKind.Absolute, out var feedUri)) + { + return []; + } + + var results = new List(); + var seenUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + var itemLimit = Math.Max(1, maxItems); + foreach (var node in document.Descendants()) + { + var localName = node.Name.LocalName; + if (!string.Equals(localName, "item", StringComparison.OrdinalIgnoreCase) && + !string.Equals(localName, "entry", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var link = ResolveAbsoluteUrl(ExtractRssItemLink(node), feedUri); + var normalizedLinkKey = NormalizeNewsUrlKey(link); + if (string.IsNullOrWhiteSpace(link) || + string.IsNullOrWhiteSpace(normalizedLinkKey) || + !seenUrls.Add(normalizedLinkKey)) + { + continue; + } + + var title = NormalizeInlineText(ExtractFirstElementValue(node, "title")); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var summarySource = ExtractFirstElementValue(node, "description") + ?? ExtractFirstElementValue(node, "summary") + ?? ExtractFirstElementValue(node, "encoded") + ?? string.Empty; + var summary = NormalizeInlineText(summarySource); + var publishTime = NormalizeInlineText( + ExtractFirstElementValue(node, "pubDate") + ?? ExtractFirstElementValue(node, "updated") + ?? ExtractFirstElementValue(node, "published") + ?? string.Empty); + var imageUrl = ExtractRssItemImageUrl(node, feedUri, summarySource); + + results.Add(new DailyNewsItemSnapshot( + Title: title, + Summary: string.IsNullOrWhiteSpace(summary) ? null : summary, + Url: link, + ImageUrl: imageUrl, + PublishTime: string.IsNullOrWhiteSpace(publishTime) ? null : publishTime)); + if (results.Count >= itemLimit) + { + break; + } + } + + return results; + } + catch + { + return []; + } + } + + private static string? ExtractRssItemLink(XElement itemNode) + { + var linkElement = itemNode.Elements() + .FirstOrDefault(element => string.Equals(element.Name.LocalName, "link", StringComparison.OrdinalIgnoreCase)); + if (linkElement is null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(linkElement.Value)) + { + return linkElement.Value.Trim(); + } + + var href = linkElement.Attribute("href")?.Value; + return string.IsNullOrWhiteSpace(href) ? null : href.Trim(); + } + + private static string? ExtractFirstElementValue(XElement itemNode, string localName) + { + var element = itemNode.Elements() + .FirstOrDefault(node => string.Equals(node.Name.LocalName, localName, StringComparison.OrdinalIgnoreCase)); + return element?.Value; + } + + private static string? ExtractRssItemImageUrl(XElement itemNode, Uri feedUri, string descriptionHtml) + { + foreach (var element in itemNode.Elements()) + { + var name = element.Name.LocalName; + if (string.Equals(name, "enclosure", StringComparison.OrdinalIgnoreCase)) + { + var type = element.Attribute("type")?.Value ?? string.Empty; + var url = ResolveAbsoluteUrl(element.Attribute("url")?.Value, feedUri); + if (!string.IsNullOrWhiteSpace(url) && + (type.Contains("image", StringComparison.OrdinalIgnoreCase) || IsLikelyContentImageUrl(url))) + { + return url; + } + } + + if (string.Equals(name, "content", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "thumbnail", StringComparison.OrdinalIgnoreCase)) + { + var url = ResolveAbsoluteUrl(element.Attribute("url")?.Value, feedUri); + if (IsLikelyContentImageUrl(url)) + { + return url; + } + } + } + + foreach (Match match in RssDescriptionImageRegex.Matches(descriptionHtml ?? string.Empty)) + { + var url = ResolveAbsoluteUrl(match.Groups["url"].Value, feedUri); + if (IsLikelyContentImageUrl(url)) + { + return url; + } + } + + return null; + } + + private static List SupplementRssItemsWithHtmlFallback( + IReadOnlyList rssItems, + IReadOnlyList htmlItems) + { + if (rssItems.Count == 0) + { + return htmlItems.ToList(); + } + + if (htmlItems.Count == 0) + { + return rssItems.ToList(); + } + + var htmlByUrl = htmlItems + .Select(item => (key: NormalizeNewsUrlKey(item.Url), item)) + .Where(pair => !string.IsNullOrWhiteSpace(pair.key)) + .GroupBy(pair => pair.key!, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().item, StringComparer.OrdinalIgnoreCase); + + var merged = new List(rssItems.Count); + foreach (var rssItem in rssItems) + { + var key = NormalizeNewsUrlKey(rssItem.Url); + if (!string.IsNullOrWhiteSpace(key) && htmlByUrl.TryGetValue(key, out var htmlItem)) + { + merged.Add(rssItem with + { + Summary = string.IsNullOrWhiteSpace(rssItem.Summary) ? htmlItem.Summary : rssItem.Summary, + ImageUrl = string.IsNullOrWhiteSpace(rssItem.ImageUrl) ? htmlItem.ImageUrl : rssItem.ImageUrl + }); + } + else + { + merged.Add(rssItem); + } + } + + return merged; + } + + private static string? NormalizeNewsUrlKey(string? url) + { + if (string.IsNullOrWhiteSpace(url)) + { + return null; + } + + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) + { + return null; + } + + var builder = new UriBuilder(uri) + { + Query = string.Empty, + Fragment = string.Empty + }; + return builder.Uri.ToString().TrimEnd('/'); + } + + private async Task FetchHtmlWithCnrEncodingAsync(string requestUrl, CancellationToken cancellationToken) + { + return await FetchTextWithCnrEncodingAsync( + requestUrl, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + cancellationToken); + } + + private async Task FetchTextWithCnrEncodingAsync( + string requestUrl, + string acceptHeader, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); + request.Headers.TryAddWithoutValidation("User-Agent", UserAgent); + request.Headers.TryAddWithoutValidation("Accept", acceptHeader); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + var payload = await response.Content.ReadAsByteArrayAsync(cancellationToken); + var decodedText = DecodeHttpPayload(payload, response.Content.Headers.ContentType?.CharSet); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {(int)response.StatusCode}: {Truncate(decodedText, 180)}"); + } + + return decodedText; + } + + private static string DecodeHttpPayload(byte[] payload, string? declaredCharset) + { + if (payload.Length == 0) + { + return string.Empty; + } + + if (TryGetTextEncoding(declaredCharset, out var declaredEncoding)) + { + return declaredEncoding.GetString(payload); + } + + var utf8Text = Encoding.UTF8.GetString(payload); + var charsetFromMeta = ExtractCharsetFromHtmlMeta(utf8Text); + if (TryGetTextEncoding(charsetFromMeta, out var metaEncoding)) + { + return metaEncoding.GetString(payload); + } + + return utf8Text; + } + + private static string? ExtractCharsetFromHtmlMeta(string? html) + { + if (string.IsNullOrWhiteSpace(html)) + { + return null; + } + + var match = Regex.Match( + html, + "charset\\s*=\\s*[\"']?(?[A-Za-z0-9_\\-]+)", + RegexOptions.IgnoreCase); + return match.Success ? match.Groups["value"].Value : null; + } + + private static bool TryGetTextEncoding(string? charset, out Encoding encoding) + { + encoding = Encoding.UTF8; + if (string.IsNullOrWhiteSpace(charset)) + { + return false; + } + + var normalized = charset.Trim().Trim('"', '\'').ToLowerInvariant(); + if (normalized is "gb2312" or "gbk" or "cp936") + { + normalized = "gb18030"; + } + + try + { + encoding = Encoding.GetEncoding(normalized); + return true; + } + catch + { + return false; + } + } + + private static IEnumerable ParseCnrDailyNewsFromListPage( + string html, + Uri listPageUri, + int maxItems) + { + if (string.IsNullOrWhiteSpace(html)) + { + yield break; + } + + var startIndex = html.IndexOf("
= 0 ? html[startIndex..] : html; + var maxCount = Math.Max(1, maxItems); + var seenUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (Match match in CnrListAnchorRegex.Matches(scope)) + { + var normalizedUrl = ResolveAbsoluteUrl(match.Groups["url"].Value, listPageUri); + if (string.IsNullOrWhiteSpace(normalizedUrl) || !seenUrls.Add(normalizedUrl)) + { + continue; + } + + var inner = match.Groups["inner"].Value; + var title = ExtractTagInnerText(inner, "strong"); + if (string.IsNullOrWhiteSpace(title)) + { + continue; + } + + var summary = ExtractTagInnerText(inner, "em"); + var publishTime = ExtractTagInnerTextByClass(inner, "span", "publishTime"); + var imageUrl = ExtractFirstImageUrl(inner, listPageUri); + yield return new DailyNewsItemSnapshot( + Title: title, + Summary: summary, + Url: normalizedUrl, + ImageUrl: imageUrl, + PublishTime: publishTime); + + if (seenUrls.Count >= maxCount) + { + yield break; + } + } + } + + private static string? ExtractTagInnerText(string htmlFragment, string tagName) + { + if (string.IsNullOrWhiteSpace(htmlFragment) || string.IsNullOrWhiteSpace(tagName)) + { + return null; + } + + var match = Regex.Match( + htmlFragment, + $"<{tagName}[^>]*>(?.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (!match.Success) + { + return null; + } + + return NormalizeInlineText(match.Groups["value"].Value); + } + + private static string? ExtractTagInnerTextByClass(string htmlFragment, string tagName, string className) + { + if (string.IsNullOrWhiteSpace(htmlFragment) || + string.IsNullOrWhiteSpace(tagName) || + string.IsNullOrWhiteSpace(className)) + { + return null; + } + + var match = Regex.Match( + htmlFragment, + $"<{tagName}[^>]*class=\"[^\"]*{Regex.Escape(className)}[^\"]*\"[^>]*>(?.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + if (!match.Success) + { + return null; + } + + return NormalizeInlineText(match.Groups["value"].Value); + } + + private static string? ExtractFirstImageUrl(string htmlFragment, Uri pageUri) + { + if (string.IsNullOrWhiteSpace(htmlFragment)) + { + return null; + } + + var matches = HtmlImageTagRegex.Matches(htmlFragment); + foreach (Match match in matches) + { + var normalized = ResolveAbsoluteUrl(match.Groups["url"].Value, pageUri); + if (IsLikelyContentImageUrl(normalized)) + { + return normalized; + } + } + + return null; + } + + private async Task TryFetchArticleCoverImageAsync(string articleUrl, CancellationToken cancellationToken) + { + if (!Uri.TryCreate(articleUrl, UriKind.Absolute, out var articleUri)) + { + return null; + } + + var html = await FetchHtmlWithCnrEncodingAsync(articleUrl, cancellationToken); + + var metaMatches = new[] + { + Regex.Match( + html, + "]+property=\"og:image\"[^>]+content=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase), + Regex.Match( + html, + "]+name=\"image\"[^>]+content=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase) + }; + + foreach (var metaMatch in metaMatches) + { + if (!metaMatch.Success) + { + continue; + } + + var metaUrl = ResolveAbsoluteUrl(metaMatch.Groups["url"].Value, articleUri); + if (IsLikelyContentImageUrl(metaUrl)) + { + return metaUrl; + } + } + + var imageMatches = Regex.Matches( + html, + "]+src=\"(?[^\"]+)\"", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + foreach (Match imageMatch in imageMatches) + { + var normalized = ResolveAbsoluteUrl(imageMatch.Groups["url"].Value, articleUri); + if (IsLikelyContentImageUrl(normalized)) + { + return normalized; + } + } + + return null; + } + + private static bool IsLikelyContentImageUrl(string? imageUrl) + { + if (string.IsNullOrWhiteSpace(imageUrl)) + { + return false; + } + + var value = imageUrl.Trim(); + if (!(value.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".png", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) || + value.EndsWith(".avif", StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + return !(value.Contains("share", StringComparison.OrdinalIgnoreCase) || + value.Contains("logo", StringComparison.OrdinalIgnoreCase) || + value.Contains("code.png", StringComparison.OrdinalIgnoreCase)); + } + + private static string? ResolveAbsoluteUrl(string? rawUrl, Uri baseUri) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var candidate = rawUrl.Trim(); + if (candidate.Contains("'+", StringComparison.Ordinal) || + candidate.Contains("+'", StringComparison.Ordinal)) + { + return null; + } + + if (candidate.StartsWith("//", StringComparison.Ordinal)) + { + return $"{baseUri.Scheme}:{candidate}"; + } + + if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri)) + { + if (!string.Equals(absoluteUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(absoluteUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return absoluteUri.ToString(); + } + + return Uri.TryCreate(baseUri, candidate, out var relativeUri) + ? relativeUri.ToString() + : null; + } + + private static string NormalizeInlineText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + var decoded = WebUtility.HtmlDecode(text); + var withoutTags = HtmlTagRegex.Replace(decoded ?? string.Empty, " "); + return Regex.Replace(withoutTags, "\\s+", " ").Trim(); + } + private static string? ReadString(JsonElement node, params string[] path) { var target = TryGetNode(node, path); diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml new file mode 100644 index 0000000..d5d1e29 --- /dev/null +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs new file mode 100644 index 0000000..3f031ff --- /dev/null +++ b/LanMountainDesktop/Views/Components/CnrDailyNewsWidget.axaml.cs @@ -0,0 +1,534 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class CnrDailyNewsWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget +{ + private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); + private static readonly HttpClient ImageHttpClient = new() + { + Timeout = TimeSpan.FromSeconds(8) + }; + + private const string BrowserUserAgent = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36"; + + private const double BaseCellSize = 48d; + private const int BaseWidthCells = 4; + private const int BaseHeightCells = 2; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromMinutes(30) + }; + + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + private readonly Bitmap?[] _newsBitmaps = new Bitmap?[2]; + private readonly string?[] _newsUrls = new string?[2]; + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + + public CnrDailyNewsWidget() + { + InitializeComponent(); + + BrandPrimaryTextBlock.FontFamily = MiSansFontFamily; + BrandSecondaryTextBlock.FontFamily = MiSansFontFamily; + RefreshGlyphTextBlock.FontFamily = MiSansFontFamily; + RefreshLabelTextBlock.FontFamily = MiSansFontFamily; + News1PrefixTextBlock.FontFamily = MiSansFontFamily; + News1TitleTextBlock.FontFamily = MiSansFontFamily; + News2TitleTextBlock.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _refreshTimer.Tick += OnRefreshTimerTick; + RefreshButton.Click += OnRefreshButtonClick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyLoadingState(); + UpdateRefreshButtonState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshNewsAsync(forceRefresh: false); + } + } + + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + if (_isAttached) + { + _ = RefreshNewsAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + UpdateRefreshButtonState(); + _refreshTimer.Start(); + _ = RefreshNewsAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + DisposeNewsBitmaps(); + UpdateRefreshButtonState(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + if (_isRefreshing) + { + return; + } + + await RefreshNewsAsync(forceRefresh: true); + e.Handled = true; + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshNewsAsync(forceRefresh: false); + } + + private void OnNewsItem1PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + TryOpenNewsUrl(0); + e.Handled = true; + } + + private void OnNewsItem2PointerPressed(object? sender, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + return; + } + + TryOpenNewsUrl(1); + e.Handled = true; + } + + private async Task RefreshNewsAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateRefreshButtonState(); + UpdateLanguageCode(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new DailyNewsQuery( + Locale: _languageCode, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetDailyNewsAsync(query, cts.Token); + if (!_isAttached || cts.IsCancellationRequested) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFailedState(); + return; + } + + await ApplySnapshotAsync(result.Data, cts.Token); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + if (_isAttached && !cts.IsCancellationRequested) + { + ApplyFailedState(); + } + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + UpdateRefreshButtonState(); + } + } + + private async Task ApplySnapshotAsync(DailyNewsSnapshot snapshot, CancellationToken cancellationToken) + { + var items = snapshot.Items is null + ? [] + : snapshot.Items.Take(2).ToArray(); + + var item1 = items.Length > 0 ? items[0] : null; + var item2 = items.Length > 1 ? items[1] : null; + + News1PrefixTextBlock.IsVisible = item1 is not null; + News1TitleTextBlock.Text = NormalizeCompactText(item1?.Title); + News2TitleTextBlock.Text = NormalizeCompactText(item2?.Title); + + _newsUrls[0] = NormalizeHttpUrl(item1?.Url); + _newsUrls[1] = NormalizeHttpUrl(item2?.Url); + UpdateNewsInteractionState(); + + StatusTextBlock.IsVisible = false; + UpdateAdaptiveLayout(); + + var loadTasks = new[] + { + TryDownloadBitmapAsync(item1?.ImageUrl, cancellationToken), + TryDownloadBitmapAsync(item2?.ImageUrl, cancellationToken) + }; + var bitmaps = await Task.WhenAll(loadTasks); + if (cancellationToken.IsCancellationRequested || !_isAttached) + { + bitmaps[0]?.Dispose(); + bitmaps[1]?.Dispose(); + return; + } + + SetNewsBitmap(0, bitmaps[0]); + SetNewsBitmap(1, bitmaps[1]); + } + + private void ApplyLoadingState() + { + _newsUrls[0] = null; + _newsUrls[1] = null; + News1PrefixTextBlock.IsVisible = true; + News1TitleTextBlock.Text = L("cnrnews.widget.loading_title", "正在获取新闻热点"); + News2TitleTextBlock.Text = L("cnrnews.widget.loading_subtitle", "请稍候"); + StatusTextBlock.Text = L("cnrnews.widget.loading", "加载中..."); + StatusTextBlock.IsVisible = true; + UpdateNewsInteractionState(); + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + _newsUrls[0] = null; + _newsUrls[1] = null; + News1PrefixTextBlock.IsVisible = false; + News1TitleTextBlock.Text = L("cnrnews.widget.fallback_title", "央广网新闻暂不可用"); + News2TitleTextBlock.Text = L("cnrnews.widget.fallback_subtitle", "点击右上角稍后重试"); + StatusTextBlock.Text = L("cnrnews.widget.fetch_failed", "新闻获取失败"); + StatusTextBlock.IsVisible = true; + SetNewsBitmap(0, null); + SetNewsBitmap(1, null); + UpdateNewsInteractionState(); + UpdateAdaptiveLayout(); + } + + private void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); + RootBorder.Padding = new Thickness( + Math.Clamp(16 * scale, 8, 28), + Math.Clamp(12 * scale, 6, 20), + Math.Clamp(16 * scale, 8, 28), + Math.Clamp(12 * scale, 6, 20)); + + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36)); + CardBorder.Padding = new Thickness( + Math.Clamp(16 * scale, 8, 24), + Math.Clamp(14 * scale, 7, 22), + Math.Clamp(16 * scale, 8, 24), + Math.Clamp(14 * scale, 7, 22)); + + var headlineFont = Math.Clamp(28 * scale, 13, 36); + BrandPrimaryTextBlock.FontSize = headlineFont; + BrandSecondaryTextBlock.FontSize = headlineFont; + + var refreshHeight = Math.Clamp(42 * scale, 24, 52); + var refreshWidth = Math.Clamp(116 * scale, 76, 152); + RefreshButton.Height = refreshHeight; + RefreshButton.Width = refreshWidth; + RefreshButton.CornerRadius = new CornerRadius(refreshHeight / 2d); + RefreshGlyphTextBlock.FontSize = Math.Clamp(19 * scale, 11, 26); + RefreshLabelTextBlock.FontSize = Math.Clamp(25 * scale, 12, 32); + + var imageWidth = Math.Clamp(totalWidth * 0.23, 68, 170); + var imageHeight = Math.Clamp(imageWidth * 0.56, 38, 94); + News1ImageHost.Width = imageWidth; + News1ImageHost.Height = imageHeight; + News2ImageHost.Width = imageWidth; + News2ImageHost.Height = imageHeight; + News1ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22)); + News2ImageHost.CornerRadius = new CornerRadius(Math.Clamp(16 * scale, 8, 22)); + + var columnGap = Math.Clamp(12 * scale, 6, 18); + NewsItem1Grid.ColumnSpacing = columnGap; + NewsItem2Grid.ColumnSpacing = columnGap; + NewsItem1Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth); + NewsItem2Grid.ColumnDefinitions[1].Width = new GridLength(imageWidth); + + var availableTextWidth = Math.Max(72, totalWidth - RootBorder.Padding.Left - RootBorder.Padding.Right - imageWidth - columnGap - Math.Clamp(24 * scale, 12, 36)); + News1TitleTextBlock.MaxWidth = availableTextWidth; + News2TitleTextBlock.MaxWidth = availableTextWidth; + + var newsFont = Math.Clamp(25 * scale, 11, 32); + News1PrefixTextBlock.FontSize = newsFont; + News1TitleTextBlock.FontSize = newsFont; + News2TitleTextBlock.FontSize = newsFont; + StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24); + + var compactLayout = totalHeight < _currentCellSize * 1.7; + News1TitleTextBlock.MaxLines = compactLayout ? 1 : 2; + News2TitleTextBlock.MaxLines = compactLayout ? 1 : 2; + } + + private void UpdateRefreshButtonState() + { + RefreshButton.IsEnabled = !_isRefreshing; + RefreshButton.Opacity = _isAttached ? 1.0 : 0.85; + RefreshGlyphTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0; + RefreshLabelTextBlock.Opacity = _isRefreshing ? 0.56 : 1.0; + } + + private void UpdateNewsInteractionState() + { + var item1Enabled = !string.IsNullOrWhiteSpace(_newsUrls[0]); + var item2Enabled = !string.IsNullOrWhiteSpace(_newsUrls[1]); + + NewsItem1Grid.IsHitTestVisible = item1Enabled; + NewsItem2Grid.IsHitTestVisible = item2Enabled; + NewsItem1Grid.Opacity = item1Enabled ? 1.0 : 0.72; + NewsItem2Grid.Opacity = item2Enabled ? 1.0 : 0.72; + } + + private static async Task TryDownloadBitmapAsync(string? imageUrl, CancellationToken cancellationToken) + { + var normalizedUrl = NormalizeHttpUrl(imageUrl); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return null; + } + + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUrl); + request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent); + request.Headers.TryAddWithoutValidation("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"); + using var response = await ImageHttpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken); + if (!response.IsSuccessStatusCode) + { + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + var memory = new MemoryStream(); + await stream.CopyToAsync(memory, cancellationToken); + memory.Position = 0; + return new Bitmap(memory); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + return null; + } + } + + private void TryOpenNewsUrl(int index) + { + if (index < 0 || index >= _newsUrls.Length) + { + return; + } + + var normalizedUrl = NormalizeHttpUrl(_newsUrls[index]); + if (string.IsNullOrWhiteSpace(normalizedUrl)) + { + return; + } + + try + { + var startInfo = new ProcessStartInfo + { + FileName = normalizedUrl, + UseShellExecute = true + }; + Process.Start(startInfo); + } + catch + { + // Ignore malformed URLs or shell launch failures. + } + } + + private static string? NormalizeHttpUrl(string? rawUrl) + { + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return null; + } + + var candidate = rawUrl.Trim(); + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri)) + { + return null; + } + + if (!string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return uri.ToString(); + } + + private void SetNewsBitmap(int index, Bitmap? bitmap) + { + if (index < 0 || index >= _newsBitmaps.Length) + { + bitmap?.Dispose(); + return; + } + + var imageControl = index == 0 ? News1Image : News2Image; + var oldBitmap = _newsBitmaps[index]; + if (ReferenceEquals(imageControl.Source, oldBitmap)) + { + imageControl.Source = null; + } + + oldBitmap?.Dispose(); + _newsBitmaps[index] = bitmap; + imageControl.Source = bitmap; + } + + private void DisposeNewsBitmaps() + { + SetNewsBitmap(0, null); + SetNewsBitmap(1, null); + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0); + } + + private static string NormalizeCompactText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return MultiWhitespaceRegex.Replace(text.Trim(), " "); + } +} diff --git a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml index e5d3301..3e23348 100644 --- a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml +++ b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml @@ -81,7 +81,7 @@ FontWeight="Bold" TextWrapping="Wrap" TextTrimming="CharacterEllipsis" - MaxLines="2" + MaxLines="4" Margin="0,0,0,8" /> + MaxLines="3" /> + MaxLines="2" /> diff --git a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs index 79aae00..1e603f7 100644 --- a/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs +++ b/LanMountainDesktop/Views/Components/DailyArtworkWidget.axaml.cs @@ -34,6 +34,9 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly FontWeight[] TitleWeightCandidates = new[] { FontWeight.Bold, FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal }; + private static readonly FontWeight[] ArtistWeightCandidates = new[] { FontWeight.SemiBold, FontWeight.Medium, FontWeight.Normal }; + private static readonly FontWeight[] SecondaryWeightCandidates = new[] { FontWeight.Medium, FontWeight.Normal, FontWeight.Light }; private static readonly HttpClient ImageHttpClient = new() { @@ -452,55 +455,98 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, var bottomStackSpacing = Math.Clamp(3 * scale, 2, 8); var reservedHeight = titleBottomMargin + separatorBottomMargin + bottomStackSpacing + 3; var textHeightBudget = Math.Max(24, rightContentHeight - reservedHeight); - var titleHeightBudget = Math.Max(16, textHeightBudget * 0.54); - var bottomTextBudget = Math.Max(10, textHeightBudget - titleHeightBudget); - var artistHeightBudget = Math.Max(8, bottomTextBudget * 0.66); - var yearHeightBudget = Math.Max(8, bottomTextBudget - artistHeightBudget); - var titleBase = Math.Clamp(44 * scale, 16, 58); - PaintingTitleTextBlock.MaxWidth = rightContentWidth; - PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin); - PaintingTitleTextBlock.FontSize = FitFontSize( + var artistBase = Math.Clamp(26 * scale, 11, 34); + var yearBase = Math.Clamp(22 * scale, 10, 30); + var titleMin = Math.Max(9.2, titleBase * 0.42); + var artistMin = Math.Max(8.4, artistBase * 0.50); + var yearMin = Math.Max(8.0, yearBase * 0.54); + + var titleDemand = Math.Clamp(NormalizeCompactText(PaintingTitleTextBlock.Text).Length, 6, 96); + var artistDemand = Math.Clamp(NormalizeCompactText(ArtistTextBlock.Text).Length, 4, 72); + var yearDemand = Math.Clamp(NormalizeCompactText(YearTextBlock.Text).Length, 2, 48); + + var minTitleHeight = Math.Max(10, titleMin * 1.10 * 2); + var minArtistHeight = Math.Max(8, artistMin * 1.14); + var minYearHeight = Math.Max(8, yearMin * 1.08); + var minTextHeightTotal = minTitleHeight + minArtistHeight + minYearHeight; + + double titleHeightBudget; + double artistHeightBudget; + double yearHeightBudget; + if (textHeightBudget <= minTextHeightTotal + 0.6) + { + var compression = textHeightBudget / Math.Max(1, minTextHeightTotal); + titleHeightBudget = Math.Max(9, minTitleHeight * compression); + artistHeightBudget = Math.Max(7, minArtistHeight * compression); + yearHeightBudget = Math.Max(7, minYearHeight * compression); + } + else + { + var extraHeight = textHeightBudget - minTextHeightTotal; + var titleWeight = titleDemand + 8d; + var artistWeight = artistDemand + 4d; + var yearWeight = yearDemand + 2d; + var weightSum = Math.Max(1d, titleWeight + artistWeight + yearWeight); + + titleHeightBudget = minTitleHeight + extraHeight * (titleWeight / weightSum); + artistHeightBudget = minArtistHeight + extraHeight * (artistWeight / weightSum); + yearHeightBudget = minYearHeight + extraHeight * (yearWeight / weightSum); + } + + var titleLayout = FitAdaptiveTextLayout( PaintingTitleTextBlock.Text, rightContentWidth, titleHeightBudget, - maxLines: 2, - minFontSize: Math.Max(12, titleBase * 0.62), + minLines: 2, + maxLines: 5, + minFontSize: titleMin, maxFontSize: titleBase, - weight: FontWeight.Bold, - lineHeightFactor: 1.12); - PaintingTitleTextBlock.LineHeight = PaintingTitleTextBlock.FontSize * 1.12; + weightCandidates: TitleWeightCandidates, + lineHeightFactor: 1.10); + PaintingTitleTextBlock.MaxWidth = rightContentWidth; + PaintingTitleTextBlock.Margin = new Thickness(0, 0, 0, titleBottomMargin); + PaintingTitleTextBlock.MaxLines = titleLayout.MaxLines; + PaintingTitleTextBlock.FontWeight = titleLayout.Weight; + PaintingTitleTextBlock.FontSize = titleLayout.FontSize; + PaintingTitleTextBlock.LineHeight = titleLayout.LineHeight; - var artistBase = Math.Clamp(26 * scale, 11, 34); if (ArtistTextBlock.Parent is StackPanel artistInfoStack) { artistInfoStack.Spacing = bottomStackSpacing; } - ArtistTextBlock.MaxWidth = rightContentWidth; - ArtistTextBlock.FontSize = FitFontSize( + var artistLayout = FitAdaptiveTextLayout( ArtistTextBlock.Text, rightContentWidth, artistHeightBudget, - maxLines: 2, - minFontSize: Math.Max(10, artistBase * 0.72), + minLines: 1, + maxLines: 4, + minFontSize: artistMin, maxFontSize: artistBase, - weight: FontWeight.SemiBold, + weightCandidates: ArtistWeightCandidates, lineHeightFactor: 1.14); - ArtistTextBlock.LineHeight = ArtistTextBlock.FontSize * 1.14; + ArtistTextBlock.MaxWidth = rightContentWidth; + ArtistTextBlock.MaxLines = artistLayout.MaxLines; + ArtistTextBlock.FontWeight = artistLayout.Weight; + ArtistTextBlock.FontSize = artistLayout.FontSize; + ArtistTextBlock.LineHeight = artistLayout.LineHeight; - var yearBase = Math.Clamp(22 * scale, 10, 30); - YearTextBlock.MaxWidth = rightContentWidth; - YearTextBlock.FontSize = FitFontSize( + var yearLayout = FitAdaptiveTextLayout( YearTextBlock.Text, rightContentWidth, yearHeightBudget, - maxLines: 1, - minFontSize: Math.Max(9.5, yearBase * 0.78), + minLines: 1, + maxLines: 3, + minFontSize: yearMin, maxFontSize: yearBase, - weight: FontWeight.Medium, + weightCandidates: SecondaryWeightCandidates, lineHeightFactor: 1.08); - YearTextBlock.LineHeight = YearTextBlock.FontSize * 1.08; + YearTextBlock.MaxWidth = rightContentWidth; + YearTextBlock.MaxLines = yearLayout.MaxLines; + YearTextBlock.FontWeight = yearLayout.Weight; + YearTextBlock.FontSize = yearLayout.FontSize; + YearTextBlock.LineHeight = yearLayout.LineHeight; RightPanelSeparator.Width = Math.Clamp(rightContentWidth * 0.58, 42, 136); RightPanelSeparator.Margin = new Thickness(0, 0, 0, separatorBottomMargin); @@ -726,6 +772,170 @@ public partial class DailyArtworkWidget : UserControl, IDesktopComponentWidget, return best; } + private static AdaptiveTextLayout FitAdaptiveTextLayout( + string? text, + double maxWidth, + double maxHeight, + int minLines, + int maxLines, + double minFontSize, + double maxFontSize, + FontWeight[] weightCandidates, + double lineHeightFactor) + { + var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim(); + var safeMinLines = Math.Max(1, minLines); + var safeMaxLines = Math.Max(safeMinLines, maxLines); + var linesByHeight = ResolveMaxLinesByHeight(maxHeight, minFontSize, lineHeightFactor, safeMinLines, safeMaxLines); + + var candidates = weightCandidates is { Length: > 0 } + ? weightCandidates + : new[] { FontWeight.Normal }; + + AdaptiveTextLayout? best = null; + foreach (var weight in candidates) + { + for (var lineLimit = linesByHeight; lineLimit >= safeMinLines; lineLimit--) + { + var fontSize = FitFontSize( + content, + maxWidth, + maxHeight, + lineLimit, + minFontSize, + maxFontSize, + weight, + lineHeightFactor); + var lineHeight = fontSize * lineHeightFactor; + var measuredSize = MeasureTextSize(content, fontSize, weight, Math.Max(1, maxWidth), lineHeight); + var measuredLineCount = ResolveLineCount(measuredSize.Height, lineHeight); + var overflowLines = Math.Max(0, measuredLineCount - lineLimit); + var overflowHeight = Math.Max(0, measuredSize.Height - maxHeight); + var overflowScore = overflowLines * 1000d + overflowHeight; + var fitsCompletely = overflowLines == 0 && overflowHeight <= 0.6; + var candidate = new AdaptiveTextLayout(fontSize, weight, lineLimit, lineHeight, overflowScore, fitsCompletely); + + if (best is null || IsBetterAdaptiveTextCandidate(candidate, best.Value)) + { + best = candidate; + } + } + } + + if (best is not null) + { + return best.Value; + } + + var fallbackFontSize = Math.Max(6, minFontSize); + return new AdaptiveTextLayout( + fallbackFontSize, + FontWeight.Normal, + safeMinLines, + fallbackFontSize * lineHeightFactor, + double.MaxValue, + fitsCompletely: false); + } + + private static bool IsBetterAdaptiveTextCandidate(AdaptiveTextLayout candidate, AdaptiveTextLayout best) + { + if (candidate.FitsCompletely && !best.FitsCompletely) + { + return true; + } + + if (!candidate.FitsCompletely && best.FitsCompletely) + { + return false; + } + + if (candidate.FitsCompletely && best.FitsCompletely) + { + if (candidate.FontSize > best.FontSize + 0.12) + { + return true; + } + + if (Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && candidate.MaxLines < best.MaxLines) + { + return true; + } + + return false; + } + + if (candidate.OverflowScore < best.OverflowScore - 0.2) + { + return true; + } + + if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 && + candidate.FontSize > best.FontSize + 0.12) + { + return true; + } + + if (Math.Abs(candidate.OverflowScore - best.OverflowScore) <= 0.2 && + Math.Abs(candidate.FontSize - best.FontSize) <= 0.12 && + candidate.MaxLines > best.MaxLines) + { + return true; + } + + return false; + } + + private static int ResolveMaxLinesByHeight( + double maxHeight, + double minFontSize, + double lineHeightFactor, + int minLines, + int maxLines) + { + var safeMinLines = Math.Max(1, minLines); + var safeMaxLines = Math.Max(safeMinLines, maxLines); + var lineHeight = Math.Max(1, Math.Max(6, minFontSize) * lineHeightFactor); + var maxHeightWithTolerance = Math.Max(1, maxHeight + 0.6); + var linesByHeight = (int)Math.Floor(maxHeightWithTolerance / lineHeight); + return Math.Clamp(linesByHeight, safeMinLines, safeMaxLines); + } + + private static int ResolveLineCount(double measuredHeight, double lineHeight) + { + return Math.Max(1, (int)Math.Ceiling(measuredHeight / Math.Max(1, lineHeight))); + } + + private readonly struct AdaptiveTextLayout + { + public AdaptiveTextLayout( + double fontSize, + FontWeight weight, + int maxLines, + double lineHeight, + double overflowScore, + bool fitsCompletely) + { + FontSize = fontSize; + Weight = weight; + MaxLines = Math.Max(1, maxLines); + LineHeight = lineHeight; + OverflowScore = overflowScore; + FitsCompletely = fitsCompletely; + } + + public double FontSize { get; } + + public FontWeight Weight { get; } + + public int MaxLines { get; } + + public double LineHeight { get; } + + public double OverflowScore { get; } + + public bool FitsCompletely { get; } + } + private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight) { var probe = new TextBlock diff --git a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml new file mode 100644 index 0000000..f0868d8 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs new file mode 100644 index 0000000..32f88c6 --- /dev/null +++ b/LanMountainDesktop/Views/Components/DailyWordWidget.axaml.cs @@ -0,0 +1,502 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views.Components; + +public partial class DailyWordWidget : UserControl, IDesktopComponentWidget, IRecommendationInfoAwareComponentWidget +{ + private static readonly Regex MultiWhitespaceRegex = new(@"\s+", RegexOptions.Compiled); + private static readonly FontFamily MiSansFontFamily = new("MiSans VF, avares://LanMountainDesktop/Assets/Fonts#MiSans"); + private static readonly IRecommendationInfoService DefaultRecommendationService = new RecommendationDataService(); + + private const double BaseCellSize = 48d; + private const int BaseWidthCells = 4; + private const int BaseHeightCells = 2; + + private readonly DispatcherTimer _refreshTimer = new() + { + Interval = TimeSpan.FromHours(6) + }; + + private readonly AppSettingsService _settingsService = new(); + private readonly LocalizationService _localizationService = new(); + + private IRecommendationInfoService _recommendationService = DefaultRecommendationService; + private CancellationTokenSource? _refreshCts; + private string _languageCode = "zh-CN"; + private double _currentCellSize = BaseCellSize; + private bool _isAttached; + private bool _isRefreshing; + + public DailyWordWidget() + { + InitializeComponent(); + + WordTextBlock.FontFamily = MiSansFontFamily; + PronunciationTextBlock.FontFamily = MiSansFontFamily; + MeaningTextBlock.FontFamily = MiSansFontFamily; + ExampleTextBlock.FontFamily = MiSansFontFamily; + ExampleTranslationTextBlock.FontFamily = MiSansFontFamily; + StatusTextBlock.FontFamily = MiSansFontFamily; + + _refreshTimer.Tick += OnRefreshTimerTick; + RefreshButton.Click += OnRefreshButtonClick; + AttachedToVisualTree += OnAttachedToVisualTree; + DetachedFromVisualTree += OnDetachedFromVisualTree; + SizeChanged += OnSizeChanged; + + ApplyCellSize(_currentCellSize); + UpdateLanguageCode(); + ApplyLoadingState(); + UpdateRefreshButtonState(); + } + + public void ApplyCellSize(double cellSize) + { + _currentCellSize = Math.Max(1, cellSize); + UpdateAdaptiveLayout(); + } + + public void SetRecommendationInfoService(IRecommendationInfoService recommendationInfoService) + { + _recommendationService = recommendationInfoService ?? DefaultRecommendationService; + if (_isAttached) + { + _ = RefreshWordAsync(forceRefresh: false); + } + } + + public void RefreshFromSettings() + { + _recommendationService.ClearCache(); + if (_isAttached) + { + _ = RefreshWordAsync(forceRefresh: true); + } + } + + private void OnAttachedToVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = true; + UpdateRefreshButtonState(); + _refreshTimer.Start(); + _ = RefreshWordAsync(forceRefresh: false); + } + + private void OnDetachedFromVisualTree(object? sender, VisualTreeAttachmentEventArgs e) + { + _isAttached = false; + _refreshTimer.Stop(); + CancelRefreshRequest(); + UpdateRefreshButtonState(); + } + + private void OnSizeChanged(object? sender, SizeChangedEventArgs e) + { + ApplyCellSize(_currentCellSize); + } + + private async void OnRefreshButtonClick(object? sender, RoutedEventArgs e) + { + if (_isRefreshing) + { + return; + } + + await RefreshWordAsync(forceRefresh: true); + e.Handled = true; + } + + private async void OnRefreshTimerTick(object? sender, EventArgs e) + { + await RefreshWordAsync(forceRefresh: false); + } + + private async Task RefreshWordAsync(bool forceRefresh) + { + if (!_isAttached || _isRefreshing) + { + return; + } + + _isRefreshing = true; + UpdateRefreshButtonState(); + UpdateLanguageCode(); + + var cts = new CancellationTokenSource(); + var previous = Interlocked.Exchange(ref _refreshCts, cts); + previous?.Cancel(); + previous?.Dispose(); + + try + { + var query = new DailyWordQuery( + Locale: _languageCode, + ForceRefresh: forceRefresh); + var result = await _recommendationService.GetDailyWordAsync(query, cts.Token); + if (!_isAttached || cts.IsCancellationRequested) + { + return; + } + + if (!result.Success || result.Data is null) + { + ApplyFailedState(); + return; + } + + ApplySnapshot(result.Data); + } + catch (OperationCanceledException) + { + // Ignore canceled requests. + } + catch + { + if (_isAttached && !cts.IsCancellationRequested) + { + ApplyFailedState(); + } + } + finally + { + if (ReferenceEquals(_refreshCts, cts)) + { + _refreshCts = null; + } + + cts.Dispose(); + _isRefreshing = false; + UpdateRefreshButtonState(); + } + } + + private void ApplySnapshot(DailyWordSnapshot snapshot) + { + WordTextBlock.Text = NormalizeCompactText(snapshot.Word); + PronunciationTextBlock.Text = BuildPronunciationText(snapshot); + MeaningTextBlock.Text = BuildMeaningText(snapshot.Meaning); + ExampleTextBlock.Text = BuildExampleText(snapshot.ExampleSentence); + ExampleTranslationTextBlock.Text = BuildExampleTranslation(snapshot.ExampleTranslation); + + StatusTextBlock.IsVisible = false; + UpdateAdaptiveLayout(); + } + + private void ApplyLoadingState() + { + WordTextBlock.Text = L("dailyword.widget.loading_word", "daily word"); + PronunciationTextBlock.Text = L("dailyword.widget.loading_pronunciation", "Fetching pronunciation..."); + MeaningTextBlock.Text = L("dailyword.widget.loading_meaning", "Fetching meaning..."); + ExampleTextBlock.Text = L("dailyword.widget.loading_example", "Fetching example sentence..."); + ExampleTranslationTextBlock.Text = L("dailyword.widget.loading_example_translation", "Loading..."); + StatusTextBlock.Text = L("dailyword.widget.loading", "Loading..."); + StatusTextBlock.IsVisible = true; + UpdateAdaptiveLayout(); + } + + private void ApplyFailedState() + { + WordTextBlock.Text = L("dailyword.widget.fallback_word", "daily word"); + PronunciationTextBlock.Text = L("dailyword.widget.fallback_pronunciation", "Pronunciation unavailable"); + MeaningTextBlock.Text = L("dailyword.widget.fallback_meaning", "Youdao dictionary is temporarily unavailable."); + ExampleTextBlock.Text = L("dailyword.widget.fallback_example", "Tap the refresh button and try again."); + ExampleTranslationTextBlock.Text = L("dailyword.widget.fallback_example_translation", "It will retry when network recovers."); + StatusTextBlock.Text = L("dailyword.widget.fetch_failed", "Daily word fetch failed"); + StatusTextBlock.IsVisible = true; + UpdateAdaptiveLayout(); + } + + private void UpdateAdaptiveLayout() + { + var scale = ResolveScale(); + var totalWidth = Bounds.Width > 1 ? Bounds.Width : _currentCellSize * BaseWidthCells; + var totalHeight = Bounds.Height > 1 ? Bounds.Height : _currentCellSize * BaseHeightCells; + + RootBorder.CornerRadius = new CornerRadius(Math.Clamp(34 * scale, 16, 52)); + RootBorder.Padding = new Thickness( + Math.Clamp(16 * scale, 8, 26), + Math.Clamp(12 * scale, 6, 20), + Math.Clamp(16 * scale, 8, 26), + Math.Clamp(12 * scale, 6, 20)); + + CardBorder.CornerRadius = new CornerRadius(Math.Clamp(24 * scale, 12, 36)); + CardBorder.Padding = new Thickness( + Math.Clamp(16 * scale, 8, 24), + Math.Clamp(14 * scale, 7, 22), + Math.Clamp(16 * scale, 8, 24), + Math.Clamp(14 * scale, 7, 22)); + + var refreshSize = Math.Clamp(38 * scale, 22, 48); + RefreshButton.Width = refreshSize; + RefreshButton.Height = refreshSize; + RefreshButton.CornerRadius = new CornerRadius(refreshSize / 2d); + RefreshIcon.FontSize = Math.Clamp(19 * scale, 12, 26); + + HaloEllipse.Width = Math.Clamp(totalWidth * 0.52, 120, 340); + HaloEllipse.Height = HaloEllipse.Width; + AccentCorner.Width = Math.Clamp(totalWidth * 0.20, 66, 132); + AccentCorner.Height = AccentCorner.Width; + AccentCorner.CornerRadius = new CornerRadius(AccentCorner.Width / 2d); + + var horizontalPadding = RootBorder.Padding.Left + RootBorder.Padding.Right + CardBorder.Padding.Left + CardBorder.Padding.Right; + var contentWidth = Math.Max(98, totalWidth - horizontalPadding); + var wordWidth = Math.Max(70, contentWidth - refreshSize - Math.Clamp(8 * scale, 5, 14)); + WordTextBlock.MaxWidth = wordWidth; + PronunciationTextBlock.MaxWidth = contentWidth; + MeaningTextBlock.MaxWidth = contentWidth; + ExampleTextBlock.MaxWidth = contentWidth; + ExampleTranslationTextBlock.MaxWidth = contentWidth; + + var compactLayout = totalHeight < _currentCellSize * 1.72; + MeaningTextBlock.MaxLines = compactLayout ? 1 : 2; + ExampleTextBlock.MaxLines = compactLayout ? 1 : 2; + ExampleTranslationTextBlock.IsVisible = !compactLayout; + ExampleTranslationTextBlock.MaxLines = 1; + + var contentHeight = Math.Max(52, totalHeight - RootBorder.Padding.Top - RootBorder.Padding.Bottom - CardBorder.Padding.Top - CardBorder.Padding.Bottom); + var wordHeightBudget = Math.Max(18, contentHeight * 0.24); + var pronunciationHeightBudget = Math.Max(14, contentHeight * 0.16); + var meaningHeightBudget = Math.Max(16, contentHeight * (compactLayout ? 0.26 : 0.30)); + var exampleHeightBudget = Math.Max(16, contentHeight - wordHeightBudget - pronunciationHeightBudget - meaningHeightBudget - Math.Clamp(16 * scale, 8, 24)); + if (!ExampleTranslationTextBlock.IsVisible) + { + exampleHeightBudget += Math.Clamp(11 * scale, 5, 18); + } + + var wordBase = Math.Clamp(56 * scale, 18, 72); + WordTextBlock.FontSize = FitFontSize( + WordTextBlock.Text, + wordWidth, + wordHeightBudget, + maxLines: 1, + minFontSize: Math.Max(14, wordBase * 0.56), + maxFontSize: wordBase, + weight: FontWeight.Bold, + lineHeightFactor: 1.04); + WordTextBlock.LineHeight = WordTextBlock.FontSize * 1.04; + + var pronunciationBase = Math.Clamp(27 * scale, 10, 36); + PronunciationTextBlock.FontSize = FitFontSize( + PronunciationTextBlock.Text, + contentWidth, + pronunciationHeightBudget, + maxLines: 1, + minFontSize: Math.Max(8.6, pronunciationBase * 0.62), + maxFontSize: pronunciationBase, + weight: FontWeight.SemiBold, + lineHeightFactor: 1.08); + PronunciationTextBlock.LineHeight = PronunciationTextBlock.FontSize * 1.08; + + var meaningBase = Math.Clamp(25 * scale, 10, 34); + MeaningTextBlock.FontSize = FitFontSize( + MeaningTextBlock.Text, + contentWidth, + meaningHeightBudget, + maxLines: Math.Max(1, MeaningTextBlock.MaxLines), + minFontSize: Math.Max(9.2, meaningBase * 0.60), + maxFontSize: meaningBase, + weight: FontWeight.SemiBold, + lineHeightFactor: 1.10); + MeaningTextBlock.LineHeight = MeaningTextBlock.FontSize * 1.10; + + var exampleBase = Math.Clamp(22 * scale, 9, 30); + ExampleTextBlock.FontSize = FitFontSize( + ExampleTextBlock.Text, + contentWidth, + exampleHeightBudget, + maxLines: Math.Max(1, ExampleTextBlock.MaxLines), + minFontSize: Math.Max(8.8, exampleBase * 0.58), + maxFontSize: exampleBase, + weight: FontWeight.Medium, + lineHeightFactor: 1.08); + ExampleTextBlock.LineHeight = ExampleTextBlock.FontSize * 1.08; + + var translationBase = Math.Clamp(20 * scale, 8, 28); + ExampleTranslationTextBlock.FontSize = FitFontSize( + ExampleTranslationTextBlock.Text, + contentWidth, + Math.Max(10, exampleHeightBudget * 0.44), + maxLines: 1, + minFontSize: Math.Max(7.8, translationBase * 0.62), + maxFontSize: translationBase, + weight: FontWeight.Medium, + lineHeightFactor: 1.06); + ExampleTranslationTextBlock.LineHeight = ExampleTranslationTextBlock.FontSize * 1.06; + + StatusTextBlock.FontSize = Math.Clamp(16 * scale, 9, 24); + } + + private void UpdateRefreshButtonState() + { + RefreshButton.IsEnabled = !_isRefreshing; + RefreshButton.Opacity = _isAttached ? 1.0 : 0.85; + RefreshIcon.Opacity = _isRefreshing ? 0.56 : 1.0; + } + + private void UpdateLanguageCode() + { + try + { + var snapshot = _settingsService.Load(); + _languageCode = _localizationService.NormalizeLanguageCode(snapshot.LanguageCode); + } + catch + { + _languageCode = "zh-CN"; + } + } + + private void CancelRefreshRequest() + { + var cts = Interlocked.Exchange(ref _refreshCts, null); + if (cts is null) + { + return; + } + + cts.Cancel(); + cts.Dispose(); + } + + private string L(string key, string fallback) + { + return _localizationService.GetString(_languageCode, key, fallback); + } + + private double ResolveScale() + { + var cellScale = Math.Clamp(_currentCellSize / BaseCellSize, 0.56, 2.0); + var widthScale = Bounds.Width > 1 + ? Math.Clamp(Bounds.Width / Math.Max(1, _currentCellSize * BaseWidthCells), 0.56, 2.0) + : 1; + var heightScale = Bounds.Height > 1 + ? Math.Clamp(Bounds.Height / Math.Max(1, _currentCellSize * BaseHeightCells), 0.56, 2.0) + : 1; + return Math.Clamp(Math.Min(cellScale, Math.Min(widthScale, heightScale)), 0.56, 2.0); + } + + private string BuildPronunciationText(DailyWordSnapshot snapshot) + { + var uk = NormalizeCompactText(snapshot.UkPronunciation); + var us = NormalizeCompactText(snapshot.UsPronunciation); + var isZh = string.Equals(_languageCode, "zh-CN", StringComparison.OrdinalIgnoreCase); + + if (!string.IsNullOrWhiteSpace(uk) && !string.IsNullOrWhiteSpace(us)) + { + return isZh + ? $"英 /{uk}/ · 美 /{us}/" + : $"UK /{uk}/ · US /{us}/"; + } + + if (!string.IsNullOrWhiteSpace(uk)) + { + return isZh ? $"英 /{uk}/" : $"UK /{uk}/"; + } + + if (!string.IsNullOrWhiteSpace(us)) + { + return isZh ? $"美 /{us}/" : $"US /{us}/"; + } + + return isZh ? "英/美 发音暂无" : "Pronunciation unavailable"; + } + + private static string BuildMeaningText(string? rawMeaning) + { + var normalized = NormalizeCompactText(rawMeaning); + return string.IsNullOrWhiteSpace(normalized) + ? "Meaning unavailable" + : normalized; + } + + private static string BuildExampleText(string? sentence) + { + var normalized = NormalizeCompactText(sentence); + return string.IsNullOrWhiteSpace(normalized) + ? "No example sentence." + : normalized; + } + + private static string BuildExampleTranslation(string? translation) + { + var normalized = NormalizeCompactText(translation); + return string.IsNullOrWhiteSpace(normalized) + ? string.Empty + : normalized; + } + + private static string NormalizeCompactText(string? text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return string.Empty; + } + + return MultiWhitespaceRegex.Replace(text.Trim(), " "); + } + + private static double FitFontSize( + string? text, + double maxWidth, + double maxHeight, + int maxLines, + double minFontSize, + double maxFontSize, + FontWeight weight, + double lineHeightFactor) + { + var content = string.IsNullOrWhiteSpace(text) ? " " : text.Trim(); + var min = Math.Max(6, minFontSize); + var max = Math.Max(min, maxFontSize); + var low = min; + var high = max; + var best = min; + + for (var i = 0; i < 18; i++) + { + var candidate = (low + high) / 2d; + var lineHeight = candidate * lineHeightFactor; + var size = MeasureTextSize(content, candidate, weight, Math.Max(1, maxWidth), lineHeight); + var lineCount = Math.Max(1, (int)Math.Ceiling(size.Height / Math.Max(1, lineHeight))); + var fits = size.Height <= maxHeight + 0.6 && lineCount <= Math.Max(1, maxLines); + + if (fits) + { + best = candidate; + low = candidate; + } + else + { + high = candidate; + } + } + + return best; + } + + private static Size MeasureTextSize(string text, double fontSize, FontWeight weight, double maxWidth, double lineHeight) + { + var probe = new TextBlock + { + Text = text, + FontFamily = MiSansFontFamily, + FontSize = fontSize, + FontWeight = weight, + TextWrapping = TextWrapping.Wrap, + LineHeight = lineHeight + }; + + probe.Measure(new Size(Math.Max(1, maxWidth), double.PositiveInfinity)); + return probe.DesiredSize; + } +} diff --git a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs index ff15111..35367d0 100644 --- a/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs +++ b/LanMountainDesktop/Views/Components/DesktopComponentRuntimeRegistry.cs @@ -229,6 +229,16 @@ public sealed class DesktopComponentRuntimeRegistry "component.daily_artwork", () => new DailyArtworkWidget(), cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopDailyWord, + "component.daily_word", + () => new DailyWordWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), + new DesktopComponentRuntimeRegistration( + BuiltInComponentIds.DesktopCnrDailyNews, + "component.cnr_daily_news", + () => new CnrDailyNewsWidget(), + cellSize => Math.Clamp(cellSize * 0.34, 14, 30)), new DesktopComponentRuntimeRegistration( BuiltInComponentIds.DesktopWhiteboard, "component.whiteboard", diff --git a/LanMountainDesktop/Views/MainWindow.Localization.cs b/LanMountainDesktop/Views/MainWindow.Localization.cs index 88cedb6..0cb05ab 100644 --- a/LanMountainDesktop/Views/MainWindow.Localization.cs +++ b/LanMountainDesktop/Views/MainWindow.Localization.cs @@ -110,6 +110,7 @@ public partial class MainWindow SettingsNavStatusBarTextBlock.Text = L("settings.nav.status_bar", "Status Bar"); SettingsNavWeatherTextBlock.Text = L("settings.nav.weather", "Weather"); SettingsNavRegionTextBlock.Text = L("settings.nav.region", "Region"); + SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update"); WallpaperPanelTitleTextBlock.Text = L("settings.wallpaper.title", "Personalize your wallpaper"); WallpaperPlacementSettingsExpander.Header = L("settings.wallpaper.placement_label", "Placement"); @@ -248,6 +249,8 @@ public partial class MainWindow "settings.region.timezone_desc", "Select a time zone. Clock and calendar widgets will follow this zone."); + ApplyUpdateLocalization(); + SettingsNavAboutTextBlock.Text = L("settings.nav.about", "About"); AboutPanelTitleTextBlock.Text = L("settings.about.title", "About"); VersionTextBlock.Text = Lf( diff --git a/LanMountainDesktop/Views/MainWindow.Settings.cs b/LanMountainDesktop/Views/MainWindow.Settings.cs index 825e9a0..f70d3af 100644 --- a/LanMountainDesktop/Views/MainWindow.Settings.cs +++ b/LanMountainDesktop/Views/MainWindow.Settings.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; @@ -64,6 +65,7 @@ public partial class MainWindow StatusBarSettingsPanel is null || WeatherSettingsPanel is null || RegionSettingsPanel is null || + UpdateSettingsPanel is null || AboutSettingsPanel is null) { return; @@ -76,7 +78,8 @@ public partial class MainWindow StatusBarSettingsPanel.IsVisible = selectedIndex == 3; WeatherSettingsPanel.IsVisible = selectedIndex == 4; RegionSettingsPanel.IsVisible = selectedIndex == 5; - AboutSettingsPanel.IsVisible = selectedIndex == 6; + UpdateSettingsPanel.IsVisible = selectedIndex == 6; + AboutSettingsPanel.IsVisible = selectedIndex == 7; if (selectedIndex == 1) { @@ -547,10 +550,12 @@ public partial class MainWindow Core.Initialize(); _libVlc ??= new LibVLC("--quiet"); - if (_videoWallpaperPlayer is null && DesktopVideoWallpaperView is not null) + if (_videoWallpaperPlayer is null) { - _videoWallpaperPlayer = new MediaPlayer(_libVlc); - DesktopVideoWallpaperView.MediaPlayer = _videoWallpaperPlayer; + _videoWallpaperPlayer = new MediaPlayer(_libVlc) + { + EnableHardwareDecoding = false + }; } if (_previewVideoWallpaperPlayer is null && WallpaperPreviewVideoView is not null) @@ -560,6 +565,212 @@ public partial class MainWindow } } + private bool ConfigureDesktopVideoRenderer() + { + if (_videoWallpaperPlayer is null || DesktopVideoWallpaperImage is null) + { + return false; + } + + var (targetWidth, targetHeight) = GetDesktopVideoRenderSize(); + var targetPitch = targetWidth * 4; + var targetBufferSize = targetPitch * targetHeight; + if (targetBufferSize <= 0) + { + return false; + } + + if (targetWidth == _desktopVideoFrameWidth && + targetHeight == _desktopVideoFrameHeight && + _desktopVideoFrameBufferPtr != IntPtr.Zero && + _desktopVideoBitmap is not null) + { + return true; + } + + ReleaseDesktopVideoRendererResources(); + + try + { + _desktopVideoFrameWidth = targetWidth; + _desktopVideoFrameHeight = targetHeight; + _desktopVideoFramePitch = targetPitch; + _desktopVideoFrameBufferSize = targetBufferSize; + _desktopVideoFrameBufferPtr = Marshal.AllocHGlobal(_desktopVideoFrameBufferSize); + _desktopVideoStagingBuffer = new byte[_desktopVideoFrameBufferSize]; + _desktopVideoBitmap = new WriteableBitmap( + new PixelSize(_desktopVideoFrameWidth, _desktopVideoFrameHeight), + new Vector(96, 96), + PixelFormat.Bgra8888, + AlphaFormat.Opaque); + EnsureDesktopVideoCallbacks(); + _videoWallpaperPlayer.SetVideoCallbacks( + _desktopVideoLockCallback!, + _desktopVideoUnlockCallback!, + _desktopVideoDisplayCallback!); + _videoWallpaperPlayer.SetVideoFormat( + "RV32", + (uint)_desktopVideoFrameWidth, + (uint)_desktopVideoFrameHeight, + (uint)_desktopVideoFramePitch); + DesktopVideoWallpaperImage.Source = _desktopVideoBitmap; + return true; + } + catch + { + ReleaseDesktopVideoRendererResources(); + return false; + } + } + + private (int Width, int Height) GetDesktopVideoRenderSize() + { + var hostWidth = DesktopHost?.Bounds.Width ?? Bounds.Width; + var hostHeight = DesktopHost?.Bounds.Height ?? Bounds.Height; + var pixelWidth = Math.Max(1, (int)Math.Round(hostWidth * RenderScaling)); + var pixelHeight = Math.Max(1, (int)Math.Round(hostHeight * RenderScaling)); + + const int maxPixelCount = 1920 * 1080; + var pixelCount = (long)pixelWidth * pixelHeight; + if (pixelCount > maxPixelCount) + { + var scale = Math.Sqrt((double)maxPixelCount / pixelCount); + pixelWidth = Math.Max(1, (int)Math.Round(pixelWidth * scale)); + pixelHeight = Math.Max(1, (int)Math.Round(pixelHeight * scale)); + } + + return (pixelWidth, pixelHeight); + } + + private void EnsureDesktopVideoCallbacks() + { + _desktopVideoLockCallback ??= OnDesktopVideoFrameLock; + _desktopVideoUnlockCallback ??= OnDesktopVideoFrameUnlock; + _desktopVideoDisplayCallback ??= OnDesktopVideoFrameDisplay; + } + + private IntPtr OnDesktopVideoFrameLock(IntPtr opaque, IntPtr planes) + { + Monitor.Enter(_desktopVideoFrameSync); + if (_desktopVideoFrameBufferPtr == IntPtr.Zero) + { + Marshal.WriteIntPtr(planes, IntPtr.Zero); + Monitor.Exit(_desktopVideoFrameSync); + return IntPtr.Zero; + } + + Marshal.WriteIntPtr(planes, _desktopVideoFrameBufferPtr); + return IntPtr.Zero; + } + + private void OnDesktopVideoFrameUnlock(IntPtr opaque, IntPtr picture, IntPtr planes) + { + if (Monitor.IsEntered(_desktopVideoFrameSync)) + { + Monitor.Exit(_desktopVideoFrameSync); + } + } + + private void OnDesktopVideoFrameDisplay(IntPtr opaque, IntPtr picture) + { + Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 1); + ScheduleDesktopVideoFrameUiRefresh(); + } + + private void ScheduleDesktopVideoFrameUiRefresh() + { + if (Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 1) == 1) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + try + { + PushDesktopVideoFrameToWallpaperImage(); + } + finally + { + Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0); + if (Volatile.Read(ref _desktopVideoFrameDirtyFlag) == 1) + { + ScheduleDesktopVideoFrameUiRefresh(); + } + } + }, DispatcherPriority.Render); + } + + private void PushDesktopVideoFrameToWallpaperImage() + { + if (Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0) == 0) + { + return; + } + + if (_desktopVideoBitmap is null || + _desktopVideoStagingBuffer is null || + _desktopVideoFrameBufferPtr == IntPtr.Zero || + _desktopVideoFrameBufferSize <= 0) + { + return; + } + + lock (_desktopVideoFrameSync) + { + if (_desktopVideoFrameBufferPtr == IntPtr.Zero) + { + return; + } + + Marshal.Copy(_desktopVideoFrameBufferPtr, _desktopVideoStagingBuffer, 0, _desktopVideoFrameBufferSize); + } + + using var framebuffer = _desktopVideoBitmap.Lock(); + var rows = Math.Min(framebuffer.Size.Height, _desktopVideoFrameHeight); + var bytesPerRow = Math.Min(framebuffer.RowBytes, _desktopVideoFramePitch); + for (var row = 0; row < rows; row++) + { + var sourceOffset = row * _desktopVideoFramePitch; + var destinationPtr = IntPtr.Add(framebuffer.Address, row * framebuffer.RowBytes); + Marshal.Copy(_desktopVideoStagingBuffer, sourceOffset, destinationPtr, bytesPerRow); + } + + if (DesktopVideoWallpaperImage is not null && + !ReferenceEquals(DesktopVideoWallpaperImage.Source, _desktopVideoBitmap)) + { + DesktopVideoWallpaperImage.Source = _desktopVideoBitmap; + } + } + + private void ReleaseDesktopVideoRendererResources() + { + Interlocked.Exchange(ref _desktopVideoFrameDirtyFlag, 0); + Interlocked.Exchange(ref _desktopVideoFrameUiRefreshScheduledFlag, 0); + + if (DesktopVideoWallpaperImage is not null) + { + DesktopVideoWallpaperImage.Source = null; + } + + _desktopVideoBitmap?.Dispose(); + _desktopVideoBitmap = null; + _desktopVideoStagingBuffer = null; + _desktopVideoFrameWidth = 0; + _desktopVideoFrameHeight = 0; + _desktopVideoFramePitch = 0; + _desktopVideoFrameBufferSize = 0; + + lock (_desktopVideoFrameSync) + { + if (_desktopVideoFrameBufferPtr != IntPtr.Zero) + { + Marshal.FreeHGlobal(_desktopVideoFrameBufferPtr); + _desktopVideoFrameBufferPtr = IntPtr.Zero; + } + } + } + private void PlayVideoWallpaper(string videoPath) { if (!File.Exists(videoPath)) @@ -575,7 +786,7 @@ public partial class MainWindow if (_videoWallpaperPlayer is null || _previewVideoWallpaperPlayer is null || _libVlc is null || - DesktopVideoWallpaperView is null || + DesktopVideoWallpaperImage is null || WallpaperPreviewVideoView is null) { _wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable."); @@ -583,6 +794,13 @@ public partial class MainWindow return; } + if (!ConfigureDesktopVideoRenderer()) + { + _wallpaperStatus = L("settings.wallpaper.video_player_unavailable", "Video player is unavailable."); + StopVideoWallpaper(); + return; + } + _videoWallpaperMedia?.Dispose(); _previewVideoWallpaperMedia?.Dispose(); _videoWallpaperMedia = new Media(_libVlc, new Uri(videoPath)); @@ -591,7 +809,7 @@ public partial class MainWindow _previewVideoWallpaperMedia.AddOption(":input-repeat=65535"); _videoWallpaperPlayer.Play(_videoWallpaperMedia); _previewVideoWallpaperPlayer.Play(_previewVideoWallpaperMedia); - DesktopVideoWallpaperView.IsVisible = true; + DesktopVideoWallpaperImage.IsVisible = true; WallpaperPreviewVideoView.IsVisible = true; } catch (Exception ex) @@ -603,9 +821,9 @@ public partial class MainWindow private void StopVideoWallpaper() { - if (DesktopVideoWallpaperView is not null) + if (DesktopVideoWallpaperImage is not null) { - DesktopVideoWallpaperView.IsVisible = false; + DesktopVideoWallpaperImage.IsVisible = false; } if (WallpaperPreviewVideoView is not null) @@ -613,16 +831,17 @@ public partial class MainWindow WallpaperPreviewVideoView.IsVisible = false; } - if (_videoWallpaperPlayer?.IsPlaying == true) + if (_videoWallpaperPlayer is not null) { _videoWallpaperPlayer.Stop(); } - if (_previewVideoWallpaperPlayer?.IsPlaying == true) + if (_previewVideoWallpaperPlayer is not null) { _previewVideoWallpaperPlayer.Stop(); } + ReleaseDesktopVideoRendererResources(); _videoWallpaperMedia?.Dispose(); _videoWallpaperMedia = null; _previewVideoWallpaperMedia?.Dispose(); @@ -660,6 +879,9 @@ public partial class MainWindow WeatherNoTlsRequests = _weatherNoTlsRequests, DailyArtworkMirrorSource = DailyArtworkMirrorSources.Normalize(_dailyArtworkMirrorSource), AutoStartWithWindows = _autoStartWithWindows, + AutoCheckUpdates = _autoCheckUpdates, + IncludePrereleaseUpdates = IncludePrereleaseUpdates, + UpdateChannel = IncludePrereleaseUpdates ? "Preview" : "Stable", TopStatusComponentIds = _topStatusComponentIds.ToList(), PinnedTaskbarActions = _pinnedTaskbarActions.Select(action => action.ToString()).ToList(), EnableDynamicTaskbarActions = _enableDynamicTaskbarActions, @@ -2012,6 +2234,24 @@ public partial class MainWindow }; } + if (UpdateOptionsSettingsExpander is not null) + { + UpdateOptionsSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.ArrowClockwiseDashesSettings, + IconVariant = variant + }; + } + + if (UpdateActionsSettingsExpander is not null) + { + UpdateActionsSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource + { + Symbol = Symbol.ArrowDownload, + IconVariant = variant + }; + } + if (AboutStartupSettingsExpander is not null) { AboutStartupSettingsExpander.IconSource = new FluentIcons.Avalonia.Fluent.SymbolIconSource diff --git a/LanMountainDesktop/Views/MainWindow.Update.cs b/LanMountainDesktop/Views/MainWindow.Update.cs new file mode 100644 index 0000000..df2c76c --- /dev/null +++ b/LanMountainDesktop/Views/MainWindow.Update.cs @@ -0,0 +1,482 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; +using LanMountainDesktop.Models; +using LanMountainDesktop.Services; + +namespace LanMountainDesktop.Views; + +public partial class MainWindow +{ + private const string UpdateChannelStable = "Stable"; + private const string UpdateChannelPreview = "Preview"; + + private bool _autoCheckUpdates = true; + private string _updateChannel = UpdateChannelStable; + private bool _suppressUpdateOptionEvents; + private bool _isCheckingUpdates; + private bool _isDownloadingUpdate; + private string _latestReleaseVersionText = "-"; + private DateTimeOffset? _latestReleasePublishedAt; + private string _updateStatusText = string.Empty; + private string _updateDownloadProgressText = string.Empty; + private double _updateDownloadProgressPercent; + private GitHubReleaseAsset? _latestReleaseInstallerAsset; + private string? _downloadedUpdateInstallerPath; + + private bool IncludePrereleaseUpdates => string.Equals( + _updateChannel, + UpdateChannelPreview, + StringComparison.OrdinalIgnoreCase); + + private void InitializeUpdateSettings(AppSettingsSnapshot snapshot) + { + _autoCheckUpdates = snapshot.AutoCheckUpdates; + _updateChannel = NormalizeUpdateChannel(snapshot.UpdateChannel, snapshot.IncludePrereleaseUpdates); + _latestReleaseVersionText = "-"; + _latestReleasePublishedAt = null; + _updateDownloadProgressPercent = 0; + _updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + _updateStatusText = L("settings.update.status_ready", "Ready to check for updates."); + _latestReleaseInstallerAsset = null; + _downloadedUpdateInstallerPath = null; + + _suppressUpdateOptionEvents = true; + try + { + if (AutoCheckUpdatesToggleSwitch is not null) + { + AutoCheckUpdatesToggleSwitch.IsChecked = _autoCheckUpdates; + } + + if (UpdateChannelChipListBox is not null) + { + UpdateChannelChipListBox.SelectedIndex = IncludePrereleaseUpdates ? 1 : 0; + } + } + finally + { + _suppressUpdateOptionEvents = false; + } + + UpdateUpdatePanelState(); + } + + private void TriggerAutoUpdateCheckIfEnabled() + { + if (!_autoCheckUpdates) + { + return; + } + + _ = CheckForUpdatesAsync(silentWhenNoUpdate: true); + } + + private void ApplyUpdateLocalization() + { + SettingsNavUpdateTextBlock.Text = L("settings.nav.update", "Update"); + UpdatePanelTitleTextBlock.Text = L("settings.update.title", "Update"); + + UpdateCurrentVersionLabelTextBlock.Text = L("settings.update.current_version_label", "Current Version"); + UpdateLatestVersionLabelTextBlock.Text = L("settings.update.latest_version_label", "Latest Release"); + UpdatePublishedAtLabelTextBlock.Text = L("settings.update.published_at_label", "Published At"); + + UpdateOptionsSettingsExpander.Header = L("settings.update.options_header", "Update Options"); + UpdateOptionsSettingsExpander.Description = L( + "settings.update.options_desc", + "Configure update checks and release channel."); + + AutoCheckUpdatesToggleSwitch.Content = L( + "settings.update.auto_check_toggle", + "Automatically check for updates on startup"); + UpdateChannelLabelTextBlock.Text = L( + "settings.update.channel_label", + "Update Channel"); + UpdateChannelStableChipItem.Content = L( + "settings.update.channel_stable", + "Stable"); + UpdateChannelPreviewChipItem.Content = L( + "settings.update.channel_preview", + "Preview"); + + UpdateActionsSettingsExpander.Header = L("settings.update.actions_header", "Update Actions"); + UpdateActionsSettingsExpander.Description = L( + "settings.update.actions_desc", + "Check releases, download installer, and start update."); + + CheckForUpdatesButton.Content = L("settings.update.check_button", "Check for Updates"); + DownloadAndInstallUpdateButton.Content = L("settings.update.download_install_button", "Download & Install"); + + if (string.IsNullOrWhiteSpace(_updateDownloadProgressText)) + { + _updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + } + + if (string.IsNullOrWhiteSpace(_updateStatusText)) + { + _updateStatusText = L("settings.update.status_ready", "Ready to check for updates."); + } + + UpdateUpdatePanelState(); + } + + private async void OnCheckForUpdatesClick(object? sender, RoutedEventArgs e) + { + await CheckForUpdatesAsync(silentWhenNoUpdate: false); + } + + private async void OnDownloadAndInstallUpdateClick(object? sender, RoutedEventArgs e) + { + if (_isCheckingUpdates || _isDownloadingUpdate) + { + return; + } + + if (_latestReleaseInstallerAsset is null) + { + await CheckForUpdatesAsync(silentWhenNoUpdate: false); + } + + if (_latestReleaseInstallerAsset is null) + { + return; + } + + await DownloadAndInstallUpdateAsync(_latestReleaseInstallerAsset); + } + + private void OnAutoCheckUpdatesToggled(object? sender, RoutedEventArgs e) + { + if (_suppressUpdateOptionEvents || AutoCheckUpdatesToggleSwitch is null) + { + return; + } + + _autoCheckUpdates = AutoCheckUpdatesToggleSwitch.IsChecked == true; + PersistSettings(); + } + + private void OnUpdateChannelSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + if (_suppressUpdateOptionEvents || UpdateChannelChipListBox is null) + { + return; + } + + var selectedChannel = UpdateChannelChipListBox.SelectedIndex == 1 + ? UpdateChannelPreview + : UpdateChannelStable; + + if (string.Equals(_updateChannel, selectedChannel, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + _updateChannel = selectedChannel; + _latestReleaseInstallerAsset = null; + _latestReleaseVersionText = "-"; + _latestReleasePublishedAt = null; + _downloadedUpdateInstallerPath = null; + _updateStatusText = Lf( + "settings.update.status_channel_changed_format", + "Update channel switched to {0}. Please check again.", + GetLocalizedUpdateChannelName(_updateChannel)); + PersistSettings(); + UpdateUpdatePanelState(); + } + + private async Task CheckForUpdatesAsync(bool silentWhenNoUpdate) + { + if (_isCheckingUpdates || _isDownloadingUpdate) + { + return; + } + + if (!OperatingSystem.IsWindows()) + { + _updateStatusText = L( + "settings.update.status_windows_only", + "Automatic installer update is currently available only on Windows."); + UpdateUpdatePanelState(); + return; + } + + _isCheckingUpdates = true; + _updateStatusText = L("settings.update.status_checking", "Checking GitHub releases..."); + _updateDownloadProgressPercent = 0; + _updateDownloadProgressText = L("settings.update.download_progress_idle", "Download progress: -"); + UpdateUpdatePanelState(); + + try + { + if (!Version.TryParse(GetAppVersionText(), out var currentVersion)) + { + currentVersion = new Version(0, 0, 0); + } + + var result = await _releaseUpdateService.CheckForUpdatesAsync( + currentVersion, + IncludePrereleaseUpdates); + + if (!result.Success) + { + _latestReleaseInstallerAsset = null; + _latestReleaseVersionText = "-"; + _latestReleasePublishedAt = null; + _downloadedUpdateInstallerPath = null; + _updateStatusText = Lf( + "settings.update.status_check_failed_format", + "Update check failed: {0}", + result.ErrorMessage ?? L("common.unknown", "Unknown error")); + return; + } + + _latestReleaseInstallerAsset = result.PreferredAsset; + _latestReleaseVersionText = result.LatestVersionText; + _latestReleasePublishedAt = result.Release?.PublishedAt; + _downloadedUpdateInstallerPath = null; + + if (!result.IsUpdateAvailable) + { + _latestReleaseInstallerAsset = null; + _updateStatusText = silentWhenNoUpdate + ? L("settings.update.status_up_to_date", "You are already on the latest version.") + : L("settings.update.status_up_to_date", "You are already on the latest version."); + return; + } + + if (_latestReleaseInstallerAsset is null) + { + _updateStatusText = L( + "settings.update.status_asset_missing", + "A new release is available, but no compatible installer was found."); + return; + } + + _updateStatusText = Lf( + "settings.update.status_available_format", + "New version {0} is available. Click Download & Install.", + _latestReleaseVersionText); + } + catch (Exception ex) + { + _updateStatusText = Lf( + "settings.update.status_check_failed_format", + "Update check failed: {0}", + ex.Message); + } + finally + { + _isCheckingUpdates = false; + UpdateUpdatePanelState(); + } + } + + private async Task DownloadAndInstallUpdateAsync(GitHubReleaseAsset asset) + { + if (_isCheckingUpdates || _isDownloadingUpdate) + { + return; + } + + _isDownloadingUpdate = true; + _updateStatusText = L("settings.update.status_downloading", "Downloading installer..."); + _updateDownloadProgressPercent = 0; + _updateDownloadProgressText = Lf( + "settings.update.download_progress_format", + "Download progress: {0:F0}%", + _updateDownloadProgressPercent); + UpdateUpdatePanelState(); + + try + { + var destinationPath = BuildUpdateInstallerPath(asset.Name); + var progress = new Progress(value => + { + _updateDownloadProgressPercent = Math.Clamp(value * 100d, 0d, 100d); + _updateDownloadProgressText = Lf( + "settings.update.download_progress_format", + "Download progress: {0:F0}%", + _updateDownloadProgressPercent); + UpdateUpdatePanelState(); + }); + + var result = await _releaseUpdateService.DownloadAssetAsync(asset, destinationPath, progress); + if (!result.Success || string.IsNullOrWhiteSpace(result.FilePath)) + { + _updateStatusText = Lf( + "settings.update.status_download_failed_format", + "Download failed: {0}", + result.ErrorMessage ?? L("common.unknown", "Unknown error")); + return; + } + + _downloadedUpdateInstallerPath = result.FilePath; + _updateDownloadProgressPercent = 100; + _updateDownloadProgressText = Lf( + "settings.update.download_progress_format", + "Download progress: {0:F0}%", + _updateDownloadProgressPercent); + _updateStatusText = L("settings.update.status_launching_installer", "Download complete. Launching installer..."); + UpdateUpdatePanelState(); + + LaunchInstallerAndExit(_downloadedUpdateInstallerPath); + } + catch (Exception ex) + { + _updateStatusText = Lf( + "settings.update.status_download_failed_format", + "Download failed: {0}", + ex.Message); + } + finally + { + _isDownloadingUpdate = false; + UpdateUpdatePanelState(); + } + } + + private void LaunchInstallerAndExit(string installerPath) + { + if (string.IsNullOrWhiteSpace(installerPath) || !File.Exists(installerPath)) + { + _updateStatusText = L( + "settings.update.status_installer_missing", + "Installer file was not found after download."); + UpdateUpdatePanelState(); + return; + } + + try + { + Process.Start(new ProcessStartInfo + { + FileName = installerPath, + WorkingDirectory = Path.GetDirectoryName(installerPath) ?? Environment.CurrentDirectory, + UseShellExecute = true + }); + + _updateStatusText = L( + "settings.update.status_installer_started", + "Installer started. The app will close for update."); + UpdateUpdatePanelState(); + + Dispatcher.UIThread.Post(Close, DispatcherPriority.Background); + } + catch (Exception ex) + { + _updateStatusText = Lf( + "settings.update.status_launch_failed_format", + "Failed to start installer: {0}", + ex.Message); + UpdateUpdatePanelState(); + } + } + + private void UpdateUpdatePanelState() + { + if (UpdateCurrentVersionValueTextBlock is not null) + { + UpdateCurrentVersionValueTextBlock.Text = GetAppVersionText(); + } + + if (UpdateLatestVersionValueTextBlock is not null) + { + UpdateLatestVersionValueTextBlock.Text = string.IsNullOrWhiteSpace(_latestReleaseVersionText) + ? "-" + : _latestReleaseVersionText; + } + + if (UpdatePublishedAtValueTextBlock is not null) + { + UpdatePublishedAtValueTextBlock.Text = _latestReleasePublishedAt.HasValue && + _latestReleasePublishedAt.Value != DateTimeOffset.MinValue + ? _latestReleasePublishedAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm") + : "-"; + } + + if (UpdateStatusTextBlock is not null) + { + UpdateStatusTextBlock.Text = string.IsNullOrWhiteSpace(_updateStatusText) + ? L("settings.update.status_ready", "Ready to check for updates.") + : _updateStatusText; + } + + if (UpdateDownloadProgressTextBlock is not null) + { + UpdateDownloadProgressTextBlock.Text = string.IsNullOrWhiteSpace(_updateDownloadProgressText) + ? L("settings.update.download_progress_idle", "Download progress: -") + : _updateDownloadProgressText; + } + + if (UpdateDownloadProgressBar is not null) + { + UpdateDownloadProgressBar.IsVisible = _isDownloadingUpdate; + UpdateDownloadProgressBar.Value = Math.Clamp(_updateDownloadProgressPercent, 0d, 100d); + } + + if (CheckForUpdatesButton is not null) + { + CheckForUpdatesButton.IsEnabled = !_isCheckingUpdates && !_isDownloadingUpdate; + } + + if (DownloadAndInstallUpdateButton is not null) + { + DownloadAndInstallUpdateButton.IsEnabled = !_isCheckingUpdates && + !_isDownloadingUpdate && + _latestReleaseInstallerAsset is not null; + } + } + + private static string NormalizeUpdateChannel(string? channel, bool includePrereleaseFallback) + { + if (string.Equals(channel, UpdateChannelPreview, StringComparison.OrdinalIgnoreCase)) + { + return UpdateChannelPreview; + } + + if (string.Equals(channel, UpdateChannelStable, StringComparison.OrdinalIgnoreCase)) + { + return UpdateChannelStable; + } + + return includePrereleaseFallback ? UpdateChannelPreview : UpdateChannelStable; + } + + private string GetLocalizedUpdateChannelName(string channel) + { + return string.Equals(channel, UpdateChannelPreview, StringComparison.OrdinalIgnoreCase) + ? L("settings.update.channel_preview", "Preview") + : L("settings.update.channel_stable", "Stable"); + } + + private static string BuildUpdateInstallerPath(string assetName) + { + var appData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + var updatesDirectory = Path.Combine(appData, "LanMountainDesktop", "Updates"); + Directory.CreateDirectory(updatesDirectory); + + var safeName = SanitizeFileName(assetName); + return Path.Combine(updatesDirectory, safeName); + } + + private static string SanitizeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return $"LanMountainDesktop-Update-{DateTime.Now:yyyyMMddHHmmss}.exe"; + } + + var sanitized = fileName; + foreach (var c in Path.GetInvalidFileNameChars()) + { + sanitized = sanitized.Replace(c, '_'); + } + + return sanitized; + } +} diff --git a/LanMountainDesktop/Views/MainWindow.axaml b/LanMountainDesktop/Views/MainWindow.axaml index be59788..325e05f 100644 --- a/LanMountainDesktop/Views/MainWindow.axaml +++ b/LanMountainDesktop/Views/MainWindow.axaml @@ -83,11 +83,12 @@ VerticalAlignment="Stretch" Background="{DynamicResource AdaptiveSurfaceBaseBrush}" /> - + + + + + + + @@ -1378,6 +1385,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +