修复
This commit is contained in:
lincube
2026-03-05 12:34:39 +08:00
parent 00694e715f
commit 469f7e1132
46 changed files with 1535 additions and 344 deletions

View File

@@ -7,6 +7,7 @@ namespace LanMountainDesktop.Services;
public sealed record DailyArtworkQuery(
string? Locale = null,
string? MirrorSource = null,
bool ForceRefresh = false);
public sealed record DailyPoetryQuery(
@@ -35,11 +36,16 @@ public sealed record RecommendationApiOptions
public string JinriShiciPoetryUrl { get; init; } = "https://v1.jinrishici.com/all.json";
public string ArtInstituteArtworkApiTemplate { get; init; } =
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link";
"https://api.artic.edu/api/v1/artworks?page={0}&limit={1}&fields=id,title,artist_title,artist_display,date_display,image_id,api_link,thumbnail";
public string ArtInstituteImageUrlTemplate { get; init; } =
"https://www.artic.edu/iiif/2/{0}/full/843,/0/default.jpg";
public string DomesticArtworkApiUrl { get; init; } =
"https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=8&mkt=zh-CN";
public string DomesticArtworkHost { get; init; } = "https://cn.bing.com";
public TimeSpan CacheDuration { get; init; } = TimeSpan.FromMinutes(20);
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(8);

View File

@@ -12,6 +12,8 @@ namespace LanMountainDesktop.Services;
public sealed class RecommendationDataService : IRecommendationInfoService, IDisposable
{
private const string UserAgent = "Mozilla/5.0";
private sealed record DailyArtworkCacheEntry(DailyArtworkSnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record DailyPoetryCacheEntry(DailyPoetrySnapshot Snapshot, DateTimeOffset ExpireAt);
private sealed record ArtworkCandidate(
@@ -19,13 +21,16 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
string? Artist,
string? Year,
string? ArtworkUrl,
string? ImageId);
string? ImageId,
string? ThumbnailDataUrl);
private readonly RecommendationApiOptions _options;
private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly AppSettingsService _appSettingsService = new();
private readonly object _cacheGate = new();
private DailyArtworkCacheEntry? _dailyArtworkCache;
private readonly Dictionary<string, DailyArtworkCacheEntry> _dailyArtworkCacheBySource =
new(StringComparer.OrdinalIgnoreCase);
private DailyPoetryCacheEntry? _dailyPoetryCache;
public RecommendationDataService(
@@ -60,7 +65,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
{
lock (_cacheGate)
{
_dailyArtworkCache = null;
_dailyArtworkCacheBySource.Clear();
_dailyPoetryCache = null;
}
}
@@ -79,7 +84,7 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.JinriShiciPoetryUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
@@ -132,45 +137,25 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
CancellationToken cancellationToken = default)
{
var normalizedQuery = query ?? new DailyArtworkQuery();
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(out var cached))
var mirrorSource = ResolveArtworkMirrorSource(normalizedQuery);
if (!normalizedQuery.ForceRefresh && TryGetDailyArtworkFromCache(mirrorSource, out var cached))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(cached);
}
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
return string.Equals(mirrorSource, DailyArtworkMirrorSources.Domestic, StringComparison.OrdinalIgnoreCase)
? await GetDailyArtworkFromDomesticSourceAsync(mirrorSource, cancellationToken)
: await GetDailyArtworkFromOverseasSourceAsync(mirrorSource, cancellationToken);
}
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromOverseasSourceAsync(
string mirrorSource,
CancellationToken cancellationToken)
{
var localDate = GetChinaLocalDate();
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
string responseText;
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
using var response = await _httpClient.SendAsync(request, cancellationToken);
responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
try
{
var responseText = await FetchOverseasArtworkPayloadAsync(localDate, cancellationToken);
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("data", out var dataArray) || dataArray.ValueKind != JsonValueKind.Array)
@@ -183,7 +168,9 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
{
var title = ReadString(item, "title");
var imageId = ReadString(item, "image_id");
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(imageId))
var thumbnailDataUrl = ReadString(item, "thumbnail", "lqip");
if (string.IsNullOrWhiteSpace(title) ||
(string.IsNullOrWhiteSpace(imageId) && string.IsNullOrWhiteSpace(thumbnailDataUrl)))
{
continue;
}
@@ -199,7 +186,8 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
artist,
ReadString(item, "date_display"),
ReadString(item, "api_link"),
imageId.Trim()));
string.IsNullOrWhiteSpace(imageId) ? null : imageId.Trim(),
string.IsNullOrWhiteSpace(thumbnailDataUrl) ? null : thumbnailDataUrl.Trim()));
}
if (candidates.Count == 0)
@@ -217,24 +205,121 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
Museum: "The Art Institute of Chicago",
ArtworkUrl: selected.ArtworkUrl,
ImageUrl: BuildArtworkImageUrl(selected.ImageId),
ThumbnailDataUrl: selected.ThumbnailDataUrl,
FetchedAt: DateTimeOffset.UtcNow);
SetDailyArtworkCache(snapshot);
SetDailyArtworkCache(mirrorSource, snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (HttpRequestException ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", ex.Message);
}
}
private bool TryGetDailyArtworkFromCache(out DailyArtworkSnapshot snapshot)
private async Task<RecommendationQueryResult<DailyArtworkSnapshot>> GetDailyArtworkFromDomesticSourceAsync(
string mirrorSource,
CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Get, _options.DomesticArtworkApiUrl);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
using var response = await _httpClient.SendAsync(request, cancellationToken);
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail(
"upstream_http_error",
$"HTTP {(int)response.StatusCode}: {Truncate(responseText, 180)}");
}
using var document = JsonDocument.Parse(responseText);
var root = document.RootElement;
if (!root.TryGetProperty("images", out var images) || images.ValueKind != JsonValueKind.Array)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image list is missing.");
}
var candidates = images.EnumerateArray().ToArray();
if (candidates.Length == 0)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_empty_result", "No daily image candidates were returned.");
}
var localDate = GetChinaLocalDate();
var indexSeed = localDate.Year * 1000 + localDate.DayOfYear;
var selected = candidates[Math.Abs(indexSeed) % candidates.Length];
var imageUrl = BuildDomesticImageUrl(
ReadString(selected, "url"),
_options.DomesticArtworkHost);
if (string.IsNullOrWhiteSpace(imageUrl))
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_parse_error", "Daily image URL is missing.");
}
var title = ReadString(selected, "title");
if (string.IsNullOrWhiteSpace(title))
{
title = ExtractDomesticTitle(ReadString(selected, "copyright"));
}
if (string.IsNullOrWhiteSpace(title))
{
title = "Bing Daily Image";
}
var dateText = ParseDomesticDateText(ReadString(selected, "startdate"));
var artworkUrl = BuildDomesticImageUrl(
ReadString(selected, "copyrightlink"),
_options.DomesticArtworkHost);
if (string.IsNullOrWhiteSpace(artworkUrl) ||
artworkUrl.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
{
artworkUrl = null;
}
var snapshot = new DailyArtworkSnapshot(
Provider: "BingCN",
Title: title.Trim(),
Artist: "Bing China",
Year: dateText,
Museum: "Bing China",
ArtworkUrl: artworkUrl,
ImageUrl: imageUrl,
ThumbnailDataUrl: null,
FetchedAt: DateTimeOffset.UtcNow);
SetDailyArtworkCache(mirrorSource, snapshot);
return RecommendationQueryResult<DailyArtworkSnapshot>.Ok(snapshot);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return RecommendationQueryResult<DailyArtworkSnapshot>.Fail("upstream_network_error", ex.Message);
}
}
private bool TryGetDailyArtworkFromCache(string mirrorSource, out DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
if (_dailyArtworkCache is not null && _dailyArtworkCache.ExpireAt > DateTimeOffset.UtcNow)
if (_dailyArtworkCacheBySource.TryGetValue(mirrorSource, out var cacheEntry) &&
cacheEntry.ExpireAt > DateTimeOffset.UtcNow)
{
snapshot = _dailyArtworkCache.Snapshot;
snapshot = cacheEntry.Snapshot;
return true;
}
}
@@ -243,11 +328,11 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
return false;
}
private void SetDailyArtworkCache(DailyArtworkSnapshot snapshot)
private void SetDailyArtworkCache(string mirrorSource, DailyArtworkSnapshot snapshot)
{
lock (_cacheGate)
{
_dailyArtworkCache = new DailyArtworkCacheEntry(
_dailyArtworkCacheBySource[mirrorSource] = new DailyArtworkCacheEntry(
snapshot,
DateTimeOffset.UtcNow.Add(_options.CacheDuration));
}
@@ -325,6 +410,105 @@ public sealed class RecommendationDataService : IRecommendationInfoService, IDis
imageId.Trim());
}
private string ResolveArtworkMirrorSource(DailyArtworkQuery query)
{
if (!string.IsNullOrWhiteSpace(query.MirrorSource))
{
return DailyArtworkMirrorSources.Normalize(query.MirrorSource);
}
try
{
var snapshot = _appSettingsService.Load();
return DailyArtworkMirrorSources.Normalize(snapshot.DailyArtworkMirrorSource);
}
catch
{
return DailyArtworkMirrorSources.Overseas;
}
}
private async Task<string> FetchOverseasArtworkPayloadAsync(DateOnly localDate, CancellationToken cancellationToken)
{
var candidateCount = Math.Clamp(_options.DefaultArtworkCandidateCount, 10, 100);
var page = Math.Clamp((localDate.DayOfYear % 100) + 1, 1, 100);
var requestUrl = string.Format(
CultureInfo.InvariantCulture,
_options.ArtInstituteArtworkApiTemplate,
page,
candidateCount);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
request.Headers.TryAddWithoutValidation("User-Agent", UserAgent);
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)}");
}
return responseText;
}
private static string? BuildDomesticImageUrl(string? rawValue, string fallbackHost)
{
if (string.IsNullOrWhiteSpace(rawValue))
{
return null;
}
var candidate = rawValue.Trim();
if (Uri.TryCreate(candidate, UriKind.Absolute, out var absoluteUri))
{
return absoluteUri.ToString();
}
if (!Uri.TryCreate(fallbackHost, UriKind.Absolute, out var hostUri))
{
return null;
}
var normalizedPath = candidate.StartsWith("/", StringComparison.Ordinal) ? candidate : $"/{candidate}";
return new Uri(hostUri, normalizedPath).ToString();
}
private static string ExtractDomesticTitle(string? copyrightText)
{
if (string.IsNullOrWhiteSpace(copyrightText))
{
return string.Empty;
}
var compact = copyrightText.Trim();
var bracketIndex = compact.IndexOf('(');
if (bracketIndex <= 0)
{
return compact;
}
return compact[..bracketIndex].Trim();
}
private static string? ParseDomesticDateText(string? rawDate)
{
if (string.IsNullOrWhiteSpace(rawDate) || rawDate.Length < 8)
{
return null;
}
if (DateTime.TryParseExact(
rawDate[..8],
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out var date))
{
return date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
}
return null;
}
private static string? ReadFirstNonEmptyLine(string? text)
{
if (string.IsNullOrWhiteSpace(text))

View File

@@ -0,0 +1,126 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed record WebView2RuntimeAvailability(
bool IsAvailable,
string? Version,
string Message);
public static class WebView2RuntimeProbe
{
private const string WebView2RuntimeClientId = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}";
private const string WebView2RuntimeKeyPath = @"SOFTWARE\Microsoft\EdgeUpdate\Clients\" + WebView2RuntimeClientId;
public const string RuntimeDownloadUrl = "https://go.microsoft.com/fwlink/p/?LinkId=2124703";
public static WebView2RuntimeAvailability GetAvailability()
{
if (!OperatingSystem.IsWindows())
{
return new WebView2RuntimeAvailability(
IsAvailable: true,
Version: null,
Message: string.Empty);
}
try
{
var version = TryGetVersionFromWebView2Api();
if (string.IsNullOrWhiteSpace(version))
{
version = TryGetVersionFromRegistry();
}
if (!string.IsNullOrWhiteSpace(version))
{
return new WebView2RuntimeAvailability(
IsAvailable: true,
Version: version.Trim(),
Message: string.Empty);
}
return new WebView2RuntimeAvailability(
IsAvailable: false,
Version: null,
Message: $"WebView2 Runtime is missing. Install it from {RuntimeDownloadUrl} and restart the app.");
}
catch (Exception ex)
{
return new WebView2RuntimeAvailability(
IsAvailable: false,
Version: null,
Message: $"WebView2 runtime check failed: {ex.Message}");
}
}
public static string ResolveUserDataFolder()
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
if (string.IsNullOrWhiteSpace(localAppData))
{
localAppData = AppContext.BaseDirectory;
}
var userDataFolder = Path.Combine(localAppData, "LanMountainDesktop", "WebView2");
Directory.CreateDirectory(userDataFolder);
return userDataFolder;
}
private static string? TryGetVersionFromWebView2Api()
{
var type = Type.GetType(
"Microsoft.Web.WebView2.Core.CoreWebView2Environment, Microsoft.Web.WebView2.Core",
throwOnError: false);
if (type is null)
{
return null;
}
var method = type.GetMethod(
"GetAvailableBrowserVersionString",
BindingFlags.Public | BindingFlags.Static,
binder: null,
types: Type.EmptyTypes,
modifiers: null);
if (method is null)
{
return null;
}
return method.Invoke(null, null) as string;
}
[SupportedOSPlatform("windows")]
private static string? TryGetVersionFromRegistry()
{
return TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry64)
?? TryReadVersionFromRegistry(RegistryHive.LocalMachine, RegistryView.Registry32)
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry64)
?? TryReadVersionFromRegistry(RegistryHive.CurrentUser, RegistryView.Registry32);
}
[SupportedOSPlatform("windows")]
private static string? TryReadVersionFromRegistry(RegistryHive hive, RegistryView view)
{
try
{
using var baseKey = RegistryKey.OpenBaseKey(hive, view);
using var runtimeKey = baseKey.OpenSubKey(WebView2RuntimeKeyPath, writable: false);
if (runtimeKey is null)
{
return null;
}
var value = runtimeKey.GetValue("pv") as string;
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
}

View File

@@ -0,0 +1,75 @@
using System;
using Microsoft.Win32;
namespace LanMountainDesktop.Services;
public sealed class WindowsStartupService
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "LanMountainDesktop";
private readonly string _startupCommand;
public WindowsStartupService()
{
var processPath = Environment.ProcessPath;
_startupCommand = string.IsNullOrWhiteSpace(processPath)
? string.Empty
: $"\"{processPath}\"";
}
public bool IsEnabled()
{
if (!OperatingSystem.IsWindows())
{
return false;
}
try
{
using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
return runKey?.GetValue(ValueName) is string value &&
!string.IsNullOrWhiteSpace(value);
}
catch
{
return false;
}
}
public bool SetEnabled(bool enabled)
{
if (!OperatingSystem.IsWindows())
{
return false;
}
if (enabled && string.IsNullOrWhiteSpace(_startupCommand))
{
return false;
}
try
{
using var runKey = Registry.CurrentUser.CreateSubKey(RunKeyPath);
if (runKey is null)
{
return false;
}
if (enabled)
{
runKey.SetValue(ValueName, _startupCommand, RegistryValueKind.String);
}
else
{
runKey.DeleteValue(ValueName, throwOnMissingValue: false);
}
return IsEnabled() == enabled;
}
catch
{
return false;
}
}
}