diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Helper/SettingsExpanderHelper.cs b/src/Snap.Hutao/Snap.Hutao/Control/Helper/SettingsExpanderHelper.cs new file mode 100644 index 00000000..3d3b9540 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Helper/SettingsExpanderHelper.cs @@ -0,0 +1,23 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using CommunityToolkit.WinUI.Controls; +using Microsoft.UI.Xaml; + +namespace Snap.Hutao.Control.Helper; + +[SuppressMessage("", "SH001")] +[DependencyProperty("IsItemsEnabled", typeof(bool), true, nameof(OnIsItemsEnabledChanged), IsAttached = true, AttachedType = typeof(SettingsExpander))] +public sealed partial class SettingsExpanderHelper +{ + private static void OnIsItemsEnabledChanged(DependencyObject dp, DependencyPropertyChangedEventArgs e) + { + foreach (object item in ((SettingsExpander)dp).Items) + { + if (item is Microsoft.UI.Xaml.Controls.Control control) + { + control.IsEnabled = (bool)e.NewValue; + } + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/DefaultItemCollectionTransitionProvider.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/DefaultItemCollectionTransitionProvider.cs new file mode 100644 index 00000000..e10f2a65 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/DefaultItemCollectionTransitionProvider.cs @@ -0,0 +1,151 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Composition; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Hosting; +using System.Numerics; +using Windows.Foundation; + +namespace Snap.Hutao.Control.Layout; + +internal sealed class DefaultItemCollectionTransitionProvider : ItemCollectionTransitionProvider +{ + private const double DefaultAnimationDurationInMs = 300.0; + + static DefaultItemCollectionTransitionProvider() + { + AnimationSlowdownFactor = 1.0; + } + + public static double AnimationSlowdownFactor { get; set; } + + protected override bool ShouldAnimateCore(ItemCollectionTransition transition) + { + return true; + } + + protected override void StartTransitions(IList transitions) + { + List addTransitions = new(); + List removeTransitions = new(); + List moveTransitions = new(); + + foreach (ItemCollectionTransition transition in addTransitions) + { + switch (transition.Operation) + { + case ItemCollectionTransitionOperation.Add: + addTransitions.Add(transition); + break; + case ItemCollectionTransitionOperation.Remove: + removeTransitions.Add(transition); + break; + case ItemCollectionTransitionOperation.Move: + moveTransitions.Add(transition); + break; + } + } + + StartAddTransitions(addTransitions, removeTransitions.Count > 0, moveTransitions.Count > 0); + StartRemoveTransitions(removeTransitions); + StartMoveTransitions(moveTransitions, removeTransitions.Count > 0); + } + + private static void StartAddTransitions(IList transitions, bool hasRemoveTransitions, bool hasMoveTransitions) + { + foreach (ItemCollectionTransition transition in transitions) + { + ItemCollectionTransitionProgress progress = transition.Start(); + Visual visual = ElementCompositionPreview.GetElementVisual(progress.Element); + Compositor compositor = visual.Compositor; + + ScalarKeyFrameAnimation fadeInAnimation = compositor.CreateScalarKeyFrameAnimation(); + fadeInAnimation.InsertKeyFrame(0.0f, 0.0f); + + if (hasMoveTransitions && hasRemoveTransitions) + { + fadeInAnimation.InsertKeyFrame(0.66f, 0.0f); + } + else if (hasMoveTransitions || hasRemoveTransitions) + { + fadeInAnimation.InsertKeyFrame(0.5f, 0.0f); + } + + fadeInAnimation.InsertKeyFrame(1.0f, 1.0f); + fadeInAnimation.Duration = TimeSpan.FromMilliseconds( + DefaultAnimationDurationInMs * ((hasRemoveTransitions ? 1 : 0) + (hasMoveTransitions ? 1 : 0) + 1) * AnimationSlowdownFactor); + + CompositionScopedBatch batch = compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + visual.StartAnimation("Opacity", fadeInAnimation); + batch.End(); + batch.Completed += (_, _) => progress.Complete(); + } + } + + private static void StartRemoveTransitions(IList transitions) + { + foreach (ItemCollectionTransition transition in transitions) + { + ItemCollectionTransitionProgress progress = transition.Start(); + Visual visual = ElementCompositionPreview.GetElementVisual(progress.Element); + Compositor compositor = visual.Compositor; + + ScalarKeyFrameAnimation fadeOutAnimation = compositor.CreateScalarKeyFrameAnimation(); + fadeOutAnimation.InsertExpressionKeyFrame(0.0f, "this.CurrentValue"); + fadeOutAnimation.InsertKeyFrame(1.0f, 0.0f); + fadeOutAnimation.Duration = TimeSpan.FromMilliseconds(DefaultAnimationDurationInMs * AnimationSlowdownFactor); + + CompositionScopedBatch batch = compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + visual.StartAnimation(nameof(Visual.Opacity), fadeOutAnimation); + batch.End(); + batch.Completed += (_, _) => + { + visual.Opacity = 1.0f; + progress.Complete(); + }; + } + } + + private static void StartMoveTransitions(IList transitions, bool hasRemoveAnimations) + { + foreach (ItemCollectionTransition transition in transitions) + { + ItemCollectionTransitionProgress progress = transition.Start(); + Visual visual = ElementCompositionPreview.GetElementVisual(progress.Element); + Compositor compositor = visual.Compositor; + CompositionScopedBatch batch = compositor.CreateScopedBatch(CompositionBatchTypes.Animation); + + // Animate offset. + if (transition.OldBounds.X != transition.NewBounds.X || + transition.OldBounds.Y != transition.NewBounds.Y) + { + AnimateOffset(visual, compositor, transition.OldBounds, transition.NewBounds, hasRemoveAnimations); + } + + batch.End(); + batch.Completed += (_, _) => progress.Complete(); + } + } + + private static void AnimateOffset(Visual visual, Compositor compositor, Rect oldBounds, Rect newBounds, bool hasRemoveAnimations) + { + Vector2KeyFrameAnimation offsetAnimation = compositor.CreateVector2KeyFrameAnimation(); + + offsetAnimation.SetVector2Parameter("delta", new Vector2( + (float)(oldBounds.X - newBounds.X), + (float)(oldBounds.Y - newBounds.Y))); + offsetAnimation.SetVector2Parameter("final", default); + offsetAnimation.InsertExpressionKeyFrame(0.0f, "this.CurrentValue + delta"); + if (hasRemoveAnimations) + { + offsetAnimation.InsertExpressionKeyFrame(0.5f, "delta"); + } + + offsetAnimation.InsertExpressionKeyFrame(1.0f, "final"); + offsetAnimation.Duration = TimeSpan.FromMilliseconds( + DefaultAnimationDurationInMs * ((hasRemoveAnimations ? 1 : 0) + 1) * AnimationSlowdownFactor); + + visual.StartAnimation("TransformMatrix._41_42", offsetAnimation); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs new file mode 100644 index 00000000..b7aa699a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredColumnLayout.cs @@ -0,0 +1,24 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using System.Diagnostics; + +namespace Snap.Hutao.Control.Layout; + +[DebuggerDisplay("Count = {Count}, Height = {Height}")] +internal class UniformStaggeredColumnLayout : List +{ + public double Height { get; private set; } + + public new void Add(UniformStaggeredItem item) + { + Height = item.Top + item.Height; + base.Add(item); + } + + public new void Clear() + { + Height = 0; + base.Clear(); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs new file mode 100644 index 00000000..3ae32b3c --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredItem.cs @@ -0,0 +1,29 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Snap.Hutao.Control.Layout; + +internal sealed class UniformStaggeredItem +{ + public UniformStaggeredItem(int index) + { + Index = index; + } + + public double Top { get; internal set; } + + public double Height { get; internal set; } + + public int Index { get; } + + public UIElement? Element { get; internal set; } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs new file mode 100644 index 00000000..43d82481 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayout.cs @@ -0,0 +1,275 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Collections.Specialized; +using System.Runtime.InteropServices; +using Windows.Foundation; + +namespace Snap.Hutao.Control.Layout; + +[DependencyProperty("MinItemWidth", typeof(double), 0D, nameof(OnMinItemWidthChanged))] +[DependencyProperty("MinColumnSpacing", typeof(double), 0D, nameof(OnSpacingChanged))] +[DependencyProperty("MinRowSpacing", typeof(double), 0D, nameof(OnSpacingChanged))] +internal sealed partial class UniformStaggeredLayout : VirtualizingLayout +{ + /// + protected override void InitializeForContextCore(VirtualizingLayoutContext context) + { + context.LayoutState = new UniformStaggeredLayoutState(context); + base.InitializeForContextCore(context); + } + + /// + protected override void UninitializeForContextCore(VirtualizingLayoutContext context) + { + context.LayoutState = null; + base.UninitializeForContextCore(context); + } + + /// + protected override void OnItemsChangedCore(VirtualizingLayoutContext context, object source, NotifyCollectionChangedEventArgs args) + { + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + switch (args.Action) + { + case NotifyCollectionChangedAction.Add: + state.RemoveFromIndex(args.NewStartingIndex); + break; + + case NotifyCollectionChangedAction.Replace: + state.RemoveFromIndex(args.NewStartingIndex); + state.RecycleElementAt(args.NewStartingIndex); // We must recycle the element to ensure that it gets the correct context + break; + + case NotifyCollectionChangedAction.Move: + int minIndex = Math.Min(args.NewStartingIndex, args.OldStartingIndex); + int maxIndex = Math.Max(args.NewStartingIndex, args.OldStartingIndex); + state.RemoveRange(minIndex, maxIndex); + break; + + case NotifyCollectionChangedAction.Remove: + state.RemoveFromIndex(args.OldStartingIndex); + break; + + case NotifyCollectionChangedAction.Reset: + state.Clear(); + break; + } + + base.OnItemsChangedCore(context, source, args); + } + + /// + protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize) + { + if (context.ItemCount == 0) + { + return new Size(availableSize.Width, 0); + } + + if ((context.RealizationRect.Width == 0) && (context.RealizationRect.Height == 0)) + { + return new Size(availableSize.Width, 0.0f); + } + + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + double availableWidth = availableSize.Width; + double availableHeight = availableSize.Height; + + (int numberOfColumns, double columnWidth) = GetNumberOfColumnsAndWidth(availableWidth, MinItemWidth, MinColumnSpacing); + + if (columnWidth != state.ColumnWidth) + { + // The items will need to be remeasured + state.Clear(); + } + + state.ColumnWidth = columnWidth; + + // adjust for column spacing on all columns expect the first + double totalWidth = state.ColumnWidth + ((numberOfColumns - 1) * (state.ColumnWidth + MinColumnSpacing)); + if (totalWidth > availableWidth) + { + numberOfColumns--; + } + else if (double.IsInfinity(availableWidth)) + { + availableWidth = totalWidth; + } + + if (numberOfColumns != state.NumberOfColumns) + { + // The items will not need to be remeasured, but they will need to go into new columns + state.ClearColumns(); + } + + if (MinRowSpacing != state.RowSpacing) + { + // If the RowSpacing changes the height of the rows will be different. + // The columns stores the height so we'll want to clear them out to + // get the proper height + state.ClearColumns(); + state.RowSpacing = MinRowSpacing; + } + + Span columnHeights = new double[numberOfColumns]; + Span itemsPerColumn = new int[numberOfColumns]; + HashSet deadColumns = new(); + + for (int i = 0; i < context.ItemCount; i++) + { + int columnIndex = GetLowestColumnIndex(columnHeights); + + bool measured = false; + UniformStaggeredItem item = state.GetItemAt(i); + if (item.Height == 0) + { + // Item has not been measured yet. Get the element and store the values + UIElement element = context.GetOrCreateElementAt(i); + element.Measure(new Size(state.ColumnWidth, availableHeight)); + item.Height = element.DesiredSize.Height; + item.Element = element; + measured = true; + } + + double spacing = itemsPerColumn[columnIndex] > 0 ? MinRowSpacing : 0; + item.Top = columnHeights[columnIndex] + spacing; + double bottom = item.Top + item.Height; + columnHeights[columnIndex] = bottom; + itemsPerColumn[columnIndex]++; + state.AddItemToColumn(item, columnIndex); + + if (bottom < context.RealizationRect.Top) + { + // The bottom of the element is above the realization area + if (item.Element is not null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + } + else if (item.Top > context.RealizationRect.Bottom) + { + // The top of the element is below the realization area + if (item.Element is not null) + { + context.RecycleElement(item.Element); + item.Element = null; + } + + deadColumns.Add(columnIndex); + } + else if (measured == false) + { + // We ALWAYS want to measure an item that will be in the bounds + item.Element = context.GetOrCreateElementAt(i); + item.Element.Measure(new Size(state.ColumnWidth, availableHeight)); + if (item.Height != item.Element.DesiredSize.Height) + { + // this item changed size; we need to recalculate layout for everything after this + state.RemoveFromIndex(i + 1); + item.Height = item.Element.DesiredSize.Height; + columnHeights[columnIndex] = item.Top + item.Height; + } + } + + if (deadColumns.Count == numberOfColumns) + { + break; + } + } + + double desiredHeight = state.GetHeight(); + + return new Size(availableWidth, desiredHeight); + } + + /// + protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize) + { + if ((context.RealizationRect.Width == 0) && (context.RealizationRect.Height == 0)) + { + return finalSize; + } + + UniformStaggeredLayoutState state = (UniformStaggeredLayoutState)context.LayoutState; + + // Cycle through each column and arrange the items that are within the realization bounds + for (int columnIndex = 0; columnIndex < state.NumberOfColumns; columnIndex++) + { + UniformStaggeredColumnLayout layout = state.GetColumnLayout(columnIndex); + Span layoutSpan = CollectionsMarshal.AsSpan(layout); + for (int i = 0; i < layoutSpan.Length; i++) + { + ref readonly UniformStaggeredItem item = ref layoutSpan[i]; + + double bottom = item.Top + item.Height; + if (bottom < context.RealizationRect.Top) + { + // element is above the realization bounds + continue; + } + + if (item.Top <= context.RealizationRect.Bottom) + { + double itemHorizontalOffset = (state.ColumnWidth * columnIndex) + (MinColumnSpacing * columnIndex); + + Rect bounds = new(itemHorizontalOffset, item.Top, state.ColumnWidth, item.Height); + UIElement element = context.GetOrCreateElementAt(item.Index); + element.Arrange(bounds); + } + else + { + break; + } + } + } + + return finalSize; + } + + private static (int NumberOfColumns, double ColumnWidth) GetNumberOfColumnsAndWidth(double availableWidth, double minItemWidth, double minColumnSpacing) + { + // test if the width can fit in 2 items + if ((2 * minItemWidth) + minColumnSpacing > availableWidth) + { + return (1, availableWidth); + } + + int columnCount = Math.Max(1, (int)((availableWidth + minColumnSpacing) / (minItemWidth + minColumnSpacing))); + double columnWidthAddSpacing = (availableWidth + minColumnSpacing) / columnCount; + return (columnCount, columnWidthAddSpacing - minColumnSpacing); + } + + private static int GetLowestColumnIndex(in ReadOnlySpan columnHeights) + { + int columnIndex = 0; + double height = columnHeights[0]; + for (int j = 1; j < columnHeights.Length; j++) + { + if (columnHeights[j] < height) + { + columnIndex = j; + height = columnHeights[j]; + } + } + + return columnIndex; + } + + private static void OnMinItemWidthChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UniformStaggeredLayout panel = (UniformStaggeredLayout)d; + panel.InvalidateMeasure(); + } + + private static void OnSpacingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + UniformStaggeredLayout panel = (UniformStaggeredLayout)d; + panel.InvalidateMeasure(); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs new file mode 100644 index 00000000..bb44b651 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Control/Layout/UniformStaggeredLayoutState.cs @@ -0,0 +1,192 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using System.Runtime.InteropServices; + +namespace Snap.Hutao.Control.Layout; + +internal sealed class UniformStaggeredLayoutState +{ + private readonly List items = new(); + private readonly VirtualizingLayoutContext context; + private readonly Dictionary columnLayout = new(); + private double lastAverageHeight; + + public UniformStaggeredLayoutState(VirtualizingLayoutContext context) + { + this.context = context; + } + + public double ColumnWidth { get; internal set; } + + public int NumberOfColumns + { + get => columnLayout.Count; + } + + public double RowSpacing { get; internal set; } + + internal void AddItemToColumn(UniformStaggeredItem item, int columnIndex) + { + if (!this.columnLayout.TryGetValue(columnIndex, out UniformStaggeredColumnLayout? columnLayout)) + { + columnLayout = new(); + this.columnLayout[columnIndex] = columnLayout; + } + + if (!columnLayout.Contains(item)) + { + columnLayout.Add(item); + } + } + + [SuppressMessage("", "CA2201")] + internal UniformStaggeredItem GetItemAt(int index) + { + if (index < 0) + { + throw new IndexOutOfRangeException(); + } + + if (index <= (items.Count - 1)) + { + return items[index]; + } + else + { + UniformStaggeredItem item = new(index); + items.Add(item); + return item; + } + } + + [SuppressMessage("", "SH007")] + internal UniformStaggeredColumnLayout GetColumnLayout(int columnIndex) + { + this.columnLayout.TryGetValue(columnIndex, out UniformStaggeredColumnLayout? columnLayout); + return columnLayout!; + } + + /// + /// Clear everything that has been calculated. + /// + internal void Clear() + { + columnLayout.Clear(); + items.Clear(); + } + + /// + /// Clear the layout columns so they will be recalculated. + /// + internal void ClearColumns() + { + columnLayout.Clear(); + } + + /// + /// Gets the estimated height of the layout. + /// + /// The estimated height of the layout. + /// + /// If all of the items have been calculated then the actual height will be returned. + /// If all of the items have not been calculated then an estimated height will be calculated based on the average height of the items. + /// + internal double GetHeight() + { + double desiredHeight = columnLayout.Values.Max(c => c.Height); + int itemCount = columnLayout.Values.Sum(c => c.Count); + + if (itemCount == context.ItemCount) + { + return desiredHeight; + } + + double averageHeight = 0; + foreach ((_, UniformStaggeredColumnLayout layout) in columnLayout) + { + averageHeight += layout.Height / layout.Count; + } + + averageHeight /= columnLayout.Count; + double estimatedHeight = (averageHeight * context.ItemCount) / columnLayout.Count; + if (estimatedHeight > desiredHeight) + { + desiredHeight = estimatedHeight; + } + + if (Math.Abs(desiredHeight - lastAverageHeight) < 5) + { + return lastAverageHeight; + } + + lastAverageHeight = desiredHeight; + return desiredHeight; + } + + internal void RecycleElementAt(int index) + { + UIElement element = context.GetOrCreateElementAt(index); + context.RecycleElement(element); + } + + internal void RemoveFromIndex(int index) + { + if (index >= items.Count) + { + // Item was added/removed but we haven't realized that far yet + return; + } + + int numToRemove = items.Count - index; + items.RemoveRange(index, numToRemove); + + foreach ((_, UniformStaggeredColumnLayout layout) in columnLayout) + { + Span layoutSpan = CollectionsMarshal.AsSpan(layout); + for (int i = 0; i < layoutSpan.Length; i++) + { + if (layoutSpan[i].Index >= index) + { + numToRemove = layoutSpan.Length - i; + layout.RemoveRange(i, numToRemove); + break; + } + } + } + } + + internal void RemoveRange(int startIndex, int endIndex) + { + for (int i = startIndex; i <= endIndex; i++) + { + if (i > items.Count) + { + break; + } + + ref readonly UniformStaggeredItem item = ref CollectionsMarshal.AsSpan(items)[i]; + item.Height = 0; + item.Top = 0; + + // We must recycle all elements to ensure that it gets the correct context + RecycleElementAt(i); + } + + foreach ((_, UniformStaggeredColumnLayout layout) in columnLayout) + { + Span layoutSpan = CollectionsMarshal.AsSpan(layout); + for (int i = 0; i < layoutSpan.Length; i++) + { + if ((startIndex <= layoutSpan[i].Index) && (layoutSpan[i].Index <= endIndex)) + { + int numToRemove = layoutSpan.Length - i; + layout.RemoveRange(i, numToRemove); + break; + } + } + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml b/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml index dd4b813d..8304b0b1 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml +++ b/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml @@ -9,11 +9,11 @@ mc:Ignorable="d"> diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml.cs b/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml.cs index 266d2a77..8ce1ca54 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml.cs +++ b/src/Snap.Hutao/Snap.Hutao/Control/Panel/PanelSelector.xaml.cs @@ -3,6 +3,7 @@ using CommunityToolkit.WinUI.Controls; using Microsoft.UI.Xaml; +using Snap.Hutao.Core.Setting; namespace Snap.Hutao.Control.Panel; @@ -11,6 +12,8 @@ namespace Snap.Hutao.Control.Panel; /// [HighQuality] [DependencyProperty("Current", typeof(string), List)] +[DependencyProperty("LocalSettingKeySuffixForCurrent", typeof(string))] +[DependencyProperty("LocalSettingKeyExtraForCurrent", typeof(string), "")] internal sealed partial class PanelSelector : Segmented { public const string List = nameof(List); @@ -42,21 +45,41 @@ internal sealed partial class PanelSelector : Segmented selectedIndexChangedCallbackToken = RegisterPropertyChangedCallback(SelectedIndexProperty, OnSelectedIndexChanged); } - private void OnSelectedIndexChanged(DependencyObject sender, DependencyProperty dp) - { - Current = IndexTypeMap[(int)GetValue(dp)]; - } - - private void OnRootLoaded(object sender, RoutedEventArgs e) + private static void OnSelectedIndexChanged(DependencyObject sender, DependencyProperty dp) { PanelSelector selector = (PanelSelector)sender; - selector.SelectedItem = selector.Items.Cast().Single(item => (string)item.Tag == Current); + selector.Current = IndexTypeMap[(int)selector.GetValue(dp)]; + + if (!string.IsNullOrEmpty(selector.LocalSettingKeySuffixForCurrent)) + { + LocalSetting.Set(GetSettingKey(selector), selector.Current); + } } - private void OnRootUnload(object sender, RoutedEventArgs e) + private static void OnRootLoaded(object sender, RoutedEventArgs e) { - UnregisterPropertyChangedCallback(SelectedIndexProperty, selectedIndexChangedCallbackToken); - Loaded -= loadedEventHandler; - Unloaded -= unloadedEventHandler; + PanelSelector selector = (PanelSelector)sender; + + if (string.IsNullOrEmpty(selector.LocalSettingKeySuffixForCurrent)) + { + return; + } + + string value = LocalSetting.Get(GetSettingKey(selector), selector.Current); + selector.Current = value; + + selector.SelectedItem = selector.Items.Cast().Single(item => (string)item.Tag == selector.Current); + } + + private static void OnRootUnload(object sender, RoutedEventArgs e) + { + PanelSelector selector = (PanelSelector)sender; + selector.UnregisterPropertyChangedCallback(SelectedIndexProperty, selector.selectedIndexChangedCallbackToken); + selector.Unloaded -= selector.unloadedEventHandler; + } + + private static string GetSettingKey(PanelSelector selector) + { + return $"Control.PanelSelector.{selector.LocalSettingKeySuffixForCurrent}{selector.LocalSettingKeyExtraForCurrent}"; } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml index 8e5db5ad..555f142e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml +++ b/src/Snap.Hutao/Snap.Hutao/Control/Theme/Glyph.xaml @@ -3,8 +3,18 @@ + + + + + + + + - + + + \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Control/ValueConverter.cs b/src/Snap.Hutao/Snap.Hutao/Control/ValueConverter.cs index c8b322cf..facb12b7 100644 --- a/src/Snap.Hutao/Snap.Hutao/Control/ValueConverter.cs +++ b/src/Snap.Hutao/Snap.Hutao/Control/ValueConverter.cs @@ -15,22 +15,7 @@ internal abstract class ValueConverter : IValueConverter /// public object? Convert(object value, Type targetType, object parameter, string language) { -#if DEBUG - try - { - return Convert((TFrom)value); - } - catch (Exception ex) - { - Ioc.Default - .GetRequiredService>>() - .LogError(ex, "值转换器异常"); - - throw; - } -#else return Convert((TFrom)value); -#endif } /// diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Threading/ITaskContext.cs b/src/Snap.Hutao/Snap.Hutao/Core/Threading/ITaskContext.cs index ab389d0e..10cf42bb 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/Threading/ITaskContext.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/Threading/ITaskContext.cs @@ -8,7 +8,7 @@ namespace Snap.Hutao.Core.Threading; /// internal interface ITaskContext { - IProgress CreateProgressForMainThread(Action handler); + SynchronizationContext GetSynchronizationContext(); /// /// 在主线程上同步等待执行操作 diff --git a/src/Snap.Hutao/Snap.Hutao/Core/Threading/TaskContext.cs b/src/Snap.Hutao/Snap.Hutao/Core/Threading/TaskContext.cs index d8aee9cd..44d97e0e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/Threading/TaskContext.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/Threading/TaskContext.cs @@ -42,8 +42,8 @@ internal sealed class TaskContext : ITaskContext dispatcherQueue.Invoke(action); } - public IProgress CreateProgressForMainThread(Action handler) + public SynchronizationContext GetSynchronizationContext() { - return new DispatcherQueueProgress(handler, synchronizationContext); + return synchronizationContext; } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialogFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/ContentDialogFactory.cs similarity index 84% rename from src/Snap.Hutao/Snap.Hutao/Factory/ContentDialogFactory.cs rename to src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/ContentDialogFactory.cs index d71f0964..ccc736c8 100644 --- a/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialogFactory.cs +++ b/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/ContentDialogFactory.cs @@ -3,9 +3,8 @@ using Microsoft.UI.Xaml.Controls; using Snap.Hutao.Core.LifeCycle; -using Snap.Hutao.Factory.Abstraction; -namespace Snap.Hutao.Factory; +namespace Snap.Hutao.Factory.ContentDialog; /// [HighQuality] @@ -13,15 +12,15 @@ namespace Snap.Hutao.Factory; [Injection(InjectAs.Singleton, typeof(IContentDialogFactory))] internal sealed partial class ContentDialogFactory : IContentDialogFactory { + private readonly ICurrentWindowReference currentWindowReference; private readonly IServiceProvider serviceProvider; private readonly ITaskContext taskContext; - private readonly ICurrentWindowReference currentWindowReference; /// public async ValueTask CreateForConfirmAsync(string title, string content) { await taskContext.SwitchToMainThreadAsync(); - ContentDialog dialog = new() + Microsoft.UI.Xaml.Controls.ContentDialog dialog = new() { XamlRoot = currentWindowReference.GetXamlRoot(), Title = title, @@ -37,7 +36,7 @@ internal sealed partial class ContentDialogFactory : IContentDialogFactory public async ValueTask CreateForConfirmCancelAsync(string title, string content, ContentDialogButton defaultButton = ContentDialogButton.Close) { await taskContext.SwitchToMainThreadAsync(); - ContentDialog dialog = new() + Microsoft.UI.Xaml.Controls.ContentDialog dialog = new() { XamlRoot = currentWindowReference.GetXamlRoot(), Title = title, @@ -51,10 +50,10 @@ internal sealed partial class ContentDialogFactory : IContentDialogFactory } /// - public async ValueTask CreateForIndeterminateProgressAsync(string title) + public async ValueTask CreateForIndeterminateProgressAsync(string title) { await taskContext.SwitchToMainThreadAsync(); - ContentDialog dialog = new() + Microsoft.UI.Xaml.Controls.ContentDialog dialog = new() { XamlRoot = currentWindowReference.GetXamlRoot(), Title = title, @@ -65,7 +64,7 @@ internal sealed partial class ContentDialogFactory : IContentDialogFactory } public async ValueTask CreateInstanceAsync(params object[] parameters) - where TContentDialog : ContentDialog + where TContentDialog : Microsoft.UI.Xaml.Controls.ContentDialog { await taskContext.SwitchToMainThreadAsync(); TContentDialog contentDialog = serviceProvider.CreateInstance(parameters); @@ -74,7 +73,7 @@ internal sealed partial class ContentDialogFactory : IContentDialogFactory } public TContentDialog CreateInstance(params object[] parameters) - where TContentDialog : ContentDialog + where TContentDialog : Microsoft.UI.Xaml.Controls.ContentDialog { TContentDialog contentDialog = serviceProvider.CreateInstance(parameters); contentDialog.XamlRoot = currentWindowReference.GetXamlRoot(); diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IContentDialogFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/IContentDialogFactory.cs similarity index 81% rename from src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IContentDialogFactory.cs rename to src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/IContentDialogFactory.cs index b09c2f6f..00c434c3 100644 --- a/src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IContentDialogFactory.cs +++ b/src/Snap.Hutao/Snap.Hutao/Factory/ContentDialog/IContentDialogFactory.cs @@ -3,7 +3,7 @@ using Microsoft.UI.Xaml.Controls; -namespace Snap.Hutao.Factory.Abstraction; +namespace Snap.Hutao.Factory.ContentDialog; /// /// 内容对话框工厂 @@ -33,11 +33,11 @@ internal interface IContentDialogFactory /// /// 标题 /// 内容对话框 - ValueTask CreateForIndeterminateProgressAsync(string title); + ValueTask CreateForIndeterminateProgressAsync(string title); TContentDialog CreateInstance(params object[] parameters) - where TContentDialog : ContentDialog; + where TContentDialog : Microsoft.UI.Xaml.Controls.ContentDialog; ValueTask CreateInstanceAsync(params object[] parameters) - where TContentDialog : ContentDialog; + where TContentDialog : Microsoft.UI.Xaml.Controls.ContentDialog; } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IPickerFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/Picker/IPickerFactory.cs similarity index 97% rename from src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IPickerFactory.cs rename to src/Snap.Hutao/Snap.Hutao/Factory/Picker/IPickerFactory.cs index 7252d9c0..353c6146 100644 --- a/src/Snap.Hutao/Snap.Hutao/Factory/Abstraction/IPickerFactory.cs +++ b/src/Snap.Hutao/Snap.Hutao/Factory/Picker/IPickerFactory.cs @@ -3,7 +3,7 @@ using Windows.Storage.Pickers; -namespace Snap.Hutao.Factory.Abstraction; +namespace Snap.Hutao.Factory.Picker; /// /// 文件选择器工厂 diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/PickerFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/Picker/PickerFactory.cs similarity index 85% rename from src/Snap.Hutao/Snap.Hutao/Factory/PickerFactory.cs rename to src/Snap.Hutao/Snap.Hutao/Factory/Picker/PickerFactory.cs index bd177eb6..4000235b 100644 --- a/src/Snap.Hutao/Snap.Hutao/Factory/PickerFactory.cs +++ b/src/Snap.Hutao/Snap.Hutao/Factory/Picker/PickerFactory.cs @@ -4,11 +4,10 @@ using Snap.Hutao.Core; using Snap.Hutao.Core.LifeCycle; using Snap.Hutao.Core.Windowing; -using Snap.Hutao.Factory.Abstraction; using Windows.Storage.Pickers; using WinRT.Interop; -namespace Snap.Hutao.Factory; +namespace Snap.Hutao.Factory.Picker; /// [HighQuality] @@ -18,7 +17,7 @@ internal sealed partial class PickerFactory : IPickerFactory { private const string AnyType = "*"; - private readonly ICurrentWindowReference currentWindow; + private readonly ICurrentWindowReference currentWindowReference; /// public FileOpenPicker GetFileOpenPicker(PickerLocationId location, string commitButton, params string[] fileTypes) @@ -80,10 +79,11 @@ internal sealed partial class PickerFactory : IPickerFactory { // Create a folder picker. T picker = new(); - if (currentWindow.Window is IWindowOptionsSource optionsSource) - { - InitializeWithWindow.Initialize(picker, optionsSource.WindowOptions.Hwnd); - } + nint hwnd = currentWindowReference.Window is IWindowOptionsSource optionsSource + ? (nint)optionsSource.WindowOptions.Hwnd + : WindowNative.GetWindowHandle(currentWindowReference.Window); + + InitializeWithWindow.Initialize(picker, hwnd); return picker; } diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/Progress/IProgressFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/Progress/IProgressFactory.cs new file mode 100644 index 00000000..016c2235 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Factory/Progress/IProgressFactory.cs @@ -0,0 +1,6 @@ +namespace Snap.Hutao.Factory.Progress; + +internal interface IProgressFactory +{ + IProgress CreateForMainThread(Action handler); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Factory/Progress/ProgressFactory.cs b/src/Snap.Hutao/Snap.Hutao/Factory/Progress/ProgressFactory.cs new file mode 100644 index 00000000..197845e9 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Factory/Progress/ProgressFactory.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Snap.Hutao.Factory.Progress; + +[ConstructorGenerated] +[Injection(InjectAs.Transient, typeof(IProgressFactory))] +internal sealed partial class ProgressFactory : IProgressFactory +{ + private readonly ITaskContext taskContext; + + public IProgress CreateForMainThread(Action handler) + { + return new DispatcherQueueProgress(handler, taskContext.GetSynchronizationContext()); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.Designer.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.Designer.cs new file mode 100644 index 00000000..2e90947e --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.Designer.cs @@ -0,0 +1,549 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Snap.Hutao.Model.Entity.Database; + +#nullable disable + +namespace Snap.Hutao.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20231103032056_AddUserFingerprint")] + partial class AddUserFingerprint + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.13"); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ArchiveId") + .HasColumnType("TEXT"); + + b.Property("Current") + .HasColumnType("INTEGER"); + + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.HasIndex("ArchiveId"); + + b.ToTable("achievements"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.AchievementArchive", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsSelected") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.ToTable("achievement_archives"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.AvatarInfo", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CalculatorRefreshTime") + .HasColumnType("TEXT"); + + b.Property("GameRecordRefreshTime") + .HasColumnType("TEXT"); + + b.Property("Info") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ShowcaseRefreshTime") + .HasColumnType("TEXT"); + + b.Property("Uid") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.ToTable("avatar_infos"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("InnerId"); + + b.HasIndex("ProjectId"); + + b.ToTable("cultivate_entries"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("IsFinished") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.HasKey("InnerId"); + + b.HasIndex("EntryId"); + + b.ToTable("cultivate_items"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateProject", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachedUid") + .HasColumnType("TEXT"); + + b.Property("IsSelected") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.ToTable("cultivate_projects"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("DailyNote") + .HasColumnType("TEXT"); + + b.Property("DailyTaskNotify") + .HasColumnType("INTEGER"); + + b.Property("DailyTaskNotifySuppressed") + .HasColumnType("INTEGER"); + + b.Property("ExpeditionNotify") + .HasColumnType("INTEGER"); + + b.Property("ExpeditionNotifySuppressed") + .HasColumnType("INTEGER"); + + b.Property("HomeCoinNotifySuppressed") + .HasColumnType("INTEGER"); + + b.Property("HomeCoinNotifyThreshold") + .HasColumnType("INTEGER"); + + b.Property("RefreshTime") + .HasColumnType("TEXT"); + + b.Property("ResinNotifySuppressed") + .HasColumnType("INTEGER"); + + b.Property("ResinNotifyThreshold") + .HasColumnType("INTEGER"); + + b.Property("TransformerNotify") + .HasColumnType("INTEGER"); + + b.Property("TransformerNotifySuppressed") + .HasColumnType("INTEGER"); + + b.Property("Uid") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.HasIndex("UserId"); + + b.ToTable("daily_notes"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaArchive", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IsSelected") + .HasColumnType("INTEGER"); + + b.Property("Uid") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.ToTable("gacha_archives"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ArchiveId") + .HasColumnType("TEXT"); + + b.Property("GachaType") + .HasColumnType("INTEGER"); + + b.Property("Id") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("QueryType") + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.HasIndex("ArchiveId"); + + b.ToTable("gacha_items"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.GameAccount", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachUid") + .HasColumnType("TEXT"); + + b.Property("MihoyoSDK") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("InnerId"); + + b.ToTable("game_accounts"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.HasIndex("ProjectId"); + + b.ToTable("inventory_items"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryReliquary", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AppendPropIdList") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("MainPropId") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.HasIndex("ProjectId"); + + b.ToTable("inventory_reliquaries"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryWeapon", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("INTEGER"); + + b.Property("Level") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("PromoteLevel") + .HasColumnType("INTEGER"); + + b.HasKey("InnerId"); + + b.HasIndex("ProjectId"); + + b.ToTable("inventory_weapons"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.ObjectCacheEntry", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("ExpireTime") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("object_cache"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.SettingEntry", b => + { + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("settings"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.SpiralAbyssEntry", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ScheduleId") + .HasColumnType("INTEGER"); + + b.Property("SpiralAbyss") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Uid") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("InnerId"); + + b.ToTable("spiral_abysses"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.User", b => + { + b.Property("InnerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Aid") + .HasColumnType("TEXT"); + + b.Property("CookieToken") + .HasColumnType("TEXT"); + + b.Property("Fingerprint") + .HasColumnType("TEXT"); + + b.Property("IsOversea") + .HasColumnType("INTEGER"); + + b.Property("IsSelected") + .HasColumnType("INTEGER"); + + b.Property("LToken") + .HasColumnType("TEXT") + .HasColumnName("Ltoken"); + + b.Property("Mid") + .HasColumnType("TEXT"); + + b.Property("SToken") + .HasColumnType("TEXT") + .HasColumnName("Stoken"); + + b.HasKey("InnerId"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b => + { + b.HasOne("Snap.Hutao.Model.Entity.AchievementArchive", "Archive") + .WithMany() + .HasForeignKey("ArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Archive"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b => + { + b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b => + { + b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "Entry") + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Entry"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b => + { + b.HasOne("Snap.Hutao.Model.Entity.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b => + { + b.HasOne("Snap.Hutao.Model.Entity.GachaArchive", "Archive") + .WithMany() + .HasForeignKey("ArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Archive"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b => + { + b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryReliquary", b => + { + b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryWeapon", b => + { + b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.cs new file mode 100644 index 00000000..772c35fe --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Migrations/20231103032056_AddUserFingerprint.cs @@ -0,0 +1,29 @@ +// +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Snap.Hutao.Migrations +{ + /// + public partial class AddUserFingerprint : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Fingerprint", + table: "users", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Fingerprint", + table: "users"); + } + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Migrations/AppDbContextModelSnapshot.cs b/src/Snap.Hutao/Snap.Hutao/Migrations/AppDbContextModelSnapshot.cs index 646ae427..dd8d37f4 100644 --- a/src/Snap.Hutao/Snap.Hutao/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Snap.Hutao/Snap.Hutao/Migrations/AppDbContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Snap.Hutao.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.10"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.13"); modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b => { @@ -400,7 +400,7 @@ namespace Snap.Hutao.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); - b.Property("ScheduleId") + b.Property("ScheduleId") .HasColumnType("INTEGER"); b.Property("SpiralAbyss") @@ -428,6 +428,9 @@ namespace Snap.Hutao.Migrations b.Property("CookieToken") .HasColumnType("TEXT"); + b.Property("Fingerprint") + .HasColumnType("TEXT"); + b.Property("IsOversea") .HasColumnType("INTEGER"); diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Entity/SettingEntry.Constant.cs b/src/Snap.Hutao/Snap.Hutao/Model/Entity/SettingEntry.Constant.cs index 5c96234d..b6e00850 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/Entity/SettingEntry.Constant.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/Entity/SettingEntry.Constant.cs @@ -53,6 +53,11 @@ internal sealed partial class SettingEntry /// public const string DailyNoteWebhookUrl = "DailyNote.WebhookUrl"; + /// + /// 启动游戏 总开关 + /// + public const string LaunchIsLaunchOptionsEnabled = "Launch.IsLaunchOptionsEnabled"; + /// /// 启动游戏 独占全屏 /// @@ -99,6 +104,8 @@ internal sealed partial class SettingEntry public const string LaunchIsMonitorEnabled = "Launch.IsMonitorEnabled"; + public const string LaunchUseStarwardPlayTimeStatistics = "Launch.UseStarwardPlayTimeStatistics"; + /// /// 启动游戏 多倍启动 /// diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs b/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs index 9a45b4ef..0b9f7bf0 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/Entity/User.cs @@ -60,6 +60,11 @@ internal sealed class User : ISelectable, IMappingFrom /// public bool IsOversea { get; set; } + /// + /// 用户指纹 Id + /// + public string? Fingerprint { get; set; } + /// /// 创建一个新的用户 /// diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/Arkhe.cs b/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/Arkhe.cs index cd236288..3ad89f47 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/Arkhe.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/Arkhe.cs @@ -14,12 +14,17 @@ internal enum Arkhe None, /// - /// 芒性 + /// 荒性 /// Ousia, /// - /// 荒性 + /// 芒性 /// Pneuma, + + /// + /// 圣俗杂座 + /// + Furina, } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/MaterialType.cs b/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/MaterialType.cs index 41d7db22..5d094fdc 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/MaterialType.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/Intrinsic/MaterialType.cs @@ -59,4 +59,5 @@ internal enum MaterialType MATERIAL_GCG_EXCHANGE_ITEM = 48, MATERIAL_QUEST_EVENT_BOOK = 49, MATERIAL_PROFILE_PICTURE = 50, + MATERIAL_RAINBOW_PRINCE_HAND_BOOK = 51, } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Model/Metadata/MonsterRelationship.cs b/src/Snap.Hutao/Snap.Hutao/Model/Metadata/MonsterRelationship.cs index e8f157d9..2493b104 100644 --- a/src/Snap.Hutao/Snap.Hutao/Model/Metadata/MonsterRelationship.cs +++ b/src/Snap.Hutao/Snap.Hutao/Model/Metadata/MonsterRelationship.cs @@ -19,6 +19,8 @@ internal static class MonsterRelationship 5071U => 507U, // 幻形花鼠 · 水 (强化) 5102U => 510U, // 历经百战的浊水粉碎幻灵 5112U => 511U, // 历经百战的浊水喷吐幻灵 + 30605U => 30603U, // 历经百战的霜剑律从 + 30606U => 30604U, // 历经百战的幽风铃兰 60402U => 60401U, // (火)岩龙蜥 60403U => 60401U, // (冰)岩龙蜥 60404U => 60401U, // (雷)岩龙蜥 diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index 27e1b0e2..9a8c6ef5 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -1967,6 +1967,12 @@ 高级功能 + + 快速切换到指定的分辨率 + + + 分辨率 + 将窗口创建为弹出窗口,不带框架 @@ -2015,6 +2021,9 @@ 文件 + + 进程间 + 在指定的显示器上运行 @@ -2030,6 +2039,12 @@ 游戏选项 + + 在游戏启动后尝试启动并使用 Starward 进行游戏时长统计 + + + 时长统计 + 进程 @@ -2726,6 +2741,9 @@ 武器活动祈愿 + + 下载链接复制成功 + 验证失败,请手动验证或前往「米游社-我的角色」页面查看 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs b/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs index b51db023..3e9834e3 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/AppOptions.cs @@ -106,6 +106,8 @@ internal sealed partial class AppOptions : DbStoreOptions /// /// 是否启用高级功能 + /// DO NOT MOVE TO OTHER CLASS + /// We are binding this property in SettingPage /// public bool IsAdvancedLaunchOptionsEnabled { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/AppOptionsExtension.cs b/src/Snap.Hutao/Snap.Hutao/Service/AppOptionsExtension.cs new file mode 100644 index 00000000..07fccf2f --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/AppOptionsExtension.cs @@ -0,0 +1,42 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using System.IO; + +namespace Snap.Hutao.Service; + +internal static class AppOptionsExtension +{ + public static bool TryGetGameFolderAndFileName(this AppOptions appOptions, [NotNullWhen(true)] out string? gameFolder, [NotNullWhen(true)] out string? gameFileName) + { + string gamePath = appOptions.GamePath; + + gameFolder = Path.GetDirectoryName(gamePath); + if (string.IsNullOrEmpty(gameFolder)) + { + gameFileName = default; + return false; + } + + gameFileName = Path.GetFileName(gamePath); + if (string.IsNullOrEmpty(gameFileName)) + { + return false; + } + + return true; + } + + public static bool TryGetGameFileName(this AppOptions appOptions, [NotNullWhen(true)] out string? gameFileName) + { + string gamePath = appOptions.GamePath; + + gameFileName = Path.GetFileName(gamePath); + if (string.IsNullOrEmpty(gameFileName)) + { + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotificationOperation.cs b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotificationOperation.cs index 2286856b..1c5890ce 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotificationOperation.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/DailyNote/DailyNoteNotificationOperation.cs @@ -24,7 +24,7 @@ internal sealed partial class DailyNoteNotificationOperation private const string ToastAttributionUnknown = "Unknown"; private readonly ITaskContext taskContext; - private readonly IGameService gameService; + private readonly IGameServiceFacade gameService; private readonly BindingClient bindingClient; private readonly DailyNoteOptions options; diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/Factory/TypedWishSummaryBuilder.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/Factory/TypedWishSummaryBuilder.cs index 917583f1..e6392eee 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/Factory/TypedWishSummaryBuilder.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/Factory/TypedWishSummaryBuilder.cs @@ -120,6 +120,7 @@ internal sealed class TypedWishSummaryBuilder { // base Name = context.Name, + TypeName = $"{context.DistributionType:D}", From = fromTimeTracker, To = toTimeTracker, TotalCount = totalCountTracker, diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs index 9c797a72..2a2bbc38 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryManualInputProvider.cs @@ -1,7 +1,7 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. -using Snap.Hutao.Factory.Abstraction; +using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Service.Metadata; using Snap.Hutao.View.Dialog; using Snap.Hutao.Web.Request.QueryString; diff --git a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryWebCacheProvider.cs b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryWebCacheProvider.cs index 45d02c9c..635c336c 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryWebCacheProvider.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/GachaLog/QueryProvider/GachaLogQueryWebCacheProvider.cs @@ -19,7 +19,7 @@ namespace Snap.Hutao.Service.GachaLog.QueryProvider; [Injection(InjectAs.Transient)] internal sealed partial class GachaLogQueryWebCacheProvider : IGachaLogQueryProvider { - private readonly IGameService gameService; + private readonly IGameServiceFacade gameService; private readonly MetadataOptions metadataOptions; /// diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/GameAccountService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/GameAccountService.cs new file mode 100644 index 00000000..bc5884ca --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/GameAccountService.cs @@ -0,0 +1,133 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core.ExceptionService; +using Snap.Hutao.Factory.ContentDialog; +using Snap.Hutao.Model.Entity; +using Snap.Hutao.View.Dialog; +using System.Collections.ObjectModel; + +namespace Snap.Hutao.Service.Game.Account; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGameAccountService))] +internal sealed partial class GameAccountService : IGameAccountService +{ + private readonly IContentDialogFactory contentDialogFactory; + private readonly IServiceProvider serviceProvider; + private readonly IGameDbService gameDbService; + private readonly ITaskContext taskContext; + private readonly AppOptions appOptions; + + private ObservableCollection? gameAccounts; + + public ObservableCollection GameAccountCollection + { + get => gameAccounts ??= gameDbService.GetGameAccountCollection(); + } + + public async ValueTask DetectGameAccountAsync() + { + ArgumentNullException.ThrowIfNull(gameAccounts); + + string? registrySdk = RegistryInterop.Get(); + if (!string.IsNullOrEmpty(registrySdk)) + { + GameAccount? account = null; + try + { + account = gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk); + } + catch (InvalidOperationException ex) + { + ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex); + } + + if (account is null) + { + LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync().ConfigureAwait(false); + (bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false); + + if (isOk) + { + account = GameAccount.From(name, registrySdk); + + // sync database + await taskContext.SwitchToBackgroundAsync(); + await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false); + + // sync cache + await taskContext.SwitchToMainThreadAsync(); + gameAccounts.Add(account); + } + } + + return account; + } + + return default; + } + + public GameAccount? DetectCurrentGameAccount() + { + ArgumentNullException.ThrowIfNull(gameAccounts); + + string? registrySdk = RegistryInterop.Get(); + + if (!string.IsNullOrEmpty(registrySdk)) + { + try + { + return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk); + } + catch (InvalidOperationException ex) + { + ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex); + } + } + + return null; + } + + public bool SetGameAccount(GameAccount account) + { + if (string.IsNullOrEmpty(appOptions.PowerShellPath)) + { + ThrowHelper.RuntimeEnvironment(SH.ServiceGameRegisteryInteropPowershellNotFound, default!); + } + + return RegistryInterop.Set(account, appOptions.PowerShellPath); + } + + public void AttachGameAccountToUid(GameAccount gameAccount, string uid) + { + gameAccount.UpdateAttachUid(uid); + gameDbService.UpdateGameAccount(gameAccount); + } + + public async ValueTask ModifyGameAccountAsync(GameAccount gameAccount) + { + await taskContext.SwitchToMainThreadAsync(); + LaunchGameAccountNameDialog dialog = serviceProvider.CreateInstance(); + (bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(true); + + if (isOk) + { + gameAccount.UpdateName(name); + + // sync database + await taskContext.SwitchToBackgroundAsync(); + await gameDbService.UpdateGameAccountAsync(gameAccount).ConfigureAwait(false); + } + } + + public async ValueTask RemoveGameAccountAsync(GameAccount gameAccount) + { + await taskContext.SwitchToMainThreadAsync(); + ArgumentNullException.ThrowIfNull(gameAccounts); + gameAccounts.Remove(gameAccount); + + await taskContext.SwitchToBackgroundAsync(); + await gameDbService.RemoveGameAccountByIdAsync(gameAccount.InnerId).ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/IGameAccountService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/IGameAccountService.cs new file mode 100644 index 00000000..108a35a6 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/IGameAccountService.cs @@ -0,0 +1,24 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Model.Entity; +using System.Collections.ObjectModel; + +namespace Snap.Hutao.Service.Game.Account; + +internal interface IGameAccountService +{ + ObservableCollection GameAccountCollection { get; } + + void AttachGameAccountToUid(GameAccount gameAccount, string uid); + + GameAccount? DetectCurrentGameAccount(); + + ValueTask DetectGameAccountAsync(); + + ValueTask ModifyGameAccountAsync(GameAccount gameAccount); + + ValueTask RemoveGameAccountAsync(GameAccount gameAccount); + + bool SetGameAccount(GameAccount account); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/RegistryInterop.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/RegistryInterop.cs similarity index 96% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/RegistryInterop.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Account/RegistryInterop.cs index 97bbd4d5..513fb4a2 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/RegistryInterop.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Account/RegistryInterop.cs @@ -9,7 +9,7 @@ using System.IO; using System.Runtime.InteropServices; using System.Text; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Account; /// /// 注册表操作 @@ -55,7 +55,7 @@ internal static class RegistryInterop try { - Process.Start(startInfo)?.WaitForExit(); + System.Diagnostics.Process.Start(startInfo)?.WaitForExit(); } catch (Win32Exception ex) { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/ChannelOptions.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/ChannelOptions.cs similarity index 97% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/ChannelOptions.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/ChannelOptions.cs index 8ebb4027..5c335637 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/ChannelOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/ChannelOptions.cs @@ -3,7 +3,7 @@ using Snap.Hutao.Model.Intrinsic; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Configuration; /// /// 多通道 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/GameChannelOptionsService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/GameChannelOptionsService.cs new file mode 100644 index 00000000..20db8658 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/GameChannelOptionsService.cs @@ -0,0 +1,95 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core.ExceptionService; +using Snap.Hutao.Core.IO.Ini; +using Snap.Hutao.Service.Game.Scheme; +using System.IO; +using static Snap.Hutao.Service.Game.GameConstants; + +namespace Snap.Hutao.Service.Game.Configuration; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGameChannelOptionsService))] +internal sealed partial class GameChannelOptionsService : IGameChannelOptionsService +{ + private readonly AppOptions appOptions; + + public ChannelOptions GetChannelOptions() + { + string gamePath = appOptions.GamePath; + string configPath = Path.Combine(Path.GetDirectoryName(gamePath) ?? string.Empty, ConfigFileName); + bool isOversea = string.Equals(Path.GetFileName(gamePath), GenshinImpactFileName, StringComparison.OrdinalIgnoreCase); + + if (!File.Exists(configPath)) + { + return ChannelOptions.FileNotFound(isOversea, configPath); + } + + using (FileStream stream = File.OpenRead(configPath)) + { + List parameters = IniSerializer.Deserialize(stream).OfType().ToList(); + string? channel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.ChannelName)?.Value; + string? subChannel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.SubChannelName)?.Value; + + return new(channel, subChannel, isOversea); + } + } + + public bool SetChannelOptions(LaunchScheme scheme) + { + string gamePath = appOptions.GamePath; + string? directory = Path.GetDirectoryName(gamePath); + ArgumentException.ThrowIfNullOrEmpty(directory); + string configPath = Path.Combine(directory, ConfigFileName); + + List elements = default!; + try + { + using (FileStream readStream = File.OpenRead(configPath)) + { + elements = IniSerializer.Deserialize(readStream).ToList(); + } + } + catch (FileNotFoundException ex) + { + ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex); + } + catch (DirectoryNotFoundException ex) + { + ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex); + } + catch (UnauthorizedAccessException ex) + { + ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelUnauthorizedAccess, ex); + } + + bool changed = false; + + foreach (IniElement element in elements) + { + if (element is IniParameter parameter) + { + if (parameter.Key == "channel") + { + changed = parameter.Set(scheme.Channel.ToString("D")) || changed; + } + + if (parameter.Key == "sub_channel") + { + changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed; + } + } + } + + if (changed) + { + using (FileStream writeStream = File.Create(configPath)) + { + IniSerializer.Serialize(writeStream, elements); + } + } + + return changed; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IGameChannelOptionsService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IGameChannelOptionsService.cs new file mode 100644 index 00000000..a07fbc9a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IGameChannelOptionsService.cs @@ -0,0 +1,13 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Service.Game.Scheme; + +namespace Snap.Hutao.Service.Game.Configuration; + +internal interface IGameChannelOptionsService +{ + ChannelOptions GetChannelOptions(); + + bool SetChannelOptions(LaunchScheme scheme); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/IgnoredInvalidChannelOptions.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IgnoredInvalidChannelOptions.cs similarity index 91% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/IgnoredInvalidChannelOptions.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IgnoredInvalidChannelOptions.cs index eac6d975..1d0e6266 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/IgnoredInvalidChannelOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Configuration/IgnoredInvalidChannelOptions.cs @@ -4,7 +4,7 @@ using Snap.Hutao.Model.Intrinsic; using System.Collections.Immutable; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Configuration; internal static class IgnoredInvalidChannelOptions { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/GamePathService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/GamePathService.cs new file mode 100644 index 00000000..7f773c77 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/GamePathService.cs @@ -0,0 +1,60 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Service.Game.Locator; + +namespace Snap.Hutao.Service.Game; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGamePathService))] +internal sealed partial class GamePathService : IGamePathService +{ + private readonly IServiceProvider serviceProvider; + private readonly AppOptions appOptions; + + public async ValueTask> SilentGetGamePathAsync() + { + // Cannot find in setting + if (string.IsNullOrEmpty(appOptions.GamePath)) + { + IGameLocatorFactory locatorFactory = serviceProvider.GetRequiredService(); + + bool isOk; + string path; + + // Try locate by unity log + (isOk, path) = await locatorFactory + .Create(GameLocationSource.UnityLog) + .LocateGamePathAsync() + .ConfigureAwait(false); + + if (!isOk) + { + // Try locate by registry + (isOk, path) = await locatorFactory + .Create(GameLocationSource.Registry) + .LocateGamePathAsync() + .ConfigureAwait(false); + } + + if (isOk) + { + // Save result. + appOptions.GamePath = path; + } + else + { + return new(false, SH.ServiceGamePathLocateFailed); + } + } + + if (!string.IsNullOrEmpty(appOptions.GamePath)) + { + return new(true, appOptions.GamePath); + } + else + { + return new(false, default!); + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/GameService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/GameService.cs deleted file mode 100644 index 25ff0bcd..00000000 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/GameService.cs +++ /dev/null @@ -1,395 +0,0 @@ -// Copyright (c) DGP Studio. All rights reserved. -// Licensed under the MIT license. - -using Snap.Hutao.Core; -using Snap.Hutao.Core.ExceptionService; -using Snap.Hutao.Core.IO.Ini; -using Snap.Hutao.Factory.Abstraction; -using Snap.Hutao.Model.Entity; -using Snap.Hutao.Service.Game.Locator; -using Snap.Hutao.Service.Game.Package; -using Snap.Hutao.View.Dialog; -using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher; -using Snap.Hutao.Web.Response; -using System.Collections.ObjectModel; -using System.Diagnostics; -using System.IO; -using static Snap.Hutao.Service.Game.GameConstants; - -namespace Snap.Hutao.Service.Game; - -/// -/// 游戏服务 -/// -[HighQuality] -[ConstructorGenerated] -[Injection(InjectAs.Singleton, typeof(IGameService))] -internal sealed partial class GameService : IGameService -{ - private readonly IContentDialogFactory contentDialogFactory; - private readonly PackageConverter packageConverter; - private readonly IServiceProvider serviceProvider; - private readonly IGameDbService gameDbService; - private readonly LaunchOptions launchOptions; - private readonly RuntimeOptions runtimeOptions; - private readonly ITaskContext taskContext; - private readonly AppOptions appOptions; - - private volatile int runningGamesCounter; - private ObservableCollection? gameAccounts; - - /// - public ObservableCollection GameAccountCollection - { - get => gameAccounts ??= gameDbService.GetGameAccountCollection(); - } - - /// - public async ValueTask> GetGamePathAsync() - { - // Cannot find in setting - if (string.IsNullOrEmpty(appOptions.GamePath)) - { - IGameLocatorFactory locatorFactory = serviceProvider.GetRequiredService(); - - // Try locate by unity log - ValueResult result = await locatorFactory - .Create(GameLocationSource.UnityLog) - .LocateGamePathAsync() - .ConfigureAwait(false); - - if (!result.IsOk) - { - // Try locate by registry - result = await locatorFactory - .Create(GameLocationSource.Registry) - .LocateGamePathAsync() - .ConfigureAwait(false); - } - - if (result.IsOk) - { - // Save result. - appOptions.GamePath = result.Value; - } - else - { - return new(false, SH.ServiceGamePathLocateFailed); - } - } - - if (!string.IsNullOrEmpty(appOptions.GamePath)) - { - return new(true, appOptions.GamePath); - } - else - { - return new(false, default!); - } - } - - /// - public ChannelOptions GetChannelOptions() - { - string gamePath = appOptions.GamePath; - string configPath = Path.Combine(Path.GetDirectoryName(gamePath) ?? string.Empty, ConfigFileName); - bool isOversea = string.Equals(Path.GetFileName(gamePath), GenshinImpactFileName, StringComparison.OrdinalIgnoreCase); - - if (!File.Exists(configPath)) - { - return ChannelOptions.FileNotFound(isOversea, configPath); - } - - using (FileStream stream = File.OpenRead(configPath)) - { - List parameters = IniSerializer.Deserialize(stream).OfType().ToList(); - string? channel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.ChannelName)?.Value; - string? subChannel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.SubChannelName)?.Value; - - return new(channel, subChannel, isOversea); - } - } - - /// - public bool SetChannelOptions(LaunchScheme scheme) - { - string gamePath = appOptions.GamePath; - string? directory = Path.GetDirectoryName(gamePath); - ArgumentException.ThrowIfNullOrEmpty(directory); - string configPath = Path.Combine(directory, ConfigFileName); - - List elements = default!; - try - { - using (FileStream readStream = File.OpenRead(configPath)) - { - elements = IniSerializer.Deserialize(readStream).ToList(); - } - } - catch (FileNotFoundException ex) - { - ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex); - } - catch (DirectoryNotFoundException ex) - { - ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex); - } - catch (UnauthorizedAccessException ex) - { - ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelUnauthorizedAccess, ex); - } - - bool changed = false; - - foreach (IniElement element in elements) - { - if (element is IniParameter parameter) - { - if (parameter.Key == "channel") - { - changed = parameter.Set(scheme.Channel.ToString("D")) || changed; - } - - if (parameter.Key == "sub_channel") - { - changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed; - } - } - } - - if (changed) - { - using (FileStream writeStream = File.Create(configPath)) - { - IniSerializer.Serialize(writeStream, elements); - } - } - - return changed; - } - - /// - public async ValueTask EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress progress) - { - string gamePath = appOptions.GamePath; - string? gameFolder = Path.GetDirectoryName(gamePath); - ArgumentException.ThrowIfNullOrEmpty(gameFolder); - string gameFileName = Path.GetFileName(gamePath); - - progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation)); - Response response = await serviceProvider - .GetRequiredService() - .GetResourceAsync(launchScheme) - .ConfigureAwait(false); - - if (response.IsOk()) - { - GameResource resource = response.Data; - - if (!launchScheme.ExecutableMatches(gameFileName)) - { - bool replaced = await packageConverter - .EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress) - .ConfigureAwait(false); - - if (replaced) - { - // We need to change the gamePath if we switched. - string exeName = launchScheme.IsOversea ? GenshinImpactFileName : YuanShenFileName; - - await taskContext.SwitchToMainThreadAsync(); - appOptions.GamePath = Path.Combine(gameFolder, exeName); - } - else - { - // We can't start the game - // when we failed to convert game - return false; - } - } - - await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false); - - return true; - } - - return false; - } - - /// - public bool IsGameRunning() - { - if (runningGamesCounter == 0) - { - return false; - } - - return Process.GetProcessesByName(YuanShenProcessName).Any() - || Process.GetProcessesByName(GenshinImpactProcessName).Any(); - } - - /// - public async ValueTask LaunchAsync(IProgress progress) - { - if (IsGameRunning()) - { - return; - } - - string gamePath = appOptions.GamePath; - ArgumentException.ThrowIfNullOrEmpty(gamePath); - - progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing)); - using (Process game = ProcessInterop.InitializeGameProcess(launchOptions, gamePath)) - { - try - { - Interlocked.Increment(ref runningGamesCounter); - game.Start(); - progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted)); - - if (runtimeOptions.IsElevated && appOptions.IsAdvancedLaunchOptionsEnabled && launchOptions.UnlockFps) - { - progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps)); - try - { - await ProcessInterop.UnlockFpsAsync(serviceProvider, game, progress).ConfigureAwait(false); - } - catch (InvalidOperationException) - { - // The Unlocker can't unlock the process - game.Kill(); - throw; - } - finally - { - progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited)); - } - } - else - { - progress.Report(new(LaunchPhase.WaitingForExit, SH.ServiceGameLaunchPhaseWaitingProcessExit)); - await game.WaitForExitAsync().ConfigureAwait(false); - progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited)); - } - } - finally - { - Interlocked.Decrement(ref runningGamesCounter); - } - } - } - - /// - public async ValueTask DetectGameAccountAsync() - { - ArgumentNullException.ThrowIfNull(gameAccounts); - - string? registrySdk = RegistryInterop.Get(); - if (!string.IsNullOrEmpty(registrySdk)) - { - GameAccount? account = null; - try - { - account = gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk); - } - catch (InvalidOperationException ex) - { - ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex); - } - - if (account is null) - { - // ContentDialog must be created by main thread. - await taskContext.SwitchToMainThreadAsync(); - LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync().ConfigureAwait(false); - (bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false); - - if (isOk) - { - account = GameAccount.From(name, registrySdk); - - // sync database - await taskContext.SwitchToBackgroundAsync(); - await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false); - - // sync cache - await taskContext.SwitchToMainThreadAsync(); - gameAccounts.Add(account); - } - } - - return account; - } - - return default; - } - - /// - public GameAccount? DetectCurrentGameAccount() - { - ArgumentNullException.ThrowIfNull(gameAccounts); - - string? registrySdk = RegistryInterop.Get(); - - if (!string.IsNullOrEmpty(registrySdk)) - { - try - { - return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk); - } - catch (InvalidOperationException ex) - { - ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex); - } - } - - return null; - } - - /// - public bool SetGameAccount(GameAccount account) - { - if (string.IsNullOrEmpty(appOptions.PowerShellPath)) - { - ThrowHelper.RuntimeEnvironment(SH.ServiceGameRegisteryInteropPowershellNotFound, default!); - } - - return RegistryInterop.Set(account, appOptions.PowerShellPath); - } - - /// - public void AttachGameAccountToUid(GameAccount gameAccount, string uid) - { - gameAccount.UpdateAttachUid(uid); - gameDbService.UpdateGameAccount(gameAccount); - } - - /// - public async ValueTask ModifyGameAccountAsync(GameAccount gameAccount) - { - await taskContext.SwitchToMainThreadAsync(); - LaunchGameAccountNameDialog dialog = serviceProvider.CreateInstance(); - (bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(true); - - if (isOk) - { - gameAccount.UpdateName(name); - - // sync database - await taskContext.SwitchToBackgroundAsync(); - await gameDbService.UpdateGameAccountAsync(gameAccount).ConfigureAwait(false); - } - } - - /// - public async ValueTask RemoveGameAccountAsync(GameAccount gameAccount) - { - await taskContext.SwitchToMainThreadAsync(); - ArgumentNullException.ThrowIfNull(gameAccounts); - gameAccounts.Remove(gameAccount); - - await taskContext.SwitchToBackgroundAsync(); - await gameDbService.RemoveGameAccountByIdAsync(gameAccount.InnerId).ConfigureAwait(false); - } -} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/GameServiceFacade.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/GameServiceFacade.cs new file mode 100644 index 00000000..cb20776b --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/GameServiceFacade.cs @@ -0,0 +1,105 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Model.Entity; +using Snap.Hutao.Service.Game.Account; +using Snap.Hutao.Service.Game.Configuration; +using Snap.Hutao.Service.Game.Package; +using Snap.Hutao.Service.Game.Process; +using Snap.Hutao.Service.Game.Scheme; +using System.Collections.ObjectModel; + +namespace Snap.Hutao.Service.Game; + +/// +/// 游戏服务 +/// +[HighQuality] +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGameServiceFacade))] +internal sealed partial class GameServiceFacade : IGameServiceFacade +{ + private readonly IGameChannelOptionsService gameChannelOptionsService; + private readonly IGameAccountService gameAccountService; + private readonly IGameProcessService gameProcessService; + private readonly IGamePackageService gamePackageService; + private readonly IGamePathService gamePathService; + + /// + public ObservableCollection GameAccountCollection + { + get => gameAccountService.GameAccountCollection; + } + + /// + public ValueTask> GetGamePathAsync() + { + return gamePathService.SilentGetGamePathAsync(); + } + + /// + public ChannelOptions GetChannelOptions() + { + return gameChannelOptionsService.GetChannelOptions(); + } + + /// + public bool SetChannelOptions(LaunchScheme scheme) + { + return gameChannelOptionsService.SetChannelOptions(scheme); + } + + /// + public ValueTask DetectGameAccountAsync() + { + return gameAccountService.DetectGameAccountAsync(); + } + + /// + public GameAccount? DetectCurrentGameAccount() + { + return gameAccountService.DetectCurrentGameAccount(); + } + + /// + public bool SetGameAccount(GameAccount account) + { + return gameAccountService.SetGameAccount(account); + } + + /// + public void AttachGameAccountToUid(GameAccount gameAccount, string uid) + { + gameAccountService.AttachGameAccountToUid(gameAccount, uid); + } + + /// + public ValueTask ModifyGameAccountAsync(GameAccount gameAccount) + { + return gameAccountService.ModifyGameAccountAsync(gameAccount); + } + + /// + public ValueTask RemoveGameAccountAsync(GameAccount gameAccount) + { + return gameAccountService.RemoveGameAccountAsync(gameAccount); + } + + /// + public bool IsGameRunning() + { + return gameProcessService.IsGameRunning(); + } + + /// + public ValueTask LaunchAsync(IProgress progress) + { + return gameProcessService.LaunchAsync(progress); + } + + /// + public ValueTask EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress progress) + { + return gamePackageService.EnsureGameResourceAsync(launchScheme, progress); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/IGamePathService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/IGamePathService.cs new file mode 100644 index 00000000..c0b09ccc --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/IGamePathService.cs @@ -0,0 +1,9 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Service.Game; + +internal interface IGamePathService +{ + ValueTask> SilentGetGamePathAsync(); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/IGameService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/IGameServiceFacade.cs similarity index 94% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/IGameService.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/IGameServiceFacade.cs index 19ad9808..89528ef7 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/IGameService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/IGameServiceFacade.cs @@ -2,7 +2,10 @@ // Licensed under the MIT license. using Snap.Hutao.Model.Entity; +using Snap.Hutao.Service.Game.Account; +using Snap.Hutao.Service.Game.Configuration; using Snap.Hutao.Service.Game.Package; +using Snap.Hutao.Service.Game.Scheme; using System.Collections.ObjectModel; namespace Snap.Hutao.Service.Game; @@ -11,7 +14,7 @@ namespace Snap.Hutao.Service.Game; /// 游戏服务 /// [HighQuality] -internal interface IGameService +internal interface IGameServiceFacade { /// /// 游戏内账号集合 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchOptions.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchOptions.cs index c086cd15..28d1e743 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchOptions.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. +using CommunityToolkit.WinUI.Controls; using Microsoft.UI.Windowing; using Snap.Hutao.Model; using Snap.Hutao.Model.Entity; @@ -23,6 +24,7 @@ internal sealed class LaunchOptions : DbStoreOptions private readonly int primaryScreenHeight; private readonly int primaryScreenFps; + private bool? isEnabled; private bool? isFullScreen; private bool? isBorderless; private bool? isExclusive; @@ -34,6 +36,8 @@ internal sealed class LaunchOptions : DbStoreOptions private int? targetFps; private NameValue? monitor; private bool? isMonitorEnabled; + private AspectRatio? selectedAspectRatio; + private bool? useStarwardPlayTimeStatistics; /// /// 构造一个新的启动游戏选项 @@ -50,6 +54,15 @@ internal sealed class LaunchOptions : DbStoreOptions InitializeScreenFps(out primaryScreenFps); } + /// + /// 是否启用启动参数 + /// + public bool IsEnabled + { + get => GetOption(ref isEnabled, SettingEntry.LaunchIsLaunchOptionsEnabled, true); + set => SetOption(ref isEnabled, SettingEntry.LaunchIsLaunchOptionsEnabled, value); + } + /// /// 是否全屏 /// @@ -152,6 +165,31 @@ internal sealed class LaunchOptions : DbStoreOptions set => SetOption(ref isMonitorEnabled, SettingEntry.LaunchIsMonitorEnabled, value); } + public List AspectRatios { get; } = new() + { + new(2560, 1440), + new(1920, 1080), + }; + + public AspectRatio? SelectedAspectRatio + { + get => selectedAspectRatio; + set + { + if (SetProperty(ref selectedAspectRatio, value) && value is AspectRatio aspectRatio) + { + ScreenWidth = (int)aspectRatio.Width; + ScreenHeight = (int)aspectRatio.Height; + } + } + } + + public bool UseStarwardPlayTimeStatistics + { + get => GetOption(ref useStarwardPlayTimeStatistics, SettingEntry.LaunchUseStarwardPlayTimeStatistics, false); + set => SetOption(ref useStarwardPlayTimeStatistics, SettingEntry.LaunchUseStarwardPlayTimeStatistics, value); + } + private static void InitializeMonitors(List> monitors) { // This list can't use foreach diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchStatus.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchStatus.cs index 62a6ea3b..df7e3c1c 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchStatus.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchStatus.cs @@ -1,6 +1,8 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. +using Snap.Hutao.Service.Game.Unlocker; + namespace Snap.Hutao.Service.Game; internal sealed class LaunchStatus @@ -14,4 +16,16 @@ internal sealed class LaunchStatus public LaunchPhase Phase { get; set; } public string Description { get; set; } -} + + public static LaunchStatus FromUnlockStatus(UnlockerStatus unlockerStatus) + { + if (unlockerStatus.FindModuleState == FindModuleResult.Ok) + { + return new(LaunchPhase.UnlockFpsSucceed, SH.ServiceGameLaunchPhaseUnlockFpsSucceed); + } + else + { + return new(LaunchPhase.UnlockFpsFailed, SH.ServiceGameLaunchPhaseUnlockFpsFailed); + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Locator/ManualGameLocator.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Locator/ManualGameLocator.cs index 7f281662..8e8ec8db 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Locator/ManualGameLocator.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Locator/ManualGameLocator.cs @@ -2,7 +2,7 @@ // Licensed under the MIT license. using Snap.Hutao.Core.IO; -using Snap.Hutao.Factory.Abstraction; +using Snap.Hutao.Factory.Picker; using Windows.Storage.Pickers; namespace Snap.Hutao.Service.Game.Locator; diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/GamePackageService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/GamePackageService.cs new file mode 100644 index 00000000..77b99c2b --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/GamePackageService.cs @@ -0,0 +1,67 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Service.Game.Scheme; +using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher; +using Snap.Hutao.Web.Response; +using System.IO; +using static Snap.Hutao.Service.Game.GameConstants; + +namespace Snap.Hutao.Service.Game.Package; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGamePackageService))] +internal sealed partial class GamePackageService : IGamePackageService +{ + private readonly PackageConverter packageConverter; + private readonly IServiceProvider serviceProvider; + private readonly ITaskContext taskContext; + private readonly AppOptions appOptions; + + public async ValueTask EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress progress) + { + if (!appOptions.TryGetGameFolderAndFileName(out string? gameFolder, out string? gameFileName)) + { + return false; + } + + progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation)); + Response response = await serviceProvider + .GetRequiredService() + .GetResourceAsync(launchScheme) + .ConfigureAwait(false); + + if (response.IsOk()) + { + GameResource resource = response.Data; + + if (!launchScheme.ExecutableMatches(gameFileName)) + { + bool replaced = await packageConverter + .EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress) + .ConfigureAwait(false); + + if (replaced) + { + // We need to change the gamePath if we switched. + string exeName = launchScheme.IsOversea ? GenshinImpactFileName : YuanShenFileName; + + await taskContext.SwitchToMainThreadAsync(); + appOptions.GamePath = Path.Combine(gameFolder, exeName); + } + else + { + // We can't start the game + // when we failed to convert game + return false; + } + } + + await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false); + + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/IGamePackageService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/IGamePackageService.cs new file mode 100644 index 00000000..ffb3d54b --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/IGamePackageService.cs @@ -0,0 +1,11 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Service.Game.Scheme; + +namespace Snap.Hutao.Service.Game.Package; + +internal interface IGamePackageService +{ + ValueTask EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress progress); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs index a01a8f22..3e39bfd9 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs @@ -6,6 +6,7 @@ using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.IO; using Snap.Hutao.Core.IO.Hashing; +using Snap.Hutao.Service.Game.Scheme; using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher; using System.IO; using System.IO.Compression; diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/GameProcessService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/GameProcessService.cs new file mode 100644 index 00000000..46d51181 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/GameProcessService.cs @@ -0,0 +1,135 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core; +using Snap.Hutao.Service.Game.Scheme; +using Snap.Hutao.Service.Game.Unlocker; +using System.IO; +using Windows.System; +using static Snap.Hutao.Service.Game.GameConstants; + +namespace Snap.Hutao.Service.Game.Process; + +/// +/// 进程互操作 +/// +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IGameProcessService))] +internal sealed partial class GameProcessService : IGameProcessService +{ + private readonly IServiceProvider serviceProvider; + private readonly RuntimeOptions runtimeOptions; + private readonly LaunchOptions launchOptions; + private readonly AppOptions appOptions; + + private volatile int runningGamesCounter; + + public bool IsGameRunning() + { + if (runningGamesCounter == 0) + { + return false; + } + + return System.Diagnostics.Process.GetProcessesByName(YuanShenProcessName).Any() + || System.Diagnostics.Process.GetProcessesByName(GenshinImpactProcessName).Any(); + } + + public async ValueTask LaunchAsync(IProgress progress) + { + if (IsGameRunning()) + { + return; + } + + string gamePath = appOptions.GamePath; + ArgumentException.ThrowIfNullOrEmpty(gamePath); + + progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing)); + using (System.Diagnostics.Process game = InitializeGameProcess(gamePath)) + { + try + { + Interlocked.Increment(ref runningGamesCounter); + game.Start(); + progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted)); + + if (launchOptions.UseStarwardPlayTimeStatistics && appOptions.TryGetGameFileName(out string? gameFileName)) + { + bool isOversea = LaunchScheme.ExecutableIsOversea(gameFileName); + await Starward.LaunchForPlayTimeStatisticsAsync(isOversea).ConfigureAwait(false); + } + + if (runtimeOptions.IsElevated && appOptions.IsAdvancedLaunchOptionsEnabled && launchOptions.UnlockFps) + { + progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps)); + try + { + await UnlockFpsAsync(game, progress).ConfigureAwait(false); + } + catch (InvalidOperationException) + { + // The Unlocker can't unlock the process + game.Kill(); + throw; + } + finally + { + progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited)); + } + } + else + { + progress.Report(new(LaunchPhase.WaitingForExit, SH.ServiceGameLaunchPhaseWaitingProcessExit)); + await game.WaitForExitAsync().ConfigureAwait(false); + progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited)); + } + } + finally + { + Interlocked.Decrement(ref runningGamesCounter); + } + } + } + + private System.Diagnostics.Process InitializeGameProcess(string gamePath) + { + string commandLine = string.Empty; + + if (launchOptions.IsEnabled) + { + Must.Argument(!(launchOptions.IsBorderless && launchOptions.IsExclusive), "无边框与独占全屏选项无法同时生效"); + + // https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html + // https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html + commandLine = new CommandLineBuilder() + .AppendIf("-popupwindow", launchOptions.IsBorderless) + .AppendIf("-window-mode", launchOptions.IsExclusive, "exclusive") + .Append("-screen-fullscreen", launchOptions.IsFullScreen ? 1 : 0) + .AppendIf("-screen-width", launchOptions.IsScreenWidthEnabled, launchOptions.ScreenWidth) + .AppendIf("-screen-height", launchOptions.IsScreenHeightEnabled, launchOptions.ScreenHeight) + .AppendIf("-monitor", launchOptions.IsMonitorEnabled, launchOptions.Monitor.Value) + .ToString(); + } + + return new() + { + StartInfo = new() + { + Arguments = commandLine, + FileName = gamePath, + UseShellExecute = true, + Verb = "runas", + WorkingDirectory = Path.GetDirectoryName(gamePath), + }, + }; + } + + private ValueTask UnlockFpsAsync(System.Diagnostics.Process game, IProgress progress, CancellationToken token = default) + { + IGameFpsUnlocker unlocker = serviceProvider.CreateInstance(game); + UnlockTimingOptions options = new(100, 20000, 3000); + Progress lockerProgress = new(unlockStatus => progress.Report(LaunchStatus.FromUnlockStatus(unlockStatus))); + return unlocker.UnlockAsync(options, lockerProgress, token); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/IGameProcessService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/IGameProcessService.cs new file mode 100644 index 00000000..2f39d442 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/IGameProcessService.cs @@ -0,0 +1,11 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Service.Game.Process; + +internal interface IGameProcessService +{ + bool IsGameRunning(); + + ValueTask LaunchAsync(IProgress progress); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/Starward.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/Starward.cs new file mode 100644 index 00000000..1a827666 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Process/Starward.cs @@ -0,0 +1,19 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Windows.System; + +namespace Snap.Hutao.Service.Game.Process; + +internal static class Starward +{ + public static async ValueTask LaunchForPlayTimeStatisticsAsync(bool isOversea) + { + string gameBiz = isOversea ? "hk4e_global" : "hk4e_cn"; + Uri starwardPlayTimeUri = $"starward://playtime/{gameBiz}".ToUri(); + if (await Launcher.QueryUriSupportAsync(starwardPlayTimeUri, LaunchQuerySupportType.Uri) is LaunchQuerySupportStatus.Available) + { + await Launcher.LaunchUriAsync(starwardPlayTimeUri); + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/ProcessInterop.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/ProcessInterop.cs deleted file mode 100644 index 15aafcd4..00000000 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/ProcessInterop.cs +++ /dev/null @@ -1,157 +0,0 @@ -// Copyright (c) DGP Studio. All rights reserved. -// Licensed under the MIT license. - -using Snap.Hutao.Core; -using Snap.Hutao.Service.Game.Unlocker; -using System.Diagnostics; -using System.IO; -using System.Runtime.InteropServices; -using Windows.Win32.Foundation; -using Windows.Win32.System.Memory; -using Windows.Win32.System.Threading; -using static Windows.Win32.PInvoke; - -namespace Snap.Hutao.Service.Game; - -/// -/// 进程互操作 -/// -internal static class ProcessInterop -{ - /// - /// 获取初始化后的游戏进程 - /// - /// 启动选项 - /// 游戏路径 - /// 初始化后的游戏进程 - public static Process InitializeGameProcess(LaunchOptions options, string gamePath) - { - Must.Argument(!(options.IsBorderless && options.IsExclusive), "无边框与独占全屏选项无法同时生效"); - - // https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html - // https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html - string commandLine = new CommandLineBuilder() - .AppendIf("-popupwindow", options.IsBorderless) - .AppendIf("-window-mode", options.IsExclusive, "exclusive") - .Append("-screen-fullscreen", options.IsFullScreen ? 1 : 0) - .AppendIf("-screen-width", options.IsScreenWidthEnabled, options.ScreenWidth) - .AppendIf("-screen-height", options.IsScreenHeightEnabled, options.ScreenHeight) - .AppendIf("-monitor", options.IsMonitorEnabled, options.Monitor.Value) - .ToString(); - - return new() - { - StartInfo = new() - { - Arguments = commandLine, - FileName = gamePath, - UseShellExecute = true, - Verb = "runas", - WorkingDirectory = Path.GetDirectoryName(gamePath), - }, - }; - } - - public static ValueTask UnlockFpsAsync(IServiceProvider serviceProvider, Process game, IProgress progress, CancellationToken token = default) - { - IGameFpsUnlocker unlocker = serviceProvider.CreateInstance(game); - UnlockTimingOptions options = new(100, 20000, 3000); - Progress lockerProgress = new(unlockStatus => progress.Report(FromUnlockStatus(unlockStatus))); - return unlocker.UnlockAsync(options, lockerProgress, token); - } - - /// - /// 尝试禁用mhypbase - /// - /// 游戏进程 - /// 游戏路径 - /// 是否禁用成功 - public static bool DisableProtection(Process game, string gamePath) - { - string? gameFolder = Path.GetDirectoryName(gamePath); - string mhypbaseDll = Path.Combine(gameFolder ?? string.Empty, "mhypbase.dll"); - - if (File.Exists(mhypbaseDll)) - { - using (File.OpenHandle(mhypbaseDll, share: FileShare.None)) - { - SpinWait.SpinUntil(() => game.MainWindowHandle != 0); - return true; - } - } - - return false; - } - - /// - /// 加载并注入指定路径的库 - /// - /// 进程句柄 - /// 库的路径,不包含'\0' - public static unsafe void LoadLibraryAndInject(in HANDLE hProcess, in ReadOnlySpan libraryPathu8) - { - HINSTANCE hKernelDll = GetModuleHandle("kernel32.dll"); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - - FARPROC pLoadLibraryA = GetProcAddress(hKernelDll, "LoadLibraryA"u8); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - - void* pNativeLibraryPath = default; - try - { - VIRTUAL_ALLOCATION_TYPE allocType = VIRTUAL_ALLOCATION_TYPE.MEM_RESERVE | VIRTUAL_ALLOCATION_TYPE.MEM_COMMIT; - pNativeLibraryPath = VirtualAllocEx(hProcess, default, unchecked((uint)libraryPathu8.Length + 1), allocType, PAGE_PROTECTION_FLAGS.PAGE_READWRITE); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - - WriteProcessMemory(hProcess, pNativeLibraryPath, libraryPathu8); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - - LPTHREAD_START_ROUTINE lpThreadLoadLibraryA = pLoadLibraryA.CreateDelegate(); - HANDLE hLoadLibraryAThread = default; - try - { - hLoadLibraryAThread = CreateRemoteThread(hProcess, default, 0, lpThreadLoadLibraryA, pNativeLibraryPath, 0); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - - WaitForSingleObject(hLoadLibraryAThread, 2000); - Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError()); - } - finally - { - CloseHandle(hLoadLibraryAThread); - } - } - finally - { - VirtualFreeEx(hProcess, pNativeLibraryPath, 0, VIRTUAL_FREE_TYPE.MEM_RELEASE); - } - } - - private static unsafe FARPROC GetProcAddress(in HINSTANCE hModule, in ReadOnlySpan lpProcName) - { - fixed (byte* lpProcNameLocal = lpProcName) - { - return Windows.Win32.PInvoke.GetProcAddress(hModule, new PCSTR(lpProcNameLocal)); - } - } - - private static unsafe BOOL WriteProcessMemory(in HANDLE hProcess, void* lpBaseAddress, in ReadOnlySpan buffer) - { - fixed (void* lpBuffer = buffer) - { - return Windows.Win32.PInvoke.WriteProcessMemory(hProcess, lpBaseAddress, lpBuffer, unchecked((uint)buffer.Length)); - } - } - - private static LaunchStatus FromUnlockStatus(UnlockerStatus unlockerStatus) - { - if (unlockerStatus.FindModuleState == FindModuleResult.Ok) - { - return new(LaunchPhase.UnlockFpsSucceed, SH.ServiceGameLaunchPhaseUnlockFpsSucceed); - } - else - { - return new(LaunchPhase.UnlockFpsFailed, SH.ServiceGameLaunchPhaseUnlockFpsFailed); - } - } -} diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/KnownLaunchSchemes.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/KnownLaunchSchemes.cs similarity index 98% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/KnownLaunchSchemes.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/KnownLaunchSchemes.cs index 16fac365..c1373917 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/KnownLaunchSchemes.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/KnownLaunchSchemes.cs @@ -3,7 +3,7 @@ using Snap.Hutao.Model.Intrinsic; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Scheme; internal static class KnownLaunchSchemes { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchScheme.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchScheme.cs similarity index 81% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchScheme.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchScheme.cs index 5aeede35..f2bf764f 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchScheme.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchScheme.cs @@ -2,14 +2,15 @@ // Licensed under the MIT license. using Snap.Hutao.Model.Intrinsic; +using Snap.Hutao.Service.Game.Configuration; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Scheme; /// /// 启动方案 /// [HighQuality] -internal partial class LaunchScheme +internal class LaunchScheme { /// /// 显示名称 @@ -56,6 +57,16 @@ internal partial class LaunchScheme public bool IsNotCompatOnly { get; private protected set; } = true; + public static bool ExecutableIsOversea(string gameFileName) + { + return gameFileName switch + { + GameConstants.GenshinImpactFileName => true, + GameConstants.YuanShenFileName => false, + _ => throw Requires.Fail("无效的游戏可执行文件名称:{0}", gameFileName), + }; + } + /// /// 多通道相等 /// diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeBilibili.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeBilibili.cs similarity index 93% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeBilibili.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeBilibili.cs index cd5057be..1d96e21d 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeBilibili.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeBilibili.cs @@ -3,7 +3,7 @@ using Snap.Hutao.Model.Intrinsic; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Scheme; internal sealed class LaunchSchemeBilibili : LaunchScheme { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeChinese.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeChinese.cs similarity index 93% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeChinese.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeChinese.cs index 1e8f0e9f..e9d27c6d 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeChinese.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeChinese.cs @@ -3,7 +3,7 @@ using Snap.Hutao.Model.Intrinsic; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Scheme; internal sealed class LaunchSchemeChinese : LaunchScheme { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeOversea.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeOversea.cs similarity index 93% rename from src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeOversea.cs rename to src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeOversea.cs index 2838d82e..08b6799a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/LaunchSchemeOversea.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Scheme/LaunchSchemeOversea.cs @@ -3,7 +3,7 @@ using Snap.Hutao.Model.Intrinsic; -namespace Snap.Hutao.Service.Game; +namespace Snap.Hutao.Service.Game.Scheme; internal sealed class LaunchSchemeOversea : LaunchScheme { diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Unlocker/GameFpsUnlocker.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Unlocker/GameFpsUnlocker.cs index 21bba708..e36f515f 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Unlocker/GameFpsUnlocker.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Unlocker/GameFpsUnlocker.cs @@ -19,7 +19,7 @@ namespace Snap.Hutao.Service.Game.Unlocker; [HighQuality] internal sealed class GameFpsUnlocker : IGameFpsUnlocker { - private readonly Process gameProcess; + private readonly System.Diagnostics.Process gameProcess; private readonly LaunchOptions launchOptions; private readonly UnlockerStatus status = new(); @@ -33,7 +33,7 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker /// /// 服务提供器 /// 游戏进程 - public GameFpsUnlocker(IServiceProvider serviceProvider, Process gameProcess) + public GameFpsUnlocker(IServiceProvider serviceProvider, System.Diagnostics.Process gameProcess) { launchOptions = serviceProvider.GetRequiredService(); this.gameProcess = gameProcess; @@ -57,7 +57,7 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker await LoopAdjustFpsAsync(options.AdjustFpsDelay, progress, token).ConfigureAwait(false); } - private static unsafe bool UnsafeReadModulesMemory(Process process, in GameModule moduleEntryInfo, out VirtualMemory memory) + private static unsafe bool UnsafeReadModulesMemory(System.Diagnostics.Process process, in GameModule moduleEntryInfo, out VirtualMemory memory) { ref readonly Module unityPlayer = ref moduleEntryInfo.UnityPlayer; ref readonly Module userAssembly = ref moduleEntryInfo.UserAssembly; @@ -68,7 +68,7 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker && ReadProcessMemory((HANDLE)process.Handle, (void*)userAssembly.Address, lpBuffer + unityPlayer.Size, userAssembly.Size); } - private static unsafe bool UnsafeReadProcessMemory(Process process, nuint baseAddress, out nuint value) + private static unsafe bool UnsafeReadProcessMemory(System.Diagnostics.Process process, nuint baseAddress, out nuint value) { ulong temp = 0; bool result = ReadProcessMemory((HANDLE)process.Handle, (void*)baseAddress, (byte*)&temp, 8); @@ -78,7 +78,7 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker return result; } - private static unsafe bool UnsafeWriteProcessMemory(Process process, nuint baseAddress, int value) + private static unsafe bool UnsafeWriteProcessMemory(System.Diagnostics.Process process, nuint baseAddress, int value) { return WriteProcessMemory((HANDLE)process.Handle, (void*)baseAddress, &value, sizeof(int)); } diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Hutao/HutaoAsAService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Hutao/HutaoAsAService.cs index 0f8579c7..dddc3381 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Hutao/HutaoAsAService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Hutao/HutaoAsAService.cs @@ -29,11 +29,11 @@ internal sealed partial class HutaoAsAService : IHutaoAsAService ApplicationDataCompositeValue excludedIds = LocalSetting.Get(SettingKeys.ExcludedAnnouncementIds, new ApplicationDataCompositeValue()); List data = excludedIds.Select(kvp => long.Parse(kvp.Key, CultureInfo.InvariantCulture)).ToList(); - Response> respose = await hutaoAsServiceClient.GetAnnouncementListAsync(data, token).ConfigureAwait(false); + Response> response = await hutaoAsServiceClient.GetAnnouncementListAsync(data, token).ConfigureAwait(false); - if (respose.IsOk()) + if (response.IsOk()) { - List list = respose.Data; + List list = response.Data; list.ForEach(item => item.DismissCommand = dismissCommand); announcements = list.ToObservableCollection(); } diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserFingerprintService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserFingerprintService.cs new file mode 100644 index 00000000..9927dcbf --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserFingerprintService.cs @@ -0,0 +1,9 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +namespace Snap.Hutao.Service.User; + +internal interface IUserFingerprintService +{ + ValueTask TryInitializeAsync(ViewModel.User.User user, CancellationToken token = default); +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserInitializationService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserInitializationService.cs index 18b906e4..03f62a30 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/IUserInitializationService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/IUserInitializationService.cs @@ -7,7 +7,7 @@ namespace Snap.Hutao.Service.User; internal interface IUserInitializationService { - ValueTask CreateOrDefaultUserFromCookieAsync(Cookie cookie, bool isOversea, CancellationToken token = default(CancellationToken)); + ValueTask CreateUserFromCookieOrDefaultAsync(Cookie cookie, bool isOversea, CancellationToken token = default(CancellationToken)); ValueTask ResumeUserAsync(Model.Entity.User inner, CancellationToken token = default(CancellationToken)); } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs new file mode 100644 index 00000000..2d8b1004 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserFingerprintService.cs @@ -0,0 +1,113 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Web.Hoyolab; +using Snap.Hutao.Web.Hoyolab.PublicData.DeviceFp; +using Snap.Hutao.Web.Response; +using System.Text; + +namespace Snap.Hutao.Service.User; + +[ConstructorGenerated] +[Injection(InjectAs.Singleton, typeof(IUserFingerprintService))] +internal sealed partial class UserFingerprintService : IUserFingerprintService +{ + private readonly DeviceFpClient deviceFpClient; + + public async ValueTask TryInitializeAsync(ViewModel.User.User user, CancellationToken token = default) + { + if (user.IsOversea) + { + // disable HoYoLAB fp approach + return; + } + + if (!string.IsNullOrEmpty(user.Fingerprint)) + { + return; + } + + string model = GetRandomStringOfLength(6); + Dictionary extendProperties = new() + { + { "cpuType", "arm64-v8a" }, + { "romCapacity", "512" }, + { "productName", model }, + { "romRemain", "256" }, + { "manufacturer", "XiaoMi" }, + { "appMemory", "512" }, + { "hostname", "dg02-pool03-kvm87" }, + { "screenSize", "1080x1920" }, + { "osVersion", "13" }, + { "aaid", string.Empty }, + { "vendor", "中国移动" }, + { "accelerometer", "1.4883357x7.1712894x6.2847486" }, + { "buildTags", "release-keys" }, + { "model", model }, + { "brand", "XiaoMi" }, + { "oaid", string.Empty }, + { "hardware", "qcom" }, + { "deviceType", "OP5913L1" }, + { "devId", "REL" }, + { "serialNumber", "unknown" }, + { "buildTime", "1687848011000" }, + { "buildUser", "root" }, + { "ramCapacity", "469679" }, + { "magnetometer", "20.081251x-27.487501x2.1937501" }, + { "display", $"{model}_13.1.0.181(CN01)" }, + { "ramRemain", "215344" }, + { "deviceInfo", $@"XiaoMi/{model}/OP5913L1:13/SKQ1.221119.001/T.118e6c7-5aa23-73911:user/release-keys" }, + { "gyroscope", "0.030226856x0.014647375x0.010652636" }, + { "vaid", string.Empty }, + { "buildType", "user" }, + { "sdkVersion", "33" }, + { "board", "taro" }, + }; + + DeviceFpData data = new() + { + DeviceId = GetRandomHexStringOfLength(16), + SeedId = $"{Guid.NewGuid()}", + Platform = "2", + SeedTime = $"{DateTimeOffset.Now.ToUnixTimeMilliseconds()}", + ExtFields = JsonSerializer.Serialize(extendProperties), + AppName = "bbs_cn", + BbsDeviceId = HoyolabOptions.DeviceId, + DeviceFp = string.IsNullOrEmpty(user.Fingerprint) ? GetRandomHexStringOfLength(13) : user.Fingerprint, + }; + + Response response = await deviceFpClient.GetFingerprintAsync(data, token).ConfigureAwait(false); + user.Fingerprint = response.IsOk() ? response.Data.DeviceFp : string.Empty; + user.NeedDbUpdateAfterResume = true; + } + + private static string GetRandomHexStringOfLength(int length) + { + const string RandomRange = "0123456789abcdef"; + + StringBuilder sb = new(length); + + for (int i = 0; i < length; i++) + { + int pos = Random.Shared.Next(0, RandomRange.Length); + sb.Append(RandomRange[pos]); + } + + return sb.ToString(); + } + + private static string GetRandomStringOfLength(int length) + { + const string RandomRange = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + StringBuilder sb = new(length); + + for (int i = 0; i < length; i++) + { + int pos = Random.Shared.Next(0, RandomRange.Length); + sb.Append(RandomRange[pos]); + } + + return sb.ToString(); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/UserInitializationService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/UserInitializationService.cs index baa26503..2d2dfb7c 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/UserInitializationService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserInitializationService.cs @@ -14,6 +14,7 @@ namespace Snap.Hutao.Service.User; [Injection(InjectAs.Singleton, typeof(IUserInitializationService))] internal sealed partial class UserInitializationService : IUserInitializationService { + private readonly IUserFingerprintService userFingerprintService; private readonly IServiceProvider serviceProvider; public async ValueTask ResumeUserAsync(Model.Entity.User inner, CancellationToken token = default) @@ -29,7 +30,7 @@ internal sealed partial class UserInitializationService : IUserInitializationSer return user; } - public async ValueTask CreateOrDefaultUserFromCookieAsync(Cookie cookie, bool isOversea, CancellationToken token = default) + public async ValueTask CreateUserFromCookieOrDefaultAsync(Cookie cookie, bool isOversea, CancellationToken token = default) { // 这里只负责创建实体用户,稍后在用户服务中保存到数据库 Model.Entity.User entity = Model.Entity.User.From(cookie, isOversea); @@ -64,8 +65,6 @@ internal sealed partial class UserInitializationService : IUserInitializationSer return false; } - bool isOversea = user.Entity.IsOversea; - if (!await TrySetUserLTokenAsync(user, token).ConfigureAwait(false)) { return false; @@ -86,6 +85,8 @@ internal sealed partial class UserInitializationService : IUserInitializationSer return false; } + await userFingerprintService.TryInitializeAsync(user, token).ConfigureAwait(false); + user.SelectedUserGameRole = user.UserGameRoles.FirstOrFirstOrDefault(role => role.IsChosen); return user.IsInitialized = true; } diff --git a/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs b/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs index 9cf52879..6ec475cf 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/User/UserService.cs @@ -73,6 +73,16 @@ internal sealed partial class UserService : IUserService, IUserServiceUnsafe { List entities = await userDbService.GetUserListAsync().ConfigureAwait(false); List users = await entities.SelectListAsync(userInitializationService.ResumeUserAsync, default).ConfigureAwait(false); + + foreach (BindingUser user in users) + { + if (user.NeedDbUpdateAfterResume) + { + await userDbService.UpdateUserAsync(user.Entity).ConfigureAwait(false); + user.NeedDbUpdateAfterResume = false; + } + } + userCollection = users.ToObservableCollection(); try @@ -202,7 +212,7 @@ internal sealed partial class UserService : IUserService, IUserServiceUnsafe private async ValueTask> TryCreateUserAndAddAsync(Cookie cookie, bool isOversea) { await taskContext.SwitchToBackgroundAsync(); - BindingUser? newUser = await userInitializationService.CreateOrDefaultUserFromCookieAsync(cookie, isOversea).ConfigureAwait(false); + BindingUser? newUser = await userInitializationService.CreateUserFromCookieOrDefaultAsync(cookie, isOversea).ConfigureAwait(false); if (newUser is not null) { diff --git a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj index 3b267b78..7ac0e919 100644 --- a/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj +++ b/src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj @@ -277,7 +277,7 @@ - + all diff --git a/src/Snap.Hutao/Snap.Hutao/View/Card/LaunchGameCard.xaml b/src/Snap.Hutao/Snap.Hutao/View/Card/LaunchGameCard.xaml index 1b84f9b9..10e7d0ef 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Card/LaunchGameCard.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Card/LaunchGameCard.xaml @@ -42,7 +42,7 @@ HorizontalAlignment="Left" VerticalAlignment="Center" FontSize="{ThemeResource TitleTextBlockFontSize}" - Glyph=""/> + Glyph="{StaticResource FontIconContentGame}"/> 1 diff --git a/src/Snap.Hutao/Snap.Hutao/View/Control/LaunchGameResourceExpander.xaml b/src/Snap.Hutao/Snap.Hutao/View/Control/LaunchGameResourceExpander.xaml index 6c9e9a07..0ae5e77a 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Control/LaunchGameResourceExpander.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Control/LaunchGameResourceExpander.xaml @@ -48,9 +48,9 @@ Height="38.4" HorizontalAlignment="Right" VerticalAlignment="Center" - Content="" - FontFamily="{StaticResource SymbolThemeFontFamily}" - NavigateUri="{Binding Path}"/> + Command="{Binding CopyPathCommand}" + Content="{StaticResource FontIconContentCopy}" + FontFamily="{StaticResource SymbolThemeFontFamily}"/> @@ -92,9 +92,9 @@ Height="38.4" HorizontalAlignment="Right" VerticalAlignment="Center" - Content="" - FontFamily="{StaticResource SymbolThemeFontFamily}" - NavigateUri="{Binding Path}"/> + Command="{Binding CopyPathCommand}" + Content="{StaticResource FontIconContentCopy}" + FontFamily="{StaticResource SymbolThemeFontFamily}"/> diff --git a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml index 0b2f163c..abdb0169 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/MainView.xaml @@ -19,7 +19,7 @@ CompactPaneLength="48" IsBackEnabled="{x:Bind ContentFrame.CanGoBack, Mode=OneWay}" IsPaneOpen="True" - OpenPaneLength="188" + OpenPaneLength="192" PaneDisplayMode="Left" UseLayoutRounding="False"> diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/AchievementPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/AchievementPage.xaml index 2677c78a..cee08806 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/AchievementPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/AchievementPage.xaml @@ -133,9 +133,10 @@ + Text="{Binding Inner.Version}" + TextTrimming="CharacterEllipsis" + TextWrapping="NoWrap"/> - - + + + + + @@ -451,7 +512,10 @@ DefaultLabelPosition="Right"> - + - - - - - - + ItemsSource="{Binding SelectedAvatar.Constellations}"/> diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml index 33d02646..92887710 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/CultivationPage.xaml @@ -12,6 +12,7 @@ xmlns:shc="using:Snap.Hutao.Control" xmlns:shcb="using:Snap.Hutao.Control.Behavior" xmlns:shci="using:Snap.Hutao.Control.Image" + xmlns:shcl="using:Snap.Hutao.Control.Layout" xmlns:shcm="using:Snap.Hutao.Control.Markup" xmlns:shvco="using:Snap.Hutao.View.Control" xmlns:shvcu="using:Snap.Hutao.ViewModel.Cultivation" @@ -149,10 +150,7 @@ - + + + + - diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/GachaLogPage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/GachaLogPage.xaml index 0f12787f..4b720db2 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/GachaLogPage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/GachaLogPage.xaml @@ -382,7 +382,7 @@ Style="{StaticResource BaseTextBlockStyle}" Text="{shcm:ResourceString Name=ViewControlStatisticsCardOrangeText}"/> @@ -391,7 +391,7 @@ Style="{StaticResource BaseTextBlockStyle}" Text="{shcm:ResourceString Name=ViewControlStatisticsCardPurpleText}"/> @@ -405,7 +405,7 @@ Style="{StaticResource BaseTextBlockStyle}" Text="{shcm:ResourceString Name=ViewControlStatisticsCardOrangeText}"/> @@ -414,7 +414,7 @@ Style="{StaticResource BaseTextBlockStyle}" Text="{shcm:ResourceString Name=ViewControlStatisticsCardPurpleText}"/> @@ -423,7 +423,7 @@ Style="{StaticResource BaseTextBlockStyle}" Text="{shcm:ResourceString Name=ViewControlStatisticsCardBlueText}"/> diff --git a/src/Snap.Hutao/Snap.Hutao/View/Page/LaunchGamePage.xaml b/src/Snap.Hutao/Snap.Hutao/View/Page/LaunchGamePage.xaml index fe5be18d..8566e7f2 100644 --- a/src/Snap.Hutao/Snap.Hutao/View/Page/LaunchGamePage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/View/Page/LaunchGamePage.xaml @@ -12,6 +12,7 @@ xmlns:shc="using:Snap.Hutao.Control" xmlns:shcb="using:Snap.Hutao.Control.Behavior" xmlns:shccs="using:Snap.Hutao.Control.Collection.Selector" + xmlns:shch="using:Snap.Hutao.Control.Helper" xmlns:shcm="using:Snap.Hutao.Control.Markup" xmlns:shvc="using:Snap.Hutao.View.Control" xmlns:shvg="using:Snap.Hutao.ViewModel.Game" @@ -34,6 +35,82 @@ DataContext="{Binding Mode=OneWay}" Header="{shcm:ResourceString Name=ViewPageLaunchGameResourceDiffHeader}"/> + + + + + + + + +