diff --git a/README.md b/README.md index 74f7e18..0b2f642 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed **目前元数据的编写进度:** -| 项目(V6.3) | 是否完成 | +| 项目(V6.4) | 是否完成 | | ----------- | ----------- | | 总体数据 | ✔️ | @@ -67,6 +67,14 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server - 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server) - Web管理后台和官网:[Snap.Server.Web](https://github.com/wangdage12/Snap.Server.Web) +**第三方工具** + +如果你想要添加你自己开发的工具到第三方工具列表中,请确保: +1. 工具应该提供源码或者开源,并且可以成功编译 +2. 工具不应提供任何可能影响游戏公平性的功能 + +工具不限于注入功能,若满足以上条件,请提 issue,或者在 discord 服务器中联系管理员 + ## 打包测试 由于采用了 wix 进行打包程序,VS 需要安装 **HeatWave for VS2022**(2026兼容)。需要 msi 安装包时,右键选中 Snap.Hutao.Installer 生成后即可在目标目录找到。默认目录:Snap.Hutao.Installer\bin\x64\Release\en-US\Snap.Hutao.Installer.msi diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index 9cbd417..fc26109 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -1598,6 +1598,9 @@ 启动 + + 版本: + 使用米游社扫描二维码 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs index e00b25e..690e0df 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs @@ -34,4 +34,18 @@ internal interface IThirdPartyToolService /// 工具信息 /// 是否已下载 bool IsToolDownloaded(ToolInfo tool); + + /// + /// 获取本地工具信息 + /// + /// 工具信息 + /// 本地工具信息,如果不存在则返回null + LocalToolInfo? GetLocalToolInfo(ToolInfo tool); + + /// + /// 检查工具是否需要更新 + /// + /// 工具信息 + /// 是否需要更新 + bool NeedsUpdate(ToolInfo tool); } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/ThirdPartyToolService.cs b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/ThirdPartyToolService.cs index 89d48be..b359369 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/ThirdPartyToolService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/ThirdPartyToolService.cs @@ -9,7 +9,9 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; using System.IO; +using System.IO.Compression; using System.Net.Http; +using System.Text.Json; namespace Snap.Hutao.Service.ThirdPartyTool; @@ -19,6 +21,7 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService { private const string ApiBaseUrl = "https://htserver.wdg.cloudns.ch/api"; private const string ToolsEndpoint = "/tools"; + private const string ToolInfoFileName = "tool_info.json"; private readonly IHttpClientFactory httpClientFactory; private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory; @@ -32,10 +35,10 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService try { HttpClient httpClient = httpClientFactory.CreateClient(); - + // 添加日志 SentrySdk.AddBreadcrumb($"Creating request to: {ApiBaseUrl}{ToolsEndpoint}", category: "ThirdPartyTool"); - + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() .SetRequestUri($"{ApiBaseUrl}{ToolsEndpoint}") .Get(); @@ -89,41 +92,32 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService try { string toolDirectory = GetToolDirectory(tool); - Directory.CreateDirectory(toolDirectory); - int totalFiles = tool.Files.Count; - int downloadedFiles = 0; + // 如果需要更新,先清理旧文件 + if (NeedsUpdate(tool) && Directory.Exists(toolDirectory)) + { + Directory.Delete(toolDirectory, true); + } + + Directory.CreateDirectory(toolDirectory); using (HttpClient httpClient = httpClientFactory.CreateClient()) { - foreach (string fileName in tool.Files) + if (tool.IsCompressed) { - string fileUrl = $"{tool.Url}{fileName}"; - string localFilePath = Path.Combine(toolDirectory, fileName); - - // 如果文件已存在,跳过下载 - if (File.Exists(localFilePath)) - { - downloadedFiles++; - progress?.Report((double)downloadedFiles / totalFiles * 100); - continue; - } - - // 下载文件 - HttpResponseMessage response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); - response.EnsureSuccessStatusCode(); - - using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false)) - using (FileStream fileStream = new(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) - { - await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false); - } - - downloadedFiles++; - progress?.Report((double)downloadedFiles / totalFiles * 100); + // 压缩包模式:下载并解压 + await DownloadAndExtractCompressedToolAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false); + } + else + { + // 非压缩包模式:直接下载所有文件 + await DownloadFilesAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false); } } + // 保存本地工具信息 + SaveLocalToolInfo(tool); + return true; } catch (Exception ex) @@ -139,15 +133,31 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService { string toolDirectory = GetToolDirectory(tool); - // 查找可执行文件(.exe) - string? executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + // 优先使用 main_exe,如果没有则查找可执行文件 + string? executablePath = tool.MainExe; + if (string.IsNullOrEmpty(executablePath)) + { + executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + } + + // 如果还是没有,尝试从目录中查找 + if (string.IsNullOrEmpty(executablePath)) + { + string[] exeFiles = Directory.GetFiles(toolDirectory, "*.exe", SearchOption.TopDirectoryOnly); + executablePath = exeFiles.FirstOrDefault(); + } + if (string.IsNullOrEmpty(executablePath)) { messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound)); return false; } - string fullPath = Path.Combine(toolDirectory, executablePath); + // 如果 executablePath 是完整路径,直接使用;否则拼接目录 + string fullPath = Path.IsPathRooted(executablePath) + ? executablePath + : Path.Combine(toolDirectory, Path.GetFileName(executablePath)); + if (!File.Exists(fullPath)) { messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath))); @@ -192,7 +202,20 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService return false; } - // 检查所有文件是否存在 + // 检查工具信息文件是否存在 + LocalToolInfo? localInfo = GetLocalToolInfo(tool); + if (localInfo is null) + { + return false; + } + + // 对于压缩包,检查目录是否有内容 + if (tool.IsCompressed) + { + return Directory.GetFiles(toolDirectory, "*", SearchOption.AllDirectories).Length > 0; + } + + // 对于非压缩包,检查所有文件是否存在 foreach (string fileName in tool.Files) { string filePath = Path.Combine(toolDirectory, fileName); @@ -205,6 +228,217 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService return true; } + public LocalToolInfo? GetLocalToolInfo(ToolInfo tool) + { + string toolDirectory = GetToolDirectory(tool); + string infoFilePath = Path.Combine(toolDirectory, ToolInfoFileName); + + if (!File.Exists(infoFilePath)) + { + return null; + } + + try + { + string json = File.ReadAllText(infoFilePath); + return JsonSerializer.Deserialize(json); + } + catch + { + return null; + } + } + + public bool NeedsUpdate(ToolInfo tool) + { + LocalToolInfo? localInfo = GetLocalToolInfo(tool); + if (localInfo is null) + { + return true; // 没有本地信息,需要下载 + } + + // 比较版本号 + return IsNewerVersion(tool.Version, localInfo.Version); + } + + private static async Task DownloadAndExtractCompressedToolAsync( + HttpClient httpClient, + ToolInfo tool, + string toolDirectory, + IProgress? progress, + CancellationToken token) + { + // 压缩包模式通常只有一个 zip 文件 + string zipFileName = tool.Files.FirstOrDefault(f => f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + ?? tool.Files[0]; + + string zipUrl = $"{tool.Url}{zipFileName}"; + string zipFilePath = Path.Combine(toolDirectory, zipFileName); + + // 下载 zip 文件 + progress?.Report(0); + + HttpResponseMessage response = await httpClient.GetAsync(zipUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false)) + using (FileStream fileStream = new(zipFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false); + } + + progress?.Report(50); + + // 解压 zip 文件 + using (ZipArchive archive = ZipFile.OpenRead(zipFilePath)) + { + // 检查是否有根目录需要处理 + bool hasRootFolder = HasSingleRootFolder(archive); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (string.IsNullOrEmpty(entry.Name)) + { + // 这是一个目录,创建它 + string? destinationPath = GetDestinationPath(entry.FullName, toolDirectory, hasRootFolder); + if (destinationPath is not null) + { + Directory.CreateDirectory(destinationPath); + } + continue; + } + + string? destFilePath = GetDestinationPath(entry.FullName, toolDirectory, hasRootFolder); + if (destFilePath is null) + { + continue; + } + + // 确保目录存在 + string? destDir = Path.GetDirectoryName(destFilePath); + if (!string.IsNullOrEmpty(destDir)) + { + Directory.CreateDirectory(destDir); + } + + entry.ExtractToFile(destFilePath, true); + } + } + + progress?.Report(90); + + // 删除 zip 文件 + File.Delete(zipFilePath); + + progress?.Report(100); + } + + private static async Task DownloadFilesAsync( + HttpClient httpClient, + ToolInfo tool, + string toolDirectory, + IProgress? progress, + CancellationToken token) + { + int totalFiles = tool.Files.Count; + int downloadedFiles = 0; + + foreach (string fileName in tool.Files) + { + string fileUrl = $"{tool.Url}{fileName}"; + string localFilePath = Path.Combine(toolDirectory, fileName); + + // 如果文件已存在,跳过下载 + if (File.Exists(localFilePath)) + { + downloadedFiles++; + progress?.Report((double)downloadedFiles / totalFiles * 100); + continue; + } + + // 下载文件 + HttpResponseMessage response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false)) + using (FileStream fileStream = new(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false); + } + + downloadedFiles++; + progress?.Report((double)downloadedFiles / totalFiles * 100); + } + } + + private void SaveLocalToolInfo(ToolInfo tool) + { + string toolDirectory = GetToolDirectory(tool); + string infoFilePath = Path.Combine(toolDirectory, ToolInfoFileName); + + LocalToolInfo localInfo = LocalToolInfo.FromToolInfo(tool); + string json = JsonSerializer.Serialize(localInfo, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(infoFilePath, json); + } + + private static bool IsNewerVersion(string remoteVersion, string localVersion) + { + // 使用 Version 类进行比较 + if (Version.TryParse(remoteVersion, out Version? remote) && Version.TryParse(localVersion, out Version? local)) + { + return remote > local; + } + + // 如果无法解析为版本号,进行字符串比较 + return !string.Equals(remoteVersion, localVersion, StringComparison.OrdinalIgnoreCase); + } + + private static bool HasSingleRootFolder(ZipArchive archive) + { + // 检查是否所有条目都在同一个根目录下 + HashSet rootFolders = []; + foreach (ZipArchiveEntry entry in archive.Entries) + { + int separatorIndex = entry.FullName.IndexOf('/'); + if (separatorIndex > 0) + { + rootFolders.Add(entry.FullName[..separatorIndex]); + } + else if (separatorIndex < 0 && !string.IsNullOrEmpty(entry.Name)) + { + // 直接在根目录下的文件 + return false; + } + } + + return rootFolders.Count == 1; + } + + private static string? GetDestinationPath(string entryPath, string toolDirectory, bool hasRootFolder) + { + if (string.IsNullOrEmpty(entryPath)) + { + return null; + } + + if (hasRootFolder) + { + // 移除根目录前缀 + int separatorIndex = entryPath.IndexOf('/'); + if (separatorIndex >= 0 && separatorIndex < entryPath.Length - 1) + { + return Path.Combine(toolDirectory, entryPath[(separatorIndex + 1)..]); + } + else if (separatorIndex >= 0) + { + // 这是根目录本身 + return null; + } + } + + return Path.Combine(toolDirectory, entryPath); + } + private static string GetToolDirectory(ToolInfo tool) { // 使用数据目录/工具名作为存储路径 diff --git a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml index 33b7504..b7ec2e1 100644 --- a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml @@ -17,6 +17,7 @@ + + + + + + diff --git a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml.cs b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml.cs index 27c823a..6e621ac 100644 --- a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml.cs +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml.cs @@ -34,13 +34,20 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog { // 在 UI 线程上获取 Tool 的引用,避免后续跨线程访问依赖属性 ToolInfo? tool = Tool; - + try { IsDownloading = true; - // 检查工具是否已下载 - if (tool is not null && !thirdPartyToolService.IsToolDownloaded(tool)) + if (tool is null) + { + return; + } + + // 检查工具是否需要下载或更新 + bool needDownload = !thirdPartyToolService.IsToolDownloaded(tool) || thirdPartyToolService.NeedsUpdate(tool); + + if (needDownload) { // 下载工具 bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false); @@ -53,15 +60,12 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog } // 启动工具 - if (tool is not null) + bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false); + if (launchSuccess) { - bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false); - if (launchSuccess) - { - await contentDialogFactory.TaskContext.SwitchToMainThreadAsync(); - Hide(); - return; - } + await contentDialogFactory.TaskContext.SwitchToMainThreadAsync(); + Hide(); + return; } } catch (Exception ex) diff --git a/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/LocalToolInfo.cs b/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/LocalToolInfo.cs new file mode 100644 index 0000000..4a4adf4 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/LocalToolInfo.cs @@ -0,0 +1,54 @@ +using System.Text.Json.Serialization; + +namespace Snap.Hutao.Web.ThirdPartyTool; + +/// +/// 本地工具信息,用于保存工具的本地状态 +/// +internal sealed class LocalToolInfo +{ + /// + /// 工具名称 + /// + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + /// + /// 工具版本号 + /// + [JsonPropertyName("version")] + public string Version { get; set; } = default!; + + /// + /// 是否为压缩包 + /// + [JsonPropertyName("is_compressed")] + public bool IsCompressed { get; set; } + + /// + /// 主可执行文件名 + /// + [JsonPropertyName("main_exe")] + public string? MainExe { get; set; } + + /// + /// 下载时间 + /// + [JsonPropertyName("download_time")] + public DateTimeOffset DownloadTime { get; set; } + + /// + /// 从 ToolInfo 创建 LocalToolInfo + /// + public static LocalToolInfo FromToolInfo(ToolInfo toolInfo) + { + return new LocalToolInfo + { + Name = toolInfo.Name, + Version = toolInfo.Version, + IsCompressed = toolInfo.IsCompressed, + MainExe = toolInfo.MainExe, + DownloadTime = DateTimeOffset.Now, + }; + } +} diff --git a/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/ToolInfo.cs b/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/ToolInfo.cs index 06447f6..b557bee 100644 --- a/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/ToolInfo.cs +++ b/src/Snap.Hutao/Snap.Hutao/Web/ThirdPartyTool/ToolInfo.cs @@ -16,4 +16,13 @@ internal sealed class ToolInfo [JsonPropertyName("files")] public List Files { get; set; } = default!; + + [JsonPropertyName("is_compressed")] + public bool IsCompressed { get; set; } + + [JsonPropertyName("main_exe")] + public string? MainExe { get; set; } + + [JsonPropertyName("version")] + public string Version { get; set; } = default!; } \ No newline at end of file