mirror of
https://github.com/wangdage12/Snap.Hutao.git
synced 2026-03-28 16:55:18 +08:00
Compare commits
13 Commits
74e9427451
...
1.18.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3865b7992 | ||
|
|
66263a82f2 | ||
|
|
a2ddeadd0a | ||
|
|
246109227d | ||
|
|
515fa004bb | ||
|
|
7c706f8dc7 | ||
|
|
9de07754c7 | ||
|
|
8e86ccc560 | ||
|
|
e1df07ac21 | ||
|
|
1dabff07a1 | ||
|
|
640686a837 | ||
|
|
e9ed7928d6 | ||
|
|
4d2943d1c9 |
2
.github/workflows/PublishDistribution.yml
vendored
2
.github/workflows/PublishDistribution.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
|||||||
-Body $r2Data `
|
-Body $r2Data `
|
||||||
-ContentType "application/json"
|
-ContentType "application/json"
|
||||||
Write-Output $response2.Content
|
Write-Output $response2.Content
|
||||||
- uses: benc-uk/workflow-dispatch@v1.2.4
|
- uses: benc-uk/workflow-dispatch@v1.3.1
|
||||||
with:
|
with:
|
||||||
workflow: Build
|
workflow: Build
|
||||||
repo: DGP-Studio/hutao-installer
|
repo: DGP-Studio/hutao-installer
|
||||||
|
|||||||
2
.github/workflows/alpha.yml
vendored
2
.github/workflows/alpha.yml
vendored
@@ -113,7 +113,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload signed msix
|
- name: Upload signed msix
|
||||||
if: success() && github.event_name != 'pull_request'
|
if: success() && github.event_name != 'pull_request'
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}
|
name: Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}
|
||||||
path: ${{ github.workspace }}/src/output/Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}.msix
|
path: ${{ github.workspace }}/src/output/Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}.msix
|
||||||
|
|||||||
2
.github/workflows/canary.yml
vendored
2
.github/workflows/canary.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload signed msix
|
- name: Upload signed msix
|
||||||
if: ${{ success() && steps.merge.outputs.continue == 'true' }}
|
if: ${{ success() && steps.merge.outputs.continue == 'true' }}
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Snap.Hutao.Canary-${{ steps.cake.outputs.version }}
|
name: Snap.Hutao.Canary-${{ steps.cake.outputs.version }}
|
||||||
path: ${{ github.workspace }}/src/output/Snap.Hutao.Canary-${{ steps.cake.outputs.version }}.msix
|
path: ${{ github.workspace }}/src/output/Snap.Hutao.Canary-${{ steps.cake.outputs.version }}.msix
|
||||||
|
|||||||
2
.github/workflows/msi-build.yml
vendored
2
.github/workflows/msi-build.yml
vendored
@@ -31,7 +31,7 @@ jobs:
|
|||||||
run: dotnet build src/Snap.Hutao/Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj -c Release
|
run: dotnet build src/Snap.Hutao/Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj -c Release
|
||||||
|
|
||||||
- name: Upload MSI Artifact
|
- name: Upload MSI Artifact
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Snap.Hutao-MSI
|
name: Snap.Hutao-MSI
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
自带的注入功能只有FPS调整,只保证FPS调整长期可用,你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
|
自带的注入功能只有FPS调整,只保证FPS调整长期可用,你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
|
||||||
|
|
||||||
官网:https://htserver.wdg.cloudns.ch/
|
官网:https://htserver.wdg12.work/
|
||||||
|
|
||||||
**该版本的特点:**
|
**该版本的特点:**
|
||||||
- 尽量保留原版功能,少重写功能,稳定性强
|
- 尽量保留原版功能,少重写功能,稳定性强
|
||||||
@@ -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)
|
- 服务端:[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
|
||||||
@@ -85,9 +93,7 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
|
|||||||
|
|
||||||
我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能
|
我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能
|
||||||
|
|
||||||
[新服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61)
|
[服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61)
|
||||||
|
|
||||||
[旧服务器状态页面](http://serverjp.wdg.cloudns.ch:3001/status/hts)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,7 +101,6 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
|
|||||||
https://github.com/wangdage12/Snap.Metadata
|
https://github.com/wangdage12/Snap.Metadata
|
||||||
|
|
||||||
仓库镜像:
|
仓库镜像:
|
||||||

|
|
||||||
|
|
||||||
http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
|
http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
|
||||||
|
|
||||||
@@ -105,10 +110,16 @@ http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
https://htserver.wdg.cloudns.ch/api/
|
https://htserver.wdg12.work/api/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**图片资源站:**
|
**图片资源站:**
|
||||||
|
|
||||||
https://htserver.wdg.cloudns.ch/
|
https://htserver.wdg12.work/
|
||||||
|
|
||||||
|
# 赞助
|
||||||
|
|
||||||
|
如果你想要为我分摊经济压力,可以在下方链接中为我赞助(支持多个预设方案,你也可以在页面下方自定义金额)
|
||||||
|
赞助的资金将全部用于服务器、域名等,若有剩余资金将升级CDN或者服务器来提升使用体验,我们的服务是完全免费的,该赞助并不会解锁额外特权,但是>=10元时将在官网新的“赞助者页面”上添加你的信息
|
||||||
|
https://ifdian.net/a/wdg12
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<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.3.0"
|
Version="1.18.6.0"
|
||||||
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
|
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
|
||||||
Language="2052"
|
Language="2052"
|
||||||
Scope="perMachine">
|
Scope="perMachine">
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ public static partial class Bootstrap
|
|||||||
private static readonly ApplicationInitializationCallback AppInitializationCallback = InitializeApp;
|
private static readonly ApplicationInitializationCallback AppInitializationCallback = InitializeApp;
|
||||||
private static Mutex? mutex;
|
private static Mutex? mutex;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether console output is enabled.
|
||||||
|
/// </summary>
|
||||||
|
public static bool ConsoleEnabled { get; private set; }
|
||||||
|
|
||||||
internal static void UseNamedPipeRedirection()
|
internal static void UseNamedPipeRedirection()
|
||||||
{
|
{
|
||||||
Debug.Assert(mutex is not null);
|
Debug.Assert(mutex is not null);
|
||||||
@@ -31,6 +36,19 @@ public static partial class Bootstrap
|
|||||||
[STAThread]
|
[STAThread]
|
||||||
private static void Main(string[] args)
|
private static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
// Check for console flag
|
||||||
|
ConsoleEnabled = args.Contains("--console") || args.Contains("--debug") ||
|
||||||
|
Environment.GetEnvironmentVariable("HUTAO_DEBUG") == "1";
|
||||||
|
|
||||||
|
if (ConsoleEnabled)
|
||||||
|
{
|
||||||
|
// Allocate a console window for debug output
|
||||||
|
HutaoNativeMethods.AllocConsole();
|
||||||
|
Console.WriteLine("[Bootstrap] Console allocated for debug output");
|
||||||
|
Console.WriteLine($"[Bootstrap] Arguments: {string.Join(" ", args)}");
|
||||||
|
Console.WriteLine($"[Bootstrap] Working Directory: {Environment.CurrentDirectory}");
|
||||||
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Starting...");
|
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Starting...");
|
||||||
#endif
|
#endif
|
||||||
@@ -124,6 +142,12 @@ public static partial class Bootstrap
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Exiting");
|
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Exiting");
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
if (ConsoleEnabled)
|
||||||
|
{
|
||||||
|
Console.WriteLine("[Bootstrap] Application exiting...");
|
||||||
|
HutaoNativeMethods.FreeConsole();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void InitializeApp(ApplicationInitializationCallbackParams param)
|
private static void InitializeApp(ApplicationInitializationCallbackParams param)
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ internal static class DependencyInjection
|
|||||||
.AddFilter(DbLoggerCategory.Query.Name, level => level >= LogLevel.Information)
|
.AddFilter(DbLoggerCategory.Query.Name, level => level >= LogLevel.Information)
|
||||||
.AddDebug()
|
.AddDebug()
|
||||||
.AddSentryTelemetry();
|
.AddSentryTelemetry();
|
||||||
|
|
||||||
|
// Add console logging if console is enabled
|
||||||
|
if (Bootstrap.ConsoleEnabled)
|
||||||
|
{
|
||||||
|
builder.AddConsole();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.AddMemoryCache()
|
.AddMemoryCache()
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -116,7 +118,10 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
|
|||||||
|
|
||||||
case YaeCommandKind.RequestResumeThread:
|
case YaeCommandKind.RequestResumeThread:
|
||||||
{
|
{
|
||||||
gameProcess.ResumeMainThread();
|
if (supportsResumeMainThread)
|
||||||
|
{
|
||||||
|
gameProcess.ResumeMainThread();
|
||||||
|
}
|
||||||
return default;
|
return default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.3.0" />
|
Version="1.18.6.0" />
|
||||||
|
|
||||||
<Properties>
|
<Properties>
|
||||||
<DisplayName>Snap Hutao</DisplayName>
|
<DisplayName>Snap Hutao</DisplayName>
|
||||||
|
|||||||
@@ -1127,12 +1127,21 @@
|
|||||||
<data name="ServiceGameSetMultiChannelUnauthorizedAccess" xml:space="preserve">
|
<data name="ServiceGameSetMultiChannelUnauthorizedAccess" xml:space="preserve">
|
||||||
<value>无法读取或保存配置文件,请以管理员模式重试</value>
|
<value>无法读取或保存配置文件,请以管理员模式重试</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ServiceGitRepositoryCloneReasonForceInvalid" xml:space="preserve">
|
||||||
|
<value>现有元数据仓库更新失败,正在重新下载</value>
|
||||||
|
</data>
|
||||||
|
<data name="ServiceGitRepositoryCloneReasonInvalidRepo" xml:space="preserve">
|
||||||
|
<value>首次下载或仓库损坏,正在下载元数据</value>
|
||||||
|
</data>
|
||||||
<data name="ServiceGitRepositoryOperationCompleted" xml:space="preserve">
|
<data name="ServiceGitRepositoryOperationCompleted" xml:space="preserve">
|
||||||
<value>操作完成</value>
|
<value>操作完成</value>
|
||||||
</data>
|
</data>
|
||||||
<data name="ServiceGitRepositoryOperationFailed" xml:space="preserve">
|
<data name="ServiceGitRepositoryOperationFailed" xml:space="preserve">
|
||||||
<value>操作失败</value>
|
<value>操作失败</value>
|
||||||
</data>
|
</data>
|
||||||
|
<data name="ServiceGitRepositoryUpdatingExisting" xml:space="preserve">
|
||||||
|
<value>检查元数据更新</value>
|
||||||
|
</data>
|
||||||
<data name="ServiceHutaoUserGachaLogExpiredAt" xml:space="preserve">
|
<data name="ServiceHutaoUserGachaLogExpiredAt" xml:space="preserve">
|
||||||
<value>祈愿记录上传服务有效期至</value>
|
<value>祈愿记录上传服务有效期至</value>
|
||||||
</data>
|
</data>
|
||||||
@@ -1598,6 +1607,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>
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -26,17 +26,22 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
|
|||||||
|
|
||||||
public override async ValueTask ExecuteAsync(LaunchExecutionContext context)
|
public override async ValueTask ExecuteAsync(LaunchExecutionContext context)
|
||||||
{
|
{
|
||||||
// 如果启用了Island(FPS解锁),则跳过启动游戏进程
|
|
||||||
// 因为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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
{
|
{
|
||||||
private readonly AsyncKeyedLock<string> repoLock = new();
|
private readonly AsyncKeyedLock<string> repoLock = new();
|
||||||
private readonly BackgroundActivityOptions backgroundActivityOptions;
|
private readonly BackgroundActivityOptions backgroundActivityOptions;
|
||||||
|
private readonly ILogger<GitRepositoryService> logger;
|
||||||
private readonly IServiceProvider serviceProvider;
|
private readonly IServiceProvider serviceProvider;
|
||||||
private readonly ITaskContext taskContext;
|
private readonly ITaskContext taskContext;
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
}
|
}
|
||||||
catch (Exception first)
|
catch (Exception first)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning(first, "[Metadata] Failed to update existing repository, fallback to reclone: Directory={Directory}, Url={Url}", directory, info.HttpsUrl.OriginalString);
|
||||||
exceptions.Add(first);
|
exceptions.Add(first);
|
||||||
return EnsureRepository(activity, directory, info, true);
|
return EnsureRepository(activity, directory, info, true);
|
||||||
}
|
}
|
||||||
@@ -110,6 +112,14 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
{
|
{
|
||||||
// Increase & decrease count in the same method, so that crash in the middle can correctly count as failure.
|
// Increase & decrease count in the same method, so that crash in the middle can correctly count as failure.
|
||||||
RepositoryAffinity.IncreaseFailure(info);
|
RepositoryAffinity.IncreaseFailure(info);
|
||||||
|
|
||||||
|
// Debug: Log the initial state
|
||||||
|
bool isRepoValid = Repository.IsValid(directory);
|
||||||
|
bool directoryExists = Directory.Exists(directory);
|
||||||
|
|
||||||
|
logger.LogInformation("[Metadata] Checking repository: Directory={Directory}, Exists={Exists}, IsValid={IsValid}, ForceInvalid={ForceInvalid}",
|
||||||
|
directory, directoryExists, isRepoValid, forceInvalid);
|
||||||
|
|
||||||
FetchOptions fetchOptions = new()
|
FetchOptions fetchOptions = new()
|
||||||
{
|
{
|
||||||
Depth = 1,
|
Depth = 1,
|
||||||
@@ -142,10 +152,18 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
CertificateCheck = static (cert, valid, host) => true,
|
CertificateCheck = static (cert, valid, host) => true,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (forceInvalid || !Repository.IsValid(directory))
|
if (forceInvalid || !isRepoValid)
|
||||||
{
|
{
|
||||||
if (Directory.Exists(directory))
|
// Debug: Log why we're cloning
|
||||||
|
string reason = forceInvalid
|
||||||
|
? SH.ServiceGitRepositoryCloneReasonForceInvalid
|
||||||
|
: SH.ServiceGitRepositoryCloneReasonInvalidRepo;
|
||||||
|
logger.LogInformation("[Metadata] Cloning repository: Reason={Reason}, Url={Url}", reason, info.HttpsUrl.OriginalString);
|
||||||
|
activity.Update(taskContext, reason, false, false, false, false);
|
||||||
|
|
||||||
|
if (directoryExists)
|
||||||
{
|
{
|
||||||
|
logger.LogInformation("[Metadata] Deleting existing directory before clone");
|
||||||
Directory.SetReadOnly(directory, false);
|
Directory.SetReadOnly(directory, false);
|
||||||
Directory.Delete(directory, true);
|
Directory.Delete(directory, true);
|
||||||
}
|
}
|
||||||
@@ -154,9 +172,15 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
{
|
{
|
||||||
Checkout = true,
|
Checkout = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
logger.LogInformation("[Metadata] Clone completed successfully");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// Debug: Log that we're updating
|
||||||
|
logger.LogInformation("[Metadata] Updating existing repository");
|
||||||
|
activity.Update(taskContext, SH.ServiceGitRepositoryUpdatingExisting, false, false, false, false);
|
||||||
|
|
||||||
// We need to ensure local repo is up to date
|
// We need to ensure local repo is up to date
|
||||||
using (Repository repo = new(directory))
|
using (Repository repo = new(directory))
|
||||||
{
|
{
|
||||||
@@ -177,17 +201,20 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
|||||||
repo.Network.Remotes.Update("origin", remote => remote.Url = info.HttpsUrl.OriginalString);
|
repo.Network.Remotes.Update("origin", remote => remote.Url = info.HttpsUrl.OriginalString);
|
||||||
repo.RemoveUntrackedFiles();
|
repo.RemoveUntrackedFiles();
|
||||||
fetchOptions.UpdateFetchHead = false;
|
fetchOptions.UpdateFetchHead = false;
|
||||||
Commands.Fetch(repo, repo.Head.RemoteName, Array.Empty<string>(), fetchOptions, default);
|
Commands.Fetch(repo, "origin", Array.Empty<string>(), fetchOptions, default);
|
||||||
|
|
||||||
// Manually patch .git/shallow file
|
// Manually patch .git/shallow file
|
||||||
File.WriteAllText(Path.Combine(directory, ".git//shallow"), string.Join("", repo.Branches.Where(static branch => branch.IsRemote).Select(static branch => $"{branch.Tip.Sha}\n")));
|
File.WriteAllText(Path.Combine(directory, ".git", "shallow"), string.Join("", repo.Branches.Where(static branch => branch.IsRemote).Select(static branch => $"{branch.Tip.Sha}\n")));
|
||||||
|
|
||||||
Branch remoteBranch = repo.Branches["origin/main"];
|
Branch remoteBranch = repo.Branches["origin/main"];
|
||||||
Branch localBranch = repo.Branches["main"] ?? repo.CreateBranch("main", remoteBranch.Tip);
|
Branch localBranch = repo.Branches["main"] ?? repo.CreateBranch("main", remoteBranch.Tip);
|
||||||
repo.Branches.Update(localBranch, b => b.TrackedBranch = remoteBranch.CanonicalName);
|
repo.Branches.Update(localBranch, b => b.TrackedBranch = remoteBranch.CanonicalName);
|
||||||
|
Commands.Checkout(repo, localBranch);
|
||||||
repo.Reset(ResetMode.Hard, remoteBranch.Tip);
|
repo.Reset(ResetMode.Hard, remoteBranch.Tip);
|
||||||
repo.RemoveUntrackedFiles();
|
repo.RemoveUntrackedFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("[Metadata] Update completed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
RepositoryAffinity.DecreaseFailure(info);
|
RepositoryAffinity.DecreaseFailure(info);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -17,8 +19,9 @@ namespace Snap.Hutao.Service.ThirdPartyTool;
|
|||||||
[Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))]
|
[Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))]
|
||||||
internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
|
internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
|
||||||
{
|
{
|
||||||
private const string ApiBaseUrl = "https://htserver.wdg.cloudns.ch/api";
|
private const string ApiBaseUrl = "https://htserver.wdg12.work/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,41 +92,32 @@ 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++;
|
await DownloadFilesAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存本地工具信息
|
||||||
|
SaveLocalToolInfo(tool);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
// 使用数据目录/工具名作为存储路径
|
// 使用数据目录/工具名作为存储路径
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
32
src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs
Normal file
32
src/Snap.Hutao/Snap.Hutao/Service/Yae/Metadata/Crc32.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Snap.Hutao.Service.Yae.Metadata;
|
||||||
|
|
||||||
|
internal interface IYaeMetadataService
|
||||||
|
{
|
||||||
|
ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
hash = default;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<UseWinUI>true</UseWinUI>
|
<UseWinUI>true</UseWinUI>
|
||||||
<UseWPF>False</UseWPF>
|
<UseWPF>False</UseWPF>
|
||||||
<!-- 配置版本号 -->
|
<!-- 配置版本号 -->
|
||||||
<Version>1.18.3.0</Version>
|
<Version>1.18.6.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" />
|
||||||
|
|||||||
@@ -117,10 +117,17 @@ internal partial class ScopedPage : Page
|
|||||||
|
|
||||||
if (DataContext is IViewModel viewModel)
|
if (DataContext is IViewModel viewModel)
|
||||||
{
|
{
|
||||||
// Wait to ensure critical viewmodel operation is completed
|
try
|
||||||
using (viewModel.CriticalSection.Enter())
|
{
|
||||||
|
// Wait to ensure critical viewmodel operation is completed.
|
||||||
|
// The view model might already be disposed when window shutdown and page unload race.
|
||||||
|
using (viewModel.CriticalSection.Enter())
|
||||||
|
{
|
||||||
|
viewModel.Uninitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
viewModel.Uninitialize();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||||
<!-- DocumentLink -->
|
<!-- DocumentLink -->
|
||||||
<x:String x:Key="DocumentLink_BugReport">https://hut.ao/statements/bug-report.html</x:String>
|
<x:String x:Key="DocumentLink_BugReport">https://hut.ao/statements/bug-report.html</x:String>
|
||||||
<x:String x:Key="DocumentLink_Home">https://hut.ao</x:String>
|
<x:String x:Key="DocumentLink_Home">https://htserver.wdg12.work</x:String>
|
||||||
<x:String x:Key="DocumentLink_MhyAccountSwitch">https://hut.ao/features/mhy-account-switch.html</x:String>
|
<x:String x:Key="DocumentLink_MhyAccountSwitch">https://hut.ao/features/mhy-account-switch.html</x:String>
|
||||||
<x:String x:Key="DocumentLink_Translate">https://translate.hut.ao</x:String>
|
<x:String x:Key="DocumentLink_Translate">https://translate.hut.ao</x:String>
|
||||||
<x:String x:Key="DocumentLink_Loopback">https://hut.ao/advanced/loopback.html</x:String>
|
<x:String x:Key="DocumentLink_Loopback">https://hut.ao/advanced/loopback.html</x:String>
|
||||||
@@ -13,32 +13,32 @@
|
|||||||
<x:String x:Key="IconGame">https://launcher-webstatic.mihoyo.com/launcher-public/2024/04/15/9ebf1bc5af2d83ca5fca21adb49cf341_2571779162329842818.png</x:String>
|
<x:String x:Key="IconGame">https://launcher-webstatic.mihoyo.com/launcher-public/2024/04/15/9ebf1bc5af2d83ca5fca21adb49cf341_2571779162329842818.png</x:String>
|
||||||
|
|
||||||
<!-- AvatarCard -->
|
<!-- AvatarCard -->
|
||||||
<x:String x:Key="UI_AvatarIcon_Costume_Card">https://htserver.wdg.cloudns.ch/static/raw/AvatarCard/UI_AvatarIcon_Costume_Card.png</x:String>
|
<x:String x:Key="UI_AvatarIcon_Costume_Card">https://htserver.wdg12.work/static/raw/AvatarCard/UI_AvatarIcon_Costume_Card.png</x:String>
|
||||||
|
|
||||||
<!-- Bg -->
|
<!-- Bg -->
|
||||||
<x:String x:Key="UI_ItemIcon_None">https://htserver.wdg.cloudns.ch/static/raw/Bg/UI_ItemIcon_None.png</x:String>
|
<x:String x:Key="UI_ItemIcon_None">https://htserver.wdg12.work/static/raw/Bg/UI_ItemIcon_None.png</x:String>
|
||||||
|
|
||||||
<!-- Mark -->
|
<!-- Mark -->
|
||||||
<x:String x:Key="UI_MarkQuest_Events_Proce">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Events_Proce.png</x:String>
|
<x:String x:Key="UI_MarkQuest_Events_Proce">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Events_Proce.png</x:String>
|
||||||
<x:String x:Key="UI_MarkQuest_Events_Start">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Events_Start.png</x:String>
|
<x:String x:Key="UI_MarkQuest_Events_Start">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Events_Start.png</x:String>
|
||||||
<x:String x:Key="UI_MarkQuest_Main_Proce">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Main_Proce.png</x:String>
|
<x:String x:Key="UI_MarkQuest_Main_Proce">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Main_Proce.png</x:String>
|
||||||
<x:String x:Key="UI_MarkQuest_Main_Start">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Main_Start.png</x:String>
|
<x:String x:Key="UI_MarkQuest_Main_Start">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Main_Start.png</x:String>
|
||||||
<x:String x:Key="UI_MarkTower">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkTower.png</x:String>
|
<x:String x:Key="UI_MarkTower">https://htserver.wdg12.work/static/raw/Mark/UI_MarkTower.png</x:String>
|
||||||
<!-- ItemIcon -->
|
<!-- ItemIcon -->
|
||||||
<x:String x:Key="UI_ItemIcon_106">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_106.png</x:String>
|
<x:String x:Key="UI_ItemIcon_106">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_106.png</x:String>
|
||||||
<x:String x:Key="UI_ItemIcon_204">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_204.png</x:String>
|
<x:String x:Key="UI_ItemIcon_204">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_204.png</x:String>
|
||||||
<x:String x:Key="UI_ItemIcon_210">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_210.png</x:String>
|
<x:String x:Key="UI_ItemIcon_210">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_210.png</x:String>
|
||||||
<x:String x:Key="UI_ItemIcon_220021">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_220021.png</x:String>
|
<x:String x:Key="UI_ItemIcon_220021">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_220021.png</x:String>
|
||||||
|
|
||||||
<!-- EmotionIcon -->
|
<!-- EmotionIcon -->
|
||||||
<x:String x:Key="UI_EmotionIcon52">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon52.png</x:String>
|
<x:String x:Key="UI_EmotionIcon52">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon52.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon71">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon71.png</x:String>
|
<x:String x:Key="UI_EmotionIcon71">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon71.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon89">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon89.png</x:String>
|
<x:String x:Key="UI_EmotionIcon89">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon89.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon250">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon250.png</x:String>
|
<x:String x:Key="UI_EmotionIcon250">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon250.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon271">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon271.png</x:String>
|
<x:String x:Key="UI_EmotionIcon271">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon271.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon272">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon272.png</x:String>
|
<x:String x:Key="UI_EmotionIcon272">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon272.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon293">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon293.png</x:String>
|
<x:String x:Key="UI_EmotionIcon293">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon293.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon433">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon433.png</x:String>
|
<x:String x:Key="UI_EmotionIcon433">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon433.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon445">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon445.png</x:String>
|
<x:String x:Key="UI_EmotionIcon445">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon445.png</x:String>
|
||||||
<x:String x:Key="UI_EmotionIcon585">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon585.png</x:String>
|
<x:String x:Key="UI_EmotionIcon585">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon585.png</x:String>
|
||||||
</ResourceDictionary>
|
</ResourceDictionary>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ internal sealed partial class HutaoPassportRegisterDialog : ContentDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage()));
|
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ internal sealed partial class HutaoPassportResetPasswordDialog : ContentDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage()));
|
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ internal sealed partial class HutaoPassportResetUsernameDialog : ContentDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage()));
|
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ internal sealed partial class HutaoPassportUnregisterDialog : ContentDialog
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage()));
|
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}}"/>
|
||||||
|
|||||||
@@ -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,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);
|
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
|
||||||
if (launchSuccess)
|
Hide();
|
||||||
{
|
return;
|
||||||
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
|
|
||||||
Hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -268,7 +268,7 @@
|
|||||||
<TextBlock>
|
<TextBlock>
|
||||||
<TextBlock.Inlines>
|
<TextBlock.Inlines>
|
||||||
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementIHaveReadText}"/>
|
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementIHaveReadText}"/>
|
||||||
<Hyperlink NavigateUri="https://github.com/DGP-Studio/Snap.Hutao/blob/main/LICENSE">
|
<Hyperlink NavigateUri="https://github.com/wangdage12/Snap.Hutao/blob/main/LICENSE">
|
||||||
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementOpenSourceLicense}"/>
|
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementOpenSourceLicense}"/>
|
||||||
</Hyperlink>
|
</Hyperlink>
|
||||||
</TextBlock.Inlines>
|
</TextBlock.Inlines>
|
||||||
|
|||||||
@@ -93,13 +93,13 @@
|
|||||||
<cwcont:SettingsExpander.Items>
|
<cwcont:SettingsExpander.Items>
|
||||||
<cwcont:SettingsCard
|
<cwcont:SettingsCard
|
||||||
Command="{Binding NavigateToUriCommand}"
|
Command="{Binding NavigateToUriCommand}"
|
||||||
CommandParameter="https://github.com/DGP-Studio/Snap.Hutao/issues/new/choose"
|
CommandParameter="https://github.com/wangdage12/Snap.Hutao"
|
||||||
Description="{shuxm:ResourceString Name=ViewPageFeedbackGithubIssuesDescription}"
|
Description="{shuxm:ResourceString Name=ViewPageFeedbackGithubIssuesDescription}"
|
||||||
Header="GitHub Issues"
|
Header="GitHub Issues"
|
||||||
IsClickEnabled="True"/>
|
IsClickEnabled="True"/>
|
||||||
<cwcont:SettingsCard
|
<cwcont:SettingsCard
|
||||||
Command="{Binding NavigateToUriCommand}"
|
Command="{Binding NavigateToUriCommand}"
|
||||||
CommandParameter="https://status.snapgenshin.cn/status"
|
CommandParameter="https://stats.uptimerobot.com/fHxWxdxK61"
|
||||||
Description="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusDescription}"
|
Description="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusDescription}"
|
||||||
Header="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusHeader}"
|
Header="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusHeader}"
|
||||||
IsClickEnabled="True"/>
|
IsClickEnabled="True"/>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ namespace Snap.Hutao.Web.Endpoint.Hutao;
|
|||||||
[Service(ServiceLifetime.Singleton, typeof(IHutaoEndpoints), Key = HutaoEndpointsKind.Release)]
|
[Service(ServiceLifetime.Singleton, typeof(IHutaoEndpoints), Key = HutaoEndpointsKind.Release)]
|
||||||
internal sealed class HutaoEndpointsForRelease : IHutaoEndpoints
|
internal sealed class HutaoEndpointsForRelease : IHutaoEndpoints
|
||||||
{
|
{
|
||||||
string IHomaRootAccess.Root { get => "https://htserver.wdg.cloudns.ch/api"; }
|
string IHomaRootAccess.Root { get => "https://htserver.wdg12.work/api"; }
|
||||||
|
|
||||||
string IInfrastructureRootAccess.Root { get => "https://htserver.wdg.cloudns.ch/api"; }
|
string IInfrastructureRootAccess.Root { get => "https://htserver.wdg12.work/api"; }
|
||||||
|
|
||||||
string IInfrastructureRawRootAccess.RawRoot { get => "https://htserver.wdg.cloudns.ch/api"; }
|
string IInfrastructureRawRootAccess.RawRoot { get => "https://htserver.wdg12.work/api"; }
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ namespace Snap.Hutao.Web.Endpoint.Hutao;
|
|||||||
|
|
||||||
internal static class StaticResourcesEndpoints
|
internal static class StaticResourcesEndpoints
|
||||||
{
|
{
|
||||||
public static string Root { get => "https://htserver.wdg.cloudns.ch"; }
|
public static string Root { get => "https://htserver.wdg12.work"; }
|
||||||
|
|
||||||
public static Uri UIIconNone { get; } = StaticRaw("Bg", "UI_Icon_None.png").ToUri();
|
public static Uri UIIconNone { get; } = StaticRaw("Bg", "UI_Icon_None.png").ToUri();
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
}
|
}
|
||||||
@@ -42,6 +42,10 @@ internal static unsafe class HutaoNativeMethods
|
|||||||
// ReSharper restore InconsistentNaming
|
// ReSharper restore InconsistentNaming
|
||||||
public const string DllName = "Snap.Hutao.Native.dll";
|
public const string DllName = "Snap.Hutao.Native.dll";
|
||||||
|
|
||||||
|
// Console APIs
|
||||||
|
public const int STD_OUTPUT_HANDLE = -11;
|
||||||
|
public const int STD_ERROR_HANDLE = -12;
|
||||||
|
|
||||||
public static HutaoNative HutaoCreateInstance()
|
public static HutaoNative HutaoCreateInstance()
|
||||||
{
|
{
|
||||||
nint pv = default;
|
nint pv = default;
|
||||||
@@ -54,6 +58,36 @@ internal static unsafe class HutaoNativeMethods
|
|||||||
return HutaoHResultIsWin32(hr, error);
|
return HutaoHResultIsWin32(hr, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allocates a new console for the calling process.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>TRUE if the function succeeds; otherwise, FALSE.</returns>
|
||||||
|
[DllImport("kernel32.dll", ExactSpelling = true)]
|
||||||
|
public static extern BOOL AllocConsole();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Detaches the calling process from its console.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>TRUE if the function succeeds; otherwise, FALSE.</returns>
|
||||||
|
[DllImport("kernel32.dll", ExactSpelling = true)]
|
||||||
|
public static extern BOOL FreeConsole();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attaches the calling process to the console of the specified process.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dwProcessId">The identifier of the process whose console is to be used.</param>
|
||||||
|
/// <returns>TRUE if the function succeeds; otherwise, FALSE.</returns>
|
||||||
|
[DllImport("kernel32.dll", ExactSpelling = true)]
|
||||||
|
public static extern BOOL AttachConsole(uint dwProcessId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a handle to the specified standard device.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nStdHandle">The standard device.</param>
|
||||||
|
/// <returns>A handle to the specified device.</returns>
|
||||||
|
[DllImport("kernel32.dll", ExactSpelling = true)]
|
||||||
|
public static extern nint GetStdHandle(int nStdHandle);
|
||||||
|
|
||||||
[DllImport(DllName, CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
|
[DllImport(DllName, CallingConvention = CallingConvention.Winapi, ExactSpelling = true)]
|
||||||
private static extern HRESULT HutaoCreateInstance(HutaoNative.Vftbl** ppv);
|
private static extern HRESULT HutaoCreateInstance(HutaoNative.Vftbl** ppv);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user