Compare commits

..

72 Commits

Author SHA1 Message Date
Masterain
b36399f572 Update .gitlab-ci.yml 2024-01-03 17:36:45 -08:00
DismissedLight
03d235876a Merge pull request #1260 from DGP-Studio/develop 2024-01-03 22:18:36 +08:00
DismissedLight
f49e9669af update version 2024-01-03 22:18:08 +08:00
DismissedLight
533c70caaa allow null package convert state 2024-01-03 21:53:13 +08:00
DismissedLight
dd59302bb3 fix bilibili server crash 2024-01-03 20:40:37 +08:00
DismissedLight
96e42f51f0 Merge pull request #1254 from DGP-Studio/develop 2024-01-03 20:02:54 +08:00
DismissedLight
5a19c19759 update version 2024-01-03 20:01:47 +08:00
DismissedLight
8fb831ef7c fix startup launch game card crash 2024-01-03 19:58:54 +08:00
Masterain
a30c8d8678 Update automation 2024-01-03 03:56:53 -08:00
DismissedLight
2655e835f8 1.9.2 package 2024-01-03 19:34:56 +08:00
qhy040404
ffd74703cd Update MGMT-publish.yml 2024-01-03 19:26:23 +08:00
DismissedLight
584465dc45 Merge pull request #1253 from DGP-Studio/develop 2024-01-03 19:14:57 +08:00
DismissedLight
a1e751160d Merge pull request #1235 from DGP-Studio/l10n_develop 2024-01-03 19:04:09 +08:00
DismissedLight
d78d2cf51a typo 2024-01-03 18:21:11 +08:00
Masterain
24709bfbf9 Update .gitlab-ci.yml 2024-01-03 01:48:36 -08:00
Lightczx
9be396b175 impl #1228 2024-01-03 17:09:51 +08:00
Lightczx
bb83e76d33 impl #1241 2024-01-03 16:17:27 +08:00
Lightczx
1ca24c8a78 remove unused using 2024-01-03 14:37:17 +08:00
DismissedLight
3d56aef221 Merge pull request #1250 from DGP-Studio/feat/1244 2024-01-03 14:26:35 +08:00
Lightczx
d43f2e76c4 code style 2024-01-03 14:26:21 +08:00
qhy040404
104fb9a3b0 finish up 2024-01-03 10:43:36 +08:00
qhy040404
d6b79584b6 streams need rework and resx 2024-01-02 23:21:11 +08:00
qhy040404
fcd0b65257 impl #1244 2024-01-02 21:15:24 +08:00
DismissedLight
802951edd7 impl #1055 2024-01-02 21:12:29 +08:00
DismissedLight
79fc42aa3b fix spinwait 2024-01-02 18:45:35 +08:00
Lightczx
fb0491dc57 get object form registry 2024-01-02 17:10:45 +08:00
Lightczx
b81d088379 rename script 2024-01-02 16:36:52 +08:00
qhy040404
553d267625 impl #1239 (#1246)
Co-authored-by: DismissedLight <1686188646@qq.com>
2024-01-02 14:26:21 +08:00
Lightczx
199e753103 use discord game sdk raw abi 2024-01-02 14:08:38 +08:00
qhy040404
48774960a7 Update GameRegistryContentTest.cs 2024-01-02 10:20:57 +08:00
DismissedLight
7bfea0e090 Create GameRegistryContentTest.cs 2024-01-01 23:21:38 +08:00
qhy040404
f0f9e387a8 direct to right doc 2024-01-01 19:35:01 +08:00
DismissedLight
f71a34a6be Merge pull request #1243 from DGP-Studio/fix/1208
fix #1208
2024-01-01 00:13:59 +08:00
DismissedLight
e6fd0b833b fix 1203 status deserialize 2023-12-31 23:59:55 +08:00
DismissedLight
d2c33cf19c optimize cache image placeholder presentation 2023-12-31 23:50:01 +08:00
qhy040404
59a7d6746f fix #1208 2023-12-31 23:36:29 +08:00
Masterain
1d074f5313 New translations sh.resx (Chinese Traditional) 2023-12-31 03:24:56 -08:00
Masterain
769a1c3812 New translations sh.resx (Russian) 2023-12-31 03:24:55 -08:00
Masterain
b54717fa9b New translations sh.resx (Japanese) 2023-12-31 03:24:54 -08:00
Masterain
ffa0b05a12 New translations sh.resx (Indonesian) 2023-12-30 02:47:33 -08:00
Masterain
d07a33f3e4 New translations sh.resx (English) 2023-12-29 02:30:33 -08:00
Lightczx
b49cd924d0 add source link 2023-12-29 13:51:27 +08:00
Lightczx
49db3003c9 fix launch game window 2023-12-29 11:56:44 +08:00
Lightczx
314c771020 clear selected game account after scheme changed 2023-12-29 11:39:01 +08:00
Lightczx
967f6f76f0 refuse convert for game in Program Files folder 2023-12-29 11:11:53 +08:00
Lightczx
5d05c31af5 fix startup crash 2023-12-29 10:05:03 +08:00
Masterain
bbd274c391 Update README.md (#1234)
* Update README.md

* Update README.md
2023-12-28 01:34:20 -08:00
Masterain
f8a8a929ac New translations sh.resx (Indonesian) 2023-12-28 01:21:13 -08:00
Masterain
cf3298dbd0 New translations sh.resx (English) 2023-12-28 01:21:12 -08:00
Masterain
a8b887def2 New translations sh.resx (Chinese Traditional) 2023-12-28 01:21:11 -08:00
Masterain
5a937b0838 New translations sh.resx (Russian) 2023-12-28 01:21:10 -08:00
Masterain
c016ae1cb8 New translations sh.resx (Korean) 2023-12-28 01:21:08 -08:00
Masterain
c7fdf8001d New translations sh.resx (Japanese) 2023-12-28 01:21:07 -08:00
Lightczx
64998453a1 Update LaunchGameViewModel.cs 2023-12-28 17:07:15 +08:00
Lightczx
9fdedd78d0 refactor Launch Game Pipeline 2023-12-28 17:06:45 +08:00
Lightczx
58e4d1b90e fix ci 2023-12-28 10:30:10 +08:00
Lightczx
e0d11bf9a0 impl #1199 2023-12-28 10:13:41 +08:00
Lightczx
51be2c76aa remove unused strings 2023-12-27 13:44:20 +08:00
DismissedLight
686d2378de Merge pull request #1232 from DGP-Studio/feat/elevate_restart 2023-12-27 13:34:48 +08:00
DismissedLight
b11526761e Merge pull request #1214 from DGP-Studio/develop 2023-12-23 19:19:45 +08:00
DismissedLight
6ee823094a Merge pull request #1207 from DGP-Studio/develop 2023-12-23 11:51:15 +08:00
qhy040404
14894b0b47 Update issue templates 2023-12-22 16:59:22 +08:00
Masterain
6834073603 Merge pull request #1204 from DGP-Studio/issue_template_publish
Update MGMT-publish.yml
2023-12-21 17:41:22 -08:00
qhy040404
911fe57fb2 check jihulab 2023-12-22 09:29:47 +08:00
qhy040404
7320cf7dd0 owner 2023-12-22 09:21:52 +08:00
qhy040404
bc6d03e442 Update MGMT-publish.yml 2023-12-22 09:20:30 +08:00
Masterain
307a49b346 Merge pull request #1191 from DGP-Studio/Masterain98-patch-2
Update .gitlab-ci.yml
2023-12-19 18:08:36 -08:00
Masterain
9f8d80ff43 Update .gitlab-ci.yml 2023-12-19 13:48:49 -08:00
Masterain
d34130b6c0 Update .gitlab-ci.yml 2023-12-17 02:44:15 -08:00
Masterain
2161f12069 Merge pull request #1187 from DGP-Studio/Masterain98-patch-1
Create .gitlab-ci.yml
2023-12-16 22:34:56 -08:00
Masterain
0bedd1894c Create .gitlab-ci.yml 2023-12-16 22:33:43 -08:00
DismissedLight
442db0bae4 Merge pull request #1184 from DGP-Studio/develop 2023-12-16 15:07:24 +08:00
87 changed files with 1923 additions and 1098 deletions

View File

@@ -22,7 +22,7 @@ body:
- label: 我知道文档站的导航栏中有**搜索功能**,且已经搜索过相关关键词
required: true
- label: 我的问题不是[已修复](https://github.com/DGP-Studio/Snap.Hutao/issues?q=is%3Aopen+is%3Aissue+label%3A%E5%B7%B2%E4%BF%AE%E5%A4%8D)的问题也不是一个别人已发布的**重复的**问题
- label: 我的问题不是[已完成](https://github.com/DGP-Studio/Snap.Hutao/issues?q=is%3Aopen+is%3Aissue+label%3A%E5%B7%B2%E5%AE%8C%E6%88%90)的问题也不是一个别人已发布的**重复的**问题
required: true
- type: input
@@ -51,7 +51,7 @@ body:
description: |
在胡桃工具箱的设置界面,你可以找到并复制你的设备 ID
如果你的问题涉及程序崩溃,请填写该项,这将有助于我们定位问题
如果你的程序已经无法启动,请下载并运行[此PowerShell脚本](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/get_device_id/GetHutaoDeviceId.ps1),它将显示你的设备 ID
如果你的程序已经无法启动,请下载并运行[此工具](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.DiagTools.exe),它将显示你的设备 ID
validations:
required: false
@@ -87,7 +87,7 @@ body:
label: 发生了什么?
description: |
详细的描述问题发生前后的行为,以便我们解决问题。**如果你的问题涉及程序崩溃,你应当检查 Windows 事件查看器,并将相关的 `.Net 错误`详情附上**
如果你无法找到该日志,请下载并运行[此PowerShell脚本](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/dump_log_script/dump_log_zh.ps1),它将输出错误日志
如果你无法找到该日志,请下载并运行[此工具](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.DiagTools.exe),它将转储问题日志至工具运行目录中的 `Snap.Hutao Error Log.txt`
validations:
required: true

View File

@@ -22,7 +22,7 @@ body:
- label: I and tried **search feature** in Snap Hutao document site, and no associated article
required: true
- label: My issue is not a [fixed issue](https://github.com/DGP-Studio/Snap.Hutao/issues?q=is%3Aopen+is%3Aissue+label%3A%E5%B7%B2%E4%BF%AE%E5%A4%8D), and it's not a duplicated issue
- label: My issue is not a [finished issue](https://github.com/DGP-Studio/Snap.Hutao/issues?q=is%3Aopen+is%3Aissue+label%3A%E5%B7%B2%E5%AE%8C%E6%88%90), and it's not a duplicated issue
required: true
- type: input
@@ -51,7 +51,7 @@ body:
description: |
In Snap Hutao's settings page, you can find and copy your device ID
If your issue is about program crash, please fill this so we can dump the log and locate the source easier
If your program cannot startup, please download and run [this PowerShell script](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/get_device_id/GetHutaoDeviceId.ps1), it will shows your device ID.
If your program cannot startup, please download and run [this tool](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.DiagTools.exe), it will shows your device ID.
validations:
required: false
@@ -87,7 +87,7 @@ body:
label: What Happened?
description: |
Describe your issue in detail to help us identify the issue. **If your issue is about program crash, you should check Windows Event Viewer, and attach associated `.Net Error` details here**If your program cannot startup, please download and run [this PowerShell script](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/get_device_id/GetHutaoDeviceId.ps1), it will shows your device ID.
If you cannot find it, please download and run [this PowerShell script](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/dump_log_script/dump_log_en.ps1), it will dump the error log.
If you cannot find it, please download and run [this tool](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.DiagTools.exe), it will dump the error log to `Snap.Hutao Error Log.txt` in the working directory of the tool.
validations:
required: true

View File

@@ -12,50 +12,26 @@ body:
value: |
## 创建版本
- [ ] 同步一次 [Crowdin](https://crowdin.com/project/snap-hutao) 翻译
- [ ] 发布 RC 版本Optional
- [ ] 合并入主分支
- [ ] 整理更新内容,等待翻译
- [ ] 打包
- [ ] 提交微软商店
- [ ] 包含更新日志
- [ ] 在 [Snap.Hutao.Docs@next-patch](https://github.com/DGP-Studio/Snap.Hutao.Docs/tree/next-patch) 分支更新文档并直接开 PR
- [ ] 更新日志
- [ ] 功能文档更新
## 发布版本 [半自动]
- [ ] 在 GitHub 个人设置中更新 [Publish-Automate Beta PAT](https://github.com/settings/tokens?type=beta),有效期需小于预计发版需要天数
- [ ] 将更新的 PAT 更新至 Publish-Automate 库的 [Actions Secrets](https://github.com/DGP-Studio/Publish-Automate/settings/secrets/actions) 中
***
***
- [ ] 运行 [Auto Publish Action](https://github.com/DGP-Studio/Publish-Automate/actions/workflows/auto-publish.yml)
- [ ] 在 https://store.rg-adguard.net/ 下载新版本安装包
- [ ] Store URL: https://apps.microsoft.com/store/detail/snap-hutao/9PH4NXJ2JN52
- [ ] 命名格式为 `Snap.Hutao x.x.x.msix`
- [ ] Merge 文档 PR
- [ ] 发布 Release
- [ ] 更新日志格式(以 1.6.2 版本为例)
```jsx
## Update log
https://hut.ao/en/statements/update-log.html#_1-6-2
## 更新日志
[此处从文档复制]
## What's Changed
**Full Changelog**: https://github.com/DGP-Studio/Snap.Hutao/compare/1.6.0...1.6.2
```
- [ ] 通知用户
- [ ] 主分支合并入 release 分支
- [ ] 等待 Release 自动发布
- [ ] 检查极狐是否同步完成 Release
- [ ] 通知用户
- type: checkboxes
id: checklist-final
attributes:
label: Final Check
description: Understand what you are doing
description: Understand what you are doing
options:
- label: I understand that I will get banned from repository if I don't have permission to use this template
required: true
required: true

View File

@@ -12,6 +12,7 @@ on:
- '.gitmodules'
- '**.md'
- 'LICENSE'
- '**.yml'
jobs:
build:

76
.gitlab-ci.yml Normal file
View File

@@ -0,0 +1,76 @@
stages:
- fetch
- release
- refresh
Fetch:
stage: fetch
rules:
- if: $CI_COMMIT_TAG
tags:
- us3
script:
- apt-get update -qy
- apt-get install -y curl jq
- RELEASE_INFO=$(curl -sSL "https://api.github.com/repos/$CI_PROJECT_PATH/releases/latest")
- ASSET_URL=$(echo "$RELEASE_INFO" | jq -r '.assets[] | select(.name | endswith(".msix")) | .browser_download_url')
- SHA256SUMS_URL=$(echo "$RELEASE_INFO" | jq -r '.assets[] | select(.name == "SHA256SUMS") | .browser_download_url')
- curl -LJO "$ASSET_URL"
- curl -LJO "$SHA256SUMS_URL"
- FILE_NAME=$(basename "$ASSET_URL")
- SHA256SUMS_NAME=$(basename "$SHA256SUMS_URL")
- echo "File name at script stage is $FILE_NAME"
- echo "SHA256SUMS name at script stage is $SHA256SUMS_NAME"
- echo "THIS_FILE_NAME=$FILE_NAME" >> next.env
- echo "THIS_SHA256SUMS_NAME=$SHA256SUMS_NAME" >> next.env
after_script:
- echo "Current Job ID is $CI_JOB_ID"
- echo "THIS_JOB_ID=$CI_JOB_ID" >> next.env
artifacts:
paths:
- "*.msix"
- "SHA256SUMS"
expire_in: 180 days
reports:
dotenv: next.env
release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
rules:
- if: $CI_COMMIT_TAG
needs:
- job: Fetch
artifacts: true
variables:
TAG: '$CI_COMMIT_TAG'
script:
- echo "Create Release $TAG"
- echo "$THIS_JOB_ID"
- echo "$THIS_FILE_NAME"
release:
name: '$TAG'
tag_name: '$TAG'
ref: '$TAG'
description: 'Release $TAG by CI'
assets:
links:
- name: "$THIS_FILE_NAME"
url: "https://$CI_SERVER_SHELL_SSH_HOST/$CI_PROJECT_PATH/-/jobs/$THIS_JOB_ID/artifacts/raw/$THIS_FILE_NAME?inline=false"
link_type: package
- name: "$THIS_SHA256SUMS_NAME"
url: "https://$CI_SERVER_SHELL_SSH_HOST/$CI_PROJECT_PATH/-/jobs/$THIS_JOB_ID/artifacts/raw/$THIS_SHA256SUMS_NAME?inline=false"
link_type: other
- name: "artifact_archive"
url: "https://$CI_SERVER_SHELL_SSH_HOST/$CI_PROJECT_PATH/-/jobs/$THIS_JOB_ID/artifacts/download?file_type=archive"
link_type: other
Refresh:
stage: refresh
rules:
- if: $CI_COMMIT_TAG
needs:
- job: release
script:
- apt-get install -y curl
- curl -X PATCH "$PURGE_URL"

View File

@@ -1,4 +1,4 @@
![Banner2-large](https://github.com/DGP-Studio/Snap.Hutao/assets/10614984/742129f4-f903-4d16-bf1e-3cbfad873ee4)
![HutaoRepoBanner2-20231222](https://github.com/DGP-Studio/Snap.Hutao/assets/10614984/2d178de1-95bc-44a1-a95e-20c5f11a8628)
胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。通过将既有的官方资源与开发团队设计的全新 功能相结合,它提供了一套完整且实用的工具集,且无需依赖任何移动设备。它不对游戏客户端进行任何破坏性修改以确保工具箱的安全性
@@ -7,7 +7,29 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
## 下载使用 / Download
[<img src="https://get.microsoft.com/images/zh-cn%20light.svg" width="30%" height="30%">](https://apps.microsoft.com/store/detail/snap-hutao/9PH4NXJ2JN52)
![](https://ci.appveyor.com/api/projects/status/n4s40t9llru4si9y?svg=true) [![GitHub Release](https://img.shields.io/github/release/DGP-Studio/Snap.Hutao?style=flat)](https://github.com/DGP-Studio/Snap.Hutao/releases/latest) [![Github All Releases](https://img.shields.io/github/downloads/DGP-Studio/Snap.Hutao/total.svg?style=flat)]()
---
#### 使用安装器安装 / Install with Snap.Hutao.Depolyment Installer
Snap.Hutao.Depolyment 是一个由 DGP-Studio 重新包装的 Windows 应用安装器,适用于缺少专业计算机知识的一般用户,可以在安装时同时解决缺少必要系统环境的问题。
Snap.Hutao.Depolyment is a Windows application installer repackaged by DGP-Studio for the users who lacks computer knowledge and can solve the problem of missing necessary system environment at the same time as the installation.
[从 GitHub 发布页获取 / Download from GitHub release](https://github.com/DGP-Studio/Snap.Hutao.Deployment/releases/latest)
[从极狐Lab 发布页获取 / Download from Jihu Gitlab release](https://jihulab.com/DGP-Studio/Snap.Hutao.Deployment/-/releases)
#### 使用 MSIX 包安装 / Install with MSIX Package
直接使用 Snap Hutao MSIX 安装包,使用 Windows 内置的 App Installer 即可安装。如在安装中出现问题,请查阅我们的[常见问题](https://hut.ao/zh/advanced/FAQ.html)文档
Install with Snap Hutao MSIX package, can be installed with Windows built-in App Installer. If you faced any issue, please check our [FAQ](https://hut.ao/en/advanced/FAQ.html) document.
[从 GitHub 发布页获取 / Download from GitHub release](https://github.com/DGP-Studio/Snap.Hutao/releases/latest)
[从极狐Lab 发布页获取 / Download from Jihu Gitlab release](https://jihulab.com/DGP-Studio/Snap.Hutao/-/releases)
## 贡献 / Contribute

View File

@@ -0,0 +1,67 @@
using Microsoft.Win32;
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.Json;
namespace Snap.Hutao.Test.IncomingFeature;
[TestClass]
public class GameRegistryContentTest
{
private static readonly JsonSerializerOptions RegistryContentSerializerOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
[TestMethod]
[SupportedOSPlatform("windows")]
public void GetRegistryContent()
{
GetRegistryContentCore(@"Software\miHoYo\原神");
GetRegistryContentCore(@"Software\miHoYo\Genshin Impact");
}
[SupportedOSPlatform("windows")]
private static void GetRegistryContentCore(string subkey)
{
using (RegistryKey key = RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64))
{
RegistryKey? gameKey = key.OpenSubKey(subkey);
Assert.IsNotNull(gameKey);
Dictionary<string, object> data = [];
foreach (string valueName in gameKey.GetValueNames())
{
data[valueName] = gameKey.GetValueKind(valueName) switch
{
RegistryValueKind.DWord => (int)gameKey.GetValue(valueName)!,
RegistryValueKind.Binary => GetStringOrObject((byte[])gameKey.GetValue(valueName)!),
_ => throw new NotImplementedException()
};
}
Console.WriteLine($"Subkey: {subkey}");
Console.WriteLine(JsonSerializer.Serialize(data, RegistryContentSerializerOptions));
}
}
private static unsafe object GetStringOrObject(byte[] bytes)
{
fixed (byte* pByte = bytes)
{
ReadOnlySpan<byte> span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pByte);
string temp = Encoding.UTF8.GetString(span);
if (temp.AsSpan()[0] is '{' or '[')
{
return JsonSerializer.Deserialize<JsonElement>(temp);
}
return temp;
}
}
}

View File

@@ -69,12 +69,12 @@ public sealed partial class App : Application
if (firstInstance.IsCurrent)
{
logger.LogInformation(ConsoleBanner);
LogDiagnosticInformation();
// manually invoke
activation.NonRedirectToActivate(firstInstance, activatedEventArgs);
activation.InitializeWith(firstInstance);
LogDiagnosticInformation();
serviceProvider.GetRequiredService<IJumpListInterop>().ConfigureAsync().SafeForget();
}
else

View File

@@ -40,12 +40,7 @@ internal sealed class CachedImage : Implementation.ImageEx
{
// The image is corrupted, remove it.
imageCache.Remove(imageUri);
return null;
}
catch (OperationCanceledException)
{
// task was explicitly canceled
return null;
return default;
}
}
}

View File

@@ -17,6 +17,7 @@
CornerRadius="{TemplateBinding CornerRadius}">
<Image
Name="PlaceholderImage"
Margin="{TemplateBinding PlaceholderMargin}"
HorizontalAlignment="{TemplateBinding HorizontalAlignment}"
VerticalAlignment="{TemplateBinding VerticalAlignment}"
Source="{TemplateBinding PlaceholderSource}"

View File

@@ -11,10 +11,6 @@ using Windows.Foundation;
namespace Snap.Hutao.Control.Image.Implementation;
internal delegate void ImageExFailedEventHandler(object sender, ImageExFailedEventArgs e);
internal delegate void ImageExOpenedEventHandler(object sender, ImageExOpenedEventArgs e);
[SuppressMessage("", "CA1001")]
[SuppressMessage("", "SH003")]
[TemplateVisualState(Name = LoadingState, GroupName = CommonGroup)]
@@ -22,98 +18,34 @@ internal delegate void ImageExOpenedEventHandler(object sender, ImageExOpenedEve
[TemplateVisualState(Name = UnloadedState, GroupName = CommonGroup)]
[TemplateVisualState(Name = FailedState, GroupName = CommonGroup)]
[TemplatePart(Name = PartImage, Type = typeof(object))]
internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlphaMaskProvider
[TemplatePart(Name = PartPlaceholderImage, Type = typeof(object))]
[DependencyProperty("Stretch", typeof(Stretch), Stretch.Uniform)]
[DependencyProperty("DecodePixelHeight", typeof(int), 0)]
[DependencyProperty("DecodePixelWidth", typeof(int), 0)]
[DependencyProperty("DecodePixelType", typeof(DecodePixelType), DecodePixelType.Physical)]
[DependencyProperty("IsCacheEnabled", typeof(bool), false)]
[DependencyProperty("EnableLazyLoading", typeof(bool), false, nameof(EnableLazyLoadingChanged))]
[DependencyProperty("LazyLoadingThreshold", typeof(double), default(double), nameof(LazyLoadingThresholdChanged))]
[DependencyProperty("PlaceholderSource", typeof(object), default(object))]
[DependencyProperty("PlaceholderStretch", typeof(Stretch), Stretch.Uniform)]
[DependencyProperty("PlaceholderMargin", typeof(Thickness))]
[DependencyProperty("Source", typeof(object), default(object), nameof(SourceChanged))]
internal abstract partial class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlphaMaskProvider
{
protected const string PartImage = "Image";
protected const string PartPlaceholderImage = "PlaceholderImage";
protected const string CommonGroup = "CommonStates";
protected const string LoadingState = "Loading";
protected const string LoadedState = "Loaded";
protected const string UnloadedState = "Unloaded";
protected const string FailedState = "Failed";
private static readonly DependencyProperty StretchProperty = DependencyProperty.Register(nameof(Stretch), typeof(Stretch), typeof(ImageExBase), new PropertyMetadata(Stretch.Uniform));
private static readonly DependencyProperty DecodePixelHeightProperty = DependencyProperty.Register(nameof(DecodePixelHeight), typeof(int), typeof(ImageExBase), new PropertyMetadata(0));
private static readonly DependencyProperty DecodePixelTypeProperty = DependencyProperty.Register(nameof(DecodePixelType), typeof(int), typeof(ImageExBase), new PropertyMetadata(DecodePixelType.Physical));
private static readonly DependencyProperty DecodePixelWidthProperty = DependencyProperty.Register(nameof(DecodePixelWidth), typeof(int), typeof(ImageExBase), new PropertyMetadata(0));
private static readonly DependencyProperty IsCacheEnabledProperty = DependencyProperty.Register(nameof(IsCacheEnabled), typeof(bool), typeof(ImageExBase), new PropertyMetadata(false));
private static readonly DependencyProperty EnableLazyLoadingProperty = DependencyProperty.Register(nameof(EnableLazyLoading), typeof(bool), typeof(ImageExBase), new PropertyMetadata(false, EnableLazyLoadingChanged));
private static readonly DependencyProperty LazyLoadingThresholdProperty = DependencyProperty.Register(nameof(LazyLoadingThreshold), typeof(double), typeof(ImageExBase), new PropertyMetadata(default(double), LazyLoadingThresholdChanged));
private static readonly DependencyProperty PlaceholderSourceProperty = DependencyProperty.Register(nameof(PlaceholderSource), typeof(ImageSource), typeof(ImageExBase), new PropertyMetadata(default(ImageSource), PlaceholderSourceChanged));
private static readonly DependencyProperty PlaceholderStretchProperty = DependencyProperty.Register(nameof(PlaceholderStretch), typeof(Stretch), typeof(ImageExBase), new PropertyMetadata(default(Stretch)));
private static readonly DependencyProperty SourceProperty = DependencyProperty.Register(nameof(Source), typeof(object), typeof(ImageExBase), new PropertyMetadata(null, SourceChanged));
private CancellationTokenSource? tokenSource;
private object? lazyLoadingSource;
private bool isInViewport;
public event ImageExFailedEventHandler? ImageExFailed;
public event ImageExOpenedEventHandler? ImageExOpened;
public event EventHandler? ImageExInitialized;
public bool IsInitialized { get; private set; }
public int DecodePixelHeight
{
get => (int)GetValue(DecodePixelHeightProperty);
set => SetValue(DecodePixelHeightProperty, value);
}
public DecodePixelType DecodePixelType
{
get => (DecodePixelType)GetValue(DecodePixelTypeProperty);
set => SetValue(DecodePixelTypeProperty, value);
}
public int DecodePixelWidth
{
get => (int)GetValue(DecodePixelWidthProperty);
set => SetValue(DecodePixelWidthProperty, value);
}
public Stretch Stretch
{
get => (Stretch)GetValue(StretchProperty);
set => SetValue(StretchProperty, value);
}
public bool IsCacheEnabled
{
get => (bool)GetValue(IsCacheEnabledProperty);
set => SetValue(IsCacheEnabledProperty, value);
}
public bool EnableLazyLoading
{
get => (bool)GetValue(EnableLazyLoadingProperty);
set => SetValue(EnableLazyLoadingProperty, value);
}
public double LazyLoadingThreshold
{
get => (double)GetValue(LazyLoadingThresholdProperty);
set => SetValue(LazyLoadingThresholdProperty, value);
}
public ImageSource PlaceholderSource
{
get => (ImageSource)GetValue(PlaceholderSourceProperty);
set => SetValue(PlaceholderSourceProperty, value);
}
public Stretch PlaceholderStretch
{
get => (Stretch)GetValue(PlaceholderStretchProperty);
set => SetValue(PlaceholderStretchProperty, value);
}
public object Source
{
get => GetValue(SourceProperty);
set => SetValue(SourceProperty, value);
}
public bool WaitUntilLoaded
{
get => true;
@@ -121,11 +53,9 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
protected object? Image { get; private set; }
public abstract CompositionBrush GetAlphaMask();
protected object? PlaceholderImage { get; private set; }
protected virtual void OnPlaceholderSourceChanged(DependencyPropertyChangedEventArgs e)
{
}
public abstract CompositionBrush GetAlphaMask();
protected virtual Task<ImageSource?> ProvideCachedResourceAsync(Uri imageUri, CancellationToken token)
{
@@ -136,61 +66,11 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
protected virtual void OnImageOpened(object sender, RoutedEventArgs e)
{
VisualStateManager.GoToState(this, LoadedState, true);
ImageExOpened?.Invoke(this, new ImageExOpenedEventArgs());
}
protected virtual void OnImageFailed(object sender, ExceptionRoutedEventArgs e)
{
VisualStateManager.GoToState(this, FailedState, true);
ImageExFailed?.Invoke(this, new ImageExFailedEventArgs(new FileNotFoundException(e.ErrorMessage)));
}
protected void AttachImageOpened(RoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageOpened += handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageOpened += handler;
}
}
protected void RemoveImageOpened(RoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageOpened -= handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageOpened -= handler;
}
}
protected void AttachImageFailed(ExceptionRoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageFailed += handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageFailed += handler;
}
}
protected void RemoveImageFailed(ExceptionRoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageFailed -= handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageFailed -= handler;
}
}
protected override void OnApplyTemplate()
@@ -199,11 +79,10 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
RemoveImageFailed(OnImageFailed);
Image = GetTemplateChild(PartImage);
PlaceholderImage = GetTemplateChild(PartPlaceholderImage);
IsInitialized = true;
ImageExInitialized?.Invoke(this, EventArgs.Empty);
if (Source is null || !EnableLazyLoading || isInViewport)
{
lazyLoadingSource = null;
@@ -218,23 +97,73 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
AttachImageFailed(OnImageFailed);
base.OnApplyTemplate();
void AttachImageOpened(RoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageOpened += handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageOpened += handler;
}
}
void AttachImageFailed(ExceptionRoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageFailed += handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageFailed += handler;
}
}
void RemoveImageOpened(RoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageOpened -= handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageOpened -= handler;
}
}
void RemoveImageFailed(ExceptionRoutedEventHandler handler)
{
if (Image is Microsoft.UI.Xaml.Controls.Image image)
{
image.ImageFailed -= handler;
}
else if (Image is ImageBrush brush)
{
brush.ImageFailed -= handler;
}
}
}
private static void EnableLazyLoadingChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ImageExBase control)
if (d is not ImageExBase control)
{
bool value = (bool)e.NewValue;
if (value)
{
control.LayoutUpdated += control.ImageExBase_LayoutUpdated;
return;
}
control.InvalidateLazyLoading();
}
else
{
control.LayoutUpdated -= control.ImageExBase_LayoutUpdated;
}
bool value = (bool)e.NewValue;
if (value)
{
control.LayoutUpdated += control.OnImageExBaseLayoutUpdated;
control.InvalidateLazyLoading();
}
else
{
control.LayoutUpdated -= control.OnImageExBaseLayoutUpdated;
}
}
@@ -246,14 +175,6 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
}
}
private static void PlaceholderSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ImageExBase control)
{
control.OnPlaceholderSourceChanged(e);
}
}
private static void SourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not ImageExBase control)
@@ -261,17 +182,19 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
return;
}
if (e.OldValue is null || e.NewValue is null || !e.OldValue.Equals(e.NewValue))
if (e.OldValue is not null && e.NewValue is not null && e.OldValue.Equals(e.NewValue))
{
if (e.NewValue is null || !control.EnableLazyLoading || control.isInViewport)
{
control.lazyLoadingSource = null;
control.SetSource(e.NewValue);
}
else
{
control.lazyLoadingSource = e.NewValue;
}
return;
}
if (e.NewValue is null || !control.EnableLazyLoading || control.isInViewport)
{
control.lazyLoadingSource = null;
control.SetSource(e.NewValue);
}
else
{
control.lazyLoadingSource = e.NewValue;
}
}
@@ -301,11 +224,24 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
else if (source is BitmapSource { PixelHeight: > 0, PixelWidth: > 0 })
{
VisualStateManager.GoToState(this, LoadedState, true);
ImageExOpened?.Invoke(this, new ImageExOpenedEventArgs());
}
}
[SuppressMessage("", "IDE0019")]
private void AttachPlaceholderSource(ImageSource? source)
{
// Setting the source at this point should call ImageExOpened/VisualStateManager.GoToState
// as we register to both the ImageOpened/ImageFailed events of the underlying control.
// We only need to call those methods if we fail in other cases before we get here.
if (PlaceholderImage is Microsoft.UI.Xaml.Controls.Image image)
{
image.Source = source;
}
else if (PlaceholderImage is ImageBrush brush)
{
brush.ImageSource = source;
}
}
private async void SetSource(object? source)
{
if (!IsInitialized)
@@ -326,22 +262,19 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
VisualStateManager.GoToState(this, LoadingState, true);
ImageSource? imageSource = source as ImageSource;
if (imageSource is not null)
if (source as ImageSource is { } imageSource)
{
AttachSource(imageSource);
return;
}
Uri? uri = source as Uri;
if (uri is null)
if (source as Uri is not { } uri)
{
string? url = source as string ?? source.ToString();
if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out uri))
{
VisualStateManager.GoToState(this, FailedState, true);
ImageExFailed?.Invoke(this, new ImageExFailedEventArgs(new UriFormatException("Invalid uri specified.")));
return;
}
}
@@ -355,61 +288,131 @@ internal abstract class ImageExBase : Microsoft.UI.Xaml.Controls.Control, IAlpha
{
await LoadImageAsync(uri, tokenSource.Token).ConfigureAwait(true);
}
catch (Exception ex)
{
SetPlaceholderSource(PlaceholderSource);
if (ex is OperationCanceledException)
{
// nothing to do as cancellation has been requested.
}
else
{
VisualStateManager.GoToState(this, FailedState, true);
}
}
}
private async void SetPlaceholderSource(object? source)
{
if (!IsInitialized)
{
return;
}
tokenSource?.Cancel();
tokenSource = new CancellationTokenSource();
AttachPlaceholderSource(null);
if (source is null)
{
return;
}
if (source as ImageSource is { } imageSource)
{
AttachPlaceholderSource(imageSource);
return;
}
if (source as Uri is not { } uri)
{
string? url = source as string ?? source.ToString();
if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out uri))
{
return;
}
}
if (!IsHttpUri(uri) && !uri.IsAbsoluteUri)
{
uri = new Uri("ms-appx:///" + uri.OriginalString.TrimStart('/'));
}
try
{
if (uri is null)
{
return;
}
ImageSource? img = await ProvideCachedResourceAsync(uri, tokenSource.Token).ConfigureAwait(true);
ArgumentNullException.ThrowIfNull(tokenSource);
if (!tokenSource.IsCancellationRequested)
{
// Only attach our image if we still have a valid request.
AttachPlaceholderSource(img);
}
}
catch (OperationCanceledException)
{
// nothing to do as cancellation has been requested.
}
catch (Exception e)
catch
{
VisualStateManager.GoToState(this, FailedState, true);
ImageExFailed?.Invoke(this, new ImageExFailedEventArgs(e));
}
}
private async Task LoadImageAsync(Uri imageUri, CancellationToken token)
{
if (imageUri is not null)
if (imageUri is null)
{
if (IsCacheEnabled)
return;
}
if (IsCacheEnabled)
{
ImageSource? img = await ProvideCachedResourceAsync(imageUri, token).ConfigureAwait(true);
ArgumentNullException.ThrowIfNull(tokenSource);
if (!tokenSource.IsCancellationRequested)
{
ImageSource? img = await ProvideCachedResourceAsync(imageUri, token).ConfigureAwait(true);
// Only attach our image if we still have a valid request.
AttachSource(img);
}
}
else if (string.Equals(imageUri.Scheme, "data", StringComparison.OrdinalIgnoreCase))
{
string source = imageUri.OriginalString;
const string base64Head = "base64,";
int index = source.IndexOf(base64Head, StringComparison.Ordinal);
if (index >= 0)
{
byte[] bytes = Convert.FromBase64String(source[(index + base64Head.Length)..]);
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(new MemoryStream(bytes).AsRandomAccessStream());
ArgumentNullException.ThrowIfNull(tokenSource);
if (!tokenSource.IsCancellationRequested)
{
// Only attach our image if we still have a valid request.
AttachSource(img);
AttachSource(bitmap);
}
}
else if (string.Equals(imageUri.Scheme, "data", StringComparison.OrdinalIgnoreCase))
}
else
{
AttachSource(new BitmapImage(imageUri)
{
string source = imageUri.OriginalString;
const string base64Head = "base64,";
int index = source.IndexOf(base64Head, StringComparison.Ordinal);
if (index >= 0)
{
byte[] bytes = Convert.FromBase64String(source[(index + base64Head.Length)..]);
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(new MemoryStream(bytes).AsRandomAccessStream());
ArgumentNullException.ThrowIfNull(tokenSource);
if (!tokenSource.IsCancellationRequested)
{
AttachSource(bitmap);
}
}
}
else
{
AttachSource(new BitmapImage(imageUri)
{
CreateOptions = BitmapCreateOptions.IgnoreImageCache,
});
}
CreateOptions = BitmapCreateOptions.IgnoreImageCache,
});
}
}
private void ImageExBase_LayoutUpdated(object? sender, object e)
private void OnImageExBaseLayoutUpdated(object? sender, object e)
{
InvalidateLazyLoading();
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Control.Image.Implementation;
internal sealed class ImageExFailedEventArgs : EventArgs
{
public ImageExFailedEventArgs(Exception errorException)
{
ErrorMessage = ErrorException?.Message;
ErrorException = errorException;
}
public Exception? ErrorException { get; private set; }
public string? ErrorMessage { get; private set; }
}

View File

@@ -1,8 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Control.Image.Implementation;
internal sealed class ImageExOpenedEventArgs : EventArgs
{
}

View File

@@ -29,6 +29,7 @@
<x:String x:Key="UI_EmotionIcon25">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon25.png</x:String>
<x:String x:Key="UI_EmotionIcon71">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon71.png</x:String>
<x:String x:Key="UI_EmotionIcon250">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon250.png</x:String>
<x:String x:Key="UI_EmotionIcon271">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon271.png</x:String>
<x:String x:Key="UI_EmotionIcon272">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon272.png</x:String>
<x:String x:Key="UI_EmotionIcon293">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon293.png</x:String>
<x:String x:Key="UI_EmotionIcon445">https://api.snapgenshin.com/static/raw/EmotionIcon/UI_EmotionIcon445.png</x:String>

View File

@@ -18,11 +18,11 @@ internal sealed class CommandLineBuilder
/// <summary>
/// 当符合条件时添加参数
/// </summary>
/// <param name="name">参数名称</param>
/// <param name="condition">条件</param>
/// <param name="name">参数名称</param>
/// <param name="value">值</param>
/// <returns>命令行建造器</returns>
public CommandLineBuilder AppendIf(string name, bool condition, object? value = null)
public CommandLineBuilder AppendIf(bool condition, string name, object? value = null)
{
return condition ? Append(name, value) : this;
}
@@ -35,7 +35,7 @@ internal sealed class CommandLineBuilder
/// <returns>命令行建造器</returns>
public CommandLineBuilder AppendIfNotNull(string name, object? value = null)
{
return AppendIf(name, value is not null, value);
return AppendIf(value is not null, name, value);
}
/// <summary>

View File

@@ -0,0 +1,23 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.Diagnostics.CodeAnalysis;
/// <summary>
/// 指示此特性附加的属性会在属性改变后会异步地设置的其他属性
/// </summary>
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
internal sealed class AlsoAsyncSetsAttribute : Attribute
{
public AlsoAsyncSetsAttribute(string propertyName)
{
}
public AlsoAsyncSetsAttribute(string propertyName1, string propertyName2)
{
}
public AlsoAsyncSetsAttribute(params string[] propertyNames)
{
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.Diagnostics.CodeAnalysis;
/// <summary>
/// 指示此特性附加的属性会在属性改变后会设置的其他属性
/// </summary>
[AttributeUsage(AttributeTargets.Property, Inherited = false)]
internal sealed class AlsoSetsAttribute : Attribute
{
public AlsoSetsAttribute(string propertyName)
{
}
public AlsoSetsAttribute(string propertyName1, string propertyName2)
{
}
public AlsoSetsAttribute(params string[] propertyNames)
{
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.ExceptionService;
internal sealed class HutaoException : Exception
{
public HutaoException(HutaoExceptionKind kind, string message, Exception? innerException)
: this(message, innerException)
{
Kind = kind;
}
public HutaoException(string message, Exception? innerException)
: base($"{message}\n{innerException?.Message}", innerException)
{
}
public HutaoExceptionKind Kind { get; private set; }
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.ExceptionService;
internal enum HutaoExceptionKind
{
None,
}

View File

@@ -49,6 +49,13 @@ internal static class ThrowHelper
throw new NotSupportedException();
}
[DoesNotReturn]
[MethodImpl(MethodImplOptions.NoInlining)]
public static NotSupportedException NotSupported(string message)
{
throw new NotSupportedException(message);
}
[DoesNotReturn]
[MethodImpl(MethodImplOptions.NoInlining)]
public static OperationCanceledException OperationCanceled(string message, Exception? inner = default)

View File

@@ -13,4 +13,9 @@ internal static class RuntimeOptionsExtension
Directory.CreateDirectory(directory);
return Path.Combine(directory, fileName);
}
public static string GetDataFolderServerCacheFolder(this RuntimeOptions options)
{
return Path.Combine(options.DataFolder, "ServerCache");
}
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Core.Threading;
internal delegate bool SpinWaitPredicate<T>(ref readonly T state);
internal static class SpinWaitPolyfill
{
public static unsafe void SpinUntil<T>(ref T state, delegate*<ref readonly T, bool> condition)
{
SpinWait spinner = default;
while (!condition(ref state))
{
spinner.SpinOnce();
}
}
}

View File

@@ -34,7 +34,7 @@
<ListView
Grid.Row="1"
ItemsSource="{Binding GameAccounts}"
ItemsSource="{Binding GameAccountsView}"
SelectedItem="{Binding SelectedGameAccount, Mode=TwoWay}">
<ListView.ItemTemplate>
<DataTemplate>

View File

@@ -14,7 +14,7 @@ namespace Snap.Hutao.Model.Entity;
/// </summary>
[HighQuality]
[Table("game_accounts")]
internal sealed class GameAccount : ObservableObject, IMappingFrom<GameAccount, string, string>
internal sealed class GameAccount : ObservableObject, IMappingFrom<GameAccount, string, string, SchemeType>
{
/// <summary>
/// 内部Id
@@ -40,21 +40,17 @@ internal sealed class GameAccount : ObservableObject, IMappingFrom<GameAccount,
/// <summary>
/// [MIHOYOSDK_ADL_PROD_CN_h3123967166]
/// [MIHOYOSDK_ADL_PROD_OVERSEA_h1158948810]
/// </summary>
public string MihoyoSDK { get; set; } = default!;
/// <summary>
/// 构造一个新的游戏内账号
/// </summary>
/// <param name="name">名称</param>
/// <param name="sdk">sdk</param>
/// <returns>游戏内账号</returns>
public static GameAccount From(string name, string sdk)
public static GameAccount From(string name, string sdk, SchemeType type)
{
return new()
{
Name = name,
MihoyoSDK = sdk,
Type = type,
};
}

View File

@@ -9,18 +9,18 @@ namespace Snap.Hutao.Model.Entity.Primitive;
[HighQuality]
internal enum SchemeType
{
/// <summary>
/// 国际服
/// </summary>
Hoyoverse,
/// <summary>
/// 国服官服
/// </summary>
Official,
ChineseOfficial,
/// <summary>
/// 国际服
/// </summary>
Oversea,
/// <summary>
/// 渠道服
/// </summary>
Bilibili,
ChineseBilibili,
}

View File

@@ -8,123 +8,45 @@ namespace Snap.Hutao.Model.Entity;
/// </summary>
internal sealed partial class SettingEntry
{
/// <summary>
/// 游戏路径
/// </summary>
public const string GamePath = "GamePath";
public const string GamePathEntries = "GamePathEntries";
public const string Culture = "Culture";
[Obsolete("不再使用 PowerShell")]
public const string PowerShellPath = "PowerShellPath";
/// <summary>
/// 空的历史记录卡池是否可见
/// </summary>
public const string IsEmptyHistoryWishVisible = "IsEmptyHistoryWishVisible";
/// <summary>
/// 窗口背景类型
/// </summary>
public const string SystemBackdropType = "SystemBackdropType";
/// <summary>
/// 启用高级功能
/// </summary>
public const string IsAdvancedLaunchOptionsEnabled = "IsAdvancedLaunchOptionsEnabled";
public const string AnnouncementRegion = "AnnouncementRegion";
public const string IsEmptyHistoryWishVisible = "IsEmptyHistoryWishVisible";
public const string GeetestCustomCompositeUrl = "GeetestCustomCompositeUrl";
/// <summary>
/// 实时便笺刷新时间
/// </summary>
public const string DailyNoteRefreshSeconds = "DailyNote.RefreshSeconds";
/// <summary>
/// 实时便笺提醒式通知
/// </summary>
public const string DailyNoteReminderNotify = "DailyNote.ReminderNotify";
/// <summary>
/// 实时便笺免打扰模式
/// </summary>
public const string DailyNoteSilentWhenPlayingGame = "DailyNote.SilentWhenPlayingGame";
/// <summary>
/// 实时便笺 WebhookUrl
/// </summary>
public const string DailyNoteWebhookUrl = "DailyNote.WebhookUrl";
/// <summary>
/// 启动游戏 总开关
/// </summary>
public const string IsAdvancedLaunchOptionsEnabled = "IsAdvancedLaunchOptionsEnabled";
public const string LaunchIsLaunchOptionsEnabled = "Launch.IsLaunchOptionsEnabled";
/// <summary>
/// 启动游戏 独占全屏
/// </summary>
public const string LaunchIsExclusive = "Launch.IsExclusive";
/// <summary>
/// 启动游戏 全屏
/// </summary>
public const string LaunchIsFullScreen = "Launch.IsFullScreen";
/// <summary>
/// 启动游戏 无边框
/// </summary>
public const string LaunchIsBorderless = "Launch.IsBorderless";
/// <summary>
/// 启动游戏 宽度
/// </summary>
public const string LaunchScreenWidth = "Launch.ScreenWidth";
public const string LaunchIsScreenWidthEnabled = "Launch.IsScreenWidthEnabled";
/// <summary>
/// 启动游戏 高度
/// </summary>
public const string LaunchScreenHeight = "Launch.ScreenHeight";
public const string LaunchIsScreenHeightEnabled = "Launch.IsScreenHeightEnabled";
/// <summary>
/// 启动游戏 解锁帧率
/// </summary>
public const string LaunchUnlockFps = "Launch.UnlockFps";
/// <summary>
/// 启动游戏 目标帧率
/// </summary>
public const string LaunchTargetFps = "Launch.TargetFps";
/// <summary>
/// 启动游戏 显示器编号
/// </summary>
public const string LaunchMonitor = "Launch.Monitor";
public const string LaunchIsMonitorEnabled = "Launch.IsMonitorEnabled";
public const string LaunchIsUseCloudThirdPartyMobile = "Launch.IsUseCloudThirdPartyMobile";
public const string LaunchIsWindowsHDREnabled = "Launch.IsWindowsHDREnabled";
public const string LaunchUseStarwardPlayTimeStatistics = "Launch.UseStarwardPlayTimeStatistics";
public const string LaunchSetDiscordActivityWhenPlaying = "Launch.SetDiscordActivityWhenPlaying";
/// <summary>
/// 启动游戏 多倍启动
/// </summary>
[Obsolete("不再支持多开")]
public const string MultipleInstances = "Launch.MultipleInstances";
/// <summary>
/// 语言
/// </summary>
public const string Culture = "Culture";
/// <summary>
/// 自定义极验接口
/// </summary>
public const string GeetestCustomCompositeUrl = "GeetestCustomCompositeUrl";
public const string AnnouncementRegion = "AnnouncementRegion";
[Obsolete("不再使用 PowerShell")]
public const string PowerShellPath = "PowerShellPath";
}

View File

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

View File

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

View File

@@ -120,6 +120,12 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>Snap Hutao Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>Snap Hutao Dev {0} [Administrator]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>Snap Hutao {0} [Administrator]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>Snap Hutao</value>
</data>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>Import</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>Select PowerShell</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>Welcome to Snap Hutao, Traveler ~</value>
</data>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>Unable to set registry key without enabling long path</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>PowerShell installation directory not found</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>Unable to read game config file {0}, file may be not exist</value>
</data>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>Switch game account failed</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>Cannot select account [{1}] for Uid [{0}], the account does not belong to current server</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>Operation completed</value>
</data>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>Pre-download</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>Select Game Path</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>This account has not been bound to the Real-time Note notification UID.</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>Device IP</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>Administrator mode will change some features' availability and behaviors</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>Administrator Mode</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>Restart with Administrator Mode</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>Show or hide wish event with no wish history</value>
</data>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>When setting the game path, please select the game program (Yuanshen.exe or GenshinImpact.exe) instead of the game launcher (launcher.exe)</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>Snap Hutao uses PowerShell to modify information in registry to change game accounts</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>PowerShell Path</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Shell Experience</value>
</data>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>Copied to clipboard</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>Completed</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>Locked</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>In Progress</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>Finished</value>
</data>

View File

@@ -120,6 +120,12 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>Snap Hutao Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>Snap Hutao Dev {0} [Administrator]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>Snap Hutao {0} [Administrator]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>Snap Hutao</value>
</data>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>Impor</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>Pilih PowerShell</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>Selamat Datang di Snap Hutao, Traveler ~</value>
</data>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>Tidak dapat mengatur kunci registri tanpa mengaktifkan path panjang</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>Direktori instalasi PowerShell tidak ditemukan</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>Tidak dapat membaca file konfigurasi game {0}, file mungkin tidak ada</value>
</data>
@@ -996,7 +996,7 @@
<value>Versi UIGF tidak didukung</value>
</data>
<data name="ServiceUpdateStatusVersionDescription" xml:space="preserve">
<value>发现新版本 {0}</value>
<value>Versi terbau {0} sudah tersedia.</value>
</data>
<data name="ServiceUserCurrentMultiMatched" xml:space="preserve">
<value>Beberapa pengguna tercatat sebagai dipilih</value>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>Gagal ganti akun game</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>Tidak bisa memilih akun [{1}] dengan UID [{0}], Akun itu tidak berada pada server tersebut</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>Tindakan selesai</value>
</data>
@@ -2097,7 +2100,7 @@
<value>Mengatur Status Aktivitas Discord Saya Ketika Saya Sedang Bermain Game</value>
</data>
<data name="ViewPageLaunchGameDiscordActivityHeader" xml:space="preserve">
<value>Discord Activity</value>
<value>Aktifitas Discord</value>
</data>
<data name="ViewPageLaunchGameFileHeader" xml:space="preserve">
<value>Berkas</value>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>Prapengunduhan</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>Pilih Path Game</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>Akun ini belum terikat ke UID notifikasi Catatan Realtime.</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>ID Perangkat</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>Mode administrator akan mengubah ketersediaan dan perilaku beberapa fitur</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>Mode Admin</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>Restart menggunakan Mode Administrator</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>Tampilkan atau sembunyikan acara keinginan tanpa riwayat Wish</value>
</data>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>Saat mengatur jalur permainan, pilih program permainan (Yuanshen.exe atau GenshinImpact.exe) bukan peluncur permainan (launcher.exe)</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>Snap Hutao menggunakan PowerShell untuk memodifikasi informasi di registri untuk mengubah akun Game</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>Path PowerShell</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Pengalaman Shell</value>
</data>
@@ -2625,10 +2634,10 @@
<value>Mengunggah Data</value>
</data>
<data name="ViewTitileUpdatePackageReadyContent" xml:space="preserve">
<value>是否立即安装?</value>
<value>Pasang sekarang?</value>
</data>
<data name="ViewTitileUpdatePackageReadyTitle" xml:space="preserve">
<value>胡桃 {0} 版本已准备就绪</value>
<value>Snap Hutao versi {0} sudah siap</value>
</data>
<data name="ViewTitleAutoClicking" xml:space="preserve">
<value>Auto Click</value>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>Disalin ke clipboard</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>Selesai</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>Terkunci</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>Dalam proses</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>Selesai</value>
</data>
@@ -2886,7 +2904,7 @@
<value>Server Internasional Server Asia</value>
</data>
<data name="WebHoyolabRegionOSCHT" xml:space="preserve">
<value>国际服 港澳台服</value>
<value>Server International: Server TW/HK/MU</value>
</data>
<data name="WebHoyolabRegionOSEURO" xml:space="preserve">
<value>Server Internasional Server Eropa</value>

View File

@@ -120,6 +120,12 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>胡桃 Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>胡桃Dev {0} [管理者]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>胡桃 {0} [管理者]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>胡桃</value>
</data>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>インポート</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>PowerShellを選択</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>胡桃へようこそ</value>
</data>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>長いパスのサポートがオフになっているため、レジストリキーを編集できません。</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>PowerShellのインストールディレクトリが見つかりません</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>ゲーム設定ファイル {0} の読み込みに失敗しました。ファイルが存在していない可能性があります。</value>
</data>
@@ -996,7 +996,7 @@
<value>サポートされていないUIGFバージョン</value>
</data>
<data name="ServiceUpdateStatusVersionDescription" xml:space="preserve">
<value>发现新版本 {0}</value>
<value>新しいバージョン {0} が利用できます</value>
</data>
<data name="ServiceUserCurrentMultiMatched" xml:space="preserve">
<value>ユーザー情報を複数選択しています。</value>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>アカウントの切り替えができませんでした</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>UID [{0}] に対するアカウント [{1}] を選択できませんでした。このアカウントは現在のサーバーに属していません。</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>操作は完了しました</value>
</data>
@@ -1653,7 +1656,7 @@
<value>重要</value>
</data>
<data name="ViewPageAnnouncementViewDetails" xml:space="preserve">
<value>查看详情</value>
<value>詳細を表示</value>
</data>
<data name="ViewPageAvatarPropertyArtifactScore" xml:space="preserve">
<value>聖遺物スコア</value>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>事前ダウンロード</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>ゲームのフォルダを選択</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>このアカウントはリアルタイムノート通知 UID として連携されていません。</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>デバイスのIP</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>管理者モードは一部の機能の使用可否と動作に影響します。</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>管理者モード</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>管理者モードで再起動</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>祈願履歴のないイベント限定祈願を祈願履歴に表示するかを変更します。</value>
</data>
@@ -2313,10 +2328,10 @@
<value>CAPTCHA</value>
</data>
<data name="ViewPageSettingHomeAnnouncementRegionDescription" xml:space="preserve">
<value>选择想要获取公告的游戏服务器</value>
<value>お知らせを受け取りたいサーバーを選択してください</value>
</data>
<data name="ViewPageSettingHomeAnnouncementRegionHeader" xml:space="preserve">
<value>公告所属服务器</value>
<value>お知らせのサーバー</value>
</data>
<data name="ViewpageSettingHomeCardDescription" xml:space="preserve">
<value>ダッシュボードを整理する</value>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>ゲームのパスを設定する際、本体YuanShen.exe または GenshinImpact.exeを選んでください。ランチャーlauncher.exeではありません</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>胡桃のゲームランチャーはPowershellを介してレジストリを変更し、ゲームで使用するアカウントを変更します。</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>PowerShell パス</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Shell エクスペリエンス</value>
</data>
@@ -2625,10 +2634,10 @@
<value>データをアップロード</value>
</data>
<data name="ViewTitileUpdatePackageReadyContent" xml:space="preserve">
<value>是否立即安装?</value>
<value>今すぐインストールしますか?</value>
</data>
<data name="ViewTitileUpdatePackageReadyTitle" xml:space="preserve">
<value>胡桃 {0} 版本已准备就绪</value>
<value>胡桃 {0} の準備が完了</value>
</data>
<data name="ViewTitleAutoClicking" xml:space="preserve">
<value>オートクリック</value>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>クリップボードにコピーしました。</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>すべて完了</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>未解放</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>進行中</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>完了しました。</value>
</data>
@@ -2871,28 +2889,28 @@
<value>ダウンロードリンクのコピーに成功しました</value>
</data>
<data name="WebHoyolabInvalidRegion" xml:space="preserve">
<value>无效的服务器</value>
<value>無効なサーバー</value>
</data>
<data name="WebHoyolabInvalidUid" xml:space="preserve">
<value>無効なUIDです</value>
</data>
<data name="WebHoyolabRegionCNGF01" xml:space="preserve">
<value>国服 官方服</value>
<value>中国サーバー: 公式</value>
</data>
<data name="WebHoyolabRegionCNQD01" xml:space="preserve">
<value>国服 渠道服</value>
<value>中国サーバー: ビリビリ(bilibili)</value>
</data>
<data name="WebHoyolabRegionOSASIA" xml:space="preserve">
<value>国际服 亚服</value>
<value>国内サーバー: アジア</value>
</data>
<data name="WebHoyolabRegionOSCHT" xml:space="preserve">
<value>国际服 港澳台服</value>
<value>海外サーバー: 台湾/香港/モンゴル</value>
</data>
<data name="WebHoyolabRegionOSEURO" xml:space="preserve">
<value>国际服 欧服</value>
<value>海外サーバー: ヨーロッパ</value>
</data>
<data name="WebHoyolabRegionOSUSA" xml:space="preserve">
<value>国际服 美服</value>
<value>海外サーバー: 北アメリカ</value>
</data>
<data name="WebHutaoServiceUnAvailable" xml:space="preserve">
<value>胡桃サーバがメンテナンス中です</value>

View File

@@ -120,6 +120,12 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>호두 Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>胡桃Dev {0} [管理员]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>胡桃 {0} [管理员]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>호두</value>
</data>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>가져오기</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>选择 PowerShell</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>欢迎使用胡桃</value>
</data>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>긴 경로 기능이 켜지지 않아 레지스트리 키 값을 설정할 수 없습니다</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>PowerShell 설치 경로를 찾을 수 없습니다</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>无法读取游戏配置文件 {0},可能是文件不存在</value>
</data>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>계정 전환 살패</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>无法选择UID [{0}] 对应的账号 [{1}],该账号不属于当前服务器</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>操作完成</value>
</data>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>사전 다운로드</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>选择游戏路径</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>该账号尚未绑定实时便笺通知 UID</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>设备 IP</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>管理员模式会影响部分功能的可用性与行为</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>管理员模式</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>以管理员身份重启</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>기원 기록 페이지에 기록되지 않은 오래된 기원 이벤트를 표시하거나 숨깁니다</value>
</data>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>게임 경로를 설정할 때 런쳐(launcher.exe) 대신 게임(YuanShen.exe 또는 GenshinImpact.exe)를 선택하세요</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>胡桃使用 PowerShell 更改注册表中的信息以修改游戏内账号</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>PowerShell 路径</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Shell 体验</value>
</data>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>已复制到剪贴板</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>全部完成</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>尚未开启</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>进行中</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>已完成</value>
</data>

View File

@@ -121,7 +121,7 @@
<value>胡桃 Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>胡桃Dev {0} [管理员]</value>
<value>胡桃 Dev {0} [管理员]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>胡桃 {0} [管理员]</value>
@@ -1271,6 +1271,12 @@
<data name="ViewDialogQRCodeTitle" xml:space="preserve">
<value>使用米游社扫描二维码</value>
</data>
<data name="ViewDialogReconfirmTextHeader" xml:space="preserve">
<value>请输入你正在启用的功能标题</value>
</data>
<data name="ViewDialogReconfirmTitle" xml:space="preserve">
<value>你正在启用一个危险功能</value>
</data>
<data name="ViewDialogSettingDeleteUserDataContent" xml:space="preserve">
<value>该操作是不可逆的,所有用户登录状态会丢失</value>
</data>
@@ -1553,6 +1559,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>切换账号失败</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>无法选择UID [{0}] 对应的账号 [{1}],该账号不属于当前服务器</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>操作完成</value>
</data>
@@ -1571,18 +1580,18 @@
<data name="ViewModelSettingCreateDesktopShortcutFailed" xml:space="preserve">
<value>创建桌面快捷方式失败</value>
</data>
<data name="ViewModelSettingDeleteServerCacheFolderContent" xml:space="preserve">
<value>后续转换会重新下载所需的文件,确定要删除吗?</value>
</data>
<data name="ViewModelSettingDeleteServerCacheFolderTitle" xml:space="preserve">
<value>删除转换服务器游戏客户端缓存</value>
</data>
<data name="ViewModelSettingFolderSizeDescription" xml:space="preserve">
<value>已使用磁盘空间:{0}</value>
</data>
<data name="ViewModelSettingGeetestCustomUrlSucceed" xml:space="preserve">
<value>无感验证复合 Url 配置成功</value>
</data>
<data name="ViewModelSettingNotRunningInElevatedMode" xml:space="preserve">
<value>当前以用户身份运行</value>
</data>
<data name="ViewModelSettingRunningInElevatedMode" xml:space="preserve">
<value>当前以管理员身份运行</value>
</data>
<data name="ViewModelSettingSetDataFolderSuccess" xml:space="preserve">
<value>设置数据目录成功,重启以应用更改</value>
</data>
@@ -2195,6 +2204,12 @@
<data name="ViewPageLaunchGameUnlockFpsOn" xml:space="preserve">
<value>启用</value>
</data>
<data name="ViewPageLaunchGameWindowsHDRDescription" xml:space="preserve">
<value>充分利用支持高动态范围的显示器获得更亮、更生动、更精细的画面</value>
</data>
<data name="ViewPageLaunchGameWindowsHDRHeader" xml:space="preserve">
<value>Windows HDR</value>
</data>
<data name="ViewPageLoginHoyoverseUserHint" xml:space="preserve">
<value>请输入你的 HoYoLab Uid</value>
</data>
@@ -2412,7 +2427,7 @@
<value>在完整阅读原神和胡桃工具箱用户协议后,我选择启用「启动游戏-高级功能」</value>
</data>
<data name="ViewPageSettingIsAdvancedLaunchOptionsEnabledHeader" xml:space="preserve">
<value>启动高级功能</value>
<value>高级功能</value>
</data>
<data name="ViewPageSettingKeyShortcutAutoClickingDescription" xml:space="preserve">
<value>更改自动连点功能的快捷键</value>
@@ -2567,6 +2582,12 @@
<data name="ViewSettingAllocConsoleHeader" xml:space="preserve">
<value>调试控制台</value>
</data>
<data name="ViewSettingDeleteServerCacheFolderDescription" xml:space="preserve">
<value>在启动游戏中转换服务器后会产生对应的游戏客户端文件用作缓存</value>
</data>
<data name="ViewSettingDeleteServerCacheFolderHeader" xml:space="preserve">
<value>删除转换服务器缓存</value>
</data>
<data name="ViewSettingFolderViewOpenFolderAction" xml:space="preserve">
<value>打开文件夹</value>
</data>
@@ -2750,6 +2771,9 @@
<data name="WebAnnouncementTimeHoursEndFormat" xml:space="preserve">
<value>{0} 小时后结束</value>
</data>
<data name="WebBridgeShareCopyToClipboardFailed" xml:space="preserve">
<value>打开剪贴板失败</value>
</data>
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>已复制到剪贴板</value>
</data>

View File

@@ -120,11 +120,17 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>Snap Hutao Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>Ху Тао Dev {0} [Администратор]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>Ху Тао {0} [Администратор]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>Snap Hutao</value>
<value>Ху Тао</value>
</data>
<data name="AppNameAndVersion" xml:space="preserve">
<value>Snap Hutao {0}</value>
<value>Ху Тао {0}</value>
</data>
<data name="ContentDialogCancelCloseButtonText" xml:space="preserve">
<value>Отмена</value>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>Импорт</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>Выберите PowerShell</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>Добро пожаловать в Snap Hutao, путешественник ~</value>
</data>
@@ -492,19 +495,19 @@
<value>Волна 3: Волна появится только после убийства всех врагов в предыдущей волне.</value>
</data>
<data name="ModelMetadataTowerWaveTypeWave4" xml:space="preserve">
<value>第四波:击败所有怪物,下一波才会出现</value>
<value>Четвертая волна: Победите всех монстров, прежде чем появится следующая волна</value>
</data>
<data name="ModelNameValueDefaultDescription" xml:space="preserve">
<value>请更新角色橱窗数据</value>
<value>Обновите данные витрины персонажей</value>
</data>
<data name="ModelNameValueDefaultName" xml:space="preserve">
<value>暂无数据</value>
<value>Нет данных</value>
</data>
<data name="ModelWeaponAffixFormat" xml:space="preserve">
<value>精炼 {0}</value>
<value>Уровень совершенствования {0}</value>
</data>
<data name="MustSelectUserAndUid" xml:space="preserve">
<value>必须登录 米游社/HoYoLAB 并选择一个用户与角色</value>
<value>Необходимо войти в учетную запись miHoYo/HoYoLAB и выбрать пользователя с персонажем</value>
</data>
<data name="ServerGachaLogServiceDeleteEntrySucceed" xml:space="preserve">
<value>删除了 Uid{0} 的 {1} 条祈愿记录</value>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>未开启长路径功能,无法设置注册表键值</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>找不到 PowerShell 的安装目录</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>无法读取游戏配置文件 {0},可能是文件不存在</value>
</data>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>切换账号失败</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>无法选择UID [{0}] 对应的账号 [{1}],该账号不属于当前服务器</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>操作完成</value>
</data>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>预下载</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>选择游戏路径</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>该账号尚未绑定实时便笺通知 UID</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>设备 IP</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>管理员模式会影响部分功能的可用性与行为</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>管理员模式</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>以管理员身份重启</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>在祈愿记录页面显示或隐藏无记录的历史祈愿活动</value>
</data>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>设置游戏路径时请选择游戏本体YuanShen.exe 或 GenshinImpact.exe而不是启动器launcher.exe</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>胡桃使用 PowerShell 更改注册表中的信息以修改游戏内账号</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>PowerShell 路径</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Shell 体验</value>
</data>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>已复制到剪贴板</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>全部完成</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>尚未开启</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>进行中</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>已完成</value>
</data>

View File

@@ -120,6 +120,12 @@
<data name="AppDevNameAndVersion" xml:space="preserve">
<value>胡桃 Dev {0}</value>
</data>
<data name="AppElevatedDevNameAndVersion" xml:space="preserve">
<value>胡桃Dev {0} [管理员]</value>
</data>
<data name="AppElevatedNameAndVersion" xml:space="preserve">
<value>胡桃 {0} [管理员]</value>
</data>
<data name="AppName" xml:space="preserve">
<value>胡桃</value>
</data>
@@ -142,7 +148,7 @@
<value>無效的 Uri</value>
</data>
<data name="ControlImageCompositionImageHttpRequest" xml:space="preserve">
<value>HTTP GET {0}</value>
<value>获取HTTP{0}</value>
</data>
<data name="ControlImageCompositionImageSystemException" xml:space="preserve">
<value>應用 CompositionImage 的源時發生異常</value>
@@ -186,9 +192,6 @@
<data name="FilePickerImportCommit" xml:space="preserve">
<value>匯入</value>
</data>
<data name="FilePickerPowerShellCommit" xml:space="preserve">
<value>選擇 PowerShell</value>
</data>
<data name="GuideWindowTitle" xml:space="preserve">
<value>歡迎使用胡桃</value>
</data>
@@ -295,7 +298,7 @@
<comment>Need EXACT same string in game</comment>
</data>
<data name="ModelIntrinsicBodyTypeBoy" xml:space="preserve">
<value>少</value>
<value>少</value>
</data>
<data name="ModelIntrinsicBodyTypeGirl" xml:space="preserve">
<value>少女</value>
@@ -307,7 +310,7 @@
<value>蘿莉</value>
</data>
<data name="ModelIntrinsicBodyTypeMale" xml:space="preserve">
<value>成</value>
<value>成年男子</value>
</data>
<data name="ModelIntrinsicElementNameElec" xml:space="preserve">
<value>雷</value>
@@ -376,7 +379,7 @@
<comment>Need EXACT same string in game</comment>
</data>
<data name="ModelMetadataAvatarPlayerName" xml:space="preserve">
<value>旅行者</value>
<value>旅</value>
</data>
<data name="ModelMetadataFetterInfoBirthdayFormat" xml:space="preserve">
<value>{0} 月 {1} 日</value>
@@ -483,7 +486,7 @@
<value>第一波:擊敗所有怪物,下一波才會出現</value>
</data>
<data name="ModelMetadataTowerWaveTypeWave1Additional" xml:space="preserve">
<value>第一波加:增援第一波怪物</value>
<value>第一波加:增援第一波怪物</value>
</data>
<data name="ModelMetadataTowerWaveTypeWave2" xml:space="preserve">
<value>第二波:擊敗所有怪物,下一波才會出現</value>
@@ -843,7 +846,7 @@
<value>找不到原神內置瀏覽器緩存路徑:\n{0}</value>
</data>
<data name="ServiceGachaLogUrlProviderCacheUrlNotFound" xml:space="preserve">
<value>找到可用的 Url</value>
<value>找到可用的 Url</value>
</data>
<data name="ServiceGachaLogUrlProviderManualInputInvalid" xml:space="preserve">
<value>提供的 Url 無效</value>
@@ -932,9 +935,6 @@
<data name="ServiceGameRegisteryInteropLongPathsDisabled" xml:space="preserve">
<value>未開啓長路徑功能,無法設定注冊表鍵值</value>
</data>
<data name="ServiceGameRegisteryInteropPowershellNotFound" xml:space="preserve">
<value>找不到 PowerShell 的安裝目錄</value>
</data>
<data name="ServiceGameSetMultiChannelConfigFileNotFound" xml:space="preserve">
<value>無法讀取遊戲設定檔 {0},可能是檔案不存在</value>
</data>
@@ -996,7 +996,7 @@
<value>不支援的 UIGF 版本</value>
</data>
<data name="ServiceUpdateStatusVersionDescription" xml:space="preserve">
<value>发现新版本 {0}</value>
<value>發現新版本 {0}</value>
</data>
<data name="ServiceUserCurrentMultiMatched" xml:space="preserve">
<value>已选中多条用户记录</value>
@@ -1553,6 +1553,9 @@
<data name="ViewModelLaunchGameSwitchGameAccountFail" xml:space="preserve">
<value>切換帳號失敗</value>
</data>
<data name="ViewModelLaunchGameUnableToSwitchUidAttachedGameAccount" xml:space="preserve">
<value>无法选择UID [{0}] 对应的账号 [{1}],该账号不属于当前服务器</value>
</data>
<data name="ViewModelSettingActionComplete" xml:space="preserve">
<value>操作完成</value>
</data>
@@ -2144,6 +2147,9 @@
<data name="ViewPageLaunchGameResourcePreDownloadHeader" xml:space="preserve">
<value>預下載</value>
</data>
<data name="ViewPageLaunchGameSelectGamePath" xml:space="preserve">
<value>选择游戏路径</value>
</data>
<data name="ViewPageLaunchGameSwitchAccountAttachUidNull" xml:space="preserve">
<value>該用戶尚未綁定即時便箋通知 UID</value>
</data>
@@ -2276,6 +2282,15 @@
<data name="ViewPageSettingDeviceIpHeader" xml:space="preserve">
<value>設備 IP</value>
</data>
<data name="ViewPageSettingElevatedModeDescription" xml:space="preserve">
<value>管理员模式会影响部分功能的可用性与行为</value>
</data>
<data name="ViewPageSettingElevatedModeHeader" xml:space="preserve">
<value>管理员模式</value>
</data>
<data name="ViewPageSettingElevatedModeRestartAction" xml:space="preserve">
<value>以管理员身份重启</value>
</data>
<data name="ViewPageSettingEmptyHistoryVisibleDescription" xml:space="preserve">
<value>在祈願紀錄頁面顯示或隱藏無記錄的歷史祈願活動</value>
</data>
@@ -2432,12 +2447,6 @@
<data name="ViewPageSettingSetGamePathHint" xml:space="preserve">
<value>設置游戲路徑時請選擇游戲本體YuanShen.exe 或 GenshinImpact.exe 而不是啓動器launcher.exe</value>
</data>
<data name="ViewPageSettingSetPowerShellDescription" xml:space="preserve">
<value>胡桃使用 PowerShell 更改註冊表中的信息以修改遊戲內賬號</value>
</data>
<data name="ViewPageSettingSetPowerShellPathHeader" xml:space="preserve">
<value>PowerShell 路徑</value>
</data>
<data name="ViewPageSettingShellExperienceHeader" xml:space="preserve">
<value>Shell 體驗</value>
</data>
@@ -2741,6 +2750,15 @@
<data name="WebBridgeShareCopyToClipboardSuccess" xml:space="preserve">
<value>已複製到剪貼簿</value>
</data>
<data name="WebDailyNoteArchonQuestStatusFinished" xml:space="preserve">
<value>全部完成</value>
</data>
<data name="WebDailyNoteArchonQuestStatusNotOpen" xml:space="preserve">
<value>尚未开启</value>
</data>
<data name="WebDailyNoteArchonQuestStatusOngoing" xml:space="preserve">
<value>进行中</value>
</data>
<data name="WebDailyNoteAttendanceRewardStatusFinishedNonReward" xml:space="preserve">
<value>已完成</value>
</data>

View File

@@ -1,10 +1,10 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Discord.GameSDK;
using Snap.Discord.GameSDK.ABI;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.Unicode;
namespace Snap.Hutao.Service.Discord;
@@ -18,75 +18,94 @@ internal static class DiscordController
private static readonly CancellationTokenSource StopTokenSource = new();
private static readonly object SyncRoot = new();
private static Snap.Discord.GameSDK.Discord? discordManager;
private static long currentClientId;
private static unsafe IDiscordCore* discordCorePtr;
private static bool isInitialized;
public static async ValueTask<Result> SetDefaultActivityAsync(DateTimeOffset startTime)
public static async ValueTask<DiscordResult> SetDefaultActivityAsync(DateTimeOffset startTime)
{
ResetManagerOrIgnore(HutaoAppId);
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
return SetDefaultActivity(startTime);
if (discordManager is null)
static unsafe DiscordResult SetDefaultActivity(in DateTimeOffset startTime)
{
return Result.Ok;
ResetManagerOrIgnore(HutaoAppId);
if (discordCorePtr is null)
{
return DiscordResult.Ok;
}
IDiscordActivityManager* activityManagerPtr = discordCorePtr->get_activity_manager(discordCorePtr);
DiscordActivity activity = default;
activity.timestamps.start = startTime.ToUnixTimeSeconds();
SetString(activity.assets.large_image, 128, "icon"u8);
SetString(activity.assets.large_text, 128, SH.AppName);
return new DiscordUpdateActivityAsyncAction(activityManagerPtr).WaitUpdateActivity(activity);
}
ActivityManager activityManager = discordManager.GetActivityManager();
Activity activity = default;
activity.Timestamps.Start = startTime.ToUnixTimeSeconds();
activity.Assets.LargeImage = "icon";
activity.Assets.LargeText = SH.AppName;
return await activityManager.UpdateActivityAsync(activity).ConfigureAwait(false);
}
public static async ValueTask<Result> SetPlayingYuanShenAsync()
public static async ValueTask<DiscordResult> SetPlayingYuanShenAsync()
{
ResetManagerOrIgnore(YuanshenId);
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
return SetPlayingYuanShen();
if (discordManager is null)
static unsafe DiscordResult SetPlayingYuanShen()
{
return Result.Ok;
ResetManagerOrIgnore(YuanshenId);
if (discordCorePtr is null)
{
return DiscordResult.Ok;
}
IDiscordActivityManager* activityManagerPtr = discordCorePtr->get_activity_manager(discordCorePtr);
DiscordActivity activity = default;
SetString(activity.state, 128, SH.FormatServiceDiscordGameLaunchedBy(SH.AppName));
SetString(activity.details, 128, SH.ServiceDiscordGameActivityDetails);
activity.timestamps.start = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
SetString(activity.assets.large_image, 128, "icon"u8);
SetString(activity.assets.large_text, 128, "原神"u8);
SetString(activity.assets.small_image, 128, "app"u8);
SetString(activity.assets.small_text, 128, SH.AppName);
return new DiscordUpdateActivityAsyncAction(activityManagerPtr).WaitUpdateActivity(activity);
}
ActivityManager activityManager = discordManager.GetActivityManager();
Activity activity = default;
activity.State = SH.FormatServiceDiscordGameLaunchedBy(SH.AppName);
activity.Details = SH.ServiceDiscordGameActivityDetails;
activity.Timestamps.Start = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
activity.Assets.LargeImage = "icon";
activity.Assets.LargeText = "原神";
activity.Assets.SmallImage = "app";
activity.Assets.SmallText = SH.AppName;
return await activityManager.UpdateActivityAsync(activity).ConfigureAwait(false);
}
public static async ValueTask<Result> SetPlayingGenshinImpactAsync()
public static async ValueTask<DiscordResult> SetPlayingGenshinImpactAsync()
{
ResetManagerOrIgnore(GenshinImpactId);
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
return SetPlayingGenshinImpact();
if (discordManager is null)
static unsafe DiscordResult SetPlayingGenshinImpact()
{
return Result.Ok;
ResetManagerOrIgnore(GenshinImpactId);
if (discordCorePtr is null)
{
return DiscordResult.Ok;
}
IDiscordActivityManager* activityManagerPtr = discordCorePtr->get_activity_manager(discordCorePtr);
DiscordActivity activity = default;
SetString(activity.state, 128, SH.FormatServiceDiscordGameLaunchedBy(SH.AppName));
SetString(activity.details, 128, SH.ServiceDiscordGameActivityDetails);
activity.timestamps.start = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
SetString(activity.assets.large_image, 128, "icon"u8);
SetString(activity.assets.large_text, 128, "Genshin Impact"u8);
SetString(activity.assets.small_image, 128, "app"u8);
SetString(activity.assets.small_text, 128, SH.AppName);
return new DiscordUpdateActivityAsyncAction(activityManagerPtr).WaitUpdateActivity(activity);
}
ActivityManager activityManager = discordManager.GetActivityManager();
Activity activity = default;
activity.State = SH.FormatServiceDiscordGameLaunchedBy(SH.AppName);
activity.Details = SH.ServiceDiscordGameActivityDetails;
activity.Timestamps.Start = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
activity.Assets.LargeImage = "icon";
activity.Assets.LargeText = "Genshin Impact";
activity.Assets.SmallImage = "app";
activity.Assets.SmallText = SH.AppName;
return await activityManager.UpdateActivityAsync(activity).ConfigureAwait(false);
}
public static void Stop()
public static unsafe void Stop()
{
if (!isInitialized)
{
@@ -98,7 +117,7 @@ internal static class DiscordController
StopTokenSource.Cancel();
try
{
discordManager?.Dispose();
discordCorePtr = default;
}
catch (SEHException)
{
@@ -108,23 +127,30 @@ internal static class DiscordController
private static unsafe void ResetManagerOrIgnore(long clientId)
{
if (discordManager?.ClientId == clientId)
if (currentClientId == clientId)
{
return;
}
// Actually requires a discord client to be running on Windows platform.
// If not, the following creation code will throw.
if (System.Diagnostics.Process.GetProcessesByName("Discord").Length == 0)
if (System.Diagnostics.Process.GetProcessesByName("Discord").Length <= 0)
{
return;
}
lock (SyncRoot)
{
discordManager?.Dispose();
discordManager = new(clientId, CreateFlags.NoRequireDiscord);
discordManager.SetLogHook(Snap.Discord.GameSDK.LogLevel.Debug, SetLogHookHandler.Create(&DebugWriteDiscordMessage));
DiscordCreateParams @params = default;
Methods.DiscordCreateParamsSetDefault(&@params);
@params.client_id = clientId;
@params.flags = (uint)DiscordCreateFlags.Default;
IDiscordCore* ptr = default;
Methods.DiscordCreate(3, &@params, &ptr);
currentClientId = clientId;
discordCorePtr = ptr;
discordCorePtr->set_log_hook(discordCorePtr, DiscordLogLevel.Debug, default, &DebugWriteDiscordMessage);
}
if (isInitialized)
@@ -135,10 +161,10 @@ internal static class DiscordController
DiscordRunCallbacksAsync(StopTokenSource.Token).SafeForget();
isInitialized = true;
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])]
static unsafe void DebugWriteDiscordMessage(Snap.Discord.GameSDK.LogLevel logLevel, byte* ptr)
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
static unsafe void DebugWriteDiscordMessage(void* state, DiscordLogLevel logLevel, sbyte* ptr)
{
ReadOnlySpan<byte> utf8 = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(ptr);
ReadOnlySpan<byte> utf8 = MemoryMarshal.CreateReadOnlySpanFromNullTerminated((byte*)ptr);
string message = System.Text.Encoding.UTF8.GetString(utf8);
System.Diagnostics.Debug.WriteLine($"[Discord.GameSDK]:[{logLevel}]:{message}");
}
@@ -146,7 +172,7 @@ internal static class DiscordController
private static async ValueTask DiscordRunCallbacksAsync(CancellationToken cancellationToken)
{
using (PeriodicTimer timer = new(TimeSpan.FromMilliseconds(1000)))
using (PeriodicTimer timer = new(TimeSpan.FromMilliseconds(500)))
{
try
{
@@ -161,15 +187,10 @@ internal static class DiscordController
{
try
{
discordManager?.RunCallbacks();
}
catch (ResultException ex)
{
// If result is Ok
// Maybe the connection is reset.
if (ex.Result is not Result.Ok)
DiscordResult result = DiscordCoreRunRunCallbacks();
if (result is not DiscordResult.Ok)
{
System.Diagnostics.Debug.WriteLine($"[Discord.GameSDK ERROR]:{ex.Result:D} {ex.Result}");
System.Diagnostics.Debug.WriteLine($"[Discord.GameSDK ERROR]:{result:D} {result}");
}
}
catch (SEHException ex)
@@ -185,5 +206,70 @@ internal static class DiscordController
{
}
}
unsafe DiscordResult DiscordCoreRunRunCallbacks()
{
if (discordCorePtr is not null)
{
return discordCorePtr->run_callbacks(discordCorePtr);
}
return DiscordResult.Ok;
}
}
private static unsafe void SetString(sbyte* reference, int length, string source)
{
Span<sbyte> sbytes = new(reference, length);
sbytes.Clear();
Utf8.FromUtf16(source.AsSpan(), MemoryMarshal.Cast<sbyte, byte>(sbytes), out _, out _);
}
private static unsafe void SetString(sbyte* reference, int length, in ReadOnlySpan<byte> source)
{
Span<sbyte> sbytes = new(reference, length);
sbytes.Clear();
source.CopyTo(MemoryMarshal.Cast<sbyte, byte>(sbytes));
}
private struct DiscordAsyncAction
{
public DiscordResult Result;
public bool IsCompleted;
}
private unsafe struct DiscordUpdateActivityAsyncAction
{
private readonly IDiscordActivityManager* activityManagerPtr;
private DiscordAsyncAction discordAsyncAction;
public DiscordUpdateActivityAsyncAction(IDiscordActivityManager* activityManagerPtr)
{
this.activityManagerPtr = activityManagerPtr;
}
public DiscordResult WaitUpdateActivity(DiscordActivity activity)
{
fixed (DiscordAsyncAction* actionPtr = &discordAsyncAction)
{
activityManagerPtr->update_activity(activityManagerPtr, &activity, actionPtr, &HandleResult);
}
SpinWaitPolyfill.SpinUntil(ref discordAsyncAction, &CheckActionCompleted);
return discordAsyncAction.Result;
}
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void HandleResult(void* state, DiscordResult result)
{
DiscordAsyncAction* action = (DiscordAsyncAction*)state;
action->Result = result;
action->IsCompleted = true;
}
private static bool CheckActionCompleted(ref readonly DiscordAsyncAction state)
{
return state.IsCompleted;
}
}
}

View File

@@ -4,6 +4,7 @@
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.View.Dialog;
using System.Collections.ObjectModel;
@@ -24,67 +25,61 @@ internal sealed partial class GameAccountService : IGameAccountService
get => gameAccounts ??= gameDbService.GetGameAccountCollection();
}
public async ValueTask<GameAccount?> DetectGameAccountAsync()
public async ValueTask<GameAccount?> DetectGameAccountAsync(SchemeType schemeType)
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
if (schemeType is SchemeType.ChineseBilibili)
{
GameAccount? account = null;
try
{
account = gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
if (account is null)
{
LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGameAccountNameDialog>().ConfigureAwait(false);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false);
if (isOk)
{
account = GameAccount.From(name, registrySdk);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false);
// sync cache
await taskContext.SwitchToMainThreadAsync();
gameAccounts.Add(account);
}
}
return account;
return default;
}
return default;
string? registrySdk = RegistryInterop.Get(schemeType);
if (string.IsNullOrEmpty(registrySdk))
{
return default;
}
GameAccount? account = SingleGameAccountOrDefault(gameAccounts, registrySdk);
if (account is null)
{
LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGameAccountNameDialog>().ConfigureAwait(false);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false);
if (isOk)
{
account = GameAccount.From(name, registrySdk, schemeType);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false);
// sync cache
await taskContext.SwitchToMainThreadAsync();
gameAccounts.Add(account);
}
}
return account;
}
public GameAccount? DetectCurrentGameAccount()
public GameAccount? DetectCurrentGameAccount(SchemeType schemeType)
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
if (schemeType is SchemeType.ChineseBilibili)
{
try
{
return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
return default;
}
return null;
string? registrySdk = RegistryInterop.Get(schemeType);
if (string.IsNullOrEmpty(registrySdk))
{
return default;
}
return SingleGameAccountOrDefault(gameAccounts, registrySdk);
}
public bool SetGameAccount(GameAccount account)
@@ -100,12 +95,12 @@ internal sealed partial class GameAccountService : IGameAccountService
public async ValueTask ModifyGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGameAccountNameDialog>().ConfigureAwait(false);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(true);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false);
if (isOk)
{
await taskContext.SwitchToMainThreadAsync();
gameAccount.UpdateName(name);
// sync database
@@ -116,11 +111,24 @@ internal sealed partial class GameAccountService : IGameAccountService
public async ValueTask RemoveGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
ArgumentNullException.ThrowIfNull(gameAccounts);
await taskContext.SwitchToMainThreadAsync();
gameAccounts.Remove(gameAccount);
await taskContext.SwitchToBackgroundAsync();
await gameDbService.RemoveGameAccountByIdAsync(gameAccount.InnerId).ConfigureAwait(false);
}
private static GameAccount? SingleGameAccountOrDefault(ObservableCollection<GameAccount> gameAccounts, string registrySdk)
{
try
{
return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
throw ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
}
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game.Account;
@@ -12,9 +13,9 @@ internal interface IGameAccountService
void AttachGameAccountToUid(GameAccount gameAccount, string uid);
GameAccount? DetectCurrentGameAccount();
GameAccount? DetectCurrentGameAccount(SchemeType schemeType);
ValueTask<GameAccount?> DetectGameAccountAsync();
ValueTask<GameAccount?> DetectGameAccountAsync(SchemeType schemeType);
ValueTask ModifyGameAccountAsync(GameAccount gameAccount);

View File

@@ -2,7 +2,9 @@
// Licensed under the MIT license.
using Microsoft.Win32;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using System.Runtime.InteropServices;
using System.Text;
@@ -13,9 +15,12 @@ namespace Snap.Hutao.Service.Game.Account;
/// </summary>
internal static class RegistryInterop
{
private const string GenshinPath = @"Software\miHoYo\原神";
private const string GenshinKey = $@"HKEY_CURRENT_USER\{GenshinPath}";
private const string SdkChineseKey = "MIHOYOSDK_ADL_PROD_CN_h3123967166";
private const string ChineseKeyName = @"HKEY_CURRENT_USER\Software\miHoYo\原神";
private const string OverseaKeyName = @"HKEY_CURRENT_USER\Software\miHoYo\Genshin Impact";
private const string SdkChineseValueName = "MIHOYOSDK_ADL_PROD_CN_h3123967166";
private const string SdkOverseaValueName = "MIHOYOSDK_ADL_PROD_OVERSEA_h1158948810";
private const string WindowsHDROnValueName = "WINDOWS_HDR_ON_h3132281285";
public static bool Set(GameAccount? account)
{
@@ -23,10 +28,10 @@ internal static class RegistryInterop
{
// 存回注册表的字节需要 '\0' 结尾
byte[] target = [.. Encoding.UTF8.GetBytes(account.MihoyoSDK), 0];
Registry.SetValue(GenshinKey, SdkChineseKey, target);
(string keyName, string valueName) = GetKeyValueName(account.Type);
Registry.SetValue(keyName, valueName, target);
string? get = Get();
if (get == account.MihoyoSDK)
if (Get(account.Type) == account.MihoyoSDK)
{
return true;
}
@@ -35,20 +40,37 @@ internal static class RegistryInterop
return false;
}
public static unsafe string? Get()
public static unsafe string? Get(SchemeType scheme)
{
object? sdk = Registry.GetValue(GenshinKey, SdkChineseKey, Array.Empty<byte>());
(string keyName, string valueName) = GetKeyValueName(scheme);
object? sdk = Registry.GetValue(keyName, valueName, Array.Empty<byte>());
if (sdk is byte[] bytes)
if (sdk is not byte[] bytes)
{
fixed (byte* pByte = bytes)
{
// 从注册表获取的字节数组带有 '\0' 结尾,需要舍去
ReadOnlySpan<byte> span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pByte);
return Encoding.UTF8.GetString(span);
}
return null;
}
return null;
fixed (byte* pByte = bytes)
{
// 从注册表获取的字节数组带有 '\0' 结尾,需要舍去
ReadOnlySpan<byte> span = MemoryMarshal.CreateReadOnlySpanFromNullTerminated(pByte);
return Encoding.UTF8.GetString(span);
}
}
public static void SetWindowsHDR(bool isOversea)
{
string keyName = isOversea ? OverseaKeyName : ChineseKeyName;
Registry.SetValue(keyName, WindowsHDROnValueName, 1);
}
private static (string KeyName, string ValueName) GetKeyValueName(SchemeType scheme)
{
return scheme switch
{
SchemeType.ChineseOfficial => (ChineseKeyName, SdkChineseValueName),
SchemeType.Oversea => (OverseaKeyName, SdkOverseaValueName),
_ => throw ThrowHelper.NotSupported($"Invalid account SchemeType: {scheme}"),
};
}
}

View File

@@ -34,21 +34,6 @@ internal readonly struct ChannelOptions
/// </summary>
public readonly string? ConfigFilePath;
/// <summary>
/// 构造一个新的多通道
/// </summary>
/// <param name="channel">通道</param>
/// <param name="subChannel">子通道</param>
/// <param name="isOversea">是否为国际服</param>
/// <param name="configFilePath">配置文件路径</param>
public ChannelOptions(string? channel, string? subChannel, bool isOversea, string? configFilePath = null)
{
_ = Enum.TryParse(channel, out Channel);
_ = Enum.TryParse(subChannel, out SubChannel);
IsOversea = isOversea;
ConfigFilePath = configFilePath;
}
public ChannelOptions(ChannelType channel, SubChannelType subChannel, bool isOversea)
{
Channel = channel;
@@ -56,24 +41,33 @@ internal readonly struct ChannelOptions
IsOversea = isOversea;
}
/// <summary>
/// 配置文件未找到
/// </summary>
/// <param name="isOversea">是否为国际服</param>
/// <param name="configFilePath">配置文件期望路径</param>
/// <returns>选项</returns>
public ChannelOptions(string? channel, string? subChannel, bool isOversea)
{
_ = Enum.TryParse(channel, out Channel);
_ = Enum.TryParse(subChannel, out SubChannel);
IsOversea = isOversea;
}
private ChannelOptions(bool isOversea, string? configFilePath)
{
IsOversea = isOversea;
ConfigFilePath = configFilePath;
}
public static ChannelOptions FileNotFound(bool isOversea, string configFilePath)
{
return new(null, null, isOversea, configFilePath);
return new(isOversea, configFilePath);
}
/// <inheritdoc/>
public override string ToString()
{
return $"[ChannelType:{Channel}] [SubChannel:{SubChannel}] [IsOversea: {IsOversea}]";
return $$"""
{ ChannelType: {{Channel}}, SubChannel: {{SubChannel}}, IsOversea: {{IsOversea}}}
""";
}
// DO NOT DELETE used in HashSet
// DO NOT DELETE, used in HashSet
public override int GetHashCode()
{
return HashCode.Combine(Channel, SubChannel, IsOversea);

View File

@@ -17,9 +17,12 @@ internal sealed partial class GameChannelOptionsService : IGameChannelOptionsSer
public ChannelOptions GetChannelOptions()
{
string gamePath = launchOptions.GamePath;
string configPath = Path.Combine(Path.GetDirectoryName(gamePath) ?? string.Empty, ConfigFileName);
bool isOversea = string.Equals(Path.GetFileName(gamePath), GenshinImpactFileName, StringComparison.OrdinalIgnoreCase);
if (!launchOptions.TryGetGamePathAndFilePathByName(ConfigFileName, out string gamePath, out string? configPath))
{
throw ThrowHelper.InvalidOperation($"Invalid game path: {gamePath}");
}
bool isOversea = LaunchScheme.ExecutableIsOversea(Path.GetFileName(gamePath));
if (!File.Exists(configPath))
{
@@ -38,10 +41,10 @@ internal sealed partial class GameChannelOptionsService : IGameChannelOptionsSer
public bool SetChannelOptions(LaunchScheme scheme)
{
string gamePath = launchOptions.GamePath;
string? directory = Path.GetDirectoryName(gamePath);
ArgumentException.ThrowIfNullOrEmpty(directory);
string configPath = Path.Combine(directory, ConfigFileName);
if (!launchOptions.TryGetGamePathAndFilePathByName(ConfigFileName, out string gamePath, out string? configPath))
{
return false;
}
List<IniElement> elements = default!;
try
@@ -70,14 +73,16 @@ internal sealed partial class GameChannelOptionsService : IGameChannelOptionsSer
{
if (element is IniParameter parameter)
{
if (parameter.Key == "channel")
if (parameter.Key is ChannelOptions.ChannelName)
{
changed = parameter.Set(scheme.Channel.ToString("D")) || changed;
continue;
}
if (parameter.Key == "sub_channel")
if (parameter.Key is ChannelOptions.SubChannelName)
{
changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed;
continue;
}
}
}

View File

@@ -9,38 +9,13 @@ namespace Snap.Hutao.Service.Game;
[HighQuality]
internal static class GameConstants
{
/// <summary>
/// 设置文件
/// </summary>
public const string ConfigFileName = "config.ini";
/// <summary>
/// 国服文件名
/// </summary>
public const string YuanShenFileName = "YuanShen.exe";
/// <summary>
/// 外服文件名
/// </summary>
public const string YuanShenFileNameUpper = "YUANSHEN.EXE";
public const string GenshinImpactFileName = "GenshinImpact.exe";
/// <summary>
/// 国服数据文件夹
/// </summary>
public const string GenshinImpactFileNameUpper = "GENSHINIMPACT.EXE";
public const string YuanShenData = "YuanShen_Data";
/// <summary>
/// 国际服数据文件夹
/// </summary>
public const string GenshinImpactData = "GenshinImpact_Data";
/// <summary>
/// 国服进程名
/// </summary>
public const string YuanShenProcessName = "YuanShen";
/// <summary>
/// 外服进程名
/// </summary>
public const string GenshinImpactProcessName = "GenshinImpact";
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
@@ -51,15 +52,15 @@ internal sealed partial class GameServiceFacade : IGameServiceFacade
}
/// <inheritdoc/>
public ValueTask<GameAccount?> DetectGameAccountAsync()
public ValueTask<GameAccount?> DetectGameAccountAsync(SchemeType scheme)
{
return gameAccountService.DetectGameAccountAsync();
return gameAccountService.DetectGameAccountAsync(scheme);
}
/// <inheritdoc/>
public GameAccount? DetectCurrentGameAccount()
public GameAccount? DetectCurrentGameAccount(SchemeType scheme)
{
return gameAccountService.DetectCurrentGameAccount();
return gameAccountService.DetectCurrentGameAccount(scheme);
}
/// <inheritdoc/>

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game.Scheme;
namespace Snap.Hutao.Service.Game;
internal static class GameServiceFacadeExtension
{
public static GameAccount? DetectCurrentGameAccount(this IGameServiceFacade gameServiceFacade, LaunchScheme scheme)
{
return gameServiceFacade.DetectCurrentGameAccount(scheme.GetSchemeType());
}
public static ValueTask<GameAccount?> DetectGameAccountAsync(this IGameServiceFacade gameServiceFacade, LaunchScheme scheme)
{
return gameServiceFacade.DetectGameAccountAsync(scheme.GetSchemeType());
}
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.Scheme;
@@ -28,7 +29,7 @@ internal interface IGameServiceFacade
/// <param name="uid">uid</param>
void AttachGameAccountToUid(GameAccount gameAccount, string uid);
ValueTask<GameAccount?> DetectGameAccountAsync();
ValueTask<GameAccount?> DetectGameAccountAsync(SchemeType scheme);
/// <summary>
/// 异步获取游戏路径
@@ -86,9 +87,5 @@ internal interface IGameServiceFacade
/// <returns>是否更改了ini文件</returns>
bool SetChannelOptions(LaunchScheme scheme);
/// <summary>
/// 检测账号
/// </summary>
/// <returns>账号</returns>
GameAccount? DetectCurrentGameAccount();
GameAccount? DetectCurrentGameAccount(SchemeType scheme);
}

View File

@@ -42,6 +42,7 @@ internal sealed class LaunchOptions : DbStoreOptions
private NameValue<int>? monitor;
private bool? isMonitorEnabled;
private bool? isUseCloudThirdPartyMobile;
private bool? isWindowsHDREnabled;
private AspectRatio? selectedAspectRatio;
private bool? useStarwardPlayTimeStatistics;
private bool? setDiscordActivityWhenPlaying;
@@ -198,6 +199,12 @@ internal sealed class LaunchOptions : DbStoreOptions
set => SetOption(ref isUseCloudThirdPartyMobile, SettingEntry.LaunchIsUseCloudThirdPartyMobile, value);
}
public bool IsWindowsHDREnabled
{
get => GetOption(ref isWindowsHDREnabled, SettingEntry.LaunchIsWindowsHDREnabled, false);
set => SetOption(ref isWindowsHDREnabled, SettingEntry.LaunchIsWindowsHDREnabled, value);
}
public List<AspectRatio> AspectRatios { get; } =
[
new(2560, 1440),

View File

@@ -9,12 +9,25 @@ namespace Snap.Hutao.Service.Game;
internal static class LaunchOptionsExtension
{
public static bool TryGetGameFolderAndFileName(this LaunchOptions options, [NotNullWhen(true)] out string? gameFolder, [NotNullWhen(true)] out string? gameFileName)
public static bool TryGetGamePathAndGameDirectory(this LaunchOptions options, out string gamePath, [NotNullWhen(true)] out string? gameDirectory)
{
gamePath = options.GamePath;
gameDirectory = Path.GetDirectoryName(gamePath);
if (string.IsNullOrEmpty(gameDirectory))
{
return false;
}
return true;
}
public static bool TryGetGameDirectoryAndGameFileName(this LaunchOptions options, [NotNullWhen(true)] out string? gameDirectory, [NotNullWhen(true)] out string? gameFileName)
{
string gamePath = options.GamePath;
gameFolder = Path.GetDirectoryName(gamePath);
if (string.IsNullOrEmpty(gameFolder))
gameDirectory = Path.GetDirectoryName(gamePath);
if (string.IsNullOrEmpty(gameDirectory))
{
gameFileName = default;
return false;
@@ -42,6 +55,18 @@ internal static class LaunchOptionsExtension
return true;
}
public static bool TryGetGamePathAndFilePathByName(this LaunchOptions options, string fileName, out string gamePath, [NotNullWhen(true)] out string? filePath)
{
if (options.TryGetGamePathAndGameDirectory(out gamePath, out string? gameDirectory))
{
filePath = Path.Combine(gameDirectory, fileName);
return true;
}
filePath = default;
return false;
}
public static ImmutableList<GamePathEntry> GetGamePathEntries(this LaunchOptions options, out GamePathEntry? entry)
{
string gamePath = options.GamePath;

View File

@@ -4,20 +4,13 @@
namespace Snap.Hutao.Service.Game.Locator;
[ConstructorGenerated]
[Injection(InjectAs.Transient, typeof(IGameLocatorFactory))]
[Injection(InjectAs.Singleton, typeof(IGameLocatorFactory))]
internal sealed partial class GameLocatorFactory : IGameLocatorFactory
{
[SuppressMessage("", "SH301")]
private readonly IServiceProvider serviceProvider;
public IGameLocator Create(GameLocationSource source)
{
return source switch
{
GameLocationSource.Registry => serviceProvider.GetRequiredService<RegistryLauncherLocator>(),
GameLocationSource.UnityLog => serviceProvider.GetRequiredService<UnityLogGameLocator>(),
GameLocationSource.Manual => serviceProvider.GetRequiredService<ManualGameLocator>(),
_ => throw Must.NeverHappen(),
};
return serviceProvider.GetRequiredKeyedService<IGameLocator>(source);
}
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game.Locator;
internal static class GameLocatorFactoryExtensions
{
public static ValueTask<ValueResult<bool, string>> LocateAsync(this IGameLocatorFactory factory, GameLocationSource source)
{
return factory.Create(source).LocateGamePathAsync();
}
}

View File

@@ -11,7 +11,7 @@ namespace Snap.Hutao.Service.Game.Locator;
/// </summary>
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Transient)]
[Injection(InjectAs.Transient, typeof(IGameLocator), Key = GameLocationSource.Manual)]
internal sealed partial class ManualGameLocator : IGameLocator
{
private readonly IFileSystemPickerInteraction fileSystemPickerInteraction;
@@ -26,7 +26,7 @@ internal sealed partial class ManualGameLocator : IGameLocator
if (isPickerOk)
{
string fileName = System.IO.Path.GetFileName(file);
if (fileName is GameConstants.YuanShenFileName or GameConstants.GenshinImpactFileName)
if (fileName.ToUpperInvariant() is GameConstants.YuanShenFileNameUpper or GameConstants.GenshinImpactFileNameUpper)
{
return ValueTask.FromResult<ValueResult<bool, string>>(new(true, file));
}

View File

@@ -13,9 +13,10 @@ namespace Snap.Hutao.Service.Game.Locator;
/// </summary>
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Transient)]
[Injection(InjectAs.Transient, typeof(IGameLocator), Key = GameLocationSource.Registry)]
internal sealed partial class RegistryLauncherLocator : IGameLocator
{
private const string RegistryKeyName = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\原神";
private readonly ITaskContext taskContext;
/// <inheritdoc/>
@@ -29,50 +30,37 @@ internal sealed partial class RegistryLauncherLocator : IGameLocator
{
return result;
}
else
{
string? path = Path.GetDirectoryName(result.Value);
ArgumentException.ThrowIfNullOrEmpty(path);
string configPath = Path.Combine(path, GameConstants.ConfigFileName);
string? escapedPath;
using (FileStream stream = File.OpenRead(configPath))
{
IEnumerable<IniElement> elements = IniSerializer.Deserialize(stream);
escapedPath = elements
.OfType<IniParameter>()
.FirstOrDefault(p => p.Key == "game_install_path")?.Value;
}
if (escapedPath is not null)
{
string gamePath = Path.Combine(Unescape(escapedPath), GameConstants.YuanShenFileName);
return new(true, gamePath);
}
string? path = Path.GetDirectoryName(result.Value);
ArgumentException.ThrowIfNullOrEmpty(path);
string configPath = Path.Combine(path, GameConstants.ConfigFileName);
string? escapedPath;
using (FileStream stream = File.OpenRead(configPath))
{
IEnumerable<IniElement> elements = IniSerializer.Deserialize(stream);
escapedPath = elements
.OfType<IniParameter>()
.FirstOrDefault(p => p.Key == "game_install_path")?.Value;
}
if (!string.IsNullOrEmpty(escapedPath))
{
string gamePath = Path.Combine(Unescape(escapedPath), GameConstants.YuanShenFileName);
return new(true, gamePath);
}
return new(false, string.Empty);
}
private static ValueResult<bool, string> LocateInternal(string key)
private static ValueResult<bool, string> LocateInternal(string valueName)
{
using (RegistryKey? uninstallKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\原神"))
if (Registry.GetValue(RegistryKeyName, valueName, null) is string path)
{
if (uninstallKey is not null)
{
if (uninstallKey.GetValue(key) is string path)
{
return new(true, path);
}
else
{
return new(false, default!);
}
}
else
{
return new(false, default!);
}
return new(true, path);
}
return new(false, default!);
}
private static string Unescape(string str)

View File

@@ -12,7 +12,7 @@ namespace Snap.Hutao.Service.Game.Locator;
/// </summary>
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Transient)]
[Injection(InjectAs.Transient, typeof(IGameLocator), Key = GameLocationSource.UnityLog)]
internal sealed partial class UnityLogGameLocator : IGameLocator
{
private readonly ITaskContext taskContext;

View File

@@ -21,7 +21,7 @@ internal sealed partial class GamePackageService : IGamePackageService
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
if (!launchOptions.TryGetGameFolderAndFileName(out string? gameFolder, out string? gameFileName))
if (!launchOptions.TryGetGameDirectoryAndGameFileName(out string? gameFolder, out string? gameFileName))
{
return false;
}
@@ -47,8 +47,7 @@ internal sealed partial class GamePackageService : IGamePackageService
if (!launchScheme.ExecutableMatches(gameFileName))
{
// We can't start the game
// when we failed to convert game
// We can't start the game when we failed to convert game
if (!await packageConverter.EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress).ConfigureAwait(false))
{
return false;
@@ -67,6 +66,13 @@ internal sealed partial class GamePackageService : IGamePackageService
private static bool CheckDirectoryPermissions(string folder)
{
// Program Files has special permissions limitation.
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
if (folder.StartsWith(programFiles, StringComparison.OrdinalIgnoreCase))
{
return false;
}
try
{
string tempFilePath = Path.Combine(folder, $"{Guid.NewGuid():N}.tmp");

View File

@@ -15,6 +15,7 @@ using System.IO.Compression;
using System.Net.Http;
using System.Text.RegularExpressions;
using static Snap.Hutao.Service.Game.GameConstants;
using RelativePathVersionItemDictionary = System.Collections.Generic.Dictionary<string, Snap.Hutao.Service.Game.Package.VersionItem>;
namespace Snap.Hutao.Service.Game.Package;
@@ -58,15 +59,15 @@ internal sealed partial class PackageConverter
string scatteredFilesUrl = gameResource.Game.Latest.DecompressedPath;
string pkgVersionUrl = $"{scatteredFilesUrl}/{PackageVersion}";
PackageConvertContext context = new(targetScheme.IsOversea, runtimeOptions.DataFolder, gameFolder, scatteredFilesUrl);
PackageConverterFileSystemContext context = new(targetScheme.IsOversea, runtimeOptions.DataFolder, gameFolder, scatteredFilesUrl);
// Step 1
progress.Report(new(SH.ServiceGamePackageRequestPackageVerion));
Dictionary<string, VersionItem> remoteItems = await GetRemoteItemsAsync(pkgVersionUrl).ConfigureAwait(false);
Dictionary<string, VersionItem> localItems = await GetLocalItemsAsync(gameFolder).ConfigureAwait(false);
RelativePathVersionItemDictionary remoteItems = await GetRemoteItemsAsync(pkgVersionUrl).ConfigureAwait(false);
RelativePathVersionItemDictionary localItems = await GetLocalItemsAsync(gameFolder).ConfigureAwait(false);
// Step 2
List<ItemOperationInfo> diffOperations = GetItemOperationInfos(remoteItems, localItems).ToList();
List<PackageItemOperationInfo> diffOperations = GetItemOperationInfos(remoteItems, localItems).ToList();
diffOperations.SortBy(i => i.Type);
// Step 3
@@ -116,16 +117,16 @@ internal sealed partial class PackageConverter
}
}
private static IEnumerable<ItemOperationInfo> GetItemOperationInfos(Dictionary<string, VersionItem> remote, Dictionary<string, VersionItem> local)
private static IEnumerable<PackageItemOperationInfo> GetItemOperationInfos(RelativePathVersionItemDictionary remote, RelativePathVersionItemDictionary local)
{
foreach ((string remoteName, VersionItem remoteItem) in remote)
{
if (local.TryGetValue(remoteName, out VersionItem? localItem))
{
if (!remoteItem.Md5.Equals(localItem.Md5, StringComparison.OrdinalIgnoreCase))
if (!(remoteItem.FileSize == localItem.FileSize && remoteItem.Md5.Equals(localItem.Md5, StringComparison.OrdinalIgnoreCase)))
{
// 本地发现了同名且不同 MD5 的项,需要替换为服务器上的项
yield return new(ItemOperationType.Replace, remoteItem, localItem);
yield return new(PackageItemOperationType.Replace, remoteItem, localItem);
}
// 同名同MD5跳过
@@ -134,22 +135,22 @@ internal sealed partial class PackageConverter
else
{
// 本地没有发现同名项
yield return new(ItemOperationType.Add, remoteItem, remoteItem);
yield return new(PackageItemOperationType.Add, remoteItem, remoteItem);
}
}
foreach ((_, VersionItem localItem) in local)
{
yield return new(ItemOperationType.Backup, localItem, localItem);
yield return new(PackageItemOperationType.Backup, localItem, localItem);
}
}
[GeneratedRegex("^(?:YuanShen_Data|GenshinImpact_Data)(?=/)")]
private static partial Regex DataFolderRegex();
private async ValueTask<Dictionary<string, VersionItem>> GetVersionItemsAsync(Stream stream)
private async ValueTask<RelativePathVersionItemDictionary> GetVersionItemsAsync(Stream stream)
{
Dictionary<string, VersionItem> results = [];
RelativePathVersionItemDictionary results = [];
using (StreamReader reader = new(stream))
{
while (await reader.ReadLineAsync().ConfigureAwait(false) is { Length: > 0 } row)
@@ -164,7 +165,7 @@ internal sealed partial class PackageConverter
return results;
}
private async ValueTask<Dictionary<string, VersionItem>> GetRemoteItemsAsync(string pkgVersionUrl)
private async ValueTask<RelativePathVersionItemDictionary> GetRemoteItemsAsync(string pkgVersionUrl)
{
try
{
@@ -179,7 +180,7 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask<Dictionary<string, VersionItem>> GetLocalItemsAsync(string gameFolder)
private async ValueTask<RelativePathVersionItemDictionary> GetLocalItemsAsync(string gameFolder)
{
using (FileStream localSteam = File.OpenRead(Path.Combine(gameFolder, PackageVersion)))
{
@@ -187,23 +188,23 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask PrepareCacheFilesAsync(List<ItemOperationInfo> operations, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask PrepareCacheFilesAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
{
foreach (ItemOperationInfo info in operations)
foreach (PackageItemOperationInfo info in operations)
{
switch (info.Type)
{
case ItemOperationType.Backup:
case PackageItemOperationType.Backup:
continue;
case ItemOperationType.Replace:
case ItemOperationType.Add:
case PackageItemOperationType.Replace:
case PackageItemOperationType.Add:
await SkipOrDownloadAsync(info, context, progress).ConfigureAwait(false);
break;
}
}
}
private async ValueTask SkipOrDownloadAsync(ItemOperationInfo info, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask SkipOrDownloadAsync(PackageItemOperationInfo info, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
{
// 还原正确的远程地址
string remoteName = string.Format(CultureInfo.CurrentCulture, info.Remote.RelativePath, context.ToDataFolderName);
@@ -257,16 +258,16 @@ internal sealed partial class PackageConverter
}
}
private async ValueTask<bool> ReplaceGameResourceAsync(List<ItemOperationInfo> operations, PackageConvertContext context, IProgress<PackageReplaceStatus> progress)
private async ValueTask<bool> ReplaceGameResourceAsync(List<PackageItemOperationInfo> operations, PackageConverterFileSystemContext context, IProgress<PackageReplaceStatus> progress)
{
// 执行下载与移动操作
foreach (ItemOperationInfo info in operations)
foreach (PackageItemOperationInfo info in operations)
{
(bool moveToBackup, bool moveToTarget) = info.Type switch
{
ItemOperationType.Backup => (true, false),
ItemOperationType.Replace => (true, true),
ItemOperationType.Add => (false, true),
PackageItemOperationType.Backup => (true, false),
PackageItemOperationType.Replace => (true, true),
PackageItemOperationType.Add => (false, true),
_ => (false, false),
};
@@ -321,7 +322,7 @@ internal sealed partial class PackageConverter
return true;
}
private async ValueTask ReplacePackageVersionFilesAsync(PackageConvertContext context)
private async ValueTask ReplacePackageVersionFilesAsync(PackageConverterFileSystemContext context)
{
foreach (string versionFilePath in Directory.EnumerateFiles(context.GameFolder, "*pkg_version"))
{

View File

@@ -6,7 +6,7 @@ using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Package;
internal readonly struct PackageConvertContext
internal readonly struct PackageConverterFileSystemContext
{
public readonly string GameFolder;
public readonly string ServerCacheFolder;
@@ -22,7 +22,7 @@ internal readonly struct PackageConvertContext
public readonly string ScatteredFilesUrl;
public readonly string PkgVersionUrl;
public PackageConvertContext(bool isTargetOversea, string dataFolder, string gameFolder, string scatteredFilesUrl)
public PackageConverterFileSystemContext(bool isTargetOversea, string dataFolder, string gameFolder, string scatteredFilesUrl)
{
GameFolder = gameFolder;
ServerCacheFolder = Path.Combine(dataFolder, "ServerCache");
@@ -37,7 +37,8 @@ internal readonly struct PackageConvertContext
? (YuanShenData, GenshinImpactData)
: (GenshinImpactData, YuanShenData);
(FromDataFolder, ToDataFolder) = (Path.Combine(GameFolder, FromDataFolderName), Path.Combine(GameFolder, ToDataFolderName));
FromDataFolder = Path.Combine(GameFolder, FromDataFolderName);
ToDataFolder = Path.Combine(GameFolder, ToDataFolderName);
ScatteredFilesUrl = scatteredFilesUrl;
PkgVersionUrl = $"{scatteredFilesUrl}/pkg_version";

View File

@@ -10,12 +10,12 @@ namespace Snap.Hutao.Service.Game.Package;
/// </summary>
[HighQuality]
[DebuggerDisplay("Action:{Type} Target:{Target} Cache:{Cache}")]
internal readonly struct ItemOperationInfo
internal readonly struct PackageItemOperationInfo
{
/// <summary>
/// 操作的类型
/// </summary>
public readonly ItemOperationType Type;
public readonly PackageItemOperationType Type;
/// <summary>
/// 目标文件
@@ -33,7 +33,7 @@ internal readonly struct ItemOperationInfo
/// <param name="type">操作类型</param>
/// <param name="remote">远程</param>
/// <param name="local">本地</param>
public ItemOperationInfo(ItemOperationType type, VersionItem remote, VersionItem local)
public PackageItemOperationInfo(PackageItemOperationType type, VersionItem remote, VersionItem local)
{
Type = type;
Remote = remote;

View File

@@ -7,7 +7,7 @@ namespace Snap.Hutao.Service.Game.Package;
/// 包文件操作的类型
/// </summary>
[HighQuality]
internal enum ItemOperationType
internal enum PackageItemOperationType
{
/// <summary>
/// 需要备份

View File

@@ -2,14 +2,13 @@
// Licensed under the MIT license.
using CommunityToolkit.Common;
using Snap.Hutao.Core.Abstraction;
namespace Snap.Hutao.Service.Game.Package;
/// <summary>
/// 包更新状态
/// </summary>
internal sealed class PackageReplaceStatus : ICloneable<PackageReplaceStatus>
internal sealed class PackageReplaceStatus
{
/// <summary>
/// 构造一个新的包更新状态
@@ -18,7 +17,7 @@ internal sealed class PackageReplaceStatus : ICloneable<PackageReplaceStatus>
public PackageReplaceStatus(string name)
{
Name = name;
Description = default!;
Description = name;
}
/// <summary>
@@ -34,10 +33,6 @@ internal sealed class PackageReplaceStatus : ICloneable<PackageReplaceStatus>
Description = $"{Converters.ToFileSizeString(bytesRead)}/{Converters.ToFileSizeString(totalBytes)}";
}
private PackageReplaceStatus()
{
}
public string Name { get; set; } = default!;
/// <summary>
@@ -54,19 +49,4 @@ internal sealed class PackageReplaceStatus : ICloneable<PackageReplaceStatus>
/// 是否有进度
/// </summary>
public bool IsIndeterminate { get => Percent < 0; }
/// <summary>
/// 克隆
/// </summary>
/// <returns>克隆的实例</returns>
public PackageReplaceStatus Clone()
{
// 进度需要在主线程上创建
return new()
{
Name = Name,
Description = Description,
Percent = Percent,
};
}
}

View File

@@ -9,7 +9,7 @@ namespace Snap.Hutao.Service.Game.PathAbstraction;
[Injection(InjectAs.Singleton, typeof(IGamePathService))]
internal sealed partial class GamePathService : IGamePathService
{
private readonly IServiceProvider serviceProvider;
private readonly IGameLocatorFactory gameLocatorFactory;
private readonly LaunchOptions launchOptions;
public async ValueTask<ValueResult<bool, string>> SilentGetGamePathAsync()
@@ -17,24 +17,16 @@ internal sealed partial class GamePathService : IGamePathService
// Cannot find in setting
if (string.IsNullOrEmpty(launchOptions.GamePath))
{
IGameLocatorFactory locatorFactory = serviceProvider.GetRequiredService<IGameLocatorFactory>();
bool isOk;
string path;
// Try locate by unity log
(isOk, path) = await locatorFactory
.Create(GameLocationSource.UnityLog)
.LocateGamePathAsync()
.ConfigureAwait(false);
(isOk, path) = await gameLocatorFactory.LocateAsync(GameLocationSource.UnityLog).ConfigureAwait(false);
if (!isOk)
{
// Try locate by registry
(isOk, path) = await locatorFactory
.Create(GameLocationSource.Registry)
.LocateGamePathAsync()
.ConfigureAwait(false);
(isOk, path) = await gameLocatorFactory.LocateAsync(GameLocationSource.Registry).ConfigureAwait(false);
}
if (isOk)
@@ -48,13 +40,11 @@ internal sealed partial class GamePathService : IGamePathService
}
}
if (!string.IsNullOrEmpty(launchOptions.GamePath))
{
return new(true, launchOptions.GamePath);
}
else
if (string.IsNullOrEmpty(launchOptions.GamePath))
{
return new(false, default!);
}
return new(true, launchOptions.GamePath);
}
}

View File

@@ -2,7 +2,9 @@
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Service.Discord;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Game.Unlocker;
using System.IO;
@@ -18,6 +20,7 @@ namespace Snap.Hutao.Service.Game.Process;
internal sealed partial class GameProcessService : IGameProcessService
{
private readonly IServiceProvider serviceProvider;
private readonly IProgressFactory progressFactory;
private readonly IDiscordService discordService;
private readonly RuntimeOptions runtimeOptions;
private readonly LaunchOptions launchOptions;
@@ -59,6 +62,11 @@ internal sealed partial class GameProcessService : IGameProcessService
bool isOversea = LaunchScheme.ExecutableIsOversea(gameFileName);
if (launchOptions.IsWindowsHDREnabled)
{
RegistryInterop.SetWindowsHDR(isOversea);
}
progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (System.Diagnostics.Process game = InitializeGameProcess(gamePath))
{
@@ -109,13 +117,13 @@ internal sealed partial class GameProcessService : IGameProcessService
// https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html
// https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html
commandLine = new CommandLineBuilder()
.AppendIf("-popupwindow", launchOptions.IsBorderless)
.AppendIf("-window-mode", launchOptions.IsExclusive, "exclusive")
.AppendIf(launchOptions.IsBorderless, "-popupwindow")
.AppendIf(launchOptions.IsExclusive, "-window-mode", "exclusive")
.Append("-screen-fullscreen", launchOptions.IsFullScreen ? 1 : 0)
.AppendIf("-screen-width", launchOptions.IsScreenWidthEnabled, launchOptions.ScreenWidth)
.AppendIf("-screen-height", launchOptions.IsScreenHeightEnabled, launchOptions.ScreenHeight)
.AppendIf("-monitor", launchOptions.IsMonitorEnabled, launchOptions.Monitor.Value)
.AppendIf("-platform_type CLOUD_THIRD_PARTY_MOBILE", launchOptions.IsUseCloudThirdPartyMobile)
.AppendIf(launchOptions.IsScreenWidthEnabled, "-screen-width", launchOptions.ScreenWidth)
.AppendIf(launchOptions.IsScreenHeightEnabled, "-screen-height", launchOptions.ScreenHeight)
.AppendIf(launchOptions.IsMonitorEnabled, "-monitor", launchOptions.Monitor.Value)
.AppendIf(launchOptions.IsUseCloudThirdPartyMobile, "-platform_type CLOUD_THIRD_PARTY_MOBILE")
.ToString();
}
@@ -138,7 +146,7 @@ internal sealed partial class GameProcessService : IGameProcessService
IGameFpsUnlocker unlocker = serviceProvider.CreateInstance<GameFpsUnlocker>(game);
#pragma warning restore CA1859
UnlockTimingOptions options = new(100, 20000, 3000);
Progress<UnlockerStatus> lockerProgress = new(unlockStatus => progress.Report(LaunchStatus.FromUnlockStatus(unlockStatus)));
IProgress<UnlockerStatus> lockerProgress = progressFactory.CreateForMainThread<UnlockerStatus>(unlockStatus => progress.Report(LaunchStatus.FromUnlockStatus(unlockStatus)));
return unlocker.UnlockAsync(options, lockerProgress, token);
}

View File

@@ -59,11 +59,11 @@ internal class LaunchScheme : IEquatable<ChannelOptions>
public static bool ExecutableIsOversea(string gameFileName)
{
return gameFileName switch
return gameFileName.ToUpperInvariant() switch
{
GameConstants.GenshinImpactFileName => true,
GameConstants.YuanShenFileName => false,
_ => throw Requires.Fail("无效的游戏可执行文件名称{0}", gameFileName),
GameConstants.GenshinImpactFileNameUpper => true,
GameConstants.YuanShenFileNameUpper => false,
_ => throw Requires.Fail("Invalid game executable file name{0}", gameFileName),
};
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game.Scheme;
internal static class LaunchSchemeExtension
{
public static SchemeType GetSchemeType(this LaunchScheme scheme)
{
return (scheme.Channel, scheme.IsOversea) switch
{
(ChannelType.Bili, false) => SchemeType.ChineseBilibili,
(_, false) => SchemeType.ChineseOfficial,
(_, true) => SchemeType.Oversea,
};
}
}

View File

@@ -228,7 +228,7 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker
nuint rip = localMemoryUserAssemblyAddress + (uint)offset;
rip += 5U;
rip += (nuint)(*(int*)(rip + 2) + 6);
rip += (nuint)(*(int*)(rip + 2U) + 6);
nuint address = userAssembly.Address + (rip - localMemoryUserAssemblyAddress);
@@ -236,6 +236,8 @@ internal sealed class GameFpsUnlocker : IGameFpsUnlocker
SpinWait.SpinUntil(() => UnsafeReadProcessMemory(gameProcess, address, out ptr) && ptr != 0);
rip = ptr - unityPlayer.Address + localMemoryUnityPlayerAddress;
// CALL or JMP
while (*(byte*)rip == 0xE8 || *(byte*)rip == 0xE9)
{
rip += (nuint)(*(int*)(rip + 1) + 5);

View File

@@ -23,7 +23,7 @@ internal sealed partial class DocumentationProvider : IDocumentationProvider
[typeof(LoginHoyoverseUserPage)] = "https://hut.ao/features/mhy-account-switch.html",
[typeof(LoginMihoyoUserPage)] = "https://hut.ao/features/mhy-account-switch.html",
[typeof(SettingPage)] = "https://hut.ao/features/hutao-settings.html",
[typeof(SpiralAbyssRecordPage)] = "https://hut.ao/features/dashboard.html",
[typeof(SpiralAbyssRecordPage)] = "https://hut.ao/features/hutao-API.html",
[typeof(TestPage)] = Home,
[typeof(WikiAvatarPage)] = "https://hut.ao/features/character-wiki.html",
[typeof(WikiMonsterPage)] = "https://hut.ao/features/monster-wiki.html",

View File

@@ -173,6 +173,7 @@
<None Remove="View\Dialog\HutaoPassportUnregisterDialog.xaml" />
<None Remove="View\Dialog\LaunchGameAccountNameDialog.xaml" />
<None Remove="View\Dialog\LaunchGamePackageConvertDialog.xaml" />
<None Remove="View\Dialog\ReconfirmDialog.xaml" />
<None Remove="View\Dialog\UserDialog.xaml" />
<None Remove="View\Dialog\UserQRCodeDialog.xaml" />
<None Remove="View\Guide\GuideView.xaml" />
@@ -303,12 +304,16 @@
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="Microsoft.Graphics.Win2D" Version="1.1.1" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.VisualStudio.Validation" Version="17.8.8" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.22621.2428" />
<PackageReference Include="Microsoft.WindowsAppSDK" Version="1.4.231115000" />
<PackageReference Include="QRCoder" Version="1.4.3" />
<PackageReference Include="Snap.Discord.GameSDK" Version="1.5.0" />
<PackageReference Include="Snap.Hutao.Deployment.Runtime" Version="1.5.0">
<PackageReference Include="Snap.Discord.GameSDK" Version="1.6.0" />
<PackageReference Include="Snap.Hutao.Deployment.Runtime" Version="1.9.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -339,6 +344,11 @@
<ItemGroup>
<None Include="..\.editorconfig" Link=".editorconfig" />
</ItemGroup>
<ItemGroup>
<Page Update="View\Dialog\ReconfirmDialog.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Page Update="View\Dialog\UserQRCodeDialog.xaml">
<Generator>MSBuild:Compile</Generator>

View File

@@ -67,7 +67,7 @@
VerticalAlignment="Bottom">
<ComboBox
DisplayMemberPath="Name"
ItemsSource="{Binding GameAccounts}"
ItemsSource="{Binding GameAccountsView}"
PlaceholderText="{shcm:ResourceString Name=ViewCardLaunchGameSelectAccountPlaceholder}"
SelectedItem="{Binding SelectedGameAccount, Mode=TwoWay}"/>
</shc:SizeRestrictedContentControl>

View File

@@ -76,16 +76,15 @@ internal partial class WebViewer : UserControl, IRecipient<UserChangedMessage>
private async ValueTask InitializeAsync()
{
if (isInitializingOrInitialized)
if (!isInitializingOrInitialized)
{
return;
isInitializingOrInitialized = true;
await WebView.EnsureCoreWebView2Async();
WebView.CoreWebView2.DisableDevToolsForReleaseBuild();
WebView.CoreWebView2.DocumentTitleChanged += documentTitleChangedEventHander;
}
isInitializingOrInitialized = true;
await WebView.EnsureCoreWebView2Async();
WebView.CoreWebView2.DisableDevToolsForReleaseBuild();
WebView.CoreWebView2.DocumentTitleChanged += documentTitleChangedEventHander;
RefreshWebview2Content();
}
@@ -128,6 +127,9 @@ internal partial class WebViewer : UserControl, IRecipient<UserChangedMessage>
string source = SourceProvider.GetSource(userAndUid);
if (!string.IsNullOrEmpty(source))
{
CoreWebView2Navigator navigator = new(coreWebView2);
await navigator.NavigateAsync("about:blank").ConfigureAwait(true);
try
{
await coreWebView2.Profile.ClearBrowsingDataAsync();
@@ -138,9 +140,6 @@ internal partial class WebViewer : UserControl, IRecipient<UserChangedMessage>
await coreWebView2.DeleteCookiesAsync(userAndUid.IsOversea).ConfigureAwait(true);
}
CoreWebView2Navigator navigator = new(coreWebView2);
await navigator.NavigateAsync("about:blank").ConfigureAwait(true);
coreWebView2
.SetCookie(user.CookieToken, user.LToken, userAndUid.IsOversea)
.SetMobileUserAgent(userAndUid.IsOversea);

View File

@@ -0,0 +1,21 @@
<ContentDialog
x:Class="Snap.Hutao.View.Dialog.ReconfirmDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:shcm="using:Snap.Hutao.Control.Markup"
Title="{shcm:ResourceString Name=ViewDialogReconfirmTitle}"
CloseButtonText="{shcm:ResourceString Name=ContentDialogCancelCloseButtonText}"
DefaultButton="Close"
IsPrimaryButtonEnabled="False"
PrimaryButtonText="{shcm:ResourceString Name=ContentDialogConfirmPrimaryButtonText}"
Style="{StaticResource DefaultContentDialogStyle}"
mc:Ignorable="d">
<TextBox
Margin="0,0,0,8"
VerticalAlignment="Top"
Header="{shcm:ResourceString Name=ViewDialogReconfirmTextHeader}"
Text="{x:Bind Text, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
</ContentDialog>

View File

@@ -0,0 +1,35 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Snap.Hutao.View.Dialog;
[DependencyProperty("Text", typeof(string), default(string), nameof(OnTextChanged))]
internal sealed partial class ReconfirmDialog : ContentDialog
{
private readonly ITaskContext taskContext;
public ReconfirmDialog(IServiceProvider serviceProvider)
{
InitializeComponent();
taskContext = serviceProvider.GetRequiredService<ITaskContext>();
}
public string ConfirmText { get; private set; } = default!;
public async ValueTask<bool> ConfirmAsync(string confirmText)
{
await taskContext.SwitchToMainThreadAsync();
ConfirmText = confirmText;
return await ShowAsync() is ContentDialogResult.Primary;
}
private static void OnTextChanged(DependencyObject sender, DependencyPropertyChangedEventArgs args)
{
ReconfirmDialog dialog = (ReconfirmDialog)sender;
dialog.IsPrimaryButtonEnabled = string.Equals(dialog.Text, dialog.ConfirmText, StringComparison.Ordinal);
}
}

View File

@@ -42,7 +42,12 @@
VerticalAlignment="Top"
cw:VisualExtensions.NormalizedCenterPoint="0.5">
<cww:ConstrainedBox AspectRatio="1080:390" CornerRadius="{ThemeResource ControlCornerRadiusTop}">
<shci:CachedImage Source="{Binding Banner}" Stretch="UniformToFill"/>
<shci:CachedImage
VerticalAlignment="Center"
PlaceholderMargin="16"
PlaceholderSource="{StaticResource UI_EmotionIcon271}"
Source="{Binding Banner}"
Stretch="UniformToFill"/>
</cww:ConstrainedBox>
<cwa:Explicit.Animations>
<cwa:AnimationSet x:Name="ImageZoomInAnimation">

View File

@@ -7,8 +7,6 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:mxi="using:Microsoft.Xaml.Interactivity"
xmlns:mxic="using:Microsoft.Xaml.Interactions.Core"
xmlns:mxim="using:Microsoft.Xaml.Interactions.Media"
xmlns:shc="using:Snap.Hutao.Control"
xmlns:shcb="using:Snap.Hutao.Control.Behavior"
xmlns:shccs="using:Snap.Hutao.Control.Collection.Selector"
@@ -198,9 +196,15 @@
<Border Style="{StaticResource BorderCardStyle}">
<ListView
ItemTemplate="{StaticResource GameAccountListTemplate}"
ItemsSource="{Binding GameAccounts}"
ItemsSource="{Binding GameAccountsView}"
SelectedItem="{Binding SelectedGameAccount, Mode=TwoWay}"/>
</Border>
<cwc:SettingsCard
Description="{shcm:ResourceString Name=ViewPageLaunchGameWindowsHDRDescription}"
Header="{shcm:ResourceString Name=ViewPageLaunchGameWindowsHDRHeader}"
HeaderIcon="{shcm:FontIcon Glyph=&#xE7F7;}">
<ToggleSwitch Width="120" IsOn="{Binding LaunchOptions.IsWindowsHDREnabled, Mode=TwoWay}"/>
</cwc:SettingsCard>
<!-- 进程 -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="{shcm:ResourceString Name=ViewPageLaunchGameProcessHeader}"/>
@@ -208,8 +212,7 @@
shch:SettingsExpanderHelper.IsItemsEnabled="{Binding LaunchOptions.IsEnabled}"
Description="{shcm:ResourceString Name=ViewPageLaunchGameArgumentsDescription}"
Header="{shcm:ResourceString Name=ViewPageLaunchGameArgumentsHeader}"
HeaderIcon="{shcm:FontIcon Glyph=&#xE943;}"
IsExpanded="True">
HeaderIcon="{shcm:FontIcon Glyph=&#xE943;}">
<ToggleSwitch Width="120" IsOn="{Binding LaunchOptions.IsEnabled, Mode=TwoWay}"/>
<cwc:SettingsExpander.Items>
<cwc:SettingsCard Description="{shcm:ResourceString Name=ViewPageLaunchGameAppearanceExclusiveDescription}" Header="-window-mode exclusive">

View File

@@ -374,9 +374,14 @@
Description="{shcm:ResourceString Name=ViewPageSettingSetDataFolderDescription}"
Header="{shcm:ResourceString Name=ViewPageSettingSetDataFolderHeader}"
IsClickEnabled="True"/>
<cwc:SettingsCard
ActionIcon="{shcm:FontIcon Glyph=&#xE76C;}"
Command="{Binding DeleteServerCacheFolderCommand}"
Description="{shcm:ResourceString Name=ViewSettingDeleteServerCacheFolderDescription}"
Header="{shcm:ResourceString Name=ViewSettingDeleteServerCacheFolderHeader}"
IsClickEnabled="True"/>
</cwc:SettingsExpander.Items>
</cwc:SettingsExpander>
<cwc:SettingsExpander
Description="{Binding CacheFolderView.Size}"
Header="{shcm:ResourceString Name=ViewPageSettingCacheFolderHeader}"
@@ -408,7 +413,6 @@
HeaderIcon="{cw:FontIcon Glyph=&#xE756;}">
<ToggleSwitch Width="120" IsOn="{Binding IsAllocConsoleDebugModeEnabled, Mode=TwoWay}"/>
</cwc:SettingsCard>
<cwc:SettingsCard
Header="{shcm:ResourceString Name=ViewPageSettingIsAdvancedLaunchOptionsEnabledHeader}"
HeaderIcon="{shcm:FontIcon Glyph=&#xE730;}"
@@ -424,7 +428,7 @@
</cwc:SettingsCard.Description>
<StackPanel Orientation="Horizontal">
<shvc:Elevation Visibility="{Binding HutaoOptions.IsElevated, Converter={StaticResource BoolToVisibilityRevertConverter}}"/>
<ToggleSwitch Width="120" IsOn="{Binding LaunchOptions.IsAdvancedLaunchOptionsEnabled, Mode=TwoWay}"/>
<ToggleSwitch Width="120" IsOn="{Binding IsAdvancedLaunchOptionsEnabled, Mode=TwoWay}"/>
</StackPanel>
</cwc:SettingsCard>
<cwc:SettingsCard
@@ -443,4 +447,4 @@
</StackPanel>
</Grid>
</ScrollViewer>
</shc:ScopedPage>
</shc:ScopedPage>

View File

@@ -1,7 +1,6 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Snap.Hutao.ViewModel;

View File

@@ -0,0 +1,27 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
namespace Snap.Hutao.ViewModel.Game;
internal sealed class GameAccountFilter
{
private readonly SchemeType? type;
public GameAccountFilter(SchemeType? type)
{
this.type = type;
}
public bool Filter(object? item)
{
if (type is null)
{
return true;
}
return item is GameAccount account && account.Type == type;
}
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Notification;
namespace Snap.Hutao.ViewModel.Game;
internal static class LaunchGameShared
{
public static LaunchScheme? GetCurrentLaunchSchemeFromConfigFile(IGameServiceFacade gameService, IInfoBarService infoBarService)
{
ChannelOptions options;
try
{
options = gameService.GetChannelOptions();
}
catch (InvalidOperationException)
{
return default;
}
if (string.IsNullOrEmpty(options.ConfigFilePath))
{
try
{
return KnownLaunchSchemes.Get().Single(scheme => scheme.Equals(options));
}
catch (InvalidOperationException)
{
if (!IgnoredInvalidChannelOptions.Contains(options))
{
// 后台收集
throw ThrowHelper.NotSupported($"不支持的 MultiChannel: {options}");
}
}
}
else
{
infoBarService.Warning(SH.FormatViewModelLaunchGameMultiChannelReadFail(options.ConfigFilePath));
}
return default;
}
}

View File

@@ -1,17 +1,17 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.Data.Sqlite;
using CommunityToolkit.WinUI.Collections;
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Control.Extension;
using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics.CodeAnalysis;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Locator;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.PathAbstraction;
@@ -56,46 +56,13 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
private readonly AppOptions appOptions;
private LaunchScheme? selectedScheme;
private ObservableCollection<GameAccount>? gameAccounts;
private AdvancedCollectionView? gameAccountsView;
private GameAccount? selectedGameAccount;
private GameResource? gameResource;
private bool gamePathSelectedAndValid;
private ImmutableList<GamePathEntry> gamePathEntries;
private GamePathEntry? selectedGamePathEntry;
public List<LaunchScheme> KnownSchemes { get; } = KnownLaunchSchemes.Get();
public LaunchScheme? SelectedScheme
{
get => selectedScheme;
set
{
SetProperty(ref selectedScheme, value, UpdateGameResourceAsync);
async ValueTask UpdateGameResourceAsync(LaunchScheme? scheme)
{
if (scheme is null)
{
return;
}
await taskContext.SwitchToBackgroundAsync();
Web.Response.Response<GameResource> response = await resourceClient
.GetResourceAsync(scheme)
.ConfigureAwait(false);
if (response.IsOk())
{
await taskContext.SwitchToMainThreadAsync();
GameResource = response.Data;
}
}
}
}
public ObservableCollection<GameAccount>? GameAccounts { get => gameAccounts; set => SetProperty(ref gameAccounts, value); }
public GameAccount? SelectedGameAccount { get => selectedGameAccount; set => SetProperty(ref selectedGameAccount, value); }
private GameAccountFilter? gameAccountFilter;
public LaunchOptions LaunchOptions { get => launchOptions; }
@@ -105,8 +72,22 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
public AppOptions AppOptions { get => appOptions; }
public List<LaunchScheme> KnownSchemes { get; } = KnownLaunchSchemes.Get();
[AlsoAsyncSets(nameof(GameResource), nameof(GameAccountsView))]
public LaunchScheme? SelectedScheme
{
get => selectedScheme;
set => SetSelectedSchemeAsync(value).SafeForget();
}
public AdvancedCollectionView? GameAccountsView { get => gameAccountsView; set => SetProperty(ref gameAccountsView, value); }
public GameAccount? SelectedGameAccount { get => selectedGameAccount; set => SetProperty(ref selectedGameAccount, value); }
public GameResource? GameResource { get => gameResource; set => SetProperty(ref gameResource, value); }
[AlsoAsyncSets(nameof(SelectedScheme), nameof(GameAccountsView))]
public bool GamePathSelectedAndValid
{
get => gamePathSelectedAndValid;
@@ -114,112 +95,100 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
{
if (SetProperty(ref gamePathSelectedAndValid, value) && value)
{
InitializeUICoreAsync().SafeForget();
RefreshUIAsync().SafeForget();
}
async ValueTask RefreshUIAsync()
{
try
{
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
LaunchScheme? scheme = LaunchGameShared.GetCurrentLaunchSchemeFromConfigFile(gameService, infoBarService);
await taskContext.SwitchToMainThreadAsync();
await SetSelectedSchemeAsync(scheme).ConfigureAwait(true);
TrySetGameAccountByDesiredUid();
// Try set to the current account.
if (SelectedScheme is not null)
{
// The GameAccount is gaurenteed to be in the view, bacause the scheme is synced
SelectedGameAccount ??= gameService.DetectCurrentGameAccount(SelectedScheme);
}
else
{
infoBarService.Warning(SH.ViewModelLaunchGameSchemeNotSelected);
}
}
}
catch (UserdataCorruptedException ex)
{
infoBarService.Error(ex);
}
}
void TrySetGameAccountByDesiredUid()
{
// Sync uid, almost never hit, so we are not so care about performance
if (memoryCache.TryRemove(DesiredUid, out object? value) && value is string uid)
{
ArgumentNullException.ThrowIfNull(GameAccountsView);
// Exists in the source collection
if (GameAccountsView.SourceCollection.Cast<GameAccount>().FirstOrDefault(g => g.AttachUid == uid) is { } sourceAccount)
{
SelectedGameAccount = GameAccountsView.Cast<GameAccount>().FirstOrDefault(g => g.AttachUid == uid);
// But not exists in the view for current scheme
if (SelectedGameAccount is null)
{
infoBarService.Warning(SH.FormatViewModelLaunchGameUnableToSwitchUidAttachedGameAccount(uid, sourceAccount.Name));
}
}
}
}
}
}
public ImmutableList<GamePathEntry> GamePathEntries { get => gamePathEntries; set => SetProperty(ref gamePathEntries, value); }
[AlsoSets(nameof(GamePathSelectedAndValid))]
public GamePathEntry? SelectedGamePathEntry
{
get => selectedGamePathEntry;
set => UpdateSelectedGamePathEntry(value, true);
set
{
if (SetProperty(ref selectedGamePathEntry, value, nameof(SelectedGamePathEntry)))
{
if (IsViewDisposed)
{
return;
}
launchOptions.GamePath = value?.Path ?? string.Empty;
GamePathSelectedAndValid = File.Exists(launchOptions.GamePath);
}
}
}
protected override ValueTask<bool> InitializeUIAsync()
{
GamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
SelectedGamePathEntry = entry;
SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions();
return ValueTask.FromResult(true);
}
private async ValueTask InitializeUICoreAsync()
{
try
{
using (await EnterCriticalExecutionAsync().ConfigureAwait(false))
{
ChannelOptions options = gameService.GetChannelOptions();
if (string.IsNullOrEmpty(options.ConfigFilePath))
{
try
{
SelectedScheme = KnownSchemes.Single(scheme => scheme.Equals(options));
}
catch (InvalidOperationException)
{
if (!IgnoredInvalidChannelOptions.Contains(options))
{
// 后台收集
throw new NotSupportedException($"不支持的 MultiChannel: {options}");
}
}
}
else
{
infoBarService.Warning(SH.FormatViewModelLaunchGameMultiChannelReadFail(options.ConfigFilePath));
}
ObservableCollection<GameAccount> accounts = gameService.GameAccountCollection;
await taskContext.SwitchToMainThreadAsync();
GameAccounts = accounts;
// Sync uid
if (memoryCache.TryRemove(DesiredUid, out object? value) && value is string uid)
{
SelectedGameAccount = GameAccounts.FirstOrDefault(g => g.AttachUid == uid);
}
// Try set to the current account.
SelectedGameAccount ??= gameService.DetectCurrentGameAccount();
}
}
catch (UserdataCorruptedException ex)
{
infoBarService.Error(ex);
}
catch (OperationCanceledException)
{
}
}
private void UpdateSelectedGamePathEntry(GamePathEntry? value, bool setBack)
{
if (SetProperty(ref selectedGamePathEntry, value, nameof(SelectedGamePathEntry)) && setBack)
{
if (IsViewDisposed)
{
return;
}
launchOptions.GamePath = value?.Path ?? string.Empty;
GamePathSelectedAndValid = File.Exists(launchOptions.GamePath);
}
}
[Command("SetGamePathCommand")]
private async Task SetGamePathAsync()
{
IGameLocator locator = gameLocatorFactory.Create(GameLocationSource.Manual);
(bool isOk, string path) = await locator.LocateGamePathAsync().ConfigureAwait(false);
(bool isOk, string path) = await gameLocatorFactory.LocateAsync(GameLocationSource.Manual).ConfigureAwait(false);
if (!isOk)
{
return;
}
await taskContext.SwitchToMainThreadAsync();
try
{
GamePathEntries = launchOptions.UpdateGamePathAndRefreshEntries(path);
}
catch (SqliteException ex)
{
// 文件夹权限不足,无法写入数据库
infoBarService.Error(ex, SH.ViewModelSettingSetGamePathDatabaseFailedTitle);
}
GamePathEntries = launchOptions.UpdateGamePathAndRefreshEntries(path);
}
[Command("ResetGamePathCommand")]
@@ -256,24 +225,20 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
// Always ensure game resources
if (!await gameService.EnsureGameResourceAsync(SelectedScheme, convertProgress).ConfigureAwait(false))
{
infoBarService.Warning(SH.ViewModelLaunchGameEnsureGameResourceFail);
infoBarService.Warning(SH.ViewModelLaunchGameEnsureGameResourceFail, dialog.State?.Name ?? string.Empty);
return;
}
else
{
await taskContext.SwitchToMainThreadAsync();
GamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
UpdateSelectedGamePathEntry(entry, true);
SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions();
}
}
if (SelectedGameAccount is not null)
if (SelectedGameAccount is not null && !gameService.SetGameAccount(SelectedGameAccount))
{
if (!gameService.SetGameAccount(SelectedGameAccount))
{
infoBarService.Warning(SH.ViewModelLaunchGameSwitchGameAccountFail);
return;
}
infoBarService.Warning(SH.ViewModelLaunchGameSwitchGameAccountFail);
return;
}
IProgress<LaunchStatus> launchProgress = progressFactory.CreateForMainThread<LaunchStatus>(status => launchStatusOptions.LaunchStatus = status);
@@ -297,11 +262,14 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
{
try
{
GameAccount? account = await gameService.DetectGameAccountAsync().ConfigureAwait(false);
if (SelectedScheme is null)
{
infoBarService.Error(SH.ViewModelLaunchGameSchemeNotSelected);
return;
}
// If user canceled the operation, the return is null,
// and thus we should not set SelectedAccount
if (account is not null)
// If user canceled the operation, the return is null
if (await gameService.DetectGameAccountAsync(SelectedScheme).ConfigureAwait(false) is { } account)
{
await taskContext.SwitchToMainThreadAsync();
SelectedGameAccount = account;
@@ -359,4 +327,54 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
await Windows.System.Launcher.LaunchFolderPathAsync(screenshot);
}
}
private async ValueTask SetSelectedSchemeAsync(LaunchScheme? value)
{
if (SetProperty(ref selectedScheme, value, nameof(SelectedScheme)))
{
UpdateGameResourceAsync(value).SafeForget();
await UpdateGameAccountsViewAsync().ConfigureAwait(false);
// Clear the selected game account to prevent setting
// incorrect CN/OS account when scheme not match
SelectedGameAccount = default;
}
async ValueTask UpdateGameResourceAsync(LaunchScheme? scheme)
{
if (scheme is null)
{
return;
}
await taskContext.SwitchToBackgroundAsync();
Web.Response.Response<GameResource> response = await resourceClient
.GetResourceAsync(scheme)
.ConfigureAwait(false);
if (response.IsOk())
{
await taskContext.SwitchToMainThreadAsync();
GameResource = response.Data;
}
}
async ValueTask UpdateGameAccountsViewAsync()
{
gameAccountFilter = new(SelectedScheme?.GetSchemeType());
ObservableCollection<GameAccount> accounts = gameService.GameAccountCollection;
await taskContext.SwitchToMainThreadAsync();
GameAccountsView = new(accounts, true)
{
Filter = gameAccountFilter.Filter,
};
}
}
private void SyncGamePathEntriesAndSelectedGamePathEntryFromLaunchOptions()
{
GamePathEntries = launchOptions.GetGamePathEntries(out GamePathEntry? entry);
SelectedGamePathEntry = entry;
}
}

View File

@@ -1,9 +1,12 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using CommunityToolkit.WinUI.Collections;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Notification;
using System.Collections.ObjectModel;
using Windows.Win32.Foundation;
@@ -17,17 +20,17 @@ namespace Snap.Hutao.ViewModel.Game;
[ConstructorGenerated(CallBaseConstructor = true)]
internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSlim<View.Page.LaunchGamePage>
{
private readonly LaunchStatusOptions launchStatusOptions;
private readonly IProgressFactory progressFactory;
private readonly IInfoBarService infoBarService;
private readonly IGameServiceFacade gameService;
private readonly ITaskContext taskContext;
private readonly IInfoBarService infoBarService;
private ObservableCollection<GameAccount>? gameAccounts;
private AdvancedCollectionView? gameAccountsView;
private GameAccount? selectedGameAccount;
private GameAccountFilter? gameAccountFilter;
/// <summary>
/// 游戏账号集合
/// </summary>
public ObservableCollection<GameAccount>? GameAccounts { get => gameAccounts; set => SetProperty(ref gameAccounts, value); }
public AdvancedCollectionView? GameAccountsView { get => gameAccountsView; set => SetProperty(ref gameAccountsView, value); }
/// <summary>
/// 选中的账号
@@ -37,19 +40,29 @@ internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSli
/// <inheritdoc/>
protected override async Task OpenUIAsync()
{
LaunchScheme? scheme = LaunchGameShared.GetCurrentLaunchSchemeFromConfigFile(gameService, infoBarService);
ObservableCollection<GameAccount> accounts = gameService.GameAccountCollection;
await taskContext.SwitchToMainThreadAsync();
GameAccounts = accounts;
try
{
// Try set to the current account.
SelectedGameAccount ??= gameService.DetectCurrentGameAccount();
if (scheme is not null)
{
// Try set to the current account.
SelectedGameAccount ??= gameService.DetectCurrentGameAccount(scheme);
}
}
catch (UserdataCorruptedException ex)
{
infoBarService.Error(ex);
}
gameAccountFilter = new(scheme?.GetSchemeType());
await taskContext.SwitchToMainThreadAsync();
GameAccountsView = new(accounts, true)
{
Filter = gameAccountFilter.Filter,
};
}
[Command("LaunchCommand")]
@@ -68,7 +81,8 @@ internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSli
}
}
await gameService.LaunchAsync(new Progress<LaunchStatus>()).ConfigureAwait(false);
IProgress<LaunchStatus> launchProgress = progressFactory.CreateForMainThread<LaunchStatus>(status => launchStatusOptions.LaunchStatus = status);
await gameService.LaunchAsync(launchProgress).ConfigureAwait(false);
}
catch (Exception ex)
{

View File

@@ -15,7 +15,6 @@ using Snap.Hutao.Model;
using Snap.Hutao.Service;
using Snap.Hutao.Service.GachaLog.QueryProvider;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Locator;
using Snap.Hutao.Service.Hutao;
using Snap.Hutao.Service.Navigation;
using Snap.Hutao.Service.Notification;
@@ -47,7 +46,6 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel
private readonly HutaoInfrastructureClient hutaoInfrastructureClient;
private readonly HutaoPassportViewModel hutaoPassportViewModel;
private readonly IContentDialogFactory contentDialogFactory;
private readonly IGameLocatorFactory gameLocatorFactory;
private readonly INavigationService navigationService;
private readonly IClipboardProvider clipboardInterop;
private readonly IShellLinkInterop shellLinkInterop;
@@ -124,11 +122,56 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel
public IPInformation? IPInformation { get => ipInformation; private set => SetProperty(ref ipInformation, value); }
[SuppressMessage("", "CA1822")]
public bool IsAllocConsoleDebugModeEnabled
{
get => LocalSetting.Get(SettingKeys.IsAllocConsoleDebugModeEnabled, false);
set => LocalSetting.Set(SettingKeys.IsAllocConsoleDebugModeEnabled, value);
set
{
ConfirmSetIsAllocConsoleDebugModeEnabledAsync(value).SafeForget();
async ValueTask ConfirmSetIsAllocConsoleDebugModeEnabledAsync(bool value)
{
if (value)
{
ReconfirmDialog dialog = await contentDialogFactory.CreateInstanceAsync<ReconfirmDialog>().ConfigureAwait(false);
if (await dialog.ConfirmAsync(SH.ViewSettingAllocConsoleHeader).ConfigureAwait(true))
{
LocalSetting.Set(SettingKeys.IsAllocConsoleDebugModeEnabled, true);
OnPropertyChanged(nameof(IsAllocConsoleDebugModeEnabled));
return;
}
}
LocalSetting.Set(SettingKeys.IsAllocConsoleDebugModeEnabled, false);
OnPropertyChanged(nameof(IsAllocConsoleDebugModeEnabled));
}
}
}
public bool IsAdvancedLaunchOptionsEnabled
{
get => launchOptions.IsAdvancedLaunchOptionsEnabled;
set
{
ConfirmSetIsAdvancedLaunchOptionsEnabledAsync(value).SafeForget();
async ValueTask ConfirmSetIsAdvancedLaunchOptionsEnabledAsync(bool value)
{
if (value)
{
ReconfirmDialog dialog = await contentDialogFactory.CreateInstanceAsync<ReconfirmDialog>().ConfigureAwait(false);
if (await dialog.ConfirmAsync(SH.ViewPageSettingIsAdvancedLaunchOptionsEnabledHeader).ConfigureAwait(true))
{
launchOptions.IsAdvancedLaunchOptionsEnabled = true;
OnPropertyChanged(nameof(IsAllocConsoleDebugModeEnabled));
return;
}
}
launchOptions.IsAdvancedLaunchOptionsEnabled = false;
OnPropertyChanged(nameof(IsAllocConsoleDebugModeEnabled));
}
}
}
protected override async ValueTask<bool> InitializeUIAsync()
@@ -225,6 +268,26 @@ internal sealed partial class SettingViewModel : Abstraction.ViewModel
}
}
[Command("DeleteServerCacheFolderCommand")]
private async Task DeleteServerCacheFolderAsync()
{
ContentDialogResult result = await contentDialogFactory.CreateForConfirmCancelAsync(
SH.ViewModelSettingDeleteServerCacheFolderTitle,
SH.ViewModelSettingDeleteServerCacheFolderContent)
.ConfigureAwait(false);
if (result is ContentDialogResult.Primary)
{
string cacheFolder = runtimeOptions.GetDataFolderServerCacheFolder();
if (Directory.Exists(cacheFolder))
{
Directory.Delete(cacheFolder, true);
}
infoBarService.Information(SH.ViewModelSettingActionComplete);
}
}
[Command("CopyDeviceIdCommand")]
private void CopyDeviceId()
{

View File

@@ -0,0 +1,41 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core.IO.DataTransfer;
using Snap.Hutao.Service.Notification;
using System.Net.Http;
namespace Snap.Hutao.Web.Bridge;
internal sealed class BridgeShareContext
{
private readonly CoreWebView2 coreWebView2;
private readonly ITaskContext taskContext;
private readonly HttpClient httpClient;
private readonly IInfoBarService infoBarService;
private readonly IClipboardProvider clipboardProvider;
private readonly JsonSerializerOptions jsonSerializerOptions;
public BridgeShareContext(CoreWebView2 coreWebView2, ITaskContext taskContext, HttpClient httpClient, IInfoBarService infoBarService, IClipboardProvider clipboardProvider, JsonSerializerOptions jsonSerializerOptions)
{
this.httpClient = httpClient;
this.taskContext = taskContext;
this.infoBarService = infoBarService;
this.clipboardProvider = clipboardProvider;
this.coreWebView2 = coreWebView2;
this.jsonSerializerOptions = jsonSerializerOptions;
}
public CoreWebView2 CoreWebView2 { get => coreWebView2; }
public ITaskContext TaskContext { get => taskContext; }
public HttpClient HttpClient { get => httpClient; }
public IInfoBarService InfoBarService { get => infoBarService; }
public IClipboardProvider ClipboardProvider { get => clipboardProvider; }
public JsonSerializerOptions JsonSerializerOptions { get => jsonSerializerOptions; }
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Web.Bridge.Model;
using System.IO;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
namespace Snap.Hutao.Web.Bridge;
internal sealed partial class BridgeShareImplmentation
{
public static async ValueTask<IJsBridgeResult?> ShareAsync(JsParam<SharePayload> param, BridgeShareContext context)
{
SharePayload payload = param.Payload;
switch (payload.Type)
{
case "image":
{
ShareContent content = payload.Content;
if (content.ImageUrl is { Length: > 0 } imageUrl)
{
await ShareFromImageUrlAsync(context, imageUrl).ConfigureAwait(false);
}
else if (content.ImageBase64 is { } imageBase64)
{
await ShareFromImageBase64Async(context, imageBase64).ConfigureAwait(false);
}
break;
}
case "screenshot":
{
await context.TaskContext.SwitchToMainThreadAsync();
// https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-captureScreenshot
string jsonParameters = """{ "format": "png", "captureBeyondViewport": true }""";
string resultJson = await context.CoreWebView2.CallDevToolsProtocolMethodAsync("Page.captureScreenshot", jsonParameters);
CaptureScreenshotResult? result = JsonSerializer.Deserialize<CaptureScreenshotResult>(resultJson, context.JsonSerializerOptions);
ArgumentNullException.ThrowIfNull(result);
await ShareFromRawPixelDataAsync(context, result.Data).ConfigureAwait(false);
break;
}
}
return new JsResult<Dictionary<string, string>>()
{
Data = new()
{
["type"] = param.Payload.Type,
},
};
}
private static async ValueTask ShareFromImageUrlAsync(BridgeShareContext context, string imageUrl)
{
using (Stream stream = await context.HttpClient.GetStreamAsync(imageUrl).ConfigureAwait(false))
{
await ShareCoreAsync(context, stream, static (stream, web) => web.CopyToAsync(stream.AsStreamForWrite())).ConfigureAwait(false);
}
}
private static ValueTask ShareFromImageBase64Async(BridgeShareContext context, string base64ImageData)
{
return ShareFromRawPixelDataAsync(context, Convert.FromBase64String(base64ImageData));
}
private static ValueTask ShareFromRawPixelDataAsync(BridgeShareContext context, byte[] rawPixelData)
{
return ShareCoreAsync(context, rawPixelData, static (stream, bytes) => stream.AsStreamForWrite().WriteAsync(bytes).AsTask());
}
private static async ValueTask ShareCoreAsync<TData>(BridgeShareContext context, TData data, Func<InMemoryRandomAccessStream, TData, Task> asyncWriteData)
{
using (InMemoryRandomAccessStream rawPixelDataStream = new())
{
await asyncWriteData(rawPixelDataStream, data).ConfigureAwait(false);
BitmapDecoder decoder = await BitmapDecoder.CreateAsync(rawPixelDataStream);
using (InMemoryRandomAccessStream stream = new())
{
BitmapEncoder encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, stream);
encoder.SetSoftwareBitmap(await decoder.GetSoftwareBitmapAsync());
await encoder.FlushAsync();
await context.TaskContext.SwitchToMainThreadAsync();
if (context.ClipboardProvider.SetBitmap(stream))
{
context.InfoBarService.Success(SH.WebBridgeShareCopyToClipboardSuccess);
}
else
{
context.InfoBarService.Error(SH.WebBridgeShareCopyToClipboardFailed);
}
}
}
}
private sealed class CaptureScreenshotResult
{
[JsonPropertyName("data")]
public byte[] Data { get; set; } = default!;
}
}

View File

@@ -3,7 +3,9 @@
using Microsoft.Web.WebView2.Core;
using Snap.Hutao.Core.DependencyInjection.Abstraction;
using Snap.Hutao.Core.IO.DataTransfer;
using Snap.Hutao.Service.Metadata;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User;
using Snap.Hutao.ViewModel.User;
using Snap.Hutao.Web.Bridge.Model;
@@ -12,6 +14,7 @@ using Snap.Hutao.Web.Hoyolab.Bbs.User;
using Snap.Hutao.Web.Hoyolab.DataSigning;
using Snap.Hutao.Web.Hoyolab.Takumi.Auth;
using Snap.Hutao.Web.Response;
using System.Net.Http;
using System.Text;
using Windows.Foundation;
@@ -24,7 +27,7 @@ namespace Snap.Hutao.Web.Bridge;
[SuppressMessage("", "CA1001")]
internal class MiHoYoJSBridge
{
private const string InitializeJsInterfaceScript2 = """
private const string InitializeJsInterfaceScript = """
window.MiHoYoJSInterface = {
postMessage: function(arg) { chrome.webview.postMessage(arg) },
closePage: function() { this.postMessage('{"method":"closePage"}') },
@@ -37,8 +40,48 @@ internal class MiHoYoJSBridge
document.querySelector('body').appendChild(st);
""";
private const string ConvertMouseToTouchScript = """
function mouseListener (e, event) {
let touch = new Touch({
identifier: Date.now(),
target: e.target,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
pageX: e.pageX,
pageY: e.pageY,
});
let touchEvent = new TouchEvent(event, {
cancelable: true,
bubbles: true,
touches: [touch],
targetTouches: [touch],
changedTouches: [touch],
});
e.target.dispatchEvent(touchEvent);
}
let mouseMoveListener = (e) => {
mouseListener(e, 'touchmove');
};
let mouseUpListener = (e) => {
mouseListener(e, 'touchend');
document.removeEventListener('mousemove', mouseMoveListener);
document.removeEventListener('mouseup', mouseUpListener);
};
let mouseDownListener = (e) => {
mouseListener(e, 'touchstart');
document.addEventListener('mousemove', mouseMoveListener);
document.addEventListener('mouseup', mouseUpListener);
};
document.addEventListener('mousedown', mouseDownListener);
""";
private readonly SemaphoreSlim webMessageSemaphore = new(1);
private readonly Guid interfaceId = Guid.NewGuid();
private readonly Guid bridgeId = Guid.NewGuid();
private readonly UserAndUid userAndUid;
private readonly IServiceProvider serviceProvider;
@@ -80,11 +123,6 @@ internal class MiHoYoJSBridge
coreWebView2 = default!;
}
/// <summary>
/// 关闭
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual async ValueTask<IJsBridgeResult?> ClosePageAsync(JsParam param)
{
await taskContext.SwitchToMainThreadAsync();
@@ -100,21 +138,11 @@ internal class MiHoYoJSBridge
return null;
}
/// <summary>
/// 调整分享设置
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual IJsBridgeResult? ConfigureShare(JsParam param)
{
return null;
}
/// <summary>
/// 获取ActionTicket
/// </summary>
/// <param name="jsParam">参数</param>
/// <returns>响应</returns>
protected virtual async ValueTask<IJsBridgeResult?> GetActionTicketAsync(JsParam<ActionTypePayload> jsParam)
{
return await serviceProvider
@@ -123,11 +151,6 @@ internal class MiHoYoJSBridge
.ConfigureAwait(false);
}
/// <summary>
/// 异步获取账户信息
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual JsResult<Dictionary<string, string>> GetCookieInfo(JsParam param)
{
ArgumentNullException.ThrowIfNull(userAndUid.User.LToken);
@@ -143,11 +166,6 @@ internal class MiHoYoJSBridge
};
}
/// <summary>
/// 获取CookieToken
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual async ValueTask<JsResult<Dictionary<string, string>>> GetCookieTokenAsync(JsParam<CookieTokenPayload> param)
{
IUserService userService = serviceProvider.GetRequiredService<IUserService>();
@@ -163,11 +181,6 @@ internal class MiHoYoJSBridge
return new() { Data = new() { [Cookie.COOKIE_TOKEN] = userAndUid.User.CookieToken[Cookie.COOKIE_TOKEN] } };
}
/// <summary>
/// 获取当前语言和时区
/// </summary>
/// <param name="param">param</param>
/// <returns>语言与时区</returns>
protected virtual JsResult<Dictionary<string, string>> GetCurrentLocale(JsParam<PushPagePayload> param)
{
MetadataOptions metadataOptions = serviceProvider.GetRequiredService<MetadataOptions>();
@@ -182,11 +195,6 @@ internal class MiHoYoJSBridge
};
}
/// <summary>
/// 获取1代动态密钥
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual JsResult<Dictionary<string, string>> GetDynamicSecrectV1(JsParam param)
{
DataSignOptions options = DataSignOptions.CreateForGeneration1(SaltType.LK2, true);
@@ -199,11 +207,6 @@ internal class MiHoYoJSBridge
};
}
/// <summary>
/// 获取2代动态密钥
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual JsResult<Dictionary<string, string>> GetDynamicSecrectV2(JsParam<DynamicSecrect2Playload> param)
{
DataSignOptions options = DataSignOptions.CreateForGeneration2(SaltType.X4, false, param.Payload.Body, param.Payload.GetQueryParam());
@@ -216,11 +219,6 @@ internal class MiHoYoJSBridge
};
}
/// <summary>
/// 获取Http请求头
/// </summary>
/// <param name="param">参数</param>
/// <returns>Http请求头</returns>
protected virtual JsResult<Dictionary<string, string>> GetHttpRequestHeader(JsParam param)
{
Dictionary<string, string> headers = new()
@@ -254,21 +252,11 @@ internal class MiHoYoJSBridge
{
}
/// <summary>
/// 获取状态栏高度
/// </summary>
/// <param name="param">参数</param>
/// <returns>结果</returns>
protected virtual JsResult<Dictionary<string, object>> GetStatusBarHeight(JsParam param)
{
return new() { Data = new() { ["statusBarHeight"] = 0 } };
}
/// <summary>
/// 获取用户基本信息
/// </summary>
/// <param name="param">参数</param>
/// <returns>响应</returns>
protected virtual async ValueTask<JsResult<Dictionary<string, object>>> GetUserInfoAsync(JsParam param)
{
Response<UserFullInfoWrapper> response = await serviceProvider
@@ -322,15 +310,19 @@ internal class MiHoYoJSBridge
return null;
}
protected virtual IJsBridgeResult? Share(JsParam<SharePayload> param)
protected virtual async ValueTask<IJsBridgeResult?> ShareAsync(JsParam<SharePayload> param)
{
return new JsResult<Dictionary<string, string>>()
using (IServiceScope scope = serviceProvider.CreateScope())
{
Data = new()
{
["type"] = param.Payload.Type,
},
};
JsonSerializerOptions jsonSerializerOptions = scope.ServiceProvider.GetRequiredService<JsonSerializerOptions>();
HttpClient httpClient = scope.ServiceProvider.GetRequiredService<HttpClient>();
IClipboardProvider clipboardProvider = scope.ServiceProvider.GetRequiredService<IClipboardProvider>();
IInfoBarService infoBarService = scope.ServiceProvider.GetRequiredService<IInfoBarService>();
BridgeShareContext context = new(coreWebView2, taskContext, httpClient, infoBarService, clipboardProvider, jsonSerializerOptions);
return await BridgeShareImplmentation.ShareAsync(param, context).ConfigureAwait(false);
}
}
protected virtual ValueTask<IJsBridgeResult?> ShowAlertDialogAsync(JsParam param)
@@ -400,7 +392,7 @@ internal class MiHoYoJSBridge
.Append(')')
.ToString();
logger?.LogInformation("[{Id}][ExecuteScript: {callback}]\n{payload}", interfaceId, callback, payload);
logger?.LogInformation("[{Id}][ExecuteScript: {callback}]\n{payload}", bridgeId, callback, payload);
await taskContext.SwitchToMainThreadAsync();
if (coreWebView2 is null || coreWebView2.IsDisposed())
@@ -414,7 +406,7 @@ internal class MiHoYoJSBridge
private async void OnWebMessageReceived(CoreWebView2 webView2, CoreWebView2WebMessageReceivedEventArgs args)
{
string message = args.TryGetWebMessageAsString();
logger.LogInformation("[{Id}][OnRawMessage]\n{message}", interfaceId, message);
logger.LogInformation("[{Id}][OnRawMessage]\n{message}", bridgeId, message);
JsParam? param = JsonSerializer.Deserialize<JsParam>(message);
ArgumentNullException.ThrowIfNull(param);
@@ -463,7 +455,7 @@ internal class MiHoYoJSBridge
"hideLoading" => null,
"login" => null,
"pushPage" => await PushPageAsync(param).ConfigureAwait(false),
"share" => Share(param),
"share" => await ShareAsync(param).ConfigureAwait(false),
"showLoading" => null,
_ => LogUnhandledMessage("Unhandled Message Type: {Method}", param.Method),
};
@@ -479,6 +471,7 @@ internal class MiHoYoJSBridge
{
DOMContentLoaded(coreWebView2);
coreWebView2.ExecuteScriptAsync(HideScrollBarScript).AsTask().SafeForget(logger);
coreWebView2.ExecuteScriptAsync(ConvertMouseToTouchScript).AsTask().SafeForget(logger);
}
private void OnNavigationStarting(CoreWebView2 coreWebView2, CoreWebView2NavigationStartingEventArgs args)
@@ -487,7 +480,7 @@ internal class MiHoYoJSBridge
ReadOnlySpan<char> uriHostSpan = uriHost.AsSpan();
if (uriHostSpan.EndsWith("mihoyo.com") || uriHostSpan.EndsWith("hoyolab.com"))
{
coreWebView2.ExecuteScriptAsync(InitializeJsInterfaceScript2).AsTask().SafeForget(logger);
coreWebView2.ExecuteScriptAsync(InitializeJsInterfaceScript).AsTask().SafeForget(logger);
}
}
}

View File

@@ -3,8 +3,15 @@
namespace Snap.Hutao.Web.Bridge.Model;
[SuppressMessage("", "SA1124")]
internal sealed class ShareContent
{
[JsonPropertyName("preview")]
public bool Preview { get; set; }
}
[JsonPropertyName("image_url")]
public string? ImageUrl { get; set; }
[JsonPropertyName("image_base64")]
public string? ImageBase64 { get; set; }
}

View File

@@ -6,6 +6,7 @@ namespace Snap.Hutao.Web.Hoyolab.Takumi.GameRecord.DailyNote;
internal sealed class ArchonQuest
{
[JsonPropertyName("status")]
[JsonConverter(typeof(JsonStringEnumConverter))]
public ArchonQuestStatus Status { get; set; }
/// <summary>

View File

@@ -3,6 +3,4 @@
namespace Snap.Hutao.Web.Request.Builder.Abstraction;
internal interface IBuilder
{
}
internal interface IBuilder;

View File

@@ -1,6 +1,8 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using System.Net.Http;
namespace Snap.Hutao.Web.Request.Builder;
[Serializable]
@@ -11,6 +13,31 @@ internal sealed class HttpContentSerializationException : Exception
{
}
private HttpContentSerializationException(Exception? innerException)
: base(GetDefaultMessage(), innerException)
{
}
public static async ValueTask<HttpContentSerializationException> CreateAsync(HttpContent? content, Exception? innerException)
{
if (content is null)
{
return new(innerException);
}
string contentString = await content.ReadAsStringAsync().ConfigureAwait(false);
string message = $"""
The (de-)serialization failed because of an arbitrary error. This most likely happened,
because an inner serializer failed to (de-)serialize the given data.
----- data begin -----
{contentString}
----- data end -----
See the inner exception for details (if available).
""";
return new(message, innerException);
}
private static string GetDefaultMessage()
{
return """

View File

@@ -49,7 +49,7 @@ internal abstract class HttpContentSerializer : IHttpContentSerializer, IHttpCon
}
catch (Exception ex) when (ex is not HttpContentSerializationException)
{
throw new HttpContentSerializationException(null, ex);
throw await HttpContentSerializationException.CreateAsync(httpContent, ex).ConfigureAwait(false);
}
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Web.Request.Builder;
internal enum HttpTryCatchSendStrategy
{
Default,
HutaoApi,
}