1
0
mirror of https://github.com/wangdage12/Snap.Hutao.git synced 2026-03-28 16:55:18 +08:00

10 Commits

Author SHA1 Message Date
fanbook-wangdage
9de07754c7 提升版本号 2026-02-28 15:25:31 +08:00
fanbook-wangdage
8e86ccc560 优化第三方工具功能(#25) 2026-02-28 15:19:16 +08:00
fanbook-wangdage
e1df07ac21 在没有本地化key时返回api的状态消息(fix #27) 2026-02-28 13:46:24 +08:00
fanbook-wangdage
640686a837 fix #26 2026-02-20 13:21:48 +08:00
fanbook-wangdage
e9ed7928d6 Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-15 11:28:29 +08:00
fanbook-wangdage
4d2943d1c9 新增yae注入获取成就功能、修改yae逻辑
修复msix没有打包解锁器的问题
修复注入时米游社账号登录不起作用的问题
2026-02-15 11:28:17 +08:00
wangdage12
74e9427451 Update README with website and version highlights
Added official website link and highlighted version features.
2026-02-09 20:14:52 +08:00
fanbook-wangdage
cb6d728c35 提升版本号、解决CI报错 2026-02-07 13:17:31 +08:00
fanbook-wangdage
f87b80cc9e Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-07 13:08:35 +08:00
fanbook-wangdage
4b313b134e 新增msi安装界面,修复WebView2权限问题,修复切换服务器时会显示等待进程退出的问题,为页面添加缓存来提示频繁切换页面时的性能 2026-02-07 13:07:51 +08:00
44 changed files with 1226 additions and 137 deletions

View File

@@ -6,6 +6,14 @@
自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。 自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
官网https://htserver.wdg.cloudns.ch/
**该版本的特点:**
- 尽量保留原版功能,少重写功能,稳定性强
- 只集成没有争议的安全的注入功能
- 大部分注入功能以第三方工具形式提供,点击即用
- 永久免费的云抽卡日志
有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ 有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ
**English** **English**
@@ -19,6 +27,8 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据 只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据
有时候我们在对某些功能有重大更改时发布测试版可在官网的下载可加入discord服务器报告功能使用情况和获取测试通知
--- ---
## 开发 ## 开发
@@ -32,7 +42,7 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
**目前元数据的编写进度:** **目前元数据的编写进度:**
| 项目V6.3 | 是否完成 | | 项目V6.4 | 是否完成 |
| ----------- | ----------- | | ----------- | ----------- |
| 总体数据 | ✔️ | | 总体数据 | ✔️ |
@@ -57,6 +67,14 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
- 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server) - 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server)
- Web管理后台和官网[Snap.Server.Web](https://github.com/wangdage12/Snap.Server.Web) - 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 由于采用了 wix 进行打包程序VS 需要安装 **HeatWave for VS2022**2026兼容。需要 msi 安装包时,右键选中 Snap.Hutao.Installer 生成后即可在目标目录找到。默认目录Snap.Hutao.Installer\bin\x64\Release\en-US\Snap.Hutao.Installer.msi

View File

@@ -4,5 +4,8 @@ This file contains the declaration of all the localizable strings.
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="en-US"> <WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="en-US">
<String Id="DowngradeError" Value="A newer version of [ProductName] is already installed." /> <String Id="DowngradeError" Value="A newer version of [ProductName] is already installed." />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="Desktop Shortcut" />
<String Id="StartMenuShortcutTitle" Value="Start Menu Shortcut" />
</WixLocalization> </WixLocalization>

View File

@@ -1,21 +1,32 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package <Package
Name="Snap.Hutao" Name="Snap.Hutao"
Manufacturer="Millennium Science Technology R-D Inst" Manufacturer="Millennium Science Technology R-D Inst"
Version="1.18.2.0" Version="1.18.5.0"
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68" UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
Language="2052"
Scope="perMachine"> Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." /> <Property Id="ApplicationFolderName" Value="Snap.Hutao" />
<Property Id="WixAppFolder" Value="WixPerMachineFolder" />
<MajorUpgrade DowngradeErrorMessage="!(loc.DowngradeError)" />
<MediaTemplate EmbedCab="yes" /> <MediaTemplate EmbedCab="yes" />
<Feature Id="ProductFeature" Title="Snap.Hutao" Level="1"> <ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<ComponentGroupRef Id="MainAppComponents" />
<!-- 快捷方式组件 --> <Feature Id="MainApp" Title="!(loc.MainAppTitle)" Level="1">
<ComponentRef Id="ApplicationShortcut" /> <ComponentGroupRef Id="MainAppComponents" />
</Feature>
<Feature Id="DesktopShortcutFeature" Title="!(loc.DesktopShortcutTitle)" Level="1">
<ComponentRef Id="DesktopShortcut" /> <ComponentRef Id="DesktopShortcut" />
</Feature> </Feature>
<Feature Id="StartMenuShortcutFeature" Title="!(loc.StartMenuShortcutTitle)" Level="1">
<ComponentRef Id="ApplicationShortcut" />
</Feature>
</Package> </Package>
<!-- 安装目录 --> <!-- 安装目录 -->

View File

@@ -0,0 +1,11 @@
<!--
This file contains the declaration of all the localizable strings.
-->
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="zh-CN">
<String Id="DowngradeError" Value="已安装更新版本的 [ProductName]。" />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="桌面快捷方式" />
<String Id="StartMenuShortcutTitle" Value="开始菜单快捷方式" />
</WixLocalization>

View File

@@ -4,6 +4,8 @@
<Platform>x64</Platform> <Platform>x64</Platform>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<Configuration>Release</Configuration> <Configuration>Release</Configuration>
<DefaultCulture>zh-CN</DefaultCulture>
<Cultures>zh-CN;en-US</Cultures>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -19,6 +21,13 @@
<SuppressRootDirectory>true</SuppressRootDirectory> <SuppressRootDirectory>true</SuppressRootDirectory>
</HarvestDirectory> </HarvestDirectory>
<PackageReference Include="WixToolset.Heat" Version="4.0.1" /> <PackageReference Include="WixToolset.Heat" Version="6.0.2" />
<PackageReference Include="WixToolset.UI.wixext" Version="6.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<WixLocalization Include="Package.zh-cn.wxl" />
<WixLocalization Include="Package.en-us.wxl" />
</ItemGroup>
</Project> </Project>

View File

@@ -33,6 +33,8 @@ internal static class HutaoRuntime
public static WebView2Version WebView2Version { get; } = InitializeWebView2(); public static WebView2Version WebView2Version { get; } = InitializeWebView2();
public static string WebView2UserDataDirectory { get; } = InitializeWebView2UserDataDirectory();
// ⚠️ 延迟初始化以避免循环依赖 // ⚠️ 延迟初始化以避免循环依赖
private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated); private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated);
@@ -144,6 +146,13 @@ internal static class HutaoRuntime
return cacheDir; return cacheDir;
} }
private static string InitializeWebView2UserDataDirectory()
{
string directory = Path.Combine(LocalCacheDirectory, "WebView2");
Directory.CreateDirectory(directory);
return directory;
}
private static bool CheckAppNotificationEnabled() private static bool CheckAppNotificationEnabled()
{ {
try try

View File

@@ -23,12 +23,13 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
private readonly TargetNativeConfiguration config; private readonly TargetNativeConfiguration config;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IProcess gameProcess; private readonly IProcess gameProcess;
private readonly bool supportsResumeMainThread;
private readonly NamedPipeServerStream serverStream; private readonly NamedPipeServerStream serverStream;
private volatile bool disposed; private volatile bool disposed;
public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config) public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config, bool supportsResumeMainThread = true)
{ {
Verify.Operation(HutaoRuntime.IsProcessElevated, "Snap Hutao must be elevated to use Yae."); Verify.Operation(HutaoRuntime.IsProcessElevated, "Snap Hutao must be elevated to use Yae.");
@@ -36,6 +37,7 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
this.gameProcess = gameProcess; this.gameProcess = gameProcess;
this.config = config; this.config = config;
this.supportsResumeMainThread = supportsResumeMainThread;
// Yae is always running elevated, so we don't need to use ACL method. // Yae is always running elevated, so we don't need to use ACL method.
serverStream = new(PipeName); serverStream = new(PipeName);
@@ -115,8 +117,11 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
} }
case YaeCommandKind.RequestResumeThread: case YaeCommandKind.RequestResumeThread:
{
if (supportsResumeMainThread)
{ {
gameProcess.ResumeMainThread(); gameProcess.ResumeMainThread();
}
return default; return default;
} }

View File

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

View File

@@ -1598,6 +1598,9 @@
<data name="ViewDialogThirdPartyToolLaunch" xml:space="preserve"> <data name="ViewDialogThirdPartyToolLaunch" xml:space="preserve">
<value>启动</value> <value>启动</value>
</data> </data>
<data name="ViewDialogThirdPartyToolVersion" xml:space="preserve">
<value>版本:</value>
</data>
<data name="ViewDialogQRCodeTitle" xml:space="preserve"> <data name="ViewDialogQRCodeTitle" xml:space="preserve">
<value>使用米游社扫描二维码</value> <value>使用米游社扫描二维码</value>
</data> </data>

View File

@@ -38,6 +38,15 @@ internal sealed partial class GachaLogService : IGachaLogService
} }
} }
public async ValueTask<IAdvancedDbCollectionView<GachaArchive>> RefreshArchiveCollectionAsync()
{
using (await archivesLock.LockAsync().ConfigureAwait(false))
{
archives = null;
return archives = gachaLogRepository.GetGachaArchiveCollection().ToAdvancedDbCollectionView(serviceProvider);
}
}
public async ValueTask<GachaStatistics> GetStatisticsAsync(GachaLogServiceMetadataContext context, GachaArchive archive) public async ValueTask<GachaStatistics> GetStatisticsAsync(GachaLogServiceMetadataContext context, GachaArchive archive)
{ {
using (ValueStopwatch.MeasureExecution(logger)) using (ValueStopwatch.MeasureExecution(logger))

View File

@@ -22,4 +22,6 @@ internal interface IGachaLogService
ValueTask RemoveArchiveAsync(GachaArchive archive); ValueTask RemoveArchiveAsync(GachaArchive archive);
ValueTask<IAdvancedDbCollectionView<GachaArchive>> GetArchiveCollectionAsync(); ValueTask<IAdvancedDbCollectionView<GachaArchive>> GetArchiveCollectionAsync();
ValueTask<IAdvancedDbCollectionView<GachaArchive>> RefreshArchiveCollectionAsync();
} }

View File

@@ -37,15 +37,20 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
return; return;
} }
// 获取unlocker.exe路径放在Snap.Hutao同一目录 // 准备 unlocker.exe 到可写的应用数据目录
string hutaoDirectory = AppContext.BaseDirectory; await PrepareUnlockerToDataDirectoryAsync().ConfigureAwait(false);
unlockerPath = Path.Combine(hutaoDirectory, UnlockerExecutableName);
// 从应用数据目录获取 unlocker.exe 路径
unlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
if (!File.Exists(unlockerPath)) if (!File.Exists(unlockerPath))
{ {
throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下"); throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下");
} }
// 添加到 Windows Defender 排除项(需要管理员权限)
await AddToDefenderExclusionAsync(unlockerPath).ConfigureAwait(false);
// 获取游戏路径 // 获取游戏路径
gamePath = context.FileSystem.GameFilePath; gamePath = context.FileSystem.GameFilePath;
@@ -81,6 +86,134 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false); await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false);
} }
private async ValueTask PrepareUnlockerToDataDirectoryAsync()
{
// 数据目录中的目标路径
string dataDirectoryUnlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
// 安装目录中的源路径
string installDirectoryUnlockerPath = Path.Combine(AppContext.BaseDirectory, UnlockerExecutableName);
// 检查是否需要复制
bool needsCopy = false;
if (!File.Exists(dataDirectoryUnlockerPath))
{
needsCopy = true;
}
else
{
// 比较文件大小和修改时间,如果不同则更新
var sourceInfo = new FileInfo(installDirectoryUnlockerPath);
var targetInfo = new FileInfo(dataDirectoryUnlockerPath);
if (sourceInfo.Length != targetInfo.Length || sourceInfo.LastWriteTime > targetInfo.LastWriteTime)
{
needsCopy = true;
}
}
// 如果需要复制,执行复制操作
if (needsCopy)
{
try
{
Directory.CreateDirectory(HutaoRuntime.DataDirectory);
File.Copy(installDirectoryUnlockerPath, dataDirectoryUnlockerPath, true);
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已复制到数据目录: {dataDirectoryUnlockerPath}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
catch (Exception ex)
{
throw HutaoException.InvalidOperation($"复制 unlockfps.exe 到数据目录失败: {ex.Message}", ex);
}
}
}
private async ValueTask AddToDefenderExclusionAsync(string executablePath)
{
try
{
// 检查是否已经在排除项中
ProcessStartInfo checkInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"(Get-MpPreference).ExclusionPath -split '\"' | Where-Object {{ $_ -eq '{executablePath}' }}\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
using (Process checkProcess = new() { StartInfo = checkInfo })
{
checkProcess.Start();
string output = await checkProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
await checkProcess.WaitForExitAsync().ConfigureAwait(false);
// 如果输出包含路径,说明已经在排除项中
if (!string.IsNullOrWhiteSpace(output) && output.Trim().Contains(executablePath))
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已在 Windows Defender 排除项中",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
return;
}
}
// 不在排除项中,尝试添加
ProcessStartInfo addInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"Add-MpPreference -ExclusionPath '{executablePath}'\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden,
Verb = "runas", // 请求管理员权限
};
using (Process addProcess = new() { StartInfo = addInfo })
{
addProcess.Start();
string output = await addProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
string error = await addProcess.StandardError.ReadToEndAsync().ConfigureAwait(false);
await addProcess.WaitForExitAsync().ConfigureAwait(false);
if (addProcess.ExitCode == 0)
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已成功添加到 Windows Defender 排除项",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
else
{
SentrySdk.AddBreadcrumb(
$"无法添加到 Windows Defender 排除项(需要管理员权限): {error ?? output}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb(
$"添加 Windows Defender 排除项失败: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
private async ValueTask CreateUnlockerConfigAsync(BeforeLaunchExecutionContext context) private async ValueTask CreateUnlockerConfigAsync(BeforeLaunchExecutionContext context)
{ {
if (string.IsNullOrEmpty(gamePath)) if (string.IsNullOrEmpty(gamePath))
@@ -88,8 +221,8 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
throw HutaoException.NotSupported("游戏路径未初始化"); throw HutaoException.NotSupported("游戏路径未初始化");
} }
// 直接在unlocker同目录创建配置文件 // 在应用数据目录创建配置文件
string unlockerConfigPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string unlockerConfigPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
int targetFps = context.LaunchOptions.TargetFps.Value; int targetFps = context.LaunchOptions.TargetFps.Value;
string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}"; string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}";
@@ -126,7 +259,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try try
{ {
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (!File.Exists(configPath)) if (!File.Exists(configPath))
{ {
throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}"); throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}");
@@ -214,6 +347,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
return string.Empty; return string.Empty;
} }
// 获取米游社登录Ticket
string? authTicket = default;
bool useAuthTicket = launchOptions.UsingHoyolabAccount.Value
&& context.TryGetOption(LaunchExecutionOptionsKey.LoginAuthTicket, out authTicket)
&& !string.IsNullOrEmpty(authTicket);
StringBuilder arguments = new(); StringBuilder arguments = new();
// 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数 // 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数
@@ -249,6 +388,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}"); arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}");
} }
// 添加米游社登录参数
if (useAuthTicket)
{
arguments.Append($" login_auth_ticket={authTicket}");
}
return arguments.ToString(); return arguments.ToString();
} }
@@ -302,7 +447,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try try
{ {
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (File.Exists(configPath)) if (File.Exists(configPath))
{ {
string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false); string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false);

View File

@@ -26,17 +26,22 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
public override async ValueTask ExecuteAsync(LaunchExecutionContext context) public override async ValueTask ExecuteAsync(LaunchExecutionContext context)
{ {
// 如果启用了IslandFPS解锁则跳过启动游戏进程
// 因为unlockfps.exe会负责启动游戏
if (context.LaunchOptions.IsIslandEnabled.Value)
{
context.Progress.Report(new(SH.ServiceGameLaunchPhaseProcessStarted));
return;
}
try try
{ {
// 对于suspended进程Yae注入模式、Island模式需要先Start()创建进程然后ResumeMainThread()恢复主线程
// 对于正常启动的进程ShellExecute、DiagnosticsProcess只调用Start()
context.Process.Start(); context.Process.Start();
// 尝试恢复主线程适用于suspended进程
try
{
context.Process.ResumeMainThread();
}
catch (HutaoException ex) when (ex.Message.Contains("ResumeMainThread is not supported"))
{
// ResumeMainThread不支持说明是正常启动的进程DiagnosticsProcess忽略此错误
}
await context.TaskContext.SwitchToMainThreadAsync(); await context.TaskContext.SwitchToMainThreadAsync();
GameLifeCycle.IsGameRunningProperty.Value = true; GameLifeCycle.IsGameRunningProperty.Value = true;
} }

View File

@@ -2,8 +2,11 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core; using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae; using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Island; using Snap.Hutao.Service.Game.Island;
using Snap.Hutao.Service.Game.Launching.Context; using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
@@ -32,36 +35,57 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeClientNotElevated); HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeClientNotElevated);
} }
if (!context.LaunchOptions.IsIslandEnabled.Value)
{
context.Process.Kill();
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeIslandNotEnabled);
return;
}
string dataFolderYaePath = Path.Combine(HutaoRuntime.DataDirectory, "YaeAchievementLib.dll"); string dataFolderYaePath = Path.Combine(HutaoRuntime.DataDirectory, "YaeAchievementLib.dll");
InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath); InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath);
// 直接使用创建的游戏进程
int actualProcessId = context.Process.Id;
if (actualProcessId == 0)
{
throw HutaoException.Throw("游戏进程未正确创建");
}
try try
{ {
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", context.Process.Id); DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", actualProcessId);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Windows Defender Application Control // Windows Defender Application Control
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION)) if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION))
{ {
context.Process.Kill();
throw HutaoException.Throw(SH.ServiceGameLaunchingHandlerEmbeddedYaeErrorSystemIntegrityPolicyViolation); throw HutaoException.Throw(SH.ServiceGameLaunchingHandlerEmbeddedYaeErrorSystemIntegrityPolicyViolation);
} }
throw; // Access Denied (0x80070005) - 权限不足,无法在远程进程中分配内存
if (ex.HResult == unchecked((int)0x80070005))
{
throw HutaoException.Throw($"无法在游戏进程中注入 DLL (访问被拒绝)。\n\n" +
$"可能的原因:\n" +
$"1. 游戏进程的完整性级别高于 Snap Hutao\n" +
$"2. Windows Defender 或其他安全软件阻止了注入\n" +
$"解决方法:\n" +
$"1. 检查 Windows Defender 设置,将 Snap Hutao 添加到排除列表\n" +
$"2. 以管理员身份运行 Snap Hutao\n" +
$"3. 检查是否有其他安全软件(如 360、火绒等干扰");
}
// 游戏进程由直接启动,已经是运行状态
// InjectUsingWindowsHook2 需要手动恢复主线程,但 DiagnosticsProcess 不支持 ResumeMainThread
// 这里不使用 InjectUsingWindowsHook2
throw new InvalidOperationException($"无法注入 DLL: {ex.Message}. 请确保没有启用 Windows Defender Application Control 或其他安全限制。", ex);
} }
try try
{ {
// 获取游戏进程用于命名管道服务器
IProcess actualProcess = ProcessFactory.TryGetById(actualProcessId, out IProcess? process)
? process
: throw HutaoException.Throw($"无法获取进程 ID {actualProcessId}");
// 已经是运行状态,不需要恢复主线程
#pragma warning disable CA2007 #pragma warning disable CA2007
await using (YaeNamedPipeServer server = new(context.ServiceProvider, context.Process, config)) await using (YaeNamedPipeServer server = new(context.ServiceProvider, actualProcess, config, supportsResumeMainThread: false))
#pragma warning restore CA2007 #pragma warning restore CA2007
{ {
receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false); receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false);
@@ -69,7 +93,6 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
} }
catch (Exception) catch (Exception)
{ {
context.Process.Kill();
throw; throw;
} }
} }

View File

@@ -21,6 +21,8 @@ internal abstract class AbstractLaunchExecutionInvoker
private bool invoked; private bool invoked;
protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; } protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; }
protected virtual bool ShouldWaitForProcessExit { get => true; }
protected virtual bool ShouldSpinWaitGameExitAfterInvoke { get => true; }
public static bool Invoking() public static bool Invoking()
{ {
@@ -40,7 +42,7 @@ internal abstract class AbstractLaunchExecutionInvoker
finally finally
{ {
Invokers.TryRemove(this, out _); Invokers.TryRemove(this, out _);
if (!Invoking()) if (!Invoking() && ShouldSpinWaitGameExitAfterInvoke)
{ {
await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false); await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false);
} }
@@ -132,7 +134,7 @@ internal abstract class AbstractLaunchExecutionInvoker
} }
// 只有在没有启用Island且进程存在时才等待退出 // 只有在没有启用Island且进程存在时才等待退出
if (process is { IsRunning: true }) if (ShouldWaitForProcessExit && process is { IsRunning: true })
{ {
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit)); progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
try try
@@ -148,7 +150,7 @@ internal abstract class AbstractLaunchExecutionInvoker
return; return;
} }
} }
else if (beforeContext.LaunchOptions.IsIslandEnabled.Value) else if (ShouldWaitForProcessExit && beforeContext.LaunchOptions.IsIslandEnabled.Value)
{ {
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit)); progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();

View File

@@ -9,6 +9,9 @@ namespace Snap.Hutao.Service.Game.Launching.Invoker;
internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{ {
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public ConvertOnlyLaunchExecutionInvoker() public ConvertOnlyLaunchExecutionInvoker()
{ {
Handlers = Handlers =

View File

@@ -2,9 +2,14 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics; using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae; using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching.Context; using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Game.Launching.Handler; using Snap.Hutao.Service.Game.Launching.Handler;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Game.Launching.Invoker; namespace Snap.Hutao.Service.Game.Launching.Invoker;
@@ -28,4 +33,99 @@ internal sealed class YaeLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{ {
return GameProcessFactory.CreateForEmbeddedYae(beforeContext); return GameProcessFactory.CreateForEmbeddedYae(beforeContext);
} }
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public async ValueTask InvokeAsync(LaunchExecutionInvocationContext context)
{
ITaskContext taskContext = context.ServiceProvider.GetRequiredService<ITaskContext>();
string lockTrace = $"{GetType().Name}.{nameof(InvokeAsync)}";
context.LaunchOptions.TryGetGameFileSystem(lockTrace, out IGameFileSystem? gameFileSystem);
ArgumentNullException.ThrowIfNull(gameFileSystem);
using (GameFileSystemReference fileSystemReference = new(gameFileSystem))
{
if (context.ViewModel.TargetScheme is not { } targetScheme)
{
throw HutaoException.InvalidOperation(SH.ViewModelLaunchGameSchemeNotSelected);
}
if (context.ViewModel.CurrentScheme is not { } currentScheme)
{
throw HutaoException.InvalidOperation(SH.ServiceGameLaunchExecutionCurrentSchemeNull);
}
IProgress<LaunchStatus?> progress = CreateStatusProgress(context.ServiceProvider);
BeforeLaunchExecutionContext beforeContext = new()
{
ViewModel = context.ViewModel,
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
FileSystem = fileSystemReference,
HoyoPlay = context.ServiceProvider.GetRequiredService<IHoyoPlayService>(),
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
CurrentScheme = currentScheme,
TargetScheme = targetScheme,
Identity = context.Identity,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.BeforeAsync(beforeContext).ConfigureAwait(false);
}
fileSystemReference.Exchange(beforeContext.FileSystem);
// Yae注入功能不依赖unlockfps.exe总是创建游戏进程
IProcess? process = CreateProcess(beforeContext);
using (process)
{
if (process is null)
{
return;
}
LaunchExecutionContext executionContext = new()
{
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
Process = process,
IsOversea = targetScheme.IsOversea,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.ExecuteAsync(executionContext).ConfigureAwait(false);
}
}
AfterLaunchExecutionContext afterContext = new()
{
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.AfterAsync(afterContext).ConfigureAwait(false);
}
}
}
private static IProgress<LaunchStatus?> CreateStatusProgress(IServiceProvider serviceProvider)
{
IProgressFactory progressFactory = serviceProvider.GetRequiredService<IProgressFactory>();
LaunchStatusOptions options = serviceProvider.GetRequiredService<LaunchStatusOptions>();
return progressFactory.CreateForMainThread<LaunchStatus?, LaunchStatusOptions>(static (status, options) => options.LaunchStatus = status, options);
}
} }

View File

@@ -34,4 +34,18 @@ internal interface IThirdPartyToolService
/// <param name="tool">工具信息</param> /// <param name="tool">工具信息</param>
/// <returns>是否已下载</returns> /// <returns>是否已下载</returns>
bool IsToolDownloaded(ToolInfo tool); bool IsToolDownloaded(ToolInfo tool);
/// <summary>
/// 获取本地工具信息
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>本地工具信息如果不存在则返回null</returns>
LocalToolInfo? GetLocalToolInfo(ToolInfo tool);
/// <summary>
/// 检查工具是否需要更新
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>是否需要更新</returns>
bool NeedsUpdate(ToolInfo tool);
} }

View File

@@ -9,7 +9,9 @@ using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text.Json;
namespace Snap.Hutao.Service.ThirdPartyTool; 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 ApiBaseUrl = "https://htserver.wdg.cloudns.ch/api";
private const string ToolsEndpoint = "/tools"; private const string ToolsEndpoint = "/tools";
private const string ToolInfoFileName = "tool_info.json";
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory; private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory;
@@ -89,40 +92,31 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
try try
{ {
string toolDirectory = GetToolDirectory(tool); 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()) 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); await DownloadAndExtractCompressedToolAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
}
// 如果文件已存在,跳过下载 else
if (File.Exists(localFilePath))
{ {
downloadedFiles++; // 非压缩包模式:直接下载所有文件
progress?.Report((double)downloadedFiles / totalFiles * 100); await DownloadFilesAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
continue; }
} }
// 下载文件 // 保存本地工具信息
HttpResponseMessage response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); SaveLocalToolInfo(tool);
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; return true;
} }
@@ -139,15 +133,31 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
{ {
string toolDirectory = GetToolDirectory(tool); string toolDirectory = GetToolDirectory(tool);
// 查找可执行文件.exe // 优先使用 main_exe如果没有则查找可执行文件
string? executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); 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)) if (string.IsNullOrEmpty(executablePath))
{ {
messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound)); messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound));
return false; 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)) if (!File.Exists(fullPath))
{ {
messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath))); messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath)));
@@ -192,7 +202,20 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
return false; 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) foreach (string fileName in tool.Files)
{ {
string filePath = Path.Combine(toolDirectory, fileName); string filePath = Path.Combine(toolDirectory, fileName);
@@ -205,6 +228,217 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
return true; 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<LocalToolInfo>(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<double>? 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<double>? 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<string> 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) private static string GetToolDirectory(ToolInfo tool)
{ {
// 使用数据目录/工具名作为存储路径 // 使用数据目录/工具名作为存储路径

View File

@@ -4,6 +4,7 @@ using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.InterChange.GachaLog; using Snap.Hutao.Model.InterChange.GachaLog;
using Snap.Hutao.Service.GachaLog; using Snap.Hutao.Service.GachaLog;
using Snap.Hutao.ViewModel.GachaLog;
using Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo; using Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
using System.Collections.Immutable; using System.Collections.Immutable;
@@ -13,6 +14,7 @@ internal abstract partial class AbstractUIGF40ImportService : IUIGFImportService
{ {
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IMessenger messenger;
[GeneratedConstructor] [GeneratedConstructor]
public partial AbstractUIGF40ImportService(IServiceProvider serviceProvider); public partial AbstractUIGF40ImportService(IServiceProvider serviceProvider);
@@ -21,6 +23,7 @@ internal abstract partial class AbstractUIGF40ImportService : IUIGFImportService
{ {
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();
ImportGachaArchives(importOptions.UIGF.Hk4e, importOptions.GachaArchiveUids); ImportGachaArchives(importOptions.UIGF.Hk4e, importOptions.GachaArchiveUids);
messenger.Send(GachaLogImportedMessage.Empty);
} }
private void ImportGachaArchives(ImmutableArray<UIGFEntry<Hk4eItem>> entries, HashSet<uint> uids) private void ImportGachaArchives(ImmutableArray<UIGFEntry<Hk4eItem>> entries, HashSet<uint> uids)

View File

@@ -29,6 +29,26 @@ internal sealed class TargetNativeConfiguration
public required uint Decompress { get; init; } public required uint Decompress { get; init; }
public static TargetNativeConfiguration Create(uint storeCmdId, uint achievementCmdId, MethodRva methodRva)
{
return new()
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
DoCmd = methodRva.DoCmd,
UpdateNormalProperty = methodRva.UpdateNormalProperty,
NewString = methodRva.NewString,
FindGameObject = methodRva.FindGameObject,
EventSystemUpdate = methodRva.EventSystemUpdate,
SimulatePointerClick = methodRva.SimulatePointerClick,
ToInt32 = methodRva.ToInt32,
TcpStatePtr = methodRva.TcpStatePtr,
SharedInfoPtr = methodRva.SharedInfoPtr,
Decompress = methodRva.Decompress,
};
}
public static TargetNativeConfiguration Create(NativeConfiguration config, bool isOversea) public static TargetNativeConfiguration Create(NativeConfiguration config, bool isOversea)
{ {
MethodRva methodRva = isOversea ? config.MethodRva.Oversea : config.MethodRva.Chinese; MethodRva methodRva = isOversea ? config.MethodRva.Oversea : config.MethodRva.Chinese;

View File

@@ -0,0 +1,32 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class Crc32
{
private const uint Polynomial = 0xEDB88320;
private static readonly uint[] Crc32Table = new uint[256];
static Crc32()
{
for (uint i = 0; i < Crc32Table.Length; i++)
{
uint value = i;
for (int j = 0; j < 8; j++)
{
value = (value >> 1) ^ ((value & 1) * Polynomial);
}
Crc32Table[i] = value;
}
}
public static uint Compute(Span<byte> buffer)
{
uint checksum = 0xFFFFFFFF;
foreach (byte b in buffer)
{
checksum = (checksum >> 8) ^ Crc32Table[(b ^ checksum) & 0xFF];
}
return ~checksum;
}
}

View File

@@ -0,0 +1,6 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal interface IYaeMetadataService
{
ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default);
}

View File

@@ -0,0 +1,188 @@
using Google.Protobuf;
using Snap.Hutao.Core.Protobuf;
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class YaeMetadataParser
{
private const uint AchievementInfoNativeConfigTag = 42; // field 5, wire type length-delimited
private const uint NativeConfigStoreCmdIdTag = 8; // field 1, varint
private const uint NativeConfigAchievementCmdIdTag = 16; // field 2, varint
private const uint NativeConfigMethodRvaTag = 82; // field 10, length-delimited
private const uint MapEntryKeyTag = 8; // field 1, varint
private const uint MapEntryValueTag = 18; // field 2, length-delimited
private const uint MethodRvaDoCmdTag = 8;
private const uint MethodRvaUpdateNormalPropTag = 24;
private const uint MethodRvaNewStringTag = 32;
private const uint MethodRvaFindGameObjectTag = 40;
private const uint MethodRvaEventSystemUpdateTag = 48;
private const uint MethodRvaSimulatePointerClickTag = 56;
private const uint MethodRvaToInt32Tag = 64;
private const uint MethodRvaTcpStatePtrTag = 72;
private const uint MethodRvaSharedInfoPtrTag = 80;
private const uint MethodRvaDecompressTag = 88;
public static YaeNativeLibConfig? ParseNativeLibConfig(byte[] data)
{
uint storeCmdId = 0;
uint achievementCmdId = 0;
Dictionary<uint, MethodRva> methodRva = [];
bool hasNativeConfig = false;
CodedInputStream input = new(data);
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case AchievementInfoNativeConfigTag:
hasNativeConfig = true;
using (CodedInputStream nativeConfigStream = input.UnsafeReadLengthDelimitedStream())
{
ParseNativeConfig(nativeConfigStream, ref storeCmdId, ref achievementCmdId, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
if (!hasNativeConfig || methodRva.Count == 0)
{
return null;
}
return new YaeNativeLibConfig
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
MethodRva = methodRva,
};
}
private static void ParseNativeConfig(CodedInputStream input, ref uint storeCmdId, ref uint achievementCmdId, Dictionary<uint, MethodRva> methodRva)
{
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case NativeConfigStoreCmdIdTag:
storeCmdId = input.ReadUInt32();
break;
case NativeConfigAchievementCmdIdTag:
achievementCmdId = input.ReadUInt32();
break;
case NativeConfigMethodRvaTag:
using (CodedInputStream entryStream = input.UnsafeReadLengthDelimitedStream())
{
ParseMethodRvaEntry(entryStream, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
}
private static void ParseMethodRvaEntry(CodedInputStream input, Dictionary<uint, MethodRva> methodRva)
{
uint key = 0;
MethodRva? value = null;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MapEntryKeyTag:
key = input.ReadUInt32();
break;
case MapEntryValueTag:
using (CodedInputStream valueStream = input.UnsafeReadLengthDelimitedStream())
{
value = ParseMethodRvaConfig(valueStream);
}
break;
default:
input.SkipLastField();
break;
}
}
if (value is not null)
{
methodRva[key] = value;
}
}
private static MethodRva ParseMethodRvaConfig(CodedInputStream input)
{
uint doCmd = 0;
uint updateNormalProp = 0;
uint newString = 0;
uint findGameObject = 0;
uint eventSystemUpdate = 0;
uint simulatePointerClick = 0;
uint toInt32 = 0;
uint tcpStatePtr = 0;
uint sharedInfoPtr = 0;
uint decompress = 0;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MethodRvaDoCmdTag:
doCmd = input.ReadUInt32();
break;
case MethodRvaUpdateNormalPropTag:
updateNormalProp = input.ReadUInt32();
break;
case MethodRvaNewStringTag:
newString = input.ReadUInt32();
break;
case MethodRvaFindGameObjectTag:
findGameObject = input.ReadUInt32();
break;
case MethodRvaEventSystemUpdateTag:
eventSystemUpdate = input.ReadUInt32();
break;
case MethodRvaSimulatePointerClickTag:
simulatePointerClick = input.ReadUInt32();
break;
case MethodRvaToInt32Tag:
toInt32 = input.ReadUInt32();
break;
case MethodRvaTcpStatePtrTag:
tcpStatePtr = input.ReadUInt32();
break;
case MethodRvaSharedInfoPtrTag:
sharedInfoPtr = input.ReadUInt32();
break;
case MethodRvaDecompressTag:
decompress = input.ReadUInt32();
break;
default:
input.SkipLastField();
break;
}
}
return new MethodRva
{
DoCmd = doCmd,
UpdateNormalProperty = updateNormalProp,
NewString = newString,
FindGameObject = findGameObject,
EventSystemUpdate = eventSystemUpdate,
SimulatePointerClick = simulatePointerClick,
ToInt32 = toInt32,
TcpStatePtr = tcpStatePtr,
SharedInfoPtr = sharedInfoPtr,
Decompress = decompress,
};
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using System.IO;
using System.Net.Http;
namespace Snap.Hutao.Service.Yae.Metadata;
[Service(ServiceLifetime.Singleton, typeof(IYaeMetadataService))]
[HttpClient(HttpClientConfiguration.Default)]
internal sealed partial class YaeMetadataService : IYaeMetadataService
{
private const string MetadataUrl = "https://rin.holohat.work/schicksal/metadata";
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6);
private static readonly string? LocalMetadataPath = TryGetLocalMetadataPath();
private readonly IHttpClientFactory httpClientFactory;
private readonly IMemoryCache memoryCache;
[GeneratedConstructor]
public partial YaeMetadataService(IServiceProvider serviceProvider);
public ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default)
{
Task<YaeNativeLibConfig?> task = memoryCache.GetOrCreateAsync($"{nameof(YaeMetadataService)}.NativeLibConfig", async entry =>
{
entry.SetSlidingExpiration(CacheDuration);
byte[] data;
if (!string.IsNullOrEmpty(LocalMetadataPath) && File.Exists(LocalMetadataPath))
{
data = await File.ReadAllBytesAsync(LocalMetadataPath, token).ConfigureAwait(false);
if (data.Length > 0)
{
return YaeMetadataParser.ParseNativeLibConfig(data);
}
}
using HttpClient httpClient = httpClientFactory.CreateClient(nameof(YaeMetadataService));
data = await httpClient.GetByteArrayAsync(MetadataUrl, token).ConfigureAwait(false);
return YaeMetadataParser.ParseNativeLibConfig(data);
});
return new ValueTask<YaeNativeLibConfig?>(task);
}
private static string? TryGetLocalMetadataPath()
{
try
{
// 尝试获取用户下载目录下的metadata文件本地测试和排查问题时使用
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(userProfile))
{
return default;
}
string localPath = Path.Combine(userProfile, "Downloads", "metadata");
return localPath;
}
catch
{
return default;
}
}
}

View File

@@ -0,0 +1,12 @@
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal sealed class YaeNativeLibConfig
{
public required uint StoreCmdId { get; init; }
public required uint AchievementCmdId { get; init; }
public required IReadOnlyDictionary<uint, MethodRva> MethodRva { get; init; }
}

View File

@@ -6,7 +6,6 @@ using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.InterChange.Achievement; using Snap.Hutao.Model.InterChange.Achievement;
using Snap.Hutao.Model.InterChange.Inventory; using Snap.Hutao.Model.InterChange.Inventory;
using Snap.Hutao.Service.Feature;
using Snap.Hutao.Service.Game; using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.FileSystem; using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching; using Snap.Hutao.Service.Game.Launching;
@@ -15,10 +14,12 @@ using Snap.Hutao.Service.Game.Launching.Invoker;
using Snap.Hutao.Service.Notification; using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User; using Snap.Hutao.Service.User;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
using Snap.Hutao.Service.Yae.Metadata;
using Snap.Hutao.Service.Yae.PlayerStore; using Snap.Hutao.Service.Yae.PlayerStore;
using Snap.Hutao.ViewModel.Game; using Snap.Hutao.ViewModel.Game;
using Snap.Hutao.ViewModel.User; using Snap.Hutao.ViewModel.User;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
namespace Snap.Hutao.Service.Yae; namespace Snap.Hutao.Service.Yae;
@@ -27,7 +28,7 @@ internal sealed partial class YaeService : IYaeService
{ {
private readonly IContentDialogFactory contentDialogFactory; private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly IFeatureService featureService; private readonly IYaeMetadataService yaeMetadataService;
private readonly IUserService userService; private readonly IUserService userService;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IMessenger messenger; private readonly IMessenger messenger;
@@ -57,15 +58,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount), Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
}; };
if (!TryGetGameVersion(context, out string? version, out bool isOversea)) TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{ {
return default; return default;
} }
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false); await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
UIAF? uiaf = default; UIAF? uiaf = default;
@@ -76,7 +74,7 @@ internal sealed partial class YaeService : IYaeService
if (data.Kind is YaeCommandKind.ResponseAchievement) if (data.Kind is YaeCommandKind.ResponseAchievement)
{ {
Debug.Assert(uiaf is null); Debug.Assert(uiaf is null);
uiaf = AchievementParser.Parse(data.Bytes, fieldId); uiaf = AchievementParser.Parse(data.Bytes);
} }
} }
} }
@@ -116,15 +114,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount), Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
}; };
if (!TryGetGameVersion(context, out string? version, out bool isOversea)) TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{ {
return default; return default;
} }
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false); await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -167,9 +162,9 @@ internal sealed partial class YaeService : IYaeService
} }
} }
private bool TryGetGameVersion(LaunchExecutionInvocationContext context, [NotNullWhen(true)] out string? version, out bool isOversea) private async ValueTask<TargetNativeConfiguration?> TryGetTargetNativeConfigurationAsync(LaunchExecutionInvocationContext context)
{ {
const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetGameVersion)}"; const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetTargetNativeConfigurationAsync)}";
if (context.LaunchOptions.TryGetGameFileSystem(LockTrace, out IGameFileSystem? gameFileSystem) is not GameFileSystemErrorKind.None) if (context.LaunchOptions.TryGetGameFileSystem(LockTrace, out IGameFileSystem? gameFileSystem) is not GameFileSystemErrorKind.None)
{ {
@@ -178,23 +173,54 @@ internal sealed partial class YaeService : IYaeService
if (gameFileSystem is null) if (gameFileSystem is null)
{ {
version = default; return default;
isOversea = false;
return false;
} }
using (gameFileSystem) using (gameFileSystem)
{ {
if (!gameFileSystem.TryGetGameVersion(out version) || string.IsNullOrEmpty(version)) if (!TryGetGameExecutableHash(gameFileSystem.GameFilePath, out uint hash))
{ {
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed)); messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
isOversea = false; return default;
}
YaeNativeLibConfig? nativeConfig = await yaeMetadataService.GetNativeLibConfigAsync().ConfigureAwait(false);
if (nativeConfig is null)
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
if (!nativeConfig.MethodRva.TryGetValue(hash, out MethodRva? methodRva))
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
return TargetNativeConfiguration.Create(nativeConfig.StoreCmdId, nativeConfig.AchievementCmdId, methodRva);
}
}
private static bool TryGetGameExecutableHash(string gameFilePath, out uint hash)
{
try
{
Span<byte> buffer = stackalloc byte[0x10000];
using FileStream stream = File.OpenRead(gameFilePath);
int read = stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
if (read < buffer.Length)
{
hash = default;
return false; return false;
} }
isOversea = gameFileSystem.IsExecutableOversea; hash = Crc32.Compute(buffer);
}
return true; return true;
} }
catch (IOException)
{
hash = default;
return false;
}
}
} }

View File

@@ -11,7 +11,7 @@
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<UseWPF>False</UseWPF> <UseWPF>False</UseWPF>
<!-- 配置版本号 --> <!-- 配置版本号 -->
<Version>1.18.2.0</Version> <Version>1.18.5.0</Version>
<UseWindowsForms>False</UseWindowsForms> <UseWindowsForms>False</UseWindowsForms>
<ImplicitUsings>False</ImplicitUsings> <ImplicitUsings>False</ImplicitUsings>
@@ -79,6 +79,15 @@
<Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" /> <Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" />
</Target> </Target>
<!-- 声明unlockfps.exe为项目内容确保MSIX打包时包含此文件 -->
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)..\..\..\bin\unlockfps.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>unlockfps.exe</PackagePath>
</Content>
</ItemGroup>
<!-- Analyzer Files --> <!-- Analyzer Files -->
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="ApiEndpoints.csv" /> <AdditionalFiles Include="ApiEndpoints.csv" />

View File

@@ -20,6 +20,8 @@ internal partial class ScopedPage : Page
protected ScopedPage() protected ScopedPage()
{ {
// Allow a small set of recent pages to be cached to reduce navigation stutter.
NavigationCacheMode = NavigationCacheMode.Enabled;
// Events/Override Methods order // Events/Override Methods order
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// Page Navigation methods: // Page Navigation methods:
@@ -103,6 +105,13 @@ internal partial class ScopedPage : Page
private void OnUnloaded(object sender, RoutedEventArgs e) private void OnUnloaded(object sender, RoutedEventArgs e)
{ {
// When navigation cache is enabled, the page instance is reused.
// Do not tear down DataContext/scope here to avoid invalid state on return.
if (NavigationCacheMode != NavigationCacheMode.Disabled)
{
return;
}
// Cancel all tasks executed by the view model // Cancel all tasks executed by the view model
viewCts.Cancel(); viewCts.Cancel();

View File

@@ -57,7 +57,7 @@ internal sealed partial class HutaoPassportRegisterDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }
} }

View File

@@ -58,7 +58,7 @@ internal sealed partial class HutaoPassportResetPasswordDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -70,7 +70,7 @@ internal sealed partial class HutaoPassportResetUsernameDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -60,7 +60,7 @@ internal sealed partial class HutaoPassportUnregisterDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -17,6 +17,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock <TextBlock
@@ -31,8 +32,17 @@
Text="{x:Bind Tool.Description, Mode=OneWay}" Text="{x:Bind Tool.Description, Mode=OneWay}"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<TextBlock
Style="{StaticResource BodyTextBlockStyle}"
Text="{shuxm:ResourceString Name=ViewDialogThirdPartyToolVersion}"/>
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind Tool.Version, Mode=OneWay}"/>
</StackPanel>
<ProgressBar <ProgressBar
Grid.Row="2" Grid.Row="3"
Height="4" Height="4"
IsIndeterminate="{x:Bind IsDownloading.Value, Mode=OneWay, FallbackValue=False}" IsIndeterminate="{x:Bind IsDownloading.Value, Mode=OneWay, FallbackValue=False}"
Visibility="{x:Bind IsDownloading.Value, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/> Visibility="{x:Bind IsDownloading.Value, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/>

View File

@@ -39,8 +39,15 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog
{ {
IsDownloading = true; IsDownloading = true;
// 检查工具是否已下载 if (tool is null)
if (tool is not null && !thirdPartyToolService.IsToolDownloaded(tool)) {
return;
}
// 检查工具是否需要下载或更新
bool needDownload = !thirdPartyToolService.IsToolDownloaded(tool) || thirdPartyToolService.NeedsUpdate(tool);
if (needDownload)
{ {
// 下载工具 // 下载工具
bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false); bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false);
@@ -53,8 +60,6 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog
} }
// 启动工具 // 启动工具
if (tool is not null)
{
bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false); bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false);
if (launchSuccess) if (launchSuccess)
{ {
@@ -63,7 +68,6 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog
return; return;
} }
} }
}
catch (Exception ex) catch (Exception ex)
{ {
messenger.Send(InfoBarMessage.Error(ex)); messenger.Send(InfoBarMessage.Error(ex));

View File

@@ -264,7 +264,10 @@
<shuxv:UserView x:Name="UserView"/> <shuxv:UserView x:Name="UserView"/>
</NavigationView.PaneFooter> </NavigationView.PaneFooter>
<Frame x:Name="ContentFrame" ContentTransitions="{StaticResource NavigationThemeTransitions}"/> <Frame
x:Name="ContentFrame"
CacheSize="5"
ContentTransitions="{StaticResource NavigationThemeTransitions}"/>
</NavigationView> </NavigationView>
</Grid> </Grid>

View File

@@ -6,6 +6,7 @@ using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Logging; using Snap.Hutao.Core.Logging;
using Snap.Hutao.Core.Setting; using Snap.Hutao.Core.Setting;
using Snap.Hutao.UI.Input.LowLevel; using Snap.Hutao.UI.Input.LowLevel;
@@ -337,7 +338,8 @@ internal sealed partial class CompactWebView2Window : Microsoft.UI.Xaml.Window,
{ {
AdditionalBrowserArguments = "--do-not-de-elevate --autoplay-policy=no-user-gesture-required", AdditionalBrowserArguments = "--do-not-de-elevate --autoplay-policy=no-user-gesture-required",
}; };
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, null, options); string userDataFolder = HutaoRuntime.WebView2UserDataDirectory;
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, userDataFolder, options);
await WebView.EnsureCoreWebView2Async(environment); await WebView.EnsureCoreWebView2Async(environment);
} }
catch (SEHException ex) catch (SEHException ex)

View File

@@ -5,6 +5,7 @@ using Microsoft.UI;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.Web.WebView2.Core; using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Logging; using Snap.Hutao.Core.Logging;
using Snap.Hutao.UI.Windowing; using Snap.Hutao.UI.Windowing;
using Snap.Hutao.UI.Windowing.Abstraction; using Snap.Hutao.UI.Windowing.Abstraction;
@@ -154,7 +155,8 @@ internal sealed partial class WebView2Window : Microsoft.UI.Xaml.Window,
{ {
AdditionalBrowserArguments = "--do-not-de-elevate", AdditionalBrowserArguments = "--do-not-de-elevate",
}; };
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, null, options); string userDataFolder = HutaoRuntime.WebView2UserDataDirectory;
CoreWebView2Environment environment = await CoreWebView2Environment.CreateWithOptionsAsync(null, userDataFolder, options);
await WebView.EnsureCoreWebView2Async(environment); await WebView.EnsureCoreWebView2Async(environment);
} }
catch (SEHException) catch (SEHException)

View File

@@ -0,0 +1,13 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.ViewModel.GachaLog;
internal sealed class GachaLogImportedMessage
{
public static readonly GachaLogImportedMessage Empty = new();
private GachaLogImportedMessage()
{
}
}

View File

@@ -26,7 +26,7 @@ namespace Snap.Hutao.ViewModel.GachaLog;
[BindableCustomPropertyProvider] [BindableCustomPropertyProvider]
[Service(ServiceLifetime.Scoped)] [Service(ServiceLifetime.Scoped)]
internal sealed partial class GachaLogViewModel : Abstraction.ViewModel internal sealed partial class GachaLogViewModel : Abstraction.ViewModel, IRecipient<GachaLogImportedMessage>
{ {
private readonly IContentDialogFactory contentDialogFactory; private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
@@ -127,6 +127,22 @@ internal sealed partial class GachaLogViewModel : Abstraction.ViewModel
UpdateStatisticsAsync(Archives?.CurrentItem).SafeForget(); UpdateStatisticsAsync(Archives?.CurrentItem).SafeForget();
} }
public async void Receive(GachaLogImportedMessage message)
{
await RefreshArchiveCollectionAsync().ConfigureAwait(false);
}
private async ValueTask RefreshArchiveCollectionAsync()
{
using (await EnterCriticalSectionAsync().ConfigureAwait(false))
{
IAdvancedDbCollectionView<GachaArchive> archives = await gachaLogService.RefreshArchiveCollectionAsync().ConfigureAwait(false);
await taskContext.SwitchToMainThreadAsync();
Archives = archives;
Archives.MoveCurrentTo(Archives.Source.SelectedOrFirstOrDefault());
}
}
[Command("RefreshByWebCacheCommand")] [Command("RefreshByWebCacheCommand")]
private async Task RefreshByWebCacheAsync() private async Task RefreshByWebCacheAsync()
{ {

View File

@@ -193,11 +193,11 @@ internal sealed partial class SpiralAbyssViewModel : Abstraction.ViewModel, IRec
{ {
HutaoResponse response = await spiralAbyssClient.UploadRecordAsync(record).ConfigureAwait(false); HutaoResponse response = await spiralAbyssClient.UploadRecordAsync(record).ConfigureAwait(false);
if (response is ILocalizableResponse localizableResponse) if (response is ILocalizableResponse)
{ {
messenger.Send(InfoBarMessage.Any( messenger.Send(InfoBarMessage.Any(
response is { ReturnCode: 0 } ? InfoBarSeverity.Success : InfoBarSeverity.Warning, response is { ReturnCode: 0 } ? InfoBarSeverity.Success : InfoBarSeverity.Warning,
localizableResponse.GetLocalizationMessage())); response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -24,7 +24,7 @@ internal sealed class HutaoResponse : Web.Response.Response, ILocalizableRespons
public override string ToString() public override string ToString()
{ {
return SH.FormatWebResponse(ReturnCode, this.GetLocalizationMessageOrDefault()); return SH.FormatWebResponse(ReturnCode, this.GetLocalizationMessageOrMessage());
} }
} }
@@ -48,6 +48,6 @@ internal sealed class HutaoResponse<TData> : Response<TData>, ILocalizableRespon
public override string ToString() public override string ToString()
{ {
return SH.FormatWebResponse(ReturnCode, this.GetLocalizationMessageOrDefault()); return SH.FormatWebResponse(ReturnCode, this.GetLocalizationMessageOrMessage());
} }
} }

View File

@@ -0,0 +1,54 @@
using System.Text.Json.Serialization;
namespace Snap.Hutao.Web.ThirdPartyTool;
/// <summary>
/// 本地工具信息,用于保存工具的本地状态
/// </summary>
internal sealed class LocalToolInfo
{
/// <summary>
/// 工具名称
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
/// <summary>
/// 工具版本号
/// </summary>
[JsonPropertyName("version")]
public string Version { get; set; } = default!;
/// <summary>
/// 是否为压缩包
/// </summary>
[JsonPropertyName("is_compressed")]
public bool IsCompressed { get; set; }
/// <summary>
/// 主可执行文件名
/// </summary>
[JsonPropertyName("main_exe")]
public string? MainExe { get; set; }
/// <summary>
/// 下载时间
/// </summary>
[JsonPropertyName("download_time")]
public DateTimeOffset DownloadTime { get; set; }
/// <summary>
/// 从 ToolInfo 创建 LocalToolInfo
/// </summary>
public static LocalToolInfo FromToolInfo(ToolInfo toolInfo)
{
return new LocalToolInfo
{
Name = toolInfo.Name,
Version = toolInfo.Version,
IsCompressed = toolInfo.IsCompressed,
MainExe = toolInfo.MainExe,
DownloadTime = DateTimeOffset.Now,
};
}
}

View File

@@ -16,4 +16,13 @@ internal sealed class ToolInfo
[JsonPropertyName("files")] [JsonPropertyName("files")]
public List<string> Files { get; set; } = default!; public List<string> 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!;
} }