Compare commits

...

19 Commits

Author SHA1 Message Date
DismissedLight
8921816873 code style 2024-01-06 22:57:25 +08:00
qhy040404
3ae4210ca0 add i18n 2024-01-06 18:32:39 +08:00
qhy040404
2f5e0cbe39 impl #1261 2024-01-06 18:25:10 +08:00
DismissedLight
d3444a9435 typo 2024-01-06 15:22:40 +08:00
DismissedLight
8b6f95c3d9 add package convert check 2024-01-06 15:21:51 +08:00
DismissedLight
88b8335e5b Merge pull request #1271 from DGP-Studio/feat/refresh_data_size 2024-01-05 23:52:55 +08:00
qhy040404
061aba715b refresh data folder size after deleting server cache 2024-01-05 23:50:28 +08:00
DismissedLight
da80631b72 code style 2024-01-05 23:28:35 +08:00
DismissedLight
97acf872bc remove status when game exited 2024-01-05 23:28:05 +08:00
DismissedLight
addaf1a9e3 Merge pull request #1270 from DGP-Studio/feat/launch-pipeline 2024-01-05 22:46:00 +08:00
DismissedLight
76183901da clean up 2024-01-05 22:33:10 +08:00
Lightczx
87ee81e7fa add handlers 2024-01-05 17:29:30 +08:00
DismissedLight
f2f858de15 create infrastructure 2024-01-04 22:51:58 +08:00
DismissedLight
c434521004 Merge pull request #1265 from DGP-Studio/fix/schedule 2024-01-04 16:03:54 +08:00
Lightczx
27ed2cefc1 fix #1242 2024-01-04 16:01:52 +08:00
qhy040404
6dc1e664b0 add task register check and delete script if register is failed 2024-01-04 13:32:43 +08:00
DismissedLight
51c3dde24b Merge pull request #1263 from DSakura207/main 2024-01-04 09:18:23 +08:00
DSakura207
2d497faaa5 Update Contributing.md 2024-01-03 18:35:47 -06:00
DSakura207
4783934b92 Add .vsconfig for installing workloads and extensions 2024-01-03 18:17:09 -06:00
63 changed files with 1100 additions and 688 deletions

View File

@@ -4,13 +4,15 @@
### Setup Snap.Hutao Project
1. Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/)
2. Open Visual Studio Installer to complete Visual Studio installation
- You need to install `.NET desktop development`, `Desktop development with C++` and `Universal Windows Platform development` components
3. Install `Single-project MSIX Packaging Tools for VS 2022` provided by Microsoft in Visual Studio marketplace
4. Use git to clone the project `https://github.com/DGP-Studio/Snap.Hutao.git` to your local device
5. Switch git branch to `develop`
6. Open project solution with your Visual Studio and then you are ready to go
1. Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/).
- No need to select workloads; Visual Studio will handle it automatically.
- Close Visual Studio Installer to ensure a smooth installation experience for workloads.
- If using Visual Studio 2022 17.9 preview, skip step 5, as automatic extension installation is supported in this version.
2. Use git to clone the project `https://github.com/DGP-Studio/Snap.Hutao.git` to your local device.
3. Switch to the`develop` branch using git.
4. Open the project solution with your Visual Studio. Visual Studio will prompt you to install the necessary workloads, closing and reopening automatically.
5. (For Visual Studio 2022 17.8) Install the [Single-project MSIX Packaging Tools for VS 2022](https://marketplace.visualstudio.com/items?itemName=ProjectReunion.MicrosoftSingleProjectMSIXPackagingToolsDev17) provided by Microsoft in Visual Studio marketplace.
6. Open the project solution with your Visual Studio, and you are ready to go.
### Start Pull Request

View File

@@ -124,9 +124,6 @@ dotnet_diagnostic.SA1623.severity = none
# SA1636: File header copyright text should match
dotnet_diagnostic.SA1636.severity = none
# SA1414: Tuple types in signatures should have element names
dotnet_diagnostic.SA1414.severity = none
# SA0001: XML comment analysis disabled
dotnet_diagnostic.SA0001.severity = none
csharp_style_prefer_parameter_null_checking = true:suggestion
@@ -325,7 +322,6 @@ dotnet_diagnostic.CA2227.severity = suggestion
# CA2251: 使用 “string.Equals”
dotnet_diagnostic.CA2251.severity = suggestion
csharp_style_prefer_primary_constructors = true:suggestion
dotnet_diagnostic.SA1010.severity = none
[*.vb]
#### 命名样式 ####

11
src/Snap.Hutao/.vsconfig Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "1.0",
"components": [
"Microsoft.VisualStudio.Workload.ManagedDesktop",
"Microsoft.VisualStudio.Workload.NativeDesktop",
"Microsoft.VisualStudio.Workload.Universal"
],
"extensions": [
"https://marketplace.visualstudio.com/items?itemName=ProjectReunion.MicrosoftSingleProjectMSIXPackagingToolsDev17"
]
}

View File

@@ -8,6 +8,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9A95A964-04B1-477A-BDE7-505525B3CAD8}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
.vsconfig = .vsconfig
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Snap.Hutao.Test", "Snap.Hutao.Test\Snap.Hutao.Test.csproj", "{D691BA9F-904C-4229-87A5-E14F2EFF2F64}"
@@ -87,11 +88,11 @@ Global
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
RESX_AutoApplyExistingTranslations = False
RESX_NeutralResourcesLanguage = zh-CN
SolutionGuid = {E4449B1C-0E6A-4D19-955E-1CA491656ABA}
RESX_SortFileContentOnSave = True
RESX_ShowErrorsInErrorList = False
RESX_Rules = {"EnabledRules":["StringFormat","WhiteSpaceLead","WhiteSpaceTail","PunctuationLead"]}
RESX_ShowErrorsInErrorList = False
RESX_SortFileContentOnSave = True
SolutionGuid = {E4449B1C-0E6A-4D19-955E-1CA491656ABA}
RESX_NeutralResourcesLanguage = zh-CN
RESX_AutoApplyExistingTranslations = False
EndGlobalSection
EndGlobal

View File

@@ -11,11 +11,14 @@ namespace Snap.Hutao.Core.IO.Ini;
[HighQuality]
internal static class IniSerializer
{
/// <summary>
/// 反序列化
/// </summary>
/// <param name="fileStream">文件流</param>
/// <returns>Ini 元素集合</returns>
public static List<IniElement> DeserializeFromFile(string filePath)
{
using (FileStream readStream = File.OpenRead(filePath))
{
return Deserialize(readStream);
}
}
public static List<IniElement> Deserialize(FileStream fileStream)
{
List<IniElement> results = [];
@@ -50,11 +53,14 @@ internal static class IniSerializer
return results;
}
/// <summary>
/// 序列化
/// </summary>
/// <param name="fileStream">写入的流</param>
/// <param name="elements">元素</param>
public static void SerializeToFile(string filePath, IEnumerable<IniElement> elements)
{
using (FileStream writeStream = File.Create(filePath))
{
Serialize(writeStream, elements);
}
}
public static void Serialize(FileStream fileStream, IEnumerable<IniElement> elements)
{
using (StreamWriter writer = new(fileStream))

View File

@@ -190,7 +190,7 @@ internal sealed partial class Activation : IActivation
serviceProvider
.GetRequiredService<IDiscordService>()
.SetNormalActivity()
.SetNormalActivityAsync()
.SafeForget();
}

View File

@@ -39,6 +39,11 @@ internal sealed class ScheduleTaskInterop : IScheduleTaskInterop
}
catch (Exception)
{
if (WScriptExists(DailyNoteRefreshScriptName, out string fullPath))
{
File.Delete(fullPath);
}
return false;
}
}

View File

@@ -69,4 +69,9 @@ internal static class StructMarshal
{
return new(point.X, point.Y, size.Width, size.Height);
}
public static SizeInt32 SizeInt32(RectInt32 rect)
{
return new(rect.Width, rect.Height);
}
}

View File

@@ -0,0 +1,19 @@
<Window
x:Class="Snap.Hutao.IdentifyMonitorWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:shcm="using:Snap.Hutao.Control.Markup"
mc:Ignorable="d">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
Spacing="3">
<TextBlock Text="{shcm:ResourceString Name=WindowIdentifyMonitorHeader}"/>
<TextBlock
Style="{StaticResource SubtitleTextBlockStyle}"
Text="{x:Bind Monitor}"
TextAlignment="Center"/>
</StackPanel>
</Window>

View File

@@ -0,0 +1,30 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Snap.Hutao.Core;
using Windows.Graphics;
namespace Snap.Hutao;
internal sealed partial class IdentifyMonitorWindow : Window
{
public IdentifyMonitorWindow(DisplayArea displayArea, int index)
{
InitializeComponent();
Monitor = $"{displayArea.DisplayId.Value:X8}:{index}";
OverlappedPresenter presenter = OverlappedPresenter.Create();
presenter.SetBorderAndTitleBar(false, false);
presenter.IsAlwaysOnTop = true;
presenter.IsResizable = false;
AppWindow.SetPresenter(presenter);
PointInt32 point = new(40, 32);
SizeInt32 size = StructMarshal.SizeInt32(displayArea.WorkArea).Scale(0.1);
AppWindow.MoveAndResize(StructMarshal.RectInt32(point, size), displayArea);
}
public string Monitor { get; private set; }
}

View File

@@ -870,11 +870,23 @@
<value>文件系统权限不足,无法转换服务器</value>
</data>
<data name="ServiceGameEnsureGameResourceQueryResourceInformation" xml:space="preserve">
<value>查询游戏资源信息</value>
<value>下载游戏资源索引</value>
</data>
<data name="ServiceGameFileOperationExceptionMessage" xml:space="preserve">
<value>游戏文件操作失败:{0}</value>
</data>
<data name="ServiceGameLaunchExecutionGameFpsUnlockFailed" xml:space="preserve">
<value>解锁帧率上限失败</value>
</data>
<data name="ServiceGameLaunchExecutionGameIsRunning" xml:space="preserve">
<value>游戏进程运行中</value>
</data>
<data name="ServiceGameLaunchExecutionGamePathNotValid" xml:space="preserve">
<value>请选择游戏路径</value>
</data>
<data name="ServiceGameLaunchExecutionGameResourceQueryIndexFailed" xml:space="preserve">
<value>下载游戏资源索引失败: {0}</value>
</data>
<data name="ServiceGameLaunchPhaseProcessExited" xml:space="preserve">
<value>游戏进程已退出</value>
</data>
@@ -1547,6 +1559,9 @@
<data name="ViewModelLaunchGameEnsureGameResourceFail" xml:space="preserve">
<value>切换服务器失败</value>
</data>
<data name="ViewModelLaunchGameIdentifyMonitorsAction" xml:space="preserve">
<value>识别显示器</value>
</data>
<data name="ViewModelLaunchGameMultiChannelReadFail" xml:space="preserve">
<value>无法读取游戏配置文件: {0},可能是文件不存在或权限不足</value>
</data>
@@ -2954,4 +2969,7 @@
<data name="WebResponseRequestExceptionFormat" xml:space="preserve">
<value>[{0}] 中的 [{1}] 网络请求异常,请稍后再试</value>
</data>
<data name="WindowIdentifyMonitorHeader" xml:space="preserve">
<value>显示器编号</value>
</data>
</root>

View File

@@ -58,7 +58,11 @@ internal sealed partial class DailyNoteOptions : DbStoreOptions
{
if (SelectedRefreshTime is not null)
{
scheduleTaskInterop.RegisterForDailyNoteRefresh(SelectedRefreshTime.Value);
if (!scheduleTaskInterop.RegisterForDailyNoteRefresh(SelectedRefreshTime.Value))
{
serviceProvider.GetRequiredService<IInfoBarService>().Warning(SH.ViewModelDailyNoteRegisterTaskFail);
return;
}
}
}
else

View File

@@ -11,14 +11,14 @@ internal sealed partial class DiscordService : IDiscordService, IDisposable
{
private readonly RuntimeOptions runtimeOptions;
public async ValueTask SetPlayingActivity(bool isOversea)
public async ValueTask SetPlayingActivityAsync(bool isOversea)
{
_ = isOversea
? await DiscordController.SetPlayingGenshinImpactAsync().ConfigureAwait(false)
: await DiscordController.SetPlayingYuanShenAsync().ConfigureAwait(false);
}
public async ValueTask SetNormalActivity()
public async ValueTask SetNormalActivityAsync()
{
_ = await DiscordController.SetDefaultActivityAsync(runtimeOptions.AppLaunchTime).ConfigureAwait(false);
}

View File

@@ -5,7 +5,7 @@ namespace Snap.Hutao.Service.Discord;
internal interface IDiscordService
{
ValueTask SetNormalActivity();
ValueTask SetNormalActivityAsync();
ValueTask SetPlayingActivity(bool isOversea);
ValueTask SetPlayingActivityAsync(bool isOversea);
}

View File

@@ -29,10 +29,9 @@ internal readonly struct ChannelOptions
/// </summary>
public readonly bool IsOversea;
/// <summary>
/// 配置文件路径 当不为 null 时则存在文件读写问题
/// </summary>
public readonly string? ConfigFilePath;
public readonly ChannelOptionsErrorKind ErrorKind;
public readonly string? FilePath;
public ChannelOptions(ChannelType channel, SubChannelType subChannel, bool isOversea)
{
@@ -48,15 +47,20 @@ internal readonly struct ChannelOptions
IsOversea = isOversea;
}
private ChannelOptions(bool isOversea, string? configFilePath)
private ChannelOptions(ChannelOptionsErrorKind errorKind, string? filePath)
{
IsOversea = isOversea;
ConfigFilePath = configFilePath;
ErrorKind = errorKind;
FilePath = filePath;
}
public static ChannelOptions FileNotFound(bool isOversea, string configFilePath)
public static ChannelOptions ConfigurationFileNotFound(string filePath)
{
return new(isOversea, configFilePath);
return new(ChannelOptionsErrorKind.ConfigurationFileNotFound, filePath);
}
public static ChannelOptions GamePathNullOrEmpty()
{
return new(ChannelOptionsErrorKind.GamePathNullOrEmpty, string.Empty);
}
/// <inheritdoc/>

View File

@@ -0,0 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Configuration;
internal enum ChannelOptionsErrorKind
{
None,
ConfigurationFileNotFound,
GamePathNullOrEmpty,
}

View File

@@ -1,11 +1,9 @@
// 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;
@@ -17,84 +15,22 @@ internal sealed partial class GameChannelOptionsService : IGameChannelOptionsSer
public ChannelOptions GetChannelOptions()
{
if (!launchOptions.TryGetGamePathAndFilePathByName(ConfigFileName, out string gamePath, out string? configPath))
if (!launchOptions.TryGetGameFileSystem(out GameFileSystem? gameFileSystem))
{
throw ThrowHelper.InvalidOperation($"Invalid game path: {gamePath}");
return ChannelOptions.GamePathNullOrEmpty();
}
bool isOversea = LaunchScheme.ExecutableIsOversea(Path.GetFileName(gamePath));
bool isOversea = LaunchScheme.ExecutableIsOversea(gameFileSystem.GameFileName);
if (!File.Exists(configPath))
if (!File.Exists(gameFileSystem.GameConfigFilePath))
{
return ChannelOptions.FileNotFound(isOversea, configPath);
return ChannelOptions.ConfigurationFileNotFound(gameFileSystem.GameConfigFilePath);
}
using (FileStream stream = File.OpenRead(configPath))
{
List<IniParameter> parameters = IniSerializer.Deserialize(stream).OfType<IniParameter>().ToList();
string? channel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.ChannelName)?.Value;
string? subChannel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.SubChannelName)?.Value;
List<IniParameter> parameters = IniSerializer.DeserializeFromFile(gameFileSystem.GameConfigFilePath).OfType<IniParameter>().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)
{
if (!launchOptions.TryGetGamePathAndFilePathByName(ConfigFileName, out string gamePath, out string? configPath))
{
return false;
}
List<IniElement> elements = default!;
try
{
using (FileStream readStream = File.OpenRead(configPath))
{
elements = [.. IniSerializer.Deserialize(readStream)];
}
}
catch (FileNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.FormatServiceGameSetMultiChannelConfigFileNotFound(configPath), ex);
}
catch (DirectoryNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.FormatServiceGameSetMultiChannelConfigFileNotFound(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 is ChannelOptions.ChannelName)
{
changed = parameter.Set(scheme.Channel.ToString("D")) || changed;
continue;
}
if (parameter.Key is ChannelOptions.SubChannelName)
{
changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed;
continue;
}
}
}
if (changed)
{
using (FileStream writeStream = File.Create(configPath))
{
IniSerializer.Serialize(writeStream, elements);
}
}
return changed;
return new(channel, subChannel, isOversea);
}
}

View File

@@ -1,13 +1,9 @@
// 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);
}

View File

@@ -10,6 +10,7 @@ namespace Snap.Hutao.Service.Game;
internal static class GameConstants
{
public const string ConfigFileName = "config.ini";
public const string PCGameSDKFilePath = @"YuanShen_Data\Plugins\PCGameSDK.dll";
public const string YuanShenFileName = "YuanShen.exe";
public const string YuanShenFileNameUpper = "YUANSHEN.EXE";
public const string GenshinImpactFileName = "GenshinImpact.exe";

View File

@@ -0,0 +1,39 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.IO;
namespace Snap.Hutao.Service.Game;
internal sealed class GameFileSystem
{
private readonly string gameFilePath;
private string? gameFileName;
private string? gameDirectory;
private string? gameConfigFilePath;
private string? pcGameSDKFilePath;
public GameFileSystem(string gameFilePath)
{
this.gameFilePath = gameFilePath;
}
public string GameFilePath { get => gameFilePath; }
public string GameFileName { get => gameFileName ??= Path.GetFileName(gameFilePath); }
public string GameDirectory
{
get
{
gameDirectory ??= Path.GetDirectoryName(gameFilePath);
ArgumentException.ThrowIfNullOrEmpty(gameDirectory);
return gameDirectory;
}
}
public string GameConfigFilePath { get => gameConfigFilePath ??= Path.Combine(GameDirectory, GameConstants.ConfigFileName); }
public string PCGameSDKFilePath { get => pcGameSDKFilePath ??= Path.Combine(GameDirectory, GameConstants.PCGameSDKFilePath); }
}

View File

@@ -5,10 +5,8 @@ using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.Launching.Handler;
using Snap.Hutao.Service.Game.PathAbstraction;
using Snap.Hutao.Service.Game.Process;
using Snap.Hutao.Service.Game.Scheme;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game;
@@ -23,8 +21,6 @@ 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;
/// <inheritdoc/>
@@ -45,12 +41,6 @@ internal sealed partial class GameServiceFacade : IGameServiceFacade
return gameChannelOptionsService.GetChannelOptions();
}
/// <inheritdoc/>
public bool SetChannelOptions(LaunchScheme scheme)
{
return gameChannelOptionsService.SetChannelOptions(scheme);
}
/// <inheritdoc/>
public ValueTask<GameAccount?> DetectGameAccountAsync(SchemeType scheme)
{
@@ -63,12 +53,6 @@ internal sealed partial class GameServiceFacade : IGameServiceFacade
return gameAccountService.DetectCurrentGameAccount(scheme);
}
/// <inheritdoc/>
public bool SetGameAccount(GameAccount account)
{
return gameAccountService.SetGameAccount(account);
}
/// <inheritdoc/>
public void AttachGameAccountToUid(GameAccount gameAccount, string uid)
{
@@ -90,18 +74,6 @@ internal sealed partial class GameServiceFacade : IGameServiceFacade
/// <inheritdoc/>
public bool IsGameRunning()
{
return gameProcessService.IsGameRunning();
}
/// <inheritdoc/>
public ValueTask LaunchAsync(IProgress<LaunchStatus> progress)
{
return gameProcessService.LaunchAsync(progress);
}
/// <inheritdoc/>
public ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
return gamePackageService.EnsureGameResourceAsync(launchScheme, progress);
return LaunchExecutionEnsureGameNotRunningHandler.IsGameRunning(out _);
}
}

View File

@@ -4,8 +4,6 @@
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
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;
@@ -49,8 +47,6 @@ internal interface IGameServiceFacade
/// <returns>是否正在运行</returns>
bool IsGameRunning();
ValueTask LaunchAsync(IProgress<LaunchStatus> progress);
/// <summary>
/// 异步修改游戏账号名称
/// </summary>
@@ -65,27 +61,5 @@ internal interface IGameServiceFacade
/// <returns>任务</returns>
ValueTask RemoveGameAccountAsync(GameAccount gameAccount);
/// <summary>
/// 替换游戏资源
/// </summary>
/// <param name="launchScheme">目标启动方案</param>
/// <param name="progress">进度</param>
/// <returns>是否替换成功</returns>
ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress);
/// <summary>
/// 修改注册表中的账号信息
/// </summary>
/// <param name="account">账号</param>
/// <returns>是否设置成功</returns>
bool SetGameAccount(GameAccount account);
/// <summary>
/// 设置多通道值
/// </summary>
/// <param name="scheme">方案</param>
/// <returns>是否更改了ini文件</returns>
bool SetChannelOptions(LaunchScheme scheme);
GameAccount? DetectCurrentGameAccount(SchemeType scheme);
}

View File

@@ -3,70 +3,25 @@
using Snap.Hutao.Service.Game.PathAbstraction;
using System.Collections.Immutable;
using System.IO;
namespace Snap.Hutao.Service.Game;
internal static class LaunchOptionsExtension
{
public static bool TryGetGamePathAndGameDirectory(this LaunchOptions options, out string gamePath, [NotNullWhen(true)] out string? gameDirectory)
{
gamePath = options.GamePath;
gameDirectory = Path.GetDirectoryName(gamePath);
if (string.IsNullOrEmpty(gameDirectory))
{
return false;
}
return true;
}
public static bool TryGetGameDirectoryAndGameFileName(this LaunchOptions options, [NotNullWhen(true)] out string? gameDirectory, [NotNullWhen(true)] out string? gameFileName)
public static bool TryGetGameFileSystem(this LaunchOptions options, [NotNullWhen(true)] out GameFileSystem? fileSystem)
{
string gamePath = options.GamePath;
gameDirectory = Path.GetDirectoryName(gamePath);
if (string.IsNullOrEmpty(gameDirectory))
{
gameFileName = default;
return false;
}
gameFileName = Path.GetFileName(gamePath);
if (string.IsNullOrEmpty(gameFileName))
if (string.IsNullOrEmpty(gamePath))
{
fileSystem = default;
return false;
}
fileSystem = new GameFileSystem(gamePath);
return true;
}
public static bool TryGetGamePathAndGameFileName(this LaunchOptions options, out string gamePath, [NotNullWhen(true)] out string? gameFileName)
{
gamePath = options.GamePath;
gameFileName = Path.GetFileName(gamePath);
if (string.IsNullOrEmpty(gameFileName))
{
return false;
}
return true;
}
public static bool TryGetGamePathAndFilePathByName(this LaunchOptions options, string fileName, out string gamePath, [NotNullWhen(true)] out string? filePath)
{
if (options.TryGetGamePathAndGameDirectory(out gamePath, out string? gameDirectory))
{
filePath = Path.Combine(gameDirectory, fileName);
return true;
}
filePath = default;
return false;
}
public static ImmutableList<GamePathEntry> GetGamePathEntries(this LaunchOptions options, out GamePathEntry? entry)
{
string gamePath = options.GamePath;

View File

@@ -0,0 +1,38 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionEnsureGameNotRunningHandler : ILaunchExecutionDelegateHandler
{
public static bool IsGameRunning([NotNullWhen(true)] out System.Diagnostics.Process? runningProcess)
{
// GetProcesses once and manually loop is O(n)
foreach (ref readonly System.Diagnostics.Process process in System.Diagnostics.Process.GetProcesses().AsSpan())
{
if (string.Equals(process.ProcessName, GameConstants.YuanShenProcessName, StringComparison.OrdinalIgnoreCase) ||
string.Equals(process.ProcessName, GameConstants.GenshinImpactProcessName, StringComparison.OrdinalIgnoreCase))
{
runningProcess = process;
return true;
}
}
runningProcess = default;
return false;
}
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (IsGameRunning(out System.Diagnostics.Process? process))
{
context.Logger.LogInformation("Game process detected, id: {Id}", process.Id);
context.Result.Kind = LaunchExecutionResultKind.GameProcessRunning;
context.Result.ErrorMessage = SH.ServiceGameLaunchExecutionGameIsRunning;
return;
}
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.Win32.SafeHandles;
using Snap.Hutao.Control.Extension;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.PathAbstraction;
using Snap.Hutao.View.Dialog;
using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using Snap.Hutao.Web.Response;
using System.Collections.Immutable;
using System.IO;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionEnsureGameResourceHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (!context.TryGetGameFileSystem(out GameFileSystem? gameFileSystem))
{
return;
}
if (ShouldConvert(context, gameFileSystem))
{
IServiceProvider serviceProvider = context.ServiceProvider;
IContentDialogFactory contentDialogFactory = serviceProvider.GetRequiredService<IContentDialogFactory>();
IProgressFactory progressFactory = serviceProvider.GetRequiredService<IProgressFactory>();
LaunchGamePackageConvertDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGamePackageConvertDialog>().ConfigureAwait(false);
IProgress<PackageConvertStatus> convertProgress = progressFactory.CreateForMainThread<PackageConvertStatus>(state => dialog.State = state);
using (await dialog.BlockAsync(context.TaskContext).ConfigureAwait(false))
{
if (!await EnsureGameResourceAsync(context, gameFileSystem, convertProgress).ConfigureAwait(false))
{
// context.Result is set in EnsureGameResourceAsync
return;
}
await context.TaskContext.SwitchToMainThreadAsync();
ImmutableList<GamePathEntry> gamePathEntries = context.Options.GetGamePathEntries(out GamePathEntry? selected);
context.ViewModel.SetGamePathEntriesAndSelectedGamePathEntry(gamePathEntries, selected);
}
}
await next().ConfigureAwait(false);
}
private static bool ShouldConvert(LaunchExecutionContext context, GameFileSystem gameFileSystem)
{
// Configuration file changed
if (context.ChannelOptionsChanged)
{
return true;
}
// Executable name not match
if (!context.Scheme.ExecutableMatches(gameFileSystem.GameFileName))
{
return true;
}
if (!context.Scheme.IsOversea)
{
// [It's Bilibili channel xor PCGameSDK.dll exists] means we need to convert
if (context.Scheme.Channel is ChannelType.Bili ^ File.Exists(gameFileSystem.PCGameSDKFilePath))
{
return true;
}
}
return false;
}
private static async ValueTask<bool> EnsureGameResourceAsync(LaunchExecutionContext context, GameFileSystem gameFileSystem, IProgress<PackageConvertStatus> progress)
{
string gameFolder = gameFileSystem.GameDirectory;
string gameFileName = gameFileSystem.GameFileName;
context.Logger.LogInformation("Game folder: {GameFolder}", gameFolder);
if (!CheckDirectoryPermissions(gameFolder))
{
context.Result.Kind = LaunchExecutionResultKind.GameDirectoryInsufficientPermissions;
context.Result.ErrorMessage = SH.ServiceGameEnsureGameResourceInsufficientDirectoryPermissions;
return false;
}
progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation));
ResourceClient resourceClient = context.ServiceProvider.GetRequiredService<ResourceClient>();
Response<GameResource> response = await resourceClient.GetResourceAsync(context.Scheme).ConfigureAwait(false);
if (!response.TryGetDataWithoutUINotification(out GameResource? resource))
{
context.Result.Kind = LaunchExecutionResultKind.GameResourceIndexQueryInvalidResponse;
context.Result.ErrorMessage = SH.FormatServiceGameLaunchExecutionGameResourceQueryIndexFailed(response);
return false;
}
PackageConverter packageConverter = context.ServiceProvider.GetRequiredService<PackageConverter>();
if (!context.Scheme.ExecutableMatches(gameFileName))
{
if (!await packageConverter.EnsureGameResourceAsync(context.Scheme, resource, gameFolder, progress).ConfigureAwait(false))
{
context.Result.Kind = LaunchExecutionResultKind.GameResourcePackageConvertInternalError;
context.Result.ErrorMessage = SH.ViewModelLaunchGameEnsureGameResourceFail;
return false;
}
// We need to change the gamePath if we switched.
string executableName = context.Scheme.IsOversea ? GameConstants.GenshinImpactFileName : GameConstants.YuanShenFileName;
await context.TaskContext.SwitchToMainThreadAsync();
context.Options.UpdateGamePathAndRefreshEntries(Path.Combine(gameFolder, executableName));
}
await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false);
return true;
}
private static bool CheckDirectoryPermissions(string folder)
{
// Program Files has special permissions limitation.
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (folder.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
string tempFilePath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp");
string tempFilePathMove = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp");
// Test create file
using (SafeFileHandle handle = File.OpenHandle(tempFilePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, preallocationSize: 32 * 1024))
{
// Test write file
RandomAccess.Write(handle, "SNAP HUTAO DIRECTORY PERMISSION CHECK"u8, 0);
RandomAccess.FlushToDisk(handle);
}
// Test move file
File.Move(tempFilePath, tempFilePathMove);
// Test delete file
File.Delete(tempFilePathMove);
return true;
}
catch (Exception)
{
return false;
}
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionEnsureSchemeHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (context.Scheme is null)
{
context.Result.Kind = LaunchExecutionResultKind.NoActiveScheme;
context.Result.ErrorMessage = SH.ViewModelLaunchGameSchemeNotSelected;
return;
}
context.Logger.LogInformation("Scheme [{Scheme}] is selected", context.Scheme.DisplayName);
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionGameProcessExitHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (!context.Process.HasExited)
{
context.Progress.Report(new(LaunchPhase.WaitingForExit, SH.ServiceGameLaunchPhaseWaitingProcessExit));
await context.Process.WaitForExitAsync().ConfigureAwait(false);
}
context.Logger.LogInformation("Game process exited with code {ExitCode}", context.Process.ExitCode);
context.Progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited));
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionGameProcessInitializationHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (!context.TryGetGameFileSystem(out GameFileSystem? gameFileSystem))
{
return;
}
context.Progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (context.Process = InitializeGameProcess(context, gameFileSystem))
{
await next().ConfigureAwait(false);
}
}
private static System.Diagnostics.Process InitializeGameProcess(LaunchExecutionContext context, GameFileSystem gameFileSystem)
{
LaunchOptions launchOptions = context.Options;
string commandLine = string.Empty;
if (launchOptions.IsEnabled)
{
// https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html
// https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html
commandLine = new CommandLineBuilder()
.AppendIf(launchOptions.IsBorderless, "-popupwindow")
.AppendIf(launchOptions.IsExclusive, "-window-mode", "exclusive")
.Append("-screen-fullscreen", launchOptions.IsFullScreen ? 1 : 0)
.AppendIf(launchOptions.IsScreenWidthEnabled, "-screen-width", launchOptions.ScreenWidth)
.AppendIf(launchOptions.IsScreenHeightEnabled, "-screen-height", launchOptions.ScreenHeight)
.AppendIf(launchOptions.IsMonitorEnabled, "-monitor", launchOptions.Monitor.Value)
.AppendIf(launchOptions.IsUseCloudThirdPartyMobile, "-platform_type CLOUD_THIRD_PARTY_MOBILE")
.ToString();
}
context.Logger.LogInformation("Command Line Arguments: {commandLine}", commandLine);
return new()
{
StartInfo = new()
{
Arguments = commandLine,
FileName = gameFileSystem.GameFilePath,
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = gameFileSystem.GameDirectory,
},
};
}
}

View File

@@ -0,0 +1,25 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Windows.Win32.Foundation;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionGameProcessStartHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
try
{
context.Process.Start();
context.Logger.LogInformation("Process started");
}
catch (Win32Exception ex) when (ex.HResult == HRESULT.E_FAIL)
{
return;
}
context.Progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted));
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.IO.Ini;
using Snap.Hutao.Service.Game.Configuration;
using System.IO;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionSetChannelOptionsHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (!context.TryGetGameFileSystem(out GameFileSystem? gameFileSystem))
{
// context.Result is set in TryGetGameFileSystem
return;
}
string configPath = gameFileSystem.GameConfigFilePath;
context.Logger.LogInformation("Game config file path: {ConfigPath}", configPath);
List<IniElement> elements = default!;
try
{
elements = [.. IniSerializer.DeserializeFromFile(configPath)];
}
catch (FileNotFoundException)
{
context.Result.Kind = LaunchExecutionResultKind.GameConfigFileNotFound;
context.Result.ErrorMessage = SH.FormatServiceGameSetMultiChannelConfigFileNotFound(configPath);
return;
}
catch (DirectoryNotFoundException)
{
context.Result.Kind = LaunchExecutionResultKind.GameConfigDirectoryNotFound;
context.Result.ErrorMessage = SH.FormatServiceGameSetMultiChannelConfigFileNotFound(configPath);
return;
}
catch (UnauthorizedAccessException)
{
context.Result.Kind = LaunchExecutionResultKind.GameConfigInsufficientPermissions;
context.Result.ErrorMessage = SH.ServiceGameSetMultiChannelUnauthorizedAccess;
return;
}
foreach (IniElement element in elements)
{
if (element is IniParameter parameter)
{
if (parameter.Key is ChannelOptions.ChannelName)
{
context.ChannelOptionsChanged = parameter.Set(context.Scheme.Channel.ToString("D")) || context.ChannelOptionsChanged;
continue;
}
if (parameter.Key is ChannelOptions.SubChannelName)
{
context.ChannelOptionsChanged = parameter.Set(context.Scheme.SubChannel.ToString("D")) || context.ChannelOptionsChanged;
continue;
}
}
}
if (context.ChannelOptionsChanged)
{
IniSerializer.SerializeToFile(configPath, elements);
}
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Discord;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionSetDiscordActivityHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
bool previousSetDiscordActivityWhenPlaying = context.Options.SetDiscordActivityWhenPlaying;
try
{
if (previousSetDiscordActivityWhenPlaying)
{
context.Logger.LogInformation("Set discord activity as playing");
await context.ServiceProvider
.GetRequiredService<IDiscordService>()
.SetPlayingActivityAsync(context.Scheme.IsOversea)
.ConfigureAwait(false);
}
await next().ConfigureAwait(false);
}
finally
{
if (previousSetDiscordActivityWhenPlaying)
{
context.Logger.LogInformation("Recover discord activity");
await context.ServiceProvider
.GetRequiredService<IDiscordService>()
.SetNormalActivityAsync()
.ConfigureAwait(false);
}
}
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Account;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionSetGameAccountHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (context.Account is not null)
{
context.Logger.LogInformation("Set game account to [{Account}]", context.Account.Name);
if (!RegistryInterop.Set(context.Account))
{
context.Result.Kind = LaunchExecutionResultKind.GameAccountRegistryWriteResultNotMatch;
context.Result.ErrorMessage = SH.ViewModelLaunchGameSwitchGameAccountFail;
return;
}
}
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Account;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionSetWindowsHDRHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (context.Options.IsWindowsHDREnabled)
{
context.Logger.LogInformation("Set Windows HDR");
RegistryInterop.SetWindowsHDR(context.Scheme.IsOversea);
}
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Windows.System;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionStarwardPlayTimeStatisticsHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (context.Options.UseStarwardPlayTimeStatistics)
{
context.Logger.LogInformation("Using starward to count game time");
await LaunchStarwardForPlayTimeStatisticsAsync(context).ConfigureAwait(false);
}
await next().ConfigureAwait(false);
}
private static async ValueTask LaunchStarwardForPlayTimeStatisticsAsync(LaunchExecutionContext context)
{
string gameBiz = context.Scheme.IsOversea ? "hk4e_global" : "hk4e_cn";
Uri starwardPlayTimeUri = $"starward://playtime/{gameBiz}".ToUri();
if (await Launcher.QueryUriSupportAsync(starwardPlayTimeUri, LaunchQuerySupportType.Uri) is LaunchQuerySupportStatus.Available)
{
context.Logger.LogInformation("Launching starward");
await Launcher.LaunchUriAsync(starwardPlayTimeUri);
}
}
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Factory.Progress;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionStatusProgressHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
IProgressFactory progressFactory = context.ServiceProvider.GetRequiredService<IProgressFactory>();
LaunchStatusOptions statusOptions = context.ServiceProvider.GetRequiredService<LaunchStatusOptions>();
context.Progress = progressFactory.CreateForMainThread<LaunchStatus>(status => statusOptions.LaunchStatus = status);
await next().ConfigureAwait(false);
// Clear status
context.Progress.Report(default!);
}
}

View File

@@ -0,0 +1,42 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Service.Game.Unlocker;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionUnlockFpsHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
RuntimeOptions runtimeOptions = context.ServiceProvider.GetRequiredService<RuntimeOptions>();
if (runtimeOptions.IsElevated && context.Options.IsAdvancedLaunchOptionsEnabled && context.Options.UnlockFps)
{
context.Logger.LogInformation("Unlocking FPS");
context.Progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps));
IProgressFactory progressFactory = context.ServiceProvider.GetRequiredService<IProgressFactory>();
IProgress<UnlockerStatus> progress = progressFactory.CreateForMainThread<UnlockerStatus>(status => context.Progress.Report(LaunchStatus.FromUnlockStatus(status)));
GameFpsUnlocker unlocker = context.ServiceProvider.CreateInstance<GameFpsUnlocker>(context.Process);
try
{
await unlocker.UnlockAsync(new(100, 20000, 3000), progress, context.CancellationToken).ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
context.Logger.LogCritical(ex, "Unlocking FPS failed");
context.Result.Kind = LaunchExecutionResultKind.GameFpsUnlockingFailed;
context.Result.ErrorMessage = ex.Message;
// The Unlocker can't unlock the process
context.Process.Kill();
}
}
await next().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching;
internal delegate ValueTask<LaunchExecutionContext> LaunchExecutionDelegate();
internal interface ILaunchExecutionDelegateHandler
{
ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next);
}

View File

@@ -0,0 +1,71 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.ViewModel.Game;
namespace Snap.Hutao.Service.Game.Launching;
[ConstructorGenerated]
internal sealed partial class LaunchExecutionContext
{
private readonly ILogger<LaunchExecutionContext> logger;
private readonly IServiceProvider serviceProvider;
private readonly ITaskContext taskContext;
private readonly LaunchOptions options;
private GameFileSystem? gameFileSystem;
[SuppressMessage("", "SH007")]
public LaunchExecutionContext(IServiceProvider serviceProvider, IViewModelSupportLaunchExecution viewModel, LaunchScheme? scheme, GameAccount? account)
: this(serviceProvider)
{
ViewModel = viewModel;
Scheme = scheme!;
Account = account;
}
public LaunchExecutionResult Result { get; } = new();
public CancellationToken CancellationToken { get; set; }
public IServiceProvider ServiceProvider { get => serviceProvider; }
public ITaskContext TaskContext { get => taskContext; }
public ILogger Logger { get => logger; }
public LaunchOptions Options { get => options; }
public IViewModelSupportLaunchExecution ViewModel { get; private set; } = default!;
public LaunchScheme Scheme { get; private set; } = default!;
public GameAccount? Account { get; private set; }
public bool ChannelOptionsChanged { get; set; }
public IProgress<LaunchStatus> Progress { get; set; } = default!;
public System.Diagnostics.Process Process { get; set; } = default!;
public bool TryGetGameFileSystem([NotNullWhen(true)] out GameFileSystem? gameFileSystem)
{
if (this.gameFileSystem is not null)
{
gameFileSystem = this.gameFileSystem;
return true;
}
if (!Options.TryGetGameFileSystem(out gameFileSystem))
{
Result.Kind = LaunchExecutionResultKind.NoActiveGamePath;
Result.ErrorMessage = SH.ServiceGameLaunchExecutionGamePathNotValid;
return false;
}
this.gameFileSystem = gameFileSystem;
return true;
}
}

View File

@@ -0,0 +1,50 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Service.Game.Launching.Handler;
namespace Snap.Hutao.Service.Game.Launching;
[Injection(InjectAs.Transient)]
internal sealed class LaunchExecutionInvoker
{
private readonly Queue<ILaunchExecutionDelegateHandler> handlers;
public LaunchExecutionInvoker()
{
handlers = [];
handlers.Enqueue(new LaunchExecutionEnsureGameNotRunningHandler());
handlers.Enqueue(new LaunchExecutionEnsureSchemeHandler());
handlers.Enqueue(new LaunchExecutionSetChannelOptionsHandler());
handlers.Enqueue(new LaunchExecutionEnsureGameResourceHandler());
handlers.Enqueue(new LaunchExecutionSetGameAccountHandler());
handlers.Enqueue(new LaunchExecutionSetWindowsHDRHandler());
handlers.Enqueue(new LaunchExecutionStatusProgressHandler());
handlers.Enqueue(new LaunchExecutionGameProcessInitializationHandler());
handlers.Enqueue(new LaunchExecutionSetDiscordActivityHandler());
handlers.Enqueue(new LaunchExecutionGameProcessStartHandler());
handlers.Enqueue(new LaunchExecutionStarwardPlayTimeStatisticsHandler());
handlers.Enqueue(new LaunchExecutionUnlockFpsHandler());
handlers.Enqueue(new LaunchExecutionGameProcessExitHandler());
}
public async ValueTask<LaunchExecutionResult> InvokeAsync(LaunchExecutionContext context)
{
await InvokeHandlerAsync(context).ConfigureAwait(false);
return context.Result;
}
private async ValueTask<LaunchExecutionContext> InvokeHandlerAsync(LaunchExecutionContext context)
{
if (handlers.TryDequeue(out ILaunchExecutionDelegateHandler? handler))
{
string typeName = TypeNameHelper.GetTypeDisplayName(handler, false);
context.Logger.LogInformation("Handler[{Handler}] begin execution", typeName);
await handler.OnExecutionAsync(context, () => InvokeHandlerAsync(context)).ConfigureAwait(false);
context.Logger.LogInformation("Handler[{Handler}] end execution", typeName);
}
return context;
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching;
internal sealed class LaunchExecutionResult
{
public LaunchExecutionResultKind Kind { get; set; }
public string ErrorMessage { get; set; } = default!;
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Launching;
internal enum LaunchExecutionResultKind
{
Ok,
NoActiveScheme,
NoActiveGamePath,
GameProcessRunning,
GameConfigFileNotFound,
GameConfigDirectoryNotFound,
GameConfigInsufficientPermissions,
GameDirectoryInsufficientPermissions,
GameResourceIndexQueryInvalidResponse,
GameResourcePackageConvertInternalError,
GameAccountRegistryWriteResultNotMatch,
GameFpsUnlockingFailed,
}

View File

@@ -1,102 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.Win32.SafeHandles;
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 LaunchOptions launchOptions;
private readonly ITaskContext taskContext;
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
if (!launchOptions.TryGetGameDirectoryAndGameFileName(out string? gameFolder, out string? gameFileName))
{
return false;
}
if (!CheckDirectoryPermissions(gameFolder))
{
progress.Report(new(SH.ServiceGameEnsureGameResourceInsufficientDirectoryPermissions));
return false;
}
progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation));
Response<GameResource> response = await serviceProvider
.GetRequiredService<ResourceClient>()
.GetResourceAsync(launchScheme)
.ConfigureAwait(false);
if (!response.IsOk())
{
return false;
}
GameResource resource = response.Data;
if (!launchScheme.ExecutableMatches(gameFileName))
{
// We can't start the game when we failed to convert game
if (!await packageConverter.EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress).ConfigureAwait(false))
{
return false;
}
// We need to change the gamePath if we switched.
string exeName = launchScheme.IsOversea ? GenshinImpactFileName : YuanShenFileName;
await taskContext.SwitchToMainThreadAsync();
launchOptions.UpdateGamePathAndRefreshEntries(Path.Combine(gameFolder, exeName));
}
await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false);
return true;
}
private static bool CheckDirectoryPermissions(string folder)
{
// Program Files has special permissions limitation.
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (folder.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
string tempFilePath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp");
string tempFilePathMove = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp");
// Test create file
using (SafeFileHandle handle = File.OpenHandle(tempFilePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, preallocationSize: 32 * 1024))
{
// Test write file
RandomAccess.Write(handle, "SNAP HUTAO DIRECTORY PERMISSION CHECK"u8, 0);
RandomAccess.FlushToDisk(handle);
}
// Test move file
File.Move(tempFilePath, tempFilePathMove);
// Test delete file
File.Delete(tempFilePathMove);
return true;
}
catch (Exception)
{
return false;
}
}
}

View File

@@ -1,11 +0,0 @@
// 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<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress);
}

View File

@@ -8,25 +8,15 @@ namespace Snap.Hutao.Service.Game.Package;
/// <summary>
/// 包更新状态
/// </summary>
internal sealed class PackageReplaceStatus
internal sealed class PackageConvertStatus
{
/// <summary>
/// 构造一个新的包更新状态
/// </summary>
/// <param name="name">描述</param>
public PackageReplaceStatus(string name)
public PackageConvertStatus(string name)
{
Name = name;
Description = name;
}
/// <summary>
/// 构造一个新的包更新状态
/// </summary>
/// <param name="name">名称</param>
/// <param name="bytesRead">读取的字节数</param>
/// <param name="totalBytes">总字节数</param>
public PackageReplaceStatus(string name, long bytesRead, long totalBytes)
public PackageConvertStatus(string name, long bytesRead, long totalBytes)
{
Percent = (double)bytesRead / totalBytes;
Name = name;

View File

@@ -34,7 +34,7 @@ internal sealed partial class PackageConverter
private readonly HttpClient httpClient;
private readonly ILogger<PackageConverter> logger;
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme targetScheme, GameResource gameResource, string gameFolder, IProgress<PackageReplaceStatus> progress)
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme targetScheme, GameResource gameResource, string gameFolder, IProgress<PackageConvertStatus> progress)
{
// 以 国服 => 国际 为例
// 1. 下载国际服的 pkg_version 文件,转换为索引字典
@@ -93,7 +93,6 @@ internal sealed partial class PackageConverter
ZipFile.ExtractToDirectory(sdkWebStream, gameFolder, true);
}
// TODO: verify sdk md5
if (File.Exists(sdkDllBackup) && File.Exists(sdkVersionBackup))
{
File.Delete(sdkDllBackup);
@@ -188,7 +187,7 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask PrepareCacheFilesAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask PrepareCacheFilesAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageConvertStatus> progress)
{
foreach (PackageItemOperationInfo info in operations)
{
@@ -204,7 +203,7 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask SkipOrDownloadAsync(PackageItemOperationInfo info, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask SkipOrDownloadAsync(PackageItemOperationInfo info, PackageConverterFileSystemContext context, IProgress<PackageConvertStatus> progress)
{
// 还原正确的远程地址
string remoteName = string.Format(CultureInfo.CurrentCulture, info.Remote.RelativePath, context.ToDataFolderName);
@@ -230,7 +229,7 @@ internal sealed partial class PackageConverter
Directory.CreateDirectory(directory);
string remoteUrl = context.GetScatteredFilesUrl(remoteName);
HttpShardCopyWorkerOptions<PackageReplaceStatus> options = new()
HttpShardCopyWorkerOptions<PackageConvertStatus> options = new()
{
HttpClient = httpClient,
SourceUrl = remoteUrl,
@@ -238,7 +237,7 @@ internal sealed partial class PackageConverter
StatusFactory = (bytesRead, totalBytes) => new(remoteName, bytesRead, totalBytes),
};
using (HttpShardCopyWorker<PackageReplaceStatus> worker = await HttpShardCopyWorker<PackageReplaceStatus>.CreateAsync(options).ConfigureAwait(false))
using (HttpShardCopyWorker<PackageConvertStatus> worker = await HttpShardCopyWorker<PackageConvertStatus>.CreateAsync(options).ConfigureAwait(false))
{
try
{
@@ -258,7 +257,7 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask<bool> ReplaceGameResourceAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask<bool> ReplaceGameResourceAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageConvertStatus> progress)
{
// 执行下载与移动操作
foreach (PackageItemOperationInfo info in operations)

View File

@@ -9,7 +9,7 @@ internal sealed class GamePathEntry
public string Path { get; set; } = default!;
[JsonIgnore]
public GamePathKind Kind { get => GetKind(Path); }
public GamePathEntryKind Kind { get => GetKind(Path); }
public static GamePathEntry Create(string path)
{
@@ -19,8 +19,8 @@ internal sealed class GamePathEntry
};
}
private static GamePathKind GetKind(string path)
private static GamePathEntryKind GetKind(string path)
{
return GamePathKind.None;
return GamePathEntryKind.None;
}
}

View File

@@ -3,7 +3,7 @@
namespace Snap.Hutao.Service.Game.PathAbstraction;
internal enum GamePathKind
internal enum GamePathEntryKind
{
None,
ChineseClient,

View File

@@ -1,186 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Service.Discord;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Game.Unlocker;
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Process;
/// <summary>
/// 进程互操作
/// </summary>
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameProcessService))]
internal sealed partial class GameProcessService : IGameProcessService
{
private readonly IServiceProvider serviceProvider;
private readonly IProgressFactory progressFactory;
private readonly IDiscordService discordService;
private readonly RuntimeOptions runtimeOptions;
private readonly LaunchOptions launchOptions;
private volatile bool isGameRunning;
public bool IsGameRunning()
{
if (isGameRunning)
{
return true;
}
// Original two GetProcessesByName is O(2n)
// GetProcesses once and manually loop is O(n)
foreach (ref System.Diagnostics.Process process in System.Diagnostics.Process.GetProcesses().AsSpan())
{
if (process.ProcessName is YuanShenProcessName or GenshinImpactProcessName)
{
return true;
}
}
return false;
}
public async ValueTask LaunchAsync(IProgress<LaunchStatus> progress)
{
if (IsGameRunning())
{
return;
}
if (!launchOptions.TryGetGamePathAndGameFileName(out string gamePath, out string? gameFileName))
{
ArgumentException.ThrowIfNullOrEmpty(gamePath);
return; // null check passing, actually never reach.
}
bool isOversea = LaunchScheme.ExecutableIsOversea(gameFileName);
if (launchOptions.IsWindowsHDREnabled)
{
RegistryInterop.SetWindowsHDR(isOversea);
}
progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (System.Diagnostics.Process game = InitializeGameProcess(gamePath))
{
await using (await GameRunningTracker.CreateAsync(this, isOversea).ConfigureAwait(false))
{
game.Start();
progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted));
if (launchOptions.UseStarwardPlayTimeStatistics)
{
await Starward.LaunchForPlayTimeStatisticsAsync(isOversea).ConfigureAwait(false);
}
if (runtimeOptions.IsElevated && launchOptions.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));
}
}
}
}
private System.Diagnostics.Process InitializeGameProcess(string gamePath)
{
string commandLine = string.Empty;
if (launchOptions.IsEnabled)
{
// https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html
// https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html
commandLine = new CommandLineBuilder()
.AppendIf(launchOptions.IsBorderless, "-popupwindow")
.AppendIf(launchOptions.IsExclusive, "-window-mode", "exclusive")
.Append("-screen-fullscreen", launchOptions.IsFullScreen ? 1 : 0)
.AppendIf(launchOptions.IsScreenWidthEnabled, "-screen-width", launchOptions.ScreenWidth)
.AppendIf(launchOptions.IsScreenHeightEnabled, "-screen-height", launchOptions.ScreenHeight)
.AppendIf(launchOptions.IsMonitorEnabled, "-monitor", launchOptions.Monitor.Value)
.AppendIf(launchOptions.IsUseCloudThirdPartyMobile, "-platform_type CLOUD_THIRD_PARTY_MOBILE")
.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<LaunchStatus> progress, CancellationToken token = default)
{
#pragma warning disable CA1859
IGameFpsUnlocker unlocker = serviceProvider.CreateInstance<GameFpsUnlocker>(game);
#pragma warning restore CA1859
UnlockTimingOptions options = new(100, 20000, 3000);
IProgress<UnlockerStatus> lockerProgress = progressFactory.CreateForMainThread<UnlockerStatus>(unlockStatus => progress.Report(LaunchStatus.FromUnlockStatus(unlockStatus)));
return unlocker.UnlockAsync(options, lockerProgress, token);
}
private class GameRunningTracker : IAsyncDisposable
{
private readonly GameProcessService service;
private readonly bool previousSetDiscordActivityWhenPlaying;
private GameRunningTracker(GameProcessService service, bool isOversea)
{
service.isGameRunning = true;
previousSetDiscordActivityWhenPlaying = service.launchOptions.SetDiscordActivityWhenPlaying;
this.service = service;
}
public static async ValueTask<GameRunningTracker> CreateAsync(GameProcessService service, bool isOversea)
{
GameRunningTracker tracker = new(service, isOversea);
if (tracker.previousSetDiscordActivityWhenPlaying)
{
await service.discordService.SetPlayingActivity(isOversea).ConfigureAwait(false);
}
return tracker;
}
public async ValueTask DisposeAsync()
{
if (previousSetDiscordActivityWhenPlaying)
{
await service.discordService.SetNormalActivity().ConfigureAwait(false);
}
service.isGameRunning = false;
}
}
}

View File

@@ -1,11 +0,0 @@
// 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<LaunchStatus> progress);
}

View File

@@ -1,19 +0,0 @@
// 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);
}
}
}

View File

@@ -107,6 +107,7 @@
<None Remove="Control\Theme\Uri.xaml" />
<None Remove="Control\Theme\WindowOverride.xaml" />
<None Remove="GuideWindow.xaml" />
<None Remove="IdentifyMonitorWindow.xaml" />
<None Remove="IdentityStructs.json" />
<None Remove="LaunchGameWindow.xaml" />
<None Remove="Resource\BlurBackground.png" />
@@ -321,7 +322,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.507">
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.556">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -544,7 +545,13 @@
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="IdentifyMonitorWindow.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="View\Control\HutaoStatisticsCard.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -40,6 +40,7 @@
</Grid.RowDefinitions>
<FontIcon
Grid.Row="0"
Grid.Column="0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
FontSize="{ThemeResource TitleTextBlockFontSize}"
@@ -61,16 +62,22 @@
Content="{StaticResource FontIconContentSetting}"
FontFamily="{StaticResource SymbolThemeFontFamily}"
ToolTipService.ToolTip="{shcm:ResourceString Name=ViewPageHomeLaunchGameSettingAction}"/>
<shc:SizeRestrictedContentControl
<StackPanel
Grid.Row="2"
Grid.ColumnSpan="3"
VerticalAlignment="Bottom">
<ComboBox
DisplayMemberPath="Name"
ItemsSource="{Binding GameAccountsView}"
PlaceholderText="{shcm:ResourceString Name=ViewCardLaunchGameSelectAccountPlaceholder}"
SelectedItem="{Binding SelectedGameAccount, Mode=TwoWay}"/>
</shc:SizeRestrictedContentControl>
VerticalAlignment="Bottom"
Spacing="8">
<TextBlock
Opacity="0.7"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{Binding LaunchStatusOptions.LaunchStatus.Description, Mode=OneWay}"/>
<shc:SizeRestrictedContentControl VerticalAlignment="Bottom">
<ComboBox
DisplayMemberPath="Name"
ItemsSource="{Binding GameAccountsView}"
PlaceholderText="{shcm:ResourceString Name=ViewCardLaunchGameSelectAccountPlaceholder}"
SelectedItem="{Binding SelectedGameAccount, Mode=TwoWay}"/>
</shc:SizeRestrictedContentControl>
</StackPanel>
</Grid>
</Grid>
</Button>

View File

@@ -11,7 +11,7 @@ namespace Snap.Hutao.View.Dialog;
/// 启动游戏客户端转换对话框
/// </summary>
[HighQuality]
[DependencyProperty("State", typeof(PackageReplaceStatus))]
[DependencyProperty("State", typeof(PackageConvertStatus))]
internal sealed partial class LaunchGamePackageConvertDialog : ContentDialog
{
/// <summary>

View File

@@ -228,7 +228,7 @@
<ToggleSwitch Width="120" IsOn="{Binding LaunchOptions.IsUseCloudThirdPartyMobile, Mode=TwoWay}"/>
</cwc:SettingsCard>
<cwc:SettingsCard Description="{shcm:ResourceString Name=ViewPageLaunchGameAppearanceAspectRatioDescription}" Header="{shcm:ResourceString Name=ViewPageLaunchGameAppearanceAspectRatioHeader}">
<shc:SizeRestrictedContentControl Margin="0,0,136,0">
<shc:SizeRestrictedContentControl Margin="0,0,136,0" VerticalAlignment="Center">
<ComboBox
ItemsSource="{Binding LaunchOptions.AspectRatios}"
PlaceholderText="{shcm:ResourceString Name=ViewPageLaunchGameAppearanceAspectRatioPlaceHolder}"
@@ -259,7 +259,11 @@
</cwc:SettingsCard>
<cwc:SettingsCard Description="{shcm:ResourceString Name=ViewPageLaunchGameMonitorsDescription}" Header="-monitor">
<StackPanel Orientation="Horizontal" Spacing="16">
<shc:SizeRestrictedContentControl>
<Button
Width="120"
Command="{Binding IdentifyMonitorsCommand}"
Content="{shcm:ResourceString Name=ViewModelLaunchGameIdentifyMonitorsAction}"/>
<shc:SizeRestrictedContentControl VerticalAlignment="Center">
<ComboBox
DisplayMemberPath="Name"
IsEnabled="{Binding LaunchOptions.IsMonitorEnabled}"

View File

@@ -0,0 +1,12 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.PathAbstraction;
using System.Collections.Immutable;
namespace Snap.Hutao.ViewModel.Game;
internal interface IViewModelSupportLaunchExecution
{
void SetGamePathEntriesAndSelectedGamePathEntry(ImmutableList<GamePathEntry> gamePathEntries, GamePathEntry? selectedEntry);
}

View File

@@ -13,17 +13,9 @@ internal static class LaunchGameShared
{
public static LaunchScheme? GetCurrentLaunchSchemeFromConfigFile(IGameServiceFacade gameService, IInfoBarService infoBarService)
{
ChannelOptions options;
try
{
options = gameService.GetChannelOptions();
}
catch (InvalidOperationException)
{
return default;
}
ChannelOptions options = gameService.GetChannelOptions();
if (string.IsNullOrEmpty(options.ConfigFilePath))
if (options.ErrorKind is ChannelOptionsErrorKind.None)
{
try
{
@@ -40,7 +32,7 @@ internal static class LaunchGameShared
}
else
{
infoBarService.Warning(SH.FormatViewModelLaunchGameMultiChannelReadFail(options.ConfigFilePath));
infoBarService.Warning($"{options.ErrorKind}", SH.FormatViewModelLaunchGameMultiChannelReadFail(options.FilePath));
}
return default;

View File

@@ -3,27 +3,24 @@
using CommunityToolkit.WinUI.Collections;
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Control.Extension;
using Microsoft.UI.Windowing;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics.CodeAnalysis;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Launching;
using Snap.Hutao.Service.Game.Locator;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.PathAbstraction;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User;
using Snap.Hutao.View.Dialog;
using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using System.IO;
using Windows.Win32.Foundation;
using Windows.Graphics;
namespace Snap.Hutao.ViewModel.Game;
@@ -33,18 +30,16 @@ namespace Snap.Hutao.ViewModel.Game;
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Scoped)]
internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel, IViewModelSupportLaunchExecution
{
/// <summary>
/// 启动游戏目标 Uid
/// </summary>
public const string DesiredUid = nameof(DesiredUid);
private readonly IContentDialogFactory contentDialogFactory;
private readonly LaunchStatusOptions launchStatusOptions;
private readonly IGameLocatorFactory gameLocatorFactory;
private readonly ILogger<LaunchGameViewModel> logger;
private readonly IProgressFactory progressFactory;
private readonly IInfoBarService infoBarService;
private readonly ResourceClient resourceClient;
private readonly RuntimeOptions runtimeOptions;
@@ -172,9 +167,16 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
}
}
public void SetGamePathEntriesAndSelectedGamePathEntry(ImmutableList<GamePathEntry> gamePathEntries, GamePathEntry? selectedEntry)
{
GamePathEntries = gamePathEntries;
SelectedGamePathEntry = selectedEntry;
}
protected override ValueTask<bool> InitializeUIAsync()
{
SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions();
ImmutableList<GamePathEntry> gamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
SetGamePathEntriesAndSelectedGamePathEntry(gamePathEntries, entry);
return ValueTask.FromResult(true);
}
@@ -207,51 +209,18 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
[Command("LaunchCommand")]
private async Task LaunchAsync()
{
if (SelectedScheme is null)
{
infoBarService.Error(SH.ViewModelLaunchGameSchemeNotSelected);
return;
}
try
{
gameService.SetChannelOptions(SelectedScheme);
LaunchExecutionContext context = new(Ioc.Default, this, SelectedScheme, SelectedGameAccount);
LaunchExecutionResult result = await new LaunchExecutionInvoker().InvokeAsync(context).ConfigureAwait(false);
LaunchGamePackageConvertDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGamePackageConvertDialog>().ConfigureAwait(false);
IProgress<PackageReplaceStatus> convertProgress = progressFactory.CreateForMainThread<PackageReplaceStatus>(state => dialog.State = state);
using (await dialog.BlockAsync(taskContext).ConfigureAwait(false))
if (result.Kind is not LaunchExecutionResultKind.Ok)
{
// Always ensure game resources
if (!await gameService.EnsureGameResourceAsync(SelectedScheme, convertProgress).ConfigureAwait(false))
{
infoBarService.Warning(SH.ViewModelLaunchGameEnsureGameResourceFail, dialog.State?.Name ?? string.Empty);
return;
}
else
{
await taskContext.SwitchToMainThreadAsync();
SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions();
}
infoBarService.Warning(result.ErrorMessage);
}
if (SelectedGameAccount is not null && !gameService.SetGameAccount(SelectedGameAccount))
{
infoBarService.Warning(SH.ViewModelLaunchGameSwitchGameAccountFail);
return;
}
IProgress<LaunchStatus> launchProgress = progressFactory.CreateForMainThread<LaunchStatus>(status => launchStatusOptions.LaunchStatus = status);
await gameService.LaunchAsync(launchProgress).ConfigureAwait(false);
}
catch (Exception ex)
{
if (ex is Win32Exception win32Exception && win32Exception.HResult == HRESULT.E_FAIL)
{
// User canceled the operation. ignore
return;
}
logger.LogCritical(ex, "Launch failed");
infoBarService.Error(ex);
}
@@ -372,9 +341,27 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
}
}
private void SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions()
[Command("IdentifyMonitorsCommand")]
private async Task IdentifyMonitorsAsync()
{
GamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
SelectedGamePathEntry = entry;
List<IdentifyMonitorWindow> windows = [];
IReadOnlyList<DisplayArea> displayAreas = DisplayArea.FindAll();
for (int i = 0; i < displayAreas.Count; i++)
{
windows.Add(new IdentifyMonitorWindow(displayAreas[i], i + 1));
}
foreach (IdentifyMonitorWindow window in windows)
{
window.Activate();
}
await Delay.FromSeconds(3).ConfigureAwait(true);
foreach (IdentifyMonitorWindow window in windows)
{
window.Close();
}
}
}

View File

@@ -3,13 +3,14 @@
using CommunityToolkit.WinUI.Collections;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Launching;
using Snap.Hutao.Service.Game.PathAbstraction;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Notification;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
using Windows.Win32.Foundation;
namespace Snap.Hutao.ViewModel.Game;
@@ -18,10 +19,10 @@ namespace Snap.Hutao.ViewModel.Game;
/// </summary>
[Injection(InjectAs.Transient)]
[ConstructorGenerated(CallBaseConstructor = true)]
internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSlim<View.Page.LaunchGamePage>
internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSlim<View.Page.LaunchGamePage>, IViewModelSupportLaunchExecution
{
private readonly LaunchStatusOptions launchStatusOptions;
private readonly IProgressFactory progressFactory;
private readonly ILogger<LaunchGameViewModelSlim> logger;
private readonly IInfoBarService infoBarService;
private readonly IGameServiceFacade gameService;
private readonly ITaskContext taskContext;
@@ -30,6 +31,8 @@ internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSli
private GameAccount? selectedGameAccount;
private GameAccountFilter? gameAccountFilter;
public LaunchStatusOptions LaunchStatusOptions { get => launchStatusOptions; }
public AdvancedCollectionView? GameAccountsView { get => gameAccountsView; set => SetProperty(ref gameAccountsView, value); }
/// <summary>
@@ -37,6 +40,10 @@ internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSli
/// </summary>
public GameAccount? SelectedGameAccount { get => selectedGameAccount; set => SetProperty(ref selectedGameAccount, value); }
public void SetGamePathEntriesAndSelectedGamePathEntry(ImmutableList<GamePathEntry> gamePathEntries, GamePathEntry? selectedEntry)
{
}
/// <inheritdoc/>
protected override async Task OpenUIAsync()
{
@@ -69,29 +76,21 @@ internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSli
private async Task LaunchAsync()
{
IInfoBarService infoBarService = ServiceProvider.GetRequiredService<IInfoBarService>();
LaunchScheme? scheme = LaunchGameShared.GetCurrentLaunchSchemeFromConfigFile(gameService, infoBarService);
try
{
if (SelectedGameAccount is not null)
{
if (!gameService.SetGameAccount(SelectedGameAccount))
{
infoBarService.Warning(SH.ViewModelLaunchGameSwitchGameAccountFail);
return;
}
}
LaunchExecutionContext context = new(Ioc.Default, this, scheme, SelectedGameAccount);
LaunchExecutionResult result = await new LaunchExecutionInvoker().InvokeAsync(context).ConfigureAwait(false);
IProgress<LaunchStatus> launchProgress = progressFactory.CreateForMainThread<LaunchStatus>(status => launchStatusOptions.LaunchStatus = status);
await gameService.LaunchAsync(launchProgress).ConfigureAwait(false);
if (result.Kind is not LaunchExecutionResultKind.Ok)
{
infoBarService.Warning(result.ErrorMessage);
}
}
catch (Exception ex)
{
if (ex is Win32Exception win32Exception && win32Exception.HResult == HRESULT.E_FAIL)
{
// User canceled the operation. ignore
return;
}
logger.LogCritical(ex, "Launch failed");
infoBarService.Error(ex);
}
}

View File

@@ -164,7 +164,7 @@ internal sealed partial class GuideViewModel : Abstraction.ViewModel
.Select(category => new DownloadSummary(serviceProvider, category))
.ToObservableCollection();
await Parallel.ForEachAsync(DownloadSummaries, async (summary, token) =>
await Parallel.ForEachAsync([..DownloadSummaries], async (summary, token) =>
{
if (await summary.DownloadAndExtractAsync().ConfigureAwait(false))
{

View File

@@ -10,34 +10,36 @@ namespace Snap.Hutao.ViewModel.Setting;
internal sealed partial class FolderViewModel : ObservableObject
{
private readonly ITaskContext taskContext;
private readonly string folder;
private string? size;
public FolderViewModel(ITaskContext taskContext, string folder)
{
this.taskContext = taskContext;
this.folder = folder;
SetFolderSizeAsync().SafeForget();
async ValueTask SetFolderSizeAsync()
{
await taskContext.SwitchToBackgroundAsync();
long totalSize = 0;
foreach (string file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
{
totalSize += new FileInfo(file).Length;
}
await taskContext.SwitchToMainThreadAsync();
Size = SH.FormatViewModelSettingFolderSizeDescription(Converters.ToFileSizeString(totalSize));
}
}
public string Folder { get => folder; }
public string? Size { get => size; set => SetProperty(ref size, value); }
public async ValueTask SetFolderSizeAsync()
{
await taskContext.SwitchToBackgroundAsync();
long totalSize = 0;
foreach (string file in Directory.EnumerateFiles(folder, "*.*", SearchOption.AllDirectories))
{
totalSize += new FileInfo(file).Length;
}
await taskContext.SwitchToMainThreadAsync();
Size = SH.FormatViewModelSettingFolderSizeDescription(Converters.ToFileSizeString(totalSize));
}
[Command("OpenFolderCommand")]
private async Task OpenDataFolderAsync()
{

View File

@@ -284,6 +284,11 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel
Directory.Delete(cacheFolder, true);
}
if (DataFolderView is not null)
{
await DataFolderView.SetFolderSizeAsync().ConfigureAwait(false);
}
infoBarService.Information(SH.ViewModelSettingActionComplete);
}
}

View File

@@ -3,7 +3,6 @@
namespace Snap.Hutao.Web.Bridge.Model;
[SuppressMessage("", "SA1124")]
internal sealed class ShareContent
{
[JsonPropertyName("preview")]

View File

@@ -24,4 +24,19 @@ internal static class ResponseExtension
return false;
}
}
public static bool TryGetDataWithoutUINotification<TData>(this Response<TData> response, [NotNullWhen(true)] out TData? data)
{
if (response.ReturnCode == 0)
{
ArgumentNullException.ThrowIfNull(response.Data);
data = response.Data;
return true;
}
else
{
data = default;
return false;
}
}
}