Compare commits

..

17 Commits

Author SHA1 Message Date
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
DismissedLight
03d235876a Merge pull request #1260 from DGP-Studio/develop 2024-01-03 22:18:36 +08:00
DismissedLight
f49e9669af update version 2024-01-03 22:18:08 +08:00
DismissedLight
533c70caaa allow null package convert state 2024-01-03 21:53:13 +08:00
DismissedLight
dd59302bb3 fix bilibili server crash 2024-01-03 20:40:37 +08:00
DismissedLight
96e42f51f0 Merge pull request #1254 from DGP-Studio/develop 2024-01-03 20:02:54 +08:00
DismissedLight
5a19c19759 update version 2024-01-03 20:01:47 +08:00
DismissedLight
8fb831ef7c fix startup launch game card crash 2024-01-03 19:58:54 +08:00
Masterain
a30c8d8678 Update automation 2024-01-03 03:56:53 -08:00
51 changed files with 860 additions and 573 deletions

View File

@@ -12,6 +12,7 @@ on:
- '.gitmodules'
- '**.md'
- 'LICENSE'
- '**.yml'
jobs:
build:

View File

@@ -70,4 +70,4 @@ Refresh:
- job: release
script:
- apt-get install -y curl
- curl -X PATCH $PURGE_URL
- curl -X PATCH "$PURGE_URL"

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

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

@@ -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

@@ -13,7 +13,7 @@
<Identity
Name="60568DGPStudio.SnapHutao"
Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52"
Version="1.9.2.0" />
Version="1.9.4.0" />
<Properties>
<DisplayName>Snap Hutao</DisplayName>

View File

@@ -13,7 +13,7 @@
<Identity
Name="60568DGPStudio.SnapHutaoDev"
Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52"
Version="1.9.2.0" />
Version="1.9.4.0" />
<Properties>
<DisplayName>Snap Hutao Dev</DisplayName>

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>

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,6 +29,11 @@ internal sealed partial class GameAccountService : IGameAccountService
{
ArgumentNullException.ThrowIfNull(gameAccounts);
if (schemeType is SchemeType.ChineseBilibili)
{
return default;
}
string? registrySdk = RegistryInterop.Get(schemeType);
if (string.IsNullOrEmpty(registrySdk))
{
@@ -62,6 +67,11 @@ internal sealed partial class GameAccountService : IGameAccountService
{
ArgumentNullException.ThrowIfNull(gameAccounts);
if (schemeType is SchemeType.ChineseBilibili)
{
return default;
}
string? registrySdk = RegistryInterop.Get(schemeType);
if (string.IsNullOrEmpty(registrySdk))

View File

@@ -38,63 +38,4 @@ internal sealed partial class GameChannelOptionsService : IGameChannelOptionsSer
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;
}
}

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

@@ -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

@@ -49,8 +49,6 @@ internal interface IGameServiceFacade
/// <returns>是否正在运行</returns>
bool IsGameRunning();
ValueTask LaunchAsync(IProgress<LaunchStatus> progress);
/// <summary>
/// 异步修改游戏账号名称
/// </summary>
@@ -65,27 +63,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

@@ -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,132 @@
// 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.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)
{
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, 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 async ValueTask<bool> EnsureGameResourceAsync(LaunchExecutionContext context, IProgress<PackageConvertStatus> progress)
{
if (!context.Options.TryGetGameDirectoryAndGameFileName(out string? gameFolder, out string? gameFileName))
{
context.Result.Kind = LaunchExecutionResultKind.NoActiveGamePath;
context.Result.ErrorMessage = SH.ServiceGameLaunchExecutionGamePathNotValid;
return false;
}
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 LaunchExecutionEnsureSchemeNotExistsHandler : 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,61 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using System.IO;
namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionGameProcessInitializationHandler : ILaunchExecutionDelegateHandler
{
public async ValueTask OnExecutionAsync(LaunchExecutionContext context, LaunchExecutionDelegate next)
{
if (!context.Options.TryGetGamePathAndGameFileName(out string gamePath, out string? gameFileName))
{
context.Result.Kind = LaunchExecutionResultKind.NoActiveGamePath;
context.Result.ErrorMessage = SH.ServiceGameLaunchExecutionGamePathNotValid;
return;
}
context.Progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (context.Process = InitializeGameProcess(context, gamePath))
{
await next().ConfigureAwait(false);
}
}
private static System.Diagnostics.Process InitializeGameProcess(LaunchExecutionContext context, string gamePath)
{
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 = gamePath,
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = Path.GetDirectoryName(gamePath),
},
};
}
}

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,80 @@
// 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.Options.TryGetGamePathAndFilePathByName(GameConstants.ConfigFileName, out string gamePath, out string? configPath))
{
context.Result.Kind = LaunchExecutionResultKind.NoActiveGamePath;
context.Result.ErrorMessage = SH.ServiceGameLaunchExecutionGamePathNotValid;
return;
}
context.Logger.LogInformation("Game config file path: {ConfigPath}", configPath);
List<IniElement> elements = default!;
try
{
using (FileStream readStream = File.OpenRead(configPath))
{
elements = [.. IniSerializer.Deserialize(readStream)];
}
}
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;
}
bool changed = false;
foreach (IniElement element in elements)
{
if (element is IniParameter parameter)
{
if (parameter.Key is ChannelOptions.ChannelName)
{
changed = parameter.Set(context.Scheme.Channel.ToString("D")) || changed;
continue;
}
if (parameter.Key is ChannelOptions.SubChannelName)
{
changed = parameter.Set(context.Scheme.SubChannel.ToString("D")) || changed;
continue;
}
}
}
if (changed)
{
using (FileStream writeStream = File.Create(configPath))
{
IniSerializer.Serialize(writeStream, 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,18 @@
// 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);
}
}

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,48 @@
// 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;
[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; set; } = default!;
public LaunchScheme Scheme { get; set; } = default!;
public GameAccount? Account { get; set; }
public IProgress<LaunchStatus> Progress { get; set; } = default!;
public System.Diagnostics.Process Process { get; set; } = default!;
}

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 LaunchExecutionEnsureSchemeNotExistsHandler());
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 = default!;
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 文件,转换为索引字典
@@ -188,7 +188,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 +204,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 +230,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 +238,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 +258,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

@@ -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

@@ -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

@@ -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,7 +13,16 @@ internal static class LaunchGameShared
{
public static LaunchScheme? GetCurrentLaunchSchemeFromConfigFile(IGameServiceFacade gameService, IInfoBarService infoBarService)
{
ChannelOptions options = gameService.GetChannelOptions();
ChannelOptions options;
try
{
options = gameService.GetChannelOptions();
}
catch (InvalidOperationException)
{
return default;
}
if (string.IsNullOrEmpty(options.ConfigFilePath))
{
try

View File

@@ -3,27 +3,22 @@
using CommunityToolkit.WinUI.Collections;
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Control.Extension;
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;
namespace Snap.Hutao.ViewModel.Game;
@@ -33,18 +28,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 +165,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 +207,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);
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);
}
@@ -371,10 +338,4 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
};
}
}
private void SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions()
{
GamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
SelectedGamePathEntry = entry;
}
}

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

@@ -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;
}
}
}