Compare commits

...
Author SHA1 Message Date
Glavo 5803235407 使用 WebURL for Java 解析 URI (#6084) 2026-05-12 21:43:19 +08:00
CiiLu afbf567fef 缓解离线皮肤 CSL 输入框打一个字弹窗一个 toast “操作失败” 问题 (#5789) 2026-05-09 21:26:06 +08:00
Glavo 72b3c1981a 支持断点续传 (#4558) 2026-05-09 21:02:27 +08:00
Glavo 72ee82543b Use null-safe converter for LineSelectButton instances (#6075) 2026-05-09 08:54:14 +08:00
Glavo 996c9f4c86 检测到系统 zlib 实现为 zlib-ng 时跳过 Forge/NeoForge 安装时的哈希检测 (#6068) 2026-05-08 12:13:06 +08:00
3gf8jv4dv f8c1e97376 fix(l10n): edit Version List options on Settings > Downloads for zh-hans (#6069) 2026-05-07 21:51:20 +08:00
CiiLu f146040ff3 fix: 版本列表项目点一下 tag 就会消失 (#6067) 2026-05-06 21:02:27 +08:00
辞庐 0da28173ab 优化设置 -> 通用页面 (#6014) 2026-05-04 17:15:38 +08:00
辞庐 be822666a7 导出 MultiMC 整合包时不再写入 MaxMemAlloc (#6032) 2026-05-04 17:12:24 +08:00
辞庐 8c99ee3b0d 优化种子隐藏按钮 (#6036) 2026-05-04 17:03:11 +08:00
Zkitefly 0e3b4a7853 Update link to software name and version number location (#6039) 2026-05-04 16:59:22 +08:00
辞庐 615b1dbaae fix: #5844 整合包实例版本号在特定场景下不显示 (#6054) 2026-05-04 16:53:56 +08:00
辞庐 0fa51f830d 旁观者模式 -> 旁观模式 (#6053) 2026-05-04 15:46:11 +08:00
ShulkerSakura 007bb345d0 Fixed wrong skin review model (#6046) 2026-05-04 15:44:11 +08:00
CalbootOnceMore 46536f11d3 [Chore] Transform FileDownloadTask.IntegrityCheck into record (#6052) 2026-05-03 20:44:10 +08:00
Glavo b1232b5dc8 缩放 8mi-tech.png 到合适的大小 (#6051) 2026-05-03 14:16:12 +08:00
8Mi_Yile f8f047d67e chore: thanks新增 八蓝米科技 (#6045) 2026-05-03 13:51:37 +08:00
辞庐 86529c6f15 清理 SwingFXUtils (#6030) 2026-05-01 11:15:11 +08:00
CalbootOnceMore d63829c344 [Chore] upgrade.RemoteVersion 转换为记录类 (#6029) 2026-04-30 21:37:54 +08:00
辞庐 34359a7e86 输入框 错误提示与确认按钮间添加间距 (#6025) 2026-04-30 20:50:29 +08:00
辞庐 aa335d8365 fix: 世界管理 -> 创建备份 点击后启动器崩溃 (#6023) 2026-04-30 20:49:52 +08:00
辞庐 a565d3b7ce 移除关于页面 “版权” 项超链接 (#6026) 2026-04-30 20:36:38 +08:00
辞庐 3bff3c7f23 邀请码输入框添加非空校验 (#6024) 2026-04-30 12:50:20 +08:00
4e6e832e9c feat: 文件选择器支持选择文件和文件夹 (#5806)
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-04-27 21:25:50 +08:00
辞庐 d6648a840e 优化微软登录未购买提示 (#5611) 2026-04-26 21:57:51 +08:00
辞庐 f8c2af716f fix: 主页面更新提示未设置鼠标指针为手形 (#6012) 2026-04-26 20:48:55 +08:00
辞庐 bd812ccb67 优化设置页更新按钮 (#6010) 2026-04-26 20:48:21 +08:00
辞庐 dad190f7e6 添加外置登录账户弹窗使用 JFXHyperLink (#6013) 2026-04-26 20:47:11 +08:00
Wulian233 1b1ba64a38 Fix 5963: 愚人节版本 Minecraft 2.0 的 Legacy Fabric 下载列表为空 (#6009) 2026-04-26 20:46:07 +08:00
辞庐 e31268c39d fix: 微软登录完成页弹窗缺少圆角 (#6006)
https://github.com/HMCL-dev/HMCL/pull/5832
2026-04-26 14:41:58 +08:00
Glavo cb9d32dc91 构建时创建 deb 包 (#5981) 2026-04-26 14:40:05 +08:00
辞庐 36568df5b1 rename NEO_FORGED to NEO_FORGE (#6008) 2026-04-26 13:55:34 +08:00
82 changed files with 1880 additions and 838 deletions

No files matched your search

+6
View File
@@ -48,3 +48,9 @@ jobs:
name: HMCL-${{ env.SHORT_SHA }}-sh
path: HMCL/build/libs/HMCL-*.sh
archive: false
- name: Upload DEB
uses: actions/upload-artifact@v7
with:
name: HMCL-${{ env.SHORT_SHA }}-deb
path: HMCL/build/libs/HMCL-*.deb
archive: false
+11
View File
@@ -46,16 +46,20 @@ jobs:
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.sh.sha256"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb"
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.deb.sha256"
- name: Verify artifacts
if: ${{ env.continue == 'true' }}
run: |
export JAR_SHA256=$(cat HMCL-$HMCL_VERSION.jar.sha256 | tr -d '\n')
export EXE_SHA256=$(cat HMCL-$HMCL_VERSION.exe.sha256 | tr -d '\n')
export SH_SHA256=$(cat HMCL-$HMCL_VERSION.sh.sha256 | tr -d '\n')
export DEB_SHA256=$(cat HMCL-$HMCL_VERSION.deb.sha256 | tr -d '\n')
echo "$JAR_SHA256 HMCL-$HMCL_VERSION.jar" | sha256sum -c
echo "$EXE_SHA256 HMCL-$HMCL_VERSION.exe" | sha256sum -c
echo "$SH_SHA256 HMCL-$HMCL_VERSION.sh" | sha256sum -c
echo "$DEB_SHA256 HMCL-$HMCL_VERSION.deb" | sha256sum -c
- name: Generate release note
if: ${{ env.continue == 'true' }}
run: |
@@ -67,6 +71,7 @@ jobs:
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($GH_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> RELEASE_NOTE
# CNB Release Note
echo "[更新日志](https://docs.hmcl.net/changelog/${{ matrix.channel }}.html#HMCL-$HMCL_VERSION)" >> CNB_RELEASE_NOTE
@@ -76,6 +81,8 @@ jobs:
echo "| [HMCL-$HMCL_VERSION.exe]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.jar]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.sh]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> CNB_RELEASE_NOTE
echo "| [HMCL-$HMCL_VERSION.deb]($CNB_DOWNLOAD_BASE_URL/v$HMCL_VERSION/HMCL-$HMCL_VERSION.deb) | \`$(cat HMCL-$HMCL_VERSION.deb.sha256)\` |" >> CNB_RELEASE_NOTE
env:
GH_DOWNLOAD_BASE_URL: https://github.com/${{ github.repository }}/releases/download
CNB_DOWNLOAD_BASE_URL: https://cnb.cool/HMCL-dev/HMCL/-/releases/download
@@ -86,6 +93,7 @@ jobs:
"HMCL-${{ env.HMCL_VERSION }}.exe" \
"HMCL-${{ env.HMCL_VERSION }}.jar" \
"HMCL-${{ env.HMCL_VERSION }}.sh" \
"HMCL-${{ env.HMCL_VERSION }}.deb" \
--target "${{ env.HMCL_COMMIT_SHA }}" \
--title "${{ env.HMCL_TAG_NAME }}" \
--notes-file RELEASE_NOTE \
@@ -120,6 +128,9 @@ jobs:
echo "Uploading HMCL-$HMCL_VERSION.sh"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.sh"
echo "Uploading HMCL-$HMCL_VERSION.deb"
~/go/bin/git-cnb release asset-upload --repo="$CNB_REPO" --tag-name "$HMCL_TAG_NAME" --file-name "HMCL-$HMCL_VERSION.deb"
env:
CNB_TOKEN: ${{ secrets.CNB_SYNC_TOKEN }}
CNB_REPO: HMCL-dev/HMCL
+37 -8
View File
@@ -6,6 +6,8 @@ import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
import org.jackhuang.hmcl.gradle.pack.CreateDeb
import org.jackhuang.hmcl.gradle.pack.ReleaseType
import org.jackhuang.hmcl.gradle.utils.PropertiesUtils
import java.net.URI
import java.nio.file.FileSystems
@@ -251,20 +253,22 @@ tasks.processResources {
}
}
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
val makeExecutables by tasks.registering {
val extensions = listOf("exe", "sh")
dependsOn(tasks.jar)
inputs.file(jarPath)
outputs.files(extensions.map { File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + it) })
outputs.files(extensions.map { artifactFile(it) })
doLast {
val jarContent = jarPath.readBytes()
ZipFile(jarPath).use { zipFile ->
for (extension in extensions) {
val output = File(jarPath.parentFile, jarPath.nameWithoutExtension + '.' + extension)
val output = artifactFile(extension)
val entry = zipFile.getEntry("assets/HMCLauncher.$extension")
?: throw GradleException("HMCLauncher.$extension not found")
@@ -279,8 +283,31 @@ val makeExecutables by tasks.registering {
}
}
val makeDeb by tasks.registering(CreateDeb::class) {
dependsOn(makeExecutables)
val debFile = layout.file(provider { artifactFile("deb") })
val debChannel = when (versionType) {
"stable" -> ReleaseType.STABLE
"dev" -> ReleaseType.DEVELOPMENT
else -> ReleaseType.NIGHTLY
}
version.set(project.version.toString())
releaseType.set(debChannel)
appShFile.set(layout.file(provider { artifactFile("sh") }))
iconFile.set(layout.projectDirectory.file("image/hmcl.png"))
outputFile.set(debFile)
doLast {
createChecksum(debFile.get().asFile)
}
}
tasks.build {
dependsOn(makeExecutables)
dependsOn(makeDeb)
}
fun parseToolOptions(options: String?): MutableList<String> {
@@ -377,12 +404,14 @@ val upgradeTerracottaConfig = tasks.register<TerracottaConfigUpgradeTask>("upgra
val destination = layout.projectDirectory.file("src/main/resources/assets/terracotta.json")
val source = layout.projectDirectory.file("terracotta-template.json");
classifiers.set(listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
))
classifiers.set(
listOf(
"windows-x86_64", "windows-arm64",
"macos-x86_64", "macos-arm64",
"linux-x86_64", "linux-arm64", "linux-loongarch64", "linux-riscv64",
"freebsd-x86_64"
)
)
version.set(libs.versions.terracotta)
downloadURL.set($$"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}-pkg.tar.gz")
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -42,10 +42,7 @@ import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.theme.Themes;
import org.jackhuang.hmcl.upgrade.UpdateChecker;
import org.jackhuang.hmcl.upgrade.UpdateHandler;
import org.jackhuang.hmcl.util.CrashReporter;
import org.jackhuang.hmcl.util.FileSaver;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.JarUtils;
import org.jackhuang.hmcl.util.platform.*;
@@ -347,6 +344,8 @@ public final class Launcher extends Application {
LOG.info("XDG Current Desktop: " + System.getenv("XDG_CURRENT_DESKTOP"));
}
LOG.info("Zlib Compatible: " + ZlibUtils.IS_ZLIB_COMPATIBLE);
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
launch(Launcher.class, args);
@@ -45,7 +45,6 @@ public final class Metadata {
public static final int RECOMMENDED_JAVA_VERSION = 21;
public static final String PUBLISH_URL = "https://hmcl.huangyuhui.net";
public static final String ABOUT_URL = PUBLISH_URL + "/about";
public static final String DOWNLOAD_URL = PUBLISH_URL + "/download";
public static final String HMCL_UPDATE_URL = System.getProperty("hmcl.update_source.override", PUBLISH_URL + "/api/update_link");
public static final String MANUAL_UPDATE_URL = "https://github.com/HMCL-dev/HMCL/releases";
@@ -468,7 +468,9 @@ public final class Accounts {
}
} else if (exception instanceof MicrosoftService.XBox400Exception) {
return i18n("account.methods.microsoft.error.wrong_verify_method");
} else if (exception instanceof MicrosoftService.NoMinecraftJavaEditionProfileException) {
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionLicenseNotFoundException) {
return i18n("account.methods.microsoft.error.no_license");
} else if (exception instanceof MicrosoftService.MinecraftJavaEditionProfileNotFoundException) {
return i18n("account.methods.microsoft.error.no_character");
} else if (exception instanceof MicrosoftService.NoXuiException) {
return i18n("account.methods.microsoft.error.add_family");
@@ -45,7 +45,7 @@ public enum VersionIconType {
public static VersionIconType getIconType(ModLoaderType modLoaderType) {
return switch (modLoaderType) {
case FORGE -> VersionIconType.FORGE;
case NEO_FORGED -> VersionIconType.NEO_FORGE;
case NEO_FORGE -> VersionIconType.NEO_FORGE;
case FABRIC -> VersionIconType.FABRIC;
case QUILT -> VersionIconType.QUILT;
case LITE_LOADER -> VersionIconType.CHICKEN;
@@ -71,9 +71,15 @@ public final class TerracottaBundle {
return new Context() {
@Override
public void withResult(boolean success) {
super.withResult(success);
delegate.withResult(success);
}
@Override
public void reset() throws IOException {
delegate.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
context.checkCancellation();
@@ -113,7 +119,7 @@ public final class TerracottaBundle {
throw new ArtifactMalformedException(String.format("Expecting %s file in terracotta bundle.", file));
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (
InputStream is = tree.getInputStream(archive);
OutputStream os = new DigestOutputStream(Files.newOutputStream(path), digest)
@@ -122,8 +128,8 @@ public final class TerracottaBundle {
}
String hash = HexFormat.of().formatHex(digest.digest());
if (!check.getChecksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.getAlgorithm(), check.getChecksum(), hash);
if (!check.checksum().equalsIgnoreCase(hash)) {
throw new ChecksumMismatchException(check.algorithm(), check.checksum(), hash);
}
switch (OperatingSystem.CURRENT_OS) {
@@ -172,7 +178,7 @@ public final class TerracottaBundle {
return false;
}
MessageDigest digest = DigestUtils.getDigest(check.getAlgorithm());
MessageDigest digest = DigestUtils.getDigest(check.algorithm());
try (InputStream is = new DigestInputStream(Files.newInputStream(path), digest)) {
int n;
while ((n = is.read(buffer)) >= 0) {
@@ -182,7 +188,7 @@ public final class TerracottaBundle {
}
}
}
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.getChecksum())) {
if (!HexFormat.of().formatHex(digest.digest()).equalsIgnoreCase(check.checksum())) {
return false;
}
}
@@ -70,6 +70,7 @@ import org.jackhuang.hmcl.util.i18n.SupportedLocale;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.platform.Architecture;
import org.jackhuang.hmcl.util.platform.OperatingSystem;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
@@ -594,7 +595,7 @@ public final class Controllers {
return pane.getCompletableFuture();
}
public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, TaskCancellationAction onCancel) {
public static TaskExecutorDialogPane taskDialog(TaskExecutor executor, String title, @NotNull TaskCancellationAction onCancel) {
TaskExecutorDialogPane pane = new TaskExecutorDialogPane(onCancel);
pane.setTitle(title);
pane.setExecutor(executor);
@@ -602,7 +603,7 @@ public final class Controllers {
return pane;
}
public static TaskExecutorDialogPane taskDialog(Task<?> task, String title, TaskCancellationAction onCancel) {
public static TaskExecutorDialogPane taskDialog(Task<?> task, String title, @NotNull TaskCancellationAction onCancel) {
TaskExecutor executor = task.executor();
TaskExecutorDialogPane pane = taskDialog(executor, title, onCancel);
executor.start();
@@ -62,6 +62,7 @@ import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.setting.StyleSheets;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -1166,12 +1167,10 @@ public final class FXUtils {
}
}
public static Image loadImage(String url) throws Exception {
URI uri = NetworkUtils.toURI(url);
URLConnection connection = NetworkUtils.createConnection(uri);
if (connection instanceof HttpURLConnection)
connection = NetworkUtils.resolveConnection((HttpURLConnection) connection);
public static Image loadImage(WebURL url) throws Exception {
URLConnection connection = NetworkUtils.createConnection(url);
if (connection instanceof HttpURLConnection httpConnection)
connection = NetworkUtils.resolveConnection(httpConnection);
try (BufferedInputStream input = new BufferedInputStream(connection.getInputStream())) {
String contentType = Objects.requireNonNull(connection.getContentType(), "");
@@ -62,6 +62,7 @@ public enum SVG {
EXTENSION_FILL("M8.8 21H5q-.825 0-1.4125-.5875T3 19V15.2q1.2 0 2.1-.7625T6 12.5q0-1.175-.9-1.9375T3 9.8V6q0-.825.5875-1.4125T5 4H9q0-1.05.725-1.775T11.5 1.5q1.05 0 1.775.725T14 4h4q.825 0 1.4125.5875T20 6v4q1.05 0 1.775.725T22.5 12.5q0 1.05-.725 1.775T20 15v4q0 .825-.5875 1.4125T18 21H14.2q0-1.25-.7875-2.125T11.5 18q-1.125 0-1.9125.875T8.8 21Z"),
FEEDBACK("M12 15Q12.425 15 12.7125 14.7125T13 14Q13 13.575 12.7125 13.2875T12 13Q11.575 13 11.2875 13.2875T11 14Q11 14.425 11.2875 14.7125T12 15ZM11 11H13V5H11V11ZM2 22V4Q2 3.175 2.5875 2.5875T4 2H20Q20.825 2 21.4125 2.5875T22 4V16Q22 16.825 21.4125 17.4125T20 18H6L2 22ZM5.15 16H20V4H4V17.125L5.15 16ZM4 16V4 16Z"),
FEEDBACK_FILL("M2 22V4q0-.825.5875-1.4125T4 2H20q.825 0 1.4125.5875T22 4V16q0 .825-.5875 1.4125T20 18H6L2 22Zm10-7q.425 0 .7125-.2875T13 14t-.2875-.7125T12 13t-.7125.2875T11 14t.2875.7125T12 15Zm-1-4h2V5H11v6Z"),
FILE_OPEN("M6 22q-0.825 0-1.4125-0.5875T4 20v-16q0-0.825 0.5875-1.4125T6 2h8l6 6v6h-2v-5H13v-5H6v16h9v2H6Zm15.95 0.375L19 19.425v2.225h-2v-5.65h5.65v2h-2.25l2.95 2.95-1.4 1.425Zm-15.95-2.375v-16 16Z"),
FOLDER("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8V18Q22 18.825 21.4125 19.4125T20 20H4ZM4 18H20V8H11.175L9.175 6H4V18ZM4 18V6 18Z"),
FOLDER_COPY("M3 21Q2.175 21 1.5875 20.4125T1 19V6H3V19H20V21H3ZM7 17Q6.175 17 5.5875 16.4125T5 15V4Q5 3.175 5.5875 2.5875T7 2H12L14 4H21Q21.825 4 22.4125 4.5875T23 6V15Q23 15.825 22.4125 16.4125T21 17H7ZM7 15H21V6H13.175L11.175 4H7V15ZM7 15V4 15Z"),
FOLDER_OPEN("M4 20Q3.175 20 2.5875 19.4125T2 18V6Q2 5.175 2.5875 4.5875T4 4H10L12 6H20Q20.825 6 21.4125 6.5875T22 8H11.175L9.175 6H4V18L6.4 10H23.5L20.925 18.575Q20.725 19.225 20.1875 19.6125T19 20H4ZM6.1 18H19L20.8 12H7.9L6.1 18ZM6.1 18 7.9 12 6.1 18ZM4 8V6 8Z"),
@@ -50,10 +50,10 @@ public final class UpgradeDialog extends JFXDialogLayout {
setHeading(new Label(i18n("update.changelog")));
setBody(new JFXSpinner());
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
String url = CHANGELOG_URL + remoteVersion.channel().channelName + ".html";
Task.supplyAsync(Schedulers.io(), () -> {
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.getVersion());
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.version());
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
if (targetVersion.compareTo(currentVersion) <= 0)
// Downgrade update, no need to display changelog
@@ -303,7 +303,7 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
for (String key : ALLOWED_LINKS) {
String value = links.get(key);
if (value != null) {
Hyperlink link = new Hyperlink(i18n("account.injector.link." + key));
JFXHyperlink link = new JFXHyperlink(i18n("account.injector.link." + key));
FXUtils.installSlowTooltip(link, value);
link.setOnAction(e -> FXUtils.openLink(value));
result.add(link);
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXComboBox;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.geometry.Insets;
@@ -29,6 +30,7 @@ import javafx.scene.control.Label;
import javafx.scene.input.DragEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.*;
import javafx.util.Duration;
import org.jackhuang.hmcl.auth.offline.OfflineAccount;
import org.jackhuang.hmcl.auth.offline.Skin;
import org.jackhuang.hmcl.auth.yggdrasil.TextureModel;
@@ -135,7 +137,9 @@ public class OfflineAccountSkinPane extends StackPane {
capeSelector.setValue(account.getSkin().getLocalCapePath());
}
skinBinding = FXUtils.observeWeak(() -> {
PauseTransition pauseTransition = new PauseTransition(Duration.seconds(1));
Runnable loadSkin = () -> {
getSkin().load(account.getUsername())
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null) {
@@ -157,6 +161,23 @@ public class OfflineAccountSkinPane extends StackPane {
result.getCape() != null ? result.getCape().getImage() : null);
}
}).start();
};
pauseTransition.setOnFinished(e -> loadSkin.run());
skinBinding = FXUtils.observeWeak(() -> {
Skin.Type selectedType = skinItem.getSelectedData();
if (selectedType == Skin.Type.CUSTOM_SKIN_LOADER_API) {
if (!cslApiField.validate()) {
pauseTransition.stop();
return;
}
pauseTransition.playFromStart();
} else {
pauseTransition.stop();
loadSkin.run();
}
}, skinItem.selectedDataProperty(), cslApiField.textProperty(), modelCombobox.valueProperty(), skinSelector.valueProperty(), capeSelector.valueProperty());
FXUtils.onChangeAndOperate(skinItem.selectedDataProperty(), selectedData -> {
@@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXPopup;
import com.jfoenix.controls.JFXTextField;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
@@ -30,6 +31,7 @@ import javafx.stage.FileChooser;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Path;
@@ -38,10 +40,18 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class FileSelector extends HBox {
private final StringProperty value = new SimpleStringProperty();
private String chooserTitle = i18n("selector.choose_file");
private boolean directory = false;
private String chooserTitle = "";
private SelectionMode selectionMode = SelectionMode.FILE;
private final ObservableList<FileChooser.ExtensionFilter> extensionFilters = FXCollections.observableArrayList();
JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15);
public enum SelectionMode {
FILE,
DIRECTORY,
FILE_OR_DIRECTORY
}
public String getValue() {
return value.get();
}
@@ -63,12 +73,12 @@ public class FileSelector extends HBox {
return this;
}
public boolean isDirectory() {
return directory;
public SelectionMode getSelectionMode() {
return selectionMode;
}
public FileSelector setDirectory(boolean directory) {
this.directory = directory;
public FileSelector setSelectionMode(SelectionMode selectionMode) {
this.selectionMode = selectionMode;
return this;
}
@@ -80,26 +90,20 @@ public class FileSelector extends HBox {
JFXTextField customField = new JFXTextField();
FXUtils.bindString(customField, valueProperty());
JFXButton selectButton = FXUtils.newToggleButton4(SVG.FOLDER_OPEN, 15);
selectButton.setOnAction(e -> {
if (directory) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(chooserTitle);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
value.setValue(path);
}
} else {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(chooserTitle);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (file != null) {
String path = FileUtils.getAbsolutePath(file);
customField.setText(path);
value.setValue(path);
switch (selectionMode) {
case FILE -> openFileChooser(customField);
case DIRECTORY -> openDirectoryChooser(customField);
case FILE_OR_DIRECTORY -> {
PopupMenu selectPopupMenu = new PopupMenu();
JFXPopup selectModePopup = new JFXPopup(selectPopupMenu);
selectPopupMenu.getContent().addAll(
new IconedMenuItem(SVG.FILE_OPEN, i18n("selector.choose_file"), () -> openFileChooser(customField), selectModePopup),
new IconedMenuItem(SVG.FOLDER_OPEN, i18n("selector.choose_directory"), () -> openDirectoryChooser(customField), selectModePopup)
);
selectModePopup.show(selectButton, JFXPopup.PopupVPosition.TOP, JFXPopup.PopupHPosition.RIGHT, -selectButton.getWidth(), 0);
}
}
});
@@ -108,4 +112,27 @@ public class FileSelector extends HBox {
setSpacing(3);
getChildren().addAll(customField, selectButton);
}
private void openFileChooser(JFXTextField customField) {
FileChooser chooser = new FileChooser();
chooser.getExtensionFilters().addAll(getExtensionFilters());
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_file") : chooserTitle);
Path file = FileUtils.toPath(chooser.showOpenDialog(Controllers.getStage()));
if (file != null) {
String path = FileUtils.getAbsolutePath(file);
customField.setText(path);
value.setValue(path);
}
}
private void openDirectoryChooser(JFXTextField customField) {
DirectoryChooser chooser = new DirectoryChooser();
chooser.setTitle(StringUtils.isBlank(chooserTitle) ? i18n("selector.choose_directory") : chooserTitle);
Path dir = FileUtils.toPath(chooser.showDialog(Controllers.getStage()));
if (dir != null) {
String path = FileUtils.getAbsolutePath(dir);
customField.setText(path);
value.setValue(path);
}
}
}
@@ -21,6 +21,7 @@ import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXTextField;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.geometry.Insets;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
@@ -56,6 +57,7 @@ public class InputDialogPane extends JFXDialogLayout implements DialogAware {
this.setBody(new VBox(textField));
lblCreationWarning = new Label();
lblCreationWarning.setPadding(new Insets(0, 5, 0, 0));
acceptPane = new SpinnerPane();
acceptPane.getStyleClass().add("small-spinner-pane");
@@ -20,10 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.controls.JFXPopup;
import javafx.beans.InvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ListProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleListProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.css.PseudoClass;
@@ -36,6 +33,8 @@ import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.UnknownNullability;
import java.util.Collection;
import java.util.Objects;
@@ -44,7 +43,7 @@ import java.util.function.Function;
import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition;
/// @author Glavo
public class LineSelectButton<T> extends LineButton {
public class LineSelectButton<T extends @UnknownNullability Object> extends LineButton {
private static final String DEFAULT_STYLE_CLASS = "line-select-button";
private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected");
@@ -57,13 +56,9 @@ public class LineSelectButton<T> extends LineButton {
this.getStyleClass().add(DEFAULT_STYLE_CLASS);
InvalidationListener updateTrailingText = observable -> {
Function<T, String> converter = getConverter();
T value = getValue();
if (value != null) {
Function<T, String> converter = getConverter();
setTrailingText(converter != null ? converter.apply(value) : value.toString());
} else {
setTrailingText(null);
}
setTrailingText(converter != null ? converter.apply(value) : Objects.toString(value, ""));
};
converterProperty().addListener(updateTrailingText);
valueProperty().addListener(updateTrailingText);
@@ -96,9 +91,6 @@ public class LineSelectButton<T> extends LineButton {
var itemTitleLabel = new Label();
itemTitleLabel.getStyleClass().add("title-label");
itemTitleLabel.textProperty().bind(Bindings.createStringBinding(() -> {
if (item == null)
return "";
Function<T, String> converter = getConverter();
return converter != null ? converter.apply(item) : Objects.toString(item, "");
}, converterProperty()));
@@ -173,10 +165,14 @@ public class LineSelectButton<T> extends LineButton {
return converterProperty().get();
}
public void setConverter(Function<T, String> value) {
public void setConverter(Function<T, String> value) { // TODO: rename
converterProperty().set(value);
}
public void setNullSafeConverter(Function<@NotNull T, String> value) {
converterProperty().set(it -> it != null ? value.apply(it) : "");
}
private ObjectProperty<Function<T, String>> descriptionConverter;
public ObjectProperty<Function<T, String>> descriptionConverterProperty() {
@@ -267,8 +267,8 @@ public final class MultiFileItem<T> extends VBox {
selector.setValue(value);
}
public FileOption<T> setDirectory(boolean directory) {
selector.setDirectory(directory);
public FileOption<T> setSelectionMode(FileSelector.SelectionMode selectionMode) {
selector.setSelectionMode(selectionMode);
return this;
}
@@ -30,7 +30,6 @@ import javafx.scene.control.ScrollPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.ui.FXUtils;
public class TwoLineListItem extends VBox {
private static final String DEFAULT_STYLE_CLASS = "two-line-list-item";
@@ -177,7 +176,18 @@ public class TwoLineListItem extends VBox {
lblTitle.setMinWidth(Label.USE_PREF_SIZE);
scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
FXUtils.onChangeAndOperate(tagsBox.heightProperty(), height -> FXUtils.setLimitHeight(scrollPane, height.doubleValue()));
var expectedHeight = Bindings.createDoubleBinding(
() -> {
if (tags.isEmpty()) return 0.0;
double h = tagsBox.prefHeight(-1);
return h > 0 ? h : Double.NaN;
},
tags, tagsBox.heightProperty()
);
scrollPane.minHeightProperty().bind(expectedHeight);
scrollPane.prefHeightProperty().bind(expectedHeight);
scrollPane.maxHeightProperty().bind(expectedHeight);
firstLine.getChildren().setAll(lblTitle, scrollPane);
tags.addListener((InvalidationListener) ignored -> scrollPane.requestLayout());
@@ -20,8 +20,8 @@ package org.jackhuang.hmcl.ui.construct;
import com.jfoenix.validation.base.ValidatorBase;
import javafx.beans.NamedArg;
import javafx.scene.control.TextInputControl;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
@@ -54,12 +54,7 @@ public class URLValidator extends ValidatorBase {
if (StringUtils.isBlank(textField.getText()))
hasErrors.set(!nullable);
else {
try {
NetworkUtils.toURI(textField.getText());
hasErrors.set(false);
} catch (IllegalArgumentException e) {
hasErrors.set(true);
}
hasErrors.set(WebURL.tryParseBrowserInput(textField.getText()) == null);
}
}
}
@@ -34,6 +34,7 @@ import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorDnD;
import org.jackhuang.hmcl.setting.EnumBackgroundImage;
@@ -205,7 +206,7 @@ public class DecoratorController {
String backgroundImageUrl = config().getBackgroundImageUrl();
if (backgroundImageUrl != null) {
try {
image = FXUtils.loadImage(backgroundImageUrl);
image = FXUtils.loadImage(WebURL.parseBrowserInput(backgroundImageUrl));
} catch (Exception e) {
LOG.warning("Couldn't load background image", e);
}
@@ -214,7 +214,7 @@ public final class ExportWizardProvider implements WizardProvider {
vs.isFullscreen(),
vs.getWidth(),
vs.getHeight(),
vs.getMaxMemory(),
null,
exportInfo.getMinMemory(),
vs.isShowLogs(),
/* showConsoleOnError */ true,
@@ -316,7 +316,7 @@ public final class ModpackInfoPage extends Control implements WizardPage {
if (skinnable.options.isRequireAuthlibInjectorServer()) {
var serversSelectButton = new LineSelectButton<AuthlibInjectorServer>();
serversSelectButton.setTitle(i18n("account.injector.server"));
serversSelectButton.setConverter(AuthlibInjectorServer::getName);
serversSelectButton.setNullSafeConverter(AuthlibInjectorServer::getName);
serversSelectButton.setDescriptionConverter(AuthlibInjectorServer::getUrl);
serversSelectButton.itemsProperty().set(config().getAuthlibInjectorServers());
@@ -72,7 +72,7 @@ public final class AboutPage extends SpinnerPane {
ComponentList legal = new ComponentList();
{
var copyright = LineButton.createExternalLinkButton(Metadata.ABOUT_URL);
var copyright = new LineButton();
copyright.setLargeTitle(true);
copyright.setTitle(i18n("about.copyright"));
copyright.setSubtitle(i18n("about.copyright.statement"));
@@ -27,14 +27,20 @@ import javafx.scene.control.ScrollPane;
import javafx.scene.control.ToggleGroup;
import javafx.scene.layout.*;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.task.FetchTask;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.WeakListenerHolder;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.javafx.SafeStringConverter;
import java.net.Proxy;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Function;
@@ -72,7 +78,7 @@ public class DownloadSettingsPage extends StackPane {
var versionListSourcePane = new LineSelectButton<String>();
versionListSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty().not());
versionListSourcePane.setTitle(i18n("settings.launcher.version_list_source"));
versionListSourcePane.setConverter(converter);
versionListSourcePane.setNullSafeConverter(converter);
versionListSourcePane.setDescriptionConverter(descriptionConverter);
versionListSourcePane.setItems(DownloadProviders.AUTO_PROVIDERS.keySet());
versionListSourcePane.valueProperty().bindBidirectional(config().versionListSourceProperty());
@@ -80,14 +86,14 @@ public class DownloadSettingsPage extends StackPane {
var downloadSourcePane = new LineSelectButton<String>();
downloadSourcePane.disableProperty().bind(autoChooseDownloadSource.selectedProperty());
downloadSourcePane.setTitle(i18n("settings.launcher.download_source"));
downloadSourcePane.setConverter(converter);
downloadSourcePane.setNullSafeConverter(converter);
downloadSourcePane.setDescriptionConverter(descriptionConverter);
downloadSourcePane.setItems(DownloadProviders.DIRECT_PROVIDERS.keySet());
downloadSourcePane.valueProperty().bindBidirectional(config().downloadTypeProperty());
var defaultAddonSourcePane = new LineSelectButton<String>();
defaultAddonSourcePane.setTitle(i18n("settings.launcher.default_addon_source"));
defaultAddonSourcePane.setConverter(key -> I18n.i18n("mods." + key));
defaultAddonSourcePane.setNullSafeConverter(key -> I18n.i18n("mods." + key));
defaultAddonSourcePane.setItems("modrinth", "curseforge");
defaultAddonSourcePane.valueProperty().bindBidirectional(config().defaultAddonSourceProperty());
@@ -98,9 +104,37 @@ public class DownloadSettingsPage extends StackPane {
}
{
var downloadList = new ComponentList();
VBox downloadThreads = new VBox(16);
downloadThreads.getStyleClass().add("card-non-transparent");
ComponentSublist fileCommonLocationSublist = new ComponentSublist();
{
{
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY)
.bindBidirectional(config().commonDirectoryProperty())
));
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
fileCommonLocationSublist.getContent().add(fileCommonLocation);
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocationSublist.subtitleProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
fileCommonLocationSublist.setHeaderRight(cleanButton);
}
{
JFXCheckBox chkAutoDownloadThreads = new JFXCheckBox(i18n("settings.launcher.download.threads.auto"));
VBox.setMargin(chkAutoDownloadThreads, new Insets(8, 0, 0, 0));
@@ -153,7 +187,8 @@ public class DownloadSettingsPage extends StackPane {
}
}
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadThreads);
downloadList.getContent().addAll(fileCommonLocationSublist, downloadThreads);
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("download")), downloadList);
}
{
@@ -330,4 +365,11 @@ public class DownloadSettingsPage extends StackPane {
}
}
private void clearCacheDirectory() {
String commonDirectory = Settings.instance().getCommonDirectory();
if (commonDirectory != null) {
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
}
}
}
@@ -28,6 +28,7 @@ import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.Tooltip;
import javafx.scene.image.ImageView;
@@ -170,6 +171,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
FXUtils.setLimitHeight(updatePane, 55);
StackPane.setAlignment(updatePane, Pos.TOP_RIGHT);
FXUtils.onClicked(updatePane, this::onUpgrade);
updatePane.setCursor(Cursor.HAND);
FXUtils.onChange(showUpdateProperty(), this::showUpdate);
{
@@ -183,7 +185,7 @@ public final class MainPage extends StackPane implements DecoratorPage {
prompt.setSubtitle(i18n("update.bubble.subtitle"));
prompt.setPickOnBounds(false);
prompt.titleProperty().bind(BindingMapping.of(latestVersionProperty()).map(latestVersion ->
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.getVersion())));
latestVersion == null ? "" : i18n("update.bubble.title", latestVersion.version())));
hBox.getChildren().setAll(SVG.UPDATE.createIcon(20), prompt);
}
@@ -279,10 +281,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
if (show && !config().isDisableAutoShowUpdateDialog()
&& getLatestVersion() != null
&& !Objects.equals(config().getPromptedVersion(), getLatestVersion().getVersion())) {
Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().getVersion()), MessageDialogPane.MessageType.INFO)
&& !Objects.equals(config().getPromptedVersion(), getLatestVersion().version())) {
Controllers.dialog(new MessageDialogPane.Builder("", i18n("update.bubble.title", getLatestVersion().version()), MessageDialogPane.MessageType.INFO)
.addAction(i18n("button.view"), () -> {
config().setPromptedVersion(getLatestVersion().getVersion());
config().setPromptedVersion(getLatestVersion().version());
onUpgrade();
})
.addCancel(null)
@@ -79,7 +79,7 @@ public class PersonalizationPage extends StackPane {
{
var brightnessPane = new LineSelectButton<String>();
brightnessPane.setTitle(i18n("settings.launcher.brightness"));
brightnessPane.setConverter(name -> i18n("settings.launcher.brightness." + name));
brightnessPane.setNullSafeConverter(name -> i18n("settings.launcher.brightness." + name));
brightnessPane.setItems("auto", "light", "dark");
brightnessPane.valueProperty().bindBidirectional(config().themeBrightnessProperty());
@@ -135,6 +135,7 @@ public class PersonalizationPage extends StackPane {
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumBackgroundImage.CUSTOM)
.setChooserTitle(i18n("launcher.background.choose"))
.addExtensionFilter(FXUtils.getImageExtensionFilter())
.setSelectionMode(FileSelector.SelectionMode.FILE_OR_DIRECTORY)
.bindBidirectional(config().backgroundImageProperty()),
new MultiFileItem.StringOption<>(i18n("launcher.background.network"), EnumBackgroundImage.NETWORK)
.setValidators(new URLValidator(true))
@@ -294,7 +295,7 @@ public class PersonalizationPage extends StackPane {
var fontAntiAliasingPane = new LineSelectButton<Optional<FontSmoothingType>>();
fontAntiAliasingPane.setTitle(i18n("settings.launcher.font.anti_aliasing"));
fontAntiAliasingPane.setSubtitle(i18n("settings.take_effect_after_restart"));
fontAntiAliasingPane.setConverter(value ->
fontAntiAliasingPane.setNullSafeConverter(value ->
value.isPresent()
? i18n("settings.launcher.font.anti_aliasing." + value.get().name().toLowerCase(Locale.ROOT))
: i18n("settings.launcher.font.anti_aliasing.auto")
@@ -20,21 +20,17 @@ package org.jackhuang.hmcl.ui.main;
import com.jfoenix.controls.JFXButton;
import javafx.beans.InvalidationListener;
import javafx.beans.WeakInvalidationListener;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.StringProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.geometry.VPos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.layout.*;
import javafx.scene.text.TextAlignment;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.EnumCommonDirectory;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -61,7 +57,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.zip.GZIPInputStream;
import java.util.zip.ZipEntry;
@@ -78,242 +76,193 @@ public final class SettingsPage extends ScrollPane {
public SettingsPage() {
this.setFitToWidth(true);
VBox rootPane = new VBox();
VBox rootPane = new VBox(10);
rootPane.setPadding(new Insets(10));
this.setContent(rootPane);
FXUtils.smoothScrolling(this);
ComponentList settingsPane = new ComponentList();
{
ComponentList updatePaneList = new ComponentList();
{
StackPane sponsorPane = new StackPane();
sponsorPane.setCursor(Cursor.HAND);
FXUtils.onClicked(sponsorPane, this::onSponsor);
sponsorPane.setPadding(new Insets(8, 0, 8, 0));
GridPane gridPane = new GridPane();
ColumnConstraints col = new ColumnConstraints();
col.setHgrow(Priority.SOMETIMES);
col.setMaxWidth(Double.POSITIVE_INFINITY);
gridPane.getColumnConstraints().setAll(col);
RowConstraints row = new RowConstraints();
row.setMinHeight(Double.NEGATIVE_INFINITY);
row.setValignment(VPos.TOP);
row.setVgrow(Priority.SOMETIMES);
gridPane.getRowConstraints().setAll(row);
ObjectProperty<UpdateChannel> updateChannel;
{
Label label = new Label(i18n("sponsor.hmcl"));
label.setWrapText(true);
label.setTextAlignment(TextAlignment.JUSTIFY);
GridPane.setRowIndex(label, 0);
GridPane.setColumnIndex(label, 0);
gridPane.getChildren().add(label);
}
sponsorPane.getChildren().setAll(gridPane);
settingsPane.getContent().add(sponsorPane);
}
JFXButton updateButton = FXUtils.newToggleButton4(SVG.UPDATE, 20);
updateButton.setOnAction(e -> onUpdate());
updateButton.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
ObjectProperty<UpdateChannel> updateChannel;
{
var updatePane = new LineSelectButton<UpdateChannel>() {
JFXButton updateButton = new JFXButton();
updateButton.setGraphic(SVG.UPDATE.createIcon(20));
updateButton.setOnAction(e -> onUpdate());
updateButton.setPadding(Insets.EMPTY);
FXUtils.installFastTooltip(updateButton, i18n("update.tooltip"));
{
getStyleClass().add("update-pane");
setNode(IDX_TRAILING, updateButton);
}
var updatePane = new LineSelectButton<UpdateChannel>() {
{
getStyleClass().add("update-pane");
setNode(IDX_TRAILING, updateButton);
}
@Override
protected int getTrailingTextIndex() {
return LineComponent.IDX_TRAILING + 1;
}
};
updateChannel = updatePane.valueProperty();
updatePane.setTitle(i18n("update"));
updatePane.setValue(UpdateChannel.getChannel());
updatePane.setConverter(channel -> i18n("update.channel." + channel.channelName));
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
{
updateListener = any -> {
boolean outdated = UpdateChecker.isOutdated();
updateButton.setVisible(outdated);
updateButton.setManaged(outdated);
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
if (UpdateChecker.isOutdated()) {
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion()));
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSubProperty.set(i18n("update.checking"));
} else {
lblUpdateSubProperty.set(i18n("update.latest"));
@Override
protected int getTrailingTextIndex() {
return LineComponent.IDX_TRAILING + 1;
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
updateChannel = updatePane.valueProperty();
updatePane.setTitle(i18n("update"));
updatePane.setValue(UpdateChannel.getChannel());
updatePane.setNullSafeConverter(channel -> i18n("update.channel." + channel.channelName));
updatePane.setItems(List.of(UpdateChannel.STABLE, UpdateChannel.DEVELOPMENT));
updatePane.setDescriptionConverter(channel -> i18n("update.note." + channel.channelName));
final StringProperty lblUpdateSubProperty = updatePane.subtitleProperty();
{
updateListener = any -> {
boolean outdated = UpdateChecker.isOutdated();
updateButton.setVisible(outdated);
updateButton.setManaged(outdated);
updatePane.pseudoClassStateChanged(PseudoClass.getPseudoClass("active"), outdated);
if (UpdateChecker.isOutdated()) {
lblUpdateSubProperty.set(i18n("update.newest_version", UpdateChecker.getLatestVersion().version()));
} else if (UpdateChecker.isCheckingUpdate()) {
lblUpdateSubProperty.set(i18n("update.checking"));
} else {
lblUpdateSubProperty.set(i18n("update.latest"));
}
};
UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener));
UpdateChecker.checkingUpdateProperty().addListener(new WeakInvalidationListener(updateListener));
updateListener.invalidated(null);
}
updatePaneList.getContent().add(updatePane);
}
settingsPane.getContent().add(updatePane);
{
LineToggleButton previewPane = new LineToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
updatePaneList.getContent().add(previewPane);
}
{
LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton();
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty());
updatePaneList.getContent().add(disableAutoShowUpdateDialogPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("update")), updatePaneList);
}
{
LineToggleButton previewPane = new LineToggleButton();
previewPane.setTitle(i18n("update.preview"));
previewPane.setSubtitle(i18n("update.preview.subtitle"));
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
ComponentList languagePaneList = new ComponentList();
InvalidationListener checkUpdateListener = e -> {
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
};
updateChannel.addListener(checkUpdateListener);
previewPane.selectedProperty().addListener(checkUpdateListener);
{
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
settingsPane.getContent().add(previewPane);
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setNullSafeConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty());
languagePaneList.getContent().add(chooseLanguagePane);
LineToggleButton disableAutoGameOptionsPane = new LineToggleButton();
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
languagePaneList.getContent().add(disableAutoGameOptionsPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.language")), languagePaneList);
}
{
LineToggleButton disableAutoShowUpdateDialogPane = new LineToggleButton();
disableAutoShowUpdateDialogPane.setTitle(i18n("update.disable_auto_show_update_dialog"));
disableAutoShowUpdateDialogPane.setSubtitle(i18n("update.disable_auto_show_update_dialog.subtitle"));
disableAutoShowUpdateDialogPane.selectedProperty().bindBidirectional(config().disableAutoShowUpdateDialogProperty());
settingsPane.getContent().add(disableAutoShowUpdateDialogPane);
ComponentList miscPaneList = new ComponentList();
if (AprilFools.isShowAprilFoolsSettings()) {
LineToggleButton disableAprilFools = new LineToggleButton();
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
miscPaneList.getContent().add(disableAprilFools);
}
{
LineToggleButton allowAutoAgentPane = new LineToggleButton();
allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent"));
allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle"));
allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty());
miscPaneList.getContent().add(allowAutoAgentPane);
}
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
miscPaneList.getContent().add(debugPane);
}
rootPane.getChildren().addAll(ComponentList.createComponentListTitle(i18n("settings.launcher.misc")), miscPaneList);
}
if (AprilFools.isShowAprilFoolsSettings()) {
LineToggleButton disableAprilFools = new LineToggleButton();
disableAprilFools.setTitle(i18n("settings.launcher.disable_april_fools"));
disableAprilFools.setSubtitle(i18n("settings.take_effect_after_restart"));
disableAprilFools.selectedProperty().bindBidirectional(config().disableAprilFoolsProperty());
settingsPane.getContent().add(disableAprilFools);
}
{
MultiFileItem<EnumCommonDirectory> fileCommonLocation = new MultiFileItem<>();
fileCommonLocation.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("launcher.cache_directory.default"), EnumCommonDirectory.DEFAULT),
new MultiFileItem.FileOption<>(i18n("settings.custom"), EnumCommonDirectory.CUSTOM)
.setChooserTitle(i18n("launcher.cache_directory.choose"))
.setDirectory(true)
.bindBidirectional(config().commonDirectoryProperty())
));
fileCommonLocation.selectedDataProperty().bindBidirectional(config().commonDirTypeProperty());
ComponentSublist fileCommonLocationSublist = new ComponentSublist();
fileCommonLocationSublist.getContent().add(fileCommonLocation);
fileCommonLocationSublist.setTitle(i18n("launcher.cache_directory"));
fileCommonLocationSublist.setHasSubtitle(true);
fileCommonLocationSublist.subtitleProperty().bind(
Bindings.createObjectBinding(() -> Optional.ofNullable(Settings.instance().getCommonDirectory())
.orElse(i18n("launcher.cache_directory.disabled")),
config().commonDirectoryProperty(), config().commonDirTypeProperty()));
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
cleanButton.setOnAction(e -> clearCacheDirectory());
fileCommonLocationSublist.setHeaderRight(cleanButton);
settingsPane.getContent().add(fileCommonLocationSublist);
}
{
var chooseLanguagePane = new LineSelectButton<SupportedLocale>();
chooseLanguagePane.setTitle(i18n("settings.launcher.language"));
chooseLanguagePane.setSubtitle(i18n("settings.take_effect_after_restart"));
SupportedLocale currentLocale = I18n.getLocale();
chooseLanguagePane.setConverter(locale -> {
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
});
chooseLanguagePane.setItems(SupportedLocale.getSupportedLocales());
chooseLanguagePane.valueProperty().bindBidirectional(config().localizationProperty());
settingsPane.getContent().add(chooseLanguagePane);
}
{
LineToggleButton disableAutoGameOptionsPane = new LineToggleButton();
disableAutoGameOptionsPane.setTitle(i18n("settings.launcher.disable_auto_game_options"));
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
settingsPane.getContent().add(disableAutoGameOptionsPane);
}
{
LineToggleButton allowAutoAgentPane = new LineToggleButton();
allowAutoAgentPane.setTitle(i18n("settings.launcher.allow_auto_agent"));
allowAutoAgentPane.setSubtitle(i18n("settings.launcher.allow_auto_agent.subtitle"));
allowAutoAgentPane.selectedProperty().bindBidirectional(config().allowAutoAgentProperty());
settingsPane.getContent().add(allowAutoAgentPane);
}
{
BorderPane debugPane = new BorderPane();
Label left = new Label(i18n("settings.launcher.debug"));
BorderPane.setAlignment(left, Pos.CENTER_LEFT);
debugPane.setLeft(left);
JFXButton openLogFolderButton = new JFXButton(i18n("settings.launcher.launcher_log.reveal"));
openLogFolderButton.setOnAction(e -> openLogFolder());
openLogFolderButton.getStyleClass().add("jfx-button-border");
if (LOG.getLogFile() == null)
openLogFolderButton.setDisable(true);
SpinnerPane exportLogPane = new SpinnerPane();
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
exportLogPane.setContent(logButton);
logButton.setOnAction(e -> {
exportLogPane.showSpinner();
onExportLogs().whenCompleteAsync((result, exception) -> {
exportLogPane.hideSpinner();
if (exception == null) {
Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", result));
FXUtils.showFileInExplorer(result);
} else {
LOG.warning("Failed to export logs", exception);
Controllers.dialog(
i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(exception),
null,
MessageType.ERROR
);
}
}, Schedulers.javafx());
});
HBox buttonBox = new HBox();
buttonBox.setSpacing(10);
buttonBox.getChildren().addAll(openLogFolderButton, exportLogPane);
BorderPane.setAlignment(buttonBox, Pos.CENTER_RIGHT);
debugPane.setRight(buttonBox);
settingsPane.getContent().add(debugPane);
}
rootPane.getChildren().add(settingsPane);
}
}
@@ -452,15 +401,4 @@ public final class SettingsPage extends ScrollPane {
return outputFile;
}), Schedulers.io());
}
private void onSponsor() {
FXUtils.openLink("https://github.com/HMCL-dev/HMCL");
}
private void clearCacheDirectory() {
String commonDirectory = Settings.instance().getCommonDirectory();
if (commonDirectory != null) {
FileUtils.cleanDirectoryQuietly(Path.of(commonDirectory, "cache"));
}
}
}
@@ -58,14 +58,14 @@ public class SkinCube extends MeshView {
5, 0, 4, 1, 0, 5, //P5,T0, P4,T1, P0,T5
5, 0, 0, 5, 1, 4, //P5,T0, P0,T5, P1,T4
// RIGHT
0, 5, 4, 6, 6, 11, //P0,T4 ,P4,T3, P6,T8
0, 5, 6, 11, 2, 10, //P0,T4 ,P6,T8, P2,T9
5, 3, 1, 4, 3, 9, //P5,T3, P1,T4, P3,T9
5, 3, 3, 9, 7, 8, //P5,T3, P3,T9, P7,T8
// FRONT
1, 4, 0, 5, 2, 10, //P1,T5, P0,T4, P2,T9
1, 4, 2, 10, 3, 9, //P1,T5, P2,T9, P3,T10
// LEFT
5, 3, 1, 4, 3, 9, //P5,T6, P1,T5, P3,T10
5, 3, 3, 9, 7, 8, //P5,T6, P3,T10,P7,T11
0, 5, 4, 6, 6, 11, //P0,T4 ,P4,T3, P6,T8
0, 5, 6, 11, 2, 10, //P0,T4 ,P6,T8, P2,T9
// BACK
4, 6, 5, 7, 7, 12, //P4,T6, P5,T7, P7,T12
4, 6, 7, 12, 6, 11, //P4,T6, P7,T12,P6,T11
@@ -131,14 +131,15 @@ public class SkinMultipleCubes extends Group {
// LEFT
getChildren().add(new Face(skin, start_x + width_interval + depth_interval, start_y + depth_interval, depth, height, interval, false, false,
() -> new Box(thick, length, length), (b, p) -> {
b.setTranslateX((width * length + thick) / 2.0);
b.setTranslateX(-(width * length + thick) / 2.0);
b.setTranslateY(-((height - 1) / 2.0 - p.getY()) * b.getHeight());
b.setTranslateZ(((depth - 1) / 2.0 - p.getX()) * b.getDepth());
}));
// RIGHT
getChildren().add(new Face(skin, start_x, start_y + depth_interval, depth, height, interval, true, false,
() -> new Box(thick, length, length), (b, p) -> {
b.setTranslateX(-(width * length + thick) / 2.0);
b.setTranslateX((width * length + thick) / 2.0);
b.setTranslateY(-((height - 1) / 2.0 - p.getY()) * b.getHeight());
b.setTranslateZ(((depth - 1) / 2.0 - p.getX()) * b.getDepth());
}));
@@ -246,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
} else {
handler.resolve();
}
});
}, "", new RequiredValidator(i18n("input.not_empty")));
});
if (ThreadLocalRandom.current().nextDouble() < 0.02D) {
@@ -183,7 +183,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
nativesDirSublist.setHasSubtitle(true);
nativesDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.advanced.natives_directory.custom"), NativesDirectoryType.CUSTOM)
.setChooserTitle(i18n("settings.advanced.natives_directory.choose"))
.setDirectory(true);
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
nativesDirItem.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("settings.advanced.natives_directory.default"), NativesDirectoryType.VERSION_FOLDER),
nativesDirCustomOption
@@ -191,7 +191,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
graphicsBackendPane = new LineSelectButton<>();
graphicsBackendPane.setTitle(i18n("settings.advanced.graphics_backend"));
graphicsBackendPane.setConverter(backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT)));
graphicsBackendPane.setNullSafeConverter(backend -> i18n("settings.advanced.graphics_backend." + backend.name().toLowerCase(Locale.ROOT)));
graphicsBackendPane.setDescriptionConverter(backend -> switch (backend) {
case DEFAULT -> i18n("settings.advanced.graphics_backend.default.desc");
case OPENGL -> i18n("settings.advanced.graphics_backend.opengl.desc");
@@ -210,7 +210,7 @@ public final class AdvancedVersionSettingPage extends StackPane implements Decor
rendererPane = new LineSelectButton<>();
rendererPane.setTitle(i18n("settings.advanced.renderer"));
rendererPane.setConverter(e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)));
rendererPane.setNullSafeConverter(e -> i18n("settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT)));
rendererPane.setDescriptionConverter(e -> {
String bundleKey = "settings.advanced.renderer." + e.name().toLowerCase(Locale.ROOT) + ".desc";
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
@@ -441,7 +441,7 @@ public class DownloadPage extends Control implements DecoratorPage {
case CLEANROOM:
content.addTag(i18n("install.installer.cleanroom"));
break;
case NEO_FORGED:
case NEO_FORGE:
content.addTag(i18n("install.installer.neoforge"));
break;
case FABRIC:
@@ -160,11 +160,11 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGED);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGED)
if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)
|| "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
@@ -487,7 +487,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
case FORGE -> i18n("install.installer.forge");
case CLEANROOM -> i18n("install.installer.cleanroom");
case LEGACY_FABRIC -> i18n("install.installer.legacyfabric");
case NEO_FORGED -> i18n("install.installer.neoforge");
case NEO_FORGE -> i18n("install.installer.neoforge");
case FABRIC -> i18n("install.installer.fabric");
case LITE_LOADER -> i18n("install.installer.liteloader");
case QUILT -> i18n("install.installer.quilt");
@@ -657,7 +657,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
case FORGE -> content.addTagWarning(i18n("install.installer.forge"));
case LEGACY_FABRIC -> content.addTagWarning(i18n("install.installer.legacyfabric"));
case CLEANROOM -> content.addTagWarning(i18n("install.installer.cleanroom"));
case NEO_FORGED -> content.addTagWarning(i18n("install.installer.neoforge"));
case NEO_FORGE -> content.addTagWarning(i18n("install.installer.neoforge"));
case FABRIC -> content.addTagWarning(i18n("install.installer.fabric"));
case LITE_LOADER -> content.addTagWarning(i18n("install.installer.liteloader"));
case QUILT -> content.addTagWarning(i18n("install.installer.quilt"));
@@ -239,7 +239,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
gameDirItem.disableProperty().bind(modpack);
gameDirCustomOption = new MultiFileItem.FileOption<>(i18n("settings.custom"), GameDirectoryType.CUSTOM)
.setChooserTitle(i18n("settings.game.working_directory.choose"))
.setDirectory(true);
.setSelectionMode(FileSelector.SelectionMode.DIRECTORY);
gameDirItem.loadChildren(Arrays.asList(
new MultiFileItem.Option<>(i18n("settings.advanced.game_dir.default"), GameDirectoryType.ROOT_FOLDER),
@@ -336,7 +336,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
launcherVisibilityPane = new LineSelectButton<>();
launcherVisibilityPane.setTitle(i18n("settings.advanced.launcher_visible"));
launcherVisibilityPane.setItems(LauncherVisibility.values());
launcherVisibilityPane.setConverter(e -> i18n("settings.advanced.launcher_visibility." + e.name().toLowerCase(Locale.ROOT)));
launcherVisibilityPane.setNullSafeConverter(e -> i18n("settings.advanced.launcher_visibility." + e.name().toLowerCase(Locale.ROOT)));
BorderPane dimensionPane = new BorderPane();
{
@@ -372,7 +372,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
enableDebugLogOutputPane.setTitle(i18n("settings.enable_debug_log_output"));
processPriorityPane = new LineSelectButton<>();
processPriorityPane.setTitle(i18n("settings.advanced.process_priority"));
processPriorityPane.setConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)));
processPriorityPane.setNullSafeConverter(e -> i18n("settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT)));
processPriorityPane.setDescriptionConverter(e -> {
String bundleKey = "settings.advanced.process_priority." + e.name().toLowerCase(Locale.ROOT) + ".desc";
return I18n.hasKey(bundleKey) ? i18n(bundleKey) : null;
@@ -40,10 +40,12 @@ import org.jackhuang.hmcl.ui.construct.RipplerContainer;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import java.nio.file.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -153,7 +155,7 @@ public final class WorldBackupsPage extends ListPageBase<WorldBackupsPage.Backup
LOG.warning("Failed to create backup", exception);
Controllers.dialog(i18n("world.backup.create.failed", StringUtils.getStackTrace(exception)), null, MessageDialogPane.MessageType.WARNING);
}
}), i18n("world.backup"), null);
}), i18n("world.backup"), TaskCancellationAction.NO_CANCEL);
}
private final class WorldBackupsPageSkin extends ToolbarListPageSkin<BackupInfo, WorldBackupsPage> {
@@ -24,13 +24,11 @@ import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Pos;
import javafx.scene.Cursor;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.effect.BoxBlur;
import javafx.scene.image.Image;
import javafx.scene.layout.HBox;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.stage.FileChooser;
import org.glavo.nbt.tag.*;
@@ -162,11 +160,10 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
seedPane.setTitle(i18n("world.info.random_seed"));
SimpleBooleanProperty visibility = new SimpleBooleanProperty();
StackPane visibilityButton = new StackPane();
JFXButton visibilityButton = FXUtils.newToggleButton4(SVG.VISIBILITY_OFF);
{
visibilityButton.setCursor(Cursor.HAND);
visibilityButton.setAlignment(Pos.CENTER_RIGHT);
FXUtils.onClicked(visibilityButton, () -> visibility.set(!visibility.get()));
visibilityButton.setAlignment(Pos.CENTER);
visibilityButton.setOnAction((event) -> visibility.set(!visibility.get()));
}
Label seedLabel = new Label();
@@ -180,7 +177,7 @@ public final class WorldInfoPage extends SpinnerPane implements WorldManagePage.
blur.setIterations(3);
FXUtils.onChangeAndOperate(visibility, isVisibility -> {
SVG icon = isVisibility ? SVG.VISIBILITY : SVG.VISIBILITY_OFF;
visibilityButton.getChildren().setAll(icon.createIcon(12));
visibilityButton.setGraphic(icon.createIcon(16));
seedLabel.setEffect(isVisibility ? null : blur);
});
}
@@ -27,8 +27,8 @@ final class HMCLDownloadTask extends FileDownloadTask {
private final RemoteVersion.Type archiveFormat;
public HMCLDownloadTask(RemoteVersion version, Path target) {
super(version.getUrl(), target, version.getIntegrityCheck());
archiveFormat = version.getType();
super(version.url(), target, version.integrityCheck());
archiveFormat = version.type();
}
@Override
@@ -23,11 +23,13 @@ import com.google.gson.JsonParseException;
import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.util.Optional;
public final class RemoteVersion {
public record RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck,
boolean preview, boolean force) {
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
try {
@@ -46,54 +48,8 @@ public final class RemoteVersion {
}
}
private final UpdateChannel channel;
private final String version;
private final String url;
private final Type type;
private final IntegrityCheck integrityCheck;
private final boolean preview;
private final boolean force;
public RemoteVersion(UpdateChannel channel, String version, String url, Type type, IntegrityCheck integrityCheck, boolean preview, boolean force) {
this.channel = channel;
this.version = version;
this.url = url;
this.type = type;
this.integrityCheck = integrityCheck;
this.preview = preview;
this.force = force;
}
public UpdateChannel getChannel() {
return channel;
}
public String getVersion() {
return version;
}
public String getUrl() {
return url;
}
public Type getType() {
return type;
}
public IntegrityCheck getIntegrityCheck() {
return integrityCheck;
}
public boolean isPreview() {
return preview;
}
public boolean isForce() {
return force;
}
@Override
public String toString() {
public @NotNull String toString() {
return "[" + version + " from " + url + "]";
}
@@ -43,13 +43,13 @@ public final class UpdateChecker {
RemoteVersion latest = latestVersion.get();
if (latest == null || isDevelopmentVersion(Metadata.VERSION)) {
return false;
} else if (latest.isForce()
} else if (latest.force()
|| Metadata.isNightly()
|| latest.getChannel() == UpdateChannel.NIGHTLY
|| latest.getChannel() != UpdateChannel.getChannel()) {
return !latest.getVersion().equals(Metadata.VERSION);
|| latest.channel() == UpdateChannel.NIGHTLY
|| latest.channel() != UpdateChannel.getChannel()) {
return !latest.version().equals(Metadata.VERSION);
} else {
return VersionNumber.compare(Metadata.VERSION, latest.getVersion()) < 0;
return VersionNumber.compare(Metadata.VERSION, latest.version()) < 0;
}
},
latestVersion);
@@ -1,162 +0,0 @@
// Copy from javafx.swing
/*
* Copyright (c) 2012, 2018, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation. Oracle designates this
* particular file as subject to the "Classpath" exception as provided
* by Oracle in the LICENSE file that accompanied this code.
*
* This code 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
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package org.jackhuang.hmcl.util;
import javafx.scene.image.*;
import javafx.scene.image.Image;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.awt.image.SampleModel;
import java.awt.image.SinglePixelPackedSampleModel;
import java.nio.IntBuffer;
/**
* This class provides utility methods for converting data types between
* Swing/AWT and JavaFX formats.
*
* @since JavaFX 2.2
*/
public final class SwingFXUtils {
private SwingFXUtils() {
}
/**
* Snapshots the specified {@link BufferedImage} and stores a copy of
* its pixels into a JavaFX {@link Image} object, creating a new
* object if needed.
* The returned {@code Image} will be a static snapshot of the state
* of the pixels in the {@code BufferedImage} at the time the method
* completes. Further changes to the {@code BufferedImage} will not
* be reflected in the {@code Image}.
* <p>
* The optional JavaFX {@link WritableImage} parameter may be reused
* to store the copy of the pixels.
* A new {@code Image} will be created if the supplied object is null,
* is too small or of a type which the image pixels cannot be easily
* converted into.
*
* @param bimg the {@code BufferedImage} object to be converted
* @param wimg an optional {@code WritableImage} object that can be
* used to store the returned pixel data
* @return an {@code Image} object representing a snapshot of the
* current pixels in the {@code BufferedImage}.
* @since JavaFX 2.2
*/
public static WritableImage toFXImage(BufferedImage bimg, WritableImage wimg) {
int bw = bimg.getWidth();
int bh = bimg.getHeight();
switch (bimg.getType()) {
case BufferedImage.TYPE_INT_ARGB:
case BufferedImage.TYPE_INT_ARGB_PRE:
break;
default:
BufferedImage converted =
new BufferedImage(bw, bh, BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D g2d = converted.createGraphics();
g2d.drawImage(bimg, 0, 0, null);
g2d.dispose();
bimg = converted;
break;
}
// assert(bimg.getType == TYPE_INT_ARGB[_PRE]);
if (wimg != null) {
int iw = (int) wimg.getWidth();
int ih = (int) wimg.getHeight();
if (iw < bw || ih < bh) {
wimg = null;
} else if (bw < iw || bh < ih) {
int[] empty = new int[iw];
PixelWriter pw = wimg.getPixelWriter();
PixelFormat<IntBuffer> pf = PixelFormat.getIntArgbPreInstance();
if (bw < iw) {
pw.setPixels(bw, 0, iw - bw, bh, pf, empty, 0, 0);
}
if (bh < ih) {
pw.setPixels(0, bh, iw, ih - bh, pf, empty, 0, 0);
}
}
}
if (wimg == null) {
wimg = new WritableImage(bw, bh);
}
PixelWriter pw = wimg.getPixelWriter();
DataBufferInt db = (DataBufferInt) bimg.getRaster().getDataBuffer();
int[] data = db.getData();
int offset = bimg.getRaster().getDataBuffer().getOffset();
int scan = 0;
SampleModel sm = bimg.getRaster().getSampleModel();
if (sm instanceof SinglePixelPackedSampleModel) {
scan = ((SinglePixelPackedSampleModel) sm).getScanlineStride();
}
PixelFormat<IntBuffer> pf = (bimg.isAlphaPremultiplied() ?
PixelFormat.getIntArgbPreInstance() :
PixelFormat.getIntArgbInstance());
pw.setPixels(0, 0, bw, bh, pf, data, offset, scan);
return wimg;
}
public static WritableImage toFXImage(BufferedImage bimg, double requestedWidth, double requestedHeight, boolean preserveRatio, boolean smooth) {
if (requestedWidth <= 0. || requestedHeight <= 0.) {
return toFXImage(bimg, null);
}
int width = (int) requestedWidth;
int height = (int) requestedHeight;
// Calculate actual dimensions if preserveRatio is true
if (preserveRatio) {
double originalWidth = bimg.getWidth();
double originalHeight = bimg.getHeight();
double scaleX = requestedWidth / originalWidth;
double scaleY = requestedHeight / originalHeight;
double scale = Math.min(scaleX, scaleY);
width = (int) (originalWidth * scale);
height = (int) (originalHeight * scale);
}
// Create scaled BufferedImage
BufferedImage scaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB_PRE);
Graphics2D g2d = scaledImage.createGraphics();
try {
if (smooth) {
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
}
g2d.drawImage(bimg, 0, 0, width, height, null);
} finally {
g2d.dispose();
}
return toFXImage(scaledImage, null);
}
}
@@ -98,5 +98,10 @@
"title": "TomlJ",
"subtitle": "Licensed under the Apache 2.0 License.",
"externalLink": "https://github.com/tomlj/tomlj"
},
{
"title": "WebURL for Java",
"subtitle": "Licensed under the Apache 2.0 License.",
"externalLink": "https://github.com/Glavo/weburl-java"
}
]
@@ -70,6 +70,12 @@
"subtitleLocalized" : "about.thanks_to.mcim.statement",
"externalLink" : "https://github.com/mcmod-info-mirror"
},
{
"image" : "/assets/img/8mi-tech.png",
"titleLocalized" : "about.thanks_to.8mi-tech",
"subtitleLocalized" : "about.thanks_to.8mi-tech.statement",
"externalLink" : "https://8mi.tech"
},
{
"image" : {
"light" : "/assets/img/github.png",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -48,6 +48,8 @@ about.thanks_to.shulkersakura.statement=For providing the logo for HMCL.
about.thanks_to.users=HMCL User Group Members
about.thanks_to.users.statement=Thanks for donations, bug reports, and so on.
about.thanks_to.yushijinhun.statement=For providing the authlib-injector related support.
about.thanks_to.8mi-tech=8Mi-Tech (Quanzhou) Co., Ltd.
about.thanks_to.8mi-tech.statement=Provides an accelerated download service for some of the game's static resources.
about.open_source=Open Source
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
@@ -108,9 +110,9 @@ account.methods.microsoft.close_page=Microsoft account authorization is now comp
account.methods.microsoft.deauthorize=Deauthorize
account.methods.microsoft.error.add_family=Please click <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">here</a> to change your account birth date to be over 18 years old, or add your account to a family.
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 click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to link one.
account.methods.microsoft.error.no_character=Please confirm that you have purchased Minecraft: Java Edition.\n\
If you have already purchased it, a game profile may not have been created. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create a game profile.
account.methods.microsoft.error.missing_xbox_account=Please confirm that you have purchased Minecraft: Java Edition. If you have, please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create your game profile.
account.methods.microsoft.error.no_license=No Minecraft purchase record found. Please click <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">here</a> to get the game.\nIf you are using Game Pass, please create your game profile <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a>. If you have already purchased the game, please check if you are logged into the correct account.
account.methods.microsoft.error.no_character=You have not created a game profile yet. Please click <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">here</a> to create one.
account.methods.microsoft.error.banned=Your account may have been banned by Xbox Live.\n\
You can click <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">here</a> to check the ban status of your account.
account.methods.microsoft.error.unknown=Failed to log in, error code: %d.
@@ -1307,6 +1309,7 @@ search.page_n=%1$d / %2$s
selector.choose=Choose
selector.choose_file=Choose file
selector.choose_directory=Choose directory
selector.custom=Custom
settings=Settings
@@ -1481,6 +1484,7 @@ settings.launcher.download_source=Download Source
settings.launcher.download_source.auto=Automatically Choose Download Sources
settings.launcher.default_addon_source=Default Addon Source
settings.launcher.enable_game_list=Show instance list in homepage
settings.launcher.misc=Misc
settings.launcher.font=Font
settings.launcher.font.anti_aliasing=Anti-aliasing
settings.launcher.font.anti_aliasing.auto=Auto
@@ -46,6 +46,8 @@ about.thanks_to.shulkersakura.statement=鑄其徽
about.thanks_to.users=HMCL 群組諸員
about.thanks_to.users.statement=敬謝諸君之貲勉、報謬、謀案
about.thanks_to.yushijinhun.statement=有助於 authlib-injector
about.thanks_to.8mi-tech=八藍米資訊科技 (泉州) 有限公所
about.thanks_to.8mi-tech.statement=供遊戲部分靜態資源之加速下載役
about.open_source=開源
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
@@ -47,6 +47,8 @@ about.thanks_to.shulkersakura.statement=提供 HMCL 的標誌
about.thanks_to.users=HMCL 使用者群組成員
about.thanks_to.users.statement=感謝使用者群組成員贊助充電、積極催更、回報問題、出謀劃策
about.thanks_to.yushijinhun.statement=提供 authlib-injector 相關支援
about.thanks_to.8mi-tech=八藍米信息科技 (泉州) 有限公司
about.thanks_to.8mi-tech.statement=提供了遊戲部分靜態資源的加速下載服務
about.open_source=開放原始碼
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL/)
@@ -109,8 +111,9 @@ account.methods.microsoft.close_page=已完成 Microsoft 帳戶授權。啟動
account.methods.microsoft.deauthorize=移除應用存取權
account.methods.microsoft.error.add_family=請點擊 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此處</a> 更改你的帳戶出生日期,使年齡滿 18 歲以上,或將帳戶加入到家庭中。
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 Xbox Live 的支援。
account.methods.microsoft.error.missing_xbox_account=請點擊下方 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 關聯 Xbox 帳戶。
account.methods.microsoft.error.no_character=請確認你已經購買了 Minecraft: Java 版。\n若已購買,則可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
account.methods.microsoft.error.missing_xbox_account=請確認你已經購買了 Minecraft: Java 版。若已購買,請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
account.methods.microsoft.error.no_license=未找到 Minecraft 購買記錄。請點擊 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此處</a> 購買遊戲。\n若你使用 Game Pass,請在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。若已購買,請檢查登入的帳戶是否正確。
account.methods.microsoft.error.no_character=你可能未建立遊戲檔案。請點擊 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此處</a> 建立遊戲檔案。
account.methods.microsoft.error.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
account.methods.microsoft.error.unknown=登入失敗。錯誤碼:%d。
account.methods.microsoft.error.wrong_verify_method=登入失敗。請在 Microsoft 帳戶登入頁面嘗試使用密碼登入,不要使用其他登入方式。
@@ -1098,6 +1101,7 @@ search.page_n=%d / %s
selector.choose=選取
selector.choose_file=選取檔案
selector.choose_directory=選取目錄
selector.custom=自訂
settings=設定
@@ -1270,6 +1274,7 @@ settings.launcher.download_source=下載來源
settings.launcher.download_source.auto=自動選取下載來源
settings.launcher.default_addon_source=遊戲內容預設下載來源
settings.launcher.enable_game_list=在首頁內顯示遊戲清單
settings.launcher.misc=雜項
settings.launcher.font=字體
settings.launcher.font.anti_aliasing=反鋸齒
settings.launcher.font.anti_aliasing.auto=自動
@@ -47,6 +47,8 @@ about.thanks_to.shulkersakura.statement=提供 HMCL 的徽标
about.thanks_to.users=HMCL 用户群成员
about.thanks_to.users.statement=感谢用户群成员赞助充电、积极催更、反馈问题、出谋划策
about.thanks_to.yushijinhun.statement=提供 authlib-injector 相关支持
about.thanks_to.8mi-tech=八蓝米信息科技 (泉州) 有限公司
about.thanks_to.8mi-tech.statement=提供了游戏部分静态资源的加速下载服务
about.open_source=开源
about.open_source.statement=GPL v3 (https://github.com/HMCL-dev/HMCL)
@@ -110,8 +112,9 @@ account.methods.microsoft.close_page=已完成微软账户授权。其余登录
account.methods.microsoft.deauthorize=解除账户授权
account.methods.microsoft.error.add_family=请点击 <a href="https://support.microsoft.com/account-billing/837badbc-999e-54d2-2617-d19206b9540a">此处</a> 更改你的账户出生日期,使年龄满 18 岁以上,或将账户加入到家庭中。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 Xbox Live 的支持。
account.methods.microsoft.error.missing_xbox_account=请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 关联 Xbox 账户。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.no_character=请确认你已经购买了 Minecraft: Java 版。\n若已购买,则可能未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.missing_xbox_account=请确认你已经购买了 Minecraft: Java 版。若已购买,请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.no_license=未找到 Minecraft 购买记录。请点击 <a href="https://www.xbox.com/games/store/minecraft-java-bedrock-edition-for-pc/9nxp44l49shj">此处</a> 购买游戏。\n若你使用 Game Pass,请在 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。若已购买,请检查登录的账户是否正确。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.no_character=你尚未创建游戏档案。请点击 <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">此处</a> 创建游戏档案。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
account.methods.microsoft.error.unknown=未知问题。错误码:%d。\n你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.error.wrong_verify_method=登录失败。请在微软账户登录页面使用密码登录,不要使用其他登录方式。\n你可以点击右上角帮助按钮进行求助。
@@ -366,11 +369,11 @@ download.provider.bmclapi.desc=适用于中国大陆的镜像源
download.provider.mojang=官方
download.provider.mojang.desc=注:OptiFine 自动安装使用 BMCLAPI 下载源
download.provider.official=尽量使用官方源
download.provider.official.desc=最新,但可能加载慢
download.provider.official.desc=优先从官方源获取信息
download.provider.balanced=选择加载速度快的下载源
download.provider.balanced.desc=平衡,但可能不是最新
download.provider.balanced.desc=根据你的网络连接情况选择最佳源
download.provider.mirror=尽量使用镜像源
download.provider.mirror.desc=加载快,但可能不是最新
download.provider.mirror.desc=推荐中国大陆用户使用
download.java=下载 Java
download.java.process=下载 Java
download.javafx=正在下载必要的运行时组件……
@@ -1015,7 +1018,7 @@ world.info.player.game_type=游戏模式
world.info.player.game_type.adventure=冒险
world.info.player.game_type.creative=创造
world.info.player.game_type.hardcore=极限
world.info.player.game_type.spectator=旁观者
world.info.player.game_type.spectator=旁观
world.info.player.game_type.survival=生存
world.info.player.health=生命值
world.info.player.last_death_location=上次死亡位置
@@ -1103,6 +1106,7 @@ search.page_n=%d / %s
selector.choose=选择
selector.choose_file=选择文件
selector.choose_directory=选择文件夹
selector.custom=自定义
settings=设置
@@ -1275,6 +1279,7 @@ settings.launcher.download_source=下载源
settings.launcher.download_source.auto=自动选择下载源
settings.launcher.default_addon_source=游戏内容默认下载源
settings.launcher.enable_game_list=在主页内显示版本列表
settings.launcher.misc=杂项
settings.launcher.font=字体
settings.launcher.font.anti_aliasing=抗锯齿
settings.launcher.font.anti_aliasing.auto=自动
@@ -44,6 +44,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
}
.dialog-card {
border-radius: 4px;
background-color: var(--monet-surface-container-high);
padding: 24px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.14), 0 0 0 1px rgba(0, 0, 0, 0.02);
+1
View File
@@ -27,6 +27,7 @@ dependencies {
api(libs.jna)
api(libs.pci.ids)
api(libs.hello.nbt)
api(libs.weburl)
compileOnlyApi(libs.jetbrains.annotations)
@@ -24,12 +24,11 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import java.io.IOException;
import java.lang.reflect.Type;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilService;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.io.NetworkUtils;
@@ -58,13 +57,17 @@ public class AuthlibInjectorServer implements Observable {
public static AuthlibInjectorServer locateServer(String url) throws IOException {
try {
url = NetworkUtils.addHttpsIfMissing(url);
HttpURLConnection conn = NetworkUtils.createHttpConnection(url);
WebURL webURL = WebURL.parseBrowserInput(url);
url = webURL.toString();
HttpURLConnection conn = NetworkUtils.createHttpConnection(webURL);
conn = NetworkUtils.resolveConnection(conn);
String ali = conn.getHeaderField("x-authlib-injector-api-location");
if (ali != null) {
URI absoluteAli = conn.getURL().toURI().resolve(NetworkUtils.toURI(ali));
if (!urlEqualsIgnoreSlash(url, absoluteAli.toString())) {
WebURL absoluteAli = WebURL.parse(ali, WebURL.of(conn.getURL()));
if (!urlEqualsIgnoreSlash(webURL.toString(), absoluteAli.toString())) {
conn.disconnect();
url = absoluteAli.toString();
conn = NetworkUtils.resolveConnection(NetworkUtils.createHttpConnection(absoluteAli));
@@ -81,7 +84,7 @@ public class AuthlibInjectorServer implements Observable {
} finally {
conn.disconnect();
}
} catch (IllegalArgumentException | URISyntaxException e) {
} catch (IllegalArgumentException e) {
throw new IOException(e);
}
}
@@ -25,7 +25,10 @@ import org.jackhuang.hmcl.auth.AuthenticationException;
import org.jackhuang.hmcl.auth.OAuth;
import org.jackhuang.hmcl.auth.ServerDisconnectException;
import org.jackhuang.hmcl.auth.ServerResponseMalformedException;
import org.jackhuang.hmcl.auth.yggdrasil.*;
import org.jackhuang.hmcl.auth.yggdrasil.CompleteGameProfile;
import org.jackhuang.hmcl.auth.yggdrasil.RemoteAuthenticationException;
import org.jackhuang.hmcl.auth.yggdrasil.Texture;
import org.jackhuang.hmcl.auth.yggdrasil.TextureType;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.*;
import org.jackhuang.hmcl.util.io.*;
@@ -245,7 +248,16 @@ public class MicrosoftService {
.createConnection();
int responseCode = conn.getResponseCode();
if (responseCode == HTTP_NOT_FOUND) {
throw new NoMinecraftJavaEditionProfileException();
MinecraftLicense license = HttpRequest.GET("https://api.minecraftservices.com/entitlements/license")
.authorization(tokenType, accessToken)
.getJson(MinecraftLicense.class);
boolean hasMinecraftLicense = license != null && license.items() != null && license.items().stream()
.anyMatch(item -> "game_minecraft".equals(item.name()));
if (!hasMinecraftLicense) {
throw new MinecraftJavaEditionLicenseNotFoundException();
} else {
throw new MinecraftJavaEditionProfileNotFoundException();
}
} else if (responseCode != 200) {
throw new ResponseCodeException("https://api.minecraftservices.com/minecraft/profile", responseCode);
}
@@ -324,7 +336,10 @@ public class MicrosoftService {
public final static class XBox400Exception extends AuthenticationException {
}
public final static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
public final static class MinecraftJavaEditionProfileNotFoundException extends AuthenticationException {
}
public final static class MinecraftJavaEditionLicenseNotFoundException extends AuthenticationException {
}
public final static class NoXuiException extends AuthenticationException {
@@ -425,6 +440,21 @@ public class MicrosoftService {
}
@JsonSerializable
public record MinecraftLicense(
@SerializedName("items") List<MinecraftLicenseItem> items,
@SerializedName("signature") String signature,
@SerializedName("keyId") String keyId
) {
}
@JsonSerializable
public record MinecraftLicenseItem(
@SerializedName("name") String name,
@SerializedName("signature") String signature
) {
}
public static class MinecraftProfileResponse extends MinecraftErrorResponse implements Validation {
@SerializedName("id")
UUID id;
@@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayInputStream;
@@ -244,10 +245,15 @@ public class Skin {
}
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
return new Context() {
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@Override
public void reset() throws IOException {
baos.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) {
baos.write(buffer, offset, len);
@@ -260,7 +266,7 @@ public class Skin {
setResult(new ByteArrayInputStream(baos.toByteArray()));
if (checkETag) {
repository.cacheBytes(response, baos.toByteArray());
repository.cacheBytes(UrlResponseInfo.of(response), baos.toByteArray());
}
}
};
@@ -242,7 +242,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
}
},
CLEANROOM(true, "cleanroom", "com\\.cleanroommc", "cleanroom", ModLoaderType.CLEANROOM),
NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGED) {
NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGE) {
private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?<forge>[0-9.]+)(-([0-9.]+))?$");
@Override
@@ -390,7 +390,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher";
public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher";
public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap";
public static final String NEO_FORGED_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client";
public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client";
public static final Set<String> FORGE_OPTIFINE_MAIN = Set.of(
LibraryAnalyzer.VANILLA_MAIN,
@@ -398,7 +398,7 @@ public final class LibraryAnalyzer implements Iterable<LibraryAnalyzer.LibraryMa
LibraryAnalyzer.MOD_LAUNCHER_MAIN,
LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN,
LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN,
LibraryAnalyzer.NEO_FORGED_BOOTSTRAP_MAIN
LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN
);
public static final VersionRange<VersionNumber> FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50");
@@ -33,6 +33,7 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.DigestUtils;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.ZlibUtils;
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
@@ -168,6 +169,22 @@ public class ForgeNewInstallTask extends Task<Version> {
}
if (!Objects.equals(code, entry.getValue())) {
if (!ZlibUtils.IS_ZLIB_COMPATIBLE && FileUtils.getExtension(artifact).equals("jar")) {
// Forge/NeoForge generates JARs dynamically during installation.
// When native compression libraries such as zlib-ng are in use,
// the resulting JAR may be compressed differently, causing its
// SHA-1 hash to differ from the expected value recorded in the
// install profile. In this case, fall back to verifying that the
// file is at least a structurally valid ZIP/JAR archive.
try {
FileDownloadTask.ZIP_INTEGRITY_CHECK_HANDLER.checkIntegrity(artifact, artifact);
LOG.info("Ignoring SHA-1 mismatch for " + artifact + " due to non-standard zlib compression output");
continue;
} catch (Exception ignored) {
}
}
Files.delete(artifact);
throw new ChecksumMismatchException("SHA-1", entry.getValue(), code);
}
@@ -52,10 +52,13 @@ public final class LegacyFabricVersionList extends VersionList<LegacyFabricRemot
lock.writeLock().lock();
try {
for (String gameVersion : gameVersions)
for (String loaderVersion : loaderVersions)
for (String metaGameVersion : gameVersions) {
String gameVersion = normalizeVersion(metaGameVersion);
for (String loaderVersion : loaderVersions) {
versions.put(gameVersion, new LegacyFabricRemoteVersion(gameVersion, loaderVersion,
Collections.singletonList(getLaunchMetaUrl(gameVersion, loaderVersion))));
Collections.singletonList(getLaunchMetaUrl(metaGameVersion, loaderVersion))));
}
}
} finally {
lock.writeLock().unlock();
}
@@ -71,6 +74,12 @@ public final class LegacyFabricVersionList extends VersionList<LegacyFabricRemot
.stream().map(GameVersion::getVersion).collect(Collectors.toList());
}
private static String normalizeVersion(String version) {
return version.startsWith("2point0_")
? "2.0_" + version.substring("2point0_".length())
: version;
}
private static String getLaunchMetaUrl(String gameVersion, String loaderVersion) {
return String.format("https://meta.legacyfabric.net/v2/versions/loader/%s/%s", gameVersion, loaderVersion);
}
@@ -21,7 +21,7 @@ public enum ModLoaderType {
UNKNOWN,
FORGE,
CLEANROOM,
NEO_FORGED,
NEO_FORGE,
FABRIC,
QUILT,
LITE_LOADER,
@@ -47,7 +47,7 @@ public final class ModManager {
var map = new HashMap<String, List<Pair<ModMetadataReader, ModLoaderType>>>();
var zipReaders = List.<Pair<ModMetadataReader, ModLoaderType>>of(
pair(ForgeNewModMetadata::fromForgeFile, ModLoaderType.FORGE),
pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGED),
pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGE),
pair(ForgeOldModMetadata::fromFile, ModLoaderType.FORGE),
pair(FabricModMetadata::fromFile, ModLoaderType.FABRIC),
pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT),
@@ -598,7 +598,7 @@ public class CurseAddon implements RemoteMod.IMod {
if ("fabric".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.FABRIC);
else if ("forge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.FORGE);
else if ("quilt".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.QUILT);
else if ("neoforge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.NEO_FORGED);
else if ("neoforge".equalsIgnoreCase(version)) return Stream.of(ModLoaderType.NEO_FORGE);
else return Stream.empty();
}).collect(Collectors.toList())
);
@@ -174,15 +174,15 @@ public final class ForgeNewModMetadata {
}
public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException {
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGED);
return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGE);
}
private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException {
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGED) {
if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGE) {
throw new IOException("Invalid mod loader: " + modLoaderType);
}
if (modLoaderType == ModLoaderType.NEO_FORGED) {
if (modLoaderType == ModLoaderType.NEO_FORGE) {
try {
return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree);
} catch (Exception ignored) {
@@ -344,7 +344,7 @@ public final class ForgeNewModMetadata {
result = ModLoaderType.FORGE;
break loop;
case "neoforge":
result = ModLoaderType.NEO_FORGED;
result = ModLoaderType.NEO_FORGE;
break loop;
}
}
@@ -668,7 +668,7 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository {
loaders.stream().flatMap(loader -> {
if ("fabric".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.FABRIC);
else if ("forge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.FORGE);
else if ("neoforge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.NEO_FORGED);
else if ("neoforge".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.NEO_FORGE);
else if ("quilt".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.QUILT);
else if ("liteloader".equalsIgnoreCase(loader)) return Stream.of(ModLoaderType.LITE_LOADER);
else return Stream.empty();
@@ -19,14 +19,18 @@ package org.jackhuang.hmcl.task;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -80,17 +84,29 @@ public final class CacheFileTask extends FetchTask<Path> {
}
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
assert checkETag;
assert response != null;
Path temp = Files.createTempFile("hmcl-download-", null);
OutputStream fileOutput = Files.newOutputStream(temp);
return new Context() {
private final Path temp = Files.createTempFile("hmcl-download-", null);
private final FileChannel fileOutput = FileChannel.open(temp,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.CREATE);
@Override
public void reset() throws IOException {
fileOutput.truncate(0L);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
fileOutput.write(buffer, offset, len);
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, offset, len);
while (byteBuffer.hasRemaining()) {
//noinspection ResultOfMethodCallIgnored
fileOutput.write(byteBuffer);
}
}
@Override
@@ -99,25 +115,27 @@ public final class CacheFileTask extends FetchTask<Path> {
fileOutput.close();
} catch (IOException e) {
LOG.warning("Failed to close file: " + temp, e);
deleteTempFile();
throw e;
}
if (!isSuccess()) {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
deleteTempFile();
return;
}
try {
setResult(repository.cacheRemoteFile(response, temp));
setResult(repository.cacheRemoteFile(UrlResponseInfo.of(response), temp));
} finally {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
deleteTempFile();
}
}
private void deleteTempFile() {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
}
};
@@ -21,10 +21,7 @@ import org.jackhuang.hmcl.event.Event;
import org.jackhuang.hmcl.event.EventBus;
import org.jackhuang.hmcl.event.EventManager;
import org.jackhuang.hmcl.util.*;
import org.jackhuang.hmcl.util.io.ContentEncoding;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.ResponseCodeException;
import org.jackhuang.hmcl.util.io.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -38,8 +35,13 @@ import java.net.http.HttpResponse;
import java.nio.file.Path;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.util.Lang.threadPool;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -91,14 +93,11 @@ public abstract class FetchTask<T> extends Task<T> {
public void execute() throws Exception {
boolean checkETag;
switch (shouldCheckETag()) {
case CHECK_E_TAG:
checkETag = true;
break;
case NOT_CHECK_E_TAG:
checkETag = false;
break;
default:
case CHECK_E_TAG -> checkETag = true;
case NOT_CHECK_E_TAG -> checkETag = false;
default -> {
return;
}
}
ArrayList<DownloadException> exceptions = null;
@@ -135,12 +134,111 @@ public abstract class FetchTask<T> extends Task<T> {
}
}
private static final class HttpResumeContext {
private static final Pattern CONTENT_RANGE_PATTERN = Pattern.compile("bytes ([0-9]+)-([0-9]+)/([0-9]+)");
static @Nullable FetchTask.HttpResumeContext of(HttpResponse<?> response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_OK)
return null;
boolean acceptRanges = response.headers().firstValue("accept-ranges").orElse("").equalsIgnoreCase("bytes");
if (!acceptRanges)
return null;
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
if (contentEncoding != ContentEncoding.IDENTITY)
return null;
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
if (contentLength < 0)
return null;
String eTag = response.headers().firstValue("etag").orElse(null);
String strongETag = isStrongETag(eTag) ? eTag : null;
String lastModified = response.headers().firstValue("last-modified").orElse(null);
if (strongETag == null && StringUtils.isBlank(lastModified))
return null;
return new HttpResumeContext(response.uri(), contentLength, strongETag, lastModified);
}
private final URI uri;
private final long contentLength;
private final @Nullable String strongETag;
private final @Nullable String lastModified;
long countUncompressed;
private HttpResumeContext(URI uri, long contentLength, @Nullable String strongETag, @Nullable String lastModified) {
this.uri = uri;
this.contentLength = contentLength;
this.strongETag = strongETag;
this.lastModified = lastModified;
}
private static boolean isStrongETag(@Nullable String eTag) {
return StringUtils.isNotBlank(eTag) && !eTag.regionMatches(true, 0, "W/", 0, 2);
}
String ifRange() {
return strongETag != null ? strongETag : Objects.requireNonNull(lastModified);
}
boolean canResume(HttpResponse<?> response) throws IOException {
if (response.statusCode() != HttpURLConnection.HTTP_PARTIAL)
return false;
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
if (contentEncoding != ContentEncoding.IDENTITY)
return false;
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
if (this.contentLength != contentLength + this.countUncompressed)
return false;
if (strongETag != null) {
String eTag = response.headers().firstValue("etag").orElse(null);
if (!strongETag.equals(eTag))
return false;
} else {
if (!uri.equals(response.uri()))
return false;
String lastModified = response.headers().firstValue("last-modified").orElse("");
if (!Objects.requireNonNull(this.lastModified).equals(lastModified))
return false;
}
String contentRange = response.headers().firstValue("content-range").orElse("");
Matcher matcher = CONTENT_RANGE_PATTERN.matcher(contentRange);
if (!matcher.matches())
return false;
try {
long start = Long.parseLong(matcher.group(1));
long end = Long.parseLong(matcher.group(2));
long total = Long.parseLong(matcher.group(3));
if (start != countUncompressed || end < start || total != this.contentLength)
return false;
return end - start + 1 == contentLength;
} catch (NumberFormatException e) {
return false;
}
}
boolean hasPartialContent() {
return countUncompressed > 0 && countUncompressed < contentLength;
}
}
private void download(Context context,
InputStream inputStream,
@Nullable FetchTask.HttpResumeContext resume, InputStream inputStream,
long contentLength,
ContentEncoding contentEncoding) throws IOException, InterruptedException {
try (var ignored = context;
var counter = new CounterInputStream(inputStream);
boolean success = false;
try (var counter = new CounterInputStream(inputStream);
var input = contentEncoding.wrap(counter)) {
long lastDownloaded = 0L;
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
@@ -150,7 +248,15 @@ public abstract class FetchTask<T> extends Task<T> {
int len = input.read(buffer);
if (len == -1) break;
context.write(buffer, 0, len);
try {
context.write(buffer, 0, len);
} catch (Throwable e) {
context.broken = true;
throw e;
}
if (resume != null)
resume.countUncompressed += len;
if (contentLength >= 0) {
// Update progress information per second
@@ -169,6 +275,10 @@ public abstract class FetchTask<T> extends Task<T> {
if (contentLength >= 0 && counter.downloaded != contentLength)
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
success = true;
}
if (success) {
context.withResult(true);
}
}
@@ -185,121 +295,197 @@ public abstract class FetchTask<T> extends Task<T> {
}
}
Context context = null;
HttpResumeContext resumeContext = null;
ArrayList<Exception> exceptions = null;
// If loading the cache fails, the cache should not be loaded again.
boolean useCachedResult = true;
for (int retryTime = 0, retryLimit = retry; retryTime < retryLimit; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
List<URI> redirects = null;
try {
beforeDownload(uri);
updateProgress(0);
HttpResponse<InputStream> response;
String bmclapiHash;
URI currentURI = uri;
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
headers.put("accept-encoding", "gzip");
if (useCachedResult && checkETag)
headers.putAll(repository.injectConnection(uri));
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
}
int code = response.statusCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
String location = response.headers().firstValue("Location").orElse(null);
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
currentURI = target;
} else {
break;
}
} while (true);
int responseCode = response.statusCode();
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(currentURI, false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(currentURI);
useCachedResult = false;
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryLimit++;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
try {
for (int retryTime = 0, retryLimit = retry; retryTime < retryLimit; retryTime++) {
if (isCancelled()) {
throw new InterruptedException();
}
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromResponse(response);
List<URI> redirects = null;
try {
beforeDownload(uri);
updateProgress(0);
download(getContext(response, checkETag, bmclapiHash),
response.body(),
contentLength,
contentEncoding);
return;
} catch (InterruptedException e) {
throw e;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (Exception ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
HttpResponse<InputStream> response;
String bmclapiHash;
exceptions.add(ex);
URI currentURI = uri;
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
headers.put("accept-encoding", "gzip");
if (retryTime < retryLimit - 1) {
// Wait for a while before retrying
Thread.sleep(200);
boolean resumeRequested = resumeContext != null && resumeContext.hasPartialContent();
if (useCachedResult && checkETag && !resumeRequested)
headers.putAll(repository.injectConnection(uri));
if (resumeRequested) {
headers.put("range", "bytes=" + resumeContext.countUncompressed + "-");
headers.put("if-range", resumeContext.ifRange());
}
do {
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI)
.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
.header("User-Agent", NetworkUtils.USER_AGENT);
headers.forEach(requestBuilder::header);
response = Holder.HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
if (DigestUtils.isSha1Digest(bmclapiHash)) {
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
if (cache.isPresent()) {
closeResponseBody(response);
useCachedResult(cache.get());
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
}
}
int code = response.statusCode();
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
if (redirects == null) {
redirects = new ArrayList<>();
} else if (redirects.size() >= 20) {
throw new IOException("Too much redirects");
}
String location = response.headers().firstValue("Location").orElse(null);
if (StringUtils.isBlank(location))
throw new IOException("Redirected to an empty location");
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
redirects.add(target);
if (!NetworkUtils.isHttpUri(target))
throw new IOException("Redirected to not http URI: " + target);
closeResponseBody(response);
currentURI = target;
} else {
break;
}
} while (true);
boolean closeResponseBody = true;
try {
int responseCode = response.statusCode();
if (resumeRequested && responseCode == 416) {
resumeContext = null;
discardContext(context);
context = null;
retryLimit++;
continue;
}
if (useCachedResult && responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
// Handle cache
try {
Path cache = repository.getCachedRemoteFile(currentURI, false);
useCachedResult(cache);
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
return;
} catch (CacheRepository.CacheExpiredException e) {
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
} catch (IOException e) {
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
repository.removeRemoteEntry(currentURI);
useCachedResult = false;
// Now we must reconnect the server since 304 may result in empty content,
// if we want to redownload the file, we must reconnect the server without etag settings.
retryLimit++;
continue;
}
} else if (responseCode / 100 == 4) {
throw new FileNotFoundException(uri.toString());
} else if (responseCode / 100 != 2) {
throw new ResponseCodeException(uri, responseCode);
}
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
var contentEncoding = ContentEncoding.fromHeaders(response.headers());
if (context == null) {
context = getContext(response, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response);
} else if (resumeRequested) {
if (resumeContext.canResume(response)) {
// Resume download
LOG.info("Resuming " + resumeContext.uri + " from " + resumeContext.countUncompressed);
} else {
// Failed to resume download, so we will retry from the beginning
resumeContext = null;
discardContext(context);
context = null;
retryLimit++;
continue;
}
} else {
discardContext(context);
context = getContext(response, checkETag, bmclapiHash);
resumeContext = HttpResumeContext.of(response);
}
try {
closeResponseBody = false;
download(context,
resumeContext, response.body(),
contentLength,
contentEncoding);
} catch (IOException | InterruptedException | RuntimeException | Error e) {
if (context.broken) {
IOUtils.closeQuietly(context, e);
context = null;
resumeContext = null;
}
throw e;
}
try {
context.close();
} catch (IOException | RuntimeException | Error e) {
context.withResult(false);
IOUtils.closeQuietly(context, e);
context = null;
resumeContext = null;
throw e;
}
context = null;
return;
} finally {
if (closeResponseBody) {
closeResponseBody(response);
}
}
} catch (InterruptedException e) {
throw e;
} catch (FileNotFoundException ex) {
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
} catch (Exception ex) {
if (exceptions == null)
exceptions = new ArrayList<>();
exceptions.add(ex);
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
if (retryTime < retryLimit - 1) {
// Wait for a while before retrying
Thread.sleep(200);
}
}
}
} finally {
if (context != null) {
try {
context.close();
} catch (IOException e) {
LOG.warning("Failed to close context for " + NetworkUtils.dropQuery(uri), e);
}
}
}
@@ -307,6 +493,19 @@ public abstract class FetchTask<T> extends Task<T> {
throw toDownloadException(uri, null, exceptions);
}
private static void closeResponseBody(HttpResponse<InputStream> response) {
InputStream body = response.body();
if (body != null)
IOUtils.closeQuietly(body);
}
private static void discardContext(@Nullable Context context) {
if (context != null) {
context.withResult(false);
IOUtils.closeQuietly(context);
}
}
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
ArrayList<Exception> exceptions = null;
for (int retryTime = 0; retryTime < retry; retryTime++) {
@@ -319,10 +518,12 @@ public abstract class FetchTask<T> extends Task<T> {
updateProgress(0);
URLConnection conn = NetworkUtils.createConnection(uri);
download(getContext(),
conn.getInputStream(),
conn.getContentLengthLong(),
ContentEncoding.fromConnection(conn));
try (Context context = getContext()) {
download(context,
null, conn.getInputStream(),
conn.getContentLengthLong(),
ContentEncoding.fromConnection(conn));
}
return;
} catch (InterruptedException e) {
throw e;
@@ -425,16 +626,22 @@ public abstract class FetchTask<T> extends Task<T> {
protected static abstract class Context implements Closeable {
private boolean success;
private boolean broken;
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
protected final boolean isSuccess() {
return success;
}
public void withResult(boolean success) {
this.success = success;
}
protected boolean isSuccess() {
return success;
}
public abstract void reset() throws IOException;
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
@Override
public abstract void close() throws IOException;
}
protected enum EnumCheckETag {
@@ -22,15 +22,20 @@ import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
import org.jackhuang.hmcl.util.io.CompressingUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.util.*;
@@ -44,10 +49,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public class FileDownloadTask extends FetchTask<Void> {
public static class IntegrityCheck {
private final String algorithm;
private final String checksum;
public record IntegrityCheck(String algorithm, String checksum) {
public IntegrityCheck(String algorithm, String checksum) {
this.algorithm = requireNonNull(algorithm);
this.checksum = requireNonNull(checksum);
@@ -58,16 +60,8 @@ public class FileDownloadTask extends FetchTask<Void> {
else return new IntegrityCheck(algorithm, checksum);
}
public String getAlgorithm() {
return algorithm;
}
public String getChecksum() {
return checksum;
}
@Override
public String toString() {
public @NotNull String toString() {
return String.format("IntegrityCheck[algorithm='%s', checksum='%s']", algorithm, checksum);
}
}
@@ -158,7 +152,7 @@ public class FileDownloadTask extends FetchTask<Void> {
protected EnumCheckETag shouldCheckETag() {
// Check cache
if (integrityCheck != null && caching) {
Optional<Path> cache = repository.checkExistentFile(candidate, integrityCheck.getAlgorithm(), integrityCheck.getChecksum());
Optional<Path> cache = repository.checkExistentFile(candidate, integrityCheck.algorithm(), integrityCheck.checksum());
if (cache.isPresent()) {
try {
FileUtils.copyFile(cache.get(), file);
@@ -185,14 +179,14 @@ public class FileDownloadTask extends FetchTask<Void> {
}
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
Path temp = Files.createTempFile(null, null);
String algorithm;
String checksum;
if (integrityCheck != null) {
algorithm = integrityCheck.getAlgorithm();
checksum = integrityCheck.getChecksum();
algorithm = integrityCheck.algorithm();
checksum = integrityCheck.checksum();
} else if (bmclapiHash != null && DigestUtils.isSha1Digest(bmclapiHash)) {
algorithm = "SHA-1";
checksum = bmclapiHash;
@@ -203,15 +197,32 @@ public class FileDownloadTask extends FetchTask<Void> {
MessageDigest digest = algorithm != null ? DigestUtils.getDigest(algorithm) : null;
OutputStream fileOutput = Files.newOutputStream(temp);
FileChannel fileOutput = FileChannel.open(temp,
StandardOpenOption.WRITE,
StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.CREATE);
return new Context() {
@Override
public void reset() throws IOException {
if (digest != null) {
digest.reset();
}
fileOutput.truncate(0L);
fileOutput.position(0L);
}
@Override
public void write(byte[] buffer, int offset, int len) throws IOException {
if (digest != null) {
digest.update(buffer, offset, len);
}
fileOutput.write(buffer, offset, len);
ByteBuffer byteBuffer = ByteBuffer.wrap(buffer, offset, len);
while (byteBuffer.hasRemaining()) {
//noinspection ResultOfMethodCallIgnored
fileOutput.write(byteBuffer);
}
}
@Override
@@ -220,47 +231,60 @@ public class FileDownloadTask extends FetchTask<Void> {
fileOutput.close();
} catch (IOException e) {
LOG.warning("Failed to close file: " + temp, e);
deleteTempFile();
throw e;
}
if (!isSuccess()) {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
deleteTempFile();
return;
}
for (IntegrityCheckHandler handler : integrityCheckHandlers) {
handler.checkIntegrity(temp, file);
}
Files.createDirectories(file.toAbsolutePath().getParent());
boolean moved = false;
try {
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
throw new IOException("Unable to move temp file from " + temp + " to " + file, e);
}
// Integrity check
if (checksum != null) {
String actualChecksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(actualChecksum)) {
throw new ChecksumMismatchException(algorithm, checksum, actualChecksum);
for (IntegrityCheckHandler handler : integrityCheckHandlers) {
handler.checkIntegrity(temp, file);
}
}
if (caching && algorithm != null) {
if (checksum != null) {
String actualChecksum = HexFormat.of().formatHex(digest.digest());
if (!checksum.equalsIgnoreCase(actualChecksum)) {
throw new ChecksumMismatchException(algorithm, checksum, actualChecksum);
}
}
Files.createDirectories(file.toAbsolutePath().getParent());
try {
repository.cacheFile(file, algorithm, checksum);
} catch (IOException e) {
LOG.warning("Failed to cache file", e);
Files.move(temp, file, StandardCopyOption.REPLACE_EXISTING);
moved = true;
} catch (Exception e) {
throw new IOException("Unable to move temp file from " + temp + " to " + file, e);
}
if (caching && algorithm != null) {
try {
repository.cacheFile(file, algorithm, checksum);
} catch (IOException e) {
LOG.warning("Failed to cache file", e);
}
}
if (checkETag) {
repository.cacheRemoteFile(UrlResponseInfo.of(response), file);
}
} finally {
if (!moved) {
deleteTempFile();
}
}
}
if (checkETag) {
repository.cacheRemoteFile(response, file);
private void deleteTempFile() {
try {
Files.deleteIfExists(temp);
} catch (IOException e) {
LOG.warning("Failed to delete file: " + temp, e);
}
}
};
@@ -20,6 +20,8 @@ package org.jackhuang.hmcl.task;
import com.google.gson.reflect.TypeToken;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.Nullable;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -61,13 +63,18 @@ public final class GetTask extends FetchTask<String> {
}
@Override
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
long length = -1;
if (response != null)
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : (int) length);
return new Context() {
@Override
public void reset() throws IOException {
baos.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) {
baos.write(buffer, offset, len);
@@ -85,7 +92,7 @@ public final class GetTask extends FetchTask<String> {
setResult(result);
if (checkETag) {
repository.cacheText(response, result);
repository.cacheText(UrlResponseInfo.of(response), result);
}
}
};
@@ -23,13 +23,13 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jackhuang.hmcl.util.io.UrlResponseInfo;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.*;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
@@ -228,20 +228,20 @@ public class CacheRepository {
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
}
public Path cacheRemoteFile(HttpResponse<?> response, Path downloaded) throws IOException {
return cacheData(response, () -> {
public Path cacheRemoteFile(UrlResponseInfo info, Path downloaded) throws IOException {
return cacheData(info, () -> {
String hash = DigestUtils.digestToString(SHA1, downloaded);
Path cached = cacheFile(downloaded, SHA1, hash);
return new CacheResult(hash, cached);
});
}
public Path cacheText(HttpResponse<?> response, String text) throws IOException {
return cacheBytes(response, text.getBytes(UTF_8));
public Path cacheText(UrlResponseInfo info, String text) throws IOException {
return cacheBytes(info, text.getBytes(UTF_8));
}
public Path cacheBytes(HttpResponse<?> response, byte[] bytes) throws IOException {
return cacheData(response, () -> {
public Path cacheBytes(UrlResponseInfo info, byte[] bytes) throws IOException {
return cacheData(info, () -> {
String hash = DigestUtils.digestToString(SHA1, bytes);
Path cached = getFile(SHA1, hash);
Files.createDirectories(cached.getParent());
@@ -252,15 +252,15 @@ public class CacheRepository {
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
private Path cacheData(HttpResponse<?> response, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = response.headers().firstValue("etag").orElse(null);
private Path cacheData(UrlResponseInfo info, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
String eTag = info.headers().firstValue("etag").orElse(null);
if (StringUtils.isBlank(eTag)) return null;
URI uri = NetworkUtils.dropQuery(response.uri());
URI uri = NetworkUtils.dropQuery(info.uri());
long expires = 0L;
expires:
try {
String cacheControl = response.headers().firstValue("cache-control").orElse(null);
String cacheControl = info.headers().firstValue("cache-control").orElse(null);
if (StringUtils.isNotBlank(cacheControl)) {
if (cacheControl.contains("no-store"))
return null;
@@ -273,7 +273,7 @@ public class CacheRepository {
}
}
String expiresHeader = response.headers().firstValue("expires").orElse(null);
String expiresHeader = info.headers().firstValue("expires").orElse(null);
if (StringUtils.isNotBlank(expiresHeader)) {
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
.toInstant().toEpochMilli();
@@ -282,7 +282,7 @@ public class CacheRepository {
LOG.warning("Failed to parse expires time", e);
}
String lastModified = response.headers().firstValue("last-modified").orElse(null);
String lastModified = info.headers().firstValue("last-modified").orElse(null);
CacheResult cacheResult = cacheSupplier.get();
ETagItem eTagItem = new ETagItem(uri.toString(),
@@ -0,0 +1,77 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.util;
import org.jetbrains.annotations.NotNullByDefault;
import java.util.Arrays;
import java.util.zip.Deflater;
/// Utilities for detecting the compatibility of the system's zlib implementation.
///
/// Some systems replace the JVM's default zlib with an alternative such as zlib-ng,
/// which may produce compressed output that differs byte-for-byte from the output of
/// the standard zlib. This can cause hash mismatches when verifying artifacts whose
/// expected checksums were computed with a specific zlib implementation.
///
/// @author Glavo
@NotNullByDefault
public final class ZlibUtils {
/// Whether the JVM's [Deflater] produces output that is byte-for-byte identical
/// to the expected output of the standard reference zlib for a known test vector.
///
/// This flag is `true` when the system's zlib behaves like the standard reference
/// implementation, and `false` when an alternative library such as zlib-ng is in
/// use and may produce different compressed bytes.
///
/// When this flag is `false`, hash verification for dynamically generated JAR
/// files (e.g., those produced by the Forge/NeoForge installer processors) may
/// be relaxed to a structural integrity check rather than an exact hash match,
/// because the alternative zlib may legitimately produce files with different
/// checksums.
public static final boolean IS_ZLIB_COMPATIBLE;
static {
var expectedCompressed = new byte[]{120, -100, 99, 96, 0, 2, 0, 0, 5, 0, 1};
byte[] compressed = new byte[64];
int compressedLength = 0;
var deflater = new Deflater();
try {
deflater.setInput(new byte[5]);
deflater.finish();
while (!deflater.finished()) {
compressedLength += deflater.deflate(compressed, compressedLength, compressed.length - compressedLength);
}
} finally {
deflater.end();
}
IS_ZLIB_COMPATIBLE = Arrays.equals(
expectedCompressed,
0, expectedCompressed.length,
compressed,
0, compressedLength
);
}
private ZlibUtils() {
}
}
@@ -22,7 +22,7 @@ import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLConnection;
import java.net.http.HttpResponse;
import java.net.http.HttpHeaders;
import java.util.zip.GZIPInputStream;
/**
@@ -53,8 +53,8 @@ public enum ContentEncoding {
}
}
public static @NotNull ContentEncoding fromResponse(HttpResponse<?> connection) throws IOException {
String encoding = connection.headers().firstValue("content-encoding").orElse("");
public static @NotNull ContentEncoding fromHeaders(HttpHeaders headers) throws IOException {
String encoding = headers.firstValue("content-encoding").orElse("");
if (encoding.isEmpty() || "identity".equals(encoding)) {
return IDENTITY;
} else if ("gzip".equalsIgnoreCase(encoding)) {
@@ -17,6 +17,7 @@
*/
package org.jackhuang.hmcl.util.io;
import org.glavo.url.WebURL;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
@@ -159,10 +160,10 @@ public final class NetworkUtils {
}
}
public static URLConnection createConnection(URI uri) throws IOException {
public static URLConnection createConnection(WebURL url) throws IOException {
URLConnection connection;
try {
connection = uri.toURL().openConnection();
connection = url.toURL().openConnection();
} catch (IllegalArgumentException | MalformedURLException e) {
throw new IOException(e);
}
@@ -176,8 +177,16 @@ public final class NetworkUtils {
return connection;
}
public static URLConnection createConnection(URI uri) throws IOException {
return createConnection(WebURL.of(uri));
}
public static HttpURLConnection createHttpConnection(WebURL url) throws IOException {
return (HttpURLConnection) createConnection(url);
}
public static HttpURLConnection createHttpConnection(String url) throws IOException {
return (HttpURLConnection) createConnection(toURI(url));
return (HttpURLConnection) createConnection(WebURL.parse(url));
}
public static HttpURLConnection createHttpConnection(URI url) throws IOException {
@@ -433,16 +442,7 @@ public final class NetworkUtils {
/// @throws IllegalArgumentException if the string is not a valid URI
public static @NotNull URI toURI(@NotNull String uri) {
try {
return new URI(encodeLocation(uri));
} catch (URISyntaxException e) {
// Possibly an Internationalized Domain Name (IDN)
return URI.create(uri);
}
}
public static @NotNull URI toURI(@NotNull URL url) {
return toURI(url.toExternalForm());
return WebURL.toURI(uri);
}
public static @Nullable URI toURIOrNull(String uri) {
@@ -0,0 +1,33 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 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.util.io;
import java.net.URI;
import java.net.http.HttpHeaders;
import java.net.http.HttpResponse;
/// @author Glavo
public record UrlResponseInfo(URI uri, HttpHeaders headers) {
public static UrlResponseInfo of(HttpResponse<?> httpResponse) {
return new UrlResponseInfo(httpResponse.uri(), httpResponse.headers());
}
public static UrlResponseInfo of(URI uri, HttpResponse.ResponseInfo info) {
return new UrlResponseInfo(uri, info.headers());
}
}
@@ -0,0 +1,336 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.task;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.jackhuang.hmcl.util.CacheRepository;
import org.jackhuang.hmcl.util.io.NetworkUtils;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpResponse;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.*;
/// Tests HTTP download retry and resume behavior.
@NotNullByDefault
public final class FetchTaskTest {
/// Marks HTTP client dependencies as initialized for isolated unit tests.
@org.junit.jupiter.api.BeforeAll
public static void notifyFetchTaskInitialized() {
FetchTask.notifyInitialized();
}
/// Ensures checksum failures are reported and do not replace an existing target file.
@Test
public void checksumMismatchFailsWithoutReplacingTarget(@TempDir Path tempDir) throws IOException {
byte[] data = "downloaded".getBytes(UTF_8);
Path target = tempDir.resolve("target.bin");
Files.writeString(target, "original", UTF_8);
try (TestHttpServer server = TestHttpServer.start(exchange -> sendBytes(exchange, 200, data))) {
FileDownloadTask task = new FileDownloadTask(
server.uri(),
target,
new FileDownloadTask.IntegrityCheck("SHA-1", "0000000000000000000000000000000000000000")
);
task.setCacheRepository(newRepository(tempDir));
task.setRetry(1);
assertFalse(task.test());
assertEquals("original", Files.readString(target, UTF_8));
}
}
/// Ensures a mismatched Content-Range response is rejected before appending bytes.
@Test
public void invalidContentRangeFallsBackToFullDownload(@TempDir Path tempDir) throws IOException {
byte[] data = "abcdefghij".getBytes(UTF_8);
byte[] wrongRangeData = "defghi".getBytes(UTF_8);
AtomicInteger requestCount = new AtomicInteger();
List<@Nullable String> ranges = new ArrayList<>();
try (TestHttpServer server = TestHttpServer.start(exchange -> {
ranges.add(exchange.getRequestHeaders().getFirst("Range"));
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
exchange.getResponseHeaders().set("Last-Modified", "Thu, 01 Jan 2026 00:00:00 GMT");
switch (requestCount.incrementAndGet()) {
case 1 -> {
exchange.sendResponseHeaders(200, data.length);
exchange.getResponseBody().write(data, 0, 4);
exchange.getResponseBody().flush();
exchange.close();
}
case 2 -> {
exchange.getResponseHeaders().set("Content-Range", "bytes 3-8/10");
sendBytes(exchange, 206, wrongRangeData);
}
default -> sendBytes(exchange, 200, data);
}
})) {
Path target = tempDir.resolve("target.bin");
FileDownloadTask task = new FileDownloadTask(server.uri(), target);
task.setCacheRepository(newRepository(tempDir));
task.setRetry(3);
assertTrue(task.test(), () -> requestCount.get() + ": " + task.getException());
assertArrayEquals(data, Files.readAllBytes(target));
assertEquals(Arrays.asList(null, "bytes=4-", null), ranges);
}
}
/// Ensures a valid Content-Range response is appended without a full redownload.
@Test
public void validContentRangeCompletesResume(@TempDir Path tempDir) throws IOException {
byte[] data = "abcdefghij".getBytes(UTF_8);
byte[] resumedData = "efghij".getBytes(UTF_8);
AtomicInteger requestCount = new AtomicInteger();
List<@Nullable String> ranges = new ArrayList<>();
List<@Nullable String> ifRanges = new ArrayList<>();
try (TestHttpServer server = TestHttpServer.start(exchange -> {
ranges.add(exchange.getRequestHeaders().getFirst("Range"));
ifRanges.add(exchange.getRequestHeaders().getFirst("If-Range"));
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
exchange.getResponseHeaders().set("Last-Modified", "Thu, 01 Jan 2026 00:00:00 GMT");
switch (requestCount.incrementAndGet()) {
case 1 -> {
exchange.sendResponseHeaders(200, data.length);
exchange.getResponseBody().write(data, 0, 4);
exchange.getResponseBody().flush();
exchange.close();
}
case 2 -> {
exchange.getResponseHeaders().set("Content-Range", "bytes 4-9/10");
sendBytes(exchange, 206, resumedData);
}
default -> fail("Unexpected full redownload request");
}
})) {
Path target = tempDir.resolve("target.bin");
FileDownloadTask task = new FileDownloadTask(server.uri(), target);
task.setCacheRepository(newRepository(tempDir));
task.setRetry(3);
assertTrue(task.test(), () -> requestCount.get() + ": " + task.getException());
assertArrayEquals(data, Files.readAllBytes(target));
assertEquals(Arrays.asList(null, "bytes=4-"), ranges);
assertEquals(Arrays.asList(null, "Thu, 01 Jan 2026 00:00:00 GMT"), ifRanges);
assertEquals(2, requestCount.get());
}
}
/// Ensures a changed strong ETag prevents appending a partial response.
@Test
public void changedStrongETagFallsBackToFullDownload(@TempDir Path tempDir) throws IOException {
byte[] data = "abcdefghij".getBytes(UTF_8);
byte[] resumedData = "efghij".getBytes(UTF_8);
AtomicInteger requestCount = new AtomicInteger();
List<@Nullable String> ranges = new ArrayList<>();
List<@Nullable String> ifRanges = new ArrayList<>();
try (TestHttpServer server = TestHttpServer.start(exchange -> {
ranges.add(exchange.getRequestHeaders().getFirst("Range"));
ifRanges.add(exchange.getRequestHeaders().getFirst("If-Range"));
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
exchange.getResponseHeaders().set("Last-Modified", "Thu, 01 Jan 2026 00:00:00 GMT");
switch (requestCount.incrementAndGet()) {
case 1 -> {
exchange.getResponseHeaders().set("ETag", "\"first\"");
exchange.sendResponseHeaders(200, data.length);
exchange.getResponseBody().write(data, 0, 4);
exchange.getResponseBody().flush();
exchange.close();
}
case 2 -> {
exchange.getResponseHeaders().set("ETag", "\"second\"");
exchange.getResponseHeaders().set("Content-Range", "bytes 4-9/10");
sendBytes(exchange, 206, resumedData);
}
default -> {
exchange.getResponseHeaders().set("ETag", "\"second\"");
sendBytes(exchange, 200, data);
}
}
})) {
Path target = tempDir.resolve("target.bin");
FileDownloadTask task = new FileDownloadTask(server.uri(), target);
task.setCacheRepository(newRepository(tempDir));
task.setRetry(3);
assertTrue(task.test(), () -> String.valueOf(task.getException()));
assertArrayEquals(data, Files.readAllBytes(target));
assertEquals(Arrays.asList(null, "bytes=4-", null), ranges);
assertEquals(Arrays.asList(null, "\"first\"", null), ifRanges);
}
}
/// Ensures a full retry uses the latest response metadata instead of stale headers.
@Test
public void fullRetryUsesLatestResponseMetadata(@TempDir Path tempDir) throws IOException {
byte[] data = "ok".getBytes(UTF_8);
byte[] firstData = "abcdefghij".getBytes(UTF_8);
byte[] wrongRangeData = "defghi".getBytes(UTF_8);
AtomicInteger requestCount = new AtomicInteger();
try (TestHttpServer server = TestHttpServer.start(exchange -> {
switch (requestCount.incrementAndGet()) {
case 1 -> {
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-16LE");
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
exchange.getResponseHeaders().set("Last-Modified", "Thu, 01 Jan 2026 00:00:00 GMT");
exchange.sendResponseHeaders(200, firstData.length);
exchange.getResponseBody().write(firstData, 0, 4);
exchange.getResponseBody().flush();
exchange.close();
}
case 2 -> {
exchange.getResponseHeaders().set("Accept-Ranges", "bytes");
exchange.getResponseHeaders().set("Last-Modified", "Thu, 01 Jan 2026 00:00:00 GMT");
exchange.getResponseHeaders().set("Content-Range", "bytes 3-8/10");
sendBytes(exchange, 206, wrongRangeData);
}
default -> {
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=UTF-8");
sendBytes(exchange, 200, data);
}
}
})) {
TextFetchTask task = new TextFetchTask(server.uri());
task.setCacheRepository(newRepository(tempDir));
task.setRetry(2);
assertTrue(task.test(), () -> requestCount.get() + ": " + task.getException());
assertEquals("ok", task.getResult());
assertEquals(3, requestCount.get());
}
}
/// Text fetch task that avoids JavaFX progress updates in isolated unit tests.
private static final class TextFetchTask extends FetchTask<String> {
/// Creates a text fetch task for one URI.
TextFetchTask(URI uri) {
super(List.of(uri));
}
@Override
protected void useCachedResult(Path cachedFile) throws IOException {
setResult(Files.readString(cachedFile));
}
@Override
protected EnumCheckETag shouldCheckETag() {
return EnumCheckETag.NOT_CHECK_E_TAG;
}
@Override
protected Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
Charset charset = NetworkUtils.getCharsetFromContentType(response == null
? null
: response.headers().firstValue("content-type").orElse(null));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
return new Context() {
@Override
public void reset() {
baos.reset();
}
@Override
public void write(byte[] buffer, int offset, int len) {
baos.write(buffer, offset, len);
}
@Override
public void close() {
if (isSuccess()) {
setResult(baos.toString(charset));
}
}
};
}
@Override
protected void updateProgress(long count, long total) {
}
@Override
protected void updateProgress(double progress) {
}
}
/// Creates an isolated cache repository for one test.
private static CacheRepository newRepository(Path tempDir) throws IOException {
CacheRepository repository = new CacheRepository();
repository.changeDirectory(Files.createDirectory(tempDir.resolve("common")));
return repository;
}
/// Sends a byte array response.
private static void sendBytes(HttpExchange exchange, int statusCode, byte[] data) throws IOException {
exchange.sendResponseHeaders(statusCode, data.length);
exchange.getResponseBody().write(data);
exchange.close();
}
/// Minimal closeable HTTP server for local download tests.
private record TestHttpServer(HttpServer server) implements AutoCloseable {
/// Starts a server with one file endpoint.
static TestHttpServer start(ThrowingHttpHandler handler) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/file", handler::handle);
server.start();
return new TestHttpServer(server);
}
/// Returns the file endpoint URI.
URI uri() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/file");
}
@Override
public void close() {
server.stop(0);
}
}
/// HTTP handler that may throw IOException.
@FunctionalInterface
private interface ThrowingHttpHandler {
/// Handles one HTTP exchange.
void handle(HttpExchange exchange) throws IOException;
}
}
+1
View File
@@ -11,6 +11,7 @@ dependencies {
implementation(libs.gson)
implementation(libs.jna)
implementation(libs.kala.compress.tar)
implementation(libs.kala.compress.ar)
}
java {
@@ -0,0 +1,300 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.gradle.pack;
import kala.compress.archivers.ar.ArArchiveEntry;
import kala.compress.archivers.ar.ArArchiveOutputStream;
import kala.compress.archivers.tar.TarArchiveEntry;
import kala.compress.archivers.tar.TarArchiveOutputStream;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.logging.Logger;
import org.gradle.api.logging.Logging;
import org.gradle.api.provider.Property;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.HashSet;
import java.util.Set;
import java.util.zip.GZIPOutputStream;
/// Creates a Debian package for the current HMCL channel.
///
/// ## Package layout
///
/// The generated `data.tar.gz` contains four installed artifacts:
///
/// - the bundled HMCL shell launcher under `/usr/share/java/hmcl/`
/// - a channel-specific command under `/usr/bin/`
/// - a desktop entry under `/usr/share/applications/`
/// - the HMCL icon under `/usr/share/icons/hicolor/256x256/apps/`
///
/// ## Channel commands and aliases
///
/// Every package installs a channel-specific executable such as `hmcl-stable`
/// or `hmcl-beta`. The generic `hmcl` command is intentionally not shipped as a
/// plain file. Instead, maintainer scripts register the channel command into
/// the shared `hmcl` alternatives group so multiple channel packages can
/// coexist without file conflicts.
///
/// @author Glavo
public abstract class CreateDeb extends DefaultTask {
public static final Logger LOGGER = Logging.getLogger(CreateDeb.class);
private static final int DIRECTORY_MODE = 0755;
private static final int EXECUTABLE_MODE = 0755;
private static final int REGULAR_FILE_MODE = 0644;
/// Debian version written into the `control` file and output filename.
@Input
public abstract Property<String> getVersion();
/// Release type metadata that controls package name, launcher name, and alias priority.
@Input
public abstract Property<ReleaseType> getReleaseType();
/// Executable `.sh` artifact produced by `makeExecutables`.
@InputFile
public abstract RegularFileProperty getAppShFile();
/// Desktop icon installed into the hicolor icon theme.
@InputFile
public abstract RegularFileProperty getIconFile();
/// Final `.deb` archive written by this task.
@OutputFile
public abstract RegularFileProperty getOutputFile();
private ReleaseType getCurrentType() {
return getReleaseType().get();
}
private String getCurrentTypeName() {
return getCurrentType().getName();
}
private String getLauncherPath() {
return "/usr/bin/hmcl-" + getCurrentTypeName();
}
private String getTargetPath() {
return "/usr/share/java/hmcl/" + getAppShFile().getAsFile().get().getName();
}
private String getDesktopFilePath() {
return "/usr/share/applications/hmcl-%s.desktop".formatted(getCurrentTypeName());
}
private String getIconTargetPath() {
return "/usr/share/icons/hicolor/256x256/apps/hmcl-%s.png".formatted(getCurrentTypeName());
}
/// Ensures parent directories exist in the tar stream before child entries are written.
private static void makeDirectories(Set<String> directories, TarArchiveOutputStream output, String dirName) throws IOException {
if (dirName.isEmpty() || ".".equals(dirName) || directories.contains(dirName))
return;
int idx = dirName.lastIndexOf('/');
if (idx > 0) {
makeDirectories(directories, output, dirName.substring(0, idx));
}
TarArchiveEntry entry = new TarArchiveEntry(dirName + "/", true);
entry.setMode(DIRECTORY_MODE);
output.putArchiveEntry(entry);
output.closeArchiveEntry();
directories.add(dirName);
}
/// Writes UTF-8 text content as a tar entry with the requested file mode.
private static void putEntry(Set<String> directories, TarArchiveOutputStream output, String name, String content, int mode) throws IOException {
putEntry(directories, output, name, content.getBytes(StandardCharsets.UTF_8), mode);
}
/// Writes binary content as a tar entry and creates parent directories on demand.
private static void putEntry(Set<String> directories, TarArchiveOutputStream output, String name, byte[] content, int mode) throws IOException {
int idx = name.lastIndexOf('/');
if (idx > 0) {
makeDirectories(directories, output, name.substring(0, idx));
}
TarArchiveEntry entry = new TarArchiveEntry(name);
entry.setMode(mode);
entry.setSize(content.length);
output.putArchiveEntry(entry);
output.write(content);
output.closeArchiveEntry();
}
/// Builds a valid `.deb` archive with `debian-binary`, `control.tar.gz`, and `data.tar.gz`.
@TaskAction
public void run() throws IOException {
Path appShFile = getAppShFile().getAsFile().get().toPath();
if (!Files.isRegularFile(appShFile))
throw new IOException("Invalid app script file: " + appShFile);
Path iconFile = getIconFile().getAsFile().get().toPath();
if (!Files.isRegularFile(iconFile))
throw new IOException("Invalid icon file: " + iconFile);
byte[] appShBytes = Files.readAllBytes(appShFile);
if (appShBytes.length == 0)
throw new IOException("Empty app script file: " + appShFile);
byte[] iconBytes = Files.readAllBytes(iconFile);
if (iconBytes.length == 0)
throw new IOException("Empty icon file: " + iconFile);
byte[] launcherScriptBytes = getLauncherScript().getBytes(StandardCharsets.UTF_8);
byte[] desktopInfoBytes = getDesktopInfo().getBytes(StandardCharsets.UTF_8);
LOGGER.lifecycle("Creating control.tar.gz");
var controlData = new ByteArrayOutputStream();
try (var output = new TarArchiveOutputStream(new GZIPOutputStream(controlData))) {
output.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
Set<String> directories = new HashSet<>();
putEntry(directories, output, "./control", getControl(appShBytes.length, launcherScriptBytes.length, desktopInfoBytes.length, iconBytes.length), REGULAR_FILE_MODE);
putEntry(directories, output, "./postinst", getPostinst(), EXECUTABLE_MODE);
putEntry(directories, output, "./prerm", getPrerm(), EXECUTABLE_MODE);
}
Path outputFile = getOutputFile().get().getAsFile().toPath();
Files.createDirectories(outputFile.getParent());
LOGGER.lifecycle("Creating data.tar.gz");
var dataTarBuffer = new ByteArrayOutputStream(12 * 1024 * 1024);
try (var output = new TarArchiveOutputStream(new GZIPOutputStream(dataTarBuffer))) {
output.setLongFileMode(TarArchiveOutputStream.LONGFILE_GNU);
Set<String> directories = new HashSet<>();
putEntry(directories, output, "." + getTargetPath(), appShBytes, EXECUTABLE_MODE);
putEntry(directories, output, "." + getLauncherPath(), launcherScriptBytes, EXECUTABLE_MODE);
putEntry(directories, output, "." + getDesktopFilePath(), desktopInfoBytes, REGULAR_FILE_MODE);
putEntry(directories, output, "." + getIconTargetPath(), iconBytes, REGULAR_FILE_MODE);
}
LOGGER.lifecycle("Creating deb file");
try (var output = new ArArchiveOutputStream(Files.newOutputStream(outputFile,
StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.CREATE))) {
putArEntry(output, "debian-binary", "2.0\n".getBytes(StandardCharsets.UTF_8));
putArEntry(output, "control.tar.gz", controlData.toByteArray());
putArEntry(output, "data.tar.gz", dataTarBuffer.toByteArray());
}
}
/// Adds one member to the outer ar container used by Debian packages.
private static void putArEntry(ArArchiveOutputStream output, String name, byte[] content) throws IOException {
ArArchiveEntry entry = new ArArchiveEntry(name, content.length);
output.putArchiveEntry(entry);
output.write(content);
output.closeArchiveEntry();
}
/// Generates the package metadata file.
///
/// `Depends` is deliberately omitted so the package stays policy-light and
/// leaves runtime dependency resolution to the target environment.
private String getControl(long appSize, long launcherScriptSize, long desktopInfoSize, long iconSize) {
long installedSize = (appSize + launcherScriptSize + desktopInfoSize + iconSize + 1023) / 1024;
return """
Package: %s
Version: %s
Section: games
Priority: optional
Architecture: all
Installed-Size: %d
Maintainer: Glavo <zjx001202@gmail.com>
Description: Hello Minecraft! Launcher
Homepage: https://github.com/HMCL-dev/HMCL
""".formatted(getCurrentType().getPackageName(), getVersion().get(), Math.max(installedSize, 1)) + "\n";
}
private static final String COMMON_LAUNCHER_PATH = "/usr/bin/hmcl";
/// Registers the channel command into the shared `hmcl` alternatives group.
private String getPostinst() {
return """
#!/bin/sh
set -e
if [ "$1" = configure ]; then
update-alternatives --install %s hmcl %s %d
fi
""".formatted(COMMON_LAUNCHER_PATH, getLauncherPath(), getCurrentType().getAlternativesPriority());
}
/// Removes the channel command from the shared `hmcl` alternatives group.
private String getPrerm() {
return """
#!/bin/sh
set -e
if [ "$1" = remove ] || [ "$1" = deconfigure ]; then
update-alternatives --remove hmcl %s
fi
""".formatted(getLauncherPath());
}
/// Creates a tiny wrapper that launches the bundled shell script from the user's home directory.
private String getLauncherScript() {
return """
#!/usr/bin/env bash
cd "$HOME"
if [ -z "${HMCL_USER_HOME:-}" ]; then
if [ -z "${XDG_DATA_HOME:-}" ]; then
export HMCL_USER_HOME="$HOME/.local/share/hmcl"
else
export HMCL_USER_HOME="$XDG_DATA_HOME/hmcl"
fi
fi
if [ -z "${HMCL_LOCAL_HOME:-}" ]; then
export HMCL_LOCAL_HOME="$HMCL_USER_HOME/local-%s"
fi
if [ -z "${HMCL_DEPENDENCIES_DIR:-}" ]; then
export HMCL_DEPENDENCIES_DIR="$HMCL_USER_HOME/dependencies"
fi
exec %s "$@"
""".formatted(getCurrentTypeName(), getTargetPath());
}
/// Generates the desktop entry that points to the channel-specific launcher command.
private String getDesktopInfo() {
return """
[Desktop Entry]
Type=Application
Name=%s
Comment=Hello Minecraft! Launcher
Exec=%s
Icon=%s
Terminal=false
StartupNotify=false
Categories=Game;
Keywords=mc;minecraft;
""".formatted(getCurrentType().getDisplayName(), getLauncherPath(), getIconTargetPath());
}
}
@@ -0,0 +1,60 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 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.gradle.pack;
/// Debian packaging metadata for one HMCL release type.
///
/// The package name, installed command, desktop file, and alternatives
/// priority are intentionally centralized here so `CreateDeb` can stay focused
/// on archive layout instead of duplicating channel-specific branching.
public enum ReleaseType {
STABLE("stable", "hmcl", "HMCL", 100),
DEVELOPMENT("beta", "hmcl-beta", "HMCL (Beta)", 200),
NIGHTLY("nightly", "hmcl-nightly", "HMCL (Nightly)", 300);
private final String name;
private final String packageName;
private final String displayName;
private final int alternativesPriority;
ReleaseType(String name, String packageName, String displayName, int alternativesPriority) {
this.name = name;
this.packageName = packageName;
this.displayName = displayName;
this.alternativesPriority = alternativesPriority;
}
///
public String getName() {
return name;
}
/// Debian package name written into `control` and used in the output filename.
public String getPackageName() {
return packageName;
}
public String getDisplayName() {
return displayName;
}
/// Priority used when registering the generic `hmcl` alias.
public int getAlternativesPriority() {
return alternativesPriority;
}
}
+1 -1
View File
@@ -131,7 +131,7 @@
<module name="SuppressionSingleFilter">
<property name="checks" value="RegexpHeader"/>
<property name="files" value=".*[\\/](SwingFXUtils|ScrollUtils|TypeUtils).java"/>
<property name="files" value=".*[\\/](ScrollUtils|TypeUtils).java"/>
</module>
<module name="RegexpHeader">
+1 -1
View File
@@ -14,7 +14,7 @@ pipeline {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
sh './gradlew clean makeExecutables makeDeb --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ pipeline {
stage('Build') {
steps {
sh 'bash ./config/jenkins/config-jenkins.sh'
sh './gradlew clean makeExecutables --stacktrace --no-daemon'
sh './gradlew clean makeExecutables makeDeb --stacktrace --no-daemon'
archiveArtifacts artifacts: 'HMCL/build/libs/*'
}
}
+1 -1
View File
@@ -72,6 +72,6 @@ The software is distributed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.h
1. When you distribute a modified version of the software, you must change the software name or the version number in a reasonable way in order to distinguish it from the original version. (Under [GPLv3, 7(c)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L372-L374))
The software name and the version number can be edited [here](https://github.com/HMCL-dev/HMCL/blob/javafx/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L33-L35).
The software name and the version number can be edited [here](https://github.com/HMCL-dev/HMCL/blob/86529c6f15b3be54ed8d89ece47566cf57f43eb7/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java#L36-L38).
2. You must not remove the copyright declaration displayed in the software. (Under [GPLv3, 7(b)](https://github.com/HMCL-dev/HMCL/blob/11820e31a85d8989e41d97476712b07e7094b190/LICENSE#L368-L370))
+3
View File
@@ -23,6 +23,7 @@ monet-fx = "0.4.0"
terracotta = "0.4.2"
nayuki-qrcodegen = "1.8.0"
jwebp = "0.2.0"
weburl = "0.2.0"
# testing
junit = "6.0.1"
@@ -36,6 +37,7 @@ hmclauncher = { module = "org.glavo.hmcl:HMCLauncher", version.ref = "hmclaunche
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
kala-compress-zip = { module = "org.glavo.kala:kala-compress-archivers-zip", version.ref = "kala-compress" }
kala-compress-tar = { module = "org.glavo.kala:kala-compress-archivers-tar", version.ref = "kala-compress" }
kala-compress-ar = { module = "org.glavo.kala:kala-compress-archivers-ar", version.ref = "kala-compress" }
simple-png-javafx = { module = "org.glavo:simple-png-javafx", version.ref = "simple-png-javafx" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
tomlj = { module = "org.tomlj:tomlj", version.ref = "tomlj" }
@@ -57,6 +59,7 @@ lwjgl-unsafe-agent = { module = "org.glavo:lwjgl-unsafe-agent", version.ref = "l
monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" }
nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcodegen" }
jwebp = { module = "org.glavo:webp", version.ref = "jwebp" }
weburl = { module = "org.glavo:weburl", version.ref = "weburl" }
# testing
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }