diff --git a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/DependencyInjection.cs b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/DependencyInjection.cs index dcdbcd5..51d1ad4 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/DependencyInjection.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/DependencyInjection.cs @@ -39,6 +39,7 @@ internal static class DependencyInjection .AddJsonOptions() .AddDatabase() .AddServices() + .AddThirdPartyToolService() .AddResponseValidation() .AddConfiguredHttpClients() diff --git a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/ServiceCollectionExtension.cs b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/ServiceCollectionExtension.cs index 6c5eca3..852d8be 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/ServiceCollectionExtension.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/DependencyInjection/ServiceCollectionExtension.cs @@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore; using Snap.Hutao.Core.Text.Json; using Snap.Hutao.Factory.Process; using Snap.Hutao.Model.Entity.Database; +using Snap.Hutao.Service.ThirdPartyTool; using Snap.Hutao.Win32; using System.Data.Common; @@ -66,5 +67,10 @@ internal static partial class ServiceCollectionExtension .UseSqlite(sqlConnectionString); } } + + public IServiceCollection AddThirdPartyToolService() + { + return services.AddSingleton(); + } } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx index 93e79fe..9cbd417 100644 --- a/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx +++ b/src/Snap.Hutao/Snap.Hutao/Resource/Localization/SH.resx @@ -1208,6 +1208,12 @@ 正在等待游戏数据 + + 未找到可执行文件 + + + 文件不存在:{0} + 后台任务 @@ -1586,6 +1592,12 @@ 正在转换客户端 + + 工具描述: + + + 启动 + 使用米游社扫描二维码 @@ -2916,6 +2928,9 @@ 注入 + + 第三方注入工具: + 已连接到游戏,更改设置将会动态反映到游戏中 diff --git a/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs new file mode 100644 index 0000000..e00b25e --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/IThirdPartyToolService.cs @@ -0,0 +1,37 @@ +using Snap.Hutao.Web.ThirdPartyTool; +using System.Collections.Immutable; + +namespace Snap.Hutao.Service.ThirdPartyTool; + +internal interface IThirdPartyToolService +{ + /// + /// 获取第三方工具列表 + /// + /// 取消令牌 + /// 工具列表 + ValueTask> GetToolsAsync(CancellationToken token = default); + + /// + /// 下载工具文件 + /// + /// 工具信息 + /// 进度报告 + /// 取消令牌 + /// 是否下载成功 + ValueTask DownloadToolAsync(ToolInfo tool, IProgress? progress = null, CancellationToken token = default); + + /// + /// 启动工具 + /// + /// 工具信息 + /// 是否启动成功 + ValueTask LaunchToolAsync(ToolInfo tool); + + /// + /// 检查工具是否已下载 + /// + /// 工具信息 + /// 是否已下载 + bool IsToolDownloaded(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 new file mode 100644 index 0000000..89d48be --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/ThirdPartyTool/ThirdPartyToolService.cs @@ -0,0 +1,213 @@ +using Snap.Hutao.Core; +using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient; +using Snap.Hutao.Core.ExceptionService; +using Snap.Hutao.Service.Notification; +using Snap.Hutao.Web.Request.Builder; +using Snap.Hutao.Web.Request.Builder.Abstraction; +using Snap.Hutao.Web.ThirdPartyTool; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.IO; +using System.Net.Http; + +namespace Snap.Hutao.Service.ThirdPartyTool; + +[HttpClient(HttpClientConfiguration.Default)] +[Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))] +internal sealed partial class ThirdPartyToolService : IThirdPartyToolService +{ + private const string ApiBaseUrl = "https://htserver.wdg.cloudns.ch/api"; + private const string ToolsEndpoint = "/tools"; + + private readonly IHttpClientFactory httpClientFactory; + private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory; + private readonly IMessenger messenger; + + [GeneratedConstructor] + public partial ThirdPartyToolService(IServiceProvider serviceProvider, HttpClient httpClient); + + public async ValueTask> GetToolsAsync(CancellationToken token = default) + { + try + { + HttpClient httpClient = httpClientFactory.CreateClient(); + + // 添加日志 + SentrySdk.AddBreadcrumb($"Creating request to: {ApiBaseUrl}{ToolsEndpoint}", category: "ThirdPartyTool"); + + HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create() + .SetRequestUri($"{ApiBaseUrl}{ToolsEndpoint}") + .Get(); + + SentrySdk.AddBreadcrumb($"Sending HTTP request", category: "ThirdPartyTool"); + + ToolApiResponse? response = await builder + .SendAsync(httpClient, token) + .ConfigureAwait(false); + + SentrySdk.AddBreadcrumb($"Request completed", category: "ThirdPartyTool"); + + if (response is null) + { + SentrySdk.AddBreadcrumb("Response is null", category: "ThirdPartyTool"); + return ImmutableArray.Empty; + } + + SentrySdk.AddBreadcrumb($"Response received: Code={response.Code}, Message={response.Message}, Data.Length={response.Data.Length}", category: "ThirdPartyTool"); + + if (response.Code != 0) + { + SentrySdk.AddBreadcrumb($"API returned error code: {response.Code}, Message: {response.Message}", category: "ThirdPartyTool"); + return ImmutableArray.Empty; + } + + return response.Data; + } + catch (HttpRequestException ex) + { + SentrySdk.AddBreadcrumb($"HTTP request failed: {ex.Message}", category: "ThirdPartyTool"); + SentrySdk.CaptureException(ex); + return ImmutableArray.Empty; + } + catch (TaskCanceledException ex) + { + SentrySdk.AddBreadcrumb($"Request timed out or was cancelled: {ex.Message}", category: "ThirdPartyTool"); + SentrySdk.CaptureException(ex); + return ImmutableArray.Empty; + } + catch (Exception ex) + { + SentrySdk.AddBreadcrumb($"Failed to get third party tools: {ex.Message}", category: "ThirdPartyTool"); + SentrySdk.CaptureException(ex); + return ImmutableArray.Empty; + } + } + + public async ValueTask DownloadToolAsync(ToolInfo tool, IProgress? progress = null, CancellationToken token = default) + { + try + { + string toolDirectory = GetToolDirectory(tool); + Directory.CreateDirectory(toolDirectory); + + int totalFiles = tool.Files.Count; + int downloadedFiles = 0; + + using (HttpClient httpClient = httpClientFactory.CreateClient()) + { + 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); + } + } + + return true; + } + catch (Exception ex) + { + messenger.Send(InfoBarMessage.Error(ex)); + return false; + } + } + + public async ValueTask LaunchToolAsync(ToolInfo tool) + { + try + { + string toolDirectory = GetToolDirectory(tool); + + // 查找可执行文件(.exe) + string? executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(executablePath)) + { + messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound)); + return false; + } + + string fullPath = Path.Combine(toolDirectory, executablePath); + if (!File.Exists(fullPath)) + { + messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath))); + return false; + } + + // 尝试以管理员权限启动 + ProcessStartInfo startInfo = new() + { + FileName = fullPath, + WorkingDirectory = toolDirectory, + UseShellExecute = true, + Verb = "runas", // 请求管理员权限 + }; + + try + { + Process.Start(startInfo); + } + catch (System.ComponentModel.Win32Exception) + { + // 用户拒绝了管理员权限,尝试以普通权限启动 + startInfo.Verb = string.Empty; + startInfo.UseShellExecute = false; + Process.Start(startInfo); + } + + return true; + } + catch (Exception ex) + { + messenger.Send(InfoBarMessage.Error(ex)); + return false; + } + } + + public bool IsToolDownloaded(ToolInfo tool) + { + string toolDirectory = GetToolDirectory(tool); + if (!Directory.Exists(toolDirectory)) + { + return false; + } + + // 检查所有文件是否存在 + foreach (string fileName in tool.Files) + { + string filePath = Path.Combine(toolDirectory, fileName); + if (!File.Exists(filePath)) + { + return false; + } + } + + return true; + } + + private static string GetToolDirectory(ToolInfo tool) + { + // 使用数据目录/工具名作为存储路径 + return Path.Combine(HutaoRuntime.DataDirectory, "Tools", tool.Name); + } +} 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 new file mode 100644 index 0000000..33b7504 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..27c823a --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Dialog/ThirdPartyToolDialog.xaml.cs @@ -0,0 +1,77 @@ +using Microsoft.UI.Xaml.Controls; +using Snap.Hutao.Factory.ContentDialog; +using Snap.Hutao.Service.Notification; +using Snap.Hutao.Service.ThirdPartyTool; +using Snap.Hutao.Web.ThirdPartyTool; + +namespace Snap.Hutao.UI.Xaml.View.Dialog; + +[DependencyProperty("Tool")] +[DependencyProperty("IsDownloading", DefaultValue = false)] +internal sealed partial class ThirdPartyToolDialog : ContentDialog +{ + private readonly IContentDialogFactory contentDialogFactory; + private readonly IThirdPartyToolService thirdPartyToolService; + private readonly IMessenger messenger; + + [GeneratedConstructor(InitializeComponent = true)] + public partial ThirdPartyToolDialog(IServiceProvider serviceProvider); + + public ThirdPartyToolDialog(IServiceProvider serviceProvider, ToolInfo tool) + : this(serviceProvider) + { + Tool = tool; + PrimaryButtonClick += OnPrimaryButtonClick; + } + + private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args) + { + args.Cancel = true; + HandleLaunchAsync().SafeForget(); + } + + private async Task HandleLaunchAsync() + { + // 在 UI 线程上获取 Tool 的引用,避免后续跨线程访问依赖属性 + ToolInfo? tool = Tool; + + try + { + IsDownloading = true; + + // 检查工具是否已下载 + if (tool is not null && !thirdPartyToolService.IsToolDownloaded(tool)) + { + // 下载工具 + bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false); + if (!downloadSuccess) + { + await contentDialogFactory.TaskContext.SwitchToMainThreadAsync(); + IsDownloading = false; + return; + } + } + + // 启动工具 + if (tool is not null) + { + bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false); + if (launchSuccess) + { + await contentDialogFactory.TaskContext.SwitchToMainThreadAsync(); + Hide(); + return; + } + } + } + catch (Exception ex) + { + messenger.Send(InfoBarMessage.Error(ex)); + } + finally + { + await contentDialogFactory.TaskContext.SwitchToMainThreadAsync(); + IsDownloading = false; + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Page/LaunchGamePage.xaml b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Page/LaunchGamePage.xaml index 3d8323b..f3e39f8 100644 --- a/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Page/LaunchGamePage.xaml +++ b/src/Snap.Hutao/Snap.Hutao/UI/Xaml/View/Page/LaunchGamePage.xaml @@ -1,5 +1,6 @@ - - - - - - - + + + + + + + + + + + + + + + + + +