diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.Designer.cs b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.Designer.cs index b4c36d8b..b97a6c26 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.Designer.cs +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.Designer.cs @@ -1500,6 +1500,15 @@ namespace Snap.Hutao.Resource.Localization { } } + /// + /// 查找类似 下载客户端文件失败:{0} 的本地化字符串。 + /// + internal static string ServiceGamePackageRequestScatteredFileFailed { + get { + return ResourceManager.GetString("ServiceGamePackageRequestScatteredFileFailed", resourceCulture); + } + } + /// /// 查找类似 无法找到游戏路径,请前往设置修改 的本地化字符串。 /// diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index b918c340..d0ccd76b 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -2078,4 +2078,7 @@ UIGF 文件的语言:{0} 与胡桃的语言:{1} 不匹配,请切换到对应语言重试 + + 下载客户端文件失败:{0} + \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationInfo.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationInfo.cs index e2261157..bb4acb4e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationInfo.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationInfo.cs @@ -20,35 +20,23 @@ internal readonly struct ItemOperationInfo /// /// 目标文件 /// - public readonly string Target; + public readonly VersionItem Remote; /// /// 移动至中时的名称 /// - public readonly string MoveTo; - - /// - /// 文件的目标Md5 - /// - public readonly string Md5; - - /// - /// 文件的目标大小 Byte - /// - public readonly long TotalBytes; + public readonly VersionItem Local; /// /// 构造一个新的包操作 /// /// 操作类型 - /// 目标 - /// 缓存 - public ItemOperationInfo(ItemOperationType type, VersionItem target, VersionItem moveTo) + /// 远程 + /// 本地 + public ItemOperationInfo(ItemOperationType type, VersionItem remote, VersionItem local) { Type = type; - Target = target.RemoteName; - MoveTo = moveTo.RemoteName; - Md5 = target.Md5; - TotalBytes = target.FileSize; + Remote = remote; + Local = local; } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationType.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationType.cs index e3ca31f3..19b8a299 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationType.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/ItemOperationType.cs @@ -10,9 +10,9 @@ namespace Snap.Hutao.Service.Game.Package; internal enum ItemOperationType { /// - /// 添加 + /// 需要备份 /// - Add = 0, + Backup = 0, /// /// 替换 @@ -20,7 +20,7 @@ internal enum ItemOperationType Replace = 1, /// - /// 需要备份 + /// 添加 /// - Backup = 2, + Add = 2, } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConvertContext.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConvertContext.cs new file mode 100644 index 00000000..605c6906 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConvertContext.cs @@ -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); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs index 7284e4c0..5a21643a 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/PackageConverter.cs @@ -1,6 +1,7 @@ // Copyright (c) DGP Studio. All rights reserved. // Licensed under the MIT license. +using Snap.Hutao.Core; using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.IO; @@ -10,6 +11,7 @@ using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher; using System.IO; using System.IO.Compression; using System.Net.Http; +using System.Text.RegularExpressions; using static Snap.Hutao.Service.Game.GameConstants; namespace Snap.Hutao.Service.Game.Package; @@ -23,9 +25,11 @@ namespace Snap.Hutao.Service.Game.Package; internal sealed partial class PackageConverter { private const string PackageVersion = "pkg_version"; - + private const string OverseaFolder = "Oversea"; + private const string ChineseFolder = "Chinese"; private readonly IServiceProvider serviceProvider; private readonly JsonSerializerOptions options; + private readonly RuntimeOptions runtimeOptions; private readonly HttpClient httpClient; /// @@ -57,15 +61,26 @@ internal sealed partial class PackageConverter // 4. 全部资源下载完成后,根据操作信息项,进行文件替换 // 处理顺序:备份/替换/新增 // 替换操作等于 先备份国服文件,随后新增国际服文件 + + // 准备下载链接 string scatteredFilesUrl = gameResource.Game.Latest.DecompressedPath; - Uri pkgVersionUri = $"{scatteredFilesUrl}/{PackageVersion}".ToUri(); - ConvertDirection direction = targetScheme.IsOversea ? ConvertDirection.ChineseToOversea : ConvertDirection.OverseaToChinese; + string pkgVersionUrl = $"{scatteredFilesUrl}/{PackageVersion}"; + PackageConvertContext context = new(targetScheme.IsOversea, runtimeOptions.DataFolder, gameFolder, scatteredFilesUrl); + + // Step 1 progress.Report(new(SH.ServiceGamePackageRequestPackageVerion)); - Dictionary remoteItems = await GetRemoteItemsAsync(pkgVersionUri).ConfigureAwait(false); - Dictionary localItems = await GetLocalItemsAsync(gameFolder, direction).ConfigureAwait(false); + Dictionary remoteItems = await GetRemoteItemsAsync(pkgVersionUrl).ConfigureAwait(false); + Dictionary localItems = await GetLocalItemsAsync(gameFolder).ConfigureAwait(false); - IEnumerable diffOperations = GetItemOperationInfos(remoteItems, localItems).OrderBy(i => i.Type); + // Step 2 + List 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); } @@ -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) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)!); File.Move(targetFullPath, cacheFilePath, true); } - private async ValueTask> GetLocalItemsAsync(string gameFolder, ConvertDirection direction) + [GeneratedRegex("^(?:YuanShen_Data|GenshinImpact_Data)(?=/)")] + private static partial Regex DataFolderRegex(); + + private async ValueTask> GetVersionItemsAsync(Stream stream) { - using (FileStream localSteam = File.OpenRead(Path.Combine(gameFolder, PackageVersion))) + Dictionary 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(row, options)!; + item.RelativePath = dataFolderRegex.Replace(item.RelativePath, "{0}"); + results.Add(item.RelativePath, item); + } } + + return results; } - private async ValueTask> GetRemoteItemsAsync(Uri pkgVersionUri) + private async ValueTask> GetRemoteItemsAsync(string pkgVersionUrl) { 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) @@ -206,104 +214,136 @@ internal sealed partial class PackageConverter } } - private async ValueTask ReplaceGameResourceAsync(IEnumerable operations, string gameFolder, string scatteredFilesUrl, ConvertDirection direction, IProgress progress) + private async ValueTask> GetLocalItemsAsync(string gameFolder) { - // 重命名 _Data 目录 - TryRenameDataFolder(gameFolder, direction); + using (FileStream localSteam = File.OpenRead(Path.Combine(gameFolder, PackageVersion))) + { + return await GetVersionItemsAsync(localSteam).ConfigureAwait(false); + } + } - // Cache folder - Core.RuntimeOptions runtimeOptions = serviceProvider.GetRequiredService(); - string cacheFolder = Path.Combine(runtimeOptions.DataFolder, "ServerCache"); - - // 执行下载与移动操作 + private async ValueTask PrepareCacheFilesAsync(List operations, PackageConvertContext context, IProgress progress) + { 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) { case ItemOperationType.Backup: - MoveToCache(moveToFilePath, targetFilePath); - break; + continue; case ItemOperationType.Replace: - MoveToCache(moveToFilePath, targetFilePath); - await ReplaceFromCacheOrWebAsync(cacheFilePath, targetFilePath, scatteredFilesUrl, info, progress).ConfigureAwait(false); - break; case ItemOperationType.Add: - await ReplaceFromCacheOrWebAsync(cacheFilePath, targetFilePath, scatteredFilesUrl, info, progress).ConfigureAwait(false); - break; - default: + await SkipOrDownloadAsync(info, context, progress).ConfigureAwait(false); break; } } - - // 重新下载所有 *pkg_version 文件 - await ReplacePackageVersionFilesAsync(scatteredFilesUrl, gameFolder).ConfigureAwait(false); - return true; } - private async ValueTask ReplaceFromCacheOrWebAsync(string cacheFilePath, string targetFilePath, string scatteredFilesUrl, ItemOperationInfo info, IProgress progress) + private async ValueTask SkipOrDownloadAsync(ItemOperationInfo info, PackageConvertContext context, IProgress 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.Md5 == remoteMd5.ToLowerInvariant() && new FileInfo(cacheFilePath).Length == info.TotalBytes) + if (info.Remote.FileSize == new FileInfo(cacheFile).Length) { - // Valid, move it to target path - // There shouldn't be any file in the path/name - File.Move(cacheFilePath, targetFilePath, false); - return; - } - else - { - // Invalid file, delete it - File.Delete(cacheFilePath); + string cacheMd5 = await MD5.HashFileAsync(cacheFile).ConfigureAwait(false); + if (info.Remote.Md5.Equals(cacheMd5, StringComparison.OrdinalIgnoreCase)) + { + return; + } } + + // Invalid file, delete it + File.Delete(cacheFile); } - // Cache no item, download it anyway. - while (true) + // Cache no matching item, download + 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; - using (Stream webStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false)) + try { - try + StreamCopyWorker 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 streamCopyWorker = new(webStream, fileStream, bytesRead => new(info.Target, bytesRead, totalBytes)); - 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().Error(ex); - await Delay.FromSeconds(2).ConfigureAwait(false); + 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. + ThrowHelper.PackageConvert(string.Format(SH.ServiceGamePackageRequestScatteredFileFailed, remoteName), ex); + } } } } } - private async ValueTask ReplacePackageVersionFilesAsync(string scatteredFilesUrl, string gameFolder) + private async ValueTask ReplaceGameResourceAsync(List operations, PackageConvertContext context, IProgress 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); @@ -316,66 +356,11 @@ internal sealed partial class PackageConverter 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); } } } } - - private async ValueTask> GetRemoteVersionItemsAsync(Stream stream) - { - Dictionary results = new(); - using (StreamReader reader = new(stream)) - { - while (await reader.ReadLineAsync().ConfigureAwait(false) is { } raw) - { - if (string.IsNullOrEmpty(raw)) - { - continue; - } - - VersionItem item = JsonSerializer.Deserialize(raw, options)!; - results.Add(item.RemoteName, item); - } - } - - return results; - } - - private async ValueTask> GetLocalVersionItemsAsync(Stream stream, ConvertDirection direction) - { - Dictionary results = new(); - - using (StreamReader reader = new(stream)) - { - while (await reader.ReadLineAsync().ConfigureAwait(false) is { } row) - { - if (string.IsNullOrEmpty(row)) - { - continue; - } - - VersionItem item = JsonSerializer.Deserialize(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; - } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/VersionItem.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/VersionItem.cs index 49205e31..a508c9d7 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/VersionItem.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Package/VersionItem.cs @@ -13,7 +13,7 @@ internal sealed class VersionItem /// 服务器上的名称 /// [JsonPropertyName("remoteName")] - public string RemoteName { get; set; } = default!; + public string RelativePath { get; set; } = default!; /// /// MD5校验值