This commit is contained in:
Lightczx
2023-07-30 21:32:15 +08:00
parent 2c45274cd3
commit 69dc8355ad
7 changed files with 226 additions and 176 deletions

View File

@@ -1500,6 +1500,15 @@ namespace Snap.Hutao.Resource.Localization {
} }
} }
/// <summary>
/// 查找类似 下载客户端文件失败:{0} 的本地化字符串。
/// </summary>
internal static string ServiceGamePackageRequestScatteredFileFailed {
get {
return ResourceManager.GetString("ServiceGamePackageRequestScatteredFileFailed", resourceCulture);
}
}
/// <summary> /// <summary>
/// 查找类似 无法找到游戏路径,请前往设置修改 的本地化字符串。 /// 查找类似 无法找到游戏路径,请前往设置修改 的本地化字符串。
/// </summary> /// </summary>

View File

@@ -2078,4 +2078,7 @@
<data name="ServiceGachaUIGFImportLanguageNotMatch" xml:space="preserve"> <data name="ServiceGachaUIGFImportLanguageNotMatch" xml:space="preserve">
<value>UIGF 文件的语言:{0} 与胡桃的语言:{1} 不匹配,请切换到对应语言重试</value> <value>UIGF 文件的语言:{0} 与胡桃的语言:{1} 不匹配,请切换到对应语言重试</value>
</data> </data>
<data name="ServiceGamePackageRequestScatteredFileFailed" xml:space="preserve">
<value>下载客户端文件失败:{0}</value>
</data>
</root> </root>

View File

@@ -20,35 +20,23 @@ internal readonly struct ItemOperationInfo
/// <summary> /// <summary>
/// 目标文件 /// 目标文件
/// </summary> /// </summary>
public readonly string Target; public readonly VersionItem Remote;
/// <summary> /// <summary>
/// 移动至中时的名称 /// 移动至中时的名称
/// </summary> /// </summary>
public readonly string MoveTo; public readonly VersionItem Local;
/// <summary>
/// 文件的目标Md5
/// </summary>
public readonly string Md5;
/// <summary>
/// 文件的目标大小 Byte
/// </summary>
public readonly long TotalBytes;
/// <summary> /// <summary>
/// 构造一个新的包操作 /// 构造一个新的包操作
/// </summary> /// </summary>
/// <param name="type">操作类型</param> /// <param name="type">操作类型</param>
/// <param name="target">目标</param> /// <param name="remote">远程</param>
/// <param name="moveTo">缓存</param> /// <param name="local">本地</param>
public ItemOperationInfo(ItemOperationType type, VersionItem target, VersionItem moveTo) public ItemOperationInfo(ItemOperationType type, VersionItem remote, VersionItem local)
{ {
Type = type; Type = type;
Target = target.RemoteName; Remote = remote;
MoveTo = moveTo.RemoteName; Local = local;
Md5 = target.Md5;
TotalBytes = target.FileSize;
} }
} }

View File

@@ -10,9 +10,9 @@ namespace Snap.Hutao.Service.Game.Package;
internal enum ItemOperationType internal enum ItemOperationType
{ {
/// <summary> /// <summary>
/// 添加 /// 需要备份
/// </summary> /// </summary>
Add = 0, Backup = 0,
/// <summary> /// <summary>
/// 替换 /// 替换
@@ -20,7 +20,7 @@ internal enum ItemOperationType
Replace = 1, Replace = 1,
/// <summary> /// <summary>
/// 需要备份 /// 添加
/// </summary> /// </summary>
Backup = 2, Add = 2,
} }

View File

@@ -0,0 +1,65 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Package;
internal readonly struct PackageConvertContext
{
public readonly string GameFolder;
public readonly string ServerCacheFolder;
public readonly string ServerCacheBackupFolder; // From
public readonly string ServerCacheTargetFolder; // To
public readonly string FromDataFolderName;
public readonly string ToDataFolderName;
public readonly string FromDataFolder;
public readonly string ToDataFolder;
public readonly string ScatteredFilesUrl;
public readonly string PkgVersionUrl;
public PackageConvertContext(bool isTargetOversea, string dataFolder, string gameFolder, string scatteredFilesUrl)
{
GameFolder = gameFolder;
ServerCacheFolder = Path.Combine(dataFolder, "ServerCache");
string serverCacheOversea = Path.Combine(ServerCacheFolder, "Oversea");
string serverCacheChinese = Path.Combine(ServerCacheFolder, "Chinese");
(ServerCacheBackupFolder, ServerCacheTargetFolder) = isTargetOversea
? (serverCacheChinese, serverCacheOversea)
: (serverCacheOversea, serverCacheChinese);
(FromDataFolderName, ToDataFolderName) = isTargetOversea
? (YuanShenData, GenshinImpactData)
: (GenshinImpactData, YuanShenData);
(FromDataFolder, ToDataFolder) = (Path.Combine(GameFolder, FromDataFolderName), Path.Combine(GameFolder, ToDataFolderName));
ScatteredFilesUrl = scatteredFilesUrl;
PkgVersionUrl = $"{scatteredFilesUrl}/pkg_version";
}
public readonly string GetScatteredFilesUrl(string file)
{
return $"{ScatteredFilesUrl}/{file}";
}
public readonly string GetServerCacheBackupFilePath(string filePath)
{
return Path.Combine(ServerCacheBackupFolder, filePath);
}
public readonly string GetServerCacheTargetFilePath(string filePath)
{
return Path.Combine(ServerCacheTargetFolder, filePath);
}
public readonly string GetGameFolderFilePath(string filePath)
{
return Path.Combine(GameFolder, filePath);
}
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) DGP Studio. All rights reserved. // Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.IO; using Snap.Hutao.Core.IO;
@@ -10,6 +11,7 @@ using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text.RegularExpressions;
using static Snap.Hutao.Service.Game.GameConstants; using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Package; namespace Snap.Hutao.Service.Game.Package;
@@ -23,9 +25,11 @@ namespace Snap.Hutao.Service.Game.Package;
internal sealed partial class PackageConverter internal sealed partial class PackageConverter
{ {
private const string PackageVersion = "pkg_version"; private const string PackageVersion = "pkg_version";
private const string OverseaFolder = "Oversea";
private const string ChineseFolder = "Chinese";
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly JsonSerializerOptions options; private readonly JsonSerializerOptions options;
private readonly RuntimeOptions runtimeOptions;
private readonly HttpClient httpClient; private readonly HttpClient httpClient;
/// <summary> /// <summary>
@@ -57,15 +61,26 @@ internal sealed partial class PackageConverter
// 4. 全部资源下载完成后,根据操作信息项,进行文件替换 // 4. 全部资源下载完成后,根据操作信息项,进行文件替换
// 处理顺序:备份/替换/新增 // 处理顺序:备份/替换/新增
// 替换操作等于 先备份国服文件,随后新增国际服文件 // 替换操作等于 先备份国服文件,随后新增国际服文件
// 准备下载链接
string scatteredFilesUrl = gameResource.Game.Latest.DecompressedPath; string scatteredFilesUrl = gameResource.Game.Latest.DecompressedPath;
Uri pkgVersionUri = $"{scatteredFilesUrl}/{PackageVersion}".ToUri(); string pkgVersionUrl = $"{scatteredFilesUrl}/{PackageVersion}";
ConvertDirection direction = targetScheme.IsOversea ? ConvertDirection.ChineseToOversea : ConvertDirection.OverseaToChinese;
PackageConvertContext context = new(targetScheme.IsOversea, runtimeOptions.DataFolder, gameFolder, scatteredFilesUrl);
// Step 1
progress.Report(new(SH.ServiceGamePackageRequestPackageVerion)); progress.Report(new(SH.ServiceGamePackageRequestPackageVerion));
Dictionary<string, VersionItem> remoteItems = await GetRemoteItemsAsync(pkgVersionUri).ConfigureAwait(false); Dictionary<string, VersionItem> remoteItems = await GetRemoteItemsAsync(pkgVersionUrl).ConfigureAwait(false);
Dictionary<string, VersionItem> localItems = await GetLocalItemsAsync(gameFolder, direction).ConfigureAwait(false); Dictionary<string, VersionItem> localItems = await GetLocalItemsAsync(gameFolder).ConfigureAwait(false);
IEnumerable<ItemOperationInfo> diffOperations = GetItemOperationInfos(remoteItems, localItems).OrderBy(i => i.Type); // Step 2
List<ItemOperationInfo> diffOperations = GetItemOperationInfos(remoteItems, localItems).ToList();
diffOperations.SortBy(i => i.Type);
// Step 3
await PrepareCacheFilesAsync(diffOperations, direction, scatteredFilesUrl, cacheFolder, progress).ConfigureAwait(false);
// Step 4
return await ReplaceGameResourceAsync(diffOperations, gameFolder, scatteredFilesUrl, direction, progress).ConfigureAwait(false); return await ReplaceGameResourceAsync(diffOperations, gameFolder, scatteredFilesUrl, direction, progress).ConfigureAwait(false);
} }
@@ -158,46 +173,39 @@ internal sealed partial class PackageConverter
} }
} }
private static void TryRenameDataFolder(string gameFolder, ConvertDirection direction)
{
string yuanShenData = Path.Combine(gameFolder, YuanShenData);
string genshinImpactData = Path.Combine(gameFolder, GenshinImpactData);
try
{
_ = direction == ConvertDirection.ChineseToOversea
? DirectoryOperation.Move(yuanShenData, genshinImpactData)
: DirectoryOperation.Move(genshinImpactData, yuanShenData);
}
catch (IOException ex)
{
// Access to the path is denied.
// When user install the game in special folder like 'Program Files'
throw ThrowHelper.GameFileOperation(SH.ServiceGamePackageRenameDataFolderFailed, ex);
}
}
private static void MoveToCache(string cacheFilePath, string targetFullPath) private static void MoveToCache(string cacheFilePath, string targetFullPath)
{ {
Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)!); Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)!);
File.Move(targetFullPath, cacheFilePath, true); File.Move(targetFullPath, cacheFilePath, true);
} }
private async ValueTask<Dictionary<string, VersionItem>> GetLocalItemsAsync(string gameFolder, ConvertDirection direction) [GeneratedRegex("^(?:YuanShen_Data|GenshinImpact_Data)(?=/)")]
private static partial Regex DataFolderRegex();
private async ValueTask<Dictionary<string, VersionItem>> GetVersionItemsAsync(Stream stream)
{ {
using (FileStream localSteam = File.OpenRead(Path.Combine(gameFolder, PackageVersion))) Dictionary<string, VersionItem> results = new();
using (StreamReader reader = new(stream))
{ {
return await GetLocalVersionItemsAsync(localSteam, direction).ConfigureAwait(false); Regex dataFolderRegex = DataFolderRegex();
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } row && !string.IsNullOrEmpty(row))
{
VersionItem item = JsonSerializer.Deserialize<VersionItem>(row, options)!;
item.RelativePath = dataFolderRegex.Replace(item.RelativePath, "{0}");
results.Add(item.RelativePath, item);
}
} }
return results;
} }
private async ValueTask<Dictionary<string, VersionItem>> GetRemoteItemsAsync(Uri pkgVersionUri) private async ValueTask<Dictionary<string, VersionItem>> GetRemoteItemsAsync(string pkgVersionUrl)
{ {
try try
{ {
using (Stream remoteSteam = await httpClient.GetStreamAsync(pkgVersionUri).ConfigureAwait(false)) using (Stream remoteSteam = await httpClient.GetStreamAsync(pkgVersionUrl).ConfigureAwait(false))
{ {
return await GetRemoteVersionItemsAsync(remoteSteam).ConfigureAwait(false); return await GetVersionItemsAsync(remoteSteam).ConfigureAwait(false);
} }
} }
catch (IOException ex) catch (IOException ex)
@@ -206,104 +214,136 @@ internal sealed partial class PackageConverter
} }
} }
private async ValueTask<bool> ReplaceGameResourceAsync(IEnumerable<ItemOperationInfo> operations, string gameFolder, string scatteredFilesUrl, ConvertDirection direction, IProgress<PackageReplaceStatus> progress) private async ValueTask<Dictionary<string, VersionItem>> GetLocalItemsAsync(string gameFolder)
{ {
// 重命名 _Data 目录 using (FileStream localSteam = File.OpenRead(Path.Combine(gameFolder, PackageVersion)))
TryRenameDataFolder(gameFolder, direction); {
return await GetVersionItemsAsync(localSteam).ConfigureAwait(false);
}
}
// Cache folder private async ValueTask PrepareCacheFilesAsync(List<ItemOperationInfo> operations, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
Core.RuntimeOptions runtimeOptions = serviceProvider.GetRequiredService<Core.RuntimeOptions>(); {
string cacheFolder = Path.Combine(runtimeOptions.DataFolder, "ServerCache");
// 执行下载与移动操作
foreach (ItemOperationInfo info in operations) foreach (ItemOperationInfo info in operations)
{ {
progress.Report(new($"{info.Target}"));
string targetFilePath = Path.Combine(gameFolder, info.Target);
string cacheFilePath = Path.Combine(cacheFolder, info.Target);
string moveToFilePath = Path.Combine(cacheFolder, info.MoveTo);
switch (info.Type) switch (info.Type)
{ {
case ItemOperationType.Backup: case ItemOperationType.Backup:
MoveToCache(moveToFilePath, targetFilePath); continue;
break;
case ItemOperationType.Replace: case ItemOperationType.Replace:
MoveToCache(moveToFilePath, targetFilePath);
await ReplaceFromCacheOrWebAsync(cacheFilePath, targetFilePath, scatteredFilesUrl, info, progress).ConfigureAwait(false);
break;
case ItemOperationType.Add: case ItemOperationType.Add:
await ReplaceFromCacheOrWebAsync(cacheFilePath, targetFilePath, scatteredFilesUrl, info, progress).ConfigureAwait(false); await SkipOrDownloadAsync(info, context, progress).ConfigureAwait(false);
break;
default:
break; break;
} }
} }
// 重新下载所有 *pkg_version 文件
await ReplacePackageVersionFilesAsync(scatteredFilesUrl, gameFolder).ConfigureAwait(false);
return true;
} }
private async ValueTask ReplaceFromCacheOrWebAsync(string cacheFilePath, string targetFilePath, string scatteredFilesUrl, ItemOperationInfo info, IProgress<PackageReplaceStatus> progress) private async ValueTask SkipOrDownloadAsync(ItemOperationInfo info, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
{ {
if (File.Exists(cacheFilePath)) // 还原正确的远程地址
string remoteName = string.Format(info.Remote.RelativePath, context.ToDataFolderName);
string cacheFile = context.GetServerCacheTargetFilePath(remoteName);
if (File.Exists(cacheFile))
{ {
string remoteMd5 = await MD5.HashFileAsync(cacheFilePath).ConfigureAwait(false); if (info.Remote.FileSize == new FileInfo(cacheFile).Length)
if (info.Md5 == remoteMd5.ToLowerInvariant() && new FileInfo(cacheFilePath).Length == info.TotalBytes)
{ {
// Valid, move it to target path string cacheMd5 = await MD5.HashFileAsync(cacheFile).ConfigureAwait(false);
// There shouldn't be any file in the path/name if (info.Remote.Md5.Equals(cacheMd5, StringComparison.OrdinalIgnoreCase))
File.Move(cacheFilePath, targetFilePath, false); {
return; return;
} }
else
{
// Invalid file, delete it
File.Delete(cacheFilePath);
} }
// Invalid file, delete it
File.Delete(cacheFile);
} }
// Cache no item, download it anyway. // Cache no matching item, download
while (true) using (FileStream fileStream = File.Create(cacheFile))
{ {
using (FileStream fileStream = File.Create(targetFilePath)) string remoteUrl = context.GetScatteredFilesUrl(remoteName);
using (HttpResponseMessage response = await httpClient.GetAsync(remoteUrl, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false))
{ {
using (HttpResponseMessage response = await httpClient.GetAsync($"{scatteredFilesUrl}/{info.Target}", HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false)) // This stream's length is incorrect,
// so we use length in the header
long totalBytes = response.Content.Headers.ContentLength ?? 0;
using (Stream webStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
{ {
long totalBytes = response.Content.Headers.ContentLength ?? 0; try
using (Stream webStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false))
{ {
try StreamCopyWorker<PackageReplaceStatus> streamCopyWorker = new(webStream, fileStream, bytesRead => new(remoteName, bytesRead, totalBytes));
await streamCopyWorker.CopyAsync(progress).ConfigureAwait(false);
fileStream.Position = 0;
string cacheMd5 = await MD5.HashAsync(fileStream).ConfigureAwait(false);
if (string.Equals(info.Remote.Md5, cacheMd5, StringComparison.OrdinalIgnoreCase))
{ {
StreamCopyWorker<PackageReplaceStatus> streamCopyWorker = new(webStream, fileStream, bytesRead => new(info.Target, bytesRead, totalBytes)); return;
await streamCopyWorker.CopyAsync(progress).ConfigureAwait(false);
fileStream.Position = 0;
string remoteMd5 = await MD5.HashAsync(fileStream).ConfigureAwait(false);
if (string.Equals(info.Md5, remoteMd5, StringComparison.OrdinalIgnoreCase))
{
return;
}
}
catch (Exception ex)
{
// System.IO.IOException: The response ended prematurely.
// System.IO.IOException: Received an unexpected EOF or 0 bytes from the transport stream.
// We want to retry forever.
serviceProvider.GetRequiredService<IInfoBarService>().Error(ex);
await Delay.FromSeconds(2).ConfigureAwait(false);
} }
} }
catch (Exception ex)
{
// System.IO.IOException: The response ended prematurely.
// System.IO.IOException: Received an unexpected EOF or 0 bytes from the transport stream.
ThrowHelper.PackageConvert(string.Format(SH.ServiceGamePackageRequestScatteredFileFailed, remoteName), ex);
}
} }
} }
} }
} }
private async ValueTask ReplacePackageVersionFilesAsync(string scatteredFilesUrl, string gameFolder) private async ValueTask<bool> ReplaceGameResourceAsync(List<ItemOperationInfo> operations, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
{ {
foreach (string versionFilePath in Directory.EnumerateFiles(gameFolder, "*pkg_version")) // 重命名 _Data 目录
try
{
DirectoryOperation.Move(context.FromDataFolder, context.ToDataFolder);
}
catch (IOException ex)
{
// Access to the path is denied.
// When user install the game in special folder like 'Program Files'
throw ThrowHelper.GameFileOperation(SH.ServiceGamePackageRenameDataFolderFailed, ex);
}
// 执行下载与移动操作
foreach (ItemOperationInfo info in operations)
{
progress.Report(new($"{info.Remote}"));
(bool backup, bool target) = info.Type switch
{
ItemOperationType.Backup => (true, false),
ItemOperationType.Replace => (true, true),
ItemOperationType.Add => (false, true),
_ => (false, false),
};
if (backup)
{
string localFileName = string.Format(info.Local.RelativePath, context.FromDataFolder);
string localFilePath = context.GetGameFolderFilePath(localFileName);
Directory.CreateDirectory(Path.GetDirectoryName(localFilePath)!);
File.Move(localFilePath, context.GetServerCacheBackupFilePath(localFileName), true);
}
if (target)
{
string targetFileName = string.Format(info.Remote.RelativePath, context.ToDataFolder);
string targetFilePath = context.GetGameFolderFilePath(targetFileName);
Directory.CreateDirectory(Path.GetDirectoryName(targetFilePath)!);
File.Move(context.GetServerCacheTargetFilePath(targetFileName), targetFilePath, true);
}
}
// 重新下载所有 *pkg_version 文件
await ReplacePackageVersionFilesAsync(context).ConfigureAwait(false);
return true;
}
private async ValueTask ReplacePackageVersionFilesAsync(PackageConvertContext context)
{
foreach (string versionFilePath in Directory.EnumerateFiles(context.GameFolder, "*pkg_version"))
{ {
string versionFileName = Path.GetFileName(versionFilePath); string versionFileName = Path.GetFileName(versionFilePath);
@@ -316,66 +356,11 @@ internal sealed partial class PackageConverter
using (FileStream versionFileStream = File.Create(versionFilePath)) using (FileStream versionFileStream = File.Create(versionFilePath))
{ {
using (Stream webStream = await httpClient.GetStreamAsync($"{scatteredFilesUrl}/{versionFileName}").ConfigureAwait(false)) using (Stream webStream = await httpClient.GetStreamAsync(context.GetScatteredFilesUrl(versionFileName)).ConfigureAwait(false))
{ {
await webStream.CopyToAsync(versionFileStream).ConfigureAwait(false); await webStream.CopyToAsync(versionFileStream).ConfigureAwait(false);
} }
} }
} }
} }
private async ValueTask<Dictionary<string, VersionItem>> GetRemoteVersionItemsAsync(Stream stream)
{
Dictionary<string, VersionItem> results = new();
using (StreamReader reader = new(stream))
{
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } raw)
{
if (string.IsNullOrEmpty(raw))
{
continue;
}
VersionItem item = JsonSerializer.Deserialize<VersionItem>(raw, options)!;
results.Add(item.RemoteName, item);
}
}
return results;
}
private async ValueTask<Dictionary<string, VersionItem>> GetLocalVersionItemsAsync(Stream stream, ConvertDirection direction)
{
Dictionary<string, VersionItem> results = new();
using (StreamReader reader = new(stream))
{
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } row)
{
if (string.IsNullOrEmpty(row))
{
continue;
}
VersionItem item = JsonSerializer.Deserialize<VersionItem>(row, options)!;
string remoteName = item.RemoteName;
// 我们已经提前重命名了整个 Data 文件夹 所以需要将 RemoteName 中的 Data 同样替换
if (remoteName.StartsWith(YuanShenData) || remoteName.StartsWith(GenshinImpactData))
{
remoteName = direction switch
{
ConvertDirection.OverseaToChinese => $"{YuanShenData}{remoteName[GenshinImpactData.Length..]}",
ConvertDirection.ChineseToOversea => $"{GenshinImpactData}{remoteName[YuanShenData.Length..]}",
_ => remoteName,
};
}
results.Add(remoteName, item);
}
}
return results;
}
} }

View File

@@ -13,7 +13,7 @@ internal sealed class VersionItem
/// 服务器上的名称 /// 服务器上的名称
/// </summary> /// </summary>
[JsonPropertyName("remoteName")] [JsonPropertyName("remoteName")]
public string RemoteName { get; set; } = default!; public string RelativePath { get; set; } = default!;
/// <summary> /// <summary>
/// MD5校验值 /// MD5校验值