Compare commits

...
92 Commits
Author SHA1 Message Date
huanghongxun a3d8c6b3b3 feat(skin): introduce new official skins. 2022-11-05 20:22:02 +08:00
huanghongxun 948928f740 feat: update HMCLauncher.exe 2022-11-05 20:11:22 +08:00
Rei Hatada 71e98c035e change (#1824) 2022-11-05 19:51:39 +08:00
Glavo af107b15c0 完善 HiPer 支持 (#1821)
* Display hiper token expiration time

* Automatically download missing files when starting HiPer

* Disable start HiPer when token is empty
2022-11-05 19:36:17 +08:00
Glavo 8687bb9487 修复 MessageDialogPane 总是折叠内容的问题 (#1815)
* fix #1751: Use ScrollPane in message dialog only when needed
close #1777

* Parse segment only if it contains xml tag
2022-11-05 19:28:24 +08:00
huanghongxun a70e53364d fix(multiplayer): force using authlib-injector in multiplayer mode. 2022-10-30 13:45:34 +08:00
zkitefly 11a1c37765 将日志更换至docs.hmcl.net (#1812)
* 将日志更换至docs.hmcl.net,以便更新日志

* 修改链接

* 更换目录
2022-10-30 01:29:51 +08:00
Glavo 68a8944810 改进 HiPer 支持 (#1763)
* Disable start hiper button when token is empty

* Use gsudo start hiper

* Automatically request administrator privileges on Windows and Linux

* Automatically start hiper with sudo on mac

* Update I18N

* Prompt user to chown unwritable files

* Update HiPer file verification failed prompt

* Log other hiper exceptions

* temporarily disable gsudo

* Detect admin rights with 'net session'

* Hide the hint pane when running with administrator privileges

* Prompt user when starting HMCL with sudo

* Fix ClassNotFoundException when no JavaFX

* Update the prompt when the permission cannot be obtained

* Add support for saving multiple HiPer configurations

* update link
close Glavo/HMCL#3

* Detect RISC-V 32

* Save original hiper authorization information

* Add support for importing and exporting license files

* Set initial license file name

* Complement the missing message

* Handling gsudo failure to obtain permission
2022-10-25 21:55:12 +08:00
zkitefly b280b238df 尝试完成 #1780 (#1789) 2022-10-25 21:52:23 +08:00
huanghongxun 0f39aad429 fix(auth): legacy user type for OfflineAccount. 2022-10-25 21:48:55 +08:00
Glavo ff78297ee5 fix IntegrityCheck::performCheck (#1792) 2022-10-23 23:28:22 +08:00
Glavo f75bbdcecc Log HMCL jar path (#1794) 2022-10-23 23:27:42 +08:00
Glavo 694e31a7fe Cache manifest in JarUtils (#1797) 2022-10-23 23:27:21 +08:00
Glavo c113670cda Migrate HMCL_DIRECTORY to the user directory (#1785) 2022-10-09 13:21:02 +08:00
zkitefly 1b3c399335 添加“测试能否联机”按钮 (#1757)
* add

* 修改图标(其实我不知道该弄啥图标好……)

* fix
2022-10-09 11:18:00 +08:00
huanghongxun 45cbe20449 fix: crash when there is an incorrect authlib-injector server url. Closes #1774. 2022-10-08 17:39:04 +08:00
zkiteflyandYuhui Huang 4d606510f6 修改 登录失败 弹窗 (#1782)
* 修改 登录失败 弹窗

* fix

* Update I18N.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Edit

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-10-08 17:33:07 +08:00
huanghongxun a6639552cf fix: lblCreationWarning not wrapping text. Closes #1783. 2022-10-08 17:31:28 +08:00
zkitefly 2a088ec555 更新多人联机页面 (#1758)
* 更新 multiplayer.error.file_not_found 翻译

* 修改翻译

* 移除标题

* add

* try fix

* try fix

* try fix

* try fix

* try fix

* try

* try

* try

* fix

* fix

* add

* fix

* fix

* add

* fix

* edit
2022-10-08 17:13:55 +08:00
zkitefly 1c46d63367 try (#1768) 2022-10-08 17:12:13 +08:00
zkitefly f2b7fae694 添加“忘记密码”按钮 (#1765) 2022-10-08 17:11:56 +08:00
Glavo 43986676d8 修复中文路径下启动 <= 1.12.2 的版本没有声音的问题 (#1754)
* Use native encoding by default

* Fix the problem that LWJGL fails to load OpenAL under Java 18
2022-10-08 17:10:27 +08:00
Glavo 2649e79d47 fix build fails with Java 19 (#1784) 2022-10-08 17:09:12 +08:00
zkitefly f83d3cd769 Fix https://github.com/huanghongxun/HMCL/issues/1703 (#1753) 2022-09-28 19:14:15 +08:00
zkitefly f124910f94 修改多人联机帮助与提示 (#1733)
* 修改多人联机提示

* text

* 撤回修改

* 1

* 修改帮助

* fix

* try

* Delete try

* Update I18N.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* 修改提示框样式

* 修改提示框样式

* fix

* 修改样式

* 修改样式

* 修改提示框样式

* 修改翻译

* 修改翻译

* add

* return

* add

* add

* 去掉图标

* 试图让左边图标为空

* Update MultiplayerPageSkin.java

* ruturn

* add

* add

* Update MultiplayerPageSkin.java

* Update I18N_zh.properties

* Update I18N_zh_CN.properties
2022-09-25 14:25:05 +08:00
zkitefly c1712c3e1e 修改翻译 (#1742) 2022-09-25 14:23:39 +08:00
huanghongxun d817c80629 fix: checkstyle. 2022-09-25 00:31:51 +08:00
huanghongxun 8d830dbf96 fix(microsoft): ok cannot be clicked. Closes #1729. 2022-09-24 23:54:36 +08:00
huanghongxun 0396e96334 feat(multiplayer): update agreement. Closes #1724. 2022-09-24 23:50:48 +08:00
huanghongxun 7b23c7bc79 feat(multiplayer): link to video tutorial. 2022-09-24 23:36:48 +08:00
huanghongxun d3ca50259a fix(modrinth): quilt is already supported. Closes #1746. 2022-09-24 23:16:11 +08:00
huanghongxun 499a65d5fb fix(multiplayer): connection. 2022-09-24 23:03:07 +08:00
huanghongxun 0b61f9856c fix(multiplayer): ui incorrect 2022-09-24 21:55:53 +08:00
huanghongxun c8409dd258 build: update gradle. 2022-09-24 18:38:04 +08:00
huanghongxun e5ea4cd79f feat(multiplayer): broadcast address to local network. 2022-09-24 18:31:22 +08:00
huanghongxun 4d54c7f6ba feat(multiplayer): show token expire time. 2022-09-24 18:31:22 +08:00
Cato : Love the world and myselfandzkitefly 0f4a5c26df 远端配置无法读取,使用本地缓存 (#1740)
* 远端配置无法读取,使用本地缓存

* 远程配置&本地配置确认

* fix err

* fix-err

* fix-err

* fix-err

* fix-err

* restore

* code clean

* fix-err

* add

* fix

* 远端读取异常日志

Co-authored-by: zkitefly <64117916+zkitefly@users.noreply.github.com>
2022-09-24 18:17:34 +08:00
Akira Yamazaki a8b09e2195 修复执行 PreLaunchCommand 时会被阻塞的问题 (#1737) 2022-09-24 16:57:19 +08:00
Cato : Love the world and myself 3ed6e38ad3 新增有效期getValidAt #1727 (#1732)
* 界面标题描述纠正

* 新增有效期getValidAt #1727

* fix err

* fix-err-again

* fix-err

* fix

* fix-4

* rename

* fix

* fix

* code clean
2022-09-24 16:56:19 +08:00
huanghongxun 2028b0ef9c fix(multiplayer): restore LocalServerBroadcaster and impl port forwarding. 2022-09-17 18:36:12 +08:00
zkitefly aa7f9b91c7 修改微软账户重新登录页面提示 (#1710)
* 修改微软账户重新登录页面提示

* 添加忘记添加的

* 修改翻译

* Update I18N_zh.properties

* Update I18N_zh_CN.properties

* Update I18N_zh_CN.properties

* Update I18N_zh.properties
2022-09-17 12:15:56 +08:00
Cato : Love the world and myself d6598310b2 界面标题描述纠正 (#1728) 2022-09-17 12:11:00 +08:00
zkitefly ce6c06987c 修改多人联机提示 (#1725)
* 修改多人联机提示

* 修改

* fix
2022-09-17 12:10:46 +08:00
huanghongxun 90955d7916 fix(ui): ComponentList prefheight wrong. 2022-09-16 02:34:23 +08:00
huanghongxun b3bbd21f1f chore(multiplayer): remove JSTUN. 2022-09-16 01:41:23 +08:00
Cato : Love the world and myselfandYuhui Huang 735a407f0e 缺少字符矫正 / NAT 检测移除 (#1718)
* 缺少字符矫正 / NAT 检测移除

* 错误修复

* 修正nat移除不彻底导致的编译错误

* 授权协议发布

* code-clean

* fix-err

* fix-err-2

* update-in18

* url-fix

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-09-16 01:18:26 +08:00
Glavo 8461b0418c close #1534: open 'java.com' when need to download Java 8 (#1716) 2022-09-15 21:28:02 +08:00
Glavo e46ab8bf9e fix the build errors and checkstyle (#1717) 2022-09-15 21:27:43 +08:00
zkitefly 330548b24e 添加游戏档案创建链接 (#1696)
* Create  获得加入Cato社区网络身份凭证的方法.txt

* Delete 获得加入Cato社区网络身份凭证的方法.txt

* Update CreateMultiplayerRoomDialog.java

* Update CreateMultiplayerRoomDialog.java

* Update MultiplayerPageSkin.java

* add makegameidsettings

* fix

* fix

* fix

* Update CreateAccountPane.java

* Update CreateAccountPane.java

* 听取意见,修改变量名为createProfileLink
2022-09-14 21:46:38 +08:00
Cato : Love the world and myselfandYuhui Huang cf5ccca42d tap/tun二选一,目前优先tun先看效果。 (#1713)
* tap/tun二选一,目前优先tun先看效果。

* 描述修正

* 修正故障描述

Co-authored-by: Yuhui Huang <i@huangyuhui.net>
2022-09-14 21:45:55 +08:00
Cato : Love the world and myself a18bd0729b 配置文件读取失败及证书失效判断 (#1715)
* 配置文件读取失败及证书失效判断

* 描述修正
2022-09-14 21:43:44 +08:00
huanghongxun 662b42c40f fix(multiplayer): get ip from hiper log. 2022-09-12 23:05:13 +08:00
huanghongxun b69c400a08 fix(multiplayer): get ip from hiper log. 2022-09-12 22:45:38 +08:00
huanghongxun f1eb2da57c feat(multiplayer): hiper. 2022-09-12 22:38:36 +08:00
huanghongxun 99be810b5e feat(multiplayer): wip: hiper 2022-09-12 22:38:36 +08:00
zkitefly 03541a6f85 修改 response.errorDescription.contains() 参数 (#1708)
* 添加两个 response.errorDescription.contains()

* 再添加一个qwq

* 删除重复项

* 恢复删除的项

* 修改

* 修改
2022-09-10 21:50:08 +08:00
Chikage0o0 5fe1ef899d fix modrinth modpack install (#1702)
* fix modrinth modpack install

* fix NullPointerException
2022-09-10 21:49:31 +08:00
CNCloud_Casey 6a81f4d240 更新整合包安装的文本介绍 (#1695)
* Update I18N_zh_CN.properties

* Update I18N_zh.properties

* Update I18N_ru.properties

* Update I18N_ja.properties

* Update I18N_es.properties

* Update I18N.properties
2022-09-05 20:11:10 +08:00
zkitefly 3c6aa489e8 添加sh支持 (#1697) 2022-09-05 20:10:49 +08:00
zkitefly ba7043fa92 修改一些提示 (#1699) 2022-09-05 20:10:37 +08:00
huanghongxun bfb6c99484 fix: checkstyle. 2022-09-04 00:26:14 +08:00
huanghongxun a6b26a0cd9 fix(microsoft): show relogin dialog when grant expired. Closes #1691. 2022-09-03 23:35:09 +08:00
huanghongxun 0c7b0f285e fix(microsoft): retry 5 times when socket timeout. Closes #1691. 2022-09-03 23:33:11 +08:00
huanghongxun 5e1ae56740 fix(ui): error dialog overflow. Closes #1677. 2022-09-03 22:44:38 +08:00
huanghongxun b17bac050b fix(ui): not parsing xml. Closes #1678. 2022-09-03 22:28:58 +08:00
huanghongxun b1952a7b37 fix(ui): not wrapping text. Closes #1673. 2022-09-03 22:26:05 +08:00
huanghongxun cc1fc1e4be fix(ui): some text overflow. Closes #1694. 2022-09-03 22:21:38 +08:00
Glavo 6c07924d9b 完善 osx-arm64 原生支持 (#1689)
* Add 1.6~1.18.2 support for osx-arm64

* fix crash on 1.12

* Update I18N
2022-09-03 16:55:27 +08:00
Glavo 835668d781 Update purchase link (#1692) 2022-09-03 16:53:55 +08:00
Glavo 8f7c09d2cc 添加 Linux MIPS64el 平台支持 (#1686)
* Add support for linux-mips64el

* Update PLATFORM.md
2022-09-02 11:37:09 +08:00
Chikage0o0 837939e27c Fix #1684 (#1685)
* 修复生成option.txt与YOSBR模组的冲突

* only search 1 layer
2022-09-02 11:36:58 +08:00
Glavo 058a8e2335 Update JavaVersionConstraint (#1683) 2022-09-01 22:20:16 +08:00
CNCloud_Casey a5af5265be Update README (#1682) 2022-08-31 15:25:29 +08:00
Gamom 8a724587ca 更新错误的翻译键 world.info.player.game_type.adventure (#1674)
* 更新错误的翻译键 world.info.player.game_type.adventure

我不理解 adventure 冒险 为什么会 翻译成极限 ...

* Update I18N_zh.properties
2022-08-31 14:20:56 +08:00
zkitefly bef9c0ad88 Fix #1675 (#1676) 2022-08-31 14:20:39 +08:00
Glavo 8d29775410 添加 Windows on Arm 平台的支持 (#1679)
* Add Minecraft 1.6~1.18.2 support for windows-arm64

* Prompt at startup on non-x86 platforms

* Automatically download JavaFX on windows-arm64

* Update PLATFORM.md
2022-08-31 14:20:27 +08:00
Glavo 620c0e4c73 Automatically download JavaFX on linux-loongarch64_ow (#1670) 2022-08-29 19:28:20 +08:00
Glavo b5c92c1564 Fix stuck on startup when no suitable Java is found (#1671) 2022-08-29 19:27:51 +08:00
huanghongxun 3c3c28aaa5 fix(launch): not escape spaces in exported shell script. Closes #1541. 2022-08-29 00:19:32 +08:00
huanghongxun a80fc34ef1 fix(launch): should not create file when throwing CommandTooLongException. Closes #1606. 2022-08-29 00:03:35 +08:00
huanghongxun 08d7ff138b fix(login): prompt to retry logging in. Closes #1658 #1591 #1544 1608. 2022-08-28 23:57:47 +08:00
huanghongxun 0c172e5d08 fix(crash): incorrect layout for feedbackLabel. Closes #1667. 2022-08-28 21:45:05 +08:00
Glavo bce2d1ea5c Revert ExitWaiter (#1668) 2022-08-28 21:05:43 +08:00
huanghongxun cbc1e26b6f feat(log): hyperlink for log analyzer feedback. Closes #1652. 2022-08-28 17:12:03 +08:00
Yuhui Huang 3b5ddcafa6 fix(quilt): Quilt Hash not enabled. Closes #1657. 2022-08-28 17:03:54 +08:00
Glavo c6a5624708 引导用户下载 Java (#1659)
* Direct users to download Java

* Read JVM options from environment variables
2022-08-28 17:00:15 +08:00
Glavo 23cc568bac Support Microsoft login in dev version (#1651) 2022-08-28 16:59:03 +08:00
Glavo 7ff679c3c8 Revert AdvancedListBox (#1647)
fix #1258, fix #1397, fix #1559, fix #1589, fix #1646
2022-08-28 16:56:52 +08:00
Glavo c873352ee9 更多平台的原生适配 (#1654)
* Add Minecraft 1.13+ support for linux-loongarch64_ow

* Add Minecraft 1.6~1.12.2 support for linux-arm32

* Update documents
2022-08-28 16:56:33 +08:00
Glavo a4164a86af Support path containing unicode characters on Mac OS (#1661) 2022-08-28 16:54:22 +08:00
Glavo c21671e86b 修复通过自动安装更新游戏时的崩溃 (#1662)
* fix game upgrade

* fix #1459: Invalidate the version number cache before saving the version
close #1566
2022-08-28 16:52:26 +08:00
Glavo d9bdc2b1f7 fix #1579: Hide tokens in the exported Minecraft logs (#1665) 2022-08-28 16:51:49 +08:00
116 changed files with 3706 additions and 2826 deletions

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
[*]
charset = utf-8
end_of_line = crlf
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = false
+5 -1
View File
@@ -39,6 +39,8 @@ jobs:
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha1"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha1"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha1"
env:
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
- name: Generate release note
@@ -46,7 +48,7 @@ jobs:
run: |
echo "HMCL v$HMCL_VERSION" >> RELEASE_NOTE
echo "" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://hmcl.huangyuhui.net/changelog/dev.html" >> RELEASE_NOTE
echo "The full changelogs can be found on the website: https://docs.hmcl.net/changelog/dev.html" >> RELEASE_NOTE
echo "Notice: changelogs are written in Chinese." >> RELEASE_NOTE
- name: Create release
if: ${{ env.continue == 'true' }}
@@ -58,6 +60,8 @@ jobs:
HMCL-${{ env.HMCL_VERSION }}.exe.sha1
HMCL-${{ env.HMCL_VERSION }}.jar
HMCL-${{ env.HMCL_VERSION }}.jar.sha1
HMCL-${{ env.HMCL_VERSION }}.sh
HMCL-${{ env.HMCL_VERSION }}.sh.sha1
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
name: ${{ env.HMCL_TAG_NAME }}
tag_name: ${{ env.HMCL_TAG_NAME }}
+4
View File
@@ -19,6 +19,10 @@ jobs:
java-package: jdk+fx
- name: Build with Gradle
run: ./gradlew build
env:
MICROSOFT_AUTH_ID: ${{ secrets.MICROSOFT_AUTH_ID }}
MICROSOFT_AUTH_SECRET: ${{ secrets.MICROSOFT_AUTH_SECRET }}
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
- name: Get short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Upload Artifacts
+3 -2
View File
@@ -47,13 +47,13 @@ val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
val enableHiPer = System.getenv("ENABLE_HIPER") ?: "false"
version = "$versionRoot.$buildNumber"
dependencies {
implementation(project(":HMCLCore"))
implementation("libs:JFoenix")
implementation("de.javawi.jstun:jstun:0.7.4")
}
fun digest(algorithm: String, bytes: ByteArray) = MessageDigest.getInstance(algorithm).digest(bytes)
@@ -151,6 +151,7 @@ tasks.getByName<com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar>("sha
"Microsoft-Auth-Secret" to microsoftAuthSecret,
"CurseForge-Api-Key" to curseForgeApiKey,
"Build-Channel" to versionType,
"Enable-HiPer" to enableHiPer,
"Class-Path" to "pack200.jar",
"Add-Opens" to listOf(
"java.base/java.lang",
@@ -19,11 +19,15 @@ package org.jackhuang.hmcl;
import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import javafx.scene.control.ButtonType;
import javafx.scene.input.Clipboard;
import javafx.scene.input.DataFormat;
import javafx.stage.Stage;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.SambaException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.AsyncTaskExecutor;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.AwtUtils;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
@@ -31,14 +35,21 @@ import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.awt.*;
import java.io.IOException;
import java.lang.management.ManagementFactory;
import java.net.*;
import java.net.CookieHandler;
import java.net.CookieManager;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
@@ -62,19 +73,57 @@ public final class Launcher extends Application {
Main.showWarningAndContinue(i18n("fatal.samba"));
} catch (IOException e) {
LOG.log(Level.SEVERE, "Failed to load config", e);
Main.showErrorAndExit(i18n("fatal.config_loading_failure", Paths.get("").toAbsolutePath().normalize()));
try {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS) {
String owner = Files.getOwner(ConfigHolder.configLocation()).getName();
String userName = System.getProperty("user.name");
if (!Files.isWritable(ConfigHolder.configLocation())
&& !userName.equals("root")
&& !userName.equals(owner)) {
ArrayList<String> files = new ArrayList<>();
{
files.add(ConfigHolder.configLocation().toString());
if (Files.exists(Metadata.HMCL_DIRECTORY))
files.add(Metadata.HMCL_DIRECTORY.toString());
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
if (Files.exists(mcDir))
files.add(mcDir.toString());
}
String command = new CommandBuilder().add("sudo", "chown", "-R", userName).addAll(files).toString();
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
ButtonType res = new Alert(Alert.AlertType.ERROR, i18n("fatal.config_loading_failure.unix", owner, command), copyAndExit, ButtonType.CLOSE)
.showAndWait()
.orElse(null);
if (res == copyAndExit) {
Clipboard.getSystemClipboard()
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
}
System.exit(1);
}
}
} catch (IOException ioe) {
LOG.log(Level.WARNING, "Failed to get file owner", ioe);
}
Main.showErrorAndExit(i18n("fatal.config_loading_failure", Paths.get("").toAbsolutePath().normalize().toString()));
}
if (ConfigHolder.isOwnerChanged()) {
ButtonType res = new Alert(Alert.AlertType.WARNING, i18n("fatal.config_change_owner_root"), ButtonType.YES, ButtonType.NO)
.showAndWait()
.orElse(null);
if (res == ButtonType.NO)
return;
}
if (Metadata.HMCL_DIRECTORY.toAbsolutePath().toString().indexOf('=') >= 0) {
Main.showWarningAndContinue(i18n("fatal.illegal_char"));
}
if (!Architecture.CURRENT_ARCH.isX86()
&& !(OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.CURRENT_ARCH == Architecture.ARM64)
&& ConfigHolder.globalConfig().getAgreementVersion() < 1) {
Main.showWarningAndContinue(i18n("fatal.unsupported_platform"));
}
// runLater to ensure ConfigHolder.init() finished initialization
Platform.runLater(() -> {
// When launcher visibility is set to "hide and reopen" without Platform.implicitExit = false,
@@ -123,6 +172,7 @@ public final class Launcher extends Application {
LOG.info("Java Home: " + System.getProperty("java.home"));
LOG.info("Current Directory: " + Paths.get("").toAbsolutePath());
LOG.info("HMCL Directory: " + Metadata.HMCL_DIRECTORY);
LOG.info("HMCL Jar Path: " + JarUtils.thisJar().map(it -> it.toAbsolutePath().toString()).orElse("Not Found"));
LOG.info("Memory: " + Runtime.getRuntime().maxMemory() / 1024 / 1024 + "MB");
ManagementFactory.getMemoryPoolMXBeans().stream().filter(bean -> bean.getName().equals("Metaspace")).findAny()
.ifPresent(bean -> LOG.info("Metaspace: " + bean.getUsage().getUsed() / 1024 / 1024 + "MB"));
@@ -17,6 +17,8 @@
*/
package org.jackhuang.hmcl;
import javafx.application.Platform;
import javafx.scene.control.Alert;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.SelfDependencyPatcher;
import org.jackhuang.hmcl.util.platform.Architecture;
@@ -68,7 +70,6 @@ public final class Main {
checkJavaFX();
Launcher.main(args);
}
@@ -116,6 +117,15 @@ public final class Main {
static void showErrorAndExit(String message) {
System.err.println(message);
System.err.println("A fatal error has occurred, forcibly exiting.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.ERROR, message).showAndWait();
System.exit(1);
}
} catch (Throwable ignored) {
}
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
System.exit(1);
}
@@ -126,6 +136,14 @@ public final class Main {
static void showWarningAndContinue(String message) {
System.err.println(message);
System.err.println("Potential issues have been detected.");
try {
if (Platform.isFxApplicationThread()) {
new Alert(Alert.AlertType.WARNING, message).showAndWait();
return;
}
} catch (Throwable ignored) {
}
JOptionPane.showMessageDialog(null, message, "Warning", JOptionPane.WARNING_MESSAGE);
}
@@ -156,7 +174,8 @@ public final class Main {
tls.init(null, instance.getTrustManagers(), null);
HttpsURLConnection.setDefaultSSLSocketFactory(tls.getSocketFactory());
LOG.info("Added Lets Encrypt root certificates as additional trust");
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException | KeyManagementException e) {
} catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException |
KeyManagementException e) {
LOG.log(Level.SEVERE, "Failed to load lets encrypt certificate. Expect problems", e);
}
}
@@ -18,11 +18,9 @@
package org.jackhuang.hmcl;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* Stores metadata about this application.
@@ -32,7 +30,7 @@ public final class Metadata {
public static final String NAME = "HMCL";
public static final String FULL_NAME = "Hello Minecraft! Launcher";
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.thisJar().flatMap(JarUtils::getImplementationVersion).orElse("@develop@"));
public static final String VERSION = System.getProperty("hmcl.version.override", JarUtils.getManifestAttribute("Implementation-Version", "@develop@"));
public static final String TITLE = NAME + " " + VERSION;
public static final String FULL_TITLE = FULL_NAME + " v" + VERSION;
@@ -40,27 +38,14 @@ public final class Metadata {
public static final String UPDATE_URL = System.getProperty("hmcl.update_source.override", "https://hmcl.huangyuhui.net/api/update_link");
public static final String CONTACT_URL = "https://github.com/huanghongxun/HMCL/issues";
public static final String HELP_URL = "https://hmcl.huangyuhui.net/help";
public static final String CHANGELOG_URL = "https://hmcl.huangyuhui.net/changelog/";
public static final String PUBLISH_URL = "http://www.mcbbs.net/thread-142335-1-1.html";
public static final String CHANGELOG_URL = "https://docs.hmcl.net/changelog/";
public static final String PUBLISH_URL = "https://www.mcbbs.net/thread-142335-1-1.html";
public static final String EULA_URL = "https://hmcl.huangyuhui.net/eula";
public static final String BUILD_CHANNEL = JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Build-Channel")).orElse("nightly");
public static final String BUILD_CHANNEL = JarUtils.getManifestAttribute("Build-Channel", "nightly");
public static final Path MINECRAFT_DIRECTORY = OperatingSystem.getWorkingDirectory("minecraft");
public static final Path HMCL_DIRECTORY = getHMCLDirectory();
private static Path getHMCLDirectory() {
String home = System.getProperty("user.home", ".");
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
// to fulfill XDG standard.
String xdgCache = System.getenv("XDG_CACHE_HOME");
if (StringUtils.isNotBlank(xdgCache)) {
return Paths.get(xdgCache, "hmcl");
}
return Paths.get(home, ".cache", "hmcl");
}
return OperatingSystem.getWorkingDirectory("hmcl");
}
public static final Path HMCL_DIRECTORY = OperatingSystem.getWorkingDirectory("hmcl");
public static boolean isStable() {
return "stable".equals(BUILD_CHANNEL);
@@ -59,8 +59,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
private void generateOptionsTxt() {
File optionsFile = new File(repository.getRunDirectory(version.getId()), "options.txt");
File configFolder = new File(repository.getRunDirectory(version.getId()), "config");
if (optionsFile.exists())
return;
if (configFolder.isDirectory())
if (findFiles(configFolder, "options.txt"))
return;
try {
// TODO: Dirty implementation here
if (I18n.getCurrentLocale().getLocale() == Locale.CHINA)
@@ -70,6 +75,20 @@ public final class HMCLGameLauncher extends DefaultLauncher {
}
}
private boolean findFiles(File folder, String fileName) {
File[] fs = folder.listFiles();
if (fs != null) {
for (File f : fs) {
if (f.isDirectory())
if (f.listFiles((dir, name) -> name.equals(fileName)) != null)
return true;
if (f.getName().equals(fileName))
return true;
}
}
return false;
}
@Override
public ManagedProcess launch() throws IOException, InterruptedException {
generateOptionsTxt();
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.game;
import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.stage.Stage;
import org.jackhuang.hmcl.Launcher;
@@ -62,6 +63,7 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
@@ -70,7 +72,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public final class LauncherHelper {
private final Profile profile;
private final Account account;
private Account account;
private final String selectedVersion;
private File scriptFile;
private final VersionSetting setting;
@@ -89,6 +91,14 @@ public final class LauncherHelper {
private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL);
public Account getAccount() {
return account;
}
public void setAccount(Account account) {
this.account = account;
}
public void setTestMode() {
launcherVisibility = LauncherVisibility.KEEP;
showLogs = true;
@@ -171,17 +181,7 @@ public final class LauncherHelper {
.thenComposeAsync(() -> {
return gameVersion.map(s -> new GameVerificationFixTask(dependencyManager, s, version.get())).orElse(null);
})
.thenComposeAsync(Task.supplyAsync(() -> {
try {
return account.logIn();
} catch (CredentialExpiredException e) {
LOG.info("Credential has expired: " + e);
return DialogController.logIn(account);
} catch (AuthenticationException e) {
LOG.warning("Authentication failed, try playing offline: " + e);
return account.playOffline().orElseThrow(() -> e);
}
}).withStage("launch.state.logging_in"))
.thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in"))
.thenComposeAsync(authInfo -> Task.supplyAsync(() -> {
LaunchOptions launchOptions = repository.getLaunchOptions(selectedVersion, javaVersionRef.get(), profile.getGameDir(), javaAgents, scriptFile != null);
return new HMCLGameLauncher(
@@ -375,7 +375,11 @@ public final class LauncherHelper {
}
}
if (targetJavaVersion != null) {
if (targetJavaVersion == null) {
Controllers.confirm(i18n("launch.failed.no_accepted_java"), i18n("message.warning"), MessageType.WARNING, continueAction, () -> {
future.completeExceptionally(new CancellationException("No accepted java"));
});
} else {
downloadJava(gameVersion.toString(), targetJavaVersion, profile)
.thenAcceptAsync(downloadedJavaVersion -> {
future.complete(downloadedJavaVersion);
@@ -564,7 +568,11 @@ public final class LauncherHelper {
JFXHyperlink link = new JFXHyperlink(i18n("download.external_link"));
link.setOnAction(e -> {
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
if (javaVersion.getMajorVersion() == JavaVersion.JAVA_8) {
FXUtils.openLink(ORACLEJDK_DOWNLOAD_LINK);
} else {
FXUtils.openLink(OPENJDK_DOWNLOAD_LINK);
}
future.completeExceptionally(new CancellationException());
});
@@ -618,6 +626,43 @@ public final class LauncherHelper {
return future;
}
private static Task<AuthInfo> logIn(Account account) {
return Task.composeAsync(() -> {
try {
return Task.completed(account.logIn());
} catch (CredentialExpiredException e) {
LOG.log(Level.INFO, "Credential has expired", e);
return Task.completed(DialogController.logIn(account));
} catch (AuthenticationException e) {
LOG.log(Level.WARNING, "Authentication failed, try skipping refresh", e);
CompletableFuture<Task<AuthInfo>> future = new CompletableFuture<>();
runInFX(() -> {
JFXButton loginOfflineButton = new JFXButton(i18n("account.login.skip"));
loginOfflineButton.setOnAction(event -> {
try {
future.complete(Task.completed(account.playOffline()));
} catch (AuthenticationException e2) {
future.completeExceptionally(e2);
}
});
JFXButton retryButton = new JFXButton(i18n("account.login.retry"));
retryButton.setOnAction(event -> {
future.complete(logIn(account));
});
Controllers.dialog(new MessageDialogPane.Builder(i18n("account.failed.server_disconnected"), i18n("account.failed"), MessageType.ERROR)
.addAction(loginOfflineButton)
.addAction(retryButton)
.addCancel(() ->
future.completeExceptionally(new CancellationException()))
.build());
});
return Task.fromCompletableFuture(future).thenComposeAsync(task -> task);
}
});
}
private static Optional<String> getLog4jPatch(Version version) {
Optional<String> log4jVersion = version.getLibraries().stream()
.filter(it -> it.is("org.apache.logging.log4j", "log4j-core")
@@ -815,6 +860,7 @@ public final class LauncherHelper {
}
private static final String ORACLEJDK_DOWNLOAD_LINK = "https://www.java.com/download";
private static final String OPENJDK_DOWNLOAD_LINK = "https://docs.microsoft.com/java/openjdk/download";
public static final Queue<ManagedProcess> PROCESSES = new ConcurrentLinkedQueue<>();
@@ -160,13 +160,13 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
@Override
public String getClientId() {
return System.getProperty("hmcl.microsoft.auth.id",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Id")).orElse(""));
JarUtils.getManifestAttribute("Microsoft-Auth-Id", ""));
}
@Override
public String getClientSecret() {
return System.getProperty("hmcl.microsoft.auth.secret",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("Microsoft-Auth-Secret")).orElse(""));
JarUtils.getManifestAttribute("Microsoft-Auth-Secret", ""));
}
@Override
@@ -22,6 +22,7 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.util.InvocationDispatcher;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
@@ -45,6 +46,7 @@ public final class ConfigHolder {
private static Config configInstance;
private static GlobalConfig globalConfigInstance;
private static boolean newlyCreated;
private static boolean ownerChanged = false;
public static Config config() {
if (configInstance == null) {
@@ -60,10 +62,18 @@ public final class ConfigHolder {
return globalConfigInstance;
}
public static Path configLocation() {
return configLocation;
}
public static boolean isNewlyCreated() {
return newlyCreated;
}
public static boolean isOwnerChanged() {
return ownerChanged;
}
public synchronized static void init() throws IOException {
if (configInstance != null) {
throw new IllegalStateException("Configuration is already loaded");
@@ -109,7 +119,7 @@ public final class ConfigHolder {
}
private static Path locateConfig() {
Path exePath = Paths.get("");
Path exePath = Paths.get("").toAbsolutePath();
try {
Path jarPath = Paths.get(ConfigHolder.class.getProtectionDomain().getCodeSource().getLocation()
.toURI()).toAbsolutePath();
@@ -143,6 +153,15 @@ public final class ConfigHolder {
private static Config loadConfig() throws IOException {
if (Files.exists(configLocation)) {
try {
if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS
&& "root".equals(System.getProperty("user.name"))
&& !"root".equals(Files.getOwner(configLocation).getName())) {
ownerChanged = true;
}
} catch (IOException e1) {
LOG.log(Level.WARNING, "Failed to get owner");
}
try {
String content = FileUtils.readText(configLocation);
Config deserialized = Config.fromJson(content);
@@ -189,6 +208,28 @@ public final class ConfigHolder {
// Global Config
private static GlobalConfig loadGlobalConfig() throws IOException {
// Migrate from old directory
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Files.notExists(GLOBAL_CONFIG_PATH)) {
Path oldHome;
String xdgCache = System.getenv("XDG_CACHE_HOME");
if (StringUtils.isNotBlank(xdgCache)) {
oldHome = Paths.get(xdgCache, "hmcl");
} else {
oldHome = Paths.get(System.getProperty("user.home", "."), ".cache", "hmcl");
}
if (Files.exists(oldHome)) {
Path oldConfigPath = oldHome.resolve("config.json");
if (Files.isRegularFile(oldConfigPath)) {
try {
Files.copy(oldConfigPath, GLOBAL_CONFIG_PATH);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to migrate global config", e);
}
}
}
}
if (Files.exists(GLOBAL_CONFIG_PATH)) {
try {
String content = FileUtils.readText(GLOBAL_CONFIG_PATH);
@@ -68,6 +68,8 @@ public class GlobalConfig implements Cloneable, Observable {
private IntegerProperty agreementVersion = new SimpleIntegerProperty();
private IntegerProperty platformPromptVersion = new SimpleIntegerProperty();
private StringProperty multiplayerToken = new SimpleStringProperty();
private BooleanProperty multiplayerRelay = new SimpleBooleanProperty();
@@ -113,6 +115,18 @@ public class GlobalConfig implements Cloneable, Observable {
this.agreementVersion.set(agreementVersion);
}
public int getPlatformPromptVersion() {
return platformPromptVersion.get();
}
public IntegerProperty platformPromptVersionProperty() {
return platformPromptVersion;
}
public void setPlatformPromptVersion(int platformPromptVersion) {
this.platformPromptVersion.set(platformPromptVersion);
}
public boolean isMultiplayerRelay() {
return multiplayerRelay.get();
}
@@ -152,6 +166,7 @@ public class GlobalConfig implements Cloneable, Observable {
public static class Serializer implements JsonSerializer<GlobalConfig>, JsonDeserializer<GlobalConfig> {
private static final Set<String> knownFields = new HashSet<>(Arrays.asList(
"agreementVersion",
"platformPromptVersion",
"multiplayerToken",
"multiplayerRelay",
"multiplayerAgreementVersion"
@@ -165,6 +180,7 @@ public class GlobalConfig implements Cloneable, Observable {
JsonObject jsonObject = new JsonObject();
jsonObject.add("agreementVersion", context.serialize(src.getAgreementVersion()));
jsonObject.add("platformPromptVersion", context.serialize(src.getPlatformPromptVersion()));
jsonObject.add("multiplayerToken", context.serialize(src.getMultiplayerToken()));
jsonObject.add("multiplayerRelay", context.serialize(src.isMultiplayerRelay()));
jsonObject.add("multiplayerAgreementVersion", context.serialize(src.getMultiplayerAgreementVersion()));
@@ -183,6 +199,7 @@ public class GlobalConfig implements Cloneable, Observable {
GlobalConfig config = new GlobalConfig();
config.setAgreementVersion(Optional.ofNullable(obj.get("agreementVersion")).map(JsonElement::getAsInt).orElse(0));
config.setPlatformPromptVersion(Optional.ofNullable(obj.get("platformPromptVersion")).map(JsonElement::getAsInt).orElse(0));
config.setMultiplayerToken(Optional.ofNullable(obj.get("multiplayerToken")).map(JsonElement::getAsString).orElse(null));
config.setMultiplayerRelay(Optional.ofNullable(obj.get("multiplayerRelay")).map(JsonElement::getAsBoolean).orElse(false));
config.setMultiplayerAgreementVersion(Optional.ofNullable(obj.get("multiplayerAgreementVersion")).map(JsonElement::getAsInt).orElse(0));
@@ -53,7 +53,9 @@ import org.jackhuang.hmcl.util.Lazy;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import java.io.File;
import java.util.List;
@@ -187,6 +189,19 @@ public final class Controllers {
stage.initStyle(StageStyle.TRANSPARENT);
stage.setScene(scene);
if (!Architecture.SYSTEM_ARCH.isX86() && globalConfig().getPlatformPromptVersion() < 1) {
Runnable continueAction = () -> globalConfig().setPlatformPromptVersion(1);
if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.osx_arm64"), null, MessageType.INFO, continueAction);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && Architecture.SYSTEM_ARCH == Architecture.ARM64) {
Controllers.dialog(i18n("fatal.unsupported_platform.windows_arm64"), null, MessageType.INFO, continueAction);
} else {
Controllers.dialog(i18n("fatal.unsupported_platform"), null, MessageType.WARNING, continueAction);
}
}
if (globalConfig().getAgreementVersion() < 1) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
@@ -710,6 +710,9 @@ public final class FXUtils {
}
public static List<Node> parseSegment(String segment, Consumer<String> hyperlinkAction) {
if (segment.indexOf('<') < 0)
return Collections.singletonList(new Text(segment));
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = factory.newDocumentBuilder();
@@ -77,7 +77,7 @@ public class GameCrashWindow extends Stage {
private final StringProperty arch = new SimpleStringProperty(Architecture.SYSTEM_ARCH.getDisplayName());
private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown")));
private final BooleanProperty loading = new SimpleBooleanProperty();
private final Label feedbackLabel = new Label(i18n("game.crash.feedback"));
private final TextFlow feedbackTextFlow = new TextFlow();
private final ManagedProcess managedProcess;
private final DefaultGameRepository repository;
@@ -104,6 +104,8 @@ public class GameCrashWindow extends Stage {
this.view = new View();
this.feedbackTextFlow.getChildren().addAll(FXUtils.parseSegment(i18n("game.crash.feedback"), Controllers::onHyperlinkAction));
setScene(new Scene(view, 800, 480));
getScene().getStylesheets().addAll(config().getTheme().getStylesheets(config().getLauncherFontFamily()));
setTitle(i18n("game.crash.title"));
@@ -137,7 +139,7 @@ public class GameCrashWindow extends Stage {
if (exception != null) {
LOG.log(Level.WARNING, "Failed to analyze crash report", exception);
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
} else {
List<CrashReportAnalyzer.Result> results = pair.getKey();
Set<String> keywords = pair.getValue();
@@ -177,12 +179,12 @@ public class GameCrashWindow extends Stage {
if (!keywords.isEmpty()) {
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.stacktrace", String.join(", ", keywords))));
} else {
reasonTextFlow.getChildren().setAll(new Text(i18n("game.crash.reason.unknown")));
reasonTextFlow.getChildren().setAll(FXUtils.parseSegment(i18n("game.crash.reason.unknown"), Controllers::onHyperlinkAction));
}
feedbackLabel.setVisible(true);
feedbackTextFlow.setVisible(true);
} else {
feedbackLabel.setVisible(false);
feedbackTextFlow.setVisible(false);
reasonTextFlow.getChildren().setAll(segments);
}
}
@@ -350,7 +352,7 @@ public class GameCrashWindow extends Stage {
gameDirPane.setPadding(new Insets(8));
VBox.setVgrow(gameDirPane, Priority.ALWAYS);
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow));
gameDirPane.getChildren().setAll(gameDir, javaDir, new VBox(reasonTitle, reasonTextFlow, feedbackTextFlow));
}
HBox toolBar = new HBox();
@@ -368,7 +370,7 @@ public class GameCrashWindow extends Stage {
toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, feedbackLabel);
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton);
}
getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
@@ -36,6 +36,7 @@ import javafx.scene.layout.*;
import javafx.stage.Stage;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import javax.swing.*;
@@ -99,7 +100,7 @@ public final class LogWindow extends Stage {
}
public void logLine(String line, Log4jLevel level) {
Log log = new Log(parseEscapeSequence(line), level);
Log log = new Log(Logging.filterForbiddenToken(parseEscapeSequence(line)), level);
logs.add(log);
if (levelShownMap.get(level).get())
impl.listView.getItems().add(log);
@@ -45,8 +45,10 @@ import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import java.net.URI;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
@@ -148,7 +150,13 @@ public class AccountListPage extends DecoratorAnimatedPage implements DecoratorP
ObservableValue<String> title = BindingMapping.of(server, AuthlibInjectorServer::getName);
item.titleProperty().bind(title);
item.subtitleProperty().set(URI.create(server.getUrl()).getHost());
String host = "";
try {
host = URI.create(server.getUrl()).getHost();
} catch (IllegalArgumentException e) {
LOG.log(Level.WARNING, "Unparsable authlib-injector server url " + server.getUrl(), e);
}
item.subtitleProperty().set(host);
Tooltip tooltip = new Tooltip();
tooltip.textProperty().bind(Bindings.format("%s (%s)", title, server.getUrl()));
FXUtils.installFastTooltip(item, tooltip);
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.layout.FlowPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.task.Schedulers;
@@ -67,6 +68,7 @@ public class AddAuthlibInjectorServerPane extends StackPane implements DialogAwa
loadFXML(this, "/assets/fxml/authlib-injector-server-add.fxml");
root.setContent(addServerPane, ContainerAnimations.NONE.getAnimationProducer());
lblCreationWarning.maxWidthProperty().bind(((FlowPane) lblCreationWarning.getParent()).widthProperty());
btnAddNext.disableProperty().bind(txtServerUrl.textProperty().isEmpty());
nextPane.hideSpinner();
@@ -286,7 +286,8 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
holder.add(Accounts.OAUTH_CALLBACK.onGrantDeviceCode.registerWeak(value -> {
runInFX(() -> deviceCode.set(value));
}));
HBox box = new HBox(8);
FlowPane box = new FlowPane();
box.setHgap(8);
JFXHyperlink birthLink = new JFXHyperlink(i18n("account.methods.microsoft.birth"));
birthLink.setOnAction(e -> FXUtils.openLink("https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a"));
JFXHyperlink profileLink = new JFXHyperlink(i18n("account.methods.microsoft.profile"));
@@ -294,8 +295,12 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
JFXHyperlink purchaseLink = new JFXHyperlink(i18n("account.methods.yggdrasil.purchase"));
JFXHyperlink deauthorizeLink = new JFXHyperlink(i18n("account.methods.microsoft.deauthorize"));
deauthorizeLink.setOnAction(e -> FXUtils.openLink("https://account.live.com/consent/Edit?client_id=000000004C794E0A"));
JFXHyperlink forgotpasswordLink = new JFXHyperlink(i18n("account.methods.forgot_password"));
forgotpasswordLink.setOnAction(e -> FXUtils.openLink("https://www.minecraft.net/password/forgot"));
JFXHyperlink createProfileLink = new JFXHyperlink(i18n("account.methods.microsoft.makegameidsettings"));
createProfileLink.setOnAction(e -> FXUtils.openLink("https://www.minecraft.net/msaprofile/mygames/editprofile"));
purchaseLink.setOnAction(e -> FXUtils.openLink(YggdrasilService.PURCHASE_URL));
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink);
box.getChildren().setAll(profileLink, birthLink, purchaseLink, deauthorizeLink, createProfileLink);
GridPane.setColumnSpan(box, 2);
vbox.getChildren().setAll(hintPane, box);
@@ -48,9 +48,9 @@ public class OAuthAccountLoginDialog extends DialogPane {
FXUtils.onChangeAndOperate(deviceCode, deviceCode -> {
if (deviceCode != null) {
FXUtils.copyText(deviceCode.getUserCode());
hintPane.setSegment(i18n("account.methods.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
hintPane.setSegment(i18n("account.login.refresh.microsoft.manual", deviceCode.getUserCode(), deviceCode.getVerificationUri()));
} else {
hintPane.setSegment(i18n("account.methods.microsoft.hint"));
hintPane.setSegment(i18n("account.login.refresh.microsoft.hint"));
}
});
hintPane.setOnMouseClicked(e -> {
@@ -19,26 +19,37 @@ package org.jackhuang.hmcl.ui.construct;
import javafx.collections.ObservableList;
import javafx.scene.Node;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import java.util.function.Consumer;
public class AdvancedListBox extends VBox {
public class AdvancedListBox extends ScrollPane {
private final VBox container = new VBox();
{
getStyleClass().add("advanced-list-box-content");
setContent(container);
FXUtils.smoothScrolling(this);
setFitToHeight(true);
setFitToWidth(true);
setHbarPolicy(ScrollBarPolicy.NEVER);
container.getStyleClass().add("advanced-list-box-content");
}
public AdvancedListBox add(Node child) {
if (child instanceof Pane || child instanceof AdvancedListItem)
getChildren().add(child);
container.getChildren().add(child);
else {
StackPane pane = new StackPane();
pane.getStyleClass().add("advanced-list-box-item");
pane.getChildren().setAll(child);
getChildren().add(pane);
container.getChildren().add(pane);
}
return this;
}
@@ -53,27 +64,27 @@ public class AdvancedListBox extends VBox {
public AdvancedListBox add(int index, Node child) {
if (child instanceof Pane || child instanceof AdvancedListItem)
getChildren().add(index, child);
container.getChildren().add(index, child);
else {
StackPane pane = new StackPane();
pane.getStyleClass().add("advanced-list-box-item");
pane.getChildren().setAll(child);
getChildren().add(index, pane);
container.getChildren().add(index, pane);
}
return this;
}
public AdvancedListBox remove(Node child) {
getChildren().remove(indexOf(child));
container.getChildren().remove(indexOf(child));
return this;
}
public int indexOf(Node child) {
if (child instanceof Pane) {
return getChildren().indexOf(child);
return container.getChildren().indexOf(child);
} else {
for (int i = 0; i < getChildren().size(); ++i) {
Node node = getChildren().get(i);
for (int i = 0; i < container.getChildren().size(); ++i) {
Node node = container.getChildren().get(i);
if (node instanceof StackPane) {
ObservableList<Node> list = ((StackPane) node).getChildren();
if (list.size() == 1 && list.get(0) == child)
@@ -88,7 +99,11 @@ public class AdvancedListBox extends VBox {
return add(new ClassTitle(category));
}
public void setSpacing(double spacing) {
container.setSpacing(spacing);
}
public void clear() {
getChildren().clear();
container.getChildren().clear();
}
}
@@ -28,11 +28,11 @@ import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Orientation;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Label;
import javafx.scene.control.SkinBase;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
@@ -115,12 +115,17 @@ public class ComponentList extends Control {
expanded = true;
}
@Override
public Orientation getContentBias() {
return Orientation.HORIZONTAL;
}
@Override
protected javafx.scene.control.Skin<?> createDefaultSkin() {
return new Skin(this);
}
protected static class Skin extends SkinBase<ComponentList> {
protected static class Skin extends ControlSkinBase<ComponentList> {
private static final PseudoClass PSEUDO_CLASS_FIRST = PseudoClass.getPseudoClass("first");
private static final PseudoClass PSEUDO_CLASS_LAST = PseudoClass.getPseudoClass("last");
@@ -166,7 +171,7 @@ public class ComponentList extends Control {
VBox vbox = new VBox();
vbox.setFillWidth(true);
Bindings.bindContent(vbox.getChildren(), list);
getChildren().setAll(vbox);
node = vbox;
}
}
@@ -0,0 +1,55 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import javafx.scene.Node;
import javafx.scene.control.Control;
import javafx.scene.control.Skin;
import java.util.Objects;
public abstract class ControlSkinBase<C extends Control> implements Skin<C> {
private final C control;
protected Node node;
/**
* Constructor for all SkinBase instances.
*
* @param control The control for which this Skin should attach to.
*/
protected ControlSkinBase(C control) {
this.control = control;
}
@Override
public C getSkinnable() {
return control;
}
@Override
public Node getNode() {
Objects.requireNonNull(node);
return node;
}
@Override
public void dispose() {
}
}
@@ -34,7 +34,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class DialogPane extends JFXDialogLayout {
private final StringProperty title = new SimpleStringProperty();
private final BooleanProperty valid = new SimpleBooleanProperty();
private final BooleanProperty valid = new SimpleBooleanProperty(true);
protected final SpinnerPane acceptPane = new SpinnerPane();
protected final JFXButton cancelButton = new JFXButton();
protected final Label warningLabel = new Label();
@@ -23,8 +23,12 @@ import javafx.fxml.FXML;
import javafx.scene.Node;
import javafx.scene.control.ButtonBase;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.scene.text.TextFlow;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -69,7 +73,17 @@ public final class MessageDialogPane extends StackPane {
public MessageDialogPane(@NotNull String text, @Nullable String title, @NotNull MessageType type) {
FXUtils.loadFXML(this, "/assets/fxml/message-dialog.fxml");
content.getChildren().setAll(FXUtils.segmentToTextFlow(text, Controllers::onHyperlinkAction));
EnhancedTextFlow textFlow = new EnhancedTextFlow();
textFlow.getChildren().setAll(FXUtils.parseSegment(text, Controllers::onHyperlinkAction));
if (textFlow.computePrefHeight(400.0) <= 350.0)
content.getChildren().setAll(textFlow);
else {
ScrollPane scrollPane = new ScrollPane(textFlow);
VBox.setVgrow(scrollPane, Priority.ALWAYS);
scrollPane.setFitToWidth(true);
content.getChildren().setAll(scrollPane);
}
if (title != null)
this.title.setText(title);
@@ -114,6 +128,13 @@ public final class MessageDialogPane extends StackPane {
return cancelButton;
}
private static final class EnhancedTextFlow extends TextFlow {
@Override
public double computePrefHeight(double width) {
return super.computePrefHeight(width);
}
}
public static class Builder {
private final MessageDialogPane dialog;
@@ -123,10 +144,11 @@ public final class MessageDialogPane extends StackPane {
public Builder addAction(Node actionNode) {
dialog.addButton(actionNode);
actionNode.getStyleClass().add("dialog-accept");
return this;
}
public Builder ok(Runnable ok) {
public Builder ok(@Nullable Runnable ok) {
JFXButton btnOk = new JFXButton(i18n("button.ok"));
btnOk.getStyleClass().add("dialog-accept");
if (ok != null) {
@@ -137,7 +159,22 @@ public final class MessageDialogPane extends StackPane {
return this;
}
public Builder yesOrNo(Runnable yes, Runnable no) {
public Builder addCancel(@Nullable Runnable cancel) {
return addCancel(i18n("button.cancel"), cancel);
}
public Builder addCancel(String cancelText, @Nullable Runnable cancel) {
JFXButton btnCancel = new JFXButton(cancelText);
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
}
dialog.addButton(btnCancel);
dialog.setCancelButton(btnCancel);
return this;
}
public Builder yesOrNo(@Nullable Runnable yes, @Nullable Runnable no) {
JFXButton btnYes = new JFXButton(i18n("button.yes"));
btnYes.getStyleClass().add("dialog-accept");
if (yes != null) {
@@ -145,27 +182,14 @@ public final class MessageDialogPane extends StackPane {
}
dialog.addButton(btnYes);
JFXButton btnNo = new JFXButton(i18n("button.no"));
btnNo.getStyleClass().add("dialog-cancel");
if (no != null) {
btnNo.setOnAction(e -> no.run());
}
dialog.addButton(btnNo);
dialog.setCancelButton(btnNo);
addCancel(i18n("button.no"), no);
return this;
}
public Builder actionOrCancel(ButtonBase actionButton, Runnable cancel) {
dialog.addButton(actionButton);
JFXButton btnCancel = new JFXButton(i18n("button.cancel"));
btnCancel.getStyleClass().add("dialog-cancel");
if (cancel != null) {
btnCancel.setOnAction(e -> cancel.run());
}
dialog.addButton(btnCancel);
dialog.setCancelButton(btnCancel);
addCancel(cancel);
return this;
}
@@ -28,7 +28,6 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.Toggle;
import javafx.scene.control.ToggleGroup;
import javafx.scene.control.Tooltip;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
@@ -165,19 +164,12 @@ public class MultiFileItem<T> extends VBox {
pane.setLeft(left);
if (StringUtils.isNotBlank(subtitle)) {
Optional<String> shortSubtitle = StringUtils.truncate(subtitle);
Label right;
if (shortSubtitle.isPresent()) {
right = new Label(shortSubtitle.get());
right.setTooltip(new Tooltip(subtitle));
} else {
right = new Label(subtitle);
}
BorderPane.setAlignment(right, Pos.CENTER_RIGHT);
right.setWrapText(true);
right.getStyleClass().add("subtitle-label");
right.setStyle("-fx-font-size: 10;");
pane.setRight(right);
Label center = new Label(subtitle);
BorderPane.setAlignment(center, Pos.CENTER_RIGHT);
center.setWrapText(true);
center.getStyleClass().add("subtitle-label");
center.setStyle("-fx-font-size: 10;");
pane.setCenter(center);
}
return pane;
@@ -50,8 +50,9 @@ public class OptionToggleButton extends StackPane {
titleLabel.textProperty().bind(title);
Label subtitleLabel = new Label();
subtitleLabel.setMouseTransparent(true);
subtitleLabel.setWrapText(true);
subtitleLabel.textProperty().bind(subtitle);
pane.setLeft(left);
pane.setCenter(left);
left.setAlignment(Pos.CENTER_LEFT);
JFXToggleButton toggleButton = new JFXToggleButton();
@@ -0,0 +1,62 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.jackhuang.hmcl.util.StringUtils;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class ServerAddressValidator extends ValidatorBase {
private final boolean nullable;
public ServerAddressValidator() {
this(false);
}
public ServerAddressValidator(@NamedArg("nullable") boolean nullable) {
this(i18n("input.url"), nullable);
}
public ServerAddressValidator(@NamedArg("message") String message, @NamedArg("nullable") boolean nullable) {
super(message);
this.nullable = nullable;
}
@Override
protected void eval() {
if (srcControl.get() instanceof TextInputControl) {
evalTextInputField();
}
}
private static final Pattern PATTERN = Pattern.compile("[-a-zA-Z0-9@:%._+~#=]{1,256}(:\\d+)?");
private void evalTextInputField() {
TextInputControl textField = ((TextInputControl) srcControl.get());
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else
hasErrors.set(!PATTERN.matcher(textField.getText()).matches());
}
}
@@ -1,3 +1,20 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
@@ -91,9 +91,11 @@ class AdditionalInstallersPage extends InstallersPage {
String optiFine = analyzer.getVersion(OPTIFINE).orElse(null);
String fabric = analyzer.getVersion(FABRIC).orElse(null);
String fabricApi = analyzer.getVersion(FABRIC_API).orElse(null);
String quilt = analyzer.getVersion(QUILT).orElse(null);
String quiltApi = analyzer.getVersion(QUILT_API).orElse(null);
InstallerItem[] libraries = group.getLibraries();
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi};
String[] versions = new String[]{game, forge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi};
String currentGameVersion = Lang.nonNull(getVersion("game"), game);
@@ -53,10 +53,9 @@ public class DownloadSettingsPage extends StackPane {
getChildren().setAll(scrollPane);
{
ComponentList downloadSource = new ComponentList();
VBox downloadSource = new VBox(8);
downloadSource.getStyleClass().add("card-non-transparent");
{
VBox pane = new VBox(8);
VBox chooseWrapper = new VBox();
chooseWrapper.setPadding(new Insets(8, 0, 8, 0));
@@ -75,7 +74,7 @@ public class DownloadSettingsPage extends StackPane {
JFXComboBox<String> cboVersionListSource = new JFXComboBox<>();
cboVersionListSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
versionListSourcePane.setRight(cboVersionListSource);
FXUtils.setLimitWidth(cboVersionListSource, 420);
FXUtils.setLimitWidth(cboVersionListSource, 400);
cboVersionListSource.getItems().setAll(DownloadProviders.providersById.keySet());
selectedItemPropertyFor(cboVersionListSource).bindBidirectional(config().versionListSourceProperty());
@@ -91,31 +90,28 @@ public class DownloadSettingsPage extends StackPane {
JFXComboBox<String> cboDownloadSource = new JFXComboBox<>();
cboDownloadSource.setConverter(stringConverter(key -> i18n("download.provider." + key)));
downloadSourcePane.setRight(cboDownloadSource);
downloadSourcePane.setCenter(cboDownloadSource);
FXUtils.setLimitWidth(cboDownloadSource, 420);
cboDownloadSource.getItems().setAll(DownloadProviders.rawProviders.keySet());
selectedItemPropertyFor(cboDownloadSource).bindBidirectional(config().downloadTypeProperty());
}
pane.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
downloadSource.getContent().add(pane);
downloadSource.getChildren().setAll(chooseWrapper, versionListSourcePane, downloadSourcePane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.version_list_source")), downloadSource);
}
{
ComponentList downloadThreads = new ComponentList();
VBox downloadThreads = new VBox(16);
downloadThreads.getStyleClass().add("card-non-transparent");
{
VBox pane = new VBox(16);
pane.setPadding(new Insets(8, 0, 8, 0));
{
JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto"));
VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0));
chkAutoDownloadThreads.selectedProperty().bindBidirectional(config().autoDownloadThreadsProperty());
pane.getChildren().add(chkAutoDownloadThreads);
downloadThreads.getChildren().add(chkAutoDownloadThreads);
chkAutoDownloadThreads.selectedProperty().addListener((a, b, newValue) -> {
if (newValue) {
@@ -150,7 +146,7 @@ public class DownloadSettingsPage extends StackPane {
});
hbox.getChildren().setAll(label, slider, threadsField);
pane.getChildren().add(hbox);
downloadThreads.getChildren().add(hbox);
}
{
@@ -158,26 +154,22 @@ public class DownloadSettingsPage extends StackPane {
VBox.setMargin(hintPane, new Insets(0, 0, 0, 30));
hintPane.disableProperty().bind(config().autoDownloadThreadsProperty());
hintPane.setText(i18n("settings.launcher.download.threads.hint"));
pane.getChildren().add(hintPane);
downloadThreads.getChildren().add(hintPane);
}
downloadThreads.getContent().add(pane);
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads);
}
{
ComponentList proxyList = new ComponentList();
VBox proxyWrapper = new VBox();
proxyWrapper.setPadding(new Insets(8, 0, 8, 0));
proxyWrapper.setSpacing(10);
VBox proxyList = new VBox(10);
proxyList.getStyleClass().add("card-non-transparent");
VBox proxyPane = new VBox();
{
JFXCheckBox chkDisableProxy = new JFXCheckBox(i18n("settings.launcher.proxy.disable"));
proxyWrapper.getChildren().add(chkDisableProxy);
VBox.setMargin(chkDisableProxy, new Insets(8, 0, 0, 0));
proxyList.getChildren().add(chkDisableProxy);
reversedSelectedPropertyFor(chkDisableProxy).bindBidirectional(config().hasProxyProperty());
proxyPane.disableProperty().bind(chkDisableProxy.selectedProperty());
}
@@ -310,9 +302,8 @@ public class DownloadSettingsPage extends StackPane {
proxyPane.getChildren().add(authPane);
}
proxyWrapper.getChildren().add(proxyPane);
proxyList.getChildren().add(proxyPane);
}
proxyList.getContent().add(proxyWrapper);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.proxy")), proxyList);
}
@@ -29,9 +29,13 @@ import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.SnapshotParameters;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.image.WritableImage;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
@@ -34,6 +34,8 @@ import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.account.AccountAdvancedListItem;
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
@@ -42,6 +44,7 @@ import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.File;
@@ -153,7 +156,18 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
multiplayerItem.setLeftGraphic(wrap(SVG::lan));
multiplayerItem.setActionButtonVisible(false);
multiplayerItem.setTitle(i18n("multiplayer"));
multiplayerItem.setOnAction(e -> Controllers.navigate(Controllers.getMultiplayerPage()));
if ("true".equalsIgnoreCase(JarUtils.getManifestAttribute("Enable-HiPer", "")))
multiplayerItem.setOnAction(e -> Controllers.navigate(Controllers.getMultiplayerPage()));
else {
JFXHyperlink link = new JFXHyperlink(i18n("multiplayer.hint.details"));
link.setOnAction(e -> FXUtils.openLink("https://hmcl.huangyuhui.net/api/redirect/multiplayer-migrate"));
multiplayerItem.setOnAction(e ->
Controllers.dialog(
new MessageDialogPane.Builder(i18n("multiplayer.hint"), null, MessageDialogPane.MessageType.INFO)
.addAction(link)
.ok(null)
.build()));
}
// sixth item in left sidebar
AdvancedListItem launcherSettingsItem = new AdvancedListItem();
@@ -1,153 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXCheckBox;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.ColumnConstraints;
import javafx.scene.layout.GridPane;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FutureCallback;
import java.util.Objects;
import java.util.Optional;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class CreateMultiplayerRoomDialog extends DialogPane implements DialogAware {
private final FutureCallback<CreationRequest> callback;
private final LocalServerDetector lanServerDetectorThread;
private final BooleanProperty allowAllJoinRequests = new SimpleBooleanProperty(true);
private LocalServerDetector.PingResponse server;
CreateMultiplayerRoomDialog(FutureCallback<CreationRequest> callback) {
this.callback = callback;
setTitle(i18n("multiplayer.session.create"));
GridPane body = new GridPane();
body.setMaxWidth(500);
body.getColumnConstraints().addAll(new ColumnConstraints(), FXUtils.getColumnHgrowing());
body.setVgap(8);
body.setHgap(16);
body.setDisable(true);
setBody(body);
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.session.create.hint"));
GridPane.setColumnSpan(hintPane, 2);
body.addRow(0, hintPane);
Label nameField = new Label();
nameField.setText(Optional.ofNullable(Accounts.getSelectedAccount())
.map(Account::getUsername)
.map(username -> i18n("multiplayer.session.name.format", username))
.orElse(""));
body.addRow(1, new Label(i18n("multiplayer.session.create.name")), nameField);
Label portLabel = new Label(i18n("multiplayer.nat.testing"));
portLabel.setText(i18n("multiplayer.nat.testing"));
body.addRow(2, new Label(i18n("multiplayer.session.create.port")), portLabel);
JFXCheckBox allowAllJoinRequestsCheckBox = new JFXCheckBox(i18n("multiplayer.session.create.join.allow"));
allowAllJoinRequestsCheckBox.selectedProperty().bindBidirectional(allowAllJoinRequests);
GridPane.setColumnSpan(allowAllJoinRequestsCheckBox, 2);
body.addRow(3, allowAllJoinRequestsCheckBox);
setValid(false);
JFXHyperlink noinLink = new JFXHyperlink();
noinLink.setText("mcer.cn");
noinLink.setOnAction(e -> FXUtils.openLink("https://mcer.cn/circle/cato"));
setActions(warningLabel, noinLink, acceptPane, cancelButton);
lanServerDetectorThread = new LocalServerDetector(3);
lanServerDetectorThread.onDetectedLanServer().register(event -> {
runInFX(() -> {
if (event.getLanServer() != null && event.getLanServer().isValid()) {
nameField.setText(event.getLanServer().getMotd());
portLabel.setText(event.getLanServer().getAd().toString());
setValid(true);
} else {
nameField.setText("");
portLabel.setText("");
onFailure(i18n("multiplayer.session.create.port.error"));
setValid(false);
}
server = event.getLanServer();
body.setDisable(false);
getProgressBar().setVisible(false);
});
});
}
@Override
protected void onAccept() {
setLoading();
callback.call(new CreationRequest(
Objects.requireNonNull(server),
allowAllJoinRequests.get()
), () -> {
runInFX(this::onSuccess);
}, msg -> {
runInFX(() -> onFailure(msg));
});
}
@Override
public void onDialogShown() {
getProgressBar().setVisible(true);
getProgressBar().setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
lanServerDetectorThread.start();
}
@Override
public void onDialogClosed() {
lanServerDetectorThread.interrupt();
}
public static class CreationRequest {
private final LocalServerDetector.PingResponse server;
private final boolean allowAllJoinRequests;
public CreationRequest(LocalServerDetector.PingResponse server, boolean allowAllJoinRequests) {
this.server = server;
this.allowAllJoinRequests = allowAllJoinRequests;
}
public LocalServerDetector.PingResponse getServer() {
return server;
}
public boolean isAllowAllJoinRequests() {
return allowAllJoinRequests;
}
}
}
@@ -1,6 +1,6 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@@ -17,31 +17,110 @@
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Lang;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.*;
import java.nio.channels.UnresolvedAddressException;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LocalServerBroadcaster implements Runnable {
private final int port;
private final MultiplayerManager.CatoSession session;
public class LocalServerBroadcaster implements AutoCloseable {
private final String address;
private final ThreadGroup threadGroup = new ThreadGroup("JoinSession");
public LocalServerBroadcaster(int port, MultiplayerManager.CatoSession session) {
this.port = port;
this.session = session;
private final EventManager<Event> onExit = new EventManager<>();
private boolean running = true;
public LocalServerBroadcaster(String address) {
this.address = address;
}
public int getPort() {
return port;
private Thread newThread(Runnable task, String name) {
Thread thread = new Thread(threadGroup, task, name);
thread.setDaemon(true);
return thread;
}
@Override
public void run() {
public void close() {
running = false;
threadGroup.interrupt();
}
public String getAddress() {
return address;
}
public EventManager<Event> onExit() {
return onExit;
}
public static final Pattern ADDRESS_PATTERN = Pattern.compile("^\\s*(\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}):(\\d{1,5})\\s*$");
public void start() {
Thread forwardPortThread = newThread(this::forwardPort, "ForwardPort");
forwardPortThread.start();
}
private void forwardPort() {
try {
Matcher matcher = ADDRESS_PATTERN.matcher(address);
if (!matcher.find()) {
throw new MalformedURLException();
}
try (Socket forwardingSocket = new Socket();
ServerSocket serverSocket = new ServerSocket()) {
forwardingSocket.setSoTimeout(30000);
forwardingSocket.connect(new InetSocketAddress(matcher.group(1), Lang.parseInt(matcher.group(2), 0)));
serverSocket.bind(null);
Thread broadcastMOTDThread = newThread(() -> broadcastMOTD(serverSocket.getLocalPort()), "BroadcastMOTD");
broadcastMOTDThread.start();
LOG.log(Level.INFO, "Listening " + serverSocket.getLocalSocketAddress());
while (running) {
Socket forwardedSocket = serverSocket.accept();
LOG.log(Level.INFO, "Accepting client");
newThread(() -> forwardTraffic(forwardingSocket, forwardedSocket), "Forward S->D").start();
newThread(() -> forwardTraffic(forwardedSocket, forwardingSocket), "Forward D->S").start();
}
}
} catch (IOException | UnresolvedAddressException e) {
LOG.log(Level.WARNING, "Error in forwarding port", e);
} finally {
close();
onExit.fireEvent(new Event(this));
}
}
private void forwardTraffic(Socket src, Socket dest) {
try (InputStream is = src.getInputStream(); OutputStream os = dest.getOutputStream()) {
byte[] buf = new byte[1024];
while (true) {
int len = is.read(buf, 0, buf.length);
if (len < 0) break;
LOG.log(Level.INFO, "Forwarding buffer " + len);
os.write(buf, 0, len);
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Disconnected", e);
}
}
private void broadcastMOTD(int port) {
DatagramSocket socket;
InetAddress broadcastAddress;
try {
@@ -52,9 +131,9 @@ public class LocalServerBroadcaster implements Runnable {
return;
}
while (session.isRunning()) {
while (running) {
try {
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", session.getName()), port).getBytes(StandardCharsets.UTF_8);
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd"), port).getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
socket.send(packet);
LOG.finest("Broadcast server 0.0.0.0:" + port);
@@ -1,141 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class LocalServerDetector extends Thread {
private final EventManager<DetectedLanServerEvent> onDetectedLanServer = new EventManager<>();
private final int retry;
public LocalServerDetector(int retry) {
this.retry = retry;
setName("LocalServerDetector");
setDaemon(true);
}
public EventManager<DetectedLanServerEvent> onDetectedLanServer() {
return onDetectedLanServer;
}
@Override
public void run() {
MulticastSocket socket;
InetAddress broadcastAddress;
try {
socket = new MulticastSocket(4445);
socket.setSoTimeout(5000);
socket.joinGroup(broadcastAddress = InetAddress.getByName("224.0.2.60"));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to create datagram socket", e);
return;
}
byte[] buf = new byte[1024];
int tried = 0;
while (!isInterrupted()) {
DatagramPacket packet = new DatagramPacket(buf, 1024);
try {
socket.receive(packet);
} catch (SocketTimeoutException e) {
if (tried++ > retry) {
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, null));
break;
}
continue;
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to detect lan server", e);
break;
}
String response = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
LOG.fine("Local server " + packet.getAddress() + ":" + packet.getPort() + " broadcast message: " + response);
onDetectedLanServer.fireEvent(new DetectedLanServerEvent(this, PingResponse.parsePingResponse(response)));
break;
}
try {
socket.leaveGroup(broadcastAddress);
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to leave multicast listening group", e);
}
socket.close();
}
public static class DetectedLanServerEvent extends Event {
private final PingResponse lanServer;
public DetectedLanServerEvent(Object source, PingResponse lanServer) {
super(source);
this.lanServer = lanServer;
}
public PingResponse getLanServer() {
return lanServer;
}
}
public static class PingResponse {
private final String motd;
private final Integer ad;
public PingResponse(String motd, Integer ad) {
this.motd = motd;
this.ad = ad;
}
public String getMotd() {
return motd;
}
public Integer getAd() {
return ad;
}
public boolean isValid() {
return ad != null;
}
public static PingResponse parsePingResponse(String message) {
return new PingResponse(
StringUtils.substringBefore(
StringUtils.substringAfter(message, "[MOTD]"),
"[/MOTD]"),
Lang.toIntOrNull(StringUtils.substringBefore(
StringUtils.substringAfter(message, "[AD]"),
"[/AD]"))
);
}
}
}
@@ -1,154 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.util.gson.JsonSubtype;
import org.jackhuang.hmcl.util.gson.JsonType;
public final class MultiplayerChannel {
private MultiplayerChannel() {
}
@JsonType(
property = "type",
subtypes = {
@JsonSubtype(clazz = HandshakeRequest.class, name = "handshake"),
@JsonSubtype(clazz = JoinRequest.class, name = "join"),
@JsonSubtype(clazz = KeepAliveRequest.class, name = "keepalive")
}
)
public static class Request {
}
public static class HandshakeRequest extends Request {
}
public static class JoinRequest extends Request {
private final String clientVersion;
private final String username;
public JoinRequest(String clientVersion, String username) {
this.clientVersion = clientVersion;
this.username = username;
}
public String getClientVersion() {
return clientVersion;
}
public String getUsername() {
return username;
}
}
public static class KeepAliveRequest extends Request {
private final long timestamp;
public KeepAliveRequest(long timestamp) {
this.timestamp = timestamp;
}
public long getTimestamp() {
return timestamp;
}
}
@JsonType(
property = "type",
subtypes = {
@JsonSubtype(clazz = HandshakeResponse.class, name = "handshake"),
@JsonSubtype(clazz = JoinResponse.class, name = "join"),
@JsonSubtype(clazz = KeepAliveResponse.class, name = "keepalive"),
@JsonSubtype(clazz = KickResponse.class, name = "kick")
}
)
public static class Response {
}
public static class HandshakeResponse extends Response {
}
public static class JoinResponse extends Response {
private final String sessionName;
private final int port;
public JoinResponse(String sessionName, int port) {
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
public int getPort() {
return port;
}
}
public static class KeepAliveResponse extends Response {
private final long timestamp;
public KeepAliveResponse(long timestamp) {
this.timestamp = timestamp;
}
public long getTimestamp() {
return timestamp;
}
}
public static class KickResponse extends Response {
private final String msg;
public KickResponse(String msg) {
this.msg = msg;
}
public String getMsg() {
return msg;
}
public static final String VERSION_NOT_MATCHED = "version_not_matched";
public static final String KICKED = "kicked";
public static final String JOIN_ACEEPTANCE_TIMEOUT = "join_acceptance_timeout";
}
public static class CatoClient extends Event {
private final String username;
public CatoClient(Object source, String username) {
super(source);
this.username = username;
}
public String getUsername() {
return username;
}
}
public static String verifyJson(String jsonString) {
if (jsonString.indexOf('\r') >= 0 || jsonString.indexOf('\n') >= 0) {
throw new IllegalArgumentException();
}
return jsonString;
}
}
@@ -1,227 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.TimerTask;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerClient extends Thread {
private final String id;
private final int port;
private int gamePort;
private boolean connected = false;
private final EventManager<ConnectedEvent> onConnected = new EventManager<>();
private final EventManager<Event> onDisconnected = new EventManager<>();
private final EventManager<KickEvent> onKicked = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
public MultiplayerClient(String id, int port) {
this.id = id;
this.port = port;
setName("MultiplayerClient");
setDaemon(true);
}
public synchronized void setGamePort(int gamePort) {
this.gamePort = gamePort;
}
public synchronized int getGamePort() {
return gamePort;
}
public EventManager<ConnectedEvent> onConnected() {
return onConnected;
}
public EventManager<Event> onDisconnected() {
return onDisconnected;
}
public EventManager<KickEvent> onKicked() {
return onKicked;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
@Override
public void run() {
LOG.info("Connecting to 127.0.0.1:" + port);
for (int i = 0; i < 5; i++) {
KeepAliveThread keepAliveThread = null;
try (Socket socket = new Socket(InetAddress.getLoopbackAddress(), port);
BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8))) {
MultiplayerServer.Endpoint endpoint = new MultiplayerServer.Endpoint(socket, writer);
socket.setSoTimeout(30000);
LOG.info("Connected to 127.0.0.1:" + port);
endpoint.write(new HandshakeRequest());
endpoint.write(new JoinRequest(MultiplayerManager.CATO_VERSION, id));
LOG.fine("Sent join request with id=" + id);
keepAliveThread = new KeepAliveThread(endpoint);
keepAliveThread.start();
TimerTask task = Lang.setTimeout(() -> {
// If after 15 seconds, we didn't receive the HandshakeResponse,
// We fail to establish the connection with server.
try {
LOG.log(Level.WARNING, "Socket connection timeout, closing socket");
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to close socket", e);
}
}, 25 * 1000);
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
return;
}
LOG.fine("Message from server:" + line);
Response response = JsonUtils.fromNonNullJson(line, Response.class);
if (response instanceof JoinResponse) {
JoinResponse joinResponse = JsonUtils.fromNonNullJson(line, JoinResponse.class);
setGamePort(joinResponse.getPort());
connected = true;
onConnected.fireEvent(new ConnectedEvent(this, joinResponse.getSessionName(), joinResponse.getPort()));
LOG.fine("Received join response with port " + joinResponse.getPort());
} else if (response instanceof KickResponse) {
LOG.fine("Kicked by the server");
onKicked.fireEvent(new KickEvent(this, ((KickResponse) response).getMsg()));
return;
} else if (response instanceof KeepAliveResponse) {
} else if (response instanceof HandshakeResponse) {
LOG.fine("Established connection with server");
onHandshake.fireEvent(new Event(this));
task.cancel();
} else {
LOG.log(Level.WARNING, "Unrecognized packet from server:" + line);
}
}
} catch (ConnectException e) {
LOG.info("Failed to connect to 127.0.0.1:" + port + ", tried " + i + " time(s)");
try {
Thread.sleep(2000);
} catch (InterruptedException ex) {
LOG.warning("MultiplayerClient interrupted");
return;
}
continue;
} catch (IOException | JsonParseException e) {
e.printStackTrace();
} finally {
if (keepAliveThread != null) {
keepAliveThread.interrupt();
}
}
}
LOG.info("Lost connection to 127.0.0.1:" + port);
onDisconnected.fireEvent(new Event(this));
}
public boolean isConnected() {
return connected;
}
private static class KeepAliveThread extends Thread {
private final MultiplayerServer.Endpoint endpoint;
public KeepAliveThread(MultiplayerServer.Endpoint endpoint) {
this.endpoint = endpoint;
}
@Override
public void run() {
while (!isInterrupted()) {
try {
endpoint.write(new KeepAliveRequest(System.currentTimeMillis()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send keep alive packet", e);
break;
}
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
break;
}
}
}
}
public static class ConnectedEvent extends Event {
private final String sessionName;
private final int port;
public ConnectedEvent(Object source, String sessionName, int port) {
super(source);
this.sessionName = sessionName;
this.port = port;
}
public String getSessionName() {
return sessionName;
}
public int getPort() {
return port;
}
}
public static class KickEvent extends Event {
private final String reason;
public KickEvent(Object source, String reason) {
super(source);
this.reason = reason;
}
public String getReason() {
return reason;
}
}
}
@@ -18,474 +18,442 @@
package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException;
import com.google.gson.annotations.SerializedName;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.*;
import java.nio.file.attribute.PosixFilePermission;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
import java.util.concurrent.TimeUnit;
import java.util.function.BiFunction;
import java.util.logging.Level;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Lang.wrap;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.util.Lang.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.io.ChecksumMismatchException.verifyChecksum;
/**
* Cato Management.
*/
public final class MultiplayerManager {
static final String CATO_VERSION = "1.2.2";
private static final String CATO_DOWNLOAD_URL = "https://gitcode.net/to/cato/-/raw/master/client/";
private static final String CATO_HASH_URL = CATO_DOWNLOAD_URL + "cato-all-files.sha1";
private static final String CATO_PATH = getCatoPath();
public static final int CATO_AGREEMENT_VERSION = 2;
// static final String HIPER_VERSION = "1.2.2";
private static final String HIPER_DOWNLOAD_URL = "https://gitcode.net/to/hiper/-/raw/master/";
private static final String HIPER_PACKAGES_URL = HIPER_DOWNLOAD_URL + "packages.sha1";
private static final String HIPER_POINTS_URL = "https://cert.mcer.cn/point.yml";
private static final Path HIPER_TEMP_CONFIG_PATH = Metadata.HMCL_DIRECTORY.resolve("hiper.yml");
private static final Path HIPER_CONFIG_DIR = Metadata.HMCL_DIRECTORY.resolve("hiper-config");
public static final Path HIPER_PATH = getHiperLocalDirectory().resolve(getHiperFileName());
public static final int HIPER_AGREEMENT_VERSION = 3;
private static final String REMOTE_ADDRESS = "127.0.0.1";
private static final String LOCAL_ADDRESS = "0.0.0.0";
private static final Map<Architecture, String> archMap = mapOf(
pair(Architecture.ARM32, "arm-7"),
pair(Architecture.ARM64, "arm64"),
pair(Architecture.X86, "386"),
pair(Architecture.X86_64, "amd64"),
pair(Architecture.LOONGARCH64, "loong64"),
pair(Architecture.MIPS, "mips"),
pair(Architecture.MIPS64, "mips64"),
pair(Architecture.MIPS64EL, "mips64le"),
pair(Architecture.PPC64LE, "ppc64le"),
pair(Architecture.RISCV64, "riscv64"),
pair(Architecture.MIPSEL, "mipsle")
);
private static final Map<OperatingSystem, String> osMap = mapOf(
pair(OperatingSystem.LINUX, "linux"),
pair(OperatingSystem.WINDOWS, "windows"),
pair(OperatingSystem.OSX, "darwin")
);
private static final String HIPER_TARGET_NAME = String.format("%s-%s",
osMap.getOrDefault(OperatingSystem.CURRENT_OS, "windows"),
archMap.getOrDefault(Architecture.SYSTEM_ARCH, "amd64"));
private static final String GSUDO_VERSION = "1.7.1";
private static final String GSUDO_TARGET_ARCH = Architecture.SYSTEM_ARCH == Architecture.X86_64 ? "amd64" : "x86";
private static final String GSUDO_FILE_NAME = "gsudo.exe";
private static final String GSUDO_DOWNLOAD_URL = "https://gitcode.net/glavo/gsudo-release/-/raw/75c952ea3afe8792b0db4fe9bab87d41b21e5895/" + GSUDO_TARGET_ARCH + "/" + GSUDO_FILE_NAME;
private static final Path GSUDO_LOCAL_FILE = Metadata.HMCL_DIRECTORY.resolve("libraries").resolve("gsudo").resolve("gsudo").resolve(GSUDO_VERSION).resolve(GSUDO_TARGET_ARCH).resolve(GSUDO_FILE_NAME);
private static final boolean USE_GSUDO;
static final boolean IS_ADMINISTRATOR;
static final BooleanBinding tokenInvalid = Bindings.createBooleanBinding(
() -> {
String token = globalConfig().multiplayerTokenProperty().getValue();
return token == null || token.isEmpty() || !StringUtils.isAlphabeticOrNumber(token);
},
globalConfig().multiplayerTokenProperty());
private static final DateFormat HIPER_VALID_TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
static {
boolean isAdministrator = false;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
try {
Process process = Runtime.getRuntime().exec(new String[]{"net.exe", "session"});
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroy();
} else {
isAdministrator = process.exitValue() == 0;
}
} catch (Throwable ignored) {
}
USE_GSUDO = !isAdministrator && OperatingSystem.SYSTEM_BUILD_NUMBER >= 10000;
} else {
isAdministrator = "root".equals(System.getProperty("user.name"));
USE_GSUDO = false;
}
IS_ADMINISTRATOR = isAdministrator;
}
private static CompletableFuture<Map<String, String>> HASH;
private MultiplayerManager() {
}
private static CompletableFuture<Map<String, String>> getCatoHash() {
public static Path getConfigPath(String token) {
return HIPER_CONFIG_DIR.resolve(Hex.encodeHex(DigestUtils.digest("SHA-1", token)) + ".yml");
}
public static void clearConfiguration() {
try {
Files.deleteIfExists(HIPER_TEMP_CONFIG_PATH);
Files.deleteIfExists(getConfigPath(ConfigHolder.globalConfig().getMultiplayerToken()));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to delete config", e);
}
}
private static CompletableFuture<Map<String, String>> getPackagesHash() {
FXUtils.checkFxUserThread();
if (HASH == null) {
HASH = CompletableFuture.supplyAsync(wrap(() -> {
String hashList = HttpRequest.GET(CATO_HASH_URL).getString();
String hashList = HttpRequest.GET(HIPER_PACKAGES_URL).getString();
Map<String, String> hashes = new HashMap<>();
for (String line : hashList.split("\n")) {
String[] items = line.trim().split(" ");
String[] items = line.trim().split(" {2}");
if (items.length == 2 && items[0].length() == 40) {
hashes.put(items[1], items[0]);
} else {
LOG.warning("Failed to parse cato hash file, hash line " + line);
LOG.warning("Failed to parse Hiper packages.sha1 file, line: " + line);
}
}
if (USE_GSUDO) {
hashes.put(GSUDO_FILE_NAME, HttpRequest.GET(GSUDO_DOWNLOAD_URL + ".sha1").getString().trim());
}
return hashes;
}));
}
return HASH;
}
public static Task<Void> downloadCato() {
return Task.fromCompletableFuture(getCatoHash()).thenComposeAsync(catoHashes ->
new FileDownloadTask(
NetworkUtils.toURL(CATO_DOWNLOAD_URL + getCatoFileName()),
getCatoExecutable().toFile(),
catoHashes.get(getCatoFileName()) == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", catoHashes.get(getCatoFileName()))
).thenRunAsync(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(getCatoExecutable());
perm.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(getCatoExecutable(), perm);
}
}));
}
public static Task<Void> downloadHiper() {
return Task.fromCompletableFuture(getPackagesHash()).thenComposeAsync(packagesHash -> {
public static Path getCatoExecutable() {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve(CATO_PATH);
}
BiFunction<String, String, FileDownloadTask> getFileDownloadTask = (String remotePath, String localFileName) -> {
String hash = packagesHash.get(remotePath);
return new FileDownloadTask(
NetworkUtils.toURL(String.format("%s%s", HIPER_DOWNLOAD_URL, remotePath)),
getHiperLocalDirectory().resolve(localFileName).toFile(),
hash == null ? null : new FileDownloadTask.IntegrityCheck("SHA-1", hash));
};
private static CompletableFuture<CatoSession> startCato(String token, State state) {
return getCatoHash().thenApplyAsync(wrap(catoHashes -> {
Path exe = getCatoExecutable();
if (!Files.isRegularFile(exe)) {
throw new CatoNotExistsException(exe);
}
if (!isPortAvailable(3478)) {
throw new CatoAlreadyStartedException();
}
try {
String hash = catoHashes.get(getCatoFileName());
if (hash != null) {
ChecksumMismatchException.verifyChecksum(exe, "SHA-1", hash);
List<Task<?>> tasks;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
if (!packagesHash.containsKey(String.format("%s/hiper.exe", HIPER_TARGET_NAME))) {
throw new HiperUnsupportedPlatformException();
}
tasks = new ArrayList<>(4);
tasks.add(getFileDownloadTask.apply(String.format("%s/hiper.exe", HIPER_TARGET_NAME), "hiper.exe"));
tasks.add(getFileDownloadTask.apply(String.format("%s/wintun.dll", HIPER_TARGET_NAME), "wintun.dll"));
// tasks.add(getFileDownloadTask.apply("tap-windows-9.21.2.exe", "tap-windows-9.21.2.exe"));
if (USE_GSUDO)
tasks.add(new FileDownloadTask(
NetworkUtils.toURL(GSUDO_DOWNLOAD_URL),
GSUDO_LOCAL_FILE.toFile(),
new FileDownloadTask.IntegrityCheck("SHA-1", packagesHash.get(GSUDO_FILE_NAME))
));
} else {
if (!packagesHash.containsKey(String.format("%s/hiper", HIPER_TARGET_NAME))) {
throw new HiperUnsupportedPlatformException();
}
tasks = Collections.singletonList(getFileDownloadTask.apply(String.format("%s/hiper", HIPER_TARGET_NAME), "hiper"));
}
return Task.allOf(tasks).thenRunAsync(() -> {
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
Set<PosixFilePermission> perm = Files.getPosixFilePermissions(HIPER_PATH);
perm.add(PosixFilePermission.OWNER_EXECUTE);
Files.setPosixFilePermissions(HIPER_PATH, perm);
}
});
});
}
public static void downloadHiperConfig(String token, Path configPath) throws IOException {
String certFileContent = HttpRequest.GET(String.format("https://cert.mcer.cn/%s.yml", token)).getString();
if (!certFileContent.equals("")) {
FileUtils.writeText(configPath, certFileContent);
}
}
public static CompletableFuture<HiperSession> startHiper(String token) {
return getPackagesHash().thenComposeAsync(packagesHash -> {
CompletableFuture<Void> future = new CompletableFuture<>();
try {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
verifyChecksum(getHiperLocalDirectory().resolve("hiper.exe"), "SHA-1", packagesHash.get(String.format("%s/hiper.exe", HIPER_TARGET_NAME)));
verifyChecksum(getHiperLocalDirectory().resolve("wintun.dll"), "SHA-1", packagesHash.get(String.format("%s/wintun.dll", HIPER_TARGET_NAME)));
// verifyChecksumAndDeleteIfNotMatched(getHiperLocalDirectory().resolve("tap-windows-9.21.2.exe"), packagesHash.get("tap-windows-9.21.2.exe"));
if (USE_GSUDO)
verifyChecksum(GSUDO_LOCAL_FILE, "SHA-1", packagesHash.get(GSUDO_FILE_NAME));
} else {
verifyChecksum(getHiperLocalDirectory().resolve("hiper"), "SHA-1", packagesHash.get(String.format("%s/hiper", HIPER_TARGET_NAME)));
}
future.complete(null);
} catch (IOException e) {
Files.deleteIfExists(exe);
throw e;
LOG.log(Level.WARNING, "Failed to verify HiPer files", e);
Platform.runLater(() -> Controllers.taskDialog(MultiplayerManager.downloadHiper()
.whenComplete(exception -> {
if (exception == null)
future.complete(null);
else
future.completeExceptionally(exception);
}), i18n("multiplayer.download"), TaskCancellationAction.NORMAL));
}
return future;
}).thenApplyAsync(wrap(ignored -> {
Path configPath = getConfigPath(token);
Files.createDirectories(configPath.getParent());
// 下载 HiPer 配置文件
Logging.registerForbiddenToken(token, "<hiper token>");
try {
downloadHiperConfig(token, configPath);
} catch (IOException e) {
LOG.log(Level.WARNING, "configuration file cloud cache token has been not available, try to use the local configuration file", e);
}
if (Files.exists(configPath)) {
Files.copy(configPath, HIPER_TEMP_CONFIG_PATH, StandardCopyOption.REPLACE_EXISTING);
try (BufferedWriter output = Files.newBufferedWriter(HIPER_TEMP_CONFIG_PATH, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
output.write("\n");
output.write("logging:\n");
output.write(" format: json\n");
output.write(" file_path: '" + Metadata.HMCL_DIRECTORY.resolve("logs").resolve("hiper.log").toString().replace("'", "''") + "'\n");
}
}
String[] commands = new String[]{HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
if (!IS_ADMINISTRATOR) {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (USE_GSUDO)
commands = new String[]{GSUDO_LOCAL_FILE.toString(), HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
break;
case LINUX:
String askpass = System.getProperty("hmcl.askpass", System.getenv("HMCL_ASKPASS"));
if ("user".equalsIgnoreCase(askpass))
commands = new String[]{"sudo", "-A", HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
else if ("false".equalsIgnoreCase(askpass))
commands = new String[]{"sudo", "--non-interactive", HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
else {
if (Files.exists(Paths.get("/usr/bin/pkexec")))
commands = new String[]{"/usr/bin/pkexec", HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
else
commands = new String[]{"sudo", "--non-interactive", HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
}
break;
case OSX:
commands = new String[]{"sudo", "--non-interactive", HIPER_PATH.toString(), "-config", HIPER_TEMP_CONFIG_PATH.toString()};
break;
}
}
String[] commands = StringUtils.isBlank(token)
? new String[]{exe.toString()}
: new String[]{exe.toString(), "-auth.token", token};
Process process = new ProcessBuilder()
.command(commands)
.start();
return new CatoSession(state, process, Arrays.asList(commands));
return new HiperSession(process, Arrays.asList(commands));
}));
}
public static CompletableFuture<CatoSession> joinSession(String token, String peer, Mode mode, int remotePort, int localPort, JoinSessionHandler handler) throws IncompatibleCatoVersionException {
LOG.info(String.format("Joining session (token=%s,peer=%s,mode=%s,remotePort=%d,localPort=%d)", token, peer, mode, remotePort, localPort));
return startCato(token, State.SLAVE).thenComposeAsync(wrap(session -> {
CompletableFuture<CatoSession> future = new CompletableFuture<>();
session.forwardPort(peer, LOCAL_ADDRESS, localPort, REMOTE_ADDRESS, remotePort, mode);
Consumer<CatoExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new CatoExitTimeoutException());
}
break;
}
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
MultiplayerClient client = new MultiplayerClient(session.getId(), localPort);
session.addRelatedThread(client);
session.setClient(client);
TimerTask task = Lang.setTimeout(() -> {
Platform.runLater(() -> future.completeExceptionally(new JoinRequestTimeoutException()));
session.stop();
}, 30 * 1000);
client.onConnected().register(connectedEvent -> {
try {
int port = findAvailablePort();
session.forwardPort(peer, LOCAL_ADDRESS, port, REMOTE_ADDRESS, connectedEvent.getPort(), mode);
session.addRelatedThread(Lang.thread(new LocalServerBroadcaster(port, session), "LocalServerBroadcaster", true));
session.setName(connectedEvent.getSessionName());
client.setGamePort(port);
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
} catch (IOException e) {
session.stop();
Platform.runLater(() -> future.completeExceptionally(e));
}
task.cancel();
});
client.onKicked().register(kickedEvent -> {
session.stop();
task.cancel();
Platform.runLater(() -> {
future.completeExceptionally(new KickedException(kickedEvent.getReason()));
});
});
client.onDisconnected().register(disconnectedEvent -> {
Platform.runLater(() -> {
if (!client.isConnected()) {
// We fail to establish connection with server
future.completeExceptionally(new ConnectionErrorException());
}
});
});
client.onHandshake().register(handshakeEvent -> {
if (handler != null) {
handler.onWaitingForJoinResponse();
}
});
client.start();
});
return future;
}));
}
public static CompletableFuture<CatoSession> createSession(String token, String sessionName, int gamePort, boolean allowAllJoinRequests) {
LOG.info(String.format("Creating session (token=%s,sessionName=%s,gamePort=%d)", token, sessionName, gamePort));
return startCato(token, State.MASTER).thenComposeAsync(wrap(session -> {
CompletableFuture<CatoSession> future = new CompletableFuture<>();
MultiplayerServer server = new MultiplayerServer(sessionName, gamePort, allowAllJoinRequests);
server.startServer();
session.setName(sessionName);
session.allowForwardingAddress(REMOTE_ADDRESS, server.getPort());
session.allowForwardingAddress(REMOTE_ADDRESS, gamePort);
session.showAllowedAddress();
Consumer<CatoExitEvent> onExit = event -> {
boolean ready = session.isReady();
switch (event.getExitCode()) {
case 1:
if (!ready) {
future.completeExceptionally(new CatoExitTimeoutException());
}
break;
}
future.completeExceptionally(new CatoExitException(event.getExitCode(), ready));
};
session.onExit.register(onExit);
session.setServer(server);
session.addRelatedThread(server);
TimerTask peerConnectionTimeoutTask = Lang.setTimeout(() -> {
future.completeExceptionally(new PeerConnectionTimeoutException());
session.stop();
}, 15 * 1000);
session.onPeerConnected.register(event -> {
peerConnectionTimeoutTask.cancel();
Platform.runLater(() -> {
session.onExit.unregister(onExit);
future.complete(session);
});
});
return future;
}));
}
public static final Pattern INVITATION_CODE_PATTERN = Pattern.compile("^(?<id>.*?)#(?<port>\\d{2,5})$");
public static Invitation parseInvitationCode(String invitationCode) throws JsonParseException {
Matcher matcher = INVITATION_CODE_PATTERN.matcher(invitationCode);
if (!matcher.find()) throw new IllegalArgumentException("Invalid invitation code");
return new Invitation(matcher.group("id"), Integer.parseInt(matcher.group("port")));
}
public static int findAvailablePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
public static String getHiperFileName() {
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
return "hiper.exe";
} else {
return "hiper";
}
}
public static boolean isPortAvailable(int port) {
try (ServerSocket socket = new ServerSocket(port)) {
return true;
} catch (IOException e) {
return false;
}
public static Path getHiperLocalDirectory() {
return Metadata.HMCL_DIRECTORY.resolve("libraries").resolve("hiper").resolve("hiper").resolve("binary");
}
private static String getCatoFileName() {
switch (OperatingSystem.CURRENT_OS) {
case WINDOWS:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-windows-amd64.exe";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-windows-arm64.exe";
} else if (Architecture.SYSTEM_ARCH == Architecture.X86) {
return "cato-client-windows-i386.exe";
} else {
return "";
}
case OSX:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-darwin-amd64";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-darwin-arm64";
} else {
return "";
}
case LINUX:
if (Architecture.SYSTEM_ARCH == Architecture.X86_64) {
return "cato-client-linux-amd64";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM32) {
return "cato-client-linux-arm7";
} else if (Architecture.SYSTEM_ARCH == Architecture.ARM64) {
return "cato-client-linux-arm64";
} else {
return "";
}
default:
return "";
}
}
public static String getCatoPath() {
String name = getCatoFileName();
if (StringUtils.isBlank(name)) return "";
return "cato/cato/" + MultiplayerManager.CATO_VERSION + "/" + name;
}
public static class CatoSession extends ManagedProcess {
private final EventManager<CatoExitEvent> onExit = new EventManager<>();
private final EventManager<CatoIdEvent> onIdGenerated = new EventManager<>();
private final EventManager<Event> onPeerConnected = new EventManager<>();
private String name;
private final State type;
private String id;
private boolean peerConnected = false;
private MultiplayerClient client;
private MultiplayerServer server;
public static class HiperSession extends ManagedProcess {
private final EventManager<HiperExitEvent> onExit = new EventManager<>();
private final EventManager<HiperIPEvent> onIPAllocated = new EventManager<>();
private final EventManager<HiperShowValidUntilEvent> onValidUntil = new EventManager<>();
private final BufferedWriter writer;
private int error = 0;
CatoSession(State type, Process process, List<String> commands) {
HiperSession(Process process, List<String> commands) {
super(process, commands);
Runtime.getRuntime().addShutdownHook(new Thread(this::stop));
LOG.info("Started cato with command: " + new CommandBuilder().addAll(commands));
LOG.info("Started hiper with command: " + new CommandBuilder().addAll(commands));
this.type = type;
addRelatedThread(Lang.thread(this::waitFor, "CatoExitWaiter", true));
pumpInputStream(this::checkCatoLog);
pumpErrorStream(this::checkCatoLog);
addRelatedThread(Lang.thread(this::waitFor, "HiperExitWaiter", true));
pumpInputStream(this::onLog);
pumpErrorStream(this::onLog);
writer = new BufferedWriter(new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8));
}
public synchronized MultiplayerClient getClient() {
return client;
}
private void onLog(String log) {
if (!log.startsWith("{")) {
LOG.warning("[HiPer] " + log);
public synchronized CatoSession setClient(MultiplayerClient client) {
this.client = client;
return this;
}
public MultiplayerServer getServer() {
return server;
}
public CatoSession setServer(MultiplayerServer server) {
this.server = server;
return this;
}
private void checkCatoLog(String log) {
LOG.info("Cato: " + log);
if (id == null) {
Matcher matcher = TEMP_TOKEN_PATTERN.matcher(log);
if (matcher.find()) {
id = matcher.group("id");
onIdGenerated.fireEvent(new CatoIdEvent(this, id));
if (log.startsWith("failed to load config"))
error = HiperExitEvent.INVALID_CONFIGURATION;
else if (log.startsWith("sudo: ") || log.startsWith("Error getting authority") || log.startsWith("Error: An error occurred trying to start process"))
error = HiperExitEvent.NO_SUDO_PRIVILEGES;
else if (log.startsWith("Failed to write to log, can't rename log file")) {
error = HiperExitEvent.NO_SUDO_PRIVILEGES;
stop();
}
return;
}
if (!peerConnected) {
Matcher matcher = PEER_CONNECTED_PATTERN.matcher(log);
if (matcher.find()) {
peerConnected = true;
onPeerConnected.fireEvent(new Event(this));
try {
Map<?, ?> logJson = JsonUtils.fromNonNullJson(log, Map.class);
String msg = "";
if (logJson.containsKey("msg")) {
msg = tryCast(logJson.get("msg"), String.class).orElse("");
if (msg.contains("Failed to get a tun/tap device")) {
error = HiperExitEvent.FAILED_GET_DEVICE;
}
if (msg.contains("Failed to load certificate from config")) {
error = HiperExitEvent.FAILED_LOAD_CONFIG;
}
if (msg.contains("Validity of client certificate")) {
Optional<String> validUntil = tryCast(logJson.get("valid"), String.class);
if (validUntil.isPresent()) {
try {
synchronized (HIPER_VALID_TIME_FORMAT) {
Date date = HIPER_VALID_TIME_FORMAT.parse(validUntil.get());
onValidUntil.fireEvent(new HiperShowValidUntilEvent(this, date));
}
} catch (JsonParseException | ParseException e) {
LOG.log(Level.WARNING, "Failed to parse certification expire time string: " + validUntil.get());
}
}
}
}
if (logJson.containsKey("network")) {
Map<?, ?> network = tryCast(logJson.get("network"), Map.class).orElse(Collections.emptyMap());
if (network.containsKey("IP") && msg.contains("Main HostMap created")) {
Optional<String> ip = tryCast(network.get("IP"), String.class);
ip.ifPresent(s -> onIPAllocated.fireEvent(new HiperIPEvent(this, s)));
}
}
} catch (JsonParseException e) {
LOG.log(Level.WARNING, "Failed to parse hiper log: " + log, e);
}
}
private void waitFor() {
try {
int exitCode = getProcess().waitFor();
LOG.info("cato exited with exitcode " + exitCode);
onExit.fireEvent(new CatoExitEvent(this, exitCode));
LOG.info("Hiper exited with exitcode " + exitCode);
if (error != 0) {
onExit.fireEvent(new HiperExitEvent(this, error));
} else {
onExit.fireEvent(new HiperExitEvent(this, exitCode));
}
} catch (InterruptedException e) {
onExit.fireEvent(new CatoExitEvent(this, CatoExitEvent.EXIT_CODE_INTERRUPTED));
onExit.fireEvent(new HiperExitEvent(this, HiperExitEvent.INTERRUPTED));
} finally {
try {
if (writer != null)
writer.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to close cato stdin writer", e);
LOG.log(Level.WARNING, "Failed to close Hiper stdin writer", e);
}
}
destroyRelatedThreads();
}
public boolean isReady() {
return id != null;
}
public synchronized String getName() {
return name;
}
public synchronized void setName(String name) {
this.name = name;
}
public State getType() {
return type;
}
@Nullable
public String getId() {
return id;
}
public String generateInvitationCode(int serverPort) {
if (id == null) {
throw new IllegalStateException("id not generated");
@Override
public void stop() {
try {
writer.write("quit\n");
writer.flush();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to quit HiPer", e);
}
return id + "#" + serverPort;
try {
getProcess().waitFor(1, TimeUnit.SECONDS);
} catch (InterruptedException ignored) {
}
super.stop();
}
public synchronized void invokeCommand(String command) throws IOException {
LOG.info("Invoking cato: " + command);
writer.write(command);
writer.newLine();
writer.flush();
}
public void forwardPort(String peerId, String localAddress, int localPort, String remoteAddress, int remotePort, Mode mode) throws IOException {
invokeCommand(String.format("net add %s %s:%d %s:%d %s", peerId, localAddress, localPort, remoteAddress, remotePort, mode.getName()));
}
public void allowForwardingAddress(String address, int port) throws IOException {
invokeCommand(String.format("ufw net open %s:%d", address, port));
}
public void showAllowedAddress() throws IOException {
invokeCommand("ufw net whitelist");
}
public EventManager<CatoExitEvent> onExit() {
public EventManager<HiperExitEvent> onExit() {
return onExit;
}
public EventManager<CatoIdEvent> onIdGenerated() {
return onIdGenerated;
public EventManager<HiperIPEvent> onIPAllocated() {
return onIPAllocated;
}
public EventManager<Event> onPeerConnected() {
return onPeerConnected;
public EventManager<HiperShowValidUntilEvent> onValidUntil() {
return onValidUntil;
}
private static final Pattern TEMP_TOKEN_PATTERN = Pattern.compile("id\\((?<id>\\w+)\\)");
private static final Pattern PEER_CONNECTED_PATTERN = Pattern.compile("Connected to main net");
private static final Pattern LOG_PATTERN = Pattern.compile("(\\[\\d+])\\s+(\\w+)\\s+(\\w+-{0,1}\\w+):\\s(.*)");
}
public static class CatoExitEvent extends Event {
public static class HiperExitEvent extends Event {
private final int exitCode;
public CatoExitEvent(Object source, int exitCode) {
public HiperExitEvent(Object source, int exitCode) {
super(source);
this.exitCode = exitCode;
}
@@ -494,85 +462,45 @@ public final class MultiplayerManager {
return exitCode;
}
public static final int EXIT_CODE_INTERRUPTED = -1;
public static final int EXIT_CODE_SESSION_EXPIRED = 10;
public static final int INTERRUPTED = -1;
public static final int INVALID_CONFIGURATION = -2;
public static final int CERTIFICATE_EXPIRED = -3;
public static final int FAILED_GET_DEVICE = -4;
public static final int FAILED_LOAD_CONFIG = -5;
public static final int NO_SUDO_PRIVILEGES = -6;
}
public static class CatoIdEvent extends Event {
private final String id;
public static class HiperIPEvent extends Event {
private final String ip;
public CatoIdEvent(Object source, String id) {
public HiperIPEvent(Object source, String ip) {
super(source);
this.id = id;
this.ip = ip;
}
public String getId() {
return id;
public String getIP() {
return ip;
}
}
enum State {
DISCONNECTED,
CONNECTING,
MASTER,
SLAVE
}
public static class HiperShowValidUntilEvent extends Event {
private final Date validAt;
public static class Invitation {
private final String id;
@SerializedName("p")
private final int channelPort;
public Invitation(String id, int channelPort) {
this.id = id;
this.channelPort = channelPort;
public HiperShowValidUntilEvent(Object source, Date validAt) {
super(source);
this.validAt = validAt;
}
public String getId() {
return id;
}
public int getChannelPort() {
return channelPort;
public Date getValidUntil() {
return validAt;
}
}
public interface JoinSessionHandler {
void onWaitingForJoinResponse();
}
public static class IncompatibleCatoVersionException extends Exception {
private final String expected;
private final String actual;
public IncompatibleCatoVersionException(String expected, String actual) {
this.expected = expected;
this.actual = actual;
}
public String getExpected() {
return expected;
}
public String getActual() {
return actual;
}
}
public enum Mode {
P2P,
BRIDGE;
String getName() {
return name().toLowerCase(Locale.ROOT);
}
}
public static class CatoExitException extends RuntimeException {
public static class HiperExitException extends RuntimeException {
private final int exitCode;
private final boolean ready;
public CatoExitException(int exitCode, boolean ready) {
public HiperExitException(int exitCode, boolean ready) {
this.exitCode = exitCode;
this.ready = ready;
}
@@ -586,13 +514,13 @@ public final class MultiplayerManager {
}
}
public static class CatoExitTimeoutException extends RuntimeException {
public static class HiperExitTimeoutException extends RuntimeException {
}
public static class CatoSessionExpiredException extends RuntimeException {
public static class HiperSessionExpiredException extends HiperInvalidConfigurationException {
}
public static class CatoAlreadyStartedException extends RuntimeException {
public static class HiperInvalidConfigurationException extends RuntimeException {
}
public static class JoinRequestTimeoutException extends RuntimeException {
@@ -616,15 +544,10 @@ public final class MultiplayerManager {
}
}
public static class CatoNotExistsException extends RuntimeException {
private final Path file;
public CatoNotExistsException(Path file) {
this.file = file;
}
public Path getFile() {
return file;
}
public static class HiperInvalidTokenException extends RuntimeException {
}
public static class HiperUnsupportedPlatformException extends RuntimeException {
}
}
@@ -0,0 +1,36 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2022 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorArtifactProvider;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import java.util.UUID;
public class MultiplayerOfflineAccount extends OfflineAccount {
public MultiplayerOfflineAccount(AuthlibInjectorArtifactProvider downloader, String username, UUID uuid, Skin skin) {
super(downloader, username, uuid, skin);
}
@Override
protected boolean loadAuthlibInjector(Skin skin) {
return true;
}
}
@@ -19,40 +19,39 @@ package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import de.javawi.jstun.test.DiscoveryInfo;
import de.javawi.jstun.test.DiscoveryTest;
import javafx.application.Platform;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.control.Label;
import javafx.scene.control.Skin;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.SystemUtils;
import java.io.File;
import java.util.Date;
import java.util.concurrent.CancellationException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.FXUtils.runInFX;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.KickResponse.*;
import static org.jackhuang.hmcl.util.Lang.resolveException;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -60,24 +59,24 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("multiplayer")));
private final ObjectProperty<MultiplayerManager.State> multiplayerState = new SimpleObjectProperty<>(MultiplayerManager.State.DISCONNECTED);
private final ReadOnlyStringWrapper token = new ReadOnlyStringWrapper();
private final ReadOnlyObjectWrapper<@Nullable Result<DiscoveryInfo>> natState = new ReadOnlyObjectWrapper<>();
private final ReadOnlyIntegerWrapper gamePort = new ReadOnlyIntegerWrapper(-1);
private final ReadOnlyObjectWrapper<MultiplayerManager.CatoSession> session = new ReadOnlyObjectWrapper<>();
private final ObservableList<MultiplayerChannel.CatoClient> clients = FXCollections.observableArrayList();
private final ReadOnlyObjectWrapper<MultiplayerManager.HiperSession> session = new ReadOnlyObjectWrapper<>();
private final IntegerProperty port = new SimpleIntegerProperty();
private final StringProperty address = new SimpleStringProperty();
private final ReadOnlyObjectWrapper<Date> expireTime = new ReadOnlyObjectWrapper<>();
private Consumer<MultiplayerManager.CatoExitEvent> onExit;
private Consumer<MultiplayerManager.CatoIdEvent> onIdGenerated;
private Consumer<Event> onPeerConnected;
private Consumer<MultiplayerManager.HiperExitEvent> onExit;
private Consumer<MultiplayerManager.HiperIPEvent> onIPAllocated;
private Consumer<MultiplayerManager.HiperShowValidUntilEvent> onValidUntil;
private final ReadOnlyObjectWrapper<LocalServerBroadcaster> broadcaster = new ReadOnlyObjectWrapper<>();
private Consumer<Event> onBroadcasterExit = null;
public MultiplayerPage() {
testNAT();
}
@Override
public void onPageShown() {
checkAgreement(this::downloadCatoIfNecessary);
checkAgreement(this::downloadHiPerIfNecessary);
}
@Override
@@ -85,70 +84,81 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
return new MultiplayerPageSkin(this);
}
public ObservableList<MultiplayerChannel.CatoClient> getClients() {
return clients;
public int getPort() {
return port.get();
}
public MultiplayerManager.State getMultiplayerState() {
return multiplayerState.get();
public IntegerProperty portProperty() {
return port;
}
public ObjectProperty<MultiplayerManager.State> multiplayerStateProperty() {
return multiplayerState;
public void setPort(int port) {
this.port.set(port);
}
public void setMultiplayerState(MultiplayerManager.State multiplayerState) {
this.multiplayerState.set(multiplayerState);
public String getAddress() {
return address.get();
}
public Result<DiscoveryInfo> getNatState() {
return natState.get();
public StringProperty addressProperty() {
return address;
}
public ReadOnlyObjectProperty<Result<DiscoveryInfo>> natStateProperty() {
return natState.getReadOnlyProperty();
public void setAddress(String address) {
this.address.set(address);
}
public String getToken() {
return token.get();
public LocalServerBroadcaster getBroadcaster() {
return broadcaster.get();
}
public ReadOnlyStringProperty tokenProperty() {
return token.getReadOnlyProperty();
public ReadOnlyObjectWrapper<LocalServerBroadcaster> broadcasterProperty() {
return broadcaster;
}
public int getGamePort() {
return gamePort.get();
public void setBroadcaster(LocalServerBroadcaster broadcaster) {
this.broadcaster.set(broadcaster);
}
public ReadOnlyIntegerProperty gamePortProperty() {
return gamePort.getReadOnlyProperty();
public Date getExpireTime() {
return expireTime.get();
}
public MultiplayerManager.CatoSession getSession() {
public ReadOnlyObjectWrapper<Date> expireTimeProperty() {
return expireTime;
}
public void setExpireTime(Date expireTime) {
this.expireTime.set(expireTime);
}
public MultiplayerManager.HiperSession getSession() {
return session.get();
}
public ReadOnlyObjectProperty<MultiplayerManager.CatoSession> sessionProperty() {
public ReadOnlyObjectProperty<MultiplayerManager.HiperSession> sessionProperty() {
return session.getReadOnlyProperty();
}
private void testNAT() {
Task.supplyAsync(() -> {
DiscoveryTest tester = new DiscoveryTest(null, 0, "stun.miwifi.com", 3478);
return tester.test();
}).whenComplete(Schedulers.javafx(), (info, exception) -> {
if (exception == null) {
natState.set(Result.ok(info));
} else {
natState.set(Result.error());
void launchGame() {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), (launcherHelper) -> {
launcherHelper.setKeep();
Account account = launcherHelper.getAccount();
if (account instanceof OfflineAccount && !(account instanceof MultiplayerOfflineAccount)) {
OfflineAccount offlineAccount = (OfflineAccount) account;
launcherHelper.setAccount(new MultiplayerOfflineAccount(
offlineAccount.getDownloader(),
offlineAccount.getUsername(),
offlineAccount.getUUID(),
offlineAccount.getSkin()
));
}
LOG.log(Level.INFO, "Nat test result " + MultiplayerPageSkin.getNATType(natState.get()), exception);
}).start();
});
}
private void checkAgreement(Runnable runnable) {
if (globalConfig().getMultiplayerAgreementVersion() < MultiplayerManager.CATO_AGREEMENT_VERSION) {
if (globalConfig().getMultiplayerAgreementVersion() < MultiplayerManager.HIPER_AGREEMENT_VERSION) {
JFXDialogLayout agreementPane = new JFXDialogLayout();
agreementPane.setHeading(new Label(i18n("launcher.agreement")));
agreementPane.setBody(new Label(i18n("multiplayer.agreement.prompt")));
@@ -157,7 +167,7 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
JFXButton yesButton = new JFXButton(i18n("launcher.agreement.accept"));
yesButton.getStyleClass().add("dialog-accept");
yesButton.setOnAction(e -> {
globalConfig().setMultiplayerAgreementVersion(MultiplayerManager.CATO_AGREEMENT_VERSION);
globalConfig().setMultiplayerAgreementVersion(MultiplayerManager.HIPER_AGREEMENT_VERSION);
runnable.run();
agreementPane.fireEvent(new DialogCloseEvent());
});
@@ -174,21 +184,18 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
}
}
private void downloadCatoIfNecessary() {
if (StringUtils.isBlank(MultiplayerManager.getCatoPath())) {
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
return;
}
if (!MultiplayerManager.getCatoExecutable().toFile().exists()) {
private void downloadHiPerIfNecessary() {
if (!MultiplayerManager.HIPER_PATH.toFile().exists()) {
setDisabled(true);
TaskExecutor executor = MultiplayerManager.downloadCato()
Controllers.taskDialog(MultiplayerManager.downloadHiper()
.whenComplete(Schedulers.javafx(), exception -> {
setDisabled(false);
if (exception != null) {
if (exception instanceof CancellationException) {
Controllers.showToast(i18n("message.cancelled"));
} else if (exception instanceof MultiplayerManager.HiperUnsupportedPlatformException) {
Controllers.dialog(i18n("multiplayer.download.unsupported"), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
} else {
Controllers.dialog(DownloadProviders.localizeErrorMessage(exception), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR);
fireEvent(new PageCloseEvent());
@@ -196,316 +203,160 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
} else {
Controllers.showToast(i18n("multiplayer.download.success"));
}
}).executor();
Controllers.taskDialog(executor, i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
executor.start();
}), i18n("multiplayer.download"), TaskCancellationAction.NORMAL);
} else {
setDisabled(false);
}
}
public void copyInvitationCode() {
if (getSession() == null || !getSession().isReady() || gamePort.get() < 0 || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
FXUtils.copyText(getSession().generateInvitationCode(getSession().getServer().getPort()));
}
public void createRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.dialog(new CreateMultiplayerRoomDialog((result, resolve, reject) -> {
int gamePort = result.getServer().getAd();
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
MultiplayerManager.createSession(globalConfig().getMultiplayerToken(), result.getServer().getMotd(), gamePort, result.isAllowAllJoinRequests())
.thenAcceptAsync(session -> {
session.getServer().setOnClientAdding((client, resolveClient, rejectClient) -> {
runInFX(() -> {
Controllers.dialog(new MessageDialogPane.Builder(i18n("multiplayer.session.create.join.prompt", client.getUsername()), i18n("multiplayer.session.create.join"), MessageDialogPane.MessageType.INFO)
.yesOrNo(resolveClient, () -> rejectClient.accept(MultiplayerChannel.KickResponse.JOIN_ACEEPTANCE_TIMEOUT))
.cancelOnTimeout(30 * 1000)
.build());
});
});
session.getServer().onClientAdded().register(event -> {
runInFX(() -> {
clients.add(event);
});
});
session.getServer().onClientDisconnected().register(event -> {
runInFX(() -> {
clients.remove(event);
});
});
initCatoSession(session);
this.gamePort.set(gamePort);
setMultiplayerState(MultiplayerManager.State.MASTER);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeCreateErrorMessage(throwable, isStaticToken));
return null;
});
}));
}
public void joinRoom() {
if (getSession() != null || getMultiplayerState() != MultiplayerManager.State.DISCONNECTED) {
throw new IllegalStateException("CatoSession already ready");
}
Controllers.prompt(new PromptDialogPane.Builder(i18n("multiplayer.session.join"), (result, resolve, reject) -> {
PromptDialogPane.Builder.HintQuestion hintQuestion = (PromptDialogPane.Builder.HintQuestion) result.get(0);
boolean isStaticToken = StringUtils.isNotBlank(globalConfig().getMultiplayerToken());
String invitationCode = ((PromptDialogPane.Builder.StringQuestion) result.get(1)).getValue();
MultiplayerManager.Invitation invitation;
try {
invitation = MultiplayerManager.parseInvitationCode(invitationCode);
} catch (Exception e) {
LOG.log(Level.WARNING, "Failed to join session", e);
reject.accept(i18n("multiplayer.session.join.invitation_code.error"));
return;
}
int localPort; // invitation channel
try {
localPort = MultiplayerManager.findAvailablePort();
} catch (Exception e) {
reject.accept(i18n("multiplayer.session.join.port.error"));
return;
}
try {
MultiplayerManager.joinSession(
globalConfig().getMultiplayerToken(),
invitation.getId(),
globalConfig().isMultiplayerRelay() && (StringUtils.isNotBlank(globalConfig().getMultiplayerToken()) || StringUtils.isNotBlank(System.getProperty("hmcl.multiplayer.relay")))
? MultiplayerManager.Mode.BRIDGE
: MultiplayerManager.Mode.P2P,
invitation.getChannelPort(),
localPort, new MultiplayerManager.JoinSessionHandler() {
@Override
public void onWaitingForJoinResponse() {
runInFX(() -> {
hintQuestion.setQuestion(i18n("multiplayer.session.join.wait"));
});
}
})
.thenAcceptAsync(session -> {
initCatoSession(session);
AtomicBoolean kicked = new AtomicBoolean();
session.getClient().onDisconnected().register(() -> {
runInFX(() -> {
stopCatoSession();
if (!kicked.get()) {
Controllers.dialog(i18n("multiplayer.session.join.lost_connection"));
}
});
});
session.getClient().onKicked().register(kickedEvent -> {
runInFX(() -> {
kicked.set(true);
Controllers.dialog(i18n("multiplayer.session.join.kicked", localizeKickMessage(kickedEvent.getReason())));
});
});
gamePort.set(session.getClient().getGamePort());
setMultiplayerState(MultiplayerManager.State.SLAVE);
resolve.run();
}, Platform::runLater)
.exceptionally(throwable -> {
reject.accept(localizeJoinErrorMessage(throwable, isStaticToken));
return null;
});
} catch (MultiplayerManager.IncompatibleCatoVersionException e) {
reject.accept(i18n("multiplayer.session.join.invitation_code.version"));
}
})
.addQuestion(new PromptDialogPane.Builder.HintQuestion(i18n("multiplayer.session.join.hint")))
.addQuestion(new PromptDialogPane.Builder.StringQuestion(i18n("multiplayer.session.join.invitation_code"), "", new RequiredValidator())));
}
private String localizeKickMessage(String message) {
if (VERSION_NOT_MATCHED.equals(message)) {
return i18n("multiplayer.session.join.kicked.version_not_matched");
} else if (KICKED.equals(message)) {
return i18n("multiplayer.session.join.kicked.kicked");
} else if (JOIN_ACEEPTANCE_TIMEOUT.equals(message)) {
return i18n("multiplayer.session.join.kicked.join_acceptance_timeout");
} else {
return message;
}
}
private String localizeErrorMessage(Throwable t, boolean isStaticToken, Function<Throwable, String> fallback) {
private String localizeErrorMessage(Throwable t) {
Throwable e = resolveException(t);
if (e instanceof CancellationException) {
LOG.info("Connection rejected by the server");
return i18n("message.cancelled");
} else if (e instanceof MultiplayerManager.KickedException) {
LOG.info("Kicked by server");
return i18n("multiplayer.session.join.kicked", localizeKickMessage(((MultiplayerManager.KickedException) e).getReason()));
} else if (e instanceof MultiplayerManager.CatoAlreadyStartedException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.error.already_started");
} else if (e instanceof MultiplayerManager.CatoNotExistsException) {
LOG.log(Level.WARNING, "Cato not found " + ((MultiplayerManager.CatoNotExistsException) e).getFile(), e);
return i18n("multiplayer.session.error.file_not_found");
} else if (e instanceof MultiplayerManager.JoinRequestTimeoutException) {
LOG.info("Cato already started");
return i18n("multiplayer.session.join.wait_timeout");
} else if (e instanceof MultiplayerManager.ConnectionErrorException) {
LOG.info("Failed to establish connection with server");
return i18n("multiplayer.session.join.error.connection");
} else if (e instanceof MultiplayerManager.CatoExitTimeoutException) {
LOG.info("Cato failed to connect to main net");
if (isStaticToken) {
return i18n("multiplayer.exit.timeout.static_token");
} else {
return i18n("multiplayer.exit.timeout.dynamic_token");
}
} else if (e instanceof MultiplayerManager.CatoExitException) {
LOG.info("Cato exited accidentally");
int exitCode = ((MultiplayerManager.CatoExitException) e).getExitCode();
if (!((MultiplayerManager.CatoExitException) e).isReady()) {
return i18n("multiplayer.exit.before_ready", exitCode);
} else {
return i18n("multiplayer.exit.after_ready", exitCode);
}
} else if (e instanceof MultiplayerManager.HiperInvalidConfigurationException) {
LOG.warning("HiPer invalid configuration");
return i18n("multiplayer.token.malformed");
} else if (e instanceof ChecksumMismatchException) {
return i18n("exception.artifact_malformed");
LOG.log(Level.WARNING, "Failed to verify HiPer files", e);
return i18n("multiplayer.error.file_not_found");
} else if (e instanceof MultiplayerManager.HiperExitException) {
int exitCode = ((MultiplayerManager.HiperExitException) e).getExitCode();
LOG.warning("HiPer exited unexpectedly with exit code " + exitCode);
return i18n("multiplayer.exit", exitCode);
} else if (e instanceof MultiplayerManager.HiperInvalidTokenException) {
LOG.warning("invalid token");
return i18n("multiplayer.token.invalid");
} else {
return fallback.apply(e);
LOG.log(Level.WARNING, "Unknown HiPer exception", e);
return e.getLocalizedMessage() + "\n" + StringUtils.getStackTrace(e);
}
}
private String localizeCreateErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to create session", e);
if (isStaticToken) {
return i18n("multiplayer.session.create.error.static_token") + e.getLocalizedMessage();
} else {
return i18n("multiplayer.session.create.error.dynamic_token") + e.getLocalizedMessage();
}
});
public void start() {
MultiplayerManager.startHiper(globalConfig().getMultiplayerToken())
.thenAcceptAsync(session -> {
this.session.set(session);
onExit = session.onExit().registerWeak(this::onExit);
onIPAllocated = session.onIPAllocated().registerWeak(this::onIPAllocated);
onValidUntil = session.onValidUntil().registerWeak(this::onValidUntil);
}, Schedulers.javafx())
.exceptionally(throwable -> {
runInFX(() -> Controllers.dialog(localizeErrorMessage(throwable), null, MessageDialogPane.MessageType.ERROR));
return null;
});
}
private String localizeJoinErrorMessage(Throwable t, boolean isStaticToken) {
return localizeErrorMessage(t, isStaticToken, e -> {
LOG.log(Level.WARNING, "Failed to join session", e);
return i18n("multiplayer.session.join.error");
});
}
public void kickPlayer(MultiplayerChannel.CatoClient client) {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.create.members.kick.prompt"), i18n("multiplayer.session.create.members.kick"), MessageDialogPane.MessageType.WARNING,
() -> {
getSession().getServer().kickPlayer(client);
}, null);
}
public void closeRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.MASTER) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.close.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void quitRoom() {
if (getSession() == null || !getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.SLAVE) {
throw new IllegalStateException("CatoSession not ready");
}
Controllers.confirm(i18n("multiplayer.session.quit.warning"), i18n("message.warning"), MessageDialogPane.MessageType.WARNING,
this::stopCatoSession, null);
}
public void cancelRoom() {
if (getSession() == null || getSession().isReady() || getMultiplayerState() != MultiplayerManager.State.CONNECTING) {
throw new IllegalStateException("CatoSession not existing or already ready");
}
stopCatoSession();
}
private void initCatoSession(MultiplayerManager.CatoSession session) {
runInFX(() -> {
onExit = session.onExit().registerWeak(this::onCatoExit);
onIdGenerated = session.onIdGenerated().registerWeak(this::onCatoIdGenerated);
onPeerConnected = session.onPeerConnected().registerWeak(this::onCatoPeerConnected);
this.clients.clear();
this.session.set(session);
});
}
private void stopCatoSession() {
public void stop() {
if (getSession() != null) {
getSession().stop();
}
clearCatoSession();
if (getBroadcaster() != null) {
getBroadcaster().close();
}
clearSession();
}
private void clearCatoSession() {
this.session.set(null);
this.token.set(null);
this.gamePort.set(-1);
this.multiplayerState.set(MultiplayerManager.State.DISCONNECTED);
public void broadcast(String url) {
LocalServerBroadcaster broadcaster = new LocalServerBroadcaster(url);
this.onBroadcasterExit = broadcaster.onExit().registerWeak(this::onBroadcasterExit);
broadcaster.start();
this.broadcaster.set(broadcaster);
}
private void onCatoExit(MultiplayerManager.CatoExitEvent event) {
public void stopBroadcasting() {
if (getBroadcaster() != null) {
getBroadcaster().close();
setBroadcaster(null);
}
}
private void onBroadcasterExit(Event event) {
runInFX(() -> {
if (this.broadcaster.get() == event.getSource()) {
this.broadcaster.set(null);
}
});
}
private void clearSession() {
this.session.set(null);
this.expireTime.set(null);
this.onExit = null;
this.onIPAllocated = null;
this.onValidUntil = null;
this.broadcaster.set(null);
this.onBroadcasterExit = null;
}
private void onIPAllocated(MultiplayerManager.HiperIPEvent event) {
runInFX(() -> this.address.set(event.getIP()));
}
private void onValidUntil(MultiplayerManager.HiperShowValidUntilEvent event) {
runInFX(() -> this.expireTime.set(event.getValidUntil()));
}
private void onExit(MultiplayerManager.HiperExitEvent event) {
runInFX(() -> {
boolean ready = ((MultiplayerManager.CatoSession) event.getSource()).isReady();
switch (event.getExitCode()) {
case 0:
break;
case MultiplayerManager.CatoExitEvent.EXIT_CODE_SESSION_EXPIRED:
Controllers.dialog(i18n("multiplayer.session.expired"));
case MultiplayerManager.HiperExitEvent.CERTIFICATE_EXPIRED:
MultiplayerManager.clearConfiguration();
Controllers.dialog(i18n("multiplayer.token.expired"));
break;
case 1:
if (!ready) {
Controllers.dialog(i18n("multiplayer.exit.timeout"));
case MultiplayerManager.HiperExitEvent.INVALID_CONFIGURATION:
MultiplayerManager.clearConfiguration();
Controllers.dialog(i18n("multiplayer.token.malformed"));
break;
case MultiplayerManager.HiperExitEvent.NO_SUDO_PRIVILEGES:
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
Controllers.confirm(i18n("multiplayer.error.failed_sudo.windows"), null, MessageDialogPane.MessageType.WARNING, () -> {
FXUtils.openLink("https://docs.hmcl.net/multiplayer/admin.html");
}, null);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX) {
Controllers.dialog(i18n("multiplayer.error.failed_sudo.linux", MultiplayerManager.HIPER_PATH.toString()), null, MessageDialogPane.MessageType.WARNING);
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.OSX) {
Controllers.confirm(i18n("multiplayer.error.failed_sudo.mac"), null, MessageDialogPane.MessageType.INFO, () -> {
try {
String text = "%hmcl-hiper ALL=(ALL:ALL) NOPASSWD: " + MultiplayerManager.HIPER_PATH.toString().replaceAll("[ @!(),:=\\\\]", "\\\\$0") + "\n";
File sudoersTmp = File.createTempFile("sudoer", ".tmp");
sudoersTmp.deleteOnExit();
FileUtils.writeText(sudoersTmp, text);
SystemUtils.callExternalProcess(
"osascript", "-e", String.format("do shell script \"%s\" with administrator privileges", String.join(";",
"dscl . create /Groups/hmcl-hiper PrimaryGroupID 758",
"dscl . merge /Groups/hmcl-hiper GroupMembership " + CommandBuilder.toShellStringLiteral(System.getProperty("user.name")) + "",
"mkdir -p /private/etc/sudoers.d",
"mv -f " + CommandBuilder.toShellStringLiteral(sudoersTmp.toString()) + " /private/etc/sudoers.d/hmcl-hiper",
"chown root /private/etc/sudoers.d/hmcl-hiper",
"chmod 0440 /private/etc/sudoers.d/hmcl-hiper"
).replaceAll("[\\\\\"]", "\\\\$0"))
);
} catch (Throwable e) {
LOG.log(Level.WARNING, "Failed to modify sudoers", e);
}
}, null);
}
break;
case -1:
case MultiplayerManager.HiperExitEvent.INTERRUPTED:
// do nothing
break;
case MultiplayerManager.HiperExitEvent.FAILED_GET_DEVICE:
Controllers.dialog(i18n("multiplayer.error.failed_get_device"));
break;
case MultiplayerManager.HiperExitEvent.FAILED_LOAD_CONFIG:
Controllers.dialog(i18n("multiplayer.error.failed_load_config"));
break;
default:
if (!((MultiplayerManager.CatoSession) event.getSource()).isReady()) {
Controllers.dialog(i18n("multiplayer.exit.before_ready", event.getExitCode()));
} else {
Controllers.dialog(i18n("multiplayer.exit.after_ready", event.getExitCode()));
}
Controllers.dialog(i18n("multiplayer.exit", event.getExitCode()));
break;
}
clearCatoSession();
});
}
private void onCatoPeerConnected(Event event) {
runInFX(() -> {
});
}
private void onCatoIdGenerated(MultiplayerManager.CatoIdEvent event) {
runInFX(() -> {
token.set(event.getId());
setMultiplayerState(((MultiplayerManager.CatoSession) event.getSource()).getType());
clearSession();
});
}
@@ -513,5 +364,4 @@ public class MultiplayerPage extends DecoratorAnimatedPage implements DecoratorP
public ReadOnlyObjectProperty<State> stateProperty() {
return state;
}
}
@@ -19,7 +19,10 @@ package org.jackhuang.hmcl.ui.multiplayer;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPasswordField;
import de.javawi.jstun.test.DiscoveryInfo;
import com.jfoenix.controls.JFXTextField;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
@@ -28,26 +31,31 @@ import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.game.LauncherHelper;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
import org.jackhuang.hmcl.setting.Theme;
import javafx.stage.FileChooser;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane.MessageType;
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
import org.jackhuang.hmcl.ui.versions.Versions;
import org.jackhuang.hmcl.util.HMCLService;
import org.jackhuang.hmcl.util.Result;
import org.jackhuang.hmcl.util.javafx.BindingMapping;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.Nullable;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
import static org.jackhuang.hmcl.util.Logging.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimatedPageSkin<MultiplayerPage> {
@@ -63,74 +71,45 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
super(control);
{
VBox roomPane = new VBox();
{
AdvancedListItem createRoomItem = new AdvancedListItem();
createRoomItem.setTitle(i18n("multiplayer.session.create"));
createRoomItem.setLeftGraphic(wrap(SVG::plusCircleOutline));
createRoomItem.setActionButtonVisible(false);
createRoomItem.setOnAction(e -> control.createRoom());
AdvancedListItem joinRoomItem = new AdvancedListItem();
joinRoomItem.setTitle(i18n("multiplayer.session.join"));
joinRoomItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
joinRoomItem.setActionButtonVisible(false);
joinRoomItem.setOnAction(e -> control.joinRoom());
AdvancedListItem copyLinkItem = new AdvancedListItem();
copyLinkItem.setTitle(i18n("multiplayer.session.copy_room_code"));
copyLinkItem.setLeftGraphic(wrap(SVG::accountArrowRightOutline));
copyLinkItem.setActionButtonVisible(false);
copyLinkItem.setOnAction(e -> control.copyInvitationCode());
AdvancedListItem cancelItem = new AdvancedListItem();
cancelItem.setTitle(i18n("button.cancel"));
cancelItem.setLeftGraphic(wrap(SVG::closeCircle));
cancelItem.setActionButtonVisible(false);
cancelItem.setOnAction(e -> control.cancelRoom());
AdvancedListItem quitItem = new AdvancedListItem();
quitItem.setTitle(i18n("multiplayer.session.quit"));
quitItem.setLeftGraphic(wrap(SVG::closeCircle));
quitItem.setActionButtonVisible(false);
quitItem.setOnAction(e -> control.quitRoom());
AdvancedListItem closeRoomItem = new AdvancedListItem();
closeRoomItem.setTitle(i18n("multiplayer.session.close"));
closeRoomItem.setLeftGraphic(wrap(SVG::closeCircle));
closeRoomItem.setActionButtonVisible(false);
closeRoomItem.setOnAction(e -> control.closeRoom());
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
roomPane.getChildren().setAll(createRoomItem, joinRoomItem);
} else if (state == MultiplayerManager.State.CONNECTING) {
roomPane.getChildren().setAll(cancelItem);
} else if (state == MultiplayerManager.State.MASTER) {
roomPane.getChildren().setAll(copyLinkItem, closeRoomItem);
} else if (state == MultiplayerManager.State.SLAVE) {
roomPane.getChildren().setAll(quitItem);
}
});
}
AdvancedListBox sideBar = new AdvancedListBox()
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("version.launch"));
item.setLeftGraphic(wrap(SVG::rocketLaunchOutline));
item.setOnAction(e -> {
Profile profile = Profiles.getSelectedProfile();
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
control.launchGame();
});
})
.startCategory(i18n("multiplayer.session"))
.add(roomPane)
.startCategory(i18n("help"))
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("help"));
item.setLeftGraphic(wrap(SVG::helpCircleOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer"));
})
// .addNavigationDrawerItem(item -> {
// item.setTitle(i18n("multiplayer.help.1"));
// item.setLeftGraphic(wrap(SVG::helpCircleOutline));
// item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer/admin.html"));
// })
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("multiplayer.help.2"));
item.setLeftGraphic(wrap(SVG::helpCircleOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer/help.html"));
})
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("multiplayer.help.3"));
item.setLeftGraphic(wrap(SVG::helpCircleOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer/help.html#%E5%88%9B%E5%BB%BA%E6%96%B9"));
})
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("multiplayer.help.4"));
item.setLeftGraphic(wrap(SVG::helpCircleOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer/help.html#%E5%8F%82%E4%B8%8E%E8%80%85"));
})
.addNavigationDrawerItem(item -> {
item.setTitle(i18n("multiplayer.help.text"));
item.setLeftGraphic(wrap(SVG::rocketLaunchOutline));
item.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/multiplayer/text.html"));
})
.addNavigationDrawerItem(report -> {
report.setTitle(i18n("feedback"));
report.setLeftGraphic(wrap(SVG::messageAlertOutline));
@@ -146,128 +125,311 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
content.setFillWidth(true);
ScrollPane scrollPane = new ScrollPane(content);
scrollPane.setFitToWidth(true);
scrollPane.setFitToHeight(true);
setCenter(scrollPane);
ComponentList roomPane = new ComponentList();
VBox mainPane = new VBox(16);
{
TransitionPane transitionPane = new TransitionPane();
roomPane.getContent().setAll(transitionPane);
VBox disconnectedPane = new VBox(8);
ComponentList offPane = new ComponentList();
{
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.state.disconnected.hint"));
HintPane hintPane = new HintPane(MessageType.WARNING);
hintPane.setText(i18n("multiplayer.off.hint"));
Label label = new Label(i18n("multiplayer.state.disconnected"));
BorderPane tokenPane = new BorderPane();
{
Label tokenTitle = new Label(i18n("multiplayer.token"));
BorderPane.setAlignment(tokenTitle, Pos.CENTER_LEFT);
tokenPane.setLeft(tokenTitle);
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
JFXPasswordField tokenField = new JFXPasswordField();
BorderPane.setAlignment(tokenField, Pos.CENTER_LEFT);
BorderPane.setMargin(tokenField, new Insets(0, 8, 0, 8));
tokenPane.setCenter(tokenField);
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
tokenField.setPromptText(i18n("multiplayer.token.prompt"));
disconnectedPane.getChildren().setAll(hintPane, label);
Validator validator = new Validator("multiplayer.token.format_invalid", StringUtils::isAlphabeticOrNumber);
InvalidationListener listener = any -> tokenField.validate();
validator.getProperties().put(validator, listener);
tokenField.textProperty().addListener(new WeakInvalidationListener(listener));
tokenField.getValidators().add(validator);
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.token.apply"));
BorderPane.setAlignment(applyLink, Pos.CENTER_RIGHT);
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
tokenPane.setRight(applyLink);
}
HBox startPane = new HBox();
{
JFXButton startButton = new JFXButton(i18n("multiplayer.off.start"));
startButton.getStyleClass().add("jfx-button-raised");
startButton.setButtonType(JFXButton.ButtonType.RAISED);
startButton.setOnMouseClicked(e -> control.start());
startButton.disableProperty().bind(MultiplayerManager.tokenInvalid);
startPane.getChildren().setAll(startButton);
startPane.setAlignment(Pos.CENTER_RIGHT);
}
if (!MultiplayerManager.IS_ADMINISTRATOR)
offPane.getContent().add(hintPane);
offPane.getContent().addAll(tokenPane, startPane);
}
VBox connectingPane = new VBox(8);
ComponentList onPane = new ComponentList();
{
Label label = new Label(i18n("multiplayer.state.connecting"));
BorderPane expirationPane = new BorderPane();
expirationPane.setLeft(new Label(i18n("multiplayer.session.expiration")));
Label expirationLabel = new Label();
expirationLabel.textProperty().bind(Bindings.createStringBinding(() ->
control.getExpireTime() == null ? "" : Locales.SIMPLE_DATE_FORMAT.get().format(control.getExpireTime()),
control.expireTimeProperty()));
expirationPane.setRight(expirationLabel);
connectingPane.getChildren().setAll(label);
GridPane masterPane = new GridPane();
masterPane.setVgap(8);
masterPane.setHgap(16);
ColumnConstraints titleColumn = new ColumnConstraints();
ColumnConstraints valueColumn = new ColumnConstraints();
ColumnConstraints rightColumn = new ColumnConstraints();
masterPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
valueColumn.setFillWidth(true);
valueColumn.setHgrow(Priority.ALWAYS);
{
BorderPane titlePane = new BorderPane();
GridPane.setColumnSpan(titlePane, 3);
Label title = new Label(i18n("multiplayer.master"));
titlePane.setLeft(title);
JFXHyperlink tutorial = new JFXHyperlink(i18n("multiplayer.master.video_tutorial"));
titlePane.setRight(tutorial);
tutorial.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-tutorial-master"));
masterPane.addRow(0, titlePane);
HintPane hintPane = new HintPane(MessageType.INFO);
GridPane.setColumnSpan(hintPane, 3);
hintPane.setText(i18n("multiplayer.master.hint"));
masterPane.addRow(1, hintPane);
Label portTitle = new Label(i18n("multiplayer.master.port"));
BorderPane.setAlignment(portTitle, Pos.CENTER_LEFT);
JFXTextField portTextField = new JFXTextField();
GridPane.setColumnSpan(portTextField, 2);
FXUtils.setValidateWhileTextChanged(portTextField, true);
portTextField.getValidators().add(new Validator(i18n("multiplayer.master.port.validate"), (text) -> {
Integer value = Lang.toIntOrNull(text);
return value != null && 0 <= value && value <= 65535;
}));
portTextField.textProperty().bindBidirectional(control.portProperty(), new StringConverter<Number>() {
@Override
public String toString(Number object) {
return Integer.toString(object.intValue());
}
@Override
public Number fromString(String string) {
return Lang.parseInt(string, 0);
}
});
masterPane.addRow(2, portTitle, portTextField);
Label serverAddressTitle = new Label(i18n("multiplayer.master.server_address"));
BorderPane.setAlignment(serverAddressTitle, Pos.CENTER_LEFT);
Label serverAddressLabel = new Label();
BorderPane.setAlignment(serverAddressLabel, Pos.CENTER_LEFT);
serverAddressLabel.textProperty().bind(Bindings.createStringBinding(() -> {
return (control.getAddress() == null ? "" : control.getAddress()) + ":" + control.getPort();
}, control.addressProperty(), control.portProperty()));
JFXButton copyButton = new JFXButton(i18n("multiplayer.master.server_address.copy"));
copyButton.setOnAction(e -> FXUtils.copyText(serverAddressLabel.getText()));
masterPane.addRow(3, serverAddressTitle, serverAddressLabel, copyButton);
}
VBox slavePane = new VBox(8);
{
BorderPane titlePane = new BorderPane();
Label title = new Label(i18n("multiplayer.slave"));
titlePane.setLeft(title);
JFXHyperlink tutorial = new JFXHyperlink(i18n("multiplayer.slave.video_tutorial"));
tutorial.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-tutorial-slave"));
titlePane.setRight(tutorial);
HintPane hintPane = new HintPane(MessageType.INFO);
GridPane.setColumnSpan(hintPane, 3);
hintPane.setText(i18n("multiplayer.slave.hint"));
slavePane.getChildren().add(hintPane);
HintPane hintPane2 = new HintPane(MessageType.WARNING);
GridPane.setColumnSpan(hintPane2, 3);
hintPane2.setText(i18n("multiplayer.slave.hint2"));
slavePane.getChildren().add(hintPane2);
GridPane notBroadcastingPane = new GridPane();
{
notBroadcastingPane.setVgap(8);
notBroadcastingPane.setHgap(16);
notBroadcastingPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
Label addressTitle = new Label(i18n("multiplayer.slave.server_address"));
JFXTextField addressField = new JFXTextField();
FXUtils.setValidateWhileTextChanged(addressField, true);
addressField.getValidators().add(new ServerAddressValidator());
JFXButton startButton = new JFXButton(i18n("multiplayer.slave.server_address.start"));
startButton.setOnAction(e -> control.broadcast(addressField.getText()));
notBroadcastingPane.addRow(0, addressTitle, addressField, startButton);
}
GridPane broadcastingPane = new GridPane();
{
broadcastingPane.setVgap(8);
broadcastingPane.setHgap(16);
broadcastingPane.getColumnConstraints().setAll(titleColumn, valueColumn, rightColumn);
Label addressTitle = new Label(i18n("multiplayer.slave.server_address"));
Label addressLabel = new Label();
addressLabel.textProperty().bind(Bindings.createStringBinding(() ->
control.getBroadcaster() != null ? control.getBroadcaster().getAddress() : "",
control.broadcasterProperty()));
JFXButton stopButton = new JFXButton(i18n("multiplayer.slave.server_address.stop"));
stopButton.setOnAction(e -> control.stopBroadcasting());
broadcastingPane.addRow(0, addressTitle, addressLabel, stopButton);
}
FXUtils.onChangeAndOperate(control.broadcasterProperty(), broadcaster -> {
if (broadcaster == null) {
slavePane.getChildren().setAll(titlePane, hintPane, hintPane2, notBroadcastingPane);
} else {
slavePane.getChildren().setAll(titlePane, hintPane, hintPane2, broadcastingPane);
}
});
}
FXUtils.onChangeAndOperate(control.expireTimeProperty(), t -> {
if (t == null) {
onPane.getContent().setAll(masterPane, slavePane);
} else {
onPane.getContent().setAll(expirationPane, masterPane, slavePane);
}
});
}
VBox masterPane = new VBox(8);
{
HintPane masterHintPane = new HintPane();
masterHintPane.setText(i18n("multiplayer.state.master.hint"));
Label label = new Label(i18n("multiplayer.state.master"));
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.master", control.getSession() == null ? "" : control.getSession().getName(), control.getGamePort()),
control.gamePortProperty(), control.sessionProperty()));
Label membersLabel = new Label(i18n("multiplayer.session.create.members"));
VBox clientsPane = new VBox(8);
clients = MappedObservableList.create(control.getClients(), ClientItem::new);
Bindings.bindContent(clientsPane.getChildren(), clients);
masterPane.getChildren().setAll(masterHintPane, label, membersLabel, clientsPane);
}
BorderPane slavePane = new BorderPane();
{
HintPane slaveHintPane = new HintPane();
slaveHintPane.setText(i18n("multiplayer.state.slave.hint"));
slavePane.setTop(slaveHintPane);
Label label = new Label();
label.textProperty().bind(Bindings.createStringBinding(() ->
i18n("multiplayer.state.slave", control.getSession() == null ? "" : control.getSession().getName(), "0.0.0.0:" + control.getGamePort()),
control.sessionProperty(), control.gamePortProperty()));
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
slavePane.setCenter(label);
JFXButton copyButton = new JFXButton(i18n("multiplayer.state.slave.copy"));
copyButton.setOnAction(e -> FXUtils.copyText("0.0.0.0:" + control.getGamePort()));
slavePane.setRight(copyButton);
}
FXUtils.onChangeAndOperate(getSkinnable().multiplayerStateProperty(), state -> {
if (state == MultiplayerManager.State.DISCONNECTED) {
transitionPane.setContent(disconnectedPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.CONNECTING) {
transitionPane.setContent(connectingPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.MASTER) {
transitionPane.setContent(masterPane, ContainerAnimations.NONE.getAnimationProducer());
} else if (state == MultiplayerManager.State.SLAVE) {
transitionPane.setContent(slavePane, ContainerAnimations.NONE.getAnimationProducer());
FXUtils.onChangeAndOperate(getSkinnable().sessionProperty(), session -> {
if (session == null) {
mainPane.getChildren().setAll(offPane);
} else {
mainPane.getChildren().setAll(onPane);
}
});
}
ComponentList natDetectionPane = new ComponentList();
ComponentList persistencePane = new ComponentList();
{
GridPane pane = new GridPane();
ColumnConstraints title = new ColumnConstraints();
ColumnConstraints value = new ColumnConstraints();
pane.getColumnConstraints().setAll(title, value);
value.setFillWidth(true);
value.setHgrow(Priority.ALWAYS);
pane.setHgap(16);
pane.setVgap(8);
HintPane hintPane = new HintPane(MessageType.WARNING);
hintPane.setText(i18n("multiplayer.persistence.hint"));
HintPane hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setText(i18n("multiplayer.nat.hint"));
GridPane.setColumnSpan(hintPane, 2);
pane.addRow(0, hintPane);
BorderPane importPane = new BorderPane();
{
Label left = new Label(i18n("multiplayer.persistence.import"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
importPane.setLeft(left);
Label natResult = new Label();
natResult.textProperty().bind(BindingMapping.of(getSkinnable().natStateProperty())
.map(MultiplayerPageSkin::getNATType));
pane.addRow(1, new Label(i18n("multiplayer.nat.type")), natResult);
JFXButton importButton = new JFXButton(i18n("multiplayer.persistence.import.button"));
importButton.setOnMouseClicked(e -> {
Path targetPath = MultiplayerManager.getConfigPath(globalConfig().getMultiplayerToken());
if (Files.exists(targetPath)) {
LOG.warning("License file " + targetPath + " already exists");
Controllers.dialog(i18n("multiplayer.persistence.import.file_already_exists"), null, MessageType.ERROR);
return;
}
natDetectionPane.getContent().add(pane);
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("multiplayer.persistence.import.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("multiplayer.persistence.license_file"), "*.yml"));
File file = fileChooser.showOpenDialog(Controllers.getStage());
if (file == null)
return;
CompletableFuture<Boolean> future = new CompletableFuture<>();
if (file.getName().matches("[a-z0-9]{40}.yml") && !targetPath.getFileName().toString().equals(file.getName())) {
Controllers.confirm(i18n("multiplayer.persistence.import.token_not_match"), null, MessageType.QUESTION,
() -> future.complete(true),
() -> future.complete(false)) ;
} else {
future.complete(true);
}
future.thenAcceptAsync(Lang.wrapConsumer(c -> {
if (c) Files.copy(file.toPath(), targetPath);
})).exceptionally(exception -> {
LOG.log(Level.WARNING, "Failed to import license file", exception);
Platform.runLater(() -> Controllers.dialog(i18n("multiplayer.persistence.import.failed"), null, MessageType.ERROR));
return null;
});
});
importButton.disableProperty().bind(MultiplayerManager.tokenInvalid);
importButton.getStyleClass().add("jfx-button-border");
importPane.setRight(importButton);
}
BorderPane exportPane = new BorderPane();
{
Label left = new Label(i18n("multiplayer.persistence.export"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
exportPane.setLeft(left);
JFXButton exportButton = new JFXButton(i18n("multiplayer.persistence.export.button"));
exportButton.setOnMouseClicked(e -> {
String token = globalConfig().getMultiplayerToken();
Path configPath = MultiplayerManager.getConfigPath(token);
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("multiplayer.persistence.export.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("multiplayer.persistence.license_file"), "*.yml"));
fileChooser.setInitialFileName(configPath.getFileName().toString());
File file = fileChooser.showSaveDialog(Controllers.getStage());
if (file == null)
return;
CompletableFuture.runAsync(Lang.wrap(() -> MultiplayerManager.downloadHiperConfig(token, configPath)), Schedulers.io())
.handleAsync((ignored, exception) -> {
if (exception != null) {
LOG.log(Level.INFO, "Unable to download hiper config file", e);
}
if (!Files.isRegularFile(configPath)) {
LOG.warning("License file " + configPath + " not exists");
Platform.runLater(() -> Controllers.dialog(i18n("multiplayer.persistence.export.file_not_exists"), null, MessageType.ERROR));
return null;
}
try {
Files.copy(configPath, file.toPath(), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ioException) {
LOG.log(Level.WARNING, "Failed to export license file", ioException);
Platform.runLater(() -> Controllers.dialog(i18n("multiplayer.persistence.export.failed"), null, MessageType.ERROR));
}
return null;
});
});
exportButton.disableProperty().bind(MultiplayerManager.tokenInvalid);
exportButton.getStyleClass().add("jfx-button-border");
exportPane.setRight(exportButton);
}
persistencePane.getContent().setAll(hintPane, importPane, exportPane);
}
ComponentList thanksPane = new ComponentList();
{
GridPane gridPane = new GridPane();
gridPane.getColumnConstraints().setAll(new ColumnConstraints(), FXUtils.getColumnHgrowing(), new ColumnConstraints());
gridPane.setVgap(8);
gridPane.setHgap(16);
// Token acts like password, we hide it here preventing users from accidentally leaking their token when taking screenshots.
JFXPasswordField tokenField = new JFXPasswordField();
tokenField.textProperty().bindBidirectional(globalConfig().multiplayerTokenProperty());
tokenField.setPromptText(i18n("multiplayer.session.create.token.prompt"));
JFXHyperlink applyLink = new JFXHyperlink(i18n("multiplayer.session.create.token.apply"));
applyLink.setOnAction(e -> HMCLService.openRedirectLink("multiplayer-static-token"));
gridPane.addRow(0, new Label(i18n("multiplayer.session.create.token")), tokenField, applyLink);
OptionToggleButton relay = new OptionToggleButton();
relay.disableProperty().bind(tokenField.textProperty().isEmpty());
relay.selectedProperty().bindBidirectional(globalConfig().multiplayerRelayProperty());
relay.setTitle(i18n("multiplayer.relay"));
relay.setSubtitle(i18n("multiplayer.relay.hint"));
HBox pane = new HBox();
pane.setAlignment(Pos.CENTER_LEFT);
@@ -278,64 +440,22 @@ public class MultiplayerPageSkin extends DecoratorAnimatedPage.DecoratorAnimated
HBox.setHgrow(placeholder, Priority.ALWAYS);
pane.getChildren().setAll(
new Label("Based on Cato"),
new Label("Based on HiPer"),
aboutLink,
placeholder,
FXUtils.segmentToTextFlow(i18n("multiplayer.powered_by"), Controllers::onHyperlinkAction));
thanksPane.getContent().addAll(gridPane, relay, pane);
thanksPane.getContent().addAll(pane);
}
content.getChildren().setAll(
ComponentList.createComponentListTitle(i18n("multiplayer.session")),
roomPane,
ComponentList.createComponentListTitle(i18n("multiplayer.nat")),
natDetectionPane,
ComponentList.createComponentListTitle(i18n("settings")),
mainPane,
ComponentList.createComponentListTitle(i18n("multiplayer.persistence")),
persistencePane,
ComponentList.createComponentListTitle(i18n("about")),
thanksPane
);
}
}
public static String getNATType(@Nullable Result<DiscoveryInfo> info) {
if (info == null) {
return i18n("multiplayer.nat.testing");
} else if (info.isError()) {
return i18n("multiplayer.nat.failed");
} else if (info.get().isBlockedUDP()) {
return i18n("multiplayer.nat.type.blocked_udp");
} else if (info.get().isFullCone()) {
return i18n("multiplayer.nat.type.full_cone");
} else if (info.get().isOpenAccess()) {
return i18n("multiplayer.nat.type.open_access");
} else if (info.get().isPortRestrictedCone()) {
return i18n("multiplayer.nat.type.port_restricted_cone");
} else if (info.get().isRestrictedCone()) {
return i18n("multiplayer.nat.type.restricted_cone");
} else if (info.get().isSymmetric()) {
return i18n("multiplayer.nat.type.symmetric");
} else if (info.get().isSymmetricUDPFirewall()) {
return i18n("multiplayer.nat.type.symmetric_udp_firewall");
} else {
return i18n("multiplayer.nat.type.unknown");
}
}
private class ClientItem extends StackPane {
ClientItem(MultiplayerChannel.CatoClient client) {
BorderPane pane = new BorderPane();
pane.setPadding(new Insets(8));
pane.setLeft(new Label(client.getUsername()));
JFXButton kickButton = new JFXButton();
kickButton.setGraphic(SVG.close(Theme.blackFillBinding(), 16, 16));
kickButton.getStyleClass().add("toggle-icon-tiny");
kickButton.setOnAction(e -> getSkinnable().kickPlayer(client));
pane.setRight(kickButton);
RipplerContainer container = new RipplerContainer(pane);
getChildren().setAll(container);
getStyleClass().add("md-list-cell");
}
}
}
@@ -1,238 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.FutureCallback;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import static org.jackhuang.hmcl.ui.multiplayer.MultiplayerChannel.*;
import static org.jackhuang.hmcl.util.Logging.LOG;
public class MultiplayerServer extends Thread {
private ServerSocket socket;
private final String sessionName;
private final int gamePort;
private final boolean allowAllJoinRequests;
private FutureCallback<CatoClient> onClientAdding;
private final EventManager<MultiplayerChannel.CatoClient> onClientAdded = new EventManager<>();
private final EventManager<MultiplayerChannel.CatoClient> onClientDisconnected = new EventManager<>();
private final EventManager<Event> onKeepAlive = new EventManager<>();
private final EventManager<Event> onHandshake = new EventManager<>();
private final Map<String, Endpoint> clients = new ConcurrentHashMap<>();
private final Map<String, Endpoint> nameClientMap = new ConcurrentHashMap<>();
public MultiplayerServer(String sessionName, int gamePort, boolean allowAllJoinRequests) {
this.sessionName = sessionName;
this.gamePort = gamePort;
this.allowAllJoinRequests = allowAllJoinRequests;
setName("MultiplayerServer");
setDaemon(true);
}
public void setOnClientAdding(FutureCallback<CatoClient> callback) {
onClientAdding = callback;
}
public EventManager<MultiplayerChannel.CatoClient> onClientAdded() {
return onClientAdded;
}
public EventManager<MultiplayerChannel.CatoClient> onClientDisconnected() {
return onClientDisconnected;
}
public EventManager<Event> onKeepAlive() {
return onKeepAlive;
}
public EventManager<Event> onHandshake() {
return onHandshake;
}
public void startServer() throws IOException {
startServer(0);
}
public void startServer(int port) throws IOException {
if (socket != null) {
throw new IllegalStateException("MultiplayerServer already started");
}
socket = new ServerSocket(port);
start();
}
public int getPort() {
if (socket == null) {
throw new IllegalStateException("MultiplayerServer not started");
}
return socket.getLocalPort();
}
@Override
public void run() {
LOG.info("Multiplayer Server listening 127.0.0.1:" + socket.getLocalPort());
try {
while (!isInterrupted()) {
Socket clientSocket = socket.accept();
clientSocket.setSoTimeout(10000);
Lang.thread(() -> handleClient(clientSocket), "MultiplayerServerClientThread", true);
}
} catch (IOException ignored) {
}
}
public void kickPlayer(CatoClient player) {
Endpoint client = nameClientMap.get(player.getUsername());
if (client == null) return;
try {
if (client.socket.isConnected()) {
client.write(new KickResponse(KickResponse.KICKED));
client.socket.close();
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to kick player " + player.getUsername() + ". Maybe already disconnected?", e);
}
}
private void handleClient(Socket targetSocket) {
String address = targetSocket.getRemoteSocketAddress().toString();
String clientName = null;
LOG.info("Accepted client " + address);
try (Socket clientSocket = targetSocket;
BufferedReader reader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8));
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream(), StandardCharsets.UTF_8))) {
clientSocket.setKeepAlive(true);
Endpoint endpoint = new Endpoint(clientSocket, writer);
clients.put(address, endpoint);
String line;
while ((line = reader.readLine()) != null) {
if (isInterrupted()) {
return;
}
LOG.fine("Message from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
MultiplayerChannel.Request request = JsonUtils.fromNonNullJson(line, MultiplayerChannel.Request.class);
if (request instanceof JoinRequest) {
JoinRequest joinRequest = (JoinRequest) request;
LOG.info("Received join request with clientVersion=" + joinRequest.getClientVersion() + ", id=" + joinRequest.getUsername());
clientName = joinRequest.getUsername();
if (!Objects.equals(MultiplayerManager.CATO_VERSION, joinRequest.getClientVersion())) {
try {
endpoint.write(new KickResponse(KickResponse.VERSION_NOT_MATCHED));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
return;
}
}
CatoClient catoClient = new CatoClient(this, clientName);
nameClientMap.put(clientName, endpoint);
onClientAdded.fireEvent(catoClient);
if (onClientAdding != null && !allowAllJoinRequests) {
onClientAdding.call(catoClient, () -> {
try {
endpoint.write(new JoinResponse(sessionName, gamePort));
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send join response.", e);
try {
socket.close();
} catch (IOException ioException) {
LOG.log(Level.WARNING, "Failed to close socket caused by join response sending failure.", e);
this.interrupt();
}
}
}, msg -> {
try {
endpoint.write(new KickResponse(msg));
LOG.info("Rejected join request from id=" + joinRequest.getUsername());
socket.close();
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to send kick response.", e);
}
});
} else {
// Allow all join requests.
endpoint.write(new JoinResponse(sessionName, gamePort));
}
} else if (request instanceof KeepAliveRequest) {
endpoint.write(new KeepAliveResponse(System.currentTimeMillis()));
onKeepAlive.fireEvent(new Event(this));
} else if (request instanceof HandshakeRequest) {
endpoint.write(new HandshakeResponse());
onHandshake.fireEvent(new Event(this));
} else {
LOG.log(Level.WARNING, "Unrecognized packet from client " + targetSocket.getRemoteSocketAddress() + ":" + line);
}
}
} catch (IOException e) {
LOG.log(Level.WARNING, "Failed to handle client socket.", e);
} catch (JsonParseException e) {
LOG.log(Level.SEVERE, "Failed to parse client request. This should not happen.", e);
} finally {
if (clientName != null) {
onClientDisconnected.fireEvent(new CatoClient(this, clientName));
}
clients.remove(address);
if (clientName != null) nameClientMap.remove(clientName);
}
}
public static class Endpoint {
public final Socket socket;
public final BufferedWriter writer;
public Endpoint(Socket socket, BufferedWriter writer) {
this.socket = socket;
this.writer = writer;
}
public synchronized void write(Object object) throws IOException {
writer.write(verifyJson(JsonUtils.UGLY_GSON.toJson(object)));
writer.newLine();
writer.flush();
}
}
}
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.util;
import java.nio.file.Path;
import java.util.Set;
import static org.jackhuang.hmcl.util.Logging.LOG;
/**
* Utility for Adding JavaFX to module path.
*
@@ -30,6 +32,6 @@ public final class JavaFXPatcher {
}
public static void patch(Set<String> modules, Path... jarPaths) {
// Nothing to do with Java 8
LOG.info("No need to patch JavaFX with Java 8");
}
}
Binary file not shown.
+1 -1
View File
@@ -885,7 +885,7 @@
-fx-background-radius: 4;
-fx-padding: 8px;
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 10, 0.12, -1, 2);
-fx-effect: dropshadow(gaussian, rgba(0, 0, 0, 0.26), 5, 0.06, -0.5, 1);
}
.card:selected {
@@ -18,7 +18,7 @@
<JFXTextField fx:id="txtServerUrl" promptText="%account.injector.server_url" onAction="#onAddNext" />
</body>
<actions>
<Label fx:id="lblCreationWarning" />
<Label fx:id="lblCreationWarning" wrapText="true" />
<HBox>
<JFXButton onMouseClicked="#onAddCancel" text="%button.cancel" styleClass="dialog-cancel" />
<SpinnerPane fx:id="nextPane" styleClass="small-spinner-pane">
@@ -17,12 +17,12 @@
<SpinnerPane fx:id="spinnerPane" styleClass="large-spinner-pane">
<VBox fx:id="borderPane" alignment="CENTER" FXUtils.limitWidth="500">
<ComponentList>
<BorderPane><left><Label text="%modpack.task.install.will" /></left><right><Label fx:id="lblModpackLocation" /></right></BorderPane>
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><right><JFXTextField fx:id="txtModpackName" StackPane.margin="$insets" /></right></BorderPane>
<BorderPane><left><Label text="%modpack.task.install.will" /></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblModpackLocation" /></center></BorderPane>
<BorderPane><left><Label BorderPane.alignment="CENTER_LEFT" text="%archive.name" /></left><center><JFXTextField BorderPane.alignment="CENTER_RIGHT" fx:id="txtModpackName" StackPane.margin="$insets" /></center></BorderPane>
<BorderPane><left><Label text="%modpack.name"/></left><right><Label fx:id="lblName" /></right></BorderPane>
<BorderPane><left><Label text="%archive.version"/></left><right><Label fx:id="lblVersion" /></right></BorderPane>
<BorderPane><left><Label text="%archive.author"/></left><right><Label fx:id="lblAuthor" /></right></BorderPane>
<BorderPane><left><Label text="%modpack.name"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblName" /></center></BorderPane>
<BorderPane><left><Label text="%archive.version"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblVersion" /></center></BorderPane>
<BorderPane><left><Label text="%archive.author"/></left><center><Label BorderPane.alignment="CENTER_RIGHT" fx:id="lblAuthor" /></center></BorderPane>
<BorderPane>
<left><JFXButton fx:id="btnDescription" onMouseClicked="#onDescribe" text="%modpack.description" styleClass="jfx-button-border" /></left>
<right><JFXButton buttonType="RAISED" fx:id="btnInstall" onMouseClicked="#onInstall" text="%button.install" styleClass="jfx-button-raised" /></right>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import com.jfoenix.controls.JFXButton?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.*?>
<?import java.lang.String?>
@@ -8,18 +7,18 @@
xmlns:fx="http://javafx.com/fxml"
type="StackPane">
<HBox spacing="8" styleClass="jfx-dialog-layout">
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40" maxHeight="40" />
<Label fx:id="graphic" translateX="10" translateY="10" minWidth="40" maxWidth="40" minHeight="40"
maxHeight="40"/>
<VBox HBox.hgrow="ALWAYS">
<StackPane>
<styleClass>
<String fx:value="jfx-layout-heading" />
<String fx:value="title" />
<String fx:value="jfx-layout-heading"/>
<String fx:value="title"/>
</styleClass>
<Label fx:id="title" text="%message.info" />
<Label fx:id="title" text="%message.info"/>
</StackPane>
<StackPane fx:id="content" styleClass="jfx-layout-body">
</StackPane>
<HBox fx:id="actions" styleClass="jfx-layout-actions" />
<StackPane fx:id="content" styleClass="jfx-layout-body"/>
<HBox fx:id="actions" styleClass="jfx-layout-actions"/>
</VBox>
</HBox>
</fx:root>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 912 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -62,7 +62,7 @@ account.create.yggdrasil=Add a Mojang account
account.create.offline=Add an offline account
account.create.authlibInjector=Create an authlib-injector account
account.email=Email
account.failed=Error code\: %d
account.failed=Account refresh failed
account.failed.character_deleted=The character has already been deleted.
account.failed.connect_authentication_server=Unable to contact authentication servers, your Internet connection may be down.
account.failed.connect_injector_server=Unable to connect to the authentication server. Please check your network and make sure you entered the correct URL.
@@ -72,10 +72,12 @@ account.failed.invalid_password=Invalid password
account.failed.invalid_token=Please try to re-login again.
account.failed.migration=Your account needs to be migrated to a Microsoft account. If you already did, you should re-login to your migrated Microsoft account instead.
account.failed.no_character=There are no characters linked to this account.
account.failed.server_disconnected=Cannot access authentication server. You can log in offline or try to re-login.
account.failed.server_response_malformed=Invalid server response, the authentication server may not be working.
account.failed.wrong_account=You have logged in to the wrong account.
account.hmcl.hint=You need to click on "Login" and complete the process in the opened tab in your browser.
account.injector.add=Add an Authentication Server
# avoid too long sequence.
account.injector.add=New Auth Server
account.injector.empty=None (You can click on the plus button on the right to add one)
account.injector.http=Warning\: This server uses the unsafe HTTP protocol, anyone between your connection will be able to see your credentials in cleartext.
account.injector.link.homepage=Homepage
@@ -85,7 +87,11 @@ account.injector.server_url=Server URL
account.injector.server_name=Server Name
account.login=Login
account.login.hint=We will not store your password.
account.login.skip=Login offline
account.login.retry=Retry
account.login.refresh=Re-login
account.login.refresh.microsoft.hint=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nPlease click on the "OK" button, and copy the code shown here later to finish the login process in the opened browser window.
account.login.refresh.microsoft.manual=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nYour device code is <b>%1$s</b>, please click here to copy. After clicking on the "OK" button, you should finish the login process in the opened browser window. If it did not show, you can go to %2$s manually.
account.logout=Logout
account.register=Register
account.manage=Account List
@@ -102,7 +108,7 @@ account.methods.microsoft.error.add_family=Since you are not yet 18 years old, a
account.methods.microsoft.error.add_family_probably=Please check if the age indicated in your account settings is at least 18 years old. If not and you believe this is an error, you can click on the above link to change it.
account.methods.microsoft.error.country_unavailable=Xbox Live is not available in your current country/region.
account.methods.microsoft.error.missing_xbox_account=Your Microsoft account does not have a linked Xbox account yet. Please create one before continuing.
account.methods.microsoft.error.no_character=Your account does not own the Minecraft Java Edition.
account.methods.microsoft.error.no_character=Your account does not own the Minecraft Java Edition.\nThe game profile may not have been created,\nplease click the link above to create it.
account.methods.microsoft.error.unknown=Failed to log in, error\: %d.
account.methods.microsoft.logging_in=Logging in...
account.methods.microsoft.hint=Please click on the "login" button, and copy the code shown here later to finish the login process in the opened browser window. \n\
@@ -111,6 +117,7 @@ If the token used to log in to the Microsoft account is leaked, you can click on
account.methods.microsoft.manual=Your device code is <b>%1$s</b>, please click here to copy. After clicking on the "Login" button, you should finish the login process in the opened browser window. If it did not show, you can go to %2$s manually.\n\
\n\
If the token used to log in to the Microsoft account is leaked, you can click on "Unauthorize the account" to deauthorize it.
account.methods.microsoft.makegameidsettings=Create Profile
account.methods.microsoft.profile=Account Profile
account.methods.microsoft.snapshot=HMCL Snapshot version does not support Microsoft login, please use the stable version or beta version to log in.
account.methods.microsoft.waiting_browser=Waiting for login to complete in the opened window...
@@ -121,6 +128,7 @@ account.methods.offline.uuid.hint=UUID is the unique identifier for the game cha
This option is for advanced users only. We do not recommend modifying this option unless you know what you are doing.
account.methods.offline.uuid.malformed=Invalid Format
account.methods.yggdrasil=Mojang
account.methods.forgot_password=Forgot Password
account.methods.yggdrasil.migration=Migrate to Microsoft Account
account.methods.yggdrasil.migration.how=How to migrate to Microsoft Account?
account.methods.yggdrasil.migration.hint=Since March 10, 2022, Mojang accounts are no longer supported, and you are required to migrate to a Microsoft account in order to play Minecraft.\n\
@@ -160,6 +168,7 @@ assets.index.malformed=Index files of downloaded assets were corrupted. You can
button.cancel=Cancel
button.change_source=Change Download Source
button.clear=Clear
button.copy_and_exit=Copy and Exit
button.delete=Delete
button.edit=Edit
button.install=Install
@@ -169,6 +178,7 @@ button.ok=OK
button.refresh=Refresh
button.remove=Remove
button.remove.confirm=Are you sure you want to permanently remove it? This action cannot be undone\!
button.retry=Retry
button.save=Save
button.save_as=Save As
button.select_all=Select All
@@ -347,8 +357,12 @@ fatal.missing_dst_root_ca_x3=The DST Root CA X3 certificate is missing from the
You can still use Hello Minecraft\! Launcher, but it will be unable to connect to some websites (such as sites that use certificates issued by Let's Encrypt), which may cause the launcher to work improperly.\n\
\n\
Please update your Java version to at least 8u101 to fix this issue.
fatal.config_change_owner_root=You are using the root account to start Hello Minecraft\! Launcher, this may cause you to fail to start Hello Minecraft\! Launcher with other account in the future.\n\
Do you still want to continue?
fatal.config_loading_failure=Cannot load configuration files.\n\
Please make sure that "Hello Minecraft\! Launcher" has read and write access to "%s" and the files in it.
fatal.config_loading_failure.unix=Hello Minecraft! Launcher could not load the profile because the profile was created by user %1$s. \n\
Please start HMCL with sudo (not recommended), or execute the following command in the terminal to change the ownership of the configuration file to the current user:\n%2$s
fatal.migration_requires_manual_reboot=Hello Minecraft\! Launcher has been upgraded. Please reopen the launcher.
fatal.apply_update_failure=We are sorry, but Hello Minecraft\! Launcher is unable to update.\n\
\n\
@@ -361,6 +375,8 @@ fatal.unsupported_platform=Minecraft is not yet fully supported for your platfor
\n\
If you can't start Minecraft 1.17 and above, you can try turning on the "Use OpenGL software renderer" option\n\
in instance settings to use CPU rendering for better compatibility.
fatal.unsupported_platform.osx_arm64=HMCL has provided support for Apple Silicon platform, using native ARM java to launch games to get a smoother game experience.\nIf you encounter problems in the game, starting the game with Java based on x86-64 architecture may have better compatibility.
fatal.unsupported_platform.windows_arm64=HMCL has provided native support for the Windows on ARM platform. If you encounter problems when playing game, please try to start the game with Java based on x86 architecture.\n\nIf you are using the <b>Qualcomm</b> platform, you may need to install the <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL Compatibility Pack</a> before playing games.\nClick the link to go to the Microsoft Store and install the compatibility pack.
feedback=Feedback
feedback.add=Add a Feedback
@@ -538,6 +554,7 @@ If you still encounter these problems, well, you may need a better computer.
game.crash.reason.resolution_too_high=The game crashed because you are using a resource pack whose texture resolution was too high.\n\
\n\
You should switch to a resource pack with lower resolution, or consider buying a better graphics card with more VRAM.
game.crash.reason.processing_of_javaagent_failed=The current game crashed because processing of -javaagent failed.\n If you add relevant parameters to the Java virtual machine parameters, please check whether they are legal and correct.\n If you do not add relevant parameters or confirm that they are legal and correct, Please try: \n Open the control panel -- Clock and region classification (this option is only available if the option is category display, and it will be skipped if not) -- Region -- the upper management tab -- the lower change system regional setting button -- turn off the "Use Unicode UTF-8 to provide global language support" option in the pop-up window, restart the device, and then try to start the game. \n You can be accessed in<a href=“https://discord.gg/jVvC7HfM6U">Discard</a>or<a href="https://kook.top/Kx7n3t">KOOK</a>ask for help.
game.crash.reason.stacktrace=The crash reason is unknown. You can view its details by clicking the "Logs" button.\n\
\n\
There are some keywords that might contain some Mod IDs. You can search them online to figure out the issue yourself.\n\
@@ -548,9 +565,7 @@ game.crash.reason.too_old_java=The game crashed because you are using a historic
You need to switch to a newer version (%1$s) of Java in the game settings and then relaunch the game. You can download Java from <a href\="https\://docs.microsoft.com/en-US/java/openjdk/download">here</a>.
game.crash.reason.unknown=We are not able to figure out why the game crashed, please refer to the game logs.\n\
\n\
<b>When asking someone else for help, please share the full game log and related crash report file with them\!</b>\n\
\n\
Is this crash caused by a common issue that almost everyone gets? You can provide your logs in a new GitHub issue to improve the launcher.
<b>When asking someone else for help, please share the full game log and related crash report file with them\!</b>\n
game.crash.reason.unsatisfied_link_error=Unable to launch Minecraft due to missing libraries\: %1$s.\n\
\n\
If you have modified native library settings, please make sure these libraries do exist. Or, please try to launch again after reverting it back to default.\n\
@@ -569,7 +584,7 @@ input.number=The input must be a number.
input.not_empty=This is a required field.
input.url=The input must be a valid URL.
install=Add a New Instance
install=New Instance
install.change_version=Change Version
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
install.failed=Installation Failed
@@ -600,7 +615,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=Not Selected
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=Install a Modpack
install.new_game=Add a New Instance
@@ -733,7 +748,7 @@ modpack.files.scripts=MineTweaker Configuration File
modpack.files.servers_dat=Server List File
modpack.install=Install Modpack %s
modpack.installing=Installing Modpack
modpack.introduction=CurseForge, MultiMC, and HMCL modpacks are currently supported.
modpack.introduction=CurseForge, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
modpack.invalid=Invalid modpack, you can try to redownload it.
modpack.mismatched_type=Modpack type mismatched, the current instance is a %s type, but the provided one is %s type.
modpack.name=Modpack Name
@@ -744,7 +759,7 @@ modpack.origin.mcbbs=MCBBS
modpack.origin.mcbbs.prompt=Post ID
modpack.scan=Parsing Modpack Index
modpack.task.install=Install Modpack
modpack.task.install.error=Unable to identify this modpack. We currently only support Curse, Modrinth, MultiMC, and HMCL modpacks.
modpack.task.install.error=Unable to identify this modpack. We currently only support Curse, Modrinth, MultiMC, and MCBBS modpacks.
modpack.task.install.will=You are going to install modpack\:
modpack.type.curse=Curse
modpack.type.curse.tolerable_error=Unable to download dependencies, you can try to continue downloading by launching this game instance.
@@ -759,7 +774,7 @@ modpack.type.multimc.export=Can be imported by Hello Minecraft\! Launcher and Mu
modpack.type.server=Auto-Update Modpack from Server
modpack.type.server.export=Allows server owner to update the game instance remotely
modpack.type.server.malformed=Invalid modpack manifest, please refer to the server modpack maker to fix this issue.
modpack.unsupported=Unsupported format, we currently only support HMCL, MultiMC, and CurseForge formats.
modpack.unsupported=Unsupported format, we currently only support MCBBS, Modrinth, MultiMC, and CurseForge formats.
modpack.update=Updating modpack
modpack.wizard=Modpack Export Wizard
modpack.wizard.step.1=Basic Settings
@@ -832,98 +847,68 @@ mods.not_modded=You must install a mod loader (Fabric, Forge or LiteLoader) firs
mods.restore=Rollback
mods.url=Official Page
multiplayer=Multiplayer
multiplayer.agreement.prompt=Before using the multiplayer service, you must agree to the terms of services from the service provider, ioi, first.\n\
By using this service, you agree to these terms of service.
multiplayer.download=Downloading Dependencies
multiplayer.download.failed=Failed to initialize multiplayer feature, some files cannot be downloaded.
multiplayer.download.success=Dependencies initialization completed
multiplayer.download.unsupported=The current operating system or architecture is unsupported.
multiplayer.exit.after_ready=The multiplayer session exited abnormally, cato exitcode %d
multiplayer.exit.before_ready=Unable to create a multiplayer session, cato exitcode %d
multiplayer.exit.timeout.static_token=Failed to connect to the multiplayer provider. Your token may not be working, you can try to clear it and then try again.\n\
Or, you can wait for a few minutes. If the feature still won't work, please refer to the administrators for more help.
multiplayer.exit.timeout.dynamic_token=Unable to connect to the multiplayer service, authentication servers may have too many requests at the time. Please wait until the next half past to allow the servers to clear some sessions.\n\
\n\
You can also buy some multiplayer tokens to get a stable multiplayer experience.
multiplayer.hint=The multiplayer feature is currently in the alpha stage. Please provide your feedback at mcer.cn.
multiplayer.nat=Network Detection
multiplayer.nat.failed=Unable to detect the network type.
multiplayer.nat.hint=Network type detection will check if your network environment supports the multiplayer functionality.\n\
\n\
If the result is 'Bad' or 'Failed', you may not be able to use this feature.\n\
multiplayer.nat.latency=Latency
multiplayer.nat.not_yet_tested=Not Yet Tested
multiplayer.nat.packet_loss_ratio=Packet Loss Rate
multiplayer.nat.testing=Testing
multiplayer.nat.type=NAT Type
multiplayer.nat.type.blocked_udp=Very Bad (UDP is blocked)
multiplayer.nat.type.full_cone=Best (Full Cone)
multiplayer.nat.type.open_access=Best (Public Network Access)
multiplayer.nat.type.port_restricted_cone=Good (Port Restricted Cone)
multiplayer.nat.type.restricted_cone=Good (Restricted Cone)
multiplayer.nat.type.symmetric=Bad (Symmetric)
multiplayer.nat.type.symmetric_udp_firewall=Bad (Symmetric with UDP Firewall)
multiplayer.nat.type.unknown=Unknown
multiplayer.powered_by=Multiplayer service is provided by <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Terms of Service</a>
multiplayer.relay=Relay Mode
multiplayer.relay.hint=The relay mode provides a smooth multiplayer experience for users with a poor Internet connection.\n\
\n\
You can enable this option after getting some tokens on mcer.cn.
multiplayer.report=Report
multiplayer.session=Session
multiplayer.session.name.format=%1$s's Session
multiplayer.session.name.motd=HMCL Multiplayer Session - %s
multiplayer.session.close=End Session
multiplayer.session.close.warning=All other players in this session will be disconnected. Continue?
multiplayer.session.copy_room_code=Copy Invitation Code
multiplayer.session.create=Create Session
multiplayer.session.create.error.dynamic_token=Unable to create a multiplayer session. Please try again later.
multiplayer.session.create.error.static_token=Unable to create a multiplayer session. You can clear your token and then try again later.
multiplayer.session.create.hint=Before creating a multiplayer session, you must click on "Open to LAN" first in the game's pause menu. Then enter the port number below.
multiplayer.session.create.join=Join Request
multiplayer.session.create.join.allow=Automatically accepts all connection requests
multiplayer.session.create.join.prompt=Player %s requests to join the multiplayer session. Do you want to accept?
multiplayer.session.create.members=Players
multiplayer.session.create.members.kick=Ban
multiplayer.session.create.members.kick.prompt=Do you want to ban this player?
multiplayer.session.create.name=Name
multiplayer.session.create.port=Port
multiplayer.session.create.port.error=Unable to detect the game port, you must click on "Open to Lan" in the game's pause menu to allow others to join your world.
multiplayer.session.create.token=Token
multiplayer.session.create.token.apply=Apply for a Static Token
multiplayer.session.create.token.prompt=Random token as default, you can click on 'Apply for a Static Token' for more information.
multiplayer.session.error.already_started=Cato service is already running, please check if there are any other HMCL instances running, or terminate cato process before continuing.
multiplayer.session.error.file_not_found=Unable to find cato executable. This program should already be downloaded after your enter this page, please reopen the launcher and try again.\n\
Also, please check if your anti-virus software marked it as malware. If so, please release it from your anti-virus software's quarantine.
multiplayer.session.expired=The multiplayer session has exceeded the maximum duration of 3 hours. Please recreate or rejoin a session.
multiplayer.session.join=Join
multiplayer.session.join.error=Unable to join the multiplayer session. You can try to use the relay mode instead.
multiplayer.session.join.error.connection=Unable to join the multiplayer session. Please make sure you are using the same HMCL version as the session host.
multiplayer.session.join.hint=You must first obtain the session code from the host to join it.
multiplayer.session.join.invitation_code=Invitation Code
multiplayer.session.join.invitation_code.error=Invalid invitation code. Please obtain one from the multiplayer session host, and make sure you are running the same HMCL version.
multiplayer.session.join.kicked=Lost connection to session\: %s.
multiplayer.session.join.kicked.join_acceptance_timeout=The host did not accept your request in time.
multiplayer.session.join.kicked.kicked=You have been kicked by the host.
multiplayer.session.join.kicked.version_not_matched=Inconsistent versions of multiplayer libraries detected, please make sure you are using the same as the session host.
multiplayer.session.join.lost_connection=Lost connection to the multiplayer session. The host might have ended it, or the server shut it down because it exceeds the time limit, or there are some issues with your Internet connection.
multiplayer.session.join.port.error=Unable to bind an available local network port for listening. Please make sure that HMCL has the privileges to listen on a port.
multiplayer.session.join.rejected=The host rejected your connection request.
multiplayer.session.join.wait=Waiting for the host to accept...
multiplayer.session.members=Members
multiplayer.session.quit=Leave
multiplayer.session.quit.warning=You will be disconnected from the server if you quit, continue?
multiplayer.session.username=Username
multiplayer.state.connecting=Connecting
multiplayer.state.disconnected=Not in a Session
multiplayer.state.disconnected.hint=The host should create a session first before others can join their world.
multiplayer.state.master=Session %1$s created, port\: %2$d
multiplayer.state.master.hint=After creating a session, you can now share your invitation code with your peers. They can now join your session after entering the code on the HMCL multiplayer page.
multiplayer.state.slave=Joined session %1$s, address\: %2$s
multiplayer.state.slave.copy=Copy Address
multiplayer.state.slave.hint=After joining a multiplayer session, you can now join the multiplayer by joining the "HMCL Multiplayer Session" server shown in the Minecraft multiplayer server list or manually connect to the below address.
multiplayer=Multiplayer online
multiplayer.agreement.prompt=Before using the functionality of multiplayer, you must agree to the user agreement and disclaimer of the multiplayer service provider mcer.cn. \nEspecially, you agree that any problems and disputes you encounter while using the multiplayer functionality (including its paid services) have nothing to do with HMCL and should be resolved through consultation with mcer.cn
multiplayer.download=Downloading dependencies
multiplayer.download.failed=Initialization failed, some files failed to download
multiplayer.download.success=Multiplayer online initialization completed
multiplayer.download.unsupported=Multiplayer dependencies are not supported on current system or platform
multiplayer.error.failed_get_device=HiPer could not create a network device, maybe HiPer has already started or lacks administrator privileges.
multiplayer.error.failed_load_config=HiPer initialization failed, maybe there is a problem with the configuration file or the certificate is invalid.
multiplayer.error.failed_sudo.linux=HiPer requires administrator privileges. You can configure how HiPer applies for privileges by setting the environment variable HMCL_ASKPASS. \nIf HMCL_ASKPASS is set to 'false', please configure sudoers file to allow '%s' to obtain root privileges without password, then restart the computer and start HMCL again;\nIf HMCL_ASKPASS is set to 'user', please set the environment variable SUDO_ASKPASS first, then HMCL will start HiPer with 'sudo --askpass' (see man sudo for details).
multiplayer.error.failed_sudo.mac=HiPer requires administrator privileges. Do you want to grant HiPer administrator rights? \N Click "Yes" to grant HiPer administrator permission, and then restart your computer and open HMCL again.
multiplayer.error.failed_sudo.windows=HiPer requires administrator privileges, please restart HMCL with administrator privileges. Would you like to see the tutorial (Simplified Chinese only)?
multiplayer.error.file_not_found=An update to HiPer has been found. Please re-enter the multiplayer online page to update HiPer.
multiplayer.error.session_expired=Current multiplayer session has been expired. Please fetch a new multiplayer token.
multiplayer.exit=HiPer exited unexpectedly with exit code %d
multiplayer.help.1=管理员权限打开
multiplayer.help.2=多人联机教程
multiplayer.help.3=创建方帮助
multiplayer.help.4=参与者帮助
multiplayer.help.text=测试能否联机
multiplayer.hint=Multiplayer online function is under maintenance.
multiplayer.hint.details=View details
multiplayer.persistence=License Management
multiplayer.persistence.export=Export license to file
multiplayer.persistence.export.button=Export license file
multiplayer.persistence.export.failed=Failed to export the license file, please check the save path you selected.
multiplayer.persistence.export.file_not_exists=Failed to obtain license file, please check whether the token is entered correctly.
multiplayer.persistence.export.title=Select the path to save the license file
multiplayer.persistence.hint=This feature is available for long-term license (valid for more than 24 hours) users, if you are using short-term license (valid for less than 24 hours), you do not need to use this feature.\nWhen you use a long-term license, please use the token to start HiPer within 24 hours after obtaining the license, otherwise HiPer will not be able to obtain the license information. \nAfterwards, you can save the license file through the "Export License File" function below. \nPlease keep the license file in a safe place. After 24 hours of authorization, this file is the only license for your. If you lose this file, you will not be able to play multiplayer games through the token.
multiplayer.persistence.import=Import license from file
multiplayer.persistence.import.button=Import license file
multiplayer.persistence.import.failed=Failed to import license file.
multiplayer.persistence.import.file_already_exists=The license file already exists.
multiplayer.persistence.import.title=Choose license file
multiplayer.persistence.import.token_not_match=The license file you selected may not match the token.\nDo you still want to continue?
multiplayer.persistence.license_file=License file
multiplayer.powered_by=This service is provided under the license of (<a href="https://mcer.cn">Matrix Lab</a>)<a href\="https://docs.hmcl.net/multiplayer/#%E4%BD%BF%E7%94%A8%E6%8E%88%E6%9D%83" >License Agreement</a>
multiplayer.report=Illegal and Violation Report
multiplayer.session.name.motd=HMCL Multiplayer Session
multiplayer.token=Token
multiplayer.token.apply=Apply for a token
multiplayer.token.expired=HiPer token expired, please restart HMCL and try again.
multiplayer.token.format_invalid=Invalid token format
multiplayer.token.invalid=Invalid token.
multiplayer.token.malformed=HiPer configuration file could not be parsed, please restart HMCL and try again.
multiplayer.token.prompt=You need a token to use the multiplayer service. Click on the "Application Credentials" next to it to view the details
multiplayer.off.hint=Due to the need to register system network service permissions, administrator rights will be requested when starting HiPer. If prompted for authentication, please allow to start HiPer.
multiplayer.off.start=Start HiPer
multiplayer.master=Prompt for creator
multiplayer.master.hint=Multiplayer online requires one player to start the game first, select the single player mode to enter a save, and select the "Open to LAN" option in the game options menu. After that you can see the port number (usually 5 digits) indicated by the game in the game chat box. Click the Generate Server Address button below, enter the port number, and you can get your server address. This address needs to be provided to other players who need to join the server to add the server.
multiplayer.master.server_address=Generate server address
multiplayer.master.server_address.copy=Copy
multiplayer.master.video_tutorial=Video Tutorial
multiplayer.master.port=Port number
multiplayer.master.port.validate=The port number (0~65535) displayed in the game chat box,when you open the game in LAN.
multiplayer.slave=Participant Prompt
multiplayer.slave.hint=1.Ask the creator to follow the instructions above\n2.Start the game\n3.Choose multiplayer mode and add a server\n4.The game will require you to enter the server address. You only need to ask the creator for the server address and enter it into the server.
multiplayer.slave.hint2=1. In general, the participant's game account must be a Microsoft account or an external login account (such as Little Skin), otherwise you need to enter the server address into the input box below and click "Join", select the multiplayer mode in the game, enter the LAN world before you can join \n2. In general, the participant's game version and module must be the same as the creator, otherwise join fails.
multiplayer.slave.server_address=Creator server address
multiplayer.slave.server_address.start=Join
multiplayer.slave.server_address.stop=Exit
multiplayer.slave.video_tutorial=Video Tutorial
multiplayer.session.expiration=Token Expiration Time
datapack=Datapacks
datapack.add=Install datapack
@@ -62,7 +62,7 @@ account.create.yggdrasil=Añadir una cuenta de Mojang
account.create.offline=Añadir una cuenta offline
account.create.authlibInjector=Crear una cuenta authlib-injector
account.email=Correo electrónico
account.failed=Código de error\: %d
account.failed=error de inicio de sesion
account.failed.character_deleted=El personaje ya ha sido eliminado.
account.failed.connect_authentication_server=No se ha podido conectar con los servidores de autenticación, es posible que la conexión a Internet esté caída.
account.failed.connect_injector_server=No se ha podido conectar con el servidor de autenticación. Por favor, compruebe su red y asegúrese de que ha introducido la URL correcta.
@@ -85,6 +85,8 @@ account.injector.server_name=Nombre del servidor
account.login=Acceder
account.login.hint=No almacenaremos su contraseña.
account.login.refresh=Reiniciar sesión
account.login.refresh.microsoft.hint=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nPor favor, haz clic en el botón "Acceder", y copia el código que se muestra aquí para terminar el proceso de acceso en la ventana abierta del navegador.
account.login.refresh.microsoft.manual=<b>Because the account authorization is invalid, you need to re-add your Microsoft account</b>\nEl código de su dispositivo es <b>%1$s</b>, por favor, haga clic aquí para copiar. Después de hacer clic en el botón "Iniciar sesión", debería terminar el proceso de inicio de sesión en la ventana abierta del navegador. Si no aparece, puede ir a %2$s manualmente.
account.logout=Salir
account.register=Registrarse
account.manage=Lista de cuentas
@@ -96,12 +98,13 @@ account.methods.microsoft.birth=Cómo actualizar la fecha de nacimiento de tu cu
account.methods.microsoft.close_page=La autorización de la cuenta de Microsoft ha finalizado. \n\
\n\
Hay algunos trabajos extra para nosotros, pero puedes cerrar esta pestaña con seguridad por ahora.
account.methods.microsoft.makegameidsettings=Create Profile
account.methods.microsoft.deauthorize=Desautorizar
account.methods.microsoft.error.add_family=Como todavía no tienes 18 años, un adulto debe añadirte a una familia para que puedas jugar a Minecraft.
account.methods.microsoft.error.add_family_probably=Por favor, comprueba si la edad indicada en la configuración de tu cuenta es de al menos 18 años. Si no es así y crees que se trata de un error, puedes hacer clic en el enlace anterior para cambiarlo.
account.methods.microsoft.error.country_unavailable=Xbox Live no está disponible en tu país/región actual.
account.methods.microsoft.error.missing_xbox_account=Tu cuenta Microsoft aún no tiene una cuenta Xbox vinculada. Crea una antes de continuar.
account.methods.microsoft.error.no_character=Tu cuenta no posee la edición Java de Minecraft.
account.methods.microsoft.error.no_character=Tu cuenta no posee la edición Java de Minecraft.\nThe game profile may not have been created,\nplease click the link above to create it.
account.methods.microsoft.error.unknown=No se ha podido iniciar sesión, error: %d.
account.methods.microsoft.logging_in=Inicio de sesión...
account.methods.microsoft.hint=Por favor, haz clic en el botón "Acceder", y copia el código que se muestra aquí para terminar el proceso de acceso en la ventana abierta del navegador.\n\
@@ -120,6 +123,7 @@ account.methods.offline.uuid.hint=UUID es el identificador único del personaje
Esta opción es sólo para usuarios avanzados. No recomendamos modificar esta opción a menos que sepas lo que estás haciendo.
account.methods.offline.uuid.malformed=Formato no válido
account.methods.yggdrasil=Mojang
account.methods.forgot_password=OLVIDÉ MI CONTRASEÑA
account.methods.yggdrasil.migration=Migrar a cuenta Microsoft
account.methods.yggdrasil.migration.how=¿Cómo migrar a la Cuenta Microsoft?
account.methods.yggdrasil.migration.hint=Desde el 10 de marzo de 2022, las cuentas de Mojang ya no son compatibles, y es necesario migrar a una cuenta de Microsoft para poder jugar a Minecraft.\n
@@ -589,7 +593,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=No seleccionado
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=Instalar un Modpack
install.new_game=Añadir instancia
@@ -725,7 +729,7 @@ modpack.files.scripts=Archivo de configuración de MineTweaker
modpack.files.servers_dat=Archivo de lista de servidores
modpack.install=Instalar Modpack %s
modpack.installing=Instalar Modpack
modpack.introduction=Actualmente se soportan los modpacks CurseForge, MultiMC y HMCL.
modpack.introduction=Actualmente se soportan los modpacks CurseForge, Modrinth, MultiMC y MCBBS.
modpack.invalid=Modpack inválido, puede intentar volver a descargarlo.
modpack.mismatched_type=Tipo de modpack erróneo, la instancia actual es del tipo %s, pero la proporcionada es del tipo %s.
modpack.name=Nombre del modpack
@@ -736,7 +740,7 @@ modpack.origin.mcbbs=MCBBS
modpack.origin.mcbbs.prompt=ID de publicación
modpack.scan=Análisis del índice de modpacks
modpack.task.install=Instalar Modpack
modpack.task.install.error=No se ha podido identificar este modpack. Actualmente sólo soportamos los modpacks Curse, Modrinth, MultiMC y HMCL.
modpack.task.install.error=No se ha podido identificar este modpack. Actualmente sólo soportamos los modpacks Curse, Modrinth, MultiMC y MCBBS.
modpack.task.install.will=Vas a instalar el modpack\:
modpack.type.curse=Curse
modpack.type.curse.tolerable_error=No se han podido descargar las dependencias, puedes intentar continuar la descarga ejecutando esta instancia del juego.
@@ -751,7 +755,7 @@ modpack.type.multimc=MultiMC
modpack.type.server=Actualización automática del modpack desde el servidor
modpack.type.server.export=Permite al propietario del servidor actualizar la instancia del juego de forma remota
modpack.type.server.malformed=Manifiesto de modpack inválido, por favor, consulte al creador del modpack del servidor para solucionar este problema.
modpack.unsupported=Formato no soportado, actualmente sólo soportamos los formatos HMCL, MultiMC y CurseForge.
modpack.unsupported=Formato no soportado, actualmente sólo soportamos los formatos MCBBS, Modrinth, MultiMC y CurseForge.
modpack.update=Actualización del modpack
modpack.wizard=Asistente de exportación de modpack
modpack.wizard.step.1=Configuración básica
@@ -838,7 +842,6 @@ O bien, puedes esperar unos minutos. Si la función sigue sin funcionar, consult
multiplayer.exit.timeout.dynamic_token=No se ha podido conectar al servicio multijugador, los servidores de autenticación pueden tener demasiadas peticiones en ese momento. Por favor, espere hasta el próximo medio tiempo para permitir que los servidores despejen algunas sesiones.\n\
\n\
También puedes comprar algunos tokens multijugador para conseguir una experiencia multijugador estable.
multiplayer.hint=La función multijugador está actualmente en fase alfa. Por favor, proporciona tus comentarios en mcer.cn.
multiplayer.nat=Detección de red
multiplayer.nat.failed=No se ha podido detectar el tipo de red.
multiplayer.nat.hint=La detección del tipo de red comprobará si su entorno de red es compatible con la funcionalidad multijugador.\n\
@@ -858,7 +861,7 @@ multiplayer.nat.type.restricted_cone=Bueno (Cono Restringido)
multiplayer.nat.type.symmetric=Malo (Simétrico)
multiplayer.nat.type.symmetric_udp_firewall=Malo (Simétrico con cortafuegos UDP)
multiplayer.nat.type.unknown=Desconocido
multiplayer.powered_by=El servicio multijugador es proporcionado por <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Términos de servicio</a>
multiplayer.powered_by=El servicio multijugador es proporcionado por <a href\="https\://mcer.cn">mcer.cn</a>. <a href\="https\://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">Licence</a>
multiplayer.relay=Modo de retransmisión
multiplayer.relay.hint=El modo de retransmisión proporciona una experiencia multijugador sin problemas para los usuarios con una mala conexión a Internet.\n\
\n\
@@ -60,7 +60,7 @@ account.create.yggdrasil=Mojang Login
account.create.offline=Offline
account.create.authlibInjector=サードパーティーアカウントを追加
account.email=メールアドレス
account.failed=エラーコード:%d
account.failed=ログインに失敗しました
account.failed.character_deleted=キャラクターが削除されました。
account.failed.connect_authentication_server=認証サーバーに接続できません。インターネット接続を確認してください。
account.failed.connect_injector_server=認証サーバーに接続できません。ネットワークをチェックして、URLが正しいことを確認してください。
@@ -83,6 +83,8 @@ account.injector.server_name=サーバー名
account.login=ログイン
account.login.hint=パスワードは保存されません。
account.login.refresh=再ログイン
account.login.refresh.microsoft.hint=<b>アカウント認証が無効なため、Microsoft アカウントを再度追加する必要があります</b>\n「OK」ボタンをクリックして、新しく開いたブラウザウィンドウでログインプロセスを続行する必要があります。
account.login.refresh.microsoft.manual=<b>アカウント認証が無効なため、Microsoft アカウントを再度追加する必要があります</b>\n「OK」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。
account.logout=ログアウト
account.register=登録
account.manage=アカウントリスト
@@ -97,9 +99,11 @@ account.methods.microsoft.error.add_family=まだ18歳ではないため、Minec
account.methods.microsoft.error.add_family_probably=すでに18歳であるかどうかにかかわらず、アカウント設定を確認してください。そうでない場合は、アカウントを家族に追加する必要があります。
account.methods.microsoft.error.country_unavailable=XBoxLiveはお住まいの国/地域ではご利用いただけません。
account.methods.microsoft.error.missing_xbox_account=MicrosoftアカウントがXboxアカウントに接続されていません。続行する前に作成してください。
account.methods.microsoft.error.no_character=アカウントにMinecraftJavaプロファイルがありません。 Microsoftアカウントは有効ですが、ゲームを所有していません。
account.methods.microsoft.error.no_character=アカウントにMinecraftJavaプロファイルがありません。\nゲームファイルが作成されていない可能性があります\n上記のリンクをクリックして作成してください
account.methods.microsoft.error.unknown=ログインに失敗しました。Microsoftはエラーコード %d で応答します。
account.methods.microsoft.logging_in=ログイン...
account.methods.forgot_password=パスワードをお忘れの方
account.methods.microsoft.makegameidsettings=プロファイルを作成
account.methods.microsoft.hint=「ログイン」ボタンをクリックして、新しく開いたブラウザウィンドウでログインプロセスを続行する必要があります。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。
account.methods.microsoft.manual=「ログイン」ボタンをクリックした後、新しく開いたブラウザウィンドウで認証を完了する必要があります。ブラウザウィンドウが表示されない場合は、ここをクリックしてURLをコピーし、ブラウザで手動で開くことができます。\nMicrosoftアカウントへのログインに使用されたトークンが誤って漏洩した場合は、下の[アカウントのバインドを解除]をクリックして、ログイン認証をキャンセルできます。
account.methods.microsoft.profile=アカウントプロファイル..
@@ -445,7 +449,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=インストールされていません
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=modpackを導入
install.new_game=新規作成
@@ -569,7 +573,7 @@ modpack.files.scripts=MineTweaker構成
modpack.files.servers_dat=サーバーリスト
modpack.install=%s modpackをインストールします
modpack.installing=modpackのインストール
modpack.introduction=Curse、MultiMC、HMCLmodpackがサポートされています。
modpack.introduction= Curse, Modrinth, MultiMC, MCBBS Modpackがサポートされています。
modpack.invalid=無効なmodpackファイル。
modpack.mismatched_type=不適切なmodpackタイプ、現在のゲームは %s modpackですが、更新ファイルは %s modpackです。
modpack.name=Modpack名
@@ -595,7 +599,7 @@ modpack.type.multimc.export=Hello Minecraftでインポートできます!ラ
modpack.type.server=サーバー自動更新Modpack
modpack.type.server.export=サーバーマネージャーがゲームクライアントをリモートで更新できるようにする
modpack.type.server.malformed=不正なサーバーのmodpackマニフェスト
modpack.unsupported=サポートされていないmodpack、HMCL、MultiMC、およびCursemodpackのみがサポートされています。
modpack.unsupported=サポートされていないmodpack、Curse, Modrinth, MultiMC, MCBBSおよびMultiMCのみがサポートされています。
modpack.update=ゲームアップデート
modpack.wizard=modpackウィザードのエクスポート
modpack.wizard.step.1=基本オプション
@@ -665,7 +669,6 @@ multiplayer.exit.after_ready=マルチプレイヤーセッションが壊れて
multiplayer.exit.before_ready=マルチプレイヤーセッションの作成に失敗しました。 cato exitcode %d
multiplayer.exit.timeout=マルチプレイヤーサーバーへの接続に失敗しました。
multiplayer.exit.timeout.dynamic_token=マルチプレイヤーサーバーへの接続に失敗しました。
multiplayer.hint=マルチプレイヤー機能は実験的なものです。フィードバックをお願いします。
multiplayer.nat=ネットワークタイプの検出
multiplayer.nat.failed=ネットワークタイプの検出に失敗しました。
multiplayer.nat.hint=ネットワークタイプの検出により、ネットワークがマルチプレイヤーモードの要件を満たしているかどうかが明確になります。
@@ -682,7 +685,7 @@ multiplayer.nat.type.restricted_cone=Medium(制限付きコーン)
multiplayer.nat.type.symmetric=Bad(対称)
multiplayer.nat.type.symmetric_udp_firewall=Bad(UDPファイアウォールと対称)
multiplayer.nat.type.unknown=不明`
multiplayer.powered_by=マルチプレイヤーサービスは<a href="https://mcer.cn">mcer.cn</a>によって提供されます。 <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">EULA</a>
multiplayer.powered_by=マルチプレイヤーサービスは<a href="https://mcer.cn">mcer.cn</a>によって提供されます。 <a href\="https://docs.hmcl.net/multiplayer/#%E4%BD%BF%E7%94%A8%E6%8E%88%E6%9D%83">Licence/a>
multiplayer.report=レポート
multiplayer.relay=リレーモード
multiplayer.relay.hint=静的トークンを持ち、テストされたNATタイプが不良対称であるユーザーの場合
@@ -60,7 +60,7 @@ account.create.yggdrasil=Добавление аккаунта Mojang
account.create.offline=Добавление офлайн аккаунта
account.create.authlibInjector=Добавление аккаунта authlib-injector
account.email=Электронная почта
account.failed=Код ошибки: %d
account.failed=Авторизация не удалась
account.failed.character_deleted=Персонаж был удалён.
account.failed.connect_authentication_server=Невозможно подключиться к серверу авторизации. Проверьте сетевое подключение.
account.failed.connect_injector_server=Невозможно подключиться к серверу авторизации. Проверьте сетевое подключение и убедитесь, что ссылка указана правильно.
@@ -83,6 +83,8 @@ account.injector.server_name=Имя сервера
account.login=Войти
account.login.hint=Мы не храним ваш пароль.
account.login.refresh=Повторный вход
account.login.refresh.microsoft.hint=<b>Поскольку авторизация учетной записи недействительна, вам необходимо повторно добавить свою учетную запись Microsoft</b>\nВам необходимо нажать кнопку «OK» и продолжить процесс авторизации во вновь открывшемся окне браузера.
account.login.refresh.microsoft.manual=<b>Поскольку авторизация учетной записи недействительна, вам необходимо повторно добавить свою учетную запись Microsoft</b>\nПосле нажатия кнопки «OK», вы должны завершить авторизацию во вновь открывшемся окне браузера. Если окно браузера не открылось, вы можете щёлкнуть здесь, чтобы скопировать ссылку и вручную открыть её в браузере.
account.logout=Выйти
account.register=Регистрация
account.manage=Список аккаунтов
@@ -97,12 +99,14 @@ account.methods.microsoft.error.add_family=Поскольку тебе ещё н
account.methods.microsoft.error.add_family_probably=Проверьте в настройках своего аккаунта, исполнилось ли вам уже 18 лет или нет. Если нет, необходимо добавить семейный аккаунт.
account.methods.microsoft.error.country_unavailable=Xbox Live недоступен в вашей стране/регионе.
account.methods.microsoft.error.missing_xbox_account=Ваш аккаунт Microsoft не подключена к Xbox. Создайте его, прежде чем продолжить.
account.methods.microsoft.error.no_character=На аккаунте отсутствует профиль Minecraft Java. Хотя аккаунт Microsoft действителен, на ней не приобретено игры.
account.methods.microsoft.error.no_character=На аккаунте отсутствует профиль Minecraft Java. \nФайл игры не может быть создан, пожалуйста,\nнажмите на ссылку выше, чтобы создать его
account.methods.microsoft.error.unknown=Не удалось войти. Microsoft отвечает с кодом ошибки %d.
account.methods.microsoft.logging_in=Авторизация...
account.methods.microsoft.makegameidsettings=Создать архив
account.methods.microsoft.hint=Вам необходимо нажать кнопку «Войти» и продолжить процесс авторизации во вновь открывшемся окне браузера.\nЕсли токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию.
account.methods.microsoft.manual=После нажатия кнопки «Войти», вы должны завершить авторизацию во вновь открывшемся окне браузера. Если окно браузера не открылось, вы можете щёлкнуть здесь, чтобы скопировать ссылку и вручную открыть её в браузере.\nЕсли токен, используемый для входа в аккаунт Microsoft случайно утёк, вы можете нажать «Отменить авторизацию аккаунта» ниже, чтобы отменить авторизацию.
account.methods.microsoft.profile=Профиль аккаунта...
account.methods.forgot_password=ЗАБЫЛ ПАРОЛЬ
account.methods.microsoft.snapshot=Лаунчер версии снапшот не поддерживает авторизацию через систему Microsoft.
account.methods.microsoft.waiting_browser=Ожидание авторизации в открытом окне браузера...
account.methods.offline=Офлайн
@@ -447,7 +451,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=Не установлен
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=Установить модпак
install.new_game=Установить новую игру
@@ -573,7 +577,7 @@ modpack.files.scripts=Конфигурация MineTweaker
modpack.files.servers_dat=Список серверов
modpack.install=Установить модпак %s
modpack.installing=Установка модпака
modpack.introduction=Поддерживаются модпаки Curse, MultiMC, HMCL.
modpack.introduction=Поддерживаются модпаки Curse, Modrinth, MultiMC, MCBBS.
modpack.invalid=Неверный файл модпака.
modpack.mismatched_type=Неправильный тип модпака, ваша текущая игра - это модпак %s, но ваш файл обновления - это модпак %s.
modpack.name=Название модпака
@@ -599,7 +603,7 @@ modpack.type.multimc.export=Может быть импортирован Hello M
modpack.type.server=Сервер автообновления модпака
modpack.type.server.export=Разрешить менеджеру сервера удаленно обновлять клиент игры
modpack.type.server.malformed=Некорректный манифест модпака сервера
modpack.unsupported=Неподдерживаемый модпак, поддерживаются только модпаки HMCL, MultiMC и Curse.
modpack.unsupported=Неподдерживаемый модпак, поддерживаются только модпаки Curse, Modrinth, MultiMC, MCBBS.
modpack.update=Обновить игру
modpack.wizard=Мастер экспорта модпака
modpack.wizard.step.1=Основные параметры
@@ -669,23 +673,7 @@ multiplayer.exit.after_ready=Сессия сетевой игры сломана
multiplayer.exit.before_ready=Не удалось создать сессию сетевой игры. Код выхода като %d
multiplayer.exit.timeout.static_token=Не удалось подключиться к серверу сетевой игры. Переключитесь на динамический токен и попробуйте снова.
multiplayer.exit.timeout.dynamic_token=Не удалось подключиться к серверу сетевой игры.
multiplayer.hint=Функция сетевой игры экспериментальная. Оставьте свой отзыв.
multiplayer.nat=Обнаружение типа сети
multiplayer.nat.hint=Определение типа сети позволит понять, соответствует ли ваша сеть нашим требованиям для сетевой игры.
multiplayer.nat.latency=Задержка
multiplayer.nat.not_yet_tested=Ещё не протестировано
multiplayer.nat.packet_loss_ratio=Коэффициент потери пакетов
multiplayer.nat.testing=Тестирование
multiplayer.nat.type=Тип NAT
multiplayer.nat.type.blocked_udp=Очень плохой (заблокированный UDP)
multiplayer.nat.type.full_cone=Хороший (полный конус)
multiplayer.nat.type.open_access=Хороший (открытый доступ)
multiplayer.nat.type.port_restricted_cone=Средний (конус с ограничением порта)
multiplayer.nat.type.restricted_cone=Средний (ограниченный конус)
multiplayer.nat.type.symmetric=Плохой (симметричный)
multiplayer.nat.type.symmetric_udp_firewall=Плохо (симметричный с брандмауэром UDP)
multiplayer.nat.type.unknown=Неизвестно
multiplayer.powered_by=Сетевая игра предоставлена <a href="https://mcer.cn">mcer.cn</a>. <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">EULA</a>
multiplayer.powered_by=Сетевая игра предоставлена <a href="https://mcer.cn">mcer.cn</a>. <a href\="https://docs.hmcl.net/multiplayer/#%E4%BD%BF%E7%94%A8%E6%8E%88%E6%9D%83">Licence</a>
multiplayer.report=Отчёт
multiplayer.relay=Режим реле
multiplayer.relay.hint=Для пользователей, у которых есть статический токен, и чей проверенный тип NAT - плохо-семитричный
@@ -60,7 +60,7 @@ account.create.yggdrasil=添加 Mojang 帳戶
account.create.offline=添加離線模式帳戶
account.create.authlibInjector=添加外置登入帳戶 (authlib-injector)
account.email=電子信箱
account.failed=錯誤碼: %d
account.failed=帳戶刷新失敗!
account.failed.character_deleted=已刪除此角色
account.failed.connect_authentication_server=無法連線至認證伺服器,可能是網路問題,請檢查設備能否正常上網或使用代理服務
account.failed.connect_injector_server=無法連線至認證伺服器,可能是網路故障,請檢查設備能否正常上網、檢查網址是否輸入錯誤或使用代理服務
@@ -70,6 +70,7 @@ account.failed.invalid_password=密碼無效
account.failed.invalid_token=請嘗試登出並重新輸入密碼登入
account.failed.migration=你的帳號需要被遷移至微軟帳號。如果你已經遷移,你需要使用微軟登錄方式登錄遷移後的微軟帳號。
account.failed.no_character=該帳戶沒有角色
account.failed.server_disconnected=無法訪問登入服務器,帳戶信息刷新失敗!\n你可以選擇跳過帳戶刷新(但啓動遊戲後可能會因為帳戶信息過期而導致無法進入開啟正版驗證的服務器)\n或嘗試再次刷新帳戶?
account.failed.server_response_malformed=無法解析認證伺服器回應,可能是伺服器故障
account.failed.wrong_account=登錄了錯誤的帳號
account.hmcl.hint=你需要點擊“登入”按鈕,並在打開的網頁中完成登入
@@ -83,7 +84,11 @@ account.injector.server_url=伺服器位址
account.injector.server_name=伺服器名稱
account.login=登入
account.login.hint=我們不會保存你的密碼
account.login.skip=重新刷新帳戶
account.login.retry=再次刷新帳戶
account.login.refresh=重新登錄
account.login.refresh.microsoft.hint=<b>因為賬戶授權失效,需要重新添加你的微軟賬戶</b>\n你需要按照以下步驟添加:\n \n1.點擊“確定”按鈕\n2.在網頁瀏覽器顯示的網站中輸入 HMCL 顯示的代碼(自動拷貝,直接粘貼即可),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n \n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>
account.login.refresh.microsoft.manual=<b>因為賬戶授權失效,需要重新添加你的微軟賬戶</b>\n你需要按照以下步驟添加:\n \n1.點擊“確定”按鈕\n2.在網頁瀏覽器顯示的網站中輸入該代碼:<b>%1$s</b>(已自動拷貝,請點此再次拷貝),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n \n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n-若網站未能打開,請手動在網頁瀏覽器中打開:%2$s\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>
account.logout=登出
account.register=註冊
account.manage=帳戶列表
@@ -98,12 +103,14 @@ account.methods.microsoft.error.add_family=由於你未滿 18 歲,你的帳戶
account.methods.microsoft.error.add_family_probably=請檢查你的帳戶設置,如果你未滿 18 歲,你的帳戶必須被加入到家庭中才能登錄遊戲。你也可以點擊上方連結更改你的帳戶的出生日期,使年齡滿 18 歲以上以繼續登錄。
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 XBox Live 的支援。
account.methods.microsoft.error.missing_xbox_account=你的微軟帳號尚未關聯 XBox 帳號,你必須先創建 XBox 帳號,才能登入遊戲。
account.methods.microsoft.error.no_character=該帳號沒有包含 Minecraft Java 版購買記錄,
account.methods.microsoft.error.no_character=該帳戶未包含 Minecraft Java 版購買記錄\n可能未創建遊戲檔案,請點擊上方鏈接創建
account.methods.microsoft.error.unknown=登入失敗,錯誤碼:%d
account.methods.microsoft.logging_in=登入中...
account.methods.microsoft.makegameidsettings=創建檔案
account.methods.microsoft.hint=你需要按照以下步驟添加:\n1.點擊“登入”按鈕\n2.在網頁瀏覽器顯示的網站中輸入 HMCL 顯示的代碼(自動拷貝,直接粘貼即可),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可!\n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>\n-若登入微軟賬號的 Token 洩露,可點擊下方“解除帳戶授權”解除登入授權,然後等待 Token 過期
account.methods.microsoft.manual=你需要按照以下步驟添加:\n1.點擊“登入”按鈕\n2.在網頁瀏覽器顯示的網站中輸入該代碼:<b>%1$s</b>(已自動拷貝,請點此再次拷貝),並點擊“下一步”\n3.按照網站的提示登入\n4.當網站顯示“ 是否允許此應用訪問你的信息?”的標識時,請點擊“是”\n5.當網站提示“大功告成”的標示時,及表示登入成功!然後等待賬戶完成添加即可\n-若網站提示“出现错误”的標識,請嘗試重新按照以上步驟登入\n-若賬戶添加失敗,請嘗試重新按照以上步驟登入\n-若網站未能打開,請手動在網頁瀏覽器中打開:%2$s\n<b>-若設備網絡環境不佳,可能登陸網站加載很慢甚至無法加載,此時請使用 VPN 或代理伺服器並重試</b>\n-若登入微軟賬號的 Token 洩露,可點擊下方“解除帳戶授權”解除登入授權,然後等待 Token 過期
account.methods.microsoft.profile=帳戶設置頁
account.methods.forgot_password=忘記密碼
account.methods.microsoft.snapshot=HMCL 快照版暫不提供微軟登入支持,請使用穩定版或測試版進行登入。
account.methods.microsoft.waiting_browser=等待在新打開的瀏覽器窗口中完成登入...
account.methods.offline=離線模式
@@ -148,6 +155,7 @@ assets.index.malformed=資源檔案的索引檔案損壞,您可以在遊戲 [
button.cancel=取消
button.change_source=切換下載源
button.clear=清除
button.copy_and_exit=複製並退出
button.delete=刪除
button.edit=編輯
button.install=安裝
@@ -157,6 +165,7 @@ button.ok=確定
button.refresh=重新整理
button.remove=刪除
button.remove.confirm=您確認要刪除嗎?該操作無法撤銷!
button.retry=重試
button.save=儲存
button.save_as=另存為
button.select_all=全選
@@ -322,12 +331,16 @@ fatal.javafx.incompatible=缺少 JavaFX 運行環境。\nHMCL 無法在低於 Ja
fatal.javafx.incompatible.loongson=缺少 JavaFX 運行環境。\n請使龍芯 JDK 8 (http://www.loongnix.cn/zh/api/java/downloads-jdk8/index.html) 啟動 HMCL。
fatal.javafx.missing=找不到 JavaFX。\n如果您使用的是 Java 11 或更高版本,請降級到 Oracle JRE 8,或者安裝 BellSoft Liberica Full JRE。\n如果您使用的是其他 OpenJDK 构建,請確保其包含 OpenJFX。
fatal.missing_dst_root_ca_x3=目前的 Java 平台缺少 DST Root CA X3 憑證。\n您依然可以使用 Hello Minecraft! Launcher,但會無法連線到部分網站 (如使用 Lets Encrypt 憑證的站台),這可能會使 Hello Minecraft! Launcher 無法正常運作。\n請將您的 Java 升級到 8u101 以上來解決此問題。
fatal.config_change_owner_root=你正在使用 root 帳戶啟動 Hello Minecraft! Launcher,這可能導致你未來無法使用其他帳戶正常啟動 Hello Minecraft! Launcher。\n是否繼續啟動?
fatal.config_loading_failure=Hello Minecraft! Launcher 無法載入設定檔案。\n請確保 Hello Minecraft! Launcher 對 "%s" 目錄及該目錄下的檔案擁有讀寫權限。
fatal.config_loading_failure.unix=Hello Minecraft! Launcher 無法載入設定檔案,因為設定檔案是由用戶 %1$s 創建的。\n請使用 root 帳戶啟動 HMCL (不推薦),或在終端中執行以下命令將設定檔案的所有權變更為當前用戶: \n%2$s
fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即將升級完成,請重新開啟 Hello Minecraft! Launcher。
fatal.apply_update_failure=我們很抱歉 Hello Minecraft! Launcher 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 Hello Minecraft! Launcher 來完成升級。\n請考慮向我們回報該問題。
fatal.samba=如果您正在通過 Samba 共亯的資料夾中運行 HMCL,HMCL 可能無法正常工作,請嘗試更新您的 Java 或在本地資料夾內運行 HMCL。
fatal.illegal_char=您的用戶資料夾路徑中存在非法字元‘=’。HMCL 能够運行,但一些功能無法正常使用。 \n您將無法使用外置登入帳戶以及離線登入更換皮膚功能,並且在進行多人遊戲時可能存在更大的安全隱患。
fatal.unsupported_platform=Minecraft 尚未你您的平臺提供完善支持,所以可能影響遊戲體驗或無法啟動遊戲。\n若無法啟動 Minecraft 1.17 及以上版本,可以嘗試在版本設定中打開“使用 OpenGL 軟渲染器”選項,\n使用 CPU 渲染以獲得更好的相容性。
fatal.unsupported_platform=Minecraft 尚未你您的平臺提供完善支持,所以可能影響遊戲體驗或無法啟動遊戲。\n若無法啟動 Minecraft 1.17 及以上版本,可以嘗試在版本設定中打開“使用 OpenGL 軟渲染器”選項,使用 CPU 渲染以獲得更好的相容性。
fatal.unsupported_platform.osx_arm64=HMCL 已為 Apple Silicon 平臺提供支援,使用 ARM 原生 Java 啟動遊戲以獲得更流暢的遊戲體驗。\n如果你在遊戲中遭遇問題,使用 x86-64 架構的 Java 啟動遊戲可能有更好的相容性。
fatal.unsupported_platform.windows_arm64=HMCL 已為 Windows on Arm 平臺提供原生支持。如果你在遊戲中遭遇問題,請嘗試使用 x86 架構的 Java 啟動遊戲。\n\n如果你正在使用<b>高通</b>平臺,你可能需要安裝 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 相容包</a>後才能進行遊戲。點擊連結前往 Microsoft Store 安裝相容包。
feedback=回饋
feedback.add=新增回饋
@@ -366,7 +379,7 @@ folder.saves=遊戲存檔資料夾
folder.screenshots=截圖資料夾
game=遊戲
game.crash.feedback=查看了日誌發現問題很常見?在回饋頁面提交回饋豐富崩潰分析系統!
game.crash.feedback=查看了日誌發現問題很常見?在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>提交回饋豐富崩潰分析系統!
game.crash.info=遊戲訊息
game.crash.reason=崩潰原因
game.crash.reason.analyzing=分析中...
@@ -406,10 +419,11 @@ game.crash.reason.no_such_method_error=當前遊戲因為代碼不完整,無
game.crash.reason.opengl_not_supported=當前遊戲因為你的顯示卡驅動存在問題,無法繼續運行。\n原因是 OpenGL 不受支援,你現在是否在遠程桌面或者串流模式下?如果是,請直接使用原電腦啟動遊戲。\n或者嘗試升級你的顯示卡驅動到最新版本後再嘗試啟動遊戲。如果你的電腦存在獨立顯示卡,你需要檢查遊戲是否使用集成/核心顯示卡啟動,如果是,請嘗試使用獨立顯示卡啟動 HMCL 與遊戲。如果仍有問題,你可能需要考慮換一個新顯示卡或新電腦。
game.crash.reason.openj9=當前遊戲無法運行在 OpenJ9 虛擬機上,請你在遊戲設置中更換 Hotspot Java 虛擬機,並重新啟動遊戲。如果沒有下載安裝,你可以在網路上自行下載。
game.crash.reason.out_of_memory=當前遊戲因為記憶體不足,無法繼續運行。\n這可能是記憶體分配太小,或者 Mod 數量過多導致的。\n你可以在遊戲設置中調大遊戲記憶體分配值以允許遊戲在更大的記憶體下運行。\n如果仍然出現該錯誤,你可能需要換一台更好的電腦。
game.crash.reason.processing_of_javaagent_failed=當前遊戲因為加載 -javaagent 參數失敗,無法繼續運行。\n如果你在 Java 虛擬機參數 中添加了相關參數,請檢查是否正確。\n如果你沒有添加相關參數或參數確認正確, 請嘗試:\n打開 控制面板 -- 時鐘和區域 分類(選項為類別顯示才有此選項,沒有就跳過)-- 區域 -- 上方的 管理 選項卡 -- 下方的 更改系統區域設置 按鈕 -- 在彈出的視窗中將 “使用Unicode UTF-8提供全球語言支持” 選項關閉,重啓設備後再嘗試啟動遊戲。\n可在<a href="https://discord.gg/jVvC7HfM6U">Discord</a>或<a href="https://kook.top/Kx7n3t">KOOK群</a>尋求幫助
game.crash.reason.resolution_too_high=當前遊戲因為材質包解析度過高,無法繼續運行\n你可以更換一個解析度更低的材質,或者更換一個視訊記憶體更大的顯示卡。
game.crash.reason.stacktrace=原因未知,請點擊日誌按鈕查看詳細訊息。\n下面是一些關鍵字,其中可能包含 Mod 名稱,你可以透過搜索的方式查找有關訊息。\n%s
game.crash.reason.too_old_java=當前遊戲因為 Java 虛擬機版本過低,無法繼續運行。\n你需要在遊戲設置中更換 %1$s 或更新版本的 Java 虛擬機,並重新啟動遊戲。如果沒有下載安裝,你可以點擊 <a href="https://docs.microsoft.com/zh-TW/java/openjdk/download">此處</a> 下載微軟 JDK。
game.crash.reason.unknown=原因未知,請點擊日誌按鈕查看詳細訊息。\n<b>不要將本界面截圖給他人!</b>如果你要求助他人,請你點擊左下角導出遊戲崩潰訊息後將導出的文件發送給他人以供分析!\n你可以向我們提供遊戲崩潰日誌以提升 HMCL 的日誌分析能力。
game.crash.reason.unknown=原因未知,請點擊日誌按鈕查看詳細訊息。\n<b>不要將本界面截圖給他人!</b>如果你要求助他人,請你點擊左下角導出遊戲崩潰訊息後將導出的文件發送給他人以供分析!
game.crash.reason.unsatisfied_link_error=當前遊戲因為缺少本地庫,無法繼續運行。\n這些本地庫缺失:%1$s。\n如果你在遊戲設置中修改了本地庫路徑選項,請你修改回預設模式。\n如果已經在預設模式下,請檢查本地庫缺失是否是 Mod 引起的,或由 HMCL 引起的。如果你確定是 HMCL 引起的,建議你向我們回饋。\n如果你確實需要自訂本地庫路徑,你需要保證其中包含缺失的本地庫。
game.crash.title=遊戲意外退出
game.directory=遊戲路徑
@@ -455,7 +469,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=不安裝
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=安裝模組包
install.new_game=安裝新遊戲版本
@@ -582,7 +596,7 @@ modpack.files.scripts=MineTweaker 設定
modpack.files.servers_dat=多人遊戲伺服器列表
modpack.install=安裝 %s 模組包
modpack.installing=正在安裝模組包
modpack.introduction=支援 Curse、MultiMC、HMCL 模組包。
modpack.introduction=支援 Curse、Modrinth、MultiMC、MCBBS 模組包。
modpack.invalid=無效的模組包升級檔案,可能是下載時出現問題。
modpack.mismatched_type=模組包類型不符,目前遊戲是 %s 模組包,但是提供的模組包更新檔案是 %s 模組包。
modpack.name=模組包名稱
@@ -593,7 +607,7 @@ modpack.origin.mcbbs=MCBBS
modpack.origin.mcbbs.prompt=貼子 id
modpack.scan=解析模組包
modpack.task.install=匯入模組包
modpack.task.install.error=無法識別該模組包,目前僅支援匯入 Curse、Modrinth、MultiMC、HMCL 模組包。
modpack.task.install.error=無法識別該模組包,目前僅支援匯入 Curse、Modrinth、MultiMC、MCBBS 模組包。
modpack.task.install.will=將會安裝模組包:
modpack.type.curse=Curse
modpack.type.curse.tolerable_error=但未能完成該模組包檔案的下載,您可以在啟動該遊戲版本時繼續該模組包檔案的下載。由於網路問題,您可能需要重試多次……
@@ -608,7 +622,7 @@ modpack.type.multimc.export=可以被 Hello Minecraft! Launcher (HMCL) 和 Multi
modpack.type.server=伺服器自動更新模組包
modpack.type.server.export=允許伺服器管理員遠端更新遊戲用戶端
modpack.type.server.malformed=伺服器模組包配置格式錯誤,請聯絡伺服器管理員解決此問題
modpack.unsupported=不支援該模組包。僅 HMCL、MultiMC、Curse 模組包受支援。
modpack.unsupported=不支援該模組包。僅 Curse、Modrinth、MultiMC、MCBBS 模組包受支援。
modpack.update=正在升級模組包
modpack.wizard=匯出模組包引導
modpack.wizard.step.1=基本設定
@@ -680,87 +694,66 @@ mods.restore=回退
mods.url=官方頁面
multiplayer=多人聯機
multiplayer.agreement.prompt=使用多人聯機功能前,你需要先同意多人聯機服務提供方 ioi 系列作品的用戶協議與免責聲明。\n用戶使用聯機服務的行為,將被視為無條件同意該用戶協議與免責聲明。
multiplayer.agreement.prompt=多人聯機功能由 速聚 提供。使用前,你需要先同意多人聯機服務提供方 速聚 的用戶協議與免責聲明。\n你需要了解,HMCL 僅為 速聚 提供多人聯機服務入口,使用中遇到的任何問題由 速聚 負責處理。您在使用多人聯機服務過程中所遇到的任何問題與糾紛(包括其付費業務)均與 HMCL 無關,應與 速聚 協商解決。
multiplayer.download=正在下載相依元件
multiplayer.download.failed=初始化失敗,部分檔案未能完成下載
multiplayer.download.success=多人聯機初始化完成
multiplayer.download.unsupported=多人聯機依賴不支持當前系統或平台
multiplayer.exit.after_ready=多人聯機會話意外退出,退出碼 %d
multiplayer.exit.before_ready=多人聯機房間創建失敗,cato 退出碼 %d
multiplayer.exit.timeout.static_token=無法連接多人聯機服務,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作,或者耐心等待十分鍾。
multiplayer.exit.timeout.dynamic_token=無法連接多人聯機服務,你可以在多人聯機頁面的回饋中回饋問題。
multiplayer.hint=多人聯機功能處於實驗階段,如果有問題請回饋。
multiplayer.nat=網路檢測
multiplayer.nat.failed=檢測失敗,但你仍然可以繼續使用聯機功能。
multiplayer.nat.hint=執行網路檢測可以讓你更清楚你的網路狀況是否符合聯機功能的需求。不符合聯機功能運行條件的網路狀況將可能導致聯機失敗。
multiplayer.nat.latency=延遲
multiplayer.nat.not_yet_tested=尚未檢測
multiplayer.nat.packet_loss_ratio=丟包率
multiplayer.nat.testing=檢測中
multiplayer.nat.type=NAT 類型
multiplayer.nat.type.blocked_udp=極差(網路禁止 UDP 協議)
multiplayer.nat.type.full_cone=好(完全圓錐型)
multiplayer.nat.type.open_access=好(公網開放型)
multiplayer.nat.type.port_restricted_cone=中(埠受限圓錐型)
multiplayer.nat.type.restricted_cone=中(受限圓錐型)
multiplayer.nat.type.symmetric=差(對稱型)
multiplayer.nat.type.symmetric_udp_firewall=差(對稱型+防火牆)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人聯機服務由(<a href="https://mcer.cn">mcer.cn</a>) 提供。<a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用戶協議與免責聲明</a>
multiplayer.error.failed_get_device=HiPer無法創建網路設備,可能是 HiPer 正在運行中。請關閉正在運行中的其他啟動器,或通過工作管理員終止 HiPer。
multiplayer.error.failed_load_config=HiPer 初始化失敗,可能是配置文件存在問題或證書已失效。
multiplayer.error.failed_sudo.linux=HiPer 需要管理員許可權才能運行,您可以通過設置環境變量 HMCL_ASKPASS 配置 HiPer 如何申請權限。\n若將 HMCL_ASKPASS 設置為 'false',請通過配置 sudoers 文件允許 '%s' 免密獲取 root 權限,重啟後再次啟動 HMCL 即可;\n若將 HMCL_ASKPASS 設置為 'user',請先設置環境變量 SUDO_ASKPASS,HMCL 將使用 sudo --askpass 啟動 HiPer (具體請參見 man sudo)。
multiplayer.error.failed_sudo.mac=HiPer 需要管理員許可權才能運行。是否要授予 HiPer 管理員許可權? \n點擊“是”授予 HiPer 管理員許可權,然後重啓電腦後再次打開啟動器即可聯機。
multiplayer.error.failed_sudo.windows=HiPer 需要管理員權限才能運行,請使用管理員權限重新啟動 HMCL。是否查看教程?
multiplayer.error.file_not_found=已發現 HiPer 有可用的更新,請重新進入多人聯機頁面以更新 HiPer。
multiplayer.error.session_expired=本次使用時間已結束,若要繼續使用,請更新索引碼
multiplayer.exit=HiPer 意外退出,退出碼 %d
multiplayer.help.1=管理員權限打開
multiplayer.help.2=多人聯機教程
multiplayer.help.3=創建方幫助
multiplayer.help.4=參與者幫助
multiplayer.help.text=測試能否聯機
multiplayer.hint=多人聯機服務正在維護中。
multiplayer.hint.details=查看詳情
multiplayer.persistence=授權信息管理
multiplayer.persistence.export=將授權信息導出至文件
multiplayer.persistence.export.button=導出授權文件
multiplayer.persistence.export.failed=導出授權文件失敗,請檢查您所選擇的保存路徑。
multiplayer.persistence.export.file_not_exists=獲取授權文件失敗,請檢查索引碼是否輸入正確。
multiplayer.persistence.export.title=選擇授權文件保存路徑
multiplayer.persistence.hint=此功能為長期授權 (有效期超過 24 小時) 用戶提供,如果你正在使用短期授權 (有效期不超過 24 小時),則無需使用本功能。\n當你使用長期授權時,請在獲取授權後的 24 小時內使用索引碼啟動 HiPer,否則 HiPer 將無法獲取授權信息。\n隨後,你可以通過下方的“導出授權信息”功能保存授權文件。\n請注意妥善保管授權文件,在獲取授權 24 小時後,該文件是你的長期授權的唯一憑證,丟失該文件將無法通過索引碼進行聯機。
multiplayer.persistence.import=從文件導入授權信息
multiplayer.persistence.import.button=導入授權文件
multiplayer.persistence.import.failed=導入授權文件失敗。
multiplayer.persistence.import.file_already_exists=授權文件已經存在。
multiplayer.persistence.import.title=選擇授權文件
multiplayer.persistence.import.token_not_match=你選擇的授權文件可能與索引碼不匹配,是否繼續導入?
multiplayer.persistence.license_file=授權文件
multiplayer.powered_by=本服務由 (<a href="https://mcer.cn">速聚</a>) 提供。<a href\="https://docs.hmcl.net/multiplayer/#%E4%BD%BF%E7%94%A8%E6%8E%88%E6%9D%83">授權協議</a>
multiplayer.report=違法違規檢舉
multiplayer.relay=橋接服務
multiplayer.relay.hint=Cato 面向較差網路提供的網路質量增強最佳化解決方案|若無法聯機請到 mcer.cn 的個人中心兌換憑證後啟用
multiplayer.session=房間
multiplayer.session.name.format=%1$s 的房間
multiplayer.session.name.motd=HMCL 多人聯機房間 - %s
multiplayer.session.close=關閉房間
multiplayer.session.close.warning=關閉房間後,已經加入聯機房間的玩家將會斷開連接,是否繼續?
multiplayer.session.copy_room_code=複製邀請碼
multiplayer.session.create=創建房間
multiplayer.session.create.error.dynamic_token=創建聯機房間失敗,請稍後再試。
multiplayer.session.create.error.static_token=創建聯機房間失敗,你的憑證可能無法正常工作,你可以清空憑證再試。\n你可以聯系在線管理員請問鑑權伺服器是否正常工作,或者耐心等待十分鍾。
multiplayer.session.create.hint=創建聯機房間前,你需要先在正在運行的遊戲內的遊戲菜單中選擇 對區域網路開放 選項,然後在下方的輸入框中輸入遊戲內提示的埠號(通常是 5 位的數字)
multiplayer.session.create.join=連接申請
multiplayer.session.create.join.allow=自動接受所有連接申請(不啟用此選項時,你需要手動同意申請,以避免不相關人士誤連你的伺服器)
multiplayer.session.create.join.prompt=玩家 %s 申請加入多人聯機房間,是否接受?
multiplayer.session.create.members=成員
multiplayer.session.create.members.kick=踢出房間
multiplayer.session.create.members.kick.prompt=是否踢出該玩家?踢出後該玩家不能再參與該聯機房間。
multiplayer.session.create.name=房間名稱
multiplayer.session.create.port=埠號
multiplayer.session.create.port.error=無法檢測遊戲埠號,你必須先啟動遊戲並在遊戲內打開對區域網路開放選項後才能啟動聯機。
multiplayer.session.create.token=憑證
multiplayer.session.create.token.apply=申請憑證
multiplayer.session.create.token.prompt=預設為無憑證。你可以點擊旁邊的“申請憑證”獲得詳情
multiplayer.session.error.already_started=本地已經開啟 cato 服務,請檢查是否有其他 HMCL 正在運行聯機服務。或者你可以在任務管理器裡殺掉 cato 進程以繼續。
multiplayer.session.error.file_not_found=找不到 cato 程序。該程序應該在進入多人聯機頁面時完成下載。請檢查你電腦的防毒軟體是否將 cato 標記為病毒,如果是,請恢復 cato。
multiplayer.session.expired=聯機會話連續使用時間超過了 3 小時,你需要重新創建/加入房間以繼續聯機。
multiplayer.session.join=加入房間
multiplayer.session.join.error=加入房間失敗
multiplayer.session.join.error.connection=加入房間失敗。無法與對方建立連接。如果你或對方的網路類型是差(對稱型),可能無法使用聯機功能。
multiplayer.session.join.hint=你需要向已經創建好房間的玩家索要邀請碼以便加入多人聯機房間
multiplayer.session.join.invitation_code=邀請碼
multiplayer.session.join.invitation_code.error=邀請碼不正確,請向開服玩家獲取邀請碼
multiplayer.session.join.kicked=你與房間失去連接:%s。
multiplayer.session.join.kicked.join_acceptance_timeout=對方未能即時同意你的加入申請
multiplayer.session.join.kicked.kicked=你被房主踢出房間。
multiplayer.session.join.kicked.version_not_matched=多人聯機功能版本號不一致,請保證連接多人聯機功能版本號一致。
multiplayer.session.join.lost_connection=你已與房間失去連接。這可能意味著房主已經解散房間,或者你無法連接至房間。
multiplayer.session.join.port.error=無法找到可用的本地網路埠,請確保 HMCL 擁有綁定本地埠的權限。
multiplayer.session.join.rejected=你被房主拒絕連接。
multiplayer.session.join.wait=等待對方同意加入申請。
multiplayer.session.members=房間成員
multiplayer.session.quit=退出房間
multiplayer.session.quit.warning=退出房間後,你將會與伺服器斷開連接,是否繼續?
multiplayer.session.username=使用者名稱
multiplayer.state.connecting=連接中
multiplayer.state.disconnected=未創建/加入房間
multiplayer.state.disconnected.hint=多人聯機功能需要先有一位玩家創建房間後,其他玩家加入房間後繼續遊戲。
multiplayer.state.master=你已創建房間:%1$s,埠號 %2$d
multiplayer.state.master.hint=創建房間後,你可以點擊複製邀請碼,並發送給你希望加入聯機的玩家。這些玩家在進入 HMCL 多人聯機頁面後,點擊加入房間並黏貼邀請碼即可加入聯機房間。
multiplayer.state.slave=你已加入房間: %1$s,位址為 %2$s
multiplayer.state.slave.copy=拷貝位址
multiplayer.state.slave.hint=加入房間後,你需要在 Minecraft 的多人遊戲頁面選擇 HMCL 多人聯機房間伺服器,或者手動添加下方的地址的伺服器。
multiplayer.token=索引碼
multiplayer.token.apply=申請索引碼
multiplayer.token.format_invalid=索引碼格式不正確
multiplayer.token.invalid=索引碼不正確。
multiplayer.token.expired=HiPer 證書過期,請重新啟動 HMCL 再試。
multiplayer.token.malformed=HiPer 配置檔案無法解析,請重新啟動 HMCL 再試。
multiplayer.token.prompt=你需要索引碼才能使用多人聯機服務。點擊旁邊的“申請索引碼”查看詳情
multiplayer.off.hint=由於需要注册系統網路服務許可權,啟動 HiPer 時將會向用戶申請管理員許可權。若出現認證提示,允許以啟動 HiPer。
multiplayer.off.start=啟動 HiPer
multiplayer.master=创建方提示
multiplayer.master.hint=1.啟動遊戲\n2.選擇單人遊戲模式進入一個存檔\n3.在遊戲選項菜單內選擇 對局域網開放 選項,之後你可以在遊戲聊天框內看到遊戲提示的端口號(通常是 4~5 位數字)\n4.點擊下方的生成伺服器地址按鈕,輸入端口號,你就可以獲得你的伺服器地址了\n5.該地址需要提供給其他需要加入伺服器的玩家用於添加伺服器。
multiplayer.master.server_address=生成伺服器地址
multiplayer.master.server_address.copy=拷貝
multiplayer.master.video_tutorial=教學影片
multiplayer.master.port=埠號
multiplayer.master.port.validate=在遊戲聊天框中出現的埠號 (0~65535)
multiplayer.slave=參與者提示
multiplayer.slave.hint=1.要求創建方按照上方的 創建方提示 操作\n2.啟動遊戲\n3.選擇多人遊戲模式,選擇添加服務器\n4.遊戲會要求你輸入服務器地址,你只需要向創建方索要服務器地址並輸入,並進入服務器即可。
multiplayer.slave.hint2=1.一般情況下,參與者的遊戲賬戶必須是 微軟賬戶 或 外置登錄賬戶(如 Little Skin),否則你需要將服務器地址輸入至下方的輸入框中並點擊“加入”,在遊戲中選擇多人遊戲模式,進入局域網世界方可加入\n2.一般情況下,參與者的遊戲版本、模組要必須與創建方的一致,否則加入失敗。
multiplayer.slave.server_address=創建方伺服器地址
multiplayer.slave.server_address.start=加入
multiplayer.slave.server_address.stop=退出
multiplayer.slave.video_tutorial=教學影片
multiplayer.session.expiration=索引碼到期時間
datapack=資料包
datapack.add=加入資料包
@@ -799,7 +792,7 @@ world.info.generate_features=生成建築
world.info.player=玩家資訊
world.info.player.food_level=饑餓值
world.info.player.game_type=遊戲模式
world.info.player.game_type.adventure=極限
world.info.player.game_type.adventure=冒險
world.info.player.game_type.creative=創造
world.info.player.game_type.spectator=旁觀
world.info.player.game_type.survival=生存
@@ -996,13 +989,13 @@ update.changelog=更新日誌
update.channel.dev=測試版
update.channel.dev.hint=你正在使用測試版。測試版包含一些未在正式版中包含的測試性功能,僅用於體驗新功能。\n\
測試版功能未受充分驗證,使用起來可能不穩定!\n\
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK</a>群以回饋問題。\n\n\
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>以回饋問題。\n\n\
你需要 <a href="https://hmcl.huangyuhui.net/api/redirect/sponsor">贊助</a> 或在回饋過程中作出貢獻以隱藏本提示。
update.channel.dev.title=測試版提示
update.channel.nightly=預覽版
update.channel.nightly.hint=你正在使用預覽版。預覽版可能會每天更新一次,包含一些未在正式版和測試版中包含的測試性功能,僅用於體驗新功能\n\
測試版功能未受充分驗證,使用起來可能不穩定!\n\
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK</a>群以回饋問題。\n\n\
如果你遇到了使用問題,可以在設置的 <a href="hmcl://settings/feedback">回饋頁面</a> 中進行回饋,或加入回饋頁面中提供的 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>以回饋問題。\n\n\
你需要 <a href="https://hmcl.huangyuhui.net/api/redirect/sponsor">贊助</a> 或在回饋過程中作出貢獻以隱藏本提示。
update.channel.nightly.title=預覽版提示
update.channel.stable=建議版本
@@ -60,7 +60,7 @@ account.create.yggdrasil=添加 Mojang 帐户
account.create.offline=添加离线模式帐户
account.create.authlibInjector=添加外置登录帐户 (authlib-injector)
account.email=邮箱
account.failed=错误码: %d
account.failed=账户刷新失败!
account.failed.character_deleted=此角色已被删除
account.failed.connect_authentication_server=无法连接认证服务器,可能是网络问题,请检查设备能否正常上网或使用代理服务
account.failed.connect_injector_server=无法连接认证服务器,可能是网络问题,请检查设备是否能正常上网,检查 URL 是否输入错误,或使用代理服务
@@ -70,6 +70,7 @@ account.failed.invalid_password=无效的密码
account.failed.invalid_token=请尝试登出并重新输入密码登录
account.failed.migration=你的帐号需要被迁移至微软帐号。如果你已经迁移,你需要使用微软登录方式登录迁移后的微软帐号。
account.failed.no_character=该帐号没有角色
account.failed.server_disconnected=无法访问登录服务器,账户信息刷新失败!\n你可以选择 跳过账户刷新(但启动游戏后可能会因为账户信息过期而导致无法进入开启正版验证的服务器)\n或尝试再次刷新账户?
account.failed.server_response_malformed=无法解析认证服务器响应,可能是服务器故障
account.failed.wrong_account=登录了错误的帐号
account.hmcl.hint=你需要点击“登录”按钮,并在打开的网页中完成登录
@@ -83,7 +84,11 @@ account.injector.server_url=服务器地址
account.injector.server_name=服务器名称
account.login=登录
account.login.hint=我们不会保存你的密码
account.login.skip=跳过账户刷新
account.login.retry=再次刷新账户
account.login.refresh=重新登录
account.login.refresh.microsoft.hint=<b>因为账户授权失效,需要重新添加你的微软账户</b>\n你需要按照以下步骤添加:\n \n1.点击“确定”按钮\n2.在网页浏览器显示的网站中输入 HMCL 显示的代码(自动拷贝,直接粘贴即可),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n \n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
account.login.refresh.microsoft.manual=<b>因为账户授权失效,需要重新添加你的微软账户</b>\n你需要按照以下步骤添加:\n \n1.点击“确定”按钮\n2.在网页浏览器显示的网站中输入:<b>%1$s</b>(已自动拷贝,点此再次拷贝),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n \n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n-若网站未能显示,请手动在网页浏览器中打开:%2$s\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
account.logout=登出
account.register=注册
account.manage=帐户列表
@@ -98,13 +103,15 @@ account.methods.microsoft.error.add_family=由于你未满 18 岁,你的帐户
account.methods.microsoft.error.add_family_probably=请检查你的帐户设置,如果年龄未满 18 岁,你的帐户必须被加入到家庭中才能登录游戏。你也可以点击上方链接更改你的帐户的出生日期,使年龄满 18 岁以上以继续登录。
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 XBox Live 的支持。
account.methods.microsoft.error.missing_xbox_account=你的微软帐户尚未关联 XBox 帐户,你必须先创建 XBox 帐户,才能登录游戏。
account.methods.microsoft.error.no_character=该帐户没有包含 Minecraft Java 版购买记录
account.methods.microsoft.error.no_character=该帐户未包含 Minecraft Java 版购买记录\n可能未创建游戏档案,请点击上方链接创建
account.methods.microsoft.error.unknown=登录失败,错误码:%d
account.methods.microsoft.logging_in=登录中……
account.methods.microsoft.makegameidsettings=创建档案
account.methods.microsoft.hint=你需要按照以下步骤添加:\n1.点击“登录”按钮\n2.在网页浏览器显示的网站中输入 HMCL 显示的代码(自动拷贝,直接粘贴即可),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可!\n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
account.methods.microsoft.manual=你需要按照以下步骤添加:\n1.点击“登录”按钮\n2.在网页浏览器显示的网站中输入:<b>%1$s</b>(已自动拷贝,点此再次拷贝),并点击“下一步”\n3.按照网站的提示登录\n4.当网站提示“是否允许此应用访问你的信息?”的标识时,请点击“是”\n5.当网站提示“大功告成”的标识时,及表示登录成功!然后等待账户完成添加即可\n-若网站提示“出现错误”的标识时,请重新按照以上步骤重新添加\n-若账户添加失败,请重新按照以上步骤重新添加\n-若网站未能显示,请手动在网页浏览器中打开:%2$s\n<b>-若设备网络环境不佳,可能登录网站加载很慢甚至无法加载,此时请使用 VPN 或代理服务器并重试</b>\n-若登录微软账户的 Token (不是密码)泄露,可点击下方“解除账户绑定”解除登录授权,然后等待 Token 过期
account.methods.microsoft.profile=帐户设置页
account.methods.microsoft.snapshot=HMCL 快照版暂不提供微软登录支持,请使用稳定版或测试版进行登录。
account.methods.forgot_password=忘记密码
account.methods.microsoft.snapshot=HMCL 快照版暂不提供微软登录支持,请使用推荐版本(稳定版)或测试版进行登录!
account.methods.microsoft.waiting_browser=等待在新打开的浏览器窗口中完成登录……
account.methods.offline=离线模式
account.methods.offline.uuid=UUID
@@ -148,6 +155,7 @@ assets.index.malformed=资源文件的索引文件损坏,您可以在相应的
button.cancel=取消
button.change_source=切换下载源
button.clear=清除
button.copy_and_exit=复制并退出
button.delete=删除
button.edit=修改
button.install=安装
@@ -157,6 +165,7 @@ button.ok=确定
button.refresh=刷新
button.remove=删除
button.remove.confirm=您确定要删除吗?此操作无法撤销!
button.retry=重试
button.save=保存
button.save_as=另存为
button.select_all=全选
@@ -322,12 +331,16 @@ fatal.javafx.incompatible=缺少 JavaFX 运行环境。\nHMCL 无法在低于 Ja
fatal.javafx.incompatible.loongson=缺少 JavaFX 运行环境。\n请使用龙芯 JDK 8 (http://www.loongnix.cn/zh/api/java/downloads-jdk8/index.html) 启动 HMCL。
fatal.javafx.missing=缺少 JavaFX 运行环境。\n如果您使用的是 Java 11 或更高版本,请降级到 Oracle JRE 8(java.com),或者安装 BellSoft Liberica Full JRE(bell-sw.com/pages/downloads/?package=jre-full)。\n如果您使用的是其他 OpenJDK 构建,请确保其包含 OpenJFX!
fatal.missing_dst_root_ca_x3=当前 Java 平台缺少 DST Root CA X3 证书。\n您依然可以使用 Hello Minecraft! Launcher,但将无法连接到部分站点(如使用 Lets Encrypt 证书的站点),这可能会使 HMCL 无法正常工作。\n请将您的 Java 升级到 8u101 以上以解决此问题。
fatal.config_loading_failure=Hello Minecraft! Launcher 无法加载配置文件\n请确保 Hello Minecraft! Launcher 对 "%s" 目录及该目录下的文件拥有读写权限!
fatal.config_change_owner_root=你正在使用 root 账户启动 Hello Minecraft! Launcher, 这可能导致你未来无法正常使用其他账户正常启动 Hello Minecraft! Launcher。\n是否继续启动?
fatal.config_loading_failure=Hello Minecraft! Launcher 无法加载配置文件\n请确保 Hello Minecraft! Launcher 对 "%s" 目录及该目录下的文件拥有读写权限。
fatal.config_loading_failure.unix=Hello Minecraft! Launcher 无法加载配置文件,因为配置文件是由用户 %1$s 创建的。\n请使用 root 账户启动 HMCL (不推荐),或在终端中执行以下命令将配置文件的所有权变更为当前用户:\n%2$s
fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即将完成升级,请重新打开 Hello Minecraft! Launcher。
fatal.apply_update_failure=我们很抱歉 Hello Minecraft! Launcher 无法自动完成升级,因为出现了一些问题……\n但你依可以从 %s 处手动下载 Hello Minecraft! Launcher 来完成升级\n请考虑向我们反馈该问题!
fatal.samba=如果您正在通过 Samba 共享的文件夹中运行 HMCL,HMCL 可能无法正常工作,请尝试更新您的 Java 或在本地文件夹内运行 HMCL。
fatal.illegal_char=您的用户文件夹路径中存在非法字符‘=’。HMCL 能够运行,但可能某些功能无法正常使用!\n你将无法使用外置登录账户以及离线登录更换皮肤功能,并且在进行多人游戏时可能存在更大的安全隐患!
fatal.unsupported_platform=Minecraft 尚未为您的平台提供完善支持,所以可能影响游戏体验或无法启动游戏。\n若无法启动 Minecraft 1.17 及以上版本,可以尝试在版本设置中打开“使用 OpenGL 软渲染器”选项,\n使用 CPU 渲染以获得更好的兼容性。
fatal.unsupported_platform=Minecraft 尚未为您的平台提供完善支持,所以可能影响游戏体验或无法启动游戏。\n若无法启动 Minecraft 1.17 及以上版本,可以尝试在版本设置中打开“使用 OpenGL 软渲染器”选项,使用 CPU 渲染以获得更好的兼容性。
fatal.unsupported_platform.osx_arm64=HMCL 已为 Apple Silicon 平台提供支持,使用 ARM 原生 Java 启动游戏以获得更流畅的游戏体验。\n如果你在游戏中遭遇问题,使用 x86-64 架构的 Java 启动游戏可能有更好的兼容性。
fatal.unsupported_platform.windows_arm64=HMCL 已为 Windows on Arm 平台提供原生支持。如果你在游戏中遭遇问题,请尝试使用 x86 架构的 Java 启动游戏。\n\n如果你正在使用<b>高通</b>平台,你可能需要安装 <a href="ms-windows-store://pdp/?productid=9NQPSL29BFFF">OpenGL 兼容包</a>后才能进行游戏。点击链接前往 Microsoft Store 安装兼容包。
feedback=反馈
feedback.add=新增反馈
@@ -366,11 +379,11 @@ folder.saves=存档文件夹
folder.screenshots=截图文件夹
game=游戏
game.crash.feedback=查看了日志发现该问题很常见?在反馈页面或 Github Issues 提交反馈,可丰富崩溃分析系统!
game.crash.feedback=查看了日志发现该问题很常见?在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>提交反馈,以丰富崩溃分析系统!
game.crash.info=游戏信息
game.crash.reason=崩溃原因
game.crash.reason.analyzing=分析中……
game.crash.reason.block=当前游戏因为某个方块不能正常工作,无法继续运行。\n你可以尝试通过 MCEdit(podshot.github.io/MCEdit-Unified)工具编辑存档删除该方块,或者直接删除相应的 Mod。\n方块类型:%1$s\n方块坐标:%2$s
game.crash.reason.block=当前游戏因为某个方块不能正常工作,无法继续运行。\n你可以尝试通过 <a href="https://podshot.github.io/MCEdit-Unified">MCEdit</a> 工具编辑存档删除该方块,或者直接删除相应的 Mod。\n方块类型:%1$s\n方块坐标:%2$s
game.crash.reason.bootstrap_failed=当前游戏因为模组 %1$s 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
game.crash.reason.config=当前游戏因为无法解析模组配置文件,无法继续运行\n模组 %1$s 的配置文件 %2$s 无法被解析。
game.crash.reason.debug_crash=当前游戏因为手动触发崩溃,无法继续运行。\n事实上游戏并没有问题,问题都是你造成的!
@@ -385,10 +398,10 @@ game.crash.reason.graphics_driver=当前游戏因为你的显卡驱动存在问
如果你的电脑存在独立显卡,你需要检查游戏是否使用集成/核芯显卡启动。如果是,请尝试使用独立显卡启动 HMCL 与游戏。如果仍有问题,你可能需要考虑换一个新显卡或新电脑……\n\
如果你确实需要使用核芯显卡,请检查你的电脑的 CPU 是否是 Intel(R) Core(TM) 3000 系列或更旧的处理器,如果是,对于 Minecraft 1.16.5 及更旧版本,请你将游戏所使用的 Java 版本降级至 1.8.0_51 及以下版本 <a href="https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html">Java 1.8.0 历史版本</a> ,否则你需要更换独立显卡或新电脑。\n\
在版本设置中打开“使用 OpenGL 软渲染器”选项也可以解决此问题,但打开此选项后在 CPU 性能不足的情况下帧数会显著降低,仅推荐以调试为目的或应急时开启。\n\
详情请点击 <a href="https://hmcl.huangyuhui.net/help/game/graphics-card">此处</a> 查看
可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
game.crash.reason.illegal_access_error=当前游戏因为某些 Mod 的问题,无法继续运行。\n如果你认识:%1$s,你可以更新或删除对应 Mod 再试……
game.crash.reason.jdk_9=当前游戏因为 Java 版本过高,无法继续运行。\n你需要下载安装 Java 8,并在游戏设置中将 Java 设置为 1.8 的版本。
game.crash.reason.jvm_32bit=当前游戏因为内存分配过大,超过了 32 位 Java 内存限制,无法继续运行。\n如果你的电脑是 64 位系统,请下载安装并更换 64 位 Java。如果你的电脑是 32 位系统,你或许可以重新安装 64 位系统,或换一台新电脑。\n或者,你可以关闭游戏内存的自动分配,并且把内存限制调节为 1024 MB 或以下。
game.crash.reason.jvm_32bit=当前游戏因为内存分配过大,超过了 32 位 Java 内存限制,无法继续运行。\n如果你的电脑是 64 位系统,请下载安装并更换 64 位 Java。如果你的电脑是 32 位系统,你或许可以重新安装 64 位系统,或换一台新电脑。\n或者,你可以关闭游戏内存的自动分配,并且把内存限制调节为 1024 MB 或以下。可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
game.crash.reason.loading_crashed_forge=当前游戏因为模组 %1$s (%2$s) 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
game.crash.reason.loading_crashed_fabric=当前游戏因为模组 %1$s 错误,无法继续运行。\n你可以尝试删除或更新该模组以解决问题。
game.crash.reason.memory_exceeded=当前游戏因为分配的内存过大,无法继续运行。\n该问题是由于系统页面文件太小导致的。\n你需要在游戏设置中关闭游戏内存的自动分配,并将游戏内存调低至游戏能正常启动为止。\n你还可以尝试调大系统的页面大小。
@@ -400,16 +413,17 @@ game.crash.reason.mod_resolution_missing=当前游戏因为缺少 Mod 前置,
game.crash.reason.mod_resolution_missing_minecraft=当前游戏因为 Mod 和 Minecraft 游戏版本不匹配,无法继续运行。\n%1$s 需要 Minecraft %2$s 才能运行。\n如果你要继续使用你已经安装的 Mod,你可以选择安装对应的 Minecraft 版本;如果你要继续使用当前 Minecraft 版本,你需要安装对应版本的 Mod。
game.crash.reason.mod_resolution_mod_version=%1$s (版本号 %2$s)
game.crash.reason.mod_resolution_mod_version.any=%1$s (任意版本)
game.crash.reason.modlauncher_8=当前游戏因为您所使用的 Forge 版本与当前使用的 Java 冲突崩溃。请尝试更新 Forge,或使用 Java 8u312/11.0.13/17.0.1 及更早版本启动\n8u312 及更早版本下载:\noracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html\n11.0.13 及更早版本下载:\noracle.com/java/technologies/javase/jdk11-archive-downloads.html\n17.0.1 及更早版本下载:\noracle.com/java/technologies/javase/jdk17-archive-downloads.html
game.crash.reason.modlauncher_8=当前游戏因为您所使用的 Forge 版本与当前使用的 Java 冲突崩溃。请尝试更新 Forge,或使用 Java 8u312/11.0.13/17.0.1 及更早版本启动\n <a href="https://oracle.com/java/technologies/javase/javase8u211-later-archive-downloads.html">8u312 及更早版本下载</a> <a href="https://oracle.com/java/technologies/javase/jdk11-archive-downloads.html">11.0.13 及更早版本下载</a> <a href="https://oracle.com/java/technologies/javase/jdk17-archive-downloads.html">17.0.1 及更早版本下载</a>\n可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
game.crash.reason.no_class_def_found_error=当前游戏因为代码不完整,无法继续运行。\n你的游戏可能缺失了某个 Mod,或者某些 Mod 文件不完整,或者 Mod 与游戏的版本不匹配。\n你可能需要重新安装游戏和 Mod,或请求他人帮助。\n缺失:%1$s
game.crash.reason.no_such_method_error=当前游戏因为代码不完整,无法继续运行。\n你的游戏可能缺失了某个 Mod,或者某些 Mod 文件不完整,或者 Mod 与游戏的版本不匹配。\n你可能需要重新安装游戏和 Mod,或请求他人帮助。
game.crash.reason.opengl_not_supported=当前游戏因为你的显卡驱动存在问题,无法继续运行。\n原因是 OpenGL 不受支持,你现在是否在远程桌面或者串流模式下?如果是,请直接使用原电脑启动游戏。\n或者尝试升级你的显卡驱动到最新版本后再尝试启动游戏。如果你的电脑存在独立显卡,你需要检查游戏是否使用集成/核心显卡启动,如果是,请尝试使用独立显卡启动 HMCL 与游戏。如果仍有问题,你可能需要考虑换一个新显卡或新电脑。
game.crash.reason.openj9=当前游戏无法运行在 OpenJ9 虚拟机上,请你在游戏设置中更换 Hotspot Java 虚拟机,并重新启动游戏。如果没有下载安装,你可以在网上自行下载。
game.crash.reason.out_of_memory=当前游戏因为内存不足,无法继续运行。\n这可能是内存分配太小,或者 Mod 数量过多导致的。\n你可以在游戏设置中调大游戏内存分配值以允许游戏在更大的内存下运行。\n如果仍然出现该错误,你可能需要换一台更好的电脑。
game.crash.reason.processing_of_javaagent_failed=当前游戏因为加载 -javaagent 参数失败,无法继续运行。\n如果你在 Java 虚拟机参数 中添加了相关参数,请检查是否合法正确\n如果你没有添加相关参数或参数确认合法正确,请尝试:\n打开 控制面板 -- 时钟和区域 分类(选项为类别显示才有此选项,没有就跳过) -- 区域 -- 上方的 管理 选项卡 -- 下方的 更改系统区域设置 按钮 -- 在弹出的窗口中将 “使用 Unicode UTF-8 提供全球语言支持” 选项关闭,重启设备后再尝试启动游戏。<a href="https://gitcode.net/chearlai/f/-/raw/master/%E5%85%B3%E9%97%AD_%E4%BD%BF%E7%94%A8_Unicode_UTF-8_%E6%8F%90%E4%BE%9B%E5%85%A8%E7%90%83%E8%AF%AD%E8%A8%80%E6%94%AF%E6%8C%81_.png">操作详情</a>\n可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
game.crash.reason.resolution_too_high=当前游戏因为材质包分辨率过高,无法继续运行\n你可以更换一个分辨率更低的材质,或者更换一个显存更大的显卡。
game.crash.reason.stacktrace=原因未知,请点击日志按钮查看详细信息。\n下面是一些关键词,其中可能包含 Mod 名称,你可以通过搜索的方式查找有关信息。\n%s
game.crash.reason.too_old_java=当前游戏因为 Java 虚拟机版本过低,无法继续运行。\n你需要在游戏设置中更换 Java %1$s 或更新版本的 Java 虚拟机,并重新启动游戏。如果没有下载安装,你可以点击 <a href="https://docs.microsoft.com/java/openjdk/download">此处</a> 下载微软 JDK。
game.crash.reason.unknown=原因未知,请点击日志按钮查看详细信息!\n<b>!!!请不要将本界面[截图]给他人!!!</b>如果你要求助他人,请你点击左下角导出游戏崩溃信息后将导出的文件发送给他人以供分析!!!\n查看了日志发现该问题很常见?你可以向我们提供游戏崩溃日志在反馈页面或 Github Issues 提交反馈以提升 HMCL 的日志分析能力
game.crash.reason.too_old_java=当前游戏因为 Java 虚拟机版本过低,无法继续运行。\n你需要在游戏设置中更换 Java %1$s 或更新版本的 Java 虚拟机,并重新启动游戏。如果没有下载安装,你可以点击 <a href="https://docs.microsoft.com/java/openjdk/download">此处</a> 下载微软 JDK。\n可在 <a href="https://discord.gg/jVvC7HfM6U">Discord</a> 或 <a href="https://kook.top/Kx7n3t">KOOK 群</a>寻求帮助
game.crash.reason.unknown=原因未知,请点击日志按钮查看详细信息!\n<b>!!!请不要将本界面【截图】给他人!!!</b>如果你要求助他人,请你点击左下角导出游戏崩溃信息后将导出的文件发送给他人以供分析!!!
game.crash.reason.unsatisfied_link_error=当前游戏因为缺少本地库,无法继续运行。\n这些本地库缺失:%1$s。\n如果你在游戏设置中修改了本地库路径选项,请你修改回预设模式。\n如果已经在预设模式下,请检查本地库缺失是否是 Mod 引起的,或由 HMCL 引起的。如果你确定是 HMCL 引起的,建议你向我们反馈。\n如果你确实需要自定义本地库路径,你需要保证其中包含缺失的本地库!
game.crash.title=游戏意外退出
game.directory=游戏路径
@@ -455,7 +469,7 @@ install.installer.liteloader=LiteLoader
install.installer.not_installed=不安装
install.installer.optifine=OptiFine
install.installer.quilt=Quilt
install.installer.quilt-api=Quilt API
install.installer.quilt-api=QSL/QFAPI
install.installer.version=%s
install.modpack=安装整合包
install.new_game=安装新游戏版本
@@ -510,7 +524,7 @@ launcher=启动器
launcher.agreement=用户协议与免责声明
launcher.agreement.accept=同意
launcher.agreement.decline=拒绝
launcher.agreement.hint=同意本软件的用户协议与免责声明以使用本软件!
launcher.agreement.hint=同意本软件的用户协议与免责声明以使用本软件。
launcher.background=背景地址
launcher.background.choose=选择背景路径
launcher.background.classic=经典
@@ -582,7 +596,7 @@ modpack.files.scripts=MineTweaker 配置
modpack.files.servers_dat=多人游戏服务器列表
modpack.install=安装 %s 整合包
modpack.installing=正在安装整合包
modpack.introduction=支持 Curse、MultiMC、HMCL 整合包。
modpack.introduction=支持 Curse、Modrinth、MultiMC、MCBBS 整合包。
modpack.invalid=无效的整合包升级文件,可能是下载时出现问题……
modpack.mismatched_type=整合包类型不匹配,当前游戏是 %s 整合包,但是提供的整合包更新文件是 %s 整合包。
modpack.name=整合包名称
@@ -593,7 +607,7 @@ modpack.origin.mcbbs=MCBBS
modpack.origin.mcbbs.prompt=贴子 id
modpack.scan=解析整合包
modpack.task.install=导入整合包
modpack.task.install.error=无法识别该整合包,目前仅支持导入 Curse、Modrinth、MultiMC、HMCL 整合包。
modpack.task.install.error=无法识别该整合包,目前仅支持导入 Curse、Modrinth、MultiMC、MCBBS 整合包。
modpack.task.install.will=将会安装整合包:
modpack.type.curse=Curse
modpack.type.curse.tolerable_error=但未能完成该整合包所需的依赖下载,您可以在启动该游戏版本时继续该整合包文件的下载。由于网络问题,您可能需要重试多次……
@@ -608,7 +622,7 @@ modpack.type.multimc.export=可以被 Hello Minecraft! Launcher (HMCL) 和 Multi
modpack.type.server=服务器自动更新整合包
modpack.type.server.export=允许服务器管理员远程更新游戏客户端
modpack.type.server.malformed=服务器整合包配置格式错误,请联系服务器管理员解决此问题
modpack.unsupported=该整合包不被支持。仅 HMCL、MultiMC、Curse 整合包受支持!
modpack.unsupported=该整合包不被支持。仅 Curse、Modrinth、MultiMC、MCBBS 整合包受支持!
modpack.update=正在升级整合包
modpack.wizard=导出整合包向导
modpack.wizard.step.1=基本设置
@@ -680,87 +694,66 @@ mods.restore=回退
mods.url=官方页面
multiplayer=多人联机
multiplayer.agreement.prompt=使用多人联机功能前,你需要先同意多人联机服务提供方 ioi 系列作品的用户协议与免责声明。\n用户使用联机服务的行为,将被视为无条件同意该用户协议与免责声明。
multiplayer.download=正在下载依赖
multiplayer.download.failed=初始化失败,部分文件未能完成下载
multiplayer.download.success=多人联机初始化完成
multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台
multiplayer.exit.after_ready=多人联机会话意外退出,退出码 %d
multiplayer.exit.before_ready=多人联机房间创建失败,cato 退出码 %d
multiplayer.exit.timeout.static_token=无法连接多人联机服务,你的凭证可能无法正常工作,你可以清空凭证再试。\n你可以联系在线管理员询问鉴权服务器是否正常工作,或者耐心等待十分钟。
multiplayer.exit.timeout.dynamic_token=无法连接多人联机服务,可能是鉴权服务器被人占满,请等待每个半点!鉴权服务器会自动刷新一次\n没有耐心且有闲钱的可以考虑购买联机凭证,不受鉴权服务器影响
multiplayer.hint=多人联机功能处于实验阶段,如果有问题请前往 mcer.cn 反馈
multiplayer.nat=网络检测
multiplayer.nat.failed=检测失败
multiplayer.nat.hint=执行网络检测可以让你更清楚你的网络状况是否符合联机功能的需求\n若检测结果为 差 或 检测失败 的网络可能导致联机失败!
multiplayer.nat.latency=延迟
multiplayer.nat.not_yet_tested=尚未检测
multiplayer.nat.packet_loss_ratio=丢包率
multiplayer.nat.testing=检测中
multiplayer.nat.type=NAT 类型
multiplayer.nat.type.blocked_udp=极差(网络禁止 UDP 协议)
multiplayer.nat.type.full_cone=好(完全圆锥型)
multiplayer.nat.type.open_access=好(公网开放型)
multiplayer.nat.type.port_restricted_cone=中(端口受限圆锥型)
multiplayer.nat.type.restricted_cone=中(受限圆锥型)
multiplayer.nat.type.symmetric=差(对称型)
multiplayer.nat.type.symmetric_udp_firewall=差(对称型+防火墙)
multiplayer.nat.type.unknown=未知
multiplayer.powered_by=多人联机服务由 (<a href="https://mcer.cn">mcer.cn</a>) 提供 <a href="https://hmcl.huangyuhui.net/api/redirect/multiplayer-agreement">用户协议与免责声明</a>
multiplayer.relay=桥接服务
multiplayer.relay.hint=Cato 面向较差网络提供的联机质量优化解决方案|无法联机时可在 mcer.cn 兑换凭证后启用
multiplayer.agreement.prompt=多人联机功能由 速聚 提供。使用前,你需要先同意多人联机服务提供方 速聚 的用户协议与免责声明。\n你需要了解,HMCL 仅为 速聚 提供多人联机服务入口,使用中遇到的任何问题由 速聚 负责处理。\n您在使用多人联机服务过程中所遇到的任何问题与纠纷(包括其付费业务)均与 HMCL 无关,请与 速聚 协商解决。
multiplayer.download=正在下载依赖……
multiplayer.download.failed=初始化失败,部分文件未能完成下载……
multiplayer.download.success=多人联机初始化完成√
multiplayer.download.unsupported=多人联机依赖不支持当前系统或平台!
multiplayer.error.failed_get_device=HiPer 无法创建网络设备,可能是 HiPer 正在运行中。请关闭正在运行中的其他启动器,或通过任务管理器终止 HiPer。
multiplayer.error.failed_load_config=HiPer 初始化失败,可能是联机索引码存在问题或索引码已过期,请检查索引码是否输入正确!你可向多人联机提供方联系寻求帮助。
multiplayer.error.failed_sudo.linux=HiPer 需要管理员权限才能运行,你可以通过设置环境变量 HMCL_ASKPASS 配置 HiPer 如何申请权限。\n若将 HMCL_ASKPASS 设置为 'false',请通过配置 sudoers 文件允许 '%s' 免密获取 root 权限,重启后再次启动 HMCL 即可;\n若将 HMCL_ASKPASS 设置为 'user',请先配置环境变量 SUDO_ASKPASS,HMCL 将使用 sudo --askpass 启动 HiPer (具体请参见 man sudo)。
multiplayer.error.failed_sudo.mac=HiPer 需要管理员权限才能运行。是否要授予 HiPer 管理员权限?\n点击“是”授予 HiPer 管理员权限,然后重启计算机后再次打开启动器即可联机。
multiplayer.error.failed_sudo.windows=HiPer 需要管理员权限才能运行,请使用管理员权限重新启动 HMCL。是否查看教程?
multiplayer.error.file_not_found=已发现 HiPer 有可用的更新,请重新进入多人联机页面以更新 HiPer。
multiplayer.error.session_expired=本次使用时间已结束,若要继续使用,请更换联机索引码
multiplayer.exit=HiPer 意外退出,退出码 %d
multiplayer.help.1=管理员权限打开
multiplayer.help.2=多人联机教程
multiplayer.help.3=创建方帮助
multiplayer.help.4=参与者帮助
multiplayer.help.text=测试能否联机
multiplayer.hint=多人联机服务正在维护中。
multiplayer.hint.details=查看详情
multiplayer.persistence=授权信息管理
multiplayer.persistence.export=将授权信息导出至文件
multiplayer.persistence.export.button=导出授权文件
multiplayer.persistence.export.failed=导出授权文件失败,请检查您所选择的保存路径。
multiplayer.persistence.export.file_not_exists=获取授权文件失败,请检查索引码是否输入正确。
multiplayer.persistence.export.title=选择授权文件保存路径
multiplayer.persistence.hint=此功能为长期授权 (有效期超过 24 小时) 用户提供,如果你正在使用短期授权 (有效期不超过 24 小时),则无需使用本功能。\n当你使用长期授权时,请在获取授权后的 24 小时内使用索引码启动 HiPer,否则 HiPer 将无法获取授权信息。\n随后,你可以通过下方的“导出授权信息”功能保存授权文件。\n请注意妥善保管授权文件,在获取授权 24 小时后,该文件是你的长期授权的唯一凭证,丢失该文件将无法通过索引码进行联机。
multiplayer.persistence.import=从文件导入授权信息
multiplayer.persistence.import.button=导入授权文件
multiplayer.persistence.import.failed=导入授权文件失败。
multiplayer.persistence.import.file_already_exists=授权文件已经存在。
multiplayer.persistence.import.title=选择授权文件
multiplayer.persistence.import.token_not_match=你选择的授权文件可能与索引码不匹配,是否继续导入?
multiplayer.persistence.license_file=授权文件
multiplayer.powered_by=HMCL 多人联机由 (<a href="https://mcer.cn">速聚</a>) 授权提供 <a href\="https://docs.hmcl.net/multiplayer/#%E4%BD%BF%E7%94%A8%E6%8E%88%E6%9D%83">授权协议</a>
multiplayer.report=违法违规举报
multiplayer.session=房间
multiplayer.session.name.format=%1$s 的房间
multiplayer.session.name.motd=HMCL 多人联机房间 - %s
multiplayer.session.close=关闭房间
multiplayer.session.close.warning=关闭房间后,已经加入联机房间的玩家将会断开连接,是否继续?
multiplayer.session.copy_room_code=拷贝邀请码
multiplayer.session.create=创建房间
multiplayer.session.create.error.dynamic_token=创建联机房间失败,可能是服务器出现问题,请稍后再试……
multiplayer.session.create.error.static_token=创建联机房间失败,你的凭证可能无法正常工作,你可以清空凭证再试……\n你可以联系在线管理员询问鉴权服务器是否正常工作,或者耐心等待十分钟。
multiplayer.session.create.hint=创建联机房间前,你需要先在正在运行的游戏内的游戏菜单中选择 对局域网开放 选项,然后在下方的输入框中确认游戏内提示的端口号(通常是 5 位的数字)
multiplayer.session.create.join=连接申请
multiplayer.session.create.join.allow=自动接受所有连接申请(不启用时,需要手动同意申请,以免无关人士误连服务器)
multiplayer.session.create.join.prompt=玩家 %s 申请加入多人联机房间,是否接受?
multiplayer.session.create.members=成员
multiplayer.session.create.members.kick=踢出房间
multiplayer.session.create.members.kick.prompt=是否踢出该玩家?踢出后该玩家不能再参与该联机房间。
multiplayer.session.create.name=房间名称
multiplayer.session.create.port=端口号
multiplayer.session.create.port.error=无法检测游戏端口号,你须启动游戏并在游戏内 打开对局域网 开放选项方能启动联机!
multiplayer.session.create.token=凭证
multiplayer.session.create.token.apply=申请凭证
multiplayer.session.create.token.prompt=默认为无凭证。你可以点击旁边的“申请凭证”获得详情
multiplayer.session.error.already_started=本地已经开启 cato 服务,请检查是否有其他 HMCL 正在运行联机服务。或者你可以在任务管理器里杀掉 cato 进程以继续。
multiplayer.session.error.file_not_found=找不到 cato 程序。该程序应该在进入多人联机页面时完成下载。请重启HMCL再试。\n请检查你电脑的杀毒软件是否将 cato 标记为病毒,如果是,请恢复 cato。
multiplayer.session.expired=联机会话连续使用时间超过了 3 小时,你需要重新创建/加入房间以继续联机!
multiplayer.session.join=加入房间
multiplayer.session.join.error=加入房间失败。若你或对方网络类型是 差(对称型)或 检测失败,可能无法使用联机功能,你可以使用凭证开启桥接模式后再试……
multiplayer.session.join.error.connection=加入房间失败。无法与对方建立连接。请检查你和对方的HMCL版本是否一致。\n如果你或对方的网络类型是差(对称型),可能无法使用联机功能。
multiplayer.session.join.hint=你需要向已经创建好房间的玩家索要邀请码以便加入多人联机房间
multiplayer.session.join.invitation_code=邀请码
multiplayer.session.join.invitation_code.error=邀请码格式不合法!请向房主获取邀请码,且检查你和对方使用的 HMCL 版本是否一致!
multiplayer.session.join.kicked=你与房间失去连接:%s。
multiplayer.session.join.kicked.join_acceptance_timeout=对方未能即时同意你的加入申请
multiplayer.session.join.kicked.kicked=你被房主踢出房间。
multiplayer.session.join.kicked.version_not_matched=多人联机功能版本号不一致,请保证连接多人联机功能版本号一致。
multiplayer.session.join.lost_connection=你已与房间失去连接。这可能意味着房主已经解散房间(非桥接服务的房间每个半点服务器自动刷新),或者你无法连接至房间……
multiplayer.session.join.port.error=无法找到可用的本地网络端口,请确保 HMCL 拥有绑定本地端口的权限!
multiplayer.session.join.rejected=你被房主拒绝连接,或房主忘记同意……
multiplayer.session.join.wait=等待对方同意加入申请……
multiplayer.session.members=房间成员
multiplayer.session.quit=退出房间
multiplayer.session.quit.warning=退出房间后,你将会与服务器断开连接,是否继续?
multiplayer.session.username=用户名
multiplayer.state.connecting=连接中
multiplayer.state.disconnected=未创建/加入房间
multiplayer.state.disconnected.hint=多人联机功能需要先有一位玩家创建房间后,其他玩家加入房间后继续游戏。
multiplayer.state.master=你已创建房间:%1$s,端口号 %2$d
multiplayer.state.master.hint=创建房间后,你可以点击拷贝邀请码,并发送给你希望加入联机的玩家。这些玩家在进入 HMCL 多人联机页面后,点击加入房间并粘贴邀请码即可加入联机房间。
multiplayer.state.slave=你已加入房间: %1$s,地址为 %2$s
multiplayer.state.slave.copy=拷贝地址
multiplayer.state.slave.hint=加入房间后,你需要在 Minecraft 的多人游戏页面选择 HMCL 多人联机房间服务器,或者手动添加下方的地址的服务器。
multiplayer.token=索引码
multiplayer.token.apply=获取联机索引码
multiplayer.token.expired=HiPer 证书过期,请更换联机索引码!
multiplayer.token.format_invalid=索引码格式不正确
multiplayer.token.invalid=索引码不正确。
multiplayer.token.malformed=HiPer 配置文件无法解析,可能是输入的联机索引码有误,或是获取证书服务器宕机。\n请检查输入的联机索引码!你可向多人联机提供方联系寻求帮助。
multiplayer.token.prompt=你需要联机索引码才能使用多人联机!点击“获取联机索引码”查看详情
multiplayer.off.hint=由于需要注册系统网络服务权限,启动 HiPer 时将会向用户申请管理员权限。若出现认证提示,允许以启动 HiPer。
multiplayer.off.start=启动 HiPer
multiplayer.master=创建方提示
multiplayer.master.hint=1.启动游戏\n2.选择单人游戏模式进入一个存档\n3.在游戏选项菜单内选择 对局域网开放 选项,之后你可以在游戏聊天框内看到游戏提示的端口号(通常是 4~5 位数字)\n4.点击下方的生成服务器地址按钮,输入端口号,你就可以获得你的服务器地址了\n5.该地址需要提供给其他需要加入服务器的玩家用于添加服务器。
multiplayer.master.server_address=生成服务器地址
multiplayer.master.server_address.copy=拷贝
multiplayer.master.video_tutorial=创建方视频教程
multiplayer.master.port=游戏局域网开放端口号
multiplayer.master.port.validate=在游戏聊天框中出现的端口号 (0~65535)
multiplayer.slave=参与者提示
multiplayer.slave.hint=1.要求创建方按照上方的 创建方提示 操作\n2.启动游戏\n3.选择多人游戏模式,选择添加服务器\n4.游戏会要求你输入服务器地址,你只需要向创建方索要服务器地址并输入,并进入服务器即可。
multiplayer.slave.hint2=1.一般情况下,参与者的游戏账户必须是 微软账户 或 外置登录账户(如 Little Skin),否则你需要将服务器地址输入至下方的输入框中并点击“加入”,在游戏中选择多人游戏模式,进入局域网世界方可加入,具体操作方法详见左侧的 帮助 \n2.一般情况下,参与者的游戏版本、模组要必须与创建方的一致,否则加入失败。
multiplayer.slave.server_address=创建方服务器地址
multiplayer.slave.server_address.start=加入
multiplayer.slave.server_address.stop=退出
multiplayer.slave.video_tutorial=参与者视频教程
multiplayer.session.expiration=索引码到期时间
datapack=数据包
datapack.add=添加数据包
@@ -800,7 +793,7 @@ world.info.generate_features=生成建筑
world.info.player=玩家信息
world.info.player.food_level=饥饿值
world.info.player.game_type=游戏模式
world.info.player.game_type.adventure=极限
world.info.player.game_type.adventure=冒险
world.info.player.game_type.creative=创造
world.info.player.game_type.spectator=旁观
world.info.player.game_type.survival=生存
File diff suppressed because it is too large. Load diff
@@ -1,69 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.junit.Ignore;
import org.junit.Test;
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class LocalServerBroadcastTest {
@Test
@Ignore("for manually testing")
public void test() {
int port = 12345;
DatagramSocket socket;
InetAddress broadcastAddress;
try {
socket = new DatagramSocket();
broadcastAddress = InetAddress.getByName("224.0.2.60");
} catch (IOException e) {
e.printStackTrace();
return;
}
while (true) {
try {
byte[] data = String.format("[MOTD]%s[/MOTD][AD]%d[/AD]", i18n("multiplayer.session.name.motd", "Test server"), port).getBytes(StandardCharsets.UTF_8);
DatagramPacket packet = new DatagramPacket(data, 0, data.length, broadcastAddress, 4445);
socket.send(packet);
System.out.println("Broadcast server 127.0.0.1:" + port);
} catch (IOException e) {
e.printStackTrace();
}
try {
Thread.sleep(1500);
} catch (InterruptedException ignored) {
return;
}
}
}
@Test
@Ignore
public void printLocalAddress() throws IOException {
DatagramSocket socket = new DatagramSocket(new InetSocketAddress((InetAddress) null, 4444));
System.out.println(socket.getLocalAddress());
}
}
@@ -1,49 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.Logging;
import org.junit.Ignore;
import org.junit.Test;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
public class LocalServerDetectorTest {
@Test
@Ignore("for manually testing")
public void test() {
try {
for (NetworkInterface networkInterface : Lang.toIterable(NetworkInterface.getNetworkInterfaces())) {
System.out.println(networkInterface.getName());
for (InetAddress address : Lang.toIterable(networkInterface.getInetAddresses())) {
System.out.println(address);
}
}
} catch (SocketException e) {
e.printStackTrace();
}
Logging.initForTest();
LocalServerDetector detector = new LocalServerDetector(3);
detector.run();
}
}
@@ -1,55 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.ui.multiplayer;
import org.jackhuang.hmcl.util.Logging;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
import java.util.concurrent.atomic.AtomicBoolean;
public class MultiplayerClientServerTest {
@Test
@Ignore
public void startServer() throws Exception {
Logging.initForTest();
int localPort = MultiplayerManager.findAvailablePort();
MultiplayerServer server = new MultiplayerServer("SessionName", 1000, true);
server.startServer(localPort);
MultiplayerClient client = new MultiplayerClient("username", localPort);
client.start();
AtomicBoolean handshakeReceived = new AtomicBoolean(false);
server.onHandshake().register(event -> {
handshakeReceived.set(true);
});
server.onKeepAlive().register(event -> {
client.interrupt();
server.interrupt();
});
server.join();
Assert.assertTrue(handshakeReceived.get());
}
}
@@ -64,7 +64,7 @@ public abstract class Account implements Observable {
* Play offline.
* @return the specific offline player's info.
*/
public abstract Optional<AuthInfo> playOffline() throws AuthenticationException;
public abstract AuthInfo playOffline() throws AuthenticationException;
public abstract Map<Object, Object> toStorage();
@@ -30,16 +30,21 @@ import java.util.UUID;
*/
@Immutable
public class AuthInfo implements AutoCloseable {
public static final String USER_TYPE_MOJANG = "mojang";
public static final String USER_TYPE_LEGACY = "legacy";
private final String username;
private final UUID uuid;
private final String accessToken;
private final String userType;
private final String userProperties;
public AuthInfo(String username, UUID uuid, String accessToken, String userProperties) {
public AuthInfo(String username, UUID uuid, String accessToken, String userType, String userProperties) {
this.username = username;
this.uuid = uuid;
this.accessToken = accessToken;
this.userType = userType;
this.userProperties = userProperties;
}
@@ -55,6 +60,10 @@ public class AuthInfo implements AutoCloseable {
return accessToken;
}
public String getUserType() {
return userType;
}
/**
* Properties of this user.
* Don't know the difference between user properties and user property map.
@@ -0,0 +1,21 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.auth;
public class NotLoggedInException extends AuthenticationException {
}
@@ -89,6 +89,7 @@ public class OAuth {
pair("grant_type", "authorization_code"), pair("client_secret", options.callback.getClientSecret()),
pair("redirect_uri", session.getRedirectURI()), pair("scope", options.scope))
.ignoreHttpCode()
.retry(5)
.getJson(AuthorizationResponse.class);
handleErrorResponse(response);
return new Result(response.accessToken, response.refreshToken);
@@ -98,6 +99,7 @@ public class OAuth {
DeviceTokenResponse deviceTokenResponse = HttpRequest.POST(deviceCodeURL)
.form(pair("client_id", options.callback.getClientId()), pair("scope", options.scope))
.ignoreHttpCode()
.retry(5)
.getJson(DeviceTokenResponse.class);
handleErrorResponse(deviceTokenResponse);
@@ -124,6 +126,7 @@ public class OAuth {
pair("code", deviceTokenResponse.deviceCode),
pair("client_id", options.callback.getClientId()))
.ignoreHttpCode()
.retry(5)
.getJson(TokenResponse.class);
if ("authorization_pending".equals(tokenResponse.error)) {
@@ -158,6 +161,7 @@ public class OAuth {
.form(query)
.accept("application/json")
.ignoreHttpCode()
.retry(5)
.getJson(RefreshResponse.class);
handleErrorResponse(response);
@@ -177,7 +181,7 @@ public class OAuth {
switch (response.error) {
case "invalid_grant":
if (response.errorDescription.contains("The user must sign in again and if needed grant the client application access to the requested scope")) {
if (response.errorDescription.contains("AADSTS70000")) {
throw new CredentialExpiredException();
}
break;
@@ -17,10 +17,7 @@
*/
package org.jackhuang.hmcl.auth.authlibinjector;
import org.jackhuang.hmcl.auth.AuthInfo;
import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.CharacterSelector;
import org.jackhuang.hmcl.auth.ServerDisconnectException;
import org.jackhuang.hmcl.auth.*;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
@@ -66,15 +63,15 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
}
@Override
public Optional<AuthInfo> playOffline() {
Optional<AuthInfo> auth = super.playOffline();
public AuthInfo playOffline() throws AuthenticationException {
AuthInfo auth = super.playOffline();
Optional<AuthlibInjectorArtifactInfo> artifact = downloader.getArtifactInfoImmediately();
Optional<String> prefetchedMeta = server.getMetadataResponse();
if (auth.isPresent() && artifact.isPresent() && prefetchedMeta.isPresent()) {
return Optional.of(new AuthlibInjectorAuthInfo(auth.get(), artifact.get(), server, prefetchedMeta.get()));
if (artifact.isPresent() && prefetchedMeta.isPresent()) {
return new AuthlibInjectorAuthInfo(auth, artifact.get(), server, prefetchedMeta.get());
} else {
return Optional.empty();
throw new NotLoggedInException();
}
}
@@ -123,7 +120,7 @@ public class AuthlibInjectorAccount extends YggdrasilAccount {
private final String prefetchedMeta;
public AuthlibInjectorAuthInfo(AuthInfo authInfo, AuthlibInjectorArtifactInfo artifact, AuthlibInjectorServer server, String prefetchedMeta) {
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), authInfo.getUserProperties());
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), authInfo.getUserType(), authInfo.getUserProperties());
this.artifact = artifact;
this.server = server;
@@ -117,8 +117,8 @@ public class MicrosoftAccount extends OAuthAccount {
}
@Override
public Optional<AuthInfo> playOffline() {
return Optional.of(session.toAuthInfo());
public AuthInfo playOffline() {
return session.toAuthInfo();
}
@Override
@@ -119,6 +119,7 @@ public class MicrosoftService {
mapOf(pair("AuthMethod", "RPS"), pair("SiteName", "user.auth.xboxlive.com"),
pair("RpsTicket", "d=" + liveAccessToken))),
pair("RelyingParty", "http://auth.xboxlive.com"), pair("TokenType", "JWT")))
.retry(5)
.accept("application/json").getJson(XBoxLiveAuthenticationResponse.class);
String uhs = getUhs(xboxResponse, null);
@@ -132,6 +133,7 @@ public class MicrosoftService {
pair("UserTokens", Collections.singletonList(xboxResponse.token)))),
pair("RelyingParty", "rp://api.minecraftservices.com/"), pair("TokenType", "JWT")))
.ignoreHttpErrorCode(401)
.retry(5)
.getJson(XBoxLiveAuthenticationResponse.class);
getUhs(minecraftXstsResponse, uhs);
@@ -140,6 +142,7 @@ public class MicrosoftService {
MinecraftLoginWithXBoxResponse minecraftResponse = HttpRequest
.POST("https://api.minecraftservices.com/authentication/login_with_xbox")
.json(mapOf(pair("identityToken", "XBL3.0 x=" + uhs + ";" + minecraftXstsResponse.token)))
.retry(5)
.accept("application/json").getJson(MinecraftLoginWithXBoxResponse.class);
long notAfter = minecraftResponse.expiresIn * 1000L + System.currentTimeMillis();
@@ -110,7 +110,7 @@ public class MicrosoftSession {
public AuthInfo toAuthInfo() {
requireNonNull(profile);
return new AuthInfo(profile.getName(), profile.getId(), accessToken, "{}");
return new AuthInfo(profile.getName(), profile.getId(), accessToken, AuthInfo.USER_TYPE_MOJANG, "{}");
}
public static class User {
@@ -67,6 +67,10 @@ public class OfflineAccount extends Account {
}
}
public AuthlibInjectorArtifactProvider getDownloader() {
return downloader;
}
@Override
public UUID getUUID() {
return uuid;
@@ -91,7 +95,7 @@ public class OfflineAccount extends Account {
invalidate();
}
private boolean loadAuthlibInjector(Skin skin) {
protected boolean loadAuthlibInjector(Skin skin) {
if (skin == null) return false;
if (skin.getType() == Skin.Type.DEFAULT) return false;
TextureModel defaultModel = TextureModel.detectUUID(getUUID());
@@ -104,7 +108,8 @@ public class OfflineAccount extends Account {
@Override
public AuthInfo logIn() throws AuthenticationException {
AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), "{}");
// Using "legacy" user type here because "mojang" user type may cause "invalid session token" or "disconnected" when connecting to a game server.
AuthInfo authInfo = new AuthInfo(username, uuid, UUIDTypeAdapter.fromUUID(UUID.randomUUID()), AuthInfo.USER_TYPE_LEGACY, "{}");
if (loadAuthlibInjector(skin)) {
CompletableFuture<AuthlibInjectorArtifactInfo> artifactTask = CompletableFuture.supplyAsync(() -> {
@@ -144,7 +149,7 @@ public class OfflineAccount extends Account {
private YggdrasilServer server;
public OfflineAuthInfo(AuthInfo authInfo, AuthlibInjectorArtifactInfo artifact) {
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), authInfo.getUserProperties());
super(authInfo.getUsername(), authInfo.getUUID(), authInfo.getAccessToken(), USER_TYPE_LEGACY, authInfo.getUserProperties());
this.artifact = artifact;
}
@@ -157,7 +162,8 @@ public class OfflineAccount extends Account {
server.start();
try {
server.addCharacter(new YggdrasilServer.Character(uuid, username, skin.load(username).run()));
server.addCharacter(new YggdrasilServer.Character(uuid, username,
skin != null ? skin.load(username).run() : null));
} catch (IOException e) {
// ignore
} catch (Exception e) {
@@ -180,8 +186,8 @@ public class OfflineAccount extends Account {
}
@Override
public Optional<AuthInfo> playOffline() throws AuthenticationException {
return Optional.of(logIn());
public AuthInfo playOffline() throws AuthenticationException {
return logIn();
}
@Override
@@ -48,8 +48,15 @@ public class Skin {
public enum Type {
DEFAULT,
STEVE,
ALEX,
ARI,
EFE,
KAI,
MAKENA,
NOOR,
STEVE,
SUNNY,
ZURI,
LOCAL_FILE,
LITTLE_SKIN,
CUSTOM_SKIN_LOADER_API,
@@ -59,10 +66,24 @@ public class Skin {
switch (type) {
case "default":
return DEFAULT;
case "steve":
return STEVE;
case "alex":
return ALEX;
case "ari":
return ARI;
case "efe":
return EFE;
case "kai":
return KAI;
case "makena":
return MAKENA;
case "noor":
return NOOR;
case "steve":
return STEVE;
case "sunny":
return SUNNY;
case "zuri":
return ZURI;
case "local_file":
return LOCAL_FILE;
case "little_skin":
@@ -115,10 +136,24 @@ public class Skin {
switch (type) {
case DEFAULT:
return Task.supplyAsync(() -> null);
case STEVE:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/steve.png")), null));
case ALEX:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/alex.png")), null));
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/alex.png")), null));
case ARI:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/ari.png")), null));
case EFE:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/efe.png")), null));
case KAI:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/kai.png")), null));
case MAKENA:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/makena.png")), null));
case NOOR:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.ALEX, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/noor.png")), null));
case STEVE:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/steve.png")), null));
case SUNNY:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/sunny.png")), null));
case ZURI:
return Task.supplyAsync(() -> new LoadedSkin(TextureModel.STEVE, Texture.loadTexture(Skin.class.getResourceAsStream("/assets/img/skin/zuri.png")), null));
case LOCAL_FILE:
return Task.supplyAsync(() -> {
Texture skin = null, cape = null;
@@ -161,8 +161,8 @@ public class YggdrasilAccount extends ClassicAccount {
}
@Override
public Optional<AuthInfo> playOffline() {
return Optional.of(session.toAuthInfo());
public AuthInfo playOffline() throws AuthenticationException {
return session.toAuthInfo();
}
@Override
@@ -274,5 +274,5 @@ public class YggdrasilService {
public static final String PROFILE_URL = "https://aka.ms/MinecraftMigration";
public static final String MIGRATION_FAQ_URL = "https://help.minecraft.net/hc/en-us/articles/360050865492-JAVA-Account-Migration-FAQ";
public static final String PURCHASE_URL = "https://www.minecraft.net/zh-hans/store/minecraft-java-edition";
public static final String PURCHASE_URL = "https://www.minecraft.net/store/minecraft-java-bedrock-edition-pc";
}
@@ -104,7 +104,7 @@ public class YggdrasilSession {
if (selectedProfile == null)
throw new IllegalStateException("No character is selected");
return new AuthInfo(selectedProfile.getName(), selectedProfile.getId(), accessToken,
return new AuthInfo(selectedProfile.getName(), selectedProfile.getId(), accessToken, AuthInfo.USER_TYPE_MOJANG,
Optional.ofNullable(userProperties)
.map(properties -> properties.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey,
@@ -66,6 +66,8 @@ public final class JavaRepository {
}
public static Stream<Path> findJavaHomeInMinecraftRuntimeDir(Path runtimeDir) {
if (!Files.isDirectory(runtimeDir))
return Stream.empty();
// Examples:
// $HOME/Library/Application Support/minecraft/runtime/java-runtime-beta/mac-os/java-runtime-beta/jre.bundle/Contents/Home
// $HOME/.minecraft/runtime/java-runtime-beta/linux/java-runtime-beta
@@ -116,7 +116,7 @@ public final class QuiltInstallTask extends Task<Version> {
}
}
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.hashed.maven), getMavenRepositoryByGroup(quiltInfo.hashed.maven), null));
// libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.hashed.maven), getMavenRepositoryByGroup(quiltInfo.hashed.maven), null));
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.intermediary.maven), getMavenRepositoryByGroup(quiltInfo.intermediary.maven), null));
libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.loader.maven), getMavenRepositoryByGroup(quiltInfo.loader.maven), null));
@@ -84,6 +84,8 @@ public final class CrashReportAnalyzer {
MODLAUNCHER_8(Pattern.compile("java\\.lang\\.NoSuchMethodError: ('void sun\\.security\\.util\\.ManifestEntryVerifier\\.<init>\\(java\\.util\\.jar\\.Manifest\\)'|sun\\.security\\.util\\.ManifestEntryVerifier\\.<init>\\(Ljava/util/jar/Manifest;\\)V)")),
// Manually triggerd debug crash
DEBUG_CRASH(Pattern.compile("Manually triggered debug crash")),
//https://github.com/huanghongxun/HMCL/issues/1780
PROCESSING_OF_JAVAAGENT_FAILED(Pattern.compile("processing of -javaagent failed")),
CONFIG(Pattern.compile("Failed loading config file (?<file>.*?) of type SERVER for modid (?<id>.*)"), "id", "file"),
// Fabric gives some warnings
FABRIC_WARNINGS(Pattern.compile("Warnings were found!(.*?)[\\n\\r]+(?<reason>[^\\[]+)\\["), "reason"),
@@ -488,6 +488,7 @@ public class DefaultGameRepository implements GameRepository {
}
public Task<Version> saveAsync(Version version) {
this.gameVersions.remove(getVersionJar(version));
if (version.isResolvedPreservingPatches()) {
return new VersionJsonSaveTask(this, MaintainTask.maintainPreservingPatches(this, version));
} else {
@@ -120,8 +120,13 @@ public enum JavaVersionConstraint {
@Override
protected boolean appliesToVersionImpl(VersionNumber gameVersionNumber, @Nullable Version version,
@Nullable JavaVersion javaVersion) {
return javaVersion != null && !javaVersion.getArchitecture().isX86()
&& (OperatingSystem.CURRENT_OS != OperatingSystem.OSX || gameVersionNumber.compareTo(VersionNumber.asVersion("1.19")) < 0);
if (javaVersion == null || javaVersion.getArchitecture() != Architecture.ARM64)
return false;
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
return gameVersionNumber.compareTo(VersionNumber.asVersion("1.6")) < 0;
return false;
}
@Override
@@ -237,15 +242,15 @@ public enum JavaVersionConstraint {
public static JavaVersion findSuitableJavaVersion(VersionNumber gameVersion, Version version) throws InterruptedException {
VersionRanges range = findSuitableJavaVersionRange(gameVersion, version);
boolean skipCheckArchitecture = Architecture.CURRENT_ARCH.isX86()
|| (OperatingSystem.CURRENT_OS == OperatingSystem.OSX && gameVersion.compareTo(VersionNumber.asVersion("1.19")) >= 0)
|| OperatingSystem.CURRENT_OS == OperatingSystem.LINUX;
boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
&& gameVersion.compareTo(VersionNumber.asVersion("1.6")) < 0;
JavaVersion mandatory = null;
JavaVersion suggested = null;
for (JavaVersion javaVersion : JavaVersion.getJavas()) {
// select the latest x86 java that this version accepts.
if (!skipCheckArchitecture && !javaVersion.getArchitecture().isX86())
if (forceX86 && !javaVersion.getArchitecture().isX86())
continue;
VersionNumber javaVersionNumber = javaVersion.getVersionNumber();
@@ -27,11 +27,7 @@ import org.jackhuang.hmcl.util.gson.UUIDTypeAdapter;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.Unzipper;
import org.jackhuang.hmcl.util.platform.CommandBuilder;
import org.jackhuang.hmcl.util.platform.JavaVersion;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jackhuang.hmcl.util.platform.Bits;
import org.jackhuang.hmcl.util.platform.*;
import org.jackhuang.hmcl.util.versioning.VersionNumber;
import java.io.*;
@@ -113,9 +109,7 @@ public class DefaultLauncher extends Launcher {
res.addAllWithoutParsing(options.getJavaArguments());
Charset encoding = OperatingSystem.NATIVE_CHARSET;
// After Java 17, file.encoding does not affect console encoding
if (options.getJava().getParsedVersion() <= JavaVersion.JAVA_17) {
if (options.getJava().getParsedVersion() < JavaVersion.JAVA_8) {
try {
String fileEncoding = res.addDefault("-Dfile.encoding=", encoding.name());
if (fileEncoding != null)
@@ -123,6 +117,21 @@ public class DefaultLauncher extends Launcher {
} catch (Throwable ex) {
LOG.log(Level.WARNING, "Bad file encoding", ex);
}
} else {
if (options.getJava().getParsedVersion() > JavaVersion.JAVA_17
&& VersionNumber.VERSION_COMPARATOR.compare(repository.getGameVersion(version).orElse("1.13"), "1.13") < 0) {
res.addDefault("-Dfile.encoding=", "COMPAT");
}
try {
String stdoutEncoding = res.addDefault("-Dsun.stdout.encoding=", encoding.name());
if (stdoutEncoding != null)
encoding = Charset.forName(stdoutEncoding.substring("-Dsun.stdout.encoding=".length()));
} catch (Throwable ex) {
LOG.log(Level.WARNING, "Bad stdout encoding", ex);
}
res.addDefault("-Dsun.stderr.encoding=", encoding.name());
}
// JVM Args
@@ -237,7 +246,7 @@ public class DefaultLauncher extends Launcher {
// Here is a workaround for this issue: https://github.com/huanghongxun/HMCL/issues/1141.
String nativeFolderPath = nativeFolder.getAbsolutePath();
Path tempNativeFolder = null;
if (OperatingSystem.CURRENT_OS == OperatingSystem.LINUX
if ((OperatingSystem.CURRENT_OS == OperatingSystem.LINUX || OperatingSystem.CURRENT_OS == OperatingSystem.OSX)
&& !StringUtils.isASCII(nativeFolderPath)) {
tempNativeFolder = Paths.get("/", "tmp", "hmcl-natives-" + UUID.randomUUID());
nativeFolderPath = tempNativeFolder + File.pathSeparator + nativeFolderPath;
@@ -392,7 +401,7 @@ public class DefaultLauncher extends Launcher {
pair("${profile_name}", Optional.ofNullable(options.getProfileName()).orElse("Minecraft")),
pair("${version_type}", Optional.ofNullable(options.getVersionType()).orElse(version.getType().getId())),
pair("${game_directory}", repository.getRunDirectory(version.getId()).getAbsolutePath()),
pair("${user_type}", "mojang"),
pair("${user_type}", authInfo.getUserType()),
pair("${assets_index_name}", version.getAssetIndex().getId()),
pair("${user_properties}", authInfo.getUserProperties()),
pair("${resolution_width}", options.getWidth().toString()),
@@ -448,7 +457,7 @@ public class DefaultLauncher extends Launcher {
if (StringUtils.isNotBlank(options.getPreLaunchCommand())) {
ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPreLaunchCommand())).directory(runDirectory);
builder.environment().putAll(getEnvVars());
builder.start().waitFor();
SystemUtils.callExternalProcess(builder);
}
Process process;
@@ -530,9 +539,6 @@ public class DefaultLauncher extends Launcher {
throw new IllegalArgumentException("The extension of " + scriptFile + " is not 'sh' or 'ps1' in macOS/Linux");
}
if (!FileUtils.makeFile(scriptFile))
throw new IOException("Script file: " + scriptFile + " cannot be created.");
final Command commandLine = generateCommandLine(nativeFolder);
final String command = usePowerShell ? null : commandLine.commandLine.toString();
@@ -542,6 +548,9 @@ public class DefaultLauncher extends Launcher {
}
}
if (!FileUtils.makeFile(scriptFile))
throw new IOException("Script file: " + scriptFile + " cannot be created.");
OutputStream outputStream = new FileOutputStream(scriptFile);
Charset charset = StandardCharsets.UTF_8;
@@ -590,7 +599,7 @@ public class DefaultLauncher extends Launcher {
writer.write("set APPDATA=" + options.getGameDir().getAbsoluteFile().getParent());
writer.newLine();
for (Map.Entry<String, String> entry : getEnvVars().entrySet()) {
writer.write("set " + entry.getKey() + "=" + entry.getValue());
writer.write("set " + entry.getKey() + "=" + CommandBuilder.toBatchStringLiteral(entry.getValue()));
writer.newLine();
}
writer.newLine();
@@ -599,7 +608,7 @@ public class DefaultLauncher extends Launcher {
writer.write("#!/usr/bin/env bash");
writer.newLine();
for (Map.Entry<String, String> entry : getEnvVars().entrySet()) {
writer.write("export " + entry.getKey() + "=" + entry.getValue());
writer.write("export " + entry.getKey() + "=" + CommandBuilder.toShellStringLiteral(entry.getValue()));
writer.newLine();
}
if (commandLine.tempNativeFolder != null) {
@@ -26,7 +26,9 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.platform.ManagedProcess;
import java.util.Collection;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
/**
*
@@ -58,38 +60,23 @@ final class ExitWaiter implements Runnable {
for (Thread thread : joins)
thread.join();
ProcessListener.ExitType exitType = null;
for (String line : process.getLines()) {
Log4jLevel level = Log4jLevel.guessLevel(line);
if (Log4jLevel.isError(level)) {
// LaunchWrapper will catch the exception logged and will exit normally.
if (exitCode != 0 && StringUtils.containsOne(line,
"Could not create the Java Virtual Machine.",
"Error occurred during initialization of VM",
"A fatal exception has occurred. Program will exit.")) {
EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(this, process));
exitType = ProcessListener.ExitType.JVM_ERROR;
break;
}
List<String> errorLines = process.getLines().stream()
.filter(Log4jLevel::guessLogLineError).collect(Collectors.toList());
ProcessListener.ExitType exitType;
if (exitCode != 0 || StringUtils.containsOne(line, "Unable to launch")) {
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
break;
}
}
if (level == Log4jLevel.WARN) {
if (StringUtils.containsOne(line, "Failed to create window")) {
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
break;
}
}
}
if (exitType == null)
// LaunchWrapper will catch the exception logged and will exit normally.
if (exitCode != 0 && StringUtils.containsOne(errorLines,
"Could not create the Java Virtual Machine.",
"Error occurred during initialization of VM",
"A fatal exception has occurred. Program will exit.")) {
EventBus.EVENT_BUS.fireEvent(new JVMLaunchFailedEvent(this, process));
exitType = ProcessListener.ExitType.JVM_ERROR;
} else if (exitCode != 0 || StringUtils.containsOne(errorLines, "Unable to launch")) {
EventBus.EVENT_BUS.fireEvent(new ProcessExitedAbnormallyEvent(this, process));
exitType = ProcessListener.ExitType.APPLICATION_ERROR;
} else {
exitType = ProcessListener.ExitType.NORMAL;
}
EventBus.EVENT_BUS.fireEvent(new ProcessStoppedEvent(this, process));
@@ -41,12 +41,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository
private static final String PREFIX = "https://api.curseforge.com";
private static String apiKey;
static {
apiKey = System.getProperty("hmcl.curseforge.apikey",
JarUtils.thisJar().flatMap(JarUtils::getManifest).map(manifest -> manifest.getMainAttributes().getValue("CurseForge-Api-Key")).orElse(""));
}
private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getManifestAttribute("CurseForge-Api-Key", ""));
private final Type type;
private final int section;
@@ -102,6 +102,8 @@ public class ModrinthCompletionTask extends Task<Void> {
Path runDirectory = repository.getRunDirectory(version).toPath();
for (ModrinthManifest.File file : manifest.getFiles()) {
if (file.getEnv() != null && file.getEnv().getOrDefault("client", "required").equals("unsupported"))
continue;
Path filePath = runDirectory.resolve(file.getPath());
if (!Files.exists(filePath) && !file.getDownloads().isEmpty()) {
FileDownloadTask task = new FileDownloadTask(file.getDownloads().get(0), filePath.toFile());
@@ -72,7 +72,8 @@ public class ModrinthInstallTask extends Task<Void> {
builder.version("fabric", modLoader.getValue());
break;
case "quilt-loader":
throw new IllegalStateException("Quilt Modloader is not supported");
builder.version("quilt", modLoader.getValue());
break;
default:
throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey());
}
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.util.Hex;
import org.jackhuang.hmcl.util.Logging;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.CompressingUtils;
@@ -25,7 +26,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.math.BigInteger;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.FileSystem;
@@ -72,7 +72,7 @@ public class FileDownloadTask extends FetchTask<Void> {
}
public void performCheck(MessageDigest digest) throws ChecksumMismatchException {
String actualChecksum = String.format("%1$040x", new BigInteger(1, digest.digest()));
String actualChecksum = Hex.encodeHex(digest.digest());
if (!checksum.equalsIgnoreCase(actualChecksum)) {
throw new ChecksumMismatchException(algorithm, checksum, actualChecksum);
}
@@ -67,6 +67,9 @@ public final class Logging {
});
try {
if (Files.isRegularFile(logFolder))
Files.delete(logFolder);
Files.createDirectories(logFolder);
FileHandler fileHandler = new FileHandler(logFolder.resolve("hmcl.log").toAbsolutePath().toString());
fileHandler.setLevel(Level.FINEST);
@@ -74,7 +77,7 @@ public final class Logging {
fileHandler.setEncoding("UTF-8");
LOG.addHandler(fileHandler);
} catch (IOException e) {
System.err.println("Unable to create hmcl.log, " + e.getMessage());
System.err.println("Unable to create hmcl.log\n" + StringUtils.getStackTrace(e));
}
ConsoleHandler consoleHandler = new ConsoleHandler();
@@ -260,6 +260,16 @@ public final class StringUtils {
return US_ASCII_ENCODER.canEncode(cs);
}
public static boolean isAlphabeticOrNumber(String str) {
int length = str.length();
for (int i = 0; i < length; i++) {
char ch = str.charAt(i);
if (!(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'z') && !(ch >= 'A' && ch <= 'Z'))
return false;
}
return true;
}
/**
* Class for computing the longest common subsequence between strings.
*/
@@ -141,6 +141,21 @@ public final class FileUtils {
writeText(file, text, UTF_8);
}
/**
* Write plain text to file. Characters are encoded into bytes using UTF-8.
* <p>
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All characters in text will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param text the text being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeText(Path file, String text) throws IOException {
writeText(file, text, UTF_8);
}
/**
* Write plain text to file.
* <p>
@@ -157,18 +172,47 @@ public final class FileUtils {
writeBytes(file, text.getBytes(charset));
}
/**
* Write plain text to file.
* <p>
* We don't care about platform difference of line separator. Because readText accept all possibilities of line separator.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All characters in text will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param text the text being written to file
* @param charset the charset to use for encoding
* @throws IOException if an I/O error occurs
*/
public static void writeText(Path file, String text, Charset charset) throws IOException {
writeBytes(file, text.getBytes(charset));
}
/**
* Write byte array to file.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param array the data being written to file
* @param data the data being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeBytes(File file, byte[] array) throws IOException {
Files.createDirectories(file.toPath().getParent());
Files.write(file.toPath(), array);
public static void writeBytes(File file, byte[] data) throws IOException {
writeBytes(file.toPath(), data);
}
/**
* Write byte array to file.
* It will create the file if it does not exist, or truncate the existing file to empty for rewriting.
* All bytes in byte array will be written into the file in binary format. Existing data will be erased.
*
* @param file the path to the file
* @param data the data being written to file
* @throws IOException if an I/O error occurs
*/
public static void writeBytes(Path file, byte[] data) throws IOException {
Files.createDirectories(file.getParent());
Files.write(file, data);
}
public static void deleteDirectory(File directory)
@@ -21,6 +21,7 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.function.ExceptionalBiConsumer;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import java.io.IOException;
@@ -28,6 +29,7 @@ import java.io.OutputStream;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.SocketTimeoutException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
@@ -49,6 +51,7 @@ public abstract class HttpRequest {
protected final Map<String, String> headers = new HashMap<>();
protected ExceptionalBiConsumer<URL, Integer, IOException> responseCodeTester;
protected final Set<Integer> toleratedHttpCodes = new HashSet<>();
protected int retryTimes = 1;
protected boolean ignoreHttpCode;
private HttpRequest(String url, String method) {
@@ -82,6 +85,14 @@ public abstract class HttpRequest {
return this;
}
public HttpRequest retry(int retryTimes) {
if (retryTimes < 1) {
throw new IllegalArgumentException("retryTimes >= 1");
}
this.retryTimes = retryTimes;
return this;
}
public abstract String getString() throws IOException;
public CompletableFuture<String> getStringAsync() {
@@ -129,9 +140,11 @@ public abstract class HttpRequest {
}
public String getString() throws IOException {
HttpURLConnection con = createConnection();
con = resolveConnection(con);
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
return getStringWithRetry(() -> {
HttpURLConnection con = createConnection();
con = resolveConnection(con);
return IOUtils.readFullyAsString(con.getInputStream(), StandardCharsets.UTF_8);
}, retryTimes);
}
}
@@ -168,25 +181,27 @@ public abstract class HttpRequest {
}
public String getString() throws IOException {
HttpURLConnection con = createConnection();
con.setDoOutput(true);
return getStringWithRetry(() -> {
HttpURLConnection con = createConnection();
con.setDoOutput(true);
try (OutputStream os = con.getOutputStream()) {
os.write(bytes);
}
try (OutputStream os = con.getOutputStream()) {
os.write(bytes);
}
if (responseCodeTester != null) {
responseCodeTester.accept(new URL(url), con.getResponseCode());
} else {
if (con.getResponseCode() / 100 != 2) {
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
String data = NetworkUtils.readData(con);
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
if (responseCodeTester != null) {
responseCodeTester.accept(new URL(url), con.getResponseCode());
} else {
if (con.getResponseCode() / 100 != 2) {
if (!ignoreHttpCode && !toleratedHttpCodes.contains(con.getResponseCode())) {
String data = NetworkUtils.readData(con);
throw new ResponseCodeException(new URL(url), con.getResponseCode(), data);
}
}
}
}
return NetworkUtils.readData(con);
return NetworkUtils.readData(con);
}, retryTimes);
}
}
@@ -203,6 +218,20 @@ public abstract class HttpRequest {
return new HttpPostRequest(url);
}
private static String getStringWithRetry(ExceptionalSupplier<String, IOException> supplier, int retryTimes) throws IOException {
SocketTimeoutException exception = null;
for (int i = 0; i < retryTimes; i++) {
try {
return supplier.get();
} catch (SocketTimeoutException e) {
exception = e;
}
}
if (exception != null)
throw exception;
throw new IOException("retry 0");
}
public interface Authorization {
String getTokenType();
@@ -18,14 +18,13 @@
package org.jackhuang.hmcl.util.io;
import java.io.IOException;
import java.io.InputStream;
import java.net.URISyntaxException;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.CodeSource;
import java.util.Optional;
import java.util.jar.Attributes;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
@@ -34,22 +33,45 @@ public final class JarUtils {
}
@SuppressWarnings("OptionalUsedAsFieldOrParameterType")
private static final Optional<Path> THIS_JAR =
Optional.ofNullable(JarUtils.class.getProtectionDomain().getCodeSource())
.map(CodeSource::getLocation)
.map(url -> {
try {
return Paths.get(url.toURI());
} catch (FileSystemNotFoundException | IllegalArgumentException | URISyntaxException e) {
return null;
}
})
.filter(Files::isRegularFile);
private static final Optional<Path> THIS_JAR;
private static final Manifest manifest;
static {
THIS_JAR = Optional.ofNullable(JarUtils.class.getProtectionDomain().getCodeSource())
.map(codeSource -> {
try {
return Paths.get(codeSource.getLocation().toURI());
} catch (FileSystemNotFoundException | IllegalArgumentException | URISyntaxException e) {
return null;
}
})
.filter(Files::isRegularFile);
Manifest mf = null;
try (InputStream input = JarUtils.class.getResourceAsStream("/META-INF/MANIFEST.MF")) {
if (input != null)
mf = new Manifest(input);
} catch (IOException e) {
// Logger has not started
e.printStackTrace();
}
if (mf == null)
mf = THIS_JAR.flatMap(JarUtils::getManifest).orElseGet(Manifest::new);
manifest = mf;
}
public static Optional<Path> thisJar() {
return THIS_JAR;
}
public static String getManifestAttribute(String name, String defaultValue) {
String value = manifest.getMainAttributes().getValue(name);
return value != null ? value : defaultValue;
}
public static Optional<Manifest> getManifest(Path jar) {
try (JarFile file = new JarFile(jar.toFile())) {
return Optional.ofNullable(file.getManifest());
@@ -57,9 +79,4 @@ public final class JarUtils {
return Optional.empty();
}
}
public static Optional<String> getImplementationVersion(Path jar) {
return Optional.of(jar).flatMap(JarUtils::getManifest)
.flatMap(manifest -> Optional.ofNullable(manifest.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION)));
}
}
@@ -47,22 +47,22 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
return of(Bindings.createObjectBinding(() -> mapper.apply(watched), watched));
}
protected final ObservableValue<T> predecessor;
protected final ObservableValue<? extends T> predecessor;
public BindingMapping(ObservableValue<T> predecessor) {
public BindingMapping(ObservableValue<? extends T> predecessor) {
this.predecessor = requireNonNull(predecessor);
bind(predecessor);
}
public <V> BindingMapping<?, V> map(Function<U, V> mapper) {
public <V> BindingMapping<?, V> map(Function<? super U, ? extends V> mapper) {
return new MappedBinding<>(this, mapper);
}
public <V> BindingMapping<?, V> flatMap(Function<U, ? extends ObservableValue<V>> mapper) {
public <V> BindingMapping<?, V> flatMap(Function<? super U, ? extends ObservableValue<? extends V>> mapper) {
return flatMap(mapper, null);
}
public <V> BindingMapping<?, V> flatMap(Function<U, ? extends ObservableValue<V>> mapper, Supplier<V> nullAlternative) {
public <V> BindingMapping<?, V> flatMap(Function<? super U, ? extends ObservableValue<? extends V>> mapper, Supplier<? extends V> nullAlternative) {
return new FlatMappedBinding<>(map(mapper), nullAlternative);
}
@@ -82,7 +82,7 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
@Override
public <V> BindingMapping<?, V> map(Function<T, V> mapper) {
public <V> BindingMapping<?, V> map(Function<? super T, ? extends V> mapper) {
return new MappedBinding<>(predecessor, mapper);
}
@@ -94,9 +94,9 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
private static class MappedBinding<T, U> extends BindingMapping<T, U> {
private final Function<T, U> mapper;
private final Function<? super T, ? extends U> mapper;
public MappedBinding(ObservableValue<T> predecessor, Function<T, U> mapper) {
public MappedBinding(ObservableValue<? extends T> predecessor, Function<? super T, ? extends U> mapper) {
super(predecessor);
this.mapper = mapper;
}
@@ -107,12 +107,12 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
}
}
private static class FlatMappedBinding<T extends ObservableValue<U>, U> extends BindingMapping<T, U> {
private static class FlatMappedBinding<T extends ObservableValue<? extends U>, U> extends BindingMapping<T, U> {
private final Supplier<U> nullAlternative;
private final Supplier<? extends U> nullAlternative;
private T lastObservable = null;
public FlatMappedBinding(ObservableValue<T> predecessor, Supplier<U> nullAlternative) {
public FlatMappedBinding(ObservableValue<? extends T> predecessor, Supplier<? extends U> nullAlternative) {
super(predecessor);
this.nullAlternative = nullAlternative;
}
@@ -148,11 +148,11 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
private T prev;
private U value;
private final Function<T, CompletableFuture<U>> mapper;
private final Function<? super T, ? extends CompletableFuture<? extends U>> mapper;
private T computingPrev;
private boolean computing = false;
public AsyncMappedBinding(ObservableValue<T> predecessor, Function<T, CompletableFuture<U>> mapper, U initial) {
public AsyncMappedBinding(ObservableValue<? extends T> predecessor, Function<? super T, ? extends CompletableFuture<? extends U>> mapper, U initial) {
super(predecessor);
this.value = initial;
this.mapper = mapper;
@@ -168,7 +168,7 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
computingPrev = currentPrev;
}
CompletableFuture<U> task;
CompletableFuture<? extends U> task;
try {
task = requireNonNull(mapper.apply(currentPrev));
} catch (Throwable e) {
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more