mirror of
https://jihulab.com/DGP-Studio/Snap.Hutao.git
synced 2025-11-19 21:02:53 +08:00
update to SDK 1.1.3
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.1.2" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.1.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -28,4 +28,8 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="CoreEnvironment\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
<!--Modify Window title bar color-->
|
||||
<StaticResource x:Key="WindowCaptionBackground" ResourceKey="ControlFillColorTransparentBrush" />
|
||||
<StaticResource x:Key="WindowCaptionBackgroundDisabled" ResourceKey="ControlFillColorTransparentBrush" />
|
||||
<ThemeResource x:Key="WindowCaptionForeground" ResourceKey="SystemControlForegroundBaseHighBrush" />
|
||||
<ThemeResource x:Key="WindowCaptionForegroundDisabled" ResourceKey="SystemControlForegroundBaseHighBrush" />
|
||||
|
||||
<StaticResource x:Key="ApplicationPageBackgroundThemeBrush" ResourceKey="ControlFillColorTransparentBrush" />
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.WinUI.UI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.VisualStudio.Threading;
|
||||
@@ -32,8 +31,9 @@ public partial class App : Application
|
||||
// load app resource
|
||||
InitializeComponent();
|
||||
InitializeDependencyInjection();
|
||||
InitializeImageCache();
|
||||
|
||||
// notice that we already call InitializeDependencyInjection() above
|
||||
// so we can use Ioc here.
|
||||
logger = Ioc.Default.GetRequiredService<ILogger<App>>();
|
||||
UnhandledException += AppUnhandledException;
|
||||
}
|
||||
@@ -43,6 +43,9 @@ public partial class App : Application
|
||||
/// </summary>
|
||||
public static Window? Window { get => window; set => window = value; }
|
||||
|
||||
/// <inheritdoc cref="Application"/>
|
||||
public static new App Current => (App)Application.Current;
|
||||
|
||||
/// <summary>
|
||||
/// Invoked when the application is launched.
|
||||
/// </summary>
|
||||
@@ -71,12 +74,13 @@ public partial class App : Application
|
||||
Window = Ioc.Default.GetRequiredService<MainWindow>();
|
||||
Window.Activate();
|
||||
|
||||
logger.LogInformation("Cache folder : {folder}", Windows.Storage.ApplicationData.Current.TemporaryFolder.Path);
|
||||
logger.LogInformation(EventIds.CommonLog, "Cache folder : {folder}", Windows.Storage.ApplicationData.Current.TemporaryFolder.Path);
|
||||
|
||||
if (Ioc.Default.GetRequiredService<IMetadataService>() is IMetadataInitializer initializer)
|
||||
{
|
||||
initializer.InitializeInternalAsync().SafeForget(logger: logger);
|
||||
}
|
||||
Ioc.Default
|
||||
.GetRequiredService<IMetadataService>()
|
||||
.As<IMetadataInitializer>()?
|
||||
.InitializeInternalAsync()
|
||||
.SafeForget(logger: logger);
|
||||
|
||||
if (uri != null)
|
||||
{
|
||||
@@ -90,7 +94,9 @@ public partial class App : Application
|
||||
IServiceProvider services = new ServiceCollection()
|
||||
|
||||
// Microsoft extension
|
||||
.AddLogging(builder => builder.AddDebug())
|
||||
.AddLogging(builder => builder
|
||||
.AddDebug()
|
||||
.AddDatabase())
|
||||
.AddMemoryCache()
|
||||
|
||||
// Hutao extensions
|
||||
@@ -107,12 +113,6 @@ public partial class App : Application
|
||||
Ioc.Default.ConfigureServices(services);
|
||||
}
|
||||
|
||||
private static void InitializeImageCache()
|
||||
{
|
||||
ImageCache.Instance.CacheDuration = TimeSpan.FromDays(30);
|
||||
ImageCache.Instance.RetryCount = 3;
|
||||
}
|
||||
|
||||
private void AppUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
|
||||
{
|
||||
logger.LogError(EventIds.UnhandledException, e.Exception, "未经处理的异常");
|
||||
|
||||
37
src/Snap.Hutao/Snap.Hutao/Context/Database/LogDbContext.cs
Normal file
37
src/Snap.Hutao/Snap.Hutao/Context/Database/LogDbContext.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Snap.Hutao.Core.Logging;
|
||||
|
||||
namespace Snap.Hutao.Context.Database;
|
||||
|
||||
/// <summary>
|
||||
/// 日志数据库上下文
|
||||
/// </summary>
|
||||
public class LogDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建一个新的
|
||||
/// </summary>
|
||||
/// <param name="options">选项</param>
|
||||
private LogDbContext(DbContextOptions<LogDbContext> options)
|
||||
: base(options)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 日志记录
|
||||
/// </summary>
|
||||
public DbSet<LogEntry> Logs { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 构造一个临时的日志数据库上下文
|
||||
/// </summary>
|
||||
/// <param name="sqlConnectionString">连接字符串</param>
|
||||
/// <returns>日志数据库上下文</returns>
|
||||
public static LogDbContext Create(string sqlConnectionString)
|
||||
{
|
||||
return new(new DbContextOptionsBuilder<LogDbContext>().UseSqlite(sqlConnectionString).Options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Snap.Hutao.Context.FileSystem;
|
||||
using Snap.Hutao.Core.Logging;
|
||||
using Snap.Hutao.Model.Entity;
|
||||
|
||||
namespace Snap.Hutao.Context.Database;
|
||||
|
||||
/// <summary>
|
||||
/// 此类只用于在生成迁移时提供数据库上下文
|
||||
/// </summary>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public class LogDbContextDesignTimeFactory : IDesignTimeDbContextFactory<LogDbContext>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
[EditorBrowsable(EditorBrowsableState.Never)]
|
||||
public LogDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
MyDocumentContext myDocument = new(new());
|
||||
return LogDbContext.Create($"Data Source={myDocument.Locate("Log.db")}");
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,13 @@ internal class CacheContext : FileSystemContext
|
||||
/// <summary>
|
||||
/// 获取缓存文件夹
|
||||
/// </summary>
|
||||
public static StorageFolder Folder
|
||||
/// <param name="folderName">文件夹名称</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>缓存文件夹</returns>
|
||||
public static Task<StorageFolder> GetFolderAsync(string folderName, CancellationToken token)
|
||||
{
|
||||
get => ApplicationData.Current.TemporaryFolder;
|
||||
StorageFolder tempstate = ApplicationData.Current.TemporaryFolder;
|
||||
return tempstate.CreateFolderAsync(folderName, CreationCollisionOption.OpenIfExists).AsTask(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using System.IO;
|
||||
|
||||
namespace Snap.Hutao.Context.FileSystem.Location;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -17,7 +17,7 @@ public class SystemBackdrop
|
||||
{
|
||||
private readonly Window window;
|
||||
|
||||
private WindowsSystemDispatcherQueueHelper? dispatcherQueueHelper;
|
||||
private DispatcherQueueHelper? dispatcherQueueHelper;
|
||||
private MicaController? backdropController;
|
||||
private SystemBackdropConfiguration? configurationSource;
|
||||
|
||||
@@ -25,7 +25,6 @@ public class SystemBackdrop
|
||||
/// 构造一个新的系统背景帮助类
|
||||
/// </summary>
|
||||
/// <param name="window">窗体</param>
|
||||
/// <param name="fallBackBehavior">回退行为</param>
|
||||
public SystemBackdrop(Window window)
|
||||
{
|
||||
this.window = window;
|
||||
@@ -49,7 +48,7 @@ public class SystemBackdrop
|
||||
}
|
||||
else
|
||||
{
|
||||
dispatcherQueueHelper = new WindowsSystemDispatcherQueueHelper();
|
||||
dispatcherQueueHelper = new DispatcherQueueHelper();
|
||||
dispatcherQueueHelper.EnsureWindowsSystemDispatcherQueueController();
|
||||
|
||||
// Hooking up the policy object
|
||||
@@ -104,16 +103,16 @@ public class SystemBackdrop
|
||||
{
|
||||
Must.NotNull(configurationSource!).Theme = ((FrameworkElement)window.Content).ActualTheme switch
|
||||
{
|
||||
ElementTheme.Dark => SystemBackdropTheme.Dark,
|
||||
ElementTheme.Light => SystemBackdropTheme.Light,
|
||||
ElementTheme.Default => SystemBackdropTheme.Default,
|
||||
ElementTheme.Light => SystemBackdropTheme.Light,
|
||||
ElementTheme.Dark => SystemBackdropTheme.Dark,
|
||||
_ => throw Must.NeverHappen(),
|
||||
};
|
||||
}
|
||||
|
||||
private class WindowsSystemDispatcherQueueHelper
|
||||
private class DispatcherQueueHelper
|
||||
{
|
||||
private object dispatcherQueueController = null!;
|
||||
private object? dispatcherQueueController = null;
|
||||
|
||||
/// <summary>
|
||||
/// 确保系统调度队列控制器存在
|
||||
@@ -128,19 +127,21 @@ public class SystemBackdrop
|
||||
|
||||
if (dispatcherQueueController == null)
|
||||
{
|
||||
DispatcherQueueOptions options;
|
||||
options.DwSize = Marshal.SizeOf(typeof(DispatcherQueueOptions));
|
||||
options.ThreadType = 2; // DQTYPE_THREAD_CURRENT
|
||||
options.ApartmentType = 2; // DQTAT_COM_STA
|
||||
DispatcherQueueOptions options = new()
|
||||
{
|
||||
DwSize = Marshal.SizeOf(typeof(DispatcherQueueOptions)),
|
||||
ThreadType = 2, // DQTYPE_THREAD_CURRENT
|
||||
ApartmentType = 2, // DQTAT_COM_STA
|
||||
};
|
||||
|
||||
_ = CreateDispatcherQueueController(options, ref dispatcherQueueController!);
|
||||
_ = CreateDispatcherQueueController(options, ref dispatcherQueueController);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("CoreMessaging.dll")]
|
||||
private static extern int CreateDispatcherQueueController(
|
||||
[In] DispatcherQueueOptions options,
|
||||
[In, Out, MarshalAs(UnmanagedType.IUnknown)] ref object dispatcherQueueController);
|
||||
[In, Out, MarshalAs(UnmanagedType.IUnknown)] ref object? dispatcherQueueController);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct DispatcherQueueOptions
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using CommunityToolkit.WinUI.UI.Behaviors;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Snap.Hutao.Core;
|
||||
|
||||
namespace Snap.Hutao.Control.Behavior;
|
||||
|
||||
/// <summary>
|
||||
/// 在元素加载完成后执行命令的行为
|
||||
/// </summary>
|
||||
internal class InvokeCommandOnLoadedBehavior : BehaviorBase<UIElement>
|
||||
{
|
||||
private static readonly DependencyProperty CommandProperty = Property<InvokeCommandOnLoadedBehavior>.Depend<ICommand>(nameof(Command));
|
||||
private static readonly DependencyProperty CommandParameterProperty = Property<InvokeCommandOnLoadedBehavior>.Depend<object>(nameof(CommandParameter));
|
||||
|
||||
/// <summary>
|
||||
/// 待执行的命令
|
||||
/// </summary>
|
||||
public ICommand Command
|
||||
{
|
||||
get { return (ICommand)GetValue(CommandProperty); }
|
||||
set { SetValue(CommandProperty, value); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命令参数
|
||||
/// </summary>
|
||||
[MaybeNull]
|
||||
public object CommandParameter
|
||||
{
|
||||
get { return GetValue(CommandParameterProperty); }
|
||||
set { SetValue(CommandParameterProperty, value); }
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void OnAssociatedObjectLoaded()
|
||||
{
|
||||
if (Command != null && Command.CanExecute(CommandParameter))
|
||||
{
|
||||
Command?.Execute(CommandParameter);
|
||||
}
|
||||
|
||||
base.OnAssociatedObjectLoaded();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using CommunityToolkit.WinUI.UI;
|
||||
using CommunityToolkit.WinUI.UI.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Snap.Hutao.Core.Caching;
|
||||
using Snap.Hutao.Extension;
|
||||
|
||||
namespace Snap.Hutao.Control.Image;
|
||||
@@ -14,11 +14,15 @@ namespace Snap.Hutao.Control.Image;
|
||||
/// </summary>
|
||||
public class CachedImage : ImageEx
|
||||
{
|
||||
private readonly IImageCache imageCache;
|
||||
|
||||
/// <summary>
|
||||
/// 构造一个新的缓存图像
|
||||
/// </summary>
|
||||
public CachedImage()
|
||||
{
|
||||
imageCache = Ioc.Default.GetRequiredService<IImageCache>();
|
||||
|
||||
IsCacheEnabled = true;
|
||||
EnableLazyLoading = true;
|
||||
}
|
||||
@@ -29,7 +33,7 @@ public class CachedImage : ImageEx
|
||||
BitmapImage? image;
|
||||
try
|
||||
{
|
||||
image = await ImageCache.Instance.GetFromCacheAsync(imageUri, true, token);
|
||||
image = await imageCache.GetFromCacheAsync(imageUri, true, token);
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
@@ -39,7 +43,7 @@ public class CachedImage : ImageEx
|
||||
catch
|
||||
{
|
||||
// maybe the image is corrupted, remove it.
|
||||
await ImageCache.Instance.RemoveAsync(imageUri.Enumerate());
|
||||
await imageCache.RemoveAsync(imageUri.Enumerate());
|
||||
throw;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ using Microsoft.UI.Composition;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Hosting;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Animation;
|
||||
using Snap.Hutao.Context.FileSystem;
|
||||
using Snap.Hutao.Core;
|
||||
using Snap.Hutao.Core.Threading;
|
||||
using Snap.Hutao.Extension;
|
||||
using Snap.Hutao.Service.Abstraction;
|
||||
using System.Numerics;
|
||||
using Windows.Graphics.Imaging;
|
||||
using Windows.Storage;
|
||||
@@ -26,7 +26,8 @@ namespace Snap.Hutao.Control.Image;
|
||||
public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
{
|
||||
private static readonly DependencyProperty SourceProperty = Property<Gradient>.Depend(nameof(Source), string.Empty, OnSourceChanged);
|
||||
private static readonly ConcurrentCancellationTokenSource<Gradient> ImageLoading = new();
|
||||
private static readonly ConcurrentCancellationTokenSource<Gradient> LoadingTokenSource = new();
|
||||
|
||||
private SpriteVisual? spriteVisual;
|
||||
private double imageAspectRatio;
|
||||
|
||||
@@ -47,31 +48,20 @@ public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
set => SetValue(SourceProperty, value);
|
||||
}
|
||||
|
||||
private static async Task<StorageFile> GetCachedFileAsync(string url, CancellationToken token)
|
||||
{
|
||||
string fileName = CacheContext.GetCacheFileName(url);
|
||||
CacheContext cacheContext = Ioc.Default.GetRequiredService<CacheContext>();
|
||||
|
||||
StorageFile storageFile;
|
||||
if (!cacheContext.FileExists(fileName))
|
||||
{
|
||||
storageFile = await CacheContext.Folder.CreateFileAsync(fileName).AsTask(token);
|
||||
await StreamHelper.GetHttpStreamToStorageFileAsync(new(url), storageFile);
|
||||
}
|
||||
else
|
||||
{
|
||||
storageFile = await CacheContext.Folder.GetFileAsync(fileName).AsTask(token);
|
||||
}
|
||||
|
||||
return storageFile;
|
||||
}
|
||||
|
||||
private static void OnSourceChanged(DependencyObject sender, DependencyPropertyChangedEventArgs arg)
|
||||
{
|
||||
Gradient gradient = (Gradient)sender;
|
||||
string url = (string)arg.NewValue;
|
||||
|
||||
gradient.ApplyImageAsync(url, ImageLoading.Register(gradient)).SafeForget();
|
||||
ILogger<Gradient> logger = Ioc.Default.GetRequiredService<ILogger<Gradient>>();
|
||||
gradient.ApplyImageAsync(url, LoadingTokenSource.Register(gradient)).SafeForget(logger, OnApplyImageFailed);
|
||||
}
|
||||
|
||||
private static void OnApplyImageFailed(Exception exception)
|
||||
{
|
||||
Ioc.Default
|
||||
.GetRequiredService<IInfoBarService>()
|
||||
.Error(exception, "应用渐变背景时发生异常");
|
||||
}
|
||||
|
||||
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
|
||||
@@ -98,7 +88,7 @@ public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
{
|
||||
await AnimationBuilder
|
||||
.Create()
|
||||
.Opacity(0, 1)
|
||||
.Opacity(0d)
|
||||
.StartAsync(this, token);
|
||||
|
||||
StorageFile storageFile = await GetCachedFileAsync(url, token);
|
||||
@@ -114,6 +104,7 @@ public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
|
||||
CompositionEffectBrush gradientEffectBrush = compositor.CompositeBlendEffectBrush(backgroundBrush, foregroundBrush);
|
||||
CompositionEffectBrush opacityMaskEffectBrush = compositor.CompositeLuminanceToAlphaEffectBrush(gradientEffectBrush);
|
||||
compositor.CreateMaskBrush();
|
||||
CompositionEffectBrush alphaMaskEffectBrush = compositor.CompositeAlphaMaskEffectBrush(imageSurfaceBrush, opacityMaskEffectBrush);
|
||||
|
||||
spriteVisual = compositor.CompositeSpriteVisual(alphaMaskEffectBrush);
|
||||
@@ -123,10 +114,40 @@ public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
|
||||
await AnimationBuilder
|
||||
.Create()
|
||||
.Opacity(1, 0)
|
||||
.Opacity(1d)
|
||||
.StartAsync(this, token);
|
||||
}
|
||||
|
||||
private async Task<StorageFile> GetCachedFileAsync(string url, CancellationToken token)
|
||||
{
|
||||
string fileName = CacheContext.GetCacheFileName(url);
|
||||
CacheContext cacheContext = Ioc.Default.GetRequiredService<CacheContext>();
|
||||
StorageFolder imageCacheFolder = await CacheContext
|
||||
.GetFolderAsync(nameof(Core.Caching.ImageCache), token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
StorageFile storageFile;
|
||||
if (!cacheContext.FileExists(nameof(Core.Caching.ImageCache), fileName))
|
||||
{
|
||||
storageFile = await imageCacheFolder
|
||||
.CreateFileAsync(fileName)
|
||||
.AsTask(token)
|
||||
.ConfigureAwait(false);
|
||||
await StreamHelper
|
||||
.GetHttpStreamToStorageFileAsync(new(url), storageFile)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
storageFile = await imageCacheFolder
|
||||
.GetFileAsync(fileName)
|
||||
.AsTask(token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return storageFile;
|
||||
}
|
||||
|
||||
private async Task<LoadedImageSurface> LoadImageSurfaceAsync(StorageFile storageFile, CancellationToken token)
|
||||
{
|
||||
using (IRandomAccessStream imageStream = await storageFile.OpenAsync(FileAccessMode.Read).AsTask(token))
|
||||
@@ -137,4 +158,4 @@ public class Gradient : Microsoft.UI.Xaml.Controls.Control
|
||||
return LoadedImageSurface.StartLoadFromStream(imageStream);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,21 +24,4 @@ public static class Browser
|
||||
failAction?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 打开浏览器
|
||||
/// </summary>
|
||||
/// <param name="urlFunc">获取链接回调</param>
|
||||
/// <param name="failAction">失败时执行的回调</param>
|
||||
public static void Open(Func<string> urlFunc, Action<Exception>? failAction = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
ProcessHelper.Start(urlFunc.Invoke());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failAction?.Invoke(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
538
src/Snap.Hutao/Snap.Hutao/Core/Caching/CacheBase.cs
Normal file
538
src/Snap.Hutao/Snap.Hutao/Core/Caching/CacheBase.cs
Normal file
@@ -0,0 +1,538 @@
|
||||
// Licensed to the .NET Foundation under one or more agreements.
|
||||
// The .NET Foundation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Snap.Hutao.Core.Caching
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides methods and tools to cache files in a folder
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Generic type as supplied by consumer of the class</typeparam>
|
||||
public abstract class CacheBase<T>
|
||||
{
|
||||
private class ConcurrentRequest
|
||||
{
|
||||
public Task<T> Task { get; set; }
|
||||
|
||||
public bool EnsureCachedCopy { get; set; }
|
||||
}
|
||||
|
||||
private readonly SemaphoreSlim _cacheFolderSemaphore = new(1);
|
||||
private StorageFolder _baseFolder = null;
|
||||
private string _cacheFolderName = null;
|
||||
|
||||
private StorageFolder _cacheFolder = null;
|
||||
private InMemoryStorage<T> _inMemoryFileStorage = null;
|
||||
|
||||
private ConcurrentDictionary<string, ConcurrentRequest> _concurrentTasks = new ConcurrentDictionary<string, ConcurrentRequest>();
|
||||
|
||||
private HttpClient _httpClient = null;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CacheBase{T}"/> class.
|
||||
/// </summary>
|
||||
protected CacheBase()
|
||||
{
|
||||
CacheDuration = TimeSpan.FromDays(1);
|
||||
_inMemoryFileStorage = new InMemoryStorage<T>();
|
||||
RetryCount = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the life duration of every cache entry.
|
||||
/// </summary>
|
||||
public TimeSpan CacheDuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of retries trying to ensure the file is cached.
|
||||
/// </summary>
|
||||
public uint RetryCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets max in-memory item storage count
|
||||
/// </summary>
|
||||
public int MaxMemoryCacheCount
|
||||
{
|
||||
get
|
||||
{
|
||||
return _inMemoryFileStorage.MaxItemCount;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
_inMemoryFileStorage.MaxItemCount = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets instance of <see cref="HttpClient"/>
|
||||
/// </summary>
|
||||
protected HttpClient HttpClient
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_httpClient == null)
|
||||
{
|
||||
var messageHandler = new HttpClientHandler() { MaxConnectionsPerServer = 20 };
|
||||
|
||||
_httpClient = new HttpClient(messageHandler);
|
||||
}
|
||||
|
||||
return _httpClient;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes FileCache and provides root folder and cache folder name
|
||||
/// </summary>
|
||||
/// <param name="folder">Folder that is used as root for cache</param>
|
||||
/// <param name="folderName">Cache folder name</param>
|
||||
/// <param name="httpMessageHandler">instance of <see cref="HttpMessageHandler"/></param>
|
||||
/// <returns>awaitable task</returns>
|
||||
public virtual async Task InitializeAsync(StorageFolder folder = null, string folderName = null, HttpMessageHandler httpMessageHandler = null)
|
||||
{
|
||||
_baseFolder = folder;
|
||||
_cacheFolderName = folderName;
|
||||
|
||||
_cacheFolder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
|
||||
if (httpMessageHandler != null)
|
||||
{
|
||||
_httpClient = new HttpClient(httpMessageHandler);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears all files in the cache
|
||||
/// </summary>
|
||||
/// <returns>awaitable task</returns>
|
||||
public async Task ClearAsync()
|
||||
{
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
var files = await folder.GetFilesAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
await InternalClearAsync(files).ConfigureAwait(false);
|
||||
|
||||
_inMemoryFileStorage.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears file if it has expired
|
||||
/// </summary>
|
||||
/// <param name="duration">timespan to compute whether file has expired or not</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
public Task ClearAsync(TimeSpan duration)
|
||||
{
|
||||
return RemoveExpiredAsync(duration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes cached files that have expired
|
||||
/// </summary>
|
||||
/// <param name="duration">Optional timespan to compute whether file has expired or not. If no value is supplied, <see cref="CacheDuration"/> is used.</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
public async Task RemoveExpiredAsync(TimeSpan? duration = null)
|
||||
{
|
||||
TimeSpan expiryDuration = duration ?? CacheDuration;
|
||||
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
var files = await folder.GetFilesAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
var filesToDelete = new List<StorageFile>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (file == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await IsFileOutOfDateAsync(file, expiryDuration, false).ConfigureAwait(false))
|
||||
{
|
||||
filesToDelete.Add(file);
|
||||
}
|
||||
}
|
||||
|
||||
await InternalClearAsync(filesToDelete).ConfigureAwait(false);
|
||||
|
||||
_inMemoryFileStorage.Clear(expiryDuration);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removed items based on uri list passed
|
||||
/// </summary>
|
||||
/// <param name="uriForCachedItems">Enumerable uri list</param>
|
||||
/// <returns>awaitable Task</returns>
|
||||
public async Task RemoveAsync(IEnumerable<Uri> uriForCachedItems)
|
||||
{
|
||||
if (uriForCachedItems == null || !uriForCachedItems.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
var files = await folder.GetFilesAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
var filesToDelete = new List<StorageFile>();
|
||||
var keys = new List<string>();
|
||||
|
||||
Dictionary<string, StorageFile> hashDictionary = new Dictionary<string, StorageFile>();
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
hashDictionary.Add(file.Name, file);
|
||||
}
|
||||
|
||||
foreach (var uri in uriForCachedItems)
|
||||
{
|
||||
string fileName = GetCacheFileName(uri);
|
||||
if (hashDictionary.TryGetValue(fileName, out var file))
|
||||
{
|
||||
filesToDelete.Add(file);
|
||||
keys.Add(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
await InternalClearAsync(filesToDelete).ConfigureAwait(false);
|
||||
|
||||
_inMemoryFileStorage.Remove(keys);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assures that item represented by Uri is cached.
|
||||
/// </summary>
|
||||
/// <param name="uri">Uri of the item</param>
|
||||
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be cached</param>
|
||||
/// <param name="storeToMemoryCache">Indicates if item should be loaded into the in-memory storage</param>
|
||||
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param>
|
||||
/// <returns>Awaitable Task</returns>
|
||||
public Task PreCacheAsync(Uri uri, bool throwOnError = false, bool storeToMemoryCache = false, CancellationToken cancellationToken = default(CancellationToken))
|
||||
{
|
||||
return GetItemAsync(uri, throwOnError, !storeToMemoryCache, cancellationToken, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves item represented by Uri from the cache. If the item is not found in the cache, it will try to downloaded and saved before returning it to the caller.
|
||||
/// </summary>
|
||||
/// <param name="uri">Uri of the item.</param>
|
||||
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be found / downloaded.</param>
|
||||
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>an instance of Generic type</returns>
|
||||
public Task<T> GetFromCacheAsync(Uri uri, bool throwOnError = false, CancellationToken cancellationToken = default(CancellationToken), List<KeyValuePair<string, object>> initializerKeyValues = null)
|
||||
{
|
||||
return GetItemAsync(uri, throwOnError, false, cancellationToken, initializerKeyValues);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the StorageFile containing cached item for given Uri
|
||||
/// </summary>
|
||||
/// <param name="uri">Uri of the item.</param>
|
||||
/// <returns>a StorageFile</returns>
|
||||
public async Task<StorageFile> GetFileFromCacheAsync(Uri uri)
|
||||
{
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
|
||||
string fileName = GetCacheFileName(uri);
|
||||
|
||||
var item = await folder.TryGetItemAsync(fileName).AsTask().ConfigureAwait(false);
|
||||
|
||||
return item as StorageFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves item represented by Uri from the in-memory cache if it exists and is not out of date. If item is not found or is out of date, default instance of the generic type is returned.
|
||||
/// </summary>
|
||||
/// <param name="uri">Uri of the item.</param>
|
||||
/// <returns>an instance of Generic type</returns>
|
||||
public T GetFromMemoryCache(Uri uri)
|
||||
{
|
||||
T instance = default(T);
|
||||
|
||||
string fileName = GetCacheFileName(uri);
|
||||
|
||||
if (_inMemoryFileStorage.MaxItemCount > 0)
|
||||
{
|
||||
var msi = _inMemoryFileStorage.GetItem(fileName, CacheDuration);
|
||||
if (msi != null)
|
||||
{
|
||||
instance = msi.Item;
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache specific hooks to process items from HTTP response
|
||||
/// </summary>
|
||||
/// <param name="stream">input stream</param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
protected abstract Task<T> InitializeTypeAsync(Stream stream, List<KeyValuePair<string, object>> initializerKeyValues = null);
|
||||
|
||||
/// <summary>
|
||||
/// Cache specific hooks to process items from HTTP response
|
||||
/// </summary>
|
||||
/// <param name="baseFile">storage file</param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
protected abstract Task<T> InitializeTypeAsync(StorageFile baseFile, List<KeyValuePair<string, object>> initializerKeyValues = null);
|
||||
|
||||
/// <summary>
|
||||
/// Override-able method that checks whether file is valid or not.
|
||||
/// </summary>
|
||||
/// <param name="file">storage file</param>
|
||||
/// <param name="duration">cache duration</param>
|
||||
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param>
|
||||
/// <returns>bool indicate whether file has expired or not</returns>
|
||||
protected virtual async Task<bool> IsFileOutOfDateAsync(StorageFile file, TimeSpan duration, bool treatNullFileAsOutOfDate = true)
|
||||
{
|
||||
if (file == null)
|
||||
{
|
||||
return treatNullFileAsOutOfDate;
|
||||
}
|
||||
|
||||
var properties = await file.GetBasicPropertiesAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
return properties.Size == 0 || DateTime.Now.Subtract(properties.DateModified.DateTime) > duration;
|
||||
}
|
||||
|
||||
private static string GetCacheFileName(Uri uri)
|
||||
{
|
||||
return CreateHash64(uri.ToString()).ToString();
|
||||
}
|
||||
|
||||
private static ulong CreateHash64(string str)
|
||||
{
|
||||
byte[] utf8 = global::System.Text.Encoding.UTF8.GetBytes(str);
|
||||
|
||||
ulong value = (ulong)utf8.Length;
|
||||
for (int n = 0; n < utf8.Length; n++)
|
||||
{
|
||||
value += (ulong)utf8[n] << ((n * 5) % 56);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private async Task<T> GetItemAsync(Uri uri, bool throwOnError, bool preCacheOnly, CancellationToken cancellationToken, List<KeyValuePair<string, object>> initializerKeyValues)
|
||||
{
|
||||
T instance = default(T);
|
||||
|
||||
string fileName = GetCacheFileName(uri);
|
||||
_concurrentTasks.TryGetValue(fileName, out var request);
|
||||
|
||||
// if similar request exists check if it was preCacheOnly and validate that current request isn't preCacheOnly
|
||||
if (request != null && request.EnsureCachedCopy && !preCacheOnly)
|
||||
{
|
||||
await request.Task.ConfigureAwait(false);
|
||||
request = null;
|
||||
}
|
||||
|
||||
if (request == null)
|
||||
{
|
||||
request = new ConcurrentRequest()
|
||||
{
|
||||
Task = GetFromCacheOrDownloadAsync(uri, fileName, preCacheOnly, cancellationToken, initializerKeyValues),
|
||||
EnsureCachedCopy = preCacheOnly
|
||||
};
|
||||
|
||||
_concurrentTasks[fileName] = request;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
instance = await request.Task.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
global::System.Diagnostics.Debug.WriteLine(ex.Message);
|
||||
if (throwOnError)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_concurrentTasks.TryRemove(fileName, out _);
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async Task<T> GetFromCacheOrDownloadAsync(Uri uri, string fileName, bool preCacheOnly, CancellationToken cancellationToken, List<KeyValuePair<string, object>> initializerKeyValues)
|
||||
{
|
||||
T instance = default(T);
|
||||
|
||||
if (_inMemoryFileStorage.MaxItemCount > 0)
|
||||
{
|
||||
var msi = _inMemoryFileStorage.GetItem(fileName, CacheDuration);
|
||||
if (msi != null)
|
||||
{
|
||||
instance = msi.Item;
|
||||
}
|
||||
}
|
||||
|
||||
if (instance != null)
|
||||
{
|
||||
return instance;
|
||||
}
|
||||
|
||||
var folder = await GetCacheFolderAsync().ConfigureAwait(false);
|
||||
var baseFile = await folder.TryGetItemAsync(fileName).AsTask().ConfigureAwait(false) as StorageFile;
|
||||
|
||||
bool downloadDataFile = baseFile == null || await IsFileOutOfDateAsync(baseFile, CacheDuration).ConfigureAwait(false);
|
||||
|
||||
if (baseFile == null)
|
||||
{
|
||||
baseFile = await folder.CreateFileAsync(fileName, CreationCollisionOption.ReplaceExisting).AsTask().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (downloadDataFile)
|
||||
{
|
||||
uint retries = 0;
|
||||
try
|
||||
{
|
||||
while (retries < RetryCount)
|
||||
{
|
||||
try
|
||||
{
|
||||
instance = await DownloadFileAsync(uri, baseFile, preCacheOnly, cancellationToken, initializerKeyValues).ConfigureAwait(false);
|
||||
|
||||
if (instance != null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
await baseFile.DeleteAsync().AsTask().ConfigureAwait(false);
|
||||
throw; // re-throwing the exception changes the stack trace. just throw
|
||||
}
|
||||
}
|
||||
|
||||
if (EqualityComparer<T>.Default.Equals(instance, default(T)) && !preCacheOnly)
|
||||
{
|
||||
instance = await InitializeTypeAsync(baseFile, initializerKeyValues).ConfigureAwait(false);
|
||||
|
||||
if (_inMemoryFileStorage.MaxItemCount > 0)
|
||||
{
|
||||
var properties = await baseFile.GetBasicPropertiesAsync().AsTask().ConfigureAwait(false);
|
||||
|
||||
var msi = new InMemoryStorageItem<T>(fileName, properties.DateModified.DateTime, instance);
|
||||
_inMemoryFileStorage.SetItem(msi);
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async Task<T> DownloadFileAsync(Uri uri, StorageFile baseFile, bool preCacheOnly, CancellationToken cancellationToken, List<KeyValuePair<string, object>> initializerKeyValues)
|
||||
{
|
||||
T instance = default(T);
|
||||
|
||||
using (MemoryStream ms = new MemoryStream())
|
||||
{
|
||||
using (var stream = await HttpClient.GetStreamAsync(uri))
|
||||
{
|
||||
stream.CopyTo(ms);
|
||||
ms.Flush();
|
||||
|
||||
ms.Position = 0;
|
||||
|
||||
using (var fs = await baseFile.OpenStreamForWriteAsync())
|
||||
{
|
||||
ms.CopyTo(fs);
|
||||
|
||||
fs.Flush();
|
||||
|
||||
ms.Position = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// if its pre-cache we aren't looking to load items in memory
|
||||
if (!preCacheOnly)
|
||||
{
|
||||
instance = await InitializeTypeAsync(ms, initializerKeyValues).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
private async Task InternalClearAsync(IEnumerable<StorageFile> files)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
await file.DeleteAsync().AsTask().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Just ignore errors for now}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes with default values if user has not initialized explicitly
|
||||
/// </summary>
|
||||
/// <returns>awaitable task</returns>
|
||||
private async Task ForceInitialiseAsync()
|
||||
{
|
||||
if (_cacheFolder != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _cacheFolderSemaphore.WaitAsync().ConfigureAwait(false);
|
||||
|
||||
_inMemoryFileStorage = new InMemoryStorage<T>();
|
||||
|
||||
if (_baseFolder == null)
|
||||
{
|
||||
_baseFolder = ApplicationData.Current.TemporaryFolder;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_cacheFolderName))
|
||||
{
|
||||
_cacheFolderName = GetType().Name;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_cacheFolder = await _baseFolder.CreateFolderAsync(_cacheFolderName, CreationCollisionOption.OpenIfExists).AsTask().ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_cacheFolderSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StorageFolder> GetCacheFolderAsync()
|
||||
{
|
||||
if (_cacheFolder == null)
|
||||
{
|
||||
await ForceInitialiseAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return _cacheFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/Snap.Hutao/Snap.Hutao/Core/Caching/IImageCache.cs
Normal file
32
src/Snap.Hutao/Snap.Hutao/Core/Caching/IImageCache.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Snap.Hutao.Core.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// 为图像缓存提供抽象
|
||||
/// </summary>
|
||||
/// <typeparam name="T">缓存类型</typeparam>
|
||||
internal interface IImageCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves item represented by Uri from the cache. If the item is not found in the cache, it will try to downloaded and saved before returning it to the caller.
|
||||
/// </summary>
|
||||
/// <param name="uri">Uri of the item.</param>
|
||||
/// <param name="throwOnError">Indicates whether or not exception should be thrown if item cannot be found / downloaded.</param>
|
||||
/// <param name="cancellationToken">instance of <see cref="CancellationToken"/></param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>an instance of Generic type</returns>
|
||||
[SuppressMessage("", "CA1068")]
|
||||
Task<BitmapImage> GetFromCacheAsync(Uri uri, bool throwOnError = false, CancellationToken cancellationToken = default(CancellationToken), List<KeyValuePair<string, object>> initializerKeyValues = null!);
|
||||
|
||||
/// <summary>
|
||||
/// Removed items based on uri list passed
|
||||
/// </summary>
|
||||
/// <param name="uriForCachedItems">Enumerable uri list</param>
|
||||
/// <returns>awaitable Task</returns>
|
||||
Task RemoveAsync(IEnumerable<Uri> uriForCachedItems);
|
||||
}
|
||||
143
src/Snap.Hutao/Snap.Hutao/Core/Caching/ImageCache.cs
Normal file
143
src/Snap.Hutao/Snap.Hutao/Core/Caching/ImageCache.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using CommunityToolkit.WinUI;
|
||||
using CommunityToolkit.WinUI.UI;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Windows.Storage;
|
||||
using Windows.Storage.FileProperties;
|
||||
|
||||
namespace Snap.Hutao.Core.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods and tools to cache files in a folder
|
||||
/// The class's name will become the cache folder's name
|
||||
/// </summary>
|
||||
[Injection(InjectAs.Singleton, typeof(IImageCache))]
|
||||
public class ImageCache : CacheBase<BitmapImage>, IImageCache
|
||||
{
|
||||
private const string DateAccessedProperty = "System.DateAccessed";
|
||||
|
||||
private readonly List<string> extendedPropertyNames = new()
|
||||
{
|
||||
DateAccessedProperty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ImageCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">http客户端</param>
|
||||
public ImageCache()
|
||||
{
|
||||
DispatcherQueue = Program.UIDispatcherQueue;
|
||||
|
||||
CacheDuration = TimeSpan.FromDays(30);
|
||||
RetryCount = 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets which DispatcherQueue is used to dispatch UI updates.
|
||||
/// </summary>
|
||||
private DispatcherQueue DispatcherQueue { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache specific hooks to process items from HTTP response
|
||||
/// </summary>
|
||||
/// <param name="stream">input stream</param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
protected override Task<BitmapImage> InitializeTypeAsync(Stream stream, List<KeyValuePair<string, object>> initializerKeyValues = null!)
|
||||
{
|
||||
if (stream.Length == 0)
|
||||
{
|
||||
throw new FileNotFoundException();
|
||||
}
|
||||
|
||||
return DispatcherQueue.EnqueueAsync(async () =>
|
||||
{
|
||||
BitmapImage image = new();
|
||||
|
||||
if (initializerKeyValues != null && initializerKeyValues.Count > 0)
|
||||
{
|
||||
foreach (KeyValuePair<string, object> kvp in initializerKeyValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kvp.Key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PropertyInfo? propInfo = image.GetType().GetProperty(kvp.Key, BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
if (propInfo != null && propInfo.CanWrite)
|
||||
{
|
||||
propInfo.SetValue(image, kvp.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This action will run on the UI thread, no need to care which thread to continue with
|
||||
await image.SetSourceAsync(stream.AsRandomAccessStream()).AsTask().ConfigureAwait(false);
|
||||
|
||||
return image;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache specific hooks to process items from HTTP response
|
||||
/// </summary>
|
||||
/// <param name="baseFile">storage file</param>
|
||||
/// <param name="initializerKeyValues">key value pairs used when initializing instance of generic type</param>
|
||||
/// <returns>awaitable task</returns>
|
||||
protected override async Task<BitmapImage> InitializeTypeAsync(StorageFile baseFile, List<KeyValuePair<string, object>> initializerKeyValues = null!)
|
||||
{
|
||||
using (Stream stream = await baseFile.OpenStreamForReadAsync().ConfigureAwait(false))
|
||||
{
|
||||
return await InitializeTypeAsync(stream, initializerKeyValues).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override-able method that checks whether file is valid or not.
|
||||
/// </summary>
|
||||
/// <param name="file">storage file</param>
|
||||
/// <param name="duration">cache duration</param>
|
||||
/// <param name="treatNullFileAsOutOfDate">option to mark uninitialized file as expired</param>
|
||||
/// <returns>bool indicate whether file has expired or not</returns>
|
||||
protected override async Task<bool> IsFileOutOfDateAsync(StorageFile file, TimeSpan duration, bool treatNullFileAsOutOfDate = true)
|
||||
{
|
||||
if (file == null)
|
||||
{
|
||||
return treatNullFileAsOutOfDate;
|
||||
}
|
||||
|
||||
// Get extended properties.
|
||||
IDictionary<string, object> extraProperties = await file.Properties
|
||||
.RetrievePropertiesAsync(extendedPropertyNames)
|
||||
.AsTask()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Get date-accessed property.
|
||||
object? propValue = extraProperties[DateAccessedProperty];
|
||||
|
||||
if (propValue != null)
|
||||
{
|
||||
DateTimeOffset? lastAccess = propValue as DateTimeOffset?;
|
||||
|
||||
if (lastAccess.HasValue)
|
||||
{
|
||||
return DateTime.Now.Subtract(lastAccess.Value.DateTime) > duration;
|
||||
}
|
||||
}
|
||||
|
||||
BasicProperties properties = await file
|
||||
.GetBasicPropertiesAsync()
|
||||
.AsTask()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return properties.Size == 0 || DateTime.Now.Subtract(properties.DateModified.DateTime) > duration;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ namespace Snap.Hutao.Core;
|
||||
/// <summary>
|
||||
/// 核心环境参数
|
||||
/// </summary>
|
||||
internal static class CoreEnvironment
|
||||
internal static partial class CoreEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前版本
|
||||
@@ -43,3 +43,8 @@ internal static class CoreEnvironment
|
||||
return Md5Convert.ToHexString($"{userName}{machineGuid}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static partial class CoreEnvironment
|
||||
{
|
||||
|
||||
}
|
||||
51
src/Snap.Hutao/Snap.Hutao/Core/Diagnostics/ValueStopwatch.cs
Normal file
51
src/Snap.Hutao/Snap.Hutao/Core/Diagnostics/ValueStopwatch.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace Snap.Hutao.Core.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 值类型的<see cref="Stopwatch"/>
|
||||
/// </summary>
|
||||
internal struct ValueStopwatch
|
||||
{
|
||||
private static readonly double TimestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency;
|
||||
|
||||
private readonly long startTimestamp;
|
||||
|
||||
private ValueStopwatch(long startTimestamp)
|
||||
{
|
||||
this.startTimestamp = startTimestamp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否处于活动状态
|
||||
/// </summary>
|
||||
public bool IsActive => startTimestamp != 0;
|
||||
|
||||
/// <summary>
|
||||
/// 触发一个新的停表
|
||||
/// </summary>
|
||||
/// <returns>一个新的停表实例</returns>
|
||||
public static ValueStopwatch StartNew() => new(Stopwatch.GetTimestamp());
|
||||
|
||||
/// <summary>
|
||||
/// 获取经过的时间
|
||||
/// </summary>
|
||||
/// <returns>经过的时间</returns>
|
||||
/// <exception cref="InvalidOperationException">当前的停表未合理的初始化</exception>
|
||||
public TimeSpan GetElapsedTime()
|
||||
{
|
||||
// Start timestamp can't be zero in an initialized ValueStopwatch.
|
||||
// It would have to be literally the first thing executed when the machine boots to be 0.
|
||||
// So it being 0 is a clear indication of default(ValueStopwatch)
|
||||
Verify.Operation(IsActive, $"An uninitialized, or 'default', {nameof(ValueStopwatch)} cannot be used to get elapsed time.");
|
||||
|
||||
long end = Stopwatch.GetTimestamp();
|
||||
long timestampDelta = end - startTimestamp;
|
||||
long ticks = (long)(TimestampToTicks * timestampDelta);
|
||||
|
||||
return new TimeSpan(ticks);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Snap.Hutao.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="ILoggerFactory"/> class.
|
||||
/// </summary>
|
||||
public static class DatabaseLoggerFactoryExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a debug logger named 'Debug' to the factory.
|
||||
/// </summary>
|
||||
/// <param name="builder">The extension method argument.</param>
|
||||
/// <returns>日志构造器</returns>
|
||||
public static ILoggingBuilder AddDatabase(this ILoggingBuilder builder)
|
||||
{
|
||||
builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, DatebaseLoggerProvider>());
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
87
src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs
Normal file
87
src/Snap.Hutao/Snap.Hutao/Core/Logging/DatebaseLogger.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Context.Database;
|
||||
|
||||
namespace Snap.Hutao.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// A logger that writes messages in the database table
|
||||
/// </summary>
|
||||
internal sealed partial class DatebaseLogger : ILogger
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly LogDbContext logDbContext;
|
||||
private readonly object logDbContextLock;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DatebaseLogger"/> class.
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the logger.</param>
|
||||
/// <param name="logDbContext">应用程序数据库上下文</param>
|
||||
/// <param name="logDbContextLock">上下文锁</param>
|
||||
public DatebaseLogger(string name, LogDbContext logDbContext, object logDbContextLock)
|
||||
{
|
||||
this.name = name;
|
||||
this.logDbContext = logDbContext;
|
||||
this.logDbContextLock = logDbContextLock;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable BeginScope<TState>(TState state)
|
||||
{
|
||||
return new NullScope();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel)
|
||||
{
|
||||
// If the filter is null, everything is enabled
|
||||
return logLevel != LogLevel.None;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string message = Must.NotNull(formatter)(state, exception);
|
||||
|
||||
if (string.IsNullOrEmpty(message))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (logDbContextLock)
|
||||
{
|
||||
logDbContext.Logs.Add(new LogEntry
|
||||
{
|
||||
Category = name,
|
||||
LogLevel = logLevel,
|
||||
EventId = eventId.Id,
|
||||
Message = message,
|
||||
Exception = exception?.ToString(),
|
||||
});
|
||||
|
||||
logDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An empty scope without any logic
|
||||
/// </summary>
|
||||
private struct NullScope : IDisposable
|
||||
{
|
||||
public NullScope()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Snap.Hutao.Context.Database;
|
||||
using Snap.Hutao.Context.FileSystem;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace Snap.Hutao.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// The provider for the <see cref="DebugLogger"/>.
|
||||
/// </summary>
|
||||
[ProviderAlias("Database")]
|
||||
public class DatebaseLoggerProvider : ILoggerProvider
|
||||
{
|
||||
// the provider is created per logger, we don't want to create to much
|
||||
private static volatile LogDbContext? logDbContext;
|
||||
private static readonly object logDbContextLock = new();
|
||||
|
||||
private static LogDbContext LogDbContext
|
||||
{
|
||||
get
|
||||
{
|
||||
if (logDbContext == null)
|
||||
{
|
||||
lock (logDbContextLock)
|
||||
{
|
||||
// prevent re-entry call
|
||||
if (logDbContext == null)
|
||||
{
|
||||
MyDocumentContext myDocument = new(new());
|
||||
logDbContext = LogDbContext.Create($"Data Source={myDocument.Locate("Log.db")}");
|
||||
if (logDbContext.Database.GetPendingMigrations().Any())
|
||||
{
|
||||
Debug.WriteLine("Performing LogDbContext Migrations");
|
||||
logDbContext.Database.Migrate();
|
||||
}
|
||||
|
||||
logDbContext.Logs.RemoveRange(logDbContext.Logs);
|
||||
logDbContext.SaveChanges();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return logDbContext;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ILogger CreateLogger(string name)
|
||||
{
|
||||
return new DatebaseLogger(name, LogDbContext, logDbContextLock);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -8,23 +8,59 @@ namespace Snap.Hutao.Core.Logging;
|
||||
/// </summary>
|
||||
internal static class EventIds
|
||||
{
|
||||
// 异常
|
||||
|
||||
/// <summary>
|
||||
/// 未经处理的异常
|
||||
/// </summary>
|
||||
public static readonly EventId UnhandledException = 100000;
|
||||
|
||||
/// <summary>
|
||||
/// Forget任务执行异常
|
||||
/// </summary>
|
||||
public static readonly EventId TaskException = 100001;
|
||||
|
||||
/// <summary>
|
||||
/// 异步命令执行异常
|
||||
/// </summary>
|
||||
public static readonly EventId AsyncCommandException = 100002;
|
||||
|
||||
/// <summary>
|
||||
/// WebView2环境异常
|
||||
/// </summary>
|
||||
public static readonly EventId WebView2EnvironmentException = 100003;
|
||||
|
||||
// 服务
|
||||
|
||||
/// <summary>
|
||||
/// 导航历史
|
||||
/// </summary>
|
||||
public static readonly EventId NavigationHistory = 100100;
|
||||
|
||||
/// <summary>
|
||||
/// 导航失败
|
||||
/// </summary>
|
||||
public static readonly EventId NavigationFailed = new(100000, nameof(NavigationFailed));
|
||||
public static readonly EventId NavigationFailed = 100101;
|
||||
|
||||
/// <summary>
|
||||
/// 未处理的异常
|
||||
/// 元数据初始化过程
|
||||
/// </summary>
|
||||
public static readonly EventId UnhandledException = new(100001, nameof(UnhandledException));
|
||||
public static readonly EventId MetadataInitialization = 100110;
|
||||
|
||||
/// <summary>
|
||||
/// Forget任务执行异常
|
||||
/// 元数据文件MD5检查
|
||||
/// </summary>
|
||||
public static readonly EventId TaskException = new(100002, nameof(TaskException));
|
||||
public static readonly EventId MetadataFileMD5Check = 100111;
|
||||
|
||||
// 杂项
|
||||
|
||||
/// <summary>
|
||||
/// Forget任务执行异常
|
||||
/// 杂项Log
|
||||
/// </summary>
|
||||
public static readonly EventId AsyncCommandException = new(100003, nameof(AsyncCommandException));
|
||||
public static readonly EventId CommonLog = 200000;
|
||||
|
||||
/// <summary>
|
||||
/// 背景状态
|
||||
/// </summary>
|
||||
public static readonly EventId BackdropState = 200001;
|
||||
}
|
||||
47
src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs
Normal file
47
src/Snap.Hutao/Snap.Hutao/Core/Logging/LogEntry.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Context.Database;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Snap.Hutao.Core.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库日志入口点
|
||||
/// </summary>
|
||||
[Table("logs")]
|
||||
public class LogEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// 内部Id
|
||||
/// </summary>
|
||||
[Key]
|
||||
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public Guid InnerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 类别
|
||||
/// </summary>
|
||||
public string Category { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 日志等级
|
||||
/// </summary>
|
||||
public LogLevel LogLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 事件Id
|
||||
/// </summary>
|
||||
public int EventId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 消息
|
||||
/// </summary>
|
||||
public string Message { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 可能的异常
|
||||
/// </summary>
|
||||
public string? Exception { get; set; }
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Snap.Hutao.Core.Logging;
|
||||
|
||||
namespace Snap.Hutao.Core;
|
||||
|
||||
@@ -36,7 +37,8 @@ internal abstract class WebView2Helper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Ioc.Default.GetRequiredService<ILogger<WebView2Helper>>().LogError(ex, "WebView2 运行时未安装");
|
||||
ILogger<WebView2Helper> logger = Ioc.Default.GetRequiredService<ILogger<WebView2Helper>>();
|
||||
logger.LogError(EventIds.WebView2EnvironmentException, ex, "WebView2 运行时未安装");
|
||||
isSupported = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ internal static class CompositionExtensions
|
||||
/// <param name="compositor">合成器</param>
|
||||
/// <param name="sourceBrush">源</param>
|
||||
/// <returns>合成效果画刷</returns>
|
||||
public static CompositionEffectBrush CompositeLuminanceToAlphaEffectBrush(this Compositor compositor, CompositionBrush sourceBrush)
|
||||
public static CompositionEffectBrush CompositeLuminanceToAlphaEffectBrush(
|
||||
this Compositor compositor,
|
||||
CompositionBrush sourceBrush)
|
||||
{
|
||||
LuminanceToAlphaEffect effect = new()
|
||||
{
|
||||
@@ -150,5 +152,24 @@ internal static class CompositionExtensions
|
||||
return brush;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个新的蒙版画刷
|
||||
/// </summary>
|
||||
/// <param name="compositor">合成器</param>
|
||||
/// <param name="source">源</param>
|
||||
/// <param name="mask">蒙版</param>
|
||||
/// <returns>蒙版画刷</returns>
|
||||
public static CompositionMaskBrush CompositeMaskBrush(
|
||||
this Compositor compositor,
|
||||
CompositionBrush source,
|
||||
CompositionBrush mask)
|
||||
{
|
||||
CompositionMaskBrush brush = compositor.CreateMaskBrush();
|
||||
brush.Source = source;
|
||||
brush.Mask = mask;
|
||||
|
||||
return brush;
|
||||
}
|
||||
|
||||
public record struct GradientStop(float Offset, Windows.UI.Color Color);
|
||||
}
|
||||
|
||||
@@ -9,19 +9,8 @@ namespace Snap.Hutao.Extension;
|
||||
/// <summary>
|
||||
/// <see cref="IEnumerable{T}"/> 扩展
|
||||
/// </summary>
|
||||
public static class EnumerableExtensions
|
||||
public static partial class EnumerableExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 将源转换为仅包含单个元素的枚举
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">源的类型</typeparam>
|
||||
/// <param name="source">源</param>
|
||||
/// <returns>集合</returns>
|
||||
public static IEnumerable<TSource> Enumerate<TSource>(this TSource source)
|
||||
{
|
||||
yield return source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 计数
|
||||
/// </summary>
|
||||
@@ -66,6 +55,17 @@ public static class EnumerableExtensions
|
||||
return source ?? new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将源转换为仅包含单个元素的枚举
|
||||
/// </summary>
|
||||
/// <typeparam name="TSource">源的类型</typeparam>
|
||||
/// <param name="source">源</param>
|
||||
/// <returns>集合</returns>
|
||||
public static IEnumerable<TSource> Enumerate<TSource>(this TSource source)
|
||||
{
|
||||
yield return source;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 寻找枚举中唯一的值,找不到时
|
||||
/// 回退到首个或默认值
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
namespace Snap.Hutao.Extension;
|
||||
|
||||
/// <summary>
|
||||
/// 内存缓存扩展
|
||||
/// </summary>
|
||||
public static class MemoryCacheExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取缓存键名称
|
||||
/// </summary>
|
||||
/// <param name="className">类名</param>
|
||||
/// <param name="propertyName">属性名</param>
|
||||
/// <returns>缓存</returns>
|
||||
public static string GetCacheKey(string className, string propertyName)
|
||||
{
|
||||
return $"{className}.Cache.{propertyName}";
|
||||
}
|
||||
}
|
||||
22
src/Snap.Hutao/Snap.Hutao/Extension/ObjectExtensions.cs
Normal file
22
src/Snap.Hutao/Snap.Hutao/Extension/ObjectExtensions.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
namespace Snap.Hutao.Extension;
|
||||
|
||||
/// <summary>
|
||||
/// 对象扩展
|
||||
/// </summary>
|
||||
public static class ObjectExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// <see langword="as"/> 的链式调用扩展
|
||||
/// </summary>
|
||||
/// <typeparam name="T">目标转换类型</typeparam>
|
||||
/// <param name="obj">对象</param>
|
||||
/// <returns>转换类型后的对象</returns>
|
||||
public static T? As<T>(this object? obj)
|
||||
where T : class
|
||||
{
|
||||
return obj as T;
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,16 @@ internal static class ReflectionExtension
|
||||
action.Invoke(type);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按字段名称设置值
|
||||
/// </summary>
|
||||
/// <param name="obj">对象</param>
|
||||
/// <param name="fieldName">字段名称</param>
|
||||
/// <param name="value">值</param>
|
||||
public static void SetPrivateFieldValueByName(this object obj, string fieldName, object? value)
|
||||
{
|
||||
FieldInfo? fieldInfo = obj.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
fieldInfo?.SetValue(obj, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,28 +8,90 @@ namespace Snap.Hutao.Extension;
|
||||
/// <summary>
|
||||
/// 任务扩展
|
||||
/// </summary>
|
||||
[SuppressMessage("", "VSTHRD003")]
|
||||
[SuppressMessage("", "VSTHRD100")]
|
||||
public static class TaskExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 安全的触发任务
|
||||
/// </summary>
|
||||
/// <param name="task">任务</param>
|
||||
/// <param name="continueOnCapturedContext">是否在捕获的上下文中继续执行</param>
|
||||
/// <param name="logger">日志器</param>
|
||||
[SuppressMessage("", "VSTHRD003")]
|
||||
[SuppressMessage("", "VSTHRD100")]
|
||||
public static async void SafeForget(this Task task, bool continueOnCapturedContext = true, ILogger? logger = null)
|
||||
public static async void SafeForget(this Task task)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task.ConfigureAwait(continueOnCapturedContext);
|
||||
await task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全的触发任务
|
||||
/// </summary>
|
||||
/// <param name="task">任务</param>
|
||||
/// <param name="logger">日志器</param>
|
||||
public static async void SafeForget(this Task task, ILogger? logger = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger?.LogError(EventIds.TaskException, e, "{caller}:{exception}", nameof(SafeForget), e.GetBaseException());
|
||||
logger?.LogError(EventIds.TaskException, e, "{caller}:\r\n{exception}", nameof(SafeForget), e.GetBaseException());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全的触发任务
|
||||
/// </summary>
|
||||
/// <param name="task">任务</param>
|
||||
/// <param name="logger">日志器</param>
|
||||
/// <param name="onException">发生异常时调用</param>
|
||||
public static async void SafeForget(this Task task, ILogger? logger = null, Action<Exception>? onException = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
// Do nothing
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger?.LogError(EventIds.TaskException, e, "{caller}:\r\n{exception}", nameof(SafeForget), e.GetBaseException());
|
||||
onException?.Invoke(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 安全的触发任务
|
||||
/// </summary>
|
||||
/// <param name="task">任务</param>
|
||||
/// <param name="logger">日志器</param>
|
||||
/// <param name="onCanceled">任务取消时调用</param>
|
||||
/// <param name="onException">发生异常时调用</param>
|
||||
public static async void SafeForget(this Task task, ILogger? logger = null, Action? onCanceled = null, Action<Exception>? onException = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await task;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
onCanceled?.Invoke();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger?.LogError(EventIds.TaskException, e, "{caller}:\r\n{exception}", nameof(SafeForget), e.GetBaseException());
|
||||
onException?.Invoke(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,4 @@ public static class TupleExtensions
|
||||
{
|
||||
return new Dictionary<TKey, TValue>(1) { { tuple.Key, tuple.Value } };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ internal class AsyncRelayCommandFactory : IAsyncRelayCommandFactory
|
||||
if (asyncRelayCommand.ExecutionTask?.Exception is AggregateException exception)
|
||||
{
|
||||
Exception baseException = exception.GetBaseException();
|
||||
logger.LogError(EventIds.AsyncCommandException, baseException, "异步命令发生了错误");
|
||||
logger.LogError(EventIds.AsyncCommandException, baseException, "{name} Exception", nameof(asyncRelayCommand));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ internal static class IocConfiguration
|
||||
{
|
||||
if (context.Database.GetPendingMigrations().Any())
|
||||
{
|
||||
Debug.WriteLine("Performing Migrations");
|
||||
Debug.WriteLine("Performing AppDbContext Migrations");
|
||||
context.Database.Migrate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Snap.Hutao.Core.Caching;
|
||||
using Snap.Hutao.Service.Metadata;
|
||||
using Snap.Hutao.Web.Enka;
|
||||
using Snap.Hutao.Web.Hoyolab.Bbs.User;
|
||||
@@ -29,6 +30,7 @@ internal static class IocHttpClientConfiguration
|
||||
{
|
||||
// services
|
||||
services.AddHttpClient<MetadataService>(DefaultConfiguration);
|
||||
// services.AddHttpClient<ImageCache>(DefaultConfiguration);
|
||||
|
||||
// normal clients
|
||||
services.AddHttpClient<AnnouncementClient>(DefaultConfiguration);
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Snap.Hutao.Context.Database;
|
||||
using Snap.Hutao.Control.HostBackdrop;
|
||||
using Snap.Hutao.Core.Logging;
|
||||
using Snap.Hutao.Core.Setting;
|
||||
using Snap.Hutao.Core.Win32;
|
||||
using System.Drawing;
|
||||
using System.Runtime.InteropServices;
|
||||
using WinRT.Interop;
|
||||
|
||||
@@ -47,32 +47,7 @@ public sealed partial class MainWindow : Window
|
||||
return new RECT(left, top, right, bottom);
|
||||
}
|
||||
|
||||
private void InitializeWindow()
|
||||
{
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(TitleBarView.DragableArea);
|
||||
|
||||
RECT rect = RetriveWindowRect();
|
||||
if (!rect.Size.IsEmpty)
|
||||
{
|
||||
WINDOWPLACEMENT windowPlacement = new()
|
||||
{
|
||||
Length = Marshal.SizeOf<WINDOWPLACEMENT>(),
|
||||
MaxPosition = new Point(-1, -1),
|
||||
NormalPosition = rect,
|
||||
ShowCmd = ShowWindowCommand.Normal,
|
||||
};
|
||||
|
||||
User32.SetWindowPlacement(handle, ref windowPlacement);
|
||||
}
|
||||
|
||||
User32.SetWindowText(handle, "胡桃");
|
||||
|
||||
bool micaApplied = new SystemBackdrop(this).TrySetBackdrop();
|
||||
logger.LogInformation("{name} 设置{result}", nameof(SystemBackdrop), micaApplied ? "成功" : "失败");
|
||||
}
|
||||
|
||||
private void SaveWindowRect()
|
||||
private static void SaveWindowRect(IntPtr handle)
|
||||
{
|
||||
WINDOWPLACEMENT windowPlacement = WINDOWPLACEMENT.Default;
|
||||
User32.GetWindowPlacement(handle, ref windowPlacement);
|
||||
@@ -83,12 +58,36 @@ public sealed partial class MainWindow : Window
|
||||
LocalSetting.SetValueType(SettingKeys.WindowBottom, windowPlacement.NormalPosition.Bottom);
|
||||
}
|
||||
|
||||
private void InitializeWindow()
|
||||
{
|
||||
ExtendsContentIntoTitleBar = true;
|
||||
SetTitleBar(TitleBarView.DragableArea);
|
||||
|
||||
User32.SetWindowText(handle, "胡桃");
|
||||
RECT rect = RetriveWindowRect();
|
||||
if (!rect.Size.IsEmpty)
|
||||
{
|
||||
WINDOWPLACEMENT windowPlacement = new()
|
||||
{
|
||||
Length = Marshal.SizeOf<WINDOWPLACEMENT>(),
|
||||
MaxPosition = new POINT(-1, -1),
|
||||
NormalPosition = rect,
|
||||
ShowCmd = ShowWindowCommand.Normal,
|
||||
};
|
||||
|
||||
User32.SetWindowPlacement(handle, ref windowPlacement);
|
||||
}
|
||||
|
||||
bool micaApplied = new SystemBackdrop(this).TrySetBackdrop();
|
||||
logger.LogInformation(EventIds.BackdropState, "Apply {name} : {result}", nameof(SystemBackdrop), micaApplied ? "succeed" : "failed");
|
||||
}
|
||||
|
||||
private void MainWindowClosed(object sender, WindowEventArgs args)
|
||||
{
|
||||
SaveWindowRect();
|
||||
SaveWindowRect(handle);
|
||||
|
||||
// save datebase
|
||||
int changes = appDbContext.SaveChanges();
|
||||
Verify.Operation(changes == 0, "存在可避免的未经处理的数据库更改");
|
||||
Verify.Operation(changes == 0, "存在未经处理的数据库记录更改");
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,13 @@ using Snap.Hutao.Context.Database;
|
||||
namespace Snap.Hutao.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20220618110357_SettingAndUser")]
|
||||
partial class SettingAndUser
|
||||
[Migration("20220720121642_Init")]
|
||||
partial class Init
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
|
||||
|
||||
modelBuilder.Entity("Snap.Hutao.Model.Entity.SettingEntry", b =>
|
||||
{
|
||||
@@ -1,16 +1,11 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Snap.Hutao.Migrations
|
||||
{
|
||||
[SuppressMessage("", "SA1413")]
|
||||
[SuppressMessage("", "SA1600")]
|
||||
[SuppressMessage("", "SA1601")]
|
||||
public partial class SettingAndUser : Migration
|
||||
public partial class Init : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
@@ -13,7 +13,7 @@ namespace Snap.Hutao.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
|
||||
|
||||
modelBuilder.Entity("Snap.Hutao.Model.Entity.SettingEntry", b =>
|
||||
{
|
||||
|
||||
52
src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220720121521_Logs.Designer.cs
generated
Normal file
52
src/Snap.Hutao/Snap.Hutao/Migrations/LogDb/20220720121521_Logs.Designer.cs
generated
Normal file
@@ -0,0 +1,52 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Snap.Hutao.Context.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Snap.Hutao.Migrations.LogDb
|
||||
{
|
||||
[DbContext(typeof(LogDbContext))]
|
||||
[Migration("20220720121521_Logs")]
|
||||
partial class Logs
|
||||
{
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
|
||||
|
||||
modelBuilder.Entity("Snap.Hutao.Core.Logging.LogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("InnerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Exception")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LogLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("InnerId");
|
||||
|
||||
b.ToTable("logs");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Snap.Hutao.Migrations.LogDb
|
||||
{
|
||||
public partial class Logs : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "logs",
|
||||
columns: table => new
|
||||
{
|
||||
InnerId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Category = table.Column<string>(type: "TEXT", nullable: false),
|
||||
LogLevel = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
EventId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Message = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Exception = table.Column<string>(type: "TEXT", nullable: true),
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_logs", x => x.InnerId);
|
||||
});
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "logs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// <auto-generated />
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Snap.Hutao.Context.Database;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Snap.Hutao.Migrations.LogDb
|
||||
{
|
||||
[DbContext(typeof(LogDbContext))]
|
||||
partial class LogDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "6.0.7");
|
||||
|
||||
modelBuilder.Entity("Snap.Hutao.Core.Logging.LogEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("InnerId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("EventId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Exception")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LogLevel")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("InnerId");
|
||||
|
||||
b.ToTable("logs");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,19 @@ internal class AvatarNameCardPicConverter : IValueConverter
|
||||
}
|
||||
|
||||
Avatar.Avatar avatar = (Avatar.Avatar)value;
|
||||
string avatarName = avatar.Icon[14..];
|
||||
string avatarName = ReplaceSpecialCaseNaming(avatar.Icon[14..]);
|
||||
return new Uri(string.Format(BaseUrl, avatarName));
|
||||
}
|
||||
|
||||
private static string ReplaceSpecialCaseNaming(string avatarName)
|
||||
{
|
||||
return avatarName switch
|
||||
{
|
||||
"Yae" => "Yae1",
|
||||
_ => avatarName,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public object ConvertBack(object value, Type targetType, object parameter, string language)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<Identity
|
||||
Name="7f0db578-026f-4e0b-a75b-d5d06bb0a74d"
|
||||
Publisher="CN=DGP Studio"
|
||||
Version="1.0.13.0" />
|
||||
Version="1.0.16.0" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>胡桃</DisplayName>
|
||||
|
||||
@@ -13,6 +13,13 @@ namespace Snap.Hutao;
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
private static volatile DispatcherQueue? dispatcherQueue;
|
||||
|
||||
/// <summary>
|
||||
/// 主线程调度器队列
|
||||
/// </summary>
|
||||
public static DispatcherQueue UIDispatcherQueue => Must.NotNull(dispatcherQueue!);
|
||||
|
||||
[DllImport("Microsoft.ui.xaml.dll")]
|
||||
private static extern void XamlCheckProcessRequirements();
|
||||
|
||||
@@ -24,7 +31,8 @@ public static class Program
|
||||
|
||||
Application.Start(p =>
|
||||
{
|
||||
SynchronizationContext context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
|
||||
dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
SynchronizationContext context = new DispatcherQueueSynchronizationContext(dispatcherQueue);
|
||||
SynchronizationContext.SetSynchronizationContext(context);
|
||||
_ = new App();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Snap.Hutao.Extension;
|
||||
using Snap.Hutao.Service.Abstraction;
|
||||
using Snap.Hutao.Web.Hoyolab.Hk4e.Common.Announcement;
|
||||
using System.Collections.Generic;
|
||||
@@ -15,7 +14,7 @@ namespace Snap.Hutao.Service;
|
||||
[Injection(InjectAs.Transient, typeof(IAnnouncementService))]
|
||||
internal class AnnouncementService : IAnnouncementService
|
||||
{
|
||||
private static readonly string CacheKey = MemoryCacheExtensions.GetCacheKey(nameof(AnnouncementService), nameof(AnnouncementWrapper));
|
||||
private static readonly string CacheKey = $"{nameof(AnnouncementService)}.Cache.{nameof(AnnouncementWrapper)}";
|
||||
|
||||
private readonly AnnouncementClient announcementClient;
|
||||
private readonly IMemoryCache memoryCache;
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Snap.Hutao.Context.FileSystem;
|
||||
using Snap.Hutao.Core.Abstraction;
|
||||
using Snap.Hutao.Core.Logging;
|
||||
using Snap.Hutao.Extension;
|
||||
using Snap.Hutao.Model.Metadata.Achievement;
|
||||
using Snap.Hutao.Model.Metadata.Avatar;
|
||||
using Snap.Hutao.Model.Metadata.Reliquary;
|
||||
@@ -78,13 +80,14 @@ internal class MetadataService : IMetadataService, IMetadataInitializer, ISuppor
|
||||
/// <inheritdoc/>
|
||||
public async Task InitializeInternalAsync(CancellationToken token = default)
|
||||
{
|
||||
logger.LogInformation("元数据初始化开始");
|
||||
logger.LogInformation(EventIds.MetadataInitialization, "Metadata initializaion begin");
|
||||
|
||||
IsInitialized = await TryUpdateMetadataAsync(token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
initializeCompletionSource.SetResult();
|
||||
logger.LogInformation("元数据初始化完成");
|
||||
|
||||
logger.LogInformation(EventIds.MetadataInitialization, "Metadata initializaion completed");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -162,12 +165,11 @@ internal class MetadataService : IMetadataService, IMetadataInitializer, ISuppor
|
||||
/// <param name="metaMd5Map">元数据校验表</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>令牌</returns>
|
||||
private async Task CheckMetadataAsync(IDictionary<string, string> metaMd5Map, CancellationToken token)
|
||||
private Task CheckMetadataAsync(IDictionary<string, string> metaMd5Map, CancellationToken token)
|
||||
{
|
||||
// TODO: Make this foreach async to imporve speed
|
||||
// enumerate files and compare md5
|
||||
foreach ((string fileName, string md5) in metaMd5Map)
|
||||
return Parallel.ForEachAsync(metaMd5Map, token, async (pair, token) =>
|
||||
{
|
||||
(string fileName, string md5) = pair;
|
||||
string fileFullName = $"{fileName}.json";
|
||||
bool skip = false;
|
||||
|
||||
@@ -179,12 +181,12 @@ internal class MetadataService : IMetadataService, IMetadataInitializer, ISuppor
|
||||
|
||||
if (!skip)
|
||||
{
|
||||
logger.LogInformation("{file} 文件 MD5 不匹配", fileFullName);
|
||||
logger.LogInformation(EventIds.MetadataFileMD5Check, "MD5 of {file} not matched", fileFullName);
|
||||
|
||||
await DownloadMetadataAsync(fileFullName, token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<string> GetFileMd5Async(string fileFullName, CancellationToken token)
|
||||
@@ -212,15 +214,13 @@ internal class MetadataService : IMetadataService, IMetadataInitializer, ISuppor
|
||||
{
|
||||
while (await streamReader.ReadLineAsync().ConfigureAwait(false) is string line)
|
||||
{
|
||||
await (streamReader.EndOfStream
|
||||
? streamWriter.WriteAsync(line) // Don't append the last line
|
||||
: streamWriter.WriteLineAsync(line))
|
||||
.ConfigureAwait(false);
|
||||
Func<string?, Task> writeMethod = streamReader.EndOfStream ? streamWriter.WriteAsync : streamWriter.WriteLineAsync;
|
||||
await writeMethod(line).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("{file} 下载完成", fileFullName);
|
||||
logger.LogInformation("Download {file} completed", fileFullName);
|
||||
}
|
||||
|
||||
private async ValueTask<T> GetMetadataAsync<T>(string fileName, CancellationToken token)
|
||||
|
||||
@@ -103,6 +103,7 @@ internal class NavigationService : INavigationService
|
||||
|
||||
if (currentType == pageType)
|
||||
{
|
||||
logger.LogInformation(EventIds.NavigationHistory, "Navigate to {pageType} : succeed, already in", pageType);
|
||||
return NavigationResult.AlreadyNavigatedTo;
|
||||
}
|
||||
|
||||
@@ -112,15 +113,14 @@ internal class NavigationService : INavigationService
|
||||
try
|
||||
{
|
||||
navigated = Frame?.Navigate(pageType, data) ?? false;
|
||||
logger.LogInformation(EventIds.NavigationHistory, "Navigate to {pageType} : {result}", pageType, navigated ? "succeed" : "failed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(EventIds.NavigationFailed, ex, "导航到指定页面时发生了错误");
|
||||
logger.LogError(EventIds.NavigationFailed, ex, "An error occurred while navigating to {pageType}", pageType);
|
||||
infoBarService.Error(ex);
|
||||
}
|
||||
|
||||
logger.LogInformation("Navigate to {pageType}:{result}", pageType, navigated ? "succeed" : "failed");
|
||||
|
||||
// 首次导航失败时使属性持续保存为false
|
||||
HasEverNavigated = HasEverNavigated || navigated;
|
||||
return navigated ? NavigationResult.Succeed : NavigationResult.Failed;
|
||||
@@ -143,7 +143,9 @@ internal class NavigationService : INavigationService
|
||||
{
|
||||
try
|
||||
{
|
||||
await data.WaitForCompletionAsync().ConfigureAwait(false);
|
||||
await data
|
||||
.WaitForCompletionAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
<Manifest Include="$(ApplicationManifest)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SettingsUI\SettingsUI.csproj" />
|
||||
<ProjectReference Include="..\Snap.Hutao.SourceGeneration\Snap.Hutao.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Defining the "Msix" ProjectCapability here allows the Single-project MSIX Packaging
|
||||
Tools extension to be activated for this project even if the Windows App SDK Nuget
|
||||
package has not yet been restored -->
|
||||
@@ -139,10 +144,6 @@
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
</Page>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SettingsUI\SettingsUI.csproj" />
|
||||
<ProjectReference Include="..\Snap.Hutao.SourceGeneration\Snap.Hutao.SourceGeneration.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Page Update="View\Page\WikiAvatarPage.xaml">
|
||||
<Generator>MSBuild:Compile</Generator>
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace Snap.Hutao.View.Helper;
|
||||
public sealed class NavHelper
|
||||
{
|
||||
private static readonly DependencyProperty NavigateToProperty = Property<NavHelper>.Attach<Type>("NavigateTo");
|
||||
private static readonly DependencyProperty ExtraDataProperty = Property<NavHelper>.Attach<INavigationData>("ExtraData");
|
||||
private static readonly DependencyProperty ExtraDataProperty = Property<NavHelper>.Attach<object>("ExtraData");
|
||||
|
||||
/// <summary>
|
||||
/// 获取导航项的目标页面类型
|
||||
@@ -41,9 +41,9 @@ public sealed class NavHelper
|
||||
/// </summary>
|
||||
/// <param name="item">待获取的导航项</param>
|
||||
/// <returns>目标页面类型的额外数据</returns>
|
||||
public static INavigationData? GetExtraData(NavigationViewItem? item)
|
||||
public static object? GetExtraData(NavigationViewItem? item)
|
||||
{
|
||||
return item?.GetValue(ExtraDataProperty) as INavigationData;
|
||||
return item?.GetValue(ExtraDataProperty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -51,7 +51,7 @@ public sealed class NavHelper
|
||||
/// </summary>
|
||||
/// <param name="item">待设置的导航项</param>
|
||||
/// <param name="value">新的目标页面类型</param>
|
||||
public static void SetExtraData(NavigationViewItem item, INavigationData value)
|
||||
public static void SetExtraData(NavigationViewItem item, object value)
|
||||
{
|
||||
item.SetValue(ExtraDataProperty, value);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<UserControl
|
||||
x:Class="Snap.Hutao.View.MainView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:helper="using:Snap.Hutao.View.Helper"
|
||||
xmlns:page="using:Snap.Hutao.View.Page"
|
||||
xmlns:view="using:Snap.Hutao.View"
|
||||
xmlns:cwu="using:CommunityToolkit.WinUI.UI"
|
||||
xmlns:shv="using:Snap.Hutao.View"
|
||||
xmlns:shvh="using:Snap.Hutao.View.Helper"
|
||||
xmlns:shvp="using:Snap.Hutao.View.Page"
|
||||
mc:Ignorable="d">
|
||||
<UserControl.Resources>
|
||||
<Thickness x:Key="NavigationViewContentMargin">0,44,0,0</Thickness>
|
||||
@@ -20,40 +21,31 @@
|
||||
ExpandedModeThresholdWidth="16"
|
||||
IsPaneOpen="True"
|
||||
IsBackEnabled="{Binding ElementName=ContentFrame,Path=CanGoBack}">
|
||||
|
||||
<NavigationView.MenuItems>
|
||||
|
||||
<NavigationViewItem Content="活动" helper:NavHelper.NavigateTo="page:AnnouncementPage">
|
||||
<NavigationViewItem.Icon>
|
||||
<BitmapIcon
|
||||
UriSource="ms-appx:///Resource/Icon/UI_BtnIcon_ActivityEntry.png"/>
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
|
||||
<NavigationViewItem Content="成就" helper:NavHelper.NavigateTo="page:AchievementPage">
|
||||
<NavigationViewItem.Icon>
|
||||
<BitmapIcon UriSource="ms-appx:///Resource/Icon/UI_Icon_Achievement.png"/>
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
|
||||
<NavigationViewItem Content="角色" helper:NavHelper.NavigateTo="page:WikiAvatarPage">
|
||||
<NavigationViewItem.Icon>
|
||||
<BitmapIcon UriSource="ms-appx:///Resource/Icon/UI_BagTabIcon_Avatar.png"/>
|
||||
</NavigationViewItem.Icon>
|
||||
</NavigationViewItem>
|
||||
<NavigationViewItem
|
||||
Content="活动"
|
||||
shvh:NavHelper.NavigateTo="shvp:AnnouncementPage"
|
||||
Icon="{cwu:BitmapIcon Source=ms-appx:///Resource/Icon/UI_BtnIcon_ActivityEntry.png}"/>
|
||||
|
||||
<NavigationViewItem
|
||||
Content="成就"
|
||||
shvh:NavHelper.NavigateTo="shvp:AchievementPage"
|
||||
Icon="{cwu:BitmapIcon Source=ms-appx:///Resource/Icon/UI_Icon_Achievement.png}"/>
|
||||
|
||||
<NavigationViewItem
|
||||
Content="角色"
|
||||
shvh:NavHelper.NavigateTo="shvp:WikiAvatarPage"
|
||||
Icon="{cwu:BitmapIcon Source=ms-appx:///Resource/Icon/UI_BagTabIcon_Avatar.png}"/>
|
||||
</NavigationView.MenuItems>
|
||||
|
||||
<NavigationView.PaneFooter>
|
||||
<view:UserView IsExpanded="{Binding ElementName=NavView,Path=IsPaneOpen}"/>
|
||||
<shv:UserView IsExpanded="{Binding ElementName=NavView,Path=IsPaneOpen}"/>
|
||||
</NavigationView.PaneFooter>
|
||||
|
||||
<Frame x:Name="ContentFrame">
|
||||
<Frame.ContentTransitions>
|
||||
<TransitionCollection>
|
||||
<NavigationThemeTransition>
|
||||
<DrillInNavigationTransitionInfo/>
|
||||
</NavigationThemeTransition>
|
||||
<NavigationThemeTransition/>
|
||||
</TransitionCollection>
|
||||
</Frame.ContentTransitions>
|
||||
</Frame>
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
<shcc:CancellablePage
|
||||
x:Class="Snap.Hutao.View.Page.AchievementPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Snap.Hutao.View.Page"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
xmlns:mxi="using:Microsoft.Xaml.Interactivity"
|
||||
xmlns:mxic="using:Microsoft.Xaml.Interactions.Core"
|
||||
xmlns:shcc="using:Snap.Hutao.Control.Cancellable"
|
||||
xmlns:settings="using:SettingsUI.Controls"
|
||||
xmlns:sc="using:SettingsUI.Controls"
|
||||
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
|
||||
xmlns:shcc="using:Snap.Hutao.Control.Cancellable"
|
||||
mc:Ignorable="d"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
<mxi:Interaction.Behaviors>
|
||||
<mxic:EventTriggerBehavior EventName="Loaded">
|
||||
<mxic:InvokeCommandAction Command="{Binding OpenUICommand}"/>
|
||||
</mxic:EventTriggerBehavior>
|
||||
<shcb:InvokeCommandOnLoadedBehavior Command="{Binding OpenUICommand}"/>
|
||||
</mxi:Interaction.Behaviors>
|
||||
<Grid>
|
||||
<ScrollViewer>
|
||||
@@ -23,7 +20,7 @@
|
||||
ItemsSource="{Binding AchievementsView}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<settings:Setting
|
||||
<sc:Setting
|
||||
Margin="0,12,0,0"
|
||||
Header="{Binding Title}"
|
||||
Description="{Binding Description}"/>
|
||||
|
||||
@@ -5,6 +5,17 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
<WebView2 x:Name="WebView"/>
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}"
|
||||
ActualThemeChanged="PageActualThemeChanged">
|
||||
<Page.Transitions>
|
||||
<TransitionCollection>
|
||||
<NavigationThemeTransition>
|
||||
<DrillInNavigationTransitionInfo/>
|
||||
</NavigationThemeTransition>
|
||||
</TransitionCollection>
|
||||
</Page.Transitions>
|
||||
<WebView2
|
||||
x:Name="WebView"
|
||||
IsRightTapEnabled="False"
|
||||
DefaultBackgroundColor="Transparent"/>
|
||||
</Page>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
using Microsoft.VisualStudio.Threading;
|
||||
using Snap.Hutao.Core;
|
||||
@@ -14,6 +15,18 @@ namespace Snap.Hutao.View.Page;
|
||||
/// </summary>
|
||||
public sealed partial class AnnouncementContentPage : Microsoft.UI.Xaml.Controls.Page
|
||||
{
|
||||
private const string LightColor1 = "color:rgba(255,255,255,1)";
|
||||
private const string LightColor2 = "color:rgba(238,238,238,1)";
|
||||
private const string LightColor3 = "color:rgba(204,204,204,1)";
|
||||
private const string LightColor4 = "color:rgba(198,196,191,1)";
|
||||
private const string LightColor5 = "color:rgba(170,170,170,1)";
|
||||
|
||||
private const string DarkColor1 = "color:rgba(0,0,0,1)";
|
||||
private const string DarkColor2 = "color:rgba(17,17,17,1)";
|
||||
private const string DarkColor3 = "color:rgba(51,51,51,1)";
|
||||
private const string DarkColor4 = "color:rgba(57,59,64,1)";
|
||||
private const string DarkColor5 = "color:rgba(85,85,85,1)";
|
||||
|
||||
// support click open browser.
|
||||
private const string MihoyoSDKDefinition =
|
||||
@"window.miHoYoGameJSSDK = {
|
||||
@@ -49,7 +62,7 @@ openInWebview: function(url){ location.href = url }}";
|
||||
await WebView.EnsureCoreWebView2Async();
|
||||
|
||||
await WebView.CoreWebView2.AddScriptToExecuteOnDocumentCreatedAsync(MihoyoSDKDefinition);
|
||||
WebView.CoreWebView2.WebMessageReceived += (_, e) => Browser.Open(e.TryGetWebMessageAsString);
|
||||
WebView.CoreWebView2.WebMessageReceived += (_, e) => Browser.Open(e.TryGetWebMessageAsString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -57,7 +70,39 @@ openInWebview: function(url){ location.href = url }}";
|
||||
return;
|
||||
}
|
||||
|
||||
WebView.NavigateToString(targetContent);
|
||||
WebView.NavigateToString(ReplaceForeground(targetContent, ActualTheme));
|
||||
data.NotifyNavigationCompleted();
|
||||
}
|
||||
|
||||
private void PageActualThemeChanged(FrameworkElement sender, object args)
|
||||
{
|
||||
WebView.NavigateToString(ReplaceForeground(targetContent, ActualTheme));
|
||||
}
|
||||
|
||||
private string? ReplaceForeground(string? rawContent, ElementTheme theme)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawContent))
|
||||
{
|
||||
return rawContent;
|
||||
}
|
||||
|
||||
bool isDarkMode = theme switch
|
||||
{
|
||||
ElementTheme.Default => App.Current.RequestedTheme == ApplicationTheme.Dark,
|
||||
ElementTheme.Dark => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if (isDarkMode)
|
||||
{
|
||||
rawContent = rawContent
|
||||
.Replace(DarkColor5, LightColor5)
|
||||
.Replace(DarkColor4, LightColor4)
|
||||
.Replace(DarkColor3, LightColor3)
|
||||
.Replace(DarkColor2, LightColor2);
|
||||
}
|
||||
|
||||
// wrap a default color body around
|
||||
return $@"<body style=""{(isDarkMode ? LightColor1 : DarkColor1)}"">{rawContent}</body>";
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,12 @@
|
||||
xmlns:shca="using:Snap.Hutao.Control.Animation"
|
||||
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
|
||||
xmlns:shcc="using:Snap.Hutao.Control.Cancellable"
|
||||
xmlns:shci="using:Snap.Hutao.Control.Image"
|
||||
xmlns:shvc="using:Snap.Hutao.View.Converter"
|
||||
mc:Ignorable="d"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
<mxi:Interaction.Behaviors>
|
||||
<mxic:EventTriggerBehavior EventName="Loaded">
|
||||
<mxic:InvokeCommandAction Command="{Binding OpenUICommand}"/>
|
||||
</mxic:EventTriggerBehavior>
|
||||
<shcb:InvokeCommandOnLoadedBehavior Command="{Binding OpenUICommand}"/>
|
||||
</mxi:Interaction.Behaviors>
|
||||
<shcc:CancellablePage.Resources>
|
||||
<cwuconv:BoolToVisibilityConverter x:Key="BoolToVisibilityConverter"/>
|
||||
@@ -75,11 +74,14 @@
|
||||
<mxi:Interaction.Behaviors>
|
||||
<shcb:AutoHeightBehavior TargetWidth="1080" TargetHeight="390"/>
|
||||
</mxi:Interaction.Behaviors>
|
||||
<Border.Background>
|
||||
<shci:CachedImage
|
||||
Stretch="UniformToFill"
|
||||
Source="{Binding Banner}"/>
|
||||
<!--<Border.Background>
|
||||
<ImageBrush
|
||||
ImageSource="{Binding Banner}"
|
||||
Stretch="UniformToFill"/>
|
||||
</Border.Background>
|
||||
</Border.Background>-->
|
||||
<cwua:Explicit.Animations>
|
||||
<cwua:AnimationSet x:Name="ImageZoomInAnimation">
|
||||
<shca:ImageZoomInAnimation/>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<Page
|
||||
x:Class="Snap.Hutao.View.Page.SettingPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Snap.Hutao.View.Page"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="using:SettingsUI.Controls"
|
||||
xmlns:sc="using:SettingsUI.Controls"
|
||||
mc:Ignorable="d"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
<Page.Resources>
|
||||
@@ -18,24 +17,24 @@
|
||||
<StackPanel
|
||||
Margin="32,0,24,0">
|
||||
|
||||
<controls:SettingsGroup Header="关于 胡桃">
|
||||
<controls:SettingExpander>
|
||||
<controls:SettingExpander.Header>
|
||||
<controls:Setting
|
||||
<sc:SettingsGroup Header="关于 胡桃">
|
||||
<sc:SettingExpander>
|
||||
<sc:SettingExpander.Header>
|
||||
<sc:Setting
|
||||
Icon=""
|
||||
Header="检查更新"
|
||||
Description="根本没有检查更新选项">
|
||||
|
||||
</controls:Setting>
|
||||
</controls:SettingExpander.Header>
|
||||
</sc:Setting>
|
||||
</sc:SettingExpander.Header>
|
||||
<InfoBar
|
||||
IsClosable="False"
|
||||
Severity="Informational"
|
||||
Message="都说了没有了"
|
||||
IsOpen="True"
|
||||
CornerRadius="0,0,4,4"/>
|
||||
</controls:SettingExpander>
|
||||
</controls:SettingsGroup>
|
||||
</sc:SettingExpander>
|
||||
</sc:SettingsGroup>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:cwuc="using:CommunityToolkit.WinUI.UI.Controls"
|
||||
xmlns:mxic="using:Microsoft.Xaml.Interactions.Core"
|
||||
xmlns:cwum="using:CommunityToolkit.WinUI.UI.Media"
|
||||
xmlns:mxi="using:Microsoft.Xaml.Interactivity"
|
||||
xmlns:shc="using:Snap.Hutao.Control"
|
||||
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
|
||||
xmlns:shci="using:Snap.Hutao.Control.Image"
|
||||
xmlns:shct="using:Snap.Hutao.Control.Text"
|
||||
xmlns:shmmc="using:Snap.Hutao.Model.Metadata.Converter"
|
||||
@@ -17,9 +18,7 @@
|
||||
d:DataContext="{d:DesignInstance Type=shv:WikiAvatarViewModel}"
|
||||
Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
|
||||
<mxi:Interaction.Behaviors>
|
||||
<mxic:EventTriggerBehavior EventName="Loaded">
|
||||
<mxic:InvokeCommandAction Command="{Binding OpenUICommand}"/>
|
||||
</mxic:EventTriggerBehavior>
|
||||
<shcb:InvokeCommandOnLoadedBehavior Command="{Binding OpenUICommand}"/>
|
||||
</mxi:Interaction.Behaviors>
|
||||
<Page.Resources>
|
||||
<shmmc:AvatarIconConverter x:Key="AvatarIconConverter"/>
|
||||
@@ -150,7 +149,7 @@
|
||||
DisplayMode="Inline"
|
||||
OpenPaneLength="200">
|
||||
<SplitView.PaneBackground>
|
||||
<SolidColorBrush Color="{StaticResource CardBackgroundFillColorSecondary}"/>
|
||||
<SolidColorBrush Color="{ThemeResource CardBackgroundFillColorSecondary}"/>
|
||||
</SplitView.PaneBackground>
|
||||
<SplitView.Pane>
|
||||
<ListView
|
||||
@@ -184,8 +183,7 @@
|
||||
<Grid>
|
||||
<shci:Gradient
|
||||
VerticalAlignment="Top"
|
||||
Source="{Binding Selected,Converter={StaticResource AvatarNameCardPicConverter}}">
|
||||
</shci:Gradient>
|
||||
Source="{Binding Selected,Converter={StaticResource AvatarNameCardPicConverter}}"/>
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,0,16,16">
|
||||
<!--简介-->
|
||||
@@ -202,16 +200,16 @@
|
||||
<ColumnDefinition/>
|
||||
<ColumnDefinition/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<BitmapIcon
|
||||
<shci:CachedImage
|
||||
Grid.Column="0"
|
||||
Width="27.2"
|
||||
Height="27.2"
|
||||
UriSource="{Binding Selected.FetterInfo.VisionBefore,Converter={StaticResource ElementNameIconConverter}}"/>
|
||||
<BitmapIcon
|
||||
Source="{Binding Selected.FetterInfo.VisionBefore,Converter={StaticResource ElementNameIconConverter}}"/>
|
||||
<shci:CachedImage
|
||||
Grid.Column="1"
|
||||
Width="27.2"
|
||||
Height="27.2"
|
||||
UriSource="{Binding Selected.Weapon,Converter={StaticResource WeaponTypeIconConverter}}"/>
|
||||
Source="{Binding Selected.Weapon,Converter={StaticResource WeaponTypeIconConverter}}"/>
|
||||
</Grid>
|
||||
<shvc:ItemIcon
|
||||
Height="100"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Extension;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Snap.Hutao.Web.Hoyolab.DynamicSecret;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Extension;
|
||||
using Snap.Hutao.Model.Entity;
|
||||
using System.Net.Http;
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ public struct PlayerUid
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
{
|
||||
return Value;
|
||||
}
|
||||
|
||||
private static string EvaluateRegion(char first)
|
||||
{
|
||||
return first switch
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Snap.Hutao.Extension;
|
||||
namespace Snap.Hutao.Web;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="HttpRequestHeaders"/> 扩展
|
||||
@@ -4,6 +4,7 @@
|
||||
using Snap.Hutao.Core.Abstraction;
|
||||
using Snap.Hutao.Extension;
|
||||
using Snap.Hutao.Model.Entity;
|
||||
using Snap.Hutao.Web.Hoyolab;
|
||||
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord;
|
||||
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.Avatar;
|
||||
using Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.SpiralAbyss;
|
||||
@@ -22,7 +23,7 @@ namespace Snap.Hutao.Web.Hutao;
|
||||
[Injection(InjectAs.Transient)]
|
||||
internal class HutaoClient : ISupportAsyncInitialization
|
||||
{
|
||||
private const string AuthAPIHost = "https://auth.snapgenshin.com";
|
||||
private const string AuthHost = "https://auth.snapgenshin.com";
|
||||
private const string HutaoAPI = "https://hutao-api.snapgenshin.com";
|
||||
|
||||
private readonly HttpClient httpClient;
|
||||
@@ -56,11 +57,11 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
if (!IsInitialized)
|
||||
{
|
||||
Auth auth = new(
|
||||
"08d9e212-0cb3-4d71-8ed7-003606da7b20",
|
||||
"7ueWgZGn53dDhrm8L5ZRw+YWfOeSWtgQmJWquRgaygw=");
|
||||
"08da6c59-da3b-48dd-8cf3-e3935a7f1d4f",
|
||||
"ox5dwglSXYgenK2YBc8KrAVPoQbIJ4eHfUciE+05WfI=");
|
||||
|
||||
HttpResponseMessage response = await httpClient
|
||||
.PostAsJsonAsync($"{AuthAPIHost}/Auth/Login", auth, jsonSerializerOptions, token)
|
||||
.PostAsJsonAsync($"{AuthHost}/Auth/Login", auth, jsonSerializerOptions, token)
|
||||
.ConfigureAwait(false);
|
||||
Response<Token>? resp = await response.Content
|
||||
.ReadFromJsonAsync<Response<Token>>(jsonSerializerOptions, token)
|
||||
@@ -74,12 +75,13 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查对应的uid当前是否上传了数据
|
||||
/// 异步检查对应的uid当前是否上传了数据
|
||||
/// GET /Record/CheckRecord/{Uid}
|
||||
/// </summary>
|
||||
/// <param name="uid">uid</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>当前是否上传了数据</returns>
|
||||
public async Task<bool> CheckPeriodRecordUploadedAsync(string uid, CancellationToken token = default)
|
||||
public async Task<bool> CheckPeriodRecordUploadedAsync(PlayerUid uid, CancellationToken token = default)
|
||||
{
|
||||
Verify.Operation(IsInitialized, "必须在初始化后才能调用其他方法");
|
||||
|
||||
@@ -90,8 +92,27 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
return resp is { Data: not null, Data.PeriodUploaded: true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取排行信息
|
||||
/// GET /Record/Rank/{Uid}
|
||||
/// </summary>
|
||||
/// <param name="uid">uid</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>排行信息</returns>
|
||||
public async Task<RankInfoWrapper?> GetRankInfoAsync(PlayerUid uid, CancellationToken token = default)
|
||||
{
|
||||
Verify.Operation(IsInitialized, "必须在初始化后才能调用其他方法");
|
||||
|
||||
Response<RankInfoWrapper>? resp = await httpClient
|
||||
.GetFromJsonAsync<Response<RankInfoWrapper>>($"{HutaoAPI}/Record/Rank/{uid}", token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return resp?.Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取总览数据
|
||||
/// GET /Statistics/Overview
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>总览信息</returns>
|
||||
@@ -108,6 +129,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色出场率
|
||||
/// GET /Statistics/AvatarParticipation
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色出场率</returns>
|
||||
@@ -122,8 +144,26 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色使用率
|
||||
/// GET /Statistics2/AvatarParticipation
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色出场率</returns>
|
||||
public async Task<IEnumerable<AvatarParticipation>> GetAvatarParticipations2Async(CancellationToken token = default)
|
||||
{
|
||||
Verify.Operation(IsInitialized, "必须在初始化后才能调用其他方法");
|
||||
|
||||
Response<IEnumerable<AvatarParticipation>>? resp = await httpClient
|
||||
.GetFromJsonAsync<Response<IEnumerable<AvatarParticipation>>>($"{HutaoAPI}/Statistics2/AvatarParticipation", token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色圣遗物搭配
|
||||
/// GET /Statistics/AvatarReliquaryUsage
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色圣遗物搭配</returns>
|
||||
@@ -140,6 +180,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色搭配数据
|
||||
/// GET /Statistics/TeamCollocation
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色搭配数据</returns>
|
||||
@@ -156,6 +197,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色武器搭配数据
|
||||
/// GET /Statistics/AvatarWEaponUsage
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色武器搭配数据</returns>
|
||||
@@ -172,6 +214,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色命座信息
|
||||
/// GET /Statistics/Constellation
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色图片列表</returns>
|
||||
@@ -188,6 +231,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取队伍出场次数 层间
|
||||
/// GET /Statistics/TeamCombination
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>队伍出场列表</returns>
|
||||
@@ -204,10 +248,11 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取队伍出场次数 层
|
||||
/// GET /Statistics2/TeamCombination
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>队伍出场列表</returns>
|
||||
public async Task<IEnumerable<TeamCombination2>> GetTeamCombination2sAsync(CancellationToken token = default)
|
||||
public async Task<IEnumerable<TeamCombination2>> GetTeamCombinations2Async(CancellationToken token = default)
|
||||
{
|
||||
Verify.Operation(IsInitialized, "必须在初始化后才能调用其他方法");
|
||||
|
||||
@@ -219,7 +264,8 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按角色列表异步获取推荐队伍
|
||||
/// 异步按角色列表异步获取推荐队伍
|
||||
/// POST /Statistics2/TeamRecommanded
|
||||
/// </summary>
|
||||
/// <param name="floor">楼层</param>
|
||||
/// <param name="avatarIds">期望的角色,按期望出现顺序排序</param>
|
||||
@@ -242,48 +288,6 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色图片列表
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>角色图片列表</returns>
|
||||
public async Task<IEnumerable<Item>> GetAvatarMapAsync(CancellationToken token = default)
|
||||
{
|
||||
Response<IEnumerable<Item>>? resp = await httpClient
|
||||
.GetFromJsonAsync<Response<IEnumerable<Item>>>($"{HutaoAPI}/GenshinItem/Avatars", token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取武器图片列表
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>武器图片列表</returns>
|
||||
public async Task<IEnumerable<Item>> GetWeaponMapAsync(CancellationToken token = default)
|
||||
{
|
||||
Response<IEnumerable<Item>>? resp = await httpClient
|
||||
.GetFromJsonAsync<Response<IEnumerable<Item>>>($"{HutaoAPI}/GenshinItem/Weapons", token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取圣遗物图片列表
|
||||
/// </summary>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>圣遗物图片列表</returns>
|
||||
public async Task<IEnumerable<Item>> GetReliquaryMapAsync(CancellationToken token = default)
|
||||
{
|
||||
Response<IEnumerable<Item>>? resp = await httpClient
|
||||
.GetFromJsonAsync<Response<IEnumerable<Item>>>($"{HutaoAPI}/GenshinItem/Reliquaries", token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return EnumerableExtensions.EmptyIfNull(resp?.Data);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 异步获取角色的深渊记录
|
||||
/// </summary>
|
||||
@@ -311,6 +315,7 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
|
||||
/// <summary>
|
||||
/// 异步上传记录
|
||||
/// POST /Record/Upload
|
||||
/// </summary>
|
||||
/// <param name="playerRecord">玩家记录</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
@@ -320,8 +325,13 @@ internal class HutaoClient : ISupportAsyncInitialization
|
||||
{
|
||||
Verify.Operation(IsInitialized, "必须在初始化后才能调用其他方法");
|
||||
|
||||
HttpResponseMessage response = await httpClient.PostAsJsonAsync($"{HutaoAPI}/Record/Upload", playerRecord, jsonSerializerOptions, token);
|
||||
return await response.Content.ReadFromJsonAsync<Response<string>>(jsonSerializerOptions, token);
|
||||
HttpResponseMessage response = await httpClient
|
||||
.PostAsJsonAsync($"{HutaoAPI}/Record/Upload", playerRecord, jsonSerializerOptions, token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await response.Content
|
||||
.ReadFromJsonAsync<Response<string>>(jsonSerializerOptions, token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private class Auth
|
||||
|
||||
33
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/Post/Damage.cs
Normal file
33
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/Post/Damage.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Snap.Hutao.Web.Hutao.Model.Post;
|
||||
|
||||
/// <summary>
|
||||
/// 伤害信息
|
||||
/// </summary>
|
||||
public class Damage
|
||||
{
|
||||
/// <summary>
|
||||
/// 构造一个新的伤害信息
|
||||
/// </summary>
|
||||
/// <param name="avatarId">角色Id</param>
|
||||
/// <param name="value">值</param>
|
||||
public Damage(int avatarId, int value)
|
||||
{
|
||||
AvatarId = avatarId;
|
||||
Value = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 角色Id
|
||||
/// </summary>
|
||||
public int AvatarId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 值
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
}
|
||||
@@ -16,32 +16,36 @@ namespace Snap.Hutao.Web.Hutao.Model.Post;
|
||||
public class PlayerRecord
|
||||
{
|
||||
/// <summary>
|
||||
/// 构造一个新的玩家记录
|
||||
/// 防止从外部构造一个新的玩家记录
|
||||
/// </summary>
|
||||
/// <param name="uid">uid</param>
|
||||
/// <param name="playerAvatars">玩家角色</param>
|
||||
/// <param name="playerSpiralAbyssesLevels">玩家深渊信息</param>
|
||||
private PlayerRecord(string uid, IEnumerable<PlayerAvatar> playerAvatars, IEnumerable<PlayerSpiralAbyssLevel> playerSpiralAbyssesLevels)
|
||||
private PlayerRecord()
|
||||
{
|
||||
Uid = uid;
|
||||
PlayerAvatars = playerAvatars;
|
||||
PlayerSpiralAbyssesLevels = playerSpiralAbyssesLevels;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// uid
|
||||
/// </summary>
|
||||
public string Uid { get; }
|
||||
public string Uid { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 玩家角色
|
||||
/// </summary>
|
||||
public IEnumerable<PlayerAvatar> PlayerAvatars { get; }
|
||||
public IEnumerable<PlayerAvatar> PlayerAvatars { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 玩家深渊信息
|
||||
/// </summary>
|
||||
public IEnumerable<PlayerSpiralAbyssLevel> PlayerSpiralAbyssesLevels { get; }
|
||||
public IEnumerable<PlayerSpiralAbyssLevel> PlayerSpiralAbyssesLevels { get; private set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// 造成最多伤害
|
||||
/// </summary>
|
||||
public Damage? DamageMost { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 承受最多伤害
|
||||
/// </summary>
|
||||
public Damage? TakeDamageMost { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 建造玩家记录
|
||||
@@ -59,7 +63,14 @@ public class PlayerRecord
|
||||
.SelectMany(f => f.Levels, (f, level) => new IndexedLevel(f.Index, level))
|
||||
.Select(indexedLevel => new PlayerSpiralAbyssLevel(indexedLevel));
|
||||
|
||||
return new PlayerRecord(uid, playerAvatars, playerSpiralAbyssLevels);
|
||||
return new()
|
||||
{
|
||||
Uid = uid,
|
||||
PlayerAvatars = playerAvatars,
|
||||
PlayerSpiralAbyssesLevels = playerSpiralAbyssLevels,
|
||||
DamageMost = GetDamage(spiralAbyss.DamageRank),
|
||||
TakeDamageMost = GetDamage(spiralAbyss.TakeDamageRank),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -68,8 +79,19 @@ public class PlayerRecord
|
||||
/// <param name="hutaoClient">使用的客户端</param>
|
||||
/// <param name="token">取消令牌</param>
|
||||
/// <returns>上传结果</returns>
|
||||
internal Task<Response<string>?> UploadRecordAsync(HutaoClient hutaoClient, CancellationToken token = default)
|
||||
internal Task<Response<string>?> UploadAsync(HutaoClient hutaoClient, CancellationToken token = default)
|
||||
{
|
||||
return hutaoClient.UploadRecordAsync(this, token);
|
||||
}
|
||||
|
||||
private static Damage? GetDamage(List<RankInfo> ranks)
|
||||
{
|
||||
if (ranks.Count > 0)
|
||||
{
|
||||
RankInfo rank = ranks[0];
|
||||
return new Damage(rank.AvatarId, rank.Value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
34
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/RankInfo.cs
Normal file
34
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/RankInfo.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Web.Hutao.Model.Converter;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Snap.Hutao.Web.Hutao.Model;
|
||||
|
||||
/// <summary>
|
||||
/// 排行信息
|
||||
/// </summary>
|
||||
public class RankInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// 角色Id
|
||||
/// </summary>
|
||||
public int AvatarId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 值
|
||||
/// </summary>
|
||||
public int Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 百分比
|
||||
/// </summary>
|
||||
public double Percent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 总体百分比
|
||||
/// </summary>
|
||||
public double PercentTotal { get; set; }
|
||||
}
|
||||
24
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/RankInfoWrapper.cs
Normal file
24
src/Snap.Hutao/Snap.Hutao/Web/Hutao/Model/RankInfoWrapper.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Web.Hutao.Model.Converter;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Snap.Hutao.Web.Hutao.Model;
|
||||
|
||||
/// <summary>
|
||||
/// 排行包装
|
||||
/// </summary>
|
||||
public class RankInfoWrapper
|
||||
{
|
||||
/// <summary>
|
||||
/// 伤害
|
||||
/// </summary>
|
||||
public RankInfo? Damage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 承受伤害
|
||||
/// </summary>
|
||||
public RankInfo? TakeDamage { get; set; }
|
||||
}
|
||||
@@ -21,4 +21,4 @@ public class ReliquarySets : List<ReliquarySet>
|
||||
: base(sets)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user