Compare commits

...
Author SHA1 Message Date
Glavo 40d21bbe24 修复无法为 MC 1.21.11 安装 Forge 的问题 (#6160) 2026-06-02 21:11:37 +08:00
Glavo d0be7efca3 将下载超时时间放宽至 30 秒 (#6151) 2026-05-30 11:50:55 +08:00
CiiLuand3gf8jv4dv 3bed03c535 设备代码流在完成授权后隐藏二维码 (#6087)
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
2026-05-29 21:08:05 +08:00
Glavo d295e606a9 修复无法用中文搜索游戏内容的问题 (#6129) 2026-05-28 20:47:41 +08:00
CiiLu 36afe32146 账户选择 popup 为已选择的账户添加底色 (#6111) 2026-05-28 20:38:36 +08:00
CiiLu 87dbbdd96f 优化陶瓦联机反馈提示 (#6120) 2026-05-28 20:35:44 +08:00
CiiLu 9b8e67b1b3 优化游戏列表 Popup 列表项大小 (#6119) 2026-05-28 20:27:12 +08:00
Glavo f5a5e6a6da Enhance nullability annotations and documentation in LocalizedText (#6149) 2026-05-28 20:19:16 +08:00
Glavo f0466c1d8e docs: remove outdated Gradle usage instructions from AGENTS.md (#6148) 2026-05-28 20:02:03 +08:00
3gf8jv4dv 903ecbb127 chore(l10n): rename Xbox to XBOX (#6130) 2026-05-27 21:03:16 +08:00
ENC_Euphony 4c6f3aeb99 fix: avoid stale versions in switch-instance popup (#6133)
Fix #6099
2026-05-27 20:05:04 +08:00
Glavo d6a4b3480f 增强 JsonUtils (#6145) 2026-05-25 20:55:37 +08:00
Glavo 5e84e5bcd6 Bump version root to 3.15 (#6144) 2026-05-25 11:05:28 +08:00
Glavo 6ce7af05b2 chore: add gradle.properties to .gitignore (#6132) 2026-05-21 21:30:48 +08:00
CiiLu 0d62a12353 fix: 列表工具栏水波纹溢出 (#6117) 2026-05-20 22:04:27 +08:00
880aab0ba5 [Feature] 资源包管理 #4980 reforged reforged reforged (#6091)
Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
Co-authored-by: Glavo <zjx001202@gmail.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-05-20 21:57:19 +08:00
Glavo 0b64e89aaa Update line breaks (#6126) 2026-05-19 12:02:05 +08:00
Wulian233 7b6379674b 在已有账户的情况下移除联机和主页的玩家名称tooltip (#6123) 2026-05-18 21:37:46 +08:00
CiiLu 55f4b0d8fc feat: 实例设置的浏览菜单中添加崩溃日志文件夹快捷打开选项 (#6110) 2026-05-18 21:26:19 +08:00
CiiLu 863c3c9c7f fix: 删除 mmc-bootstrap 后无法补全 (#6102)
resolves #6064
2026-05-18 21:23:29 +08:00
ToobLac 1f5fb983b0 [Bugfix] 修复 CurseForge 模组类别翻译错误 (#6106) 2026-05-18 21:09:35 +08:00
CiiLu 856d5ec5af fix: LineSelectButton 在页面底部时方向异常 (#6118) 2026-05-18 20:51:16 +08:00
CiiLu 20a5670876 移除主页面 “下载” 按钮的 Tooltip (#6121) 2026-05-18 20:30:17 +08:00
Glavo bdf6a58be8 在无法显示 Swing 窗口时不显示 JavaFX 下载进度对话框 (#6074) 2026-05-16 03:00:31 +08:00
Glavo 7c74dd438f Add .gitattributes for line ending normalization and file type definition (#6089) 2026-05-15 16:50:02 +08:00
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
149 changed files with 5882 additions and 2649 deletions

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
# Default: normalize all text files to LF in the repository and on checkout.
# Git auto-detects binary files and leaves them untouched.
* text=auto eol=lf
# ─── Explicitly text – source & build ────────────────────────────────────────
*.java text eol=lf
*.kt text eol=lf
*.kts text eol=lf
*.groovy text eol=lf
*.gradle text eol=lf
# ─── Configuration & data ─────────────────────────────────────────────────────
*.properties text eol=lf
*.toml text eol=lf
*.xml text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.csv text eol=lf
*.html text eol=lf
*.css text eol=lf
*.txt text eol=lf
*.md text eol=lf
*.svg text eol=lf
*.MF text eol=lf
# Gradle dependency-lock files
*.lockfile text eol=lf
# ─── Scripts ──────────────────────────────────────────────────────────────────
# Unix shell scripts must always use LF so they stay executable on Linux/macOS.
*.sh text eol=lf
gradlew text eol=lf
# CI pipeline scripts
Jenkinsfile text eol=lf
# Windows batch files must keep CRLF so cmd.exe can parse them.
*.bat text eol=crlf
# ─── Binary files ─────────────────────────────────────────────────────────────
# Raster / animated images
*.png binary
*.apng binary
*.jpg binary
*.jpeg binary
*.webp binary
*.gif binary
*.ico binary
# Archives & compiled artifacts
*.jar binary
*.zip binary
*.class binary
# Cryptographic / certificate files
*.der binary
# Minecraft schematic
*.litematic binary
# Compiled / intermediate build artefacts
*.bin binary
*.probe binary
+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
+1
View File
@@ -5,6 +5,7 @@ hs_err_pid*
*.hprof
.gradle
gradle.properties
*.lck
*.1
-6
View File
@@ -19,9 +19,3 @@ These rules apply to all Java code written or modified in this repository.
- Documentation must use `///` Markdown-style Javadoc comments.
- Keep documentation accurate and specific to the actual behavior, constraints, and side effects.
- Add concise implementation comments inside complex logic whenever they materially improve readability or explain non-obvious behavior.
## Gradle
- When invoking Gradle in this repository, always set `GRADLE_USER_HOME` to the workspace-local `.gradle-user-home` directory.
- Prefer commands such as `./gradlew -g .gradle-user-home ...` or the equivalent environment-variable-based configuration.
- When running Gradle `test` tasks, use a higher timeout of ten minutes.
+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";
@@ -18,7 +18,6 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.ui.versions.ModTranslations;
@@ -47,33 +46,42 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
Set<String> englishSearchFiltersSet = new HashSet<>(INITIAL_CAPACITY);
Set<String> englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY);
int count = 0;
for (ModTranslations.Mod mod : ModTranslations.getTranslationsByRepositoryType(getType()).searchMod(searchFilter)) {
for (String englishWord : StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName())) {
if (englishSearchFiltersSet.contains(englishWord)) {
continue;
}
englishSearchFiltersSet.add(englishWord);
String englishSearchFilter = String.join(" ", StringUtils.tokenize(StringUtils.isNotBlank(mod.getSubname()) ? mod.getSubname() : mod.getName()));
if (StringUtils.isNotBlank(englishSearchFilter)) {
englishSearchFiltersSet.add(englishSearchFilter);
}
count++;
if (count >= 3) break;
}
if (englishSearchFiltersSet.isEmpty()) {
return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder);
}
RemoteMod[] searchResultArray = new RemoteMod[pageSize];
int totalPages, chineseIndex = 0, englishIndex = pageSize - 1;
{
SearchResult searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, String.join(" ", englishSearchFiltersSet), getBackedRemoteModRepositorySortOrder(), sortOrder);
for (Iterator<RemoteMod> iterator = searchResult.getUnsortedResults().iterator(); iterator.hasNext(); ) {
SearchResult searchResult = null;
List<RemoteMod> remoteMods = List.of();
for (String englishSearchFilter : englishSearchFiltersSet) {
searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, getBackedRemoteModRepositorySortOrder(), sortOrder);
remoteMods = searchResult.getUnsortedResults().toList();
if (!remoteMods.isEmpty()) {
break;
}
}
for (RemoteMod remoteMod : remoteMods) {
if (chineseIndex > englishIndex) {
LOG.warning("Too many search results! Are the backed remote mod repository broken? Or are the API broken?");
continue;
}
RemoteMod remoteMod = iterator.next();
ModTranslations.Mod chineseTranslation = ModTranslations.getTranslationsByRepositoryType(getType()).getModByCurseForgeId(remoteMod.getSlug());
if (chineseTranslation != null && !StringUtils.isBlank(chineseTranslation.getName()) && StringUtils.containsChinese(chineseTranslation.getName())) {
searchResultArray[chineseIndex++] = remoteMod;
@@ -110,8 +118,8 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor
}
@Override
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file);
public Optional<RemoteMod.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file);
}
@Override
@@ -30,7 +30,10 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.*;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
@@ -172,6 +175,7 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
public static class Factory implements OAuth.Callback {
public final EventManager<GrantDeviceCodeEvent> onGrantDeviceCode = new EventManager<>();
public final EventManager<LoginCompletedDeviceCodeEvent> onLoginCompletedDeviceCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserAuthorizationCode = new EventManager<>();
public final EventManager<OpenBrowserEvent> onOpenBrowserDevice = new EventManager<>();
@@ -199,6 +203,11 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
onGrantDeviceCode.fireEvent(new GrantDeviceCodeEvent(this, userCode, verificationURI));
}
@Override
public void loginCompletedDeviceCode() {
onLoginCompletedDeviceCode.fireEvent(new LoginCompletedDeviceCodeEvent(this));
}
@Override
public void openBrowser(OAuth.GrantFlow grantFlow, String url) throws IOException {
lastlyOpenedURL = url;
@@ -235,6 +244,12 @@ public final class OAuthServer extends NanoHTTPD implements OAuth.Session {
}
}
public static class LoginCompletedDeviceCodeEvent extends Event {
public LoginCompletedDeviceCodeEvent(Object source) {
super(source);
}
}
public static class OpenBrowserEvent extends Event {
private final String url;
@@ -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(), "");
@@ -1585,9 +1584,8 @@ public final class FXUtils {
}
/**
* Intelligently determines the popup position to prevent the menu from exceeding screen boundaries.
* Supports multi-monitor setups by detecting the current screen where the component is located.
* Now handles first-time popup display by forcing layout measurement.
* Intelligently determines the popup position to prevent the menu from exceeding screen boundaries,
* window boundaries, and clipping containers like ScrollPane.
*
* @param root the root node to calculate position relative to
* @param popupInstance the popup instance to position
@@ -1596,8 +1594,10 @@ public final class FXUtils {
public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, JFXPopup popupInstance) {
// Get the screen bounds in screen coordinates
Bounds screenBounds = root.localToScreen(root.getBoundsInLocal());
if (screenBounds == null) {
return JFXPopup.PopupVPosition.TOP; // Fallback
}
// Convert Bounds to Rectangle2D for getScreensForRectangle method
Rectangle2D boundsRect = new Rectangle2D(
screenBounds.getMinX(), screenBounds.getMinY(),
screenBounds.getWidth(), screenBounds.getHeight()
@@ -1608,13 +1608,36 @@ public final class FXUtils {
Screen currentScreen = screens.isEmpty() ? Screen.getPrimary() : screens.get(0);
Rectangle2D visualBounds = currentScreen.getVisualBounds();
double screenHeight = visualBounds.getHeight();
double screenMinY = visualBounds.getMinY();
double itemScreenY = screenBounds.getMinY();
// 1. Initial bounds based on the physical Screen limits
double clipMinY = visualBounds.getMinY();
double clipMaxY = visualBounds.getMaxY();
// Calculate available space relative to the current screen
double availableSpaceAbove = itemScreenY - screenMinY;
double availableSpaceBelow = screenMinY + screenHeight - itemScreenY - root.getBoundsInLocal().getHeight();
// 2. Constrain by Window (Stage) bounds so it doesn't overflow the application window
if (root.getScene() != null && root.getScene().getWindow() != null) {
javafx.stage.Window window = root.getScene().getWindow();
clipMinY = Math.max(clipMinY, window.getY());
clipMaxY = Math.min(clipMaxY, window.getY() + window.getHeight());
}
// 3. Constrain by parent ScrollPanes to handle clipping within the UI layout
Node parent = root.getParent();
while (parent != null) {
if (parent instanceof ScrollPane) {
Bounds spBounds = parent.localToScreen(parent.getBoundsInLocal());
if (spBounds != null) {
clipMinY = Math.max(clipMinY, spBounds.getMinY());
clipMaxY = Math.min(clipMaxY, spBounds.getMaxY());
}
}
parent = parent.getParent();
}
double itemScreenY = screenBounds.getMinY();
double itemScreenMaxY = screenBounds.getMaxY();
// Calculate available space relative to the narrowest calculated clipping bounds
double availableSpaceAbove = itemScreenY - clipMinY;
double availableSpaceBelow = clipMaxY - itemScreenMaxY;
// Get popup content and ensure it's properly measured
Region popupContent = popupInstance.getPopupContent();
@@ -1630,8 +1653,7 @@ public final class FXUtils {
menuHeight = popupContent.getHeight();
if (menuHeight <= 0) {
// Fallback: estimate based on number of menu items
// Each menu item is roughly 36px height + separators + padding
menuHeight = 300; // Conservative estimate for the current menu structure
menuHeight = 300;
}
} else {
menuHeight = popupContent.getHeight();
@@ -1640,9 +1662,12 @@ public final class FXUtils {
// Add some margin for safety
menuHeight += 20;
return (availableSpaceAbove > menuHeight && availableSpaceBelow < menuHeight)
? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward
: JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward
// Logic: if there isn't enough space below, AND there is more space above, open upwards.
if (availableSpaceBelow < menuHeight && availableSpaceAbove > availableSpaceBelow) {
return JFXPopup.PopupVPosition.BOTTOM; // Show menu above the button, expanding upward
} else {
return JFXPopup.PopupVPosition.TOP; // Show menu below the button, expanding downward
}
}
public static void useJFXContextMenu(TextInputControl control) {
@@ -60,8 +60,10 @@ public enum SVG {
EXPLORE("M12 12Zm0 8q-3.325 0-5.6625-2.3375T4 12Q4 8.675 6.3375 6.3375T12 4q3.325-0 5.6625 2.3375T20 12q0 3.325-2.3375 5.6625T12 20Zm0 2q2.075-0 3.9-.7875t3.175-2.1375q1.35-1.35 2.1375-3.175T22 12q-0-2.075-.7875-3.9T19.075 4.925q-1.35-1.35-3.175-2.1375T12 2q-2.075 0-3.9.7875T4.925 4.925Q3.575 6.275 2.7875 8.1T2 12q0 2.075.7875 3.9T4.925 19.075q1.35 1.35 3.175 2.1375T12 22Zm0-8.5q.625 0 1.0625-.4375T13.5 12t-.4375-1.0625T12 10.5t-1.0625.4375T10.5 12t.4375 1.0625T12 13.5Zm-4.5 3 2-7 7-2-2 7-7 2Z"),
EXTENSION("M8.8 21H5Q4.175 21 3.5875 20.4125T3 19V15.2Q4.2 15.2 5.1 14.4375T6 12.5Q6 11.325 5.1 10.5625T3 9.8V6Q3 5.175 3.5875 4.5875T5 4H9Q9 2.95 9.725 2.225T11.5 1.5Q12.55 1.5 13.275 2.225T14 4H18Q18.825 4 19.4125 4.5875T20 6V10Q21.05 10 21.775 10.725T22.5 12.5Q22.5 13.55 21.775 14.275T20 15V19Q20 19.825 19.4125 20.4125T18 21H14.2Q14.2 19.75 13.4125 18.875T11.5 18Q10.375 18 9.5875 18.875T8.8 21ZM5 19H7.125Q7.725 17.35 9.05 16.675T11.5 16Q12.625 16 13.95 16.675T15.875 19H18V13H20Q20.2 13 20.35 12.85T20.5 12.5Q20.5 12.3 20.35 12.15T20 12H18V6H12V4Q12 3.8 11.85 3.65T11.5 3.5Q11.3 3.5 11.15 3.65T11 4V6H5V8.2Q6.35 8.7 7.175 9.875T8 12.5Q8 13.925 7.175 15.1T5 16.8V19ZM11.5 12.5Z"),
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"),
FRAME_BUG("M12 19q1.65 0 2.825-1.175T16 15v-4q0-1.65-1.175-2.825T12 7T9.175 8.175T8 11v4q0 1.65 1.175 2.825T12 19m-2-3h4v-2h-4zm0-4h4v-2h-4zm2 9q-1.625 0-3.012-.8T6.8 18H4v-2h2.1q-.075-.5-.088-1T6 14H4v-2h2q0-.5.012-1t.088-1H4V8h2.8q.35-.575.788-1.075T8.6 6.05L7 4.4L8.4 3l2.15 2.15q.7-.225 1.425-.225t1.425.225L15.6 3L17 4.4l-1.65 1.65q.575.375 1.038.862T17.2 8H20v2h-2.1q.075.5.088 1T18 12h2v2h-2q0 .5-.013 1t-.087 1H20v2h-2.8q-.8 1.4-2.187 2.2T12 21M1 6V3q0-.825.588-1.412T3 1h3v2H3v3zm5 17H3q-.825 0-1.412-.587T1 21v-3h2v3h3zm12 0v-2h3v-3h2v3q0 .825-.587 1.413T21 23zm3-17V3h-3V1h3q.825 0 1.413.588T23 3v3z"),
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"),
@@ -55,6 +55,7 @@ public abstract class ToolbarListPageSkin<E, P extends ListPageBase<E>> extends
toolbar.setAlignment(Pos.CENTER_LEFT);
toolbar.setPickOnBounds(false);
toolbar.getChildren().setAll(toolbarButtons);
FXUtils.setOverflowHidden(toolbar, 8);
root.getContent().add(toolbar);
}
@@ -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
@@ -17,7 +17,6 @@
*/
package org.jackhuang.hmcl.ui.account;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
@@ -27,7 +26,6 @@ import javafx.scene.control.Tooltip;
import org.jackhuang.hmcl.auth.Account;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorAccount;
import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
import org.jackhuang.hmcl.auth.yggdrasil.YggdrasilAccount;
import org.jackhuang.hmcl.game.TexturesLoader;
import org.jackhuang.hmcl.setting.Accounts;
import org.jackhuang.hmcl.ui.FXUtils;
@@ -42,6 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
public class AccountAdvancedListItem extends AdvancedListItem {
private final Tooltip tooltip;
private final Canvas canvas;
private boolean tooltipInstalled;
private final ObjectProperty<Account> account = new SimpleObjectProperty<Account>() {
@@ -51,10 +50,10 @@ public class AccountAdvancedListItem extends AdvancedListItem {
if (account == null) {
titleProperty().unbind();
subtitleProperty().unbind();
tooltip.textProperty().unbind();
setTitle(i18n("account.missing"));
setSubtitle(i18n("account.missing.add"));
tooltip.setText(i18n("account.create"));
installTooltip();
TexturesLoader.unbindAvatar(canvas);
TexturesLoader.drawAvatar(canvas, TexturesLoader.getDefaultSkinImage());
@@ -62,7 +61,7 @@ public class AccountAdvancedListItem extends AdvancedListItem {
} else {
titleProperty().bind(BindingMapping.of(account, Account::getCharacter));
subtitleProperty().bind(accountSubtitle(account));
tooltip.textProperty().bind(accountTooltip(account));
uninstallTooltip();
TexturesLoader.bindAvatar(canvas, account);
}
}
@@ -74,7 +73,6 @@ public class AccountAdvancedListItem extends AdvancedListItem {
public AccountAdvancedListItem(Account account) {
tooltip = new Tooltip();
FXUtils.installFastTooltip(this, tooltip);
canvas = new Canvas(32, 32);
canvas.setMouseTransparent(true);
@@ -103,19 +101,17 @@ public class AccountAdvancedListItem extends AdvancedListItem {
}
}
private static ObservableValue<String> accountTooltip(Account account) {
if (account instanceof AuthlibInjectorAccount) {
AuthlibInjectorServer server = ((AuthlibInjectorAccount) account).getServer();
return Bindings.format("%s (%s) (%s)",
BindingMapping.of(account, Account::getCharacter),
account.getUsername(),
BindingMapping.of(server, AuthlibInjectorServer::getName));
} else if (account instanceof YggdrasilAccount) {
return Bindings.format("%s (%s)",
BindingMapping.of(account, Account::getCharacter),
account.getUsername());
} else {
return BindingMapping.of(account, Account::getCharacter);
private void installTooltip() {
if (!tooltipInstalled) {
FXUtils.installFastTooltip(this, tooltip);
tooltipInstalled = true;
}
}
private void uninstallTooltip() {
if (tooltipInstalled) {
Tooltip.uninstall(this, tooltip);
tooltipInstalled = false;
}
}
@@ -57,6 +57,7 @@ public final class AccountListPopupMenu extends StackPane {
for (Account account : Accounts.getAccounts()) {
AccountAdvancedListItem item = new AccountAdvancedListItem(account);
if (Accounts.getSelectedAccount() == account) item.setActive(true);
item.setOnAction(e -> {
Accounts.setSelectedAccount(account);
if (getScene().getWindow() instanceof JFXPopup popup)
@@ -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);
@@ -124,6 +124,11 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
step.set(new Step.WaitForScanQrCode(event.getUserCode(), event.getVerificationUri()));
}));
holder.registerWeak(Accounts.OAUTH_CALLBACK.onLoginCompletedDeviceCode, event -> Platform.runLater(() -> {
if (step.get() instanceof Step.WaitForScanQrCode)
step.set(new Step.DeviceLoginCompleted());
}));
this.step.set(Accounts.OAUTH_CALLBACK.getClientId().isEmpty()
? new Step.Init()
: new Step.StartAuthorizationCodeLogin());
@@ -220,6 +225,13 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
codeBox.setMaxWidth(USE_PREF_SIZE);
rootContainer.getChildren().addAll(deviceHint, new Group(qrCode), codeBox);
} else if (currentStep instanceof Step.DeviceLoginCompleted) {
loginButtonSpinner.setLoading(true);
var hintPane = new HintPane(MessageDialogPane.MessageType.INFO);
hintPane.setSegment(i18n("account.methods.microsoft.methods.device.hint.completed"));
rootContainer.getChildren().addAll(hintPane);
} else if (currentStep instanceof Step.LoginFailed failed) {
btnLogin.setOnAction(e -> this.step.set(new Step.StartAuthorizationCodeLogin()));
loginButtonSpinner.setLoading(false);
@@ -312,6 +324,10 @@ public class MicrosoftAccountLoginPane extends JFXDialogLayout implements Dialog
}
record DeviceLoginCompleted() implements Step {
}
record LoginFailed(String message) implements Step {
}
}
@@ -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);
}
@@ -68,6 +68,13 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage {
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_MOD =
(downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_RESOURCE_PACK =
(downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "resourcepacks");
public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_SHADER =
(downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "shaderpacks");
private final ReadOnlyObjectWrapper<DecoratorPage.State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1));
private final TabHeader tab;
private final TabHeader.Tab<VersionsPage> newGameTab = new TabHeader.Tab<>("newGameTab");
@@ -99,9 +106,9 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
page.getActions().add(installLocalModpackButton);
return page;
}));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "mods"), true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "resourcepacks"), true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((downloadProvider, profile, version, mod, file) -> download(downloadProvider, profile, version, file, "shaderpacks"), true)));
modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true)));
resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true)));
shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true)));
worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS)));
tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab);
@@ -213,8 +220,9 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage
tab.select(modpackTab, false);
}
public void showResourcepackDownloads() {
public DownloadListPage showResourcePackDownloads() {
tab.select(resourcePackTab, false);
return resourcePackTab.getNode();
}
public DownloadListPage showModDownloads() {
@@ -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")
@@ -187,7 +187,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
Controllers.getDownloadPage().showGameDownloads();
Controllers.navigate(Controllers.getDownloadPage());
});
FXUtils.installFastTooltip(downloadItem, i18n("download.hint"));
if (AnimationUtils.isAnimationEnabled()) {
FXUtils.prepareOnMouseEnter(downloadItem, Controllers::prepareDownloadPage);
}
@@ -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());
}));
@@ -164,7 +164,7 @@ public class TerracottaControllerPage extends StackPane {
if (uninitialized.hasLegacy() && I18n.isUseChinese()) {
Object feedback = config().getShownTips().get(FEEDBACK_TIP);
if (!(feedback instanceof Number number) || number.intValue() < 1) {
Controllers.confirm(i18n("terracotta.feedback.desc"), i18n("terracotta.feedback.title"), () -> {
Controllers.confirm(i18n("terracotta.feedback.desc.update"), i18n("terracotta.feedback.title"), () -> {
FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK);
config().getShownTips().put(FEEDBACK_TIP, 1);
}, () -> {
@@ -246,7 +246,7 @@ public class TerracottaControllerPage extends StackPane {
} else {
handler.resolve();
}
});
}, "", new RequiredValidator(i18n("input.not_empty")));
});
if (ThreadLocalRandom.current().nextDouble() < 0.02D) {
@@ -18,7 +18,7 @@
package org.jackhuang.hmcl.ui.versions;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.LocalAddonFile;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@@ -30,33 +30,33 @@ import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class ModCheckUpdatesTask extends Task<List<LocalModFile.ModUpdate>> {
public class AddonCheckUpdatesTask<T extends LocalAddonFile> extends Task<List<LocalAddonFile.AddonUpdate>> {
private final DownloadProvider downloadProvider;
private final List<Task<LocalModFile.ModUpdate>> dependents;
private final List<Task<LocalAddonFile.AddonUpdate>> dependents;
public ModCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<LocalModFile> mods) {
public AddonCheckUpdatesTask(DownloadProvider downloadProvider, String gameVersion, Collection<T> addons) {
this.downloadProvider = downloadProvider;
dependents = mods.stream().map(mod ->
dependents = addons.stream().map(addon ->
Task.supplyAsync(Schedulers.io(), () -> {
LocalModFile.ModUpdate candidate = null;
LocalAddonFile.AddonUpdate candidate = null;
for (RemoteMod.Type type : RemoteMod.Type.values()) {
LocalModFile.ModUpdate update = null;
LocalAddonFile.AddonUpdate update = null;
try {
update = mod.checkUpdates(downloadProvider, gameVersion, type.getRemoteModRepository());
update = addon.checkUpdates(downloadProvider, gameVersion, type);
} catch (IOException e) {
LOG.warning(String.format("Cannot check update for mod %s.", mod.getFileName()), e);
LOG.warning(String.format("Cannot check update for addon %s.", addon.getFileName()), e);
}
if (update == null) {
continue;
}
if (candidate == null || candidate.getCandidate().getDatePublished().isBefore(update.getCandidate().getDatePublished())) {
if (candidate == null || candidate.targetVersion().getDatePublished().isBefore(update.targetVersion().getDatePublished())) {
candidate = update;
}
}
return candidate;
}).setName(mod.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
}).setName(addon.getFileName()).setSignificance(TaskSignificance.MAJOR).withCounter("update.checking")
).toList();
setStage("update.checking");
@@ -29,8 +29,8 @@ import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.mod.ModManager;
import org.jackhuang.hmcl.mod.LocalAddonFile;
import org.jackhuang.hmcl.mod.LocalAddonManager;
import org.jackhuang.hmcl.mod.RemoteMod;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jackhuang.hmcl.task.Schedulers;
@@ -41,10 +41,11 @@ import org.jackhuang.hmcl.ui.construct.JFXCheckBoxTableCell;
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.CSVTable;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
@@ -56,50 +57,50 @@ import java.util.function.Function;
import java.util.stream.Collectors;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public class ModUpdatesPage extends BorderPane implements DecoratorPage {
public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane implements DecoratorPage {
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("mods.check_updates")));
private final ModManager modManager;
private final ObservableList<ModUpdateObject> objects;
private final LocalAddonManager<F> localAddonManager;
private final ObservableList<AddonUpdateObject> objects;
@SuppressWarnings("unchecked")
public ModUpdatesPage(ModManager modManager, List<LocalModFile.ModUpdate> updates) {
this.modManager = modManager;
public AddonUpdatesPage(LocalAddonManager<F> localAddonManager, List<LocalAddonFile.AddonUpdate> updates) {
this.localAddonManager = localAddonManager;
getStyleClass().add("gray-background");
TableColumn<ModUpdateObject, Boolean> enabledColumn = new TableColumn<>();
TableColumn<AddonUpdateObject, Boolean> enabledColumn = new TableColumn<>();
var allEnabledBox = new JFXCheckBox();
enabledColumn.setStyle("-fx-alignment: CENTER;");
enabledColumn.setGraphic(allEnabledBox);
enabledColumn.setCellFactory(JFXCheckBoxTableCell.forTableColumn(enabledColumn));
setupCellValueFactory(enabledColumn, ModUpdateObject::enabledProperty);
setupCellValueFactory(enabledColumn, AddonUpdateObject::enabledProperty);
enabledColumn.setEditable(true);
enabledColumn.setMaxWidth(40);
enabledColumn.setMinWidth(40);
TableColumn<ModUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("mods.check_updates.file"));
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("mods.check_updates.file"));
fileNameColumn.setPrefWidth(200);
setupCellValueFactory(fileNameColumn, ModUpdateObject::fileNameProperty);
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
TableColumn<ModUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("mods.check_updates.current_version"));
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("mods.check_updates.current_version"));
currentVersionColumn.setPrefWidth(200);
setupCellValueFactory(currentVersionColumn, ModUpdateObject::currentVersionProperty);
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
TableColumn<ModUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("mods.check_updates.target_version"));
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("mods.check_updates.target_version"));
targetVersionColumn.setPrefWidth(200);
setupCellValueFactory(targetVersionColumn, ModUpdateObject::targetVersionProperty);
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
TableColumn<ModUpdateObject, String> sourceColumn = new TableColumn<>(i18n("mods.check_updates.source"));
setupCellValueFactory(sourceColumn, ModUpdateObject::sourceProperty);
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("mods.check_updates.source"));
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
objects = FXCollections.observableList(updates.stream().map(ModUpdateObject::new).collect(Collectors.toList()));
objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::new).collect(Collectors.toList()));
FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new));
TableView<ModUpdateObject> table = new TableView<>(objects);
TableView<AddonUpdateObject> table = new TableView<>(objects);
table.setEditable(true);
table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn);
setMargin(table, new Insets(10, 10, 5, 10));
@@ -114,7 +115,7 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
exportListButton.setOnAction(e -> exportList());
JFXButton nextButton = FXUtils.newRaisedButton(i18n("mods.check_updates.confirm"));
nextButton.setOnAction(e -> updateMods());
nextButton.setOnAction(e -> updateFiles());
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
@@ -125,23 +126,24 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
setBottom(actions);
}
private <T> void setupCellValueFactory(TableColumn<ModUpdateObject, T> column, Function<ModUpdateObject, ObservableValue<T>> mapper) {
private <T> void setupCellValueFactory(TableColumn<AddonUpdateObject, T> column, Function<AddonUpdateObject, ObservableValue<T>> mapper) {
column.setCellValueFactory(param -> mapper.apply(param.getValue()));
}
private void updateMods() {
ModUpdateTask task = new ModUpdateTask(
modManager,
private void updateFiles() {
AddonUpdateTask task = new AddonUpdateTask(
localAddonManager.getDirectory(),
objects.stream()
.filter(o -> o.enabled.get())
.map(object -> pair(object.data.getLocalMod(), object.data.getCandidate()))
.collect(Collectors.toList()));
.filter(AddonUpdateObject::isEnabled)
.map(AddonUpdateObject::getData)
.toList()
);
Controllers.taskDialog(
task.whenComplete(Schedulers.javafx(), exception -> {
fireEvent(new PageCloseEvent());
if (!task.getFailedMods().isEmpty()) {
if (!task.getFailedAddons().isEmpty()) {
Controllers.dialog(i18n("mods.check_updates.failed_download") + "\n" +
task.getFailedMods().stream().map(LocalModFile::getFileName).collect(Collectors.joining("\n")),
task.getFailedAddons().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")),
i18n("install.failed"),
MessageDialogPane.MessageType.ERROR);
}
@@ -189,22 +191,22 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
return state;
}
private static final class ModUpdateObject {
final LocalModFile.ModUpdate data;
private static final class AddonUpdateObject {
final LocalAddonFile.AddonUpdate data;
final BooleanProperty enabled = new SimpleBooleanProperty();
final StringProperty fileName = new SimpleStringProperty();
final StringProperty currentVersion = new SimpleStringProperty();
final StringProperty targetVersion = new SimpleStringProperty();
final StringProperty source = new SimpleStringProperty();
public ModUpdateObject(LocalModFile.ModUpdate data) {
public AddonUpdateObject(LocalAddonFile.AddonUpdate data) {
this.data = data;
enabled.set(!data.getLocalMod().getModManager().isDisabled(data.getLocalMod().getFile()));
fileName.set(data.getLocalMod().getFileName());
currentVersion.set(data.getCurrentVersion().getVersion());
targetVersion.set(data.getCandidate().getVersion());
switch (data.getCurrentVersion().getSelf().getType()) {
enabled.set(!data.localAddonFile().isDisabled());
fileName.set(data.localAddonFile().getFileName());
currentVersion.set(data.currentVersion().getVersion());
targetVersion.set(data.targetVersion().getVersion());
switch (data.currentVersion().getSelf().getType()) {
case CURSEFORGE:
source.set(i18n("mods.curseforge"));
break;
@@ -213,6 +215,10 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
}
}
public LocalAddonFile.AddonUpdate getData() {
return data;
}
public boolean isEnabled() {
return enabled.get();
}
@@ -274,30 +280,32 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
}
}
public static class ModUpdateTask extends Task<Void> {
public static class AddonUpdateTask extends Task<Void> {
private final Collection<Task<?>> dependents;
private final List<LocalModFile> failedMods = new ArrayList<>();
private final List<LocalAddonFile> failedAddons = new ArrayList<>();
ModUpdateTask(ModManager modManager, List<Pair<LocalModFile, RemoteMod.Version>> mods) {
AddonUpdateTask(Path addonDirectory, List<LocalAddonFile.AddonUpdate> addons) {
setStage("mods.check_updates.confirm");
getProperties().put("total", mods.size());
getProperties().put("total", addons.size());
this.dependents = new ArrayList<>();
for (Pair<LocalModFile, RemoteMod.Version> mod : mods) {
LocalModFile local = mod.getKey();
RemoteMod.Version remote = mod.getValue();
boolean isDisabled = local.getModManager().isDisabled(local.getFile());
for (LocalAddonFile.AddonUpdate addon : addons) {
LocalAddonFile local = addon.localAddonFile();
RemoteMod.Version remote = addon.targetVersion();
boolean isDisabled = local.isDisabled();
String originalFileName = local.getFile().getFileName().toString();
dependents.add(Task
.runAsync(Schedulers.javafx(), () -> local.setOld(true))
.thenComposeAsync(() -> {
String fileName = remote.getFile().getFilename();
String fileName = addon.useRemoteFileName() ? remote.getFile().getFilename() : originalFileName;
if (isDisabled)
fileName += ModManager.DISABLED_EXTENSION;
fileName = StringUtils.addSuffix(fileName, LocalAddonManager.DISABLED_EXTENSION);
var task = new FileDownloadTask(
remote.getFile().getUrl(),
modManager.getModsDirectory().resolve(fileName));
addonDirectory.resolve(fileName)
);
task.setName(remote.getName());
return task;
@@ -307,16 +315,22 @@ public class ModUpdatesPage extends BorderPane implements DecoratorPage {
// restore state if failed
local.setOld(false);
if (isDisabled)
local.disable();
failedMods.add(local);
local.markDisabled();
failedAddons.add(local);
} else if (!local.keepOldFiles()) {
try {
local.delete();
} catch (IOException e) {
LOG.warning("Failed to delete outdated addon: " + local.getFile(), e);
}
}
})
.withCounter("mods.check_updates.confirm"));
}
}
public List<LocalModFile> getFailedMods() {
return failedMods;
public List<LocalAddonFile> getFailedAddons() {
return failedAddons;
}
@Override
@@ -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;
@@ -192,7 +192,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
}).executor(true);
}
protected String getLocalizedCategory(String category) {
protected String getLocalizedCategory(String category, Object self) {
return repository instanceof ModrinthRemoteModRepository
? i18n("modrinth.category." + category)
: i18n("curse.category." + category);
@@ -204,9 +204,9 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
private String getLocalizedCategoryIndent(ModDownloadListPageSkin.CategoryIndented category) {
return StringUtils.repeats(' ', category.indent * 4) +
(category.getCategory() == null
(category.category() == null
? i18n("curse.category.0")
: getLocalizedCategory(category.getCategory().getId()));
: getLocalizedCategory(category.category().id(), category.category().self()));
}
protected String getLocalizedOfficialPage() {
@@ -386,7 +386,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
int pageOffset = control.pageOffset.get();
getSkinnable().search(gameVersionField.getSelectionModel().getSelectedItem(),
Optional.ofNullable(categoryComboBox.getSelectionModel().getSelectedItem())
.map(CategoryIndented::getCategory)
.map(CategoryIndented::category)
.orElse(null),
pageOffset == -1 ? 0 : pageOffset,
nameField.getText(),
@@ -585,7 +585,7 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
content.getTags().clear();
for (String category : item.getCategories()) {
if (getSkinnable().shouldDisplayCategory(category))
content.addTag(getSkinnable().getLocalizedCategory(category));
content.addTag(getSkinnable().getLocalizedCategory(category, null));
}
iconLoader.load(imageContainer.imageProperty(), item.getIconUrl());
setGraphic(wrapper);
@@ -597,29 +597,13 @@ public class DownloadListPage extends Control implements DecoratorPage, VersionP
getChildren().setAll(pane);
}
private static class CategoryIndented {
private record CategoryIndented(int indent, RemoteModRepository.Category category) {
private static final CategoryIndented ALL = new CategoryIndented(0, null);
private final int indent;
private final RemoteModRepository.Category category;
public CategoryIndented(int indent, RemoteModRepository.Category category) {
this.indent = indent;
this.category = category;
}
public int getIndent() {
return indent;
}
public RemoteModRepository.Category getCategory() {
return category;
}
}
private static void resolveCategory(RemoteModRepository.Category category, int indent, List<CategoryIndented> result) {
result.add(new CategoryIndented(indent, category));
for (RemoteModRepository.Category subcategory : category.getSubcategories()) {
for (RemoteModRepository.Category subcategory : category.subcategories()) {
resolveCategory(subcategory, indent + 1, result);
}
}
@@ -73,6 +73,7 @@ public class DownloadPage extends Control implements DecoratorPage {
private final Profile.ProfileVersion version;
private final DownloadCallback callback;
private final DownloadListPage page;
private final RemoteModRepository.Type type;
private SimpleMultimap<String, RemoteMod.Version, List<RemoteMod.Version>> versions;
@@ -80,7 +81,8 @@ public class DownloadPage extends Control implements DecoratorPage {
this.page = page;
this.repository = page.repository;
this.addon = addon;
this.translations = ModTranslations.getTranslationsByRepositoryType(repository.getType());
this.type = Objects.requireNonNullElse(addon.getRepositoryType(), repository.getType());
this.translations = ModTranslations.getTranslationsByRepositoryType(this.type);
this.mod = translations.getModByCurseForgeId(addon.getSlug());
this.version = version;
this.callback = callback;
@@ -159,13 +161,13 @@ public class DownloadPage extends Control implements DecoratorPage {
public void download(RemoteMod mod, RemoteMod.Version file) {
if (this.callback == null) {
saveAs(mod, file);
saveAs(file);
} else {
this.callback.download(page.getDownloadProvider(), version.getProfile(), version.getVersion(), mod, file);
}
}
public void saveAs(RemoteMod mod, RemoteMod.Version file) {
public void saveAs(RemoteMod.Version file) {
String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.');
FileChooser fileChooser = new FileChooser();
@@ -226,7 +228,7 @@ public class DownloadPage extends Control implements DecoratorPage {
content.getSubtitleLabel().setWrapText(true);
getSkinnable().addon.getCategories().stream()
.filter(category -> getSkinnable().page.shouldDisplayCategory(category))
.map(category -> getSkinnable().page.getLocalizedCategory(category))
.map(category -> getSkinnable().page.getLocalizedCategory(category, null))
.forEach(content::addTag);
content.getFirstLine().setMinWidth(0);
descriptionPane.getChildren().add(content);
@@ -282,14 +284,22 @@ public class DownloadPage extends Control implements DecoratorPage {
resolve:
for (RemoteMod.Version modVersion : modVersions) {
for (ModLoaderType loader : modVersion.getLoaders()) {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new ModItem(control.addon, modVersion, control)
);
break resolve;
if (getSkinnable().type == RemoteModRepository.Type.MOD) {
for (ModLoaderType loader : modVersion.getLoaders()) {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new ModItem(control.addon, modVersion, control)
);
break resolve;
}
}
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)),
new ModItem(control.addon, modVersion, control)
);
break;
}
}
}
@@ -364,7 +374,7 @@ public class DownloadPage extends Control implements DecoratorPage {
Pair.pair(RemoteMod.DependencyType.BROKEN, "mods.dependency.broken")
));
DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) {
DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version) {
HBox pane = new HBox(8);
pane.setPadding(new Insets(0, 8, 0, 8));
pane.setAlignment(Pos.CENTER_LEFT);
@@ -374,6 +384,14 @@ public class DownloadPage extends Control implements DecoratorPage {
var imageView = new ImageContainer(40);
pane.getChildren().setAll(imageView, content);
FXUtils.setLimitHeight(this, 60);
RemoteModRepository.Type type = addon.getRepositoryType();
DownloadCallback callback = switch (type) {
case MOD -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD;
case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK;
case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER;
default -> null; // Dependencies should not be modpacks, worlds or customized stuff
};
setOnAction((e) -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(page, addon, version, callback));
@@ -381,12 +399,12 @@ public class DownloadPage extends Control implements DecoratorPage {
setNode(IDX_LEADING, pane);
if (addon != RemoteMod.BROKEN) {
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug());
ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.getSlug());
content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle());
content.setSubtitle(addon.getDescription());
for (String category : addon.getCategories()) {
if (page.shouldDisplayCategory(category))
content.addTag(page.getLocalizedCategory(category));
content.addTag(page.getLocalizedCategory(category, null));
}
if (StringUtils.isNotBlank(addon.getIconUrl())) {
imageView.imageProperty().bind(FXUtils.newRemoteImage(addon.getIconUrl(), 80, 80, true, true));
@@ -441,7 +459,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:
@@ -473,7 +491,7 @@ public class DownloadPage extends Control implements DecoratorPage {
private static final class ModVersion extends JFXDialogLayout {
public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) {
RemoteModRepository.Type type = selfPage.repository.getType();
RemoteModRepository.Type type = selfPage.type;
String title = switch (type) {
case WORLD -> "world.download.title";
@@ -525,7 +543,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) {
fireEvent(new DialogCloseEvent());
}
selfPage.saveAs(mod, version);
selfPage.saveAs(version);
});
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
@@ -569,7 +587,7 @@ public class DownloadPage extends Control implements DecoratorPage {
if (dep == RemoteMod.BROKEN) {
return;
}
DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dep, selfPage.version, selfPage.callback);
DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dep, selfPage.version);
dependencies.get(dependency.getType()).add(dependencyModItem);
})
.setSignificance(Task.TaskSignificance.MINOR));
@@ -34,7 +34,10 @@ import javafx.scene.control.Skin;
import javafx.scene.control.SkinBase;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.scene.layout.VBox;
import javafx.util.Duration;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.setting.Profiles;
@@ -241,6 +244,8 @@ public class GameListPage extends DecoratorAnimatedPage implements DecoratorPage
toolbarPane.setContent(toolbarNormal, ContainerAnimations.FADE);
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
}
@@ -31,7 +31,6 @@ import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.ListView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Region;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.game.Version;
@@ -53,7 +52,10 @@ public final class GameListPopupMenu extends StackPane {
double initOffsetX, double initOffsetY,
Profile profile, List<Version> versions) {
GameListPopupMenu menu = new GameListPopupMenu();
menu.getItems().setAll(versions.stream().map(it -> new GameItem(profile, it.getId())).toList());
menu.getItems().setAll(versions.stream()
.filter(it -> profile.getRepository().hasVersion(it.getId()))
.map(it -> new GameItem(profile, it.getId()))
.toList());
JFXPopup popup = new JFXPopup(menu);
popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY);
}
@@ -66,10 +68,11 @@ public final class GameListPopupMenu extends StackPane {
this.getStyleClass().add("popup-menu-content");
listView.setCellFactory(Cell::new);
listView.setFixedCellSize(60);
listView.setFixedCellSize(50);
listView.setPrefWidth(300);
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(60).add(2));
listView.prefHeightProperty().bind(Bindings.size(getItems()).multiply(50).add(2));
Label placeholder = new Label(i18n("version.empty"));
placeholder.setStyle("-fx-padding: 10px; -fx-text-fill: -monet-on-surface-variant; -fx-font-style: italic;");
@@ -94,35 +97,34 @@ public final class GameListPopupMenu extends StackPane {
public Cell(ListView<GameItem> listView) {
this.setPadding(Insets.EMPTY);
HBox root = new HBox();
root.setSpacing(8);
root.setAlignment(Pos.CENTER_LEFT);
StackPane imageViewContainer = new StackPane();
FXUtils.setLimitWidth(imageViewContainer, 32);
FXUtils.setLimitHeight(imageViewContainer, 32);
this.imageView = new ImageContainer(32);
imageViewContainer.getChildren().setAll(imageView);
this.imageView.setMouseTransparent(true);
BorderPane.setAlignment(imageView, Pos.CENTER);
this.content = new TwoLineListItem();
this.content.setMouseTransparent(true);
FXUtils.onChangeAndOperate(tag, tag -> {
content.getTags().clear();
if (StringUtils.isNotBlank(tag)) {
content.addTag(tag);
}
});
BorderPane.setAlignment(content, Pos.CENTER);
root.getChildren().setAll(imageView, content);
StackPane pane = new StackPane();
pane.getChildren().setAll(root);
pane.getStyleClass().add("menu-container");
root.setMouseTransparent(true);
BorderPane container = new BorderPane();
container.getStyleClass().add("container");
container.setPickOnBounds(false);
container.setLeft(imageView);
container.setCenter(content);
RipplerContainer ripplerContainer = new RipplerContainer(pane);
FXUtils.onClicked(ripplerContainer, () -> {
RipplerContainer ripplerContainer = new RipplerContainer(container);
StackPane rootPane = new StackPane();
rootPane.getStyleClass().add("advanced-list-item");
rootPane.getChildren().setAll(ripplerContainer);
rootPane.maxWidthProperty().bind(listView.widthProperty().subtract(5));
FXUtils.onClicked(rootPane, () -> {
GameItem item = getItem();
if (item != null) {
item.getProfile().setSelectedVersion(item.getId());
@@ -130,8 +132,8 @@ public final class GameListPopupMenu extends StackPane {
popup.hide();
}
});
this.graphic = ripplerContainer;
ripplerContainer.maxWidthProperty().bind(listView.widthProperty().subtract(5));
this.graphic = rootPane;
}
@Override
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.ui.versions;
import org.jackhuang.hmcl.game.LocalizedRemoteModRepository;
import org.jackhuang.hmcl.mod.RemoteModRepository;
import org.jackhuang.hmcl.mod.curse.CurseAddon;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.util.i18n.I18n;
@@ -50,6 +51,14 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.RESOURCE_PACK, CurseForgeRemoteModRepository.RESOURCE_PACKS, ModrinthRemoteModRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofCurseForgeResourcePack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.RESOURCE_PACK, CurseForgeRemoteModRepository.RESOURCE_PACKS, null);
}
public static DownloadListPage ofModrinthResourcePack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
return new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.RESOURCE_PACK, null, ModrinthRemoteModRepository.RESOURCE_PACKS);
}
public static DownloadListPage ofShaderPack(DownloadPage.DownloadCallback callback, boolean versionSelection) {
var page = new HMCLLocalizedDownloadListPage(callback, versionSelection, RemoteModRepository.Type.SHADER_PACK, CurseForgeRemoteModRepository.SHADERS, ModrinthRemoteModRepository.SHADER_PACKS);
page.supportChinese.set(false);
@@ -125,7 +134,7 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
}
@Override
protected String getLocalizedCategory(String category) {
protected String getLocalizedCategory(String category, Object self) {
if (category.isEmpty()) {
return "";
}
@@ -135,6 +144,9 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
return I18n.getResourceBundle().getString(key);
} catch (MissingResourceException e) {
LOG.warning("Cannot find key " + key + " in resource bundle");
if (self instanceof CurseAddon.Category curseCategory) {
return curseCategory.getName();
}
return category;
}
}
@@ -48,8 +48,8 @@ import java.util.concurrent.CancellationException;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.locks.ReentrantLock;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObject> implements VersionPage.VersionLoadable, PageAware {
private final BooleanProperty modded = new SimpleBooleanProperty(this, "modded", false);
@@ -64,7 +64,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
public ModListPage() {
FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "zip", "litemod").contains(FileUtils.getExtension(it)), mods -> {
FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> {
mods.forEach(it -> {
try {
modManager.addMod(it);
@@ -105,8 +105,8 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
CompletableFuture.supplyAsync(() -> {
lock.lock();
try {
modManager.refreshMods();
return modManager.getMods().stream().map(ModListPageSkin.ModInfoObject::new).toList();
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModListPageSkin.ModInfoObject::new).toList();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
@@ -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);
}
@@ -173,7 +173,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.litemod"));
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
if (res == null) return;
@@ -240,10 +240,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
if (gameVersion.isPresent()) {
return new ModCheckUpdatesTask(DownloadProviders.getDownloadProvider(), gameVersion.get(), mods);
}
return null;
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null);
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception instanceof CancellationException) return;
@@ -252,7 +249,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
} else if (result.isEmpty()) {
Controllers.dialog(i18n("mods.check_updates.empty"));
} else {
Controllers.navigateForward(new ModUpdatesPage(modManager, result));
Controllers.navigateForward(new AddonUpdatesPage<>(modManager, result));
}
})
.withStagesHints("update.checking"),
@@ -72,7 +72,6 @@ import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
@@ -198,6 +197,9 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
@@ -274,17 +276,11 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
if (queryString.startsWith("regex:")) {
try {
Pattern pattern = Pattern.compile(queryString.substring("regex:".length()));
predicate = s -> s != null && pattern.matcher(s).find();
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
} else {
String lowerQueryString = queryString.toLowerCase(Locale.ROOT);
predicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerQueryString);
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
@@ -478,7 +474,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
RemoteModRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteMod.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo(), modInfo.getModInfo().getFile());
Optional<RemoteMod.Version> versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile());
if (versionOptional.isPresent()) {
RemoteMod remoteMod = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().getModid());
FXUtils.runInFX(() -> {
@@ -487,7 +483,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");
@@ -508,7 +504,7 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false),
remoteMod,
new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()),
(downloadProvider, profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(downloadProvider, profile, version, file, "mods")
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD
));
});
button.setDisable(false);
@@ -657,7 +653,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"));
@@ -0,0 +1,643 @@
/*
* 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.ui.versions;
import com.jfoenix.controls.*;
import javafx.animation.PauseTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.css.PseudoClass;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.stage.Stage;
import javafx.util.Duration;
import org.jackhuang.hmcl.mod.*;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.resourcepack.ResourcePackFile;
import org.jackhuang.hmcl.resourcepack.ResourcePackManager;
import org.jackhuang.hmcl.setting.ConfigHolder;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.ListPageBase;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
import org.jackhuang.hmcl.ui.animation.TransitionPane;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.TaskCancellationAction;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcePackListPage extends ListPageBase<ResourcePackListPage.ResourcePackInfoObject> implements VersionPage.VersionLoadable {
private static final String TIP_KEY = "resourcePackWarning";
private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) {
return switch (compatibility) {
case TOO_NEW -> i18n("resourcepack.warning.too_new");
case TOO_OLD -> i18n("resourcepack.warning.too_old");
case INVALID -> i18n("resourcepack.warning.invalid");
case MISSING_PACK_META -> i18n("resourcepack.warning.missing_pack_meta");
case MISSING_GAME_META -> i18n("resourcepack.warning.missing_game_meta");
default -> null;
};
}
private Profile profile;
private String instanceId;
private Path resourcePackDirectory;
private ResourcePackManager resourcePackManager;
private final ReentrantLock lock = new ReentrantLock();
public ResourcePackListPage() {
FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles);
}
@Override
protected Skin<?> createDefaultSkin() {
return new ResourcePackListPageSkin(this);
}
@Override
public void loadVersion(Profile profile, String version) {
this.profile = profile;
this.instanceId = version;
this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version);
this.resourcePackDirectory = this.resourcePackManager.getDirectory();
refresh();
}
public void refresh() {
if (resourcePackManager == null) return;
setDisable(false);
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
lock.lock();
try {
if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) return null;
resourcePackManager.refresh();
return resourcePackManager.arePacksEnabled(resourcePackManager.getLocalFiles().stream())
.map(ResourcePackInfoObject::new)
.toList();
} finally {
lock.unlock();
}
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception == null) {
if (result != null) {
getItems().setAll(result);
} else { // Unsupported mc version
getItems().clear();
setDisable(true);
}
} else {
LOG.warning("Failed to load resource packs", exception);
getItems().clear();
}
setLoading(false);
}).start();
}
public void addFiles(List<Path> files) {
if (resourcePackManager == null) return;
List<Path> failures = new ArrayList<>();
Task.runAsync(() -> {
for (Path file : files) {
try {
resourcePackManager.importResourcePack(file);
} catch (Exception e) {
LOG.warning("Failed to add resource pack", e);
failures.add(file);
}
}
}).withRunAsync(Schedulers.javafx(), () -> {
if (!failures.isEmpty()) {
StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed"));
for (Path file: failures) {
failure.append("\n").append(file.toString());
}
Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
refresh();
}).start();
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip"));
List<Path> files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage()));
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onDownload() {
Controllers.getDownloadPage().showResourcePackDownloads().selectVersion(instanceId);
Controllers.navigate(Controllers.getDownloadPage());
}
private void onOpenFolder() {
if (resourcePackDirectory != null) {
FXUtils.openFolder(resourcePackDirectory);
}
}
private void setSelectedEnabled(List<ResourcePackInfoObject> selectedItems, boolean enabled) {
if (!Boolean.TRUE.equals(ConfigHolder.config().getShownTips().get(TIP_KEY)) && enabled && !selectedItems.stream().map(ResourcePackInfoObject::getFile).allMatch(ResourcePackFile::isCompatible)) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.warning"),
MessageDialogPane.MessageType.WARNING,
() -> {
ConfigHolder.config().getShownTips().put(TIP_KEY, true);
setSelectedEnabled(selectedItems, true);
}, null);
} else {
if (resourcePackManager == null) return;
if (enabled) {
resourcePackManager.enableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
} else {
resourcePackManager.disableResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList());
}
for (ResourcePackInfoObject item : selectedItems) {
item.enabledProperty().set(enabled);
}
}
}
private void removeSelected(List<ResourcePackInfoObject> selectedItems) {
try {
if (resourcePackManager != null) {
if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) {
refresh();
}
}
} catch (IOException e) {
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to delete resource packs", e);
}
}
public void checkUpdates(Collection<ResourcePackFile> resourcePacks) {
Runnable action = () -> Controllers.taskDialog(Task
.composeAsync(() -> {
Optional<String> gameVersion = profile.getRepository().getGameVersion(instanceId);
return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null);
})
.whenComplete(Schedulers.javafx(), (result, exception) -> {
if (exception != null || result == null) {
Controllers.dialog(i18n("mods.check_updates.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR);
} else if (result.isEmpty()) {
Controllers.dialog(i18n("mods.check_updates.empty"));
} else {
Controllers.navigateForward(new AddonUpdatesPage<>(resourcePackManager, result));
}
})
.withStagesHints("update.checking"),
i18n("mods.check_updates"), TaskCancellationAction.NORMAL);
if (profile.getRepository().isModpack(instanceId)) {
Controllers.confirm(
i18n("resourcepack.update_in_modpack.warning"), null,
MessageDialogPane.MessageType.WARNING,
action, null);
} else {
action.run();
}
}
private static final class ResourcePackListPageSkin extends SkinBase<ResourcePackListPage> {
private final JFXListView<ResourcePackInfoObject> listView;
private final JFXTextField searchField = new JFXTextField();
private final TransitionPane toolbarPane = new TransitionPane();
private final HBox searchBar = new HBox();
private final HBox toolbarNormal = new HBox();
private final HBox toolbarSelecting = new HBox();
private boolean isSearching;
private ResourcePackListPageSkin(ResourcePackListPage control) {
super(control);
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
{
// Toolbar Selecting
toolbarSelecting.getChildren().setAll(
createToolbarButton2(i18n("button.remove"), SVG.DELETE_FOREVER, () -> {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> {
control.removeSelected(listView.getSelectionModel().getSelectedItems());
}, null);
}),
createToolbarButton2(i18n("button.enable"), SVG.CHECK, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)),
createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () ->
control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)),
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
control.checkUpdates(
listView.getSelectionModel().getSelectedItems().stream().map(ResourcePackInfoObject::getFile).toList()
)
),
createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () ->
listView.getSelectionModel().selectAll()),
createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () ->
listView.getSelectionModel().clearSelection())
);
// Search Bar
searchBar.setAlignment(Pos.CENTER);
searchBar.setPadding(new Insets(0, 5, 0, 5));
searchField.setPromptText(i18n("search"));
HBox.setHgrow(searchField, Priority.ALWAYS);
PauseTransition pause = new PauseTransition(Duration.millis(100));
pause.setOnFinished(e -> search());
FXUtils.onChange(searchField.textProperty(), newValue -> {
pause.setRate(1);
pause.playFromStart();
});
JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE,
() -> {
changeToolbar(toolbarNormal);
isSearching = false;
searchField.clear();
Bindings.bindContent(listView.getItems(), getSkinnable().getItems());
});
onEscPressed(searchField, closeSearchBar::fire);
searchBar.getChildren().setAll(searchField, closeSearchBar);
// Toolbar Normal
toolbarNormal.setAlignment(Pos.CENTER_LEFT);
toolbarNormal.setPickOnBounds(false);
toolbarNormal.getChildren().setAll(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh),
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder),
createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, () ->
control.checkUpdates(listView.getItems().stream().map(ResourcePackInfoObject::getFile).toList())
),
createToolbarButton2(i18n("download"), SVG.DOWNLOAD, control::onDownload),
createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar))
);
FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(),
selectedItem -> {
if (selectedItem == null)
changeToolbar(isSearching ? searchBar : toolbarNormal);
else
changeToolbar(toolbarSelecting);
});
FXUtils.setOverflowHidden(toolbarPane, 8);
root.getContent().add(toolbarPane);
// Clear selection when pressing ESC
root.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
if (e.getCode() == KeyCode.ESCAPE) {
if (listView.getSelectionModel().getSelectedItem() != null) {
listView.getSelectionModel().clearSelection();
e.consume();
}
}
});
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(control.loadingProperty());
listView.setCellFactory(x -> new ResourcePackListCell(listView, control));
listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
Bindings.bindContent(listView.getItems(), control.getItems());
listView.setOnContextMenuRequested(event -> {
ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem();
if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) {
listView.getSelectionModel().clearSelection();
Controllers.dialog(new ResourcePackInfoDialog(control, selectedItem));
}
});
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
private void changeToolbar(HBox newToolbar) {
Node oldToolbar = toolbarPane.getCurrentNode();
if (newToolbar != oldToolbar) {
toolbarPane.setContent(newToolbar, ContainerAnimations.FADE);
if (newToolbar == searchBar) {
Platform.runLater(searchField::requestFocus);
}
}
}
private void search() {
isSearching = true;
Bindings.unbindContent(listView.getItems(), getSkinnable().getItems());
String queryString = searchField.getText();
if (StringUtils.isBlank(queryString)) {
listView.getItems().setAll(getSkinnable().getItems());
} else {
listView.getItems().clear();
Predicate<@Nullable String> predicate;
try {
predicate = StringUtils.compileQuery(queryString);
} catch (Throwable e) {
LOG.warning("Illegal regular expression", e);
return;
}
// Do we need to search in the background thread?
for (ResourcePackInfoObject item : getSkinnable().getItems()) {
ResourcePackFile resourcePack = item.getFile();
LocalAddonFile.Description description = resourcePack.getDescription();
Stream<String> descriptionParts = description == null
? Stream.empty()
: description.getParts().stream().map(LocalAddonFile.Description.Part::getText);
if (predicate.test(resourcePack.getFileNameWithExtension())
|| predicate.test(resourcePack.getFileName())
|| descriptionParts.anyMatch(predicate)) {
listView.getItems().add(item);
}
}
}
}
}
public static class ResourcePackInfoObject {
private final ResourcePackFile file;
private final BooleanProperty enabled;
public ResourcePackInfoObject(Pair<ResourcePackFile, Boolean> pair) {
this.file = pair.key();
this.enabled = new SimpleBooleanProperty(this, "enabled", pair.value());
}
public ResourcePackFile getFile() {
return file;
}
public BooleanProperty enabledProperty() {
return enabled;
}
Image getIcon() {
Image image = file.getIcon();
if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 ||
(Math.abs(image.getWidth() - image.getHeight()) >= 1)) {
image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
return image;
}
}
private static final class ResourcePackListCell extends MDListCell<ResourcePackInfoObject> {
private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning");
private final ResourcePackListPage page;
private final JFXCheckBox checkBox;
private final ImageContainer imageContainer = new ImageContainer(24);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER);
private final JFXButton btnInfo = FXUtils.newToggleButton4(SVG.INFO);
private ResourcePackInfoObject object = null;
private BooleanProperty booleanProperty = null;
public ResourcePackListCell(JFXListView<ResourcePackInfoObject> listView, ResourcePackListPage page) {
super(listView);
this.page = page;
getStyleClass().add("resource-pack-list-cell");
HBox root = new HBox(8);
root.setPickOnBounds(false);
root.setAlignment(Pos.CENTER_LEFT);
checkBox = new JFXCheckBox() {
@Override
public void fire() {
if (!Boolean.TRUE.equals(ConfigHolder.config().getShownTips().get(TIP_KEY)) && !isSelected() && object != null && !object.getFile().isCompatible()) {
Controllers.confirm(
i18n("resourcepack.warning.manipulate"),
i18n("message.info"),
MessageDialogPane.MessageType.INFO,
() -> {
super.fire();
ConfigHolder.config().getShownTips().put(TIP_KEY, true);
}, null);
} else {
super.fire();
}
}
};
checkBox.setOnAction(e -> {
if (object != null) object.file.setEnabled(checkBox.isSelected());
});
HBox.setHgrow(content, Priority.ALWAYS);
content.setMouseTransparent(true);
root.getChildren().setAll(checkBox, imageContainer, content, btnReveal, btnInfo);
setSelectable();
StackPane.setMargin(root, new Insets(8));
getContainer().getChildren().add(root);
}
@Override
protected void updateControl(ResourcePackInfoObject item, boolean empty) {
pseudoClassStateChanged(WARNING, false);
if (empty || item == null) return;
this.object = item;
ResourcePackFile file = item.getFile();
imageContainer.setImage(item.getIcon());
content.getTags().clear();
content.setTitle(file.getFileName());
{
var description = file.getDescription();
content.setSubtitle(description != null ? description.toStringSingleLine() : "");
}
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile()));
btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(this.page, item)));
if (booleanProperty != null) {
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
}
checkBox.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty());
{
var compatibility = file.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
pseudoClassStateChanged(WARNING, true);
content.addTagWarning(getWarning(compatibility));
}
}
}
}
private static final class ResourcePackInfoDialog extends JFXDialogLayout {
ResourcePackInfoDialog(ResourcePackListPage page, ResourcePackInfoObject packInfoObject) {
ResourcePackFile pack = packInfoObject.getFile();
HBox titleContainer = new HBox();
titleContainer.setSpacing(8);
Stage stage = Controllers.getStage();
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
ImageContainer imageContainer = new ImageContainer(40);
imageContainer.setImage(packInfoObject.getIcon());
TwoLineListItem title = new TwoLineListItem();
title.setTitle(pack.getFileName());
title.setSubtitle(pack.getFileNameWithExtension());
var compatibility = pack.getCompatibility();
if (compatibility != ResourcePackFile.Compatibility.COMPATIBLE) {
title.addTagWarning(getWarning(compatibility));
}
titleContainer.getChildren().setAll(imageContainer, title);
setHeading(titleContainer);
Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString());
description.setWrapText(true);
FXUtils.copyOnDoubleClick(description);
ScrollPane descriptionPane = new ScrollPane(description);
FXUtils.smoothScrolling(descriptionPane);
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
descriptionPane.setFitToWidth(true);
FXUtils.onChange(description.heightProperty(), newVal -> {
double maxHeight = stage.getHeight() * 0.5;
double targetHeight = Math.min(newVal.doubleValue(), maxHeight);
descriptionPane.setPrefViewportHeight(targetHeight);
});
setBody(descriptionPane);
for (Pair<String, ? extends RemoteModRepository> item : Arrays.asList(
pair("mods.curseforge", CurseForgeRemoteModRepository.RESOURCE_PACKS),
pair("mods.modrinth", ModrinthRemoteModRepository.RESOURCE_PACKS)
)) {
RemoteModRepository repository = item.getValue();
JFXHyperlink button = new JFXHyperlink(i18n(item.getKey()));
Task.runAsync(() -> {
Optional<RemoteMod.Version> versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile());
if (versionOptional.isPresent()) {
RemoteMod remoteMod = repository.getModById(DownloadProviders.getDownloadProvider(), versionOptional.get().getModid());
FXUtils.runInFX(() -> {
button.setOnAction(e -> {
fireEvent(new DialogCloseEvent());
Controllers.navigate(new DownloadPage(
repository instanceof CurseForgeRemoteModRepository
? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false)
: HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false),
remoteMod,
new Profile.ProfileVersion(page.profile, page.instanceId),
org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK
));
});
button.setDisable(false);
});
}
}).start();
button.setDisable(true);
getActions().add(button);
}
JFXButton okButton = new JFXButton();
okButton.getStyleClass().add("dialog-accept");
okButton.setText(i18n("button.ok"));
okButton.setOnAction(e -> fireEvent(new DialogCloseEvent()));
getActions().add(okButton);
onEscPressed(this, okButton::fire);
}
}
}
@@ -1,265 +0,0 @@
/*
* 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.ui.versions;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXListView;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.image.Image;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.stage.FileChooser;
import org.jackhuang.hmcl.mod.LocalModFile;
import org.jackhuang.hmcl.resourcepack.ResourcepackFile;
import org.jackhuang.hmcl.setting.Profile;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ResourcepackListPage extends ListPageBase<ResourcepackListPage.ResourcepackInfoObject> implements VersionPage.VersionLoadable {
private Path resourcepackDirectory;
public ResourcepackListPage() {
FXUtils.applyDragListener(this, file -> file.getFileName().toString().endsWith(".zip"), this::addFiles);
}
@Override
protected Skin<?> createDefaultSkin() {
return new ResourcepackListPageSkin(this);
}
@Override
public void loadVersion(Profile profile, String version) {
this.resourcepackDirectory = profile.getRepository().getResourcepacksDirectory(version);
try {
if (!Files.exists(resourcepackDirectory)) {
Files.createDirectories(resourcepackDirectory);
}
} catch (IOException e) {
LOG.warning("Failed to create resourcepack directory" + resourcepackDirectory, e);
}
refresh();
}
public void refresh() {
if (resourcepackDirectory == null || !Files.isDirectory(resourcepackDirectory)) return;
setLoading(true);
Task.supplyAsync(Schedulers.io(), () -> {
try (Stream<Path> stream = Files.list(resourcepackDirectory)) {
return stream.sorted(Comparator.comparing(FileUtils::getName))
.flatMap(item -> {
try {
return Stream.of(ResourcepackFile.parse(item)).filter(Objects::nonNull).map(ResourcepackInfoObject::new);
} catch (IOException e) {
LOG.warning("Failed to load resourcepack " + item, e);
return Stream.empty();
}
})
.toList();
}
}).whenComplete(Schedulers.javafx(), ((result, exception) -> {
if (exception == null) {
getItems().setAll(result);
} else {
LOG.warning("Failed to load resourcepacks", exception);
getItems().clear();
}
setLoading(false);
})).start();
}
public void addFiles(List<Path> files) {
if (resourcepackDirectory == null) return;
try {
for (Path file : files) {
Path target = resourcepackDirectory.resolve(file.getFileName());
if (!Files.exists(target)) {
Files.copy(file, target);
}
}
} catch (IOException e) {
LOG.warning("Failed to add resourcepacks", e);
Controllers.dialog(i18n("resourcepack.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
}
refresh();
}
public void onAddFiles() {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("resourcepack.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.resourcepack"), "*.zip"));
List<Path> files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage()));
if (files != null && !files.isEmpty()) {
addFiles(files);
}
}
private void onDownload() {
Controllers.getDownloadPage().showResourcepackDownloads();
Controllers.navigate(Controllers.getDownloadPage());
}
private static final class ResourcepackListPageSkin extends ToolbarListPageSkin<ResourcepackInfoObject, ResourcepackListPage> {
public ResourcepackListPageSkin(ResourcepackListPage control) {
super(control);
}
@Override
protected List<Node> initializeToolbar(ResourcepackListPage skinnable) {
return List.of(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, skinnable::onDownload)
);
}
@Override
protected ListCell<ResourcepackInfoObject> createListCell(JFXListView<ResourcepackInfoObject> listView) {
return new ResourcepackListCell(listView, getSkinnable());
}
}
public static class ResourcepackInfoObject {
private final ResourcepackFile file;
private WeakReference<Image> iconCache;
public ResourcepackInfoObject(ResourcepackFile file) {
this.file = file;
}
public ResourcepackFile getFile() {
return file;
}
Image getIcon() {
Image image = null;
if (iconCache != null && (image = iconCache.get()) != null) {
return image;
}
byte[] iconData = file.getIcon();
if (iconData != null) {
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) {
image = new Image(inputStream, 64, 64, true, true);
} catch (Exception e) {
LOG.warning("Failed to load resourcepack icon " + file.getPath(), e);
}
}
if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 ||
(Math.abs(image.getWidth() - image.getHeight()) >= 1)) {
image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png");
}
iconCache = new WeakReference<>(image);
return image;
}
}
private static final class ResourcepackListCell extends MDListCell<ResourcepackInfoObject> {
private final ImageContainer imageView = new ImageContainer(32);
private final TwoLineListItem content = new TwoLineListItem();
private final JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
private final JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
private final ResourcepackListPage page;
public ResourcepackListCell(JFXListView<ResourcepackInfoObject> listView, ResourcepackListPage page) {
super(listView);
this.page = page;
BorderPane root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
HBox left = new HBox(8);
left.setAlignment(Pos.CENTER);
left.getChildren().add(imageView);
left.setPadding(new Insets(0, 8, 0, 0));
FXUtils.setLimitWidth(left, 48);
root.setLeft(left);
HBox.setHgrow(content, Priority.ALWAYS);
root.setCenter(content);
HBox right = new HBox(8);
right.setAlignment(Pos.CENTER_RIGHT);
right.getChildren().setAll(btnReveal, btnDelete);
root.setRight(right);
getContainer().getChildren().add(new RipplerContainer(root));
}
@Override
protected void updateControl(ResourcepackListPage.ResourcepackInfoObject item, boolean empty) {
if (empty || item == null) {
return;
}
ResourcepackFile file = item.getFile();
imageView.setImage(item.getIcon());
content.setTitle(file.getName());
LocalModFile.Description description = file.getDescription();
content.setSubtitle(description != null ? description.toString() : "");
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getPath()));
btnDelete.setOnAction(event ->
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
() -> onDelete(file), null));
}
private void onDelete(ResourcepackFile file) {
try {
if (Files.isDirectory(file.getPath())) {
FileUtils.deleteDirectory(file.getPath());
} else {
Files.delete(file.getPath());
}
page.refresh();
} catch (IOException e) {
Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR);
LOG.warning("Failed to delete resourcepack", e);
}
}
}
}
@@ -59,7 +59,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
private final TabHeader.Tab<ModListPage> modListTab = new TabHeader.Tab<>("modListTab");
private final TabHeader.Tab<WorldListPage> worldListTab = new TabHeader.Tab<>("worldList");
private final TabHeader.Tab<SchematicsPage> schematicsTab = new TabHeader.Tab<>("schematicsTab");
private final TabHeader.Tab<ResourcepackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TabHeader.Tab<ResourcePackListPage> resourcePackTab = new TabHeader.Tab<>("resourcePackTab");
private final TransitionPane transitionPane = new TransitionPane();
private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty();
private final ObjectProperty<Profile.ProfileVersion> version = new SimpleObjectProperty<>();
@@ -78,7 +78,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new VersionSettingsPage(false)));
installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new));
modListTab.setNodeSupplier(loadVersionFor(ModListPage::new));
resourcePackTab.setNodeSupplier(loadVersionFor(ResourcepackListPage::new));
resourcePackTab.setNodeSupplier(loadVersionFor(ResourcePackListPage::new));
worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new));
schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new));
@@ -302,7 +302,8 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
new IconedMenuItem(SVG.SCRIPT, i18n("folder.logs"), () -> control.onBrowse("logs"), browsePopup)
new IconedMenuItem(SVG.SCRIPT, i18n("folder.logs"), () -> control.onBrowse("logs"), browsePopup),
new IconedMenuItem(SVG.FRAME_BUG, i18n("folder.crash-reports"), () -> control.onBrowse("crash-reports"), browsePopup)
);
PopupMenu managementList = new PopupMenu();
@@ -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);
@@ -174,7 +174,7 @@ public final class NativePatcher {
if (lwjglVersionChanged) {
ModManager modManager = repository.getModManager(version.getId());
try {
for (LocalModFile mod : modManager.getMods()) {
for (LocalModFile mod : modManager.getLocalFiles()) {
if ("sodium".equals(mod.getId())) {
// https://github.com/CaffeineMC/sodium/issues/2561
javaArguments.add("-Dsodium.checks.issue2561=false");
@@ -269,7 +269,14 @@ public final class SelfDependencyPatcher {
AtomicBoolean isCancelled = new AtomicBoolean();
AtomicBoolean showDetails = new AtomicBoolean();
ProgressFrame dialog = new ProgressFrame(i18n("download.javafx"));
ProgressFrame dialog;
try {
dialog = new SwingProgressFrame(i18n("download.javafx"));
} catch (HeadlessException e) {
LOG.warning("Failed to open dialog", e);
dialog = new FakeProgressFrame();
}
dialog.setProgressMaximum(dependencies.size() + 1);
dialog.setProgress(count);
dialog.setOnCancel(() -> isCancelled.set(true));
@@ -296,9 +303,10 @@ public final class SelfDependencyPatcher {
DependencyDescriptor dependency = dependencies.get(i);
final String url = repository.resolveDependencyURL(dependency);
ProgressFrame finalDialog = dialog;
SwingUtilities.invokeLater(() -> {
dialog.setCurrent(dependency.module);
dialog.incrementProgress();
finalDialog.setCurrent(dependency.module);
finalDialog.incrementProgress();
});
LOG.info("Downloading " + url);
@@ -382,14 +390,32 @@ public final class SelfDependencyPatcher {
}
}
public static class ProgressFrame extends JDialog {
public sealed interface ProgressFrame {
void setCurrent(String component);
void setProgressMaximum(int total);
void setProgress(int n);
void incrementProgress();
void setOnCancel(Runnable action);
void setOnChangeSource(Runnable action);
void setVisible(boolean visible);
void dispose();
}
public static final class SwingProgressFrame extends JDialog implements ProgressFrame {
private final JProgressBar progressBar;
private final JLabel progressText;
private final JButton btnChangeSource;
private final JButton btnCancel;
public ProgressFrame(String title) {
public SwingProgressFrame(String title) {
JPanel panel = new JPanel();
setResizable(false);
@@ -453,4 +479,39 @@ public final class SelfDependencyPatcher {
btnChangeSource.addActionListener(e -> action.run());
}
}
public static final class FakeProgressFrame implements ProgressFrame {
@Override
public void setCurrent(String component) {
}
@Override
public void setProgressMaximum(int total) {
}
@Override
public void setProgress(int n) {
}
@Override
public void incrementProgress() {
}
@Override
public void setOnCancel(Runnable action) {
}
@Override
public void setOnChangeSource(Runnable action) {
}
@Override
public void setVisible(boolean visible) {
}
@Override
public void dispose() {
}
}
}
@@ -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",
@@ -1093,6 +1093,10 @@
-fx-background-color: -monet-error-container;
}
.resource-pack-list-cell:warning {
-fx-background-color: -monet-error-container;
}
.multi-file-item {
-fx-padding: 10 0 10 0;
}
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)
@@ -104,14 +106,14 @@ account.methods.microsoft.birth=How to Change Your Account Birth Date
account.methods.microsoft.code=Code (Copied to Clipboard)
account.methods.microsoft.close_page=Microsoft account authorization is now completed.\n\
\n\
There are some extra works for us, but you can safely close this tab for now.
There are some extra work for us, but you can safely close this tab for now.
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.banned=Your account may have been banned by Xbox Live.\n\
account.methods.microsoft.error.country_unavailable=XBOX Live is not available in your current country/region.
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.
account.methods.microsoft.error.wrong_verify_method=Failed to log in. Please try logging into your account using PASSWORD instead of other login methods.
@@ -120,6 +122,7 @@ account.methods.microsoft.makegameidsettings=Create Profile / Edit Profile Name
account.methods.microsoft.hint=Click the "Log in" button to start adding your Microsoft account.
account.methods.microsoft.methods.device=Log In with QR Code
account.methods.microsoft.methods.device.hint=Scan QR code or visit <a href="%s">%s</a> to complete login, enter <b>%s</b> in the opened page.
account.methods.microsoft.methods.device.hint.completed=Microsoft account authorization is now completed. There are some extra work for us, just wait for a while.
account.methods.microsoft.methods.browser=Log In via Browser
account.methods.microsoft.methods.browser.hint=Click the "Log in" button or <a href="%s">copy the link</a> and paste it into the browser to log in.
account.methods.microsoft.manual=<b>If your internet connection is bad, it may cause web pages to load slowly or fail to load altogether.\nYou may try again later or switch to a different internet connection.</b>
@@ -184,8 +187,10 @@ button.change_source=Change Download Source
button.clear=Clear
button.copy_and_exit=Copy and Exit
button.delete=Delete
button.disable=Disable
button.do_not_show_again=Don't show again
button.edit=Edit
button.enable=Enable
button.install=Install
button.export=Export
button.no=No
@@ -239,6 +244,8 @@ curse.category.4472=Tech
curse.category.4473=Magic
curse.category.5128=Vanilla+
curse.category.7418=Horror
curse.category.9243=Expert
curse.category.10683=RLCraft
# https://addons-ecs.forgesvc.net/api/v2/category/section/6
curse.category.5299=Education
@@ -264,7 +271,9 @@ curse.category.419=Magic
curse.category.412=Technology
curse.category.4557=Redstone
curse.category.428=Tinker's Construct
# '
curse.category.8937=ModJam 2025
curse.category.9026=CreativeMode
curse.category.10775=Horror
curse.category.414=Player Transport
curse.category.4486=Lucky Blocks
curse.category.432=Buildcraft
@@ -296,6 +305,8 @@ curse.category.4558=Redstone
curse.category.4843=Automation
curse.category.4906=MCreator
curse.category.7669=Twilight Forest
curse.category.9049=Refined Storage
curse.category.10754=Farmer's Delight
# https://addons-ecs.forgesvc.net/api/v2/category/section/12
curse.category.5244=Font Packs
@@ -314,6 +325,7 @@ curse.category.404=Animated
curse.category.4465=Mod Support
curse.category.402=Medieval
curse.category.401=Modern
curse.category.8939=ModJam 2025
# https://addons-ecs.forgesvc.net/api/v2/category/section/17
curse.category.4464=Modded World
@@ -351,7 +363,6 @@ curse.sort.total_downloads=Total Downloads
datetime.format=MMM d, yyyy, h\:mm\:ss a
download=Download
download.hint=Install games and modpacks or download mods, resource packs, shaders, and worlds.
download.code.404=File "%s" not found on the remote server.
download.content=Addons
download.shader=Shaders
@@ -464,6 +475,7 @@ file=File
folder.config=Configs
folder.game=Working Directory
folder.logs=Logs
folder.crash-reports=Crash Reports
folder.mod=Mods
folder.resourcepacks=Resource Packs
folder.shaderpacks=Shader Packs
@@ -1086,11 +1098,11 @@ mods.category=Category
mods.channel.alpha=Alpha
mods.channel.beta=Beta
mods.channel.release=Release
mods.check_updates=Mod update process
mods.check_updates=File update process
mods.check_updates.button=Update
mods.check_updates.confirm=Update
mods.check_updates.current_version=Current Version
mods.check_updates.empty=All mods are up-to-date
mods.check_updates.empty=All files are up-to-date
mods.check_updates.failed_check=Failed to check for updates.
mods.check_updates.failed_download=Failed to download some files.
mods.check_updates.file=File
@@ -1261,12 +1273,19 @@ repositories.chooser.title=Choose download source for JavaFX
resourcepack=Resource Packs
resourcepack.add=Add
resourcepack.add.failed=Failed to add resource packs:
resourcepack.add.title=Choose resource pack archive you want to add
resourcepack.manage=Resource Packs
resourcepack.download=Download
resourcepack.add.failed=Failed to add resource pack
resourcepack.delete.failed=Failed to delete resource pack
resourcepack.download=Download
resourcepack.download.title=Download Resource Pack - %1s
resourcepack.manage=Resource Packs
resourcepack.update_in_modpack.warning=Updating resource packs in a modpack can lead to irreparable results, possibly leading to incorrect textures and sounds. Are you sure you want to update?
resourcepack.warning.invalid=Invalid pack meta
resourcepack.warning.manipulate=Incompatible resource packs might not work well in game.\n\nAre you sure to enable the resource pack(s)?
resourcepack.warning.missing_game_meta=Missing instance meta
resourcepack.warning.missing_pack_meta=Missing pack meta
resourcepack.warning.too_new=For newer Minecraft
resourcepack.warning.too_old=For older Minecraft
reveal.in_file_manager=Reveal in File Manager
@@ -1307,6 +1326,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 +1501,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
@@ -1546,7 +1567,8 @@ terracotta.terracotta=Terracotta | Multiplayer
terracotta.status=Lobby
terracotta.back=Exit
terracotta.feedback.title=Fill Out Feedback Form
terracotta.feedback.desc=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.feedback.desc=We hope you can take 10 seconds to fill out the feedback form.
terracotta.feedback.desc.update=As HMCL updates Multiplayer Core, we hope you can take 10 seconds to fill out the feedback form.
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
terracotta.difficulty.easiest=Excellent network: almost connected!
terracotta.difficulty.simple=Good network: connection may take some time
@@ -107,11 +107,11 @@ account.methods.microsoft.close_page=اكتمل تفويض حساب Microsoft.\n
لا يزال هناك بعض الخطوات من جانبنا، لكن يمكنك إغلاق هذا التبويب بأمان الآن.
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=لا يوجد حساب Xbox مرتبط بحساب Microsoft الخاص بك بعد. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لربط واحد.
account.methods.microsoft.error.country_unavailable=XBOX Live غير متاح في بلدك/منطقتك الحالية.
account.methods.microsoft.error.missing_xbox_account=لا يوجد حساب XBOX مرتبط بحساب Microsoft الخاص بك بعد. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لربط واحد.
account.methods.microsoft.error.no_character=يُرجى التأكد من أنك اشتريت Minecraft: Java Edition.\n\
إذا اشتريته بالفعل، فقد لا يكون ملف اللعبة قد تم إنشاؤه. يُرجى النقر <a href="https://www.minecraft.net/msaprofile/mygames/editprofile">هنا</a> لإنشاء ملف اللعبة.
account.methods.microsoft.error.banned=قد يكون حسابك محظوراً من Xbox Live.\n\
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=فشل تسجيل الدخول. يُرجى محاولة تسجيل الدخول إلى حسابك باستخدام كلمة المرور بدلاً من طرق الدخول الأخرى.
@@ -181,8 +181,10 @@ button.change_source=تغيير مصدر التنزيل
button.clear=مسح
button.copy_and_exit=نسخ والخروج
button.delete=حذف
button.disable=تعطيل
button.do_not_show_again=لا تُظهر مجدداً
button.edit=تعديل
button.enable=تفعيل
button.install=تثبيت
button.export=تصدير
button.no=لا
@@ -344,7 +346,6 @@ curse.sort.total_downloads=إجمالي التنزيلات
datetime.format=MMM d, yyyy, h\:mm\:ss a
download=تنزيل
download.hint=ثبّت الألعاب وحزم المودات أو نزّل المودات وحزم الموارد والظلال والعوالم.
download.code.404=الملف "%s" غير موجود على الخادم البعيد.
download.content=الإضافات
download.shader=الظلال
@@ -1473,7 +1474,7 @@ terracotta.terracotta=Terracotta | متعدد اللاعبين
terracotta.status=الردهة
terracotta.back=خروج
terracotta.feedback.title=ملء استمارة التقييم
terracotta.feedback.desc=مع تحديث HMCL لنواة متعدد اللاعبين، نأمل أن تخصص 10 ثوانٍ لملء استمارة التقييم.
terracotta.feedback.desc.update=مع تحديث HMCL لنواة متعدد اللاعبين، نأمل أن تخصص 10 ثوانٍ لملء استمارة التقييم.
terracotta.sudo_installing=يجب على HMCL التحقق من كلمة مرورك قبل تثبيت نواة متعدد اللاعبين
terracotta.difficulty.easiest=شبكة ممتازة: الاتصال شبه مضمون!
terracotta.difficulty.simple=شبكة جيدة: قد يستغرق الاتصال بعض الوقت
@@ -162,8 +162,10 @@ button.change_source=Cambiar fuente de descarga
button.clear=Limpiar
button.copy_and_exit=Copiar y salir
button.delete=Borrar
button.disable=Desactivar
button.do_not_show_again=No volver a mostrar
button.edit=Editar
button.enable=Activar
button.install=Instalar
button.export=Exportar
button.no=No
@@ -324,7 +326,6 @@ curse.sort.total_downloads=Descargas totales
datetime.format=d MMM yyyy, H\:mm\:ss
download=Descargar
download.hint=Instalar juegos y modpacks o descargar mods, paquetes de recursos, sombreadores y mundos.
download.code.404=Archivo no encontrado en el servidor remoto: %s
download.content=Complementos
download.shader=Sombreadores
@@ -131,7 +131,9 @@ button.change_source=ダウンロードソースの変更
button.clear=クリア
button.copy_and_exit=コピーして終了
button.delete=削除
button.disable=無効にする
button.edit=編集
button.enable=有効にする
button.install=インストール
button.export=エクスポート
button.no=いいえ
@@ -283,7 +285,6 @@ curse.sort.total_downloads=合計ダウンロード数
datetime.format=yyyy/MM/dd H:mm:ss
download=ダウンロード
download.hint=ゲームや modpack をインストールするか、mod、リソース パック、マップ、シェーダーをダウンロードします
download.code.404=リモートサーバーにファイルが見つかりません:%s
download.content=ゲームコンテンツ
download.shader=シェーダー
@@ -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)
@@ -320,7 +322,6 @@ curse.sort.total_downloads=引數
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
download=引
download.hint=裝戯事、改囊集,引改囊、資囊、光影與生界
download.code.404=伺服器無案曰:%s\n君可求助於右上之鈕。
download.content=戲案
download.shader=光影
@@ -161,7 +161,9 @@ button.change_source=Изменить источник скачивания
button.clear=Очистить
button.copy_and_exit=Скопировать и выйти
button.delete=Удалить
button.disable=Отключить
button.edit=Изменить
button.enable=Включить
button.install=Установить
button.export=Экспорт
button.no=Нет
@@ -321,7 +323,6 @@ curse.sort.total_downloads=Скачиваний
datetime.format=d MMM yyyy г., HH\:mm\:ss
download=Скачать
download.hint=Установить игры и модпаки или скачать моды, пакеты ресурсов, шейдеры и миры.
download.code.404=Файл «%s» не найден на удаленном сервере.
download.content=Аддоны
download.shader=Шейдеры
@@ -160,7 +160,9 @@ button.change_source=Змінити джерело завантаження
button.clear=Очистити
button.copy_and_exit=Копіювати та вийти
button.delete=Видалити
button.disable=Вимкнути
button.edit=Редагувати
button.enable=Увімкнути
button.install=Встановити
button.export=Експортувати
button.no=Ні
@@ -320,7 +322,6 @@ curse.sort.total_downloads=Всього завантажень
datetime.format=dd.MM.yyyy, HH:mm:ss
download=Завантажити
download.hint=Встановіть ігри та модпаки або завантажте моди, пакети ресурсів, шейдери та світи.
download.code.404=Файл "%s" не знайдено на віддаленому сервері.
download.content=Додатки
download.shader=Шейдери
@@ -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/)
@@ -108,10 +110,11 @@ account.methods.microsoft.code=程式碼 (已自動複製)
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.banned=你的帳戶可能被 Xbox Live 封禁。\n你可以點擊 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此處</a> 按鈕查詢帳戶封禁狀態。
account.methods.microsoft.error.country_unavailable=你所在的國家或地區不受 XBOX Live 的支援。
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 帳戶登入頁面嘗試使用密碼登入,不要使用其他登入方式。
account.methods.microsoft.logging_in=登入中……
@@ -119,6 +122,7 @@ account.methods.microsoft.makegameidsettings=建立檔案 / 編輯檔案名稱
account.methods.microsoft.hint=點擊「登入」按鈕開始新增 Microsoft 帳戶。
account.methods.microsoft.methods.device=掃描 QR Code 登入
account.methods.microsoft.methods.device.hint=掃描 QR Code 或訪問 <a href="%s">%s</a>,在開啟的頁面中輸入 <b>%s</b> 完成登入。
account.methods.microsoft.methods.device.hint.completed=已完成 Microsoft 帳戶授權。其餘登入步驟將由啟動器自動執行,請稍候。
account.methods.microsoft.methods.browser=在瀏覽器中登入
account.methods.microsoft.methods.browser.hint=點擊「登入」按鈕或者<a href="%s">複製連結</a>並在瀏覽器中貼上以登入。
account.methods.microsoft.manual=<b>若網路環境不佳,可能會導致網頁載入緩慢甚至無法載入,請稍後再試或更換網路環境後再試。</b>
@@ -181,9 +185,11 @@ button.change_source=切換下載源
button.clear=清除
button.copy_and_exit=複製並退出
button.delete=刪除
button.disable=停用
button.do_not_show_again=不再顯示
button.edit=編輯
button.install=安裝
button.enable=啟用
button.export=匯出
button.no=否
button.ok=確定
@@ -236,6 +242,8 @@ curse.category.4472=科技
curse.category.4473=魔法
curse.category.5128=原版增強
curse.category.7418=恐怖
curse.category.9243=專家
curse.category.10683=RLCraft
# https://addons-ecs.forgesvc.net/api/v2/category/section/6
curse.category.5299=教育
@@ -260,6 +268,9 @@ curse.category.429=工業 (Industrialcraft)
curse.category.419=魔法
curse.category.412=科技
curse.category.4557=紅石
curse.category.8937=ModJam 2025
curse.category.9026=創造模式
curse.category.10775=恐怖
curse.category.428=匠魂
curse.category.414=交通運輸
curse.category.4486=幸運方塊 (Lucky Blocks)
@@ -292,8 +303,10 @@ curse.category.4558=紅石
curse.category.4843=自動化
curse.category.4906=MCreator
curse.category.7669=暮色森林
curse.category.9049=精緻儲存 (Refined Storage)
curse.category.10754=農夫樂事 (Farmer's Delight)
# https://addons-ecs.forgesvc.net/api/v2/category/section/6
# https://addons-ecs.forgesvc.net/api/v2/category/section/12
curse.category.5244=字體包
curse.category.5193=封包
curse.category.399=蒸汽朋克
@@ -310,6 +323,7 @@ curse.category.404=動態效果
curse.category.4465=模組支援
curse.category.402=中世紀風格
curse.category.401=現代風格
curse.category.8939=ModJam 2025
# https://addons-ecs.forgesvc.net/api/v2/category/section/17
curse.category.4464=模組
@@ -347,7 +361,6 @@ curse.sort.total_downloads=下載量
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
download=下載
download.hint=安裝遊戲和模組包或下載模組、資源包、光影和世界
download.code.404=遠端伺服器沒有需要下載的檔案:%s
download.content=遊戲內容
download.shader=光影
@@ -428,6 +441,7 @@ file=檔案
folder.config=模組設定目錄
folder.game=實例執行目錄
folder.logs=日誌目錄
folder.crash-reports=崩潰報告目錄
folder.mod=模組目錄
folder.resourcepacks=資源包目錄
folder.shaderpacks=著色器包目錄
@@ -881,11 +895,11 @@ mods.category=類別
mods.channel.alpha=Alpha
mods.channel.beta=Beta
mods.channel.release=Release
mods.check_updates=模組更新檢查
mods.check_updates=檔案更新檢查
mods.check_updates.button=檢查更新
mods.check_updates.confirm=更新
mods.check_updates.current_version=目前版本
mods.check_updates.empty=沒有需要更新的模組
mods.check_updates.empty=沒有需要更新的檔案
mods.check_updates.failed_check=檢查更新失敗
mods.check_updates.failed_download=部分檔案下載失敗
mods.check_updates.file=檔案
@@ -1052,12 +1066,19 @@ repositories.chooser.title=選取 JavaFX 下載源
resourcepack=資源包
resourcepack.add=新增資源包
resourcepack.add.failed=新增資源包失敗:
resourcepack.add.title=選取要新增的資源包壓縮檔
resourcepack.manage=資源包管理
resourcepack.download=下載資源包
resourcepack.add.failed=新增資源包失敗
resourcepack.delete.failed=刪除資源包失敗
resourcepack.download=下載資源包
resourcepack.download.title=資源包下載 - %1s
resourcepack.manage=資源包管理
resourcepack.update_in_modpack.warning=更新模組包中的資源包可能導致模組包損壞,使遊戲紋理和音效異常。你確定要更新嗎?
resourcepack.warning.invalid=資源包中繼資料無效
resourcepack.warning.manipulate=不相容的資源包在遊戲內可能不會正常執行。\n\n你確定要啟用這些資源包嗎?
resourcepack.warning.missing_game_meta=目前實例中繼資料缺失
resourcepack.warning.missing_pack_meta=資源包中繼資料缺失
resourcepack.warning.too_new=適用於新版本
resourcepack.warning.too_old=適用於舊版本
reveal.in_file_manager=在檔案管理員中查看
@@ -1098,6 +1119,7 @@ search.page_n=%d / %s
selector.choose=選取
selector.choose_file=選取檔案
selector.choose_directory=選取目錄
selector.custom=自訂
settings=設定
@@ -1270,6 +1292,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=自動
@@ -1335,7 +1358,8 @@ terracotta.terracotta=Terracotta | 陶瓦聯機
terracotta.status=聯機大廳
terracotta.back=退出
terracotta.feedback.title=填寫回饋表
terracotta.feedback.desc=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.feedback.desc=我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.feedback.desc.update=在 HMCL 更新聯機核心時,我們歡迎您用 10 秒時間填寫聯機品質回饋收集表。
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
terracotta.difficulty.easiest=目前網路狀況極佳:稍等一下就成功!
terracotta.difficulty.simple=目前網路狀況較好:建立連線需要一段時間…
@@ -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,10 +111,11 @@ account.methods.microsoft.code=代码 (已自动复制)
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.banned=你的账户可能被 Xbox Live 封禁。\n你可以点击 <a href="https://enforcement.xbox.com/enforcement/showenforcementhistory">此处</a> 按钮查询账户封禁状态。
account.methods.microsoft.error.country_unavailable=你所在的国家或地区不受 XBOX Live 的支持。
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你可以点击右上角帮助按钮进行求助。
account.methods.microsoft.logging_in=登录中……
@@ -120,6 +123,7 @@ account.methods.microsoft.makegameidsettings=创建档案 / 编辑档案名称
account.methods.microsoft.hint=点击“登录”按钮开始添加微软账户。
account.methods.microsoft.methods.device=扫描二维码登录
account.methods.microsoft.methods.device.hint=扫描二维码或访问 <a href="%s">%s</a>,在打开的页面中输入 <b>%s</b> 完成登录。
account.methods.microsoft.methods.device.hint.completed=已完成微软账户授权。其余登录步骤将由启动器自动执行,请稍候。
account.methods.microsoft.methods.browser=在浏览器登录
account.methods.microsoft.methods.browser.hint=点击“登录”按钮或者<a href="%s">复制链接</a>并在浏览器中粘贴以登录。
account.methods.microsoft.manual=<b>若网络环境不佳,可能会导致网页加载缓慢甚至无法加载,请使用网络代理并重试。</b>\n\
@@ -183,9 +187,11 @@ button.change_source=切换下载源
button.clear=清除
button.copy_and_exit=复制并退出
button.delete=删除
button.disable=禁用
button.do_not_show_again=不再显示
button.edit=修改
button.install=安装
button.enable=启用
button.export=导出
button.no=否
button.ok=确定
@@ -238,6 +244,8 @@ curse.category.4472=科技
curse.category.4473=魔法
curse.category.5128=原版增强
curse.category.7418=恐怖
curse.category.9243=专家
curse.category.10683=RLCraft
# https://addons-ecs.forgesvc.net/api/v2/category/section/6
curse.category.5299=教育
@@ -262,6 +270,9 @@ curse.category.429=工业 (Industrialcraft)
curse.category.419=魔法
curse.category.412=科技
curse.category.4557=红石
curse.category.8937=ModJam 2025
curse.category.9026=创造模式
curse.category.10775=恐怖
curse.category.428=匠魂
curse.category.414=交通运输
curse.category.4486=幸运方块 (Lucky Blocks)
@@ -294,6 +305,8 @@ curse.category.4558=红石
curse.category.4843=自动化
curse.category.4906=MCreator
curse.category.7669=暮色森林
curse.category.9049=精致存储 (Refined Storage)
curse.category.10754=农夫乐事 (Farmer's Delight)
# https://addons-ecs.forgesvc.net/api/v2/category/section/12
curse.category.5244=字体包
@@ -312,6 +325,7 @@ curse.category.404=动态效果
curse.category.4465=模组支持
curse.category.402=中世纪风格
curse.category.401=现代风格
curse.category.8939=ModJam 2025
# https://addons-ecs.forgesvc.net/api/v2/category/section/17
curse.category.4464=模组
@@ -349,7 +363,6 @@ curse.sort.total_downloads=下载量
datetime.format=yyyy 年 MM 月 dd 日 HH:mm:ss
download=下载
download.hint=安装游戏和整合包或下载模组、资源包、光影和世界
download.code.404=远程服务器不包含需要下载的文件: %s\n你可以点击右上角帮助按钮进行求助。
download.content=游戏内容
download.shader=光影
@@ -366,11 +379,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=正在下载必要的运行时组件……
@@ -432,6 +445,7 @@ file=文件
folder.config=配置文件夹
folder.game=实例运行文件夹
folder.logs=日志文件夹
folder.crash-reports=崩溃报告文件夹
folder.mod=模组文件夹
folder.resourcepacks=资源包文件夹
folder.shaderpacks=光影包文件夹
@@ -886,11 +900,11 @@ mods.category=类别
mods.channel.alpha=快照版本
mods.channel.beta=测试版本
mods.channel.release=稳定版本
mods.check_updates=模组更新检查
mods.check_updates=文件更新检查
mods.check_updates.button=检查更新
mods.check_updates.confirm=更新
mods.check_updates.current_version=当前版本
mods.check_updates.empty=没有需要更新的模组
mods.check_updates.empty=没有需要更新的文件
mods.check_updates.failed_check=检查更新失败
mods.check_updates.failed_download=部分文件下载失败
mods.check_updates.file=文件
@@ -1015,7 +1029,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=上次死亡位置
@@ -1057,12 +1071,19 @@ repositories.chooser.title=选择 JavaFX 下载源
resourcepack=资源包
resourcepack.add=添加资源包
resourcepack.add.failed=添加资源包失败:
resourcepack.add.title=选择要添加的资源包压缩包
resourcepack.manage=资源包管理
resourcepack.download=下载资源包
resourcepack.add.failed=添加资源包失败
resourcepack.delete.failed=删除资源包失败
resourcepack.download=下载资源包
resourcepack.download.title=资源包下载 - %1s
resourcepack.manage=资源包管理
resourcepack.update_in_modpack.warning=更新整合包中的资源包可能导致整合包损坏,使游戏纹理和音效异常。你确定要更新吗?
resourcepack.warning.invalid=资源包元数据无效
resourcepack.warning.manipulate=不兼容的资源包在游戏内可能不会正常运行。\n\n你确定要启用这些资源包吗?
resourcepack.warning.missing_game_meta=实例元数据缺失
resourcepack.warning.missing_pack_meta=资源包元数据缺失
resourcepack.warning.too_new=适用于新版本
resourcepack.warning.too_old=适用于旧版本
reveal.in_file_manager=在文件管理器中查看
@@ -1103,6 +1124,7 @@ search.page_n=%d / %s
selector.choose=选择
selector.choose_file=选择文件
selector.choose_directory=选择文件夹
selector.custom=自定义
settings=设置
@@ -1275,6 +1297,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=自动
@@ -1340,7 +1363,8 @@ terracotta.terracotta=Terracotta | 陶瓦联机
terracotta.status=联机大厅
terracotta.back=退出
terracotta.feedback.title=填写反馈表
terracotta.feedback.desc=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.feedback.desc=我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.feedback.desc.update=在 HMCL 更新联机核心时,我们欢迎您用 10 秒时间填写联机质量反馈收集表。
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
terracotta.difficulty.easiest=当前网络状态极好:稍等一下就成功!
terracotta.difficulty.simple=当前网络状态较好:建立连接需要一段时间……
@@ -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)
@@ -160,6 +160,8 @@ public class OAuth {
continue;
}
options.callback.loginCompletedDeviceCode();
return new Result(tokenResponse.accessToken, tokenResponse.refreshToken);
}
}
@@ -268,6 +270,8 @@ public class OAuth {
void grantDeviceCode(String userCode, String verificationURI);
void loginCompletedDeviceCode();
/**
* Open browser
*
@@ -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);
}
@@ -19,6 +19,7 @@ package org.jackhuang.hmcl.download.game;
import org.jackhuang.hmcl.download.AbstractDependencyManager;
import org.jackhuang.hmcl.download.LibraryAnalyzer;
import org.jackhuang.hmcl.download.MaintainTask;
import org.jackhuang.hmcl.game.DefaultGameRepository;
import org.jackhuang.hmcl.game.GameRepository;
import org.jackhuang.hmcl.game.Library;
@@ -33,12 +34,15 @@ import org.jackhuang.hmcl.util.versioning.VersionNumber;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -169,7 +173,13 @@ public final class GameLibrariesTask extends Task<Void> {
throw new IOException("Cannot fix optifine", e);
}
}
} else if ("org.jackhuang.hmcl".equals(library.getGroupId()) && "mmc-bootstrap".equals(library.getArtifactId())) {
try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) {
Files.createDirectories(file.getParent());
Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), file, StandardCopyOption.REPLACE_EXISTING);
}
}
if (shouldDownloadLibrary(gameRepository, version, library, integrityCheck) && (library.hasDownloadURL() || !"optifine".equals(library.getGroupId()))) {
dependencies.add(new LibraryDownloadTask(dependencyManager, file, library).withCounter("hmcl.install.libraries"));
} else {
@@ -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);
}
@@ -154,6 +154,11 @@ public class DefaultGameRepository implements GameRepository {
return getRunDirectory(id).resolve("mods");
}
@Override
public Path getResourcePackDirectory(String id) {
return getRunDirectory(id).resolve("resourcepacks");
}
@Override
public Path getVersionRoot(String id) {
return getBaseDirectory().resolve("versions/" + id);
@@ -564,8 +569,4 @@ public class DefaultGameRepository implements GameRepository {
.append("baseDirectory", baseDirectory)
.toString();
}
public Path getResourcepacksDirectory(String id) {
return getRunDirectory(id).resolve("resourcepacks");
}
}
@@ -137,6 +137,12 @@ public interface GameRepository extends VersionProvider {
/// @return the mods directory
Path getModsDirectory(String id);
/// Get the directory for placing resource packs.
///
/// @param id instance id
/// @return the resource pack directory
Path getResourcePackDirectory(String id);
/**
* Get minecraft jar
*
@@ -247,10 +247,10 @@ public class DataPack {
private Path statusFile;
private final BooleanProperty activeProperty;
private final String id;
private final LocalModFile.Description description;
private final LocalAddonFile.Description description;
private final DataPack parentDataPack;
public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, DataPack parentDataPack) {
public Pack(Path path, boolean isDirectory, String id, LocalAddonFile.Description description, DataPack parentDataPack) {
this.path = path;
this.isDirectory = isDirectory;
this.id = id;
@@ -309,7 +309,7 @@ public class DataPack {
return id;
}
public LocalModFile.Description getDescription() {
public LocalAddonFile.Description getDescription() {
return description;
}
@@ -0,0 +1,118 @@
/*
* 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.mod;
import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/// Sub-classes should implement `Comparable`
public abstract class LocalAddonFile {
protected LocalAddonFile() {
}
public abstract Path getFile();
/// Without extension
public abstract String getFileName();
public boolean isDisabled() {
return FileUtils.getName(getFile()).endsWith(LocalAddonManager.DISABLED_EXTENSION);
}
public abstract void markDisabled() throws IOException;
public abstract void setOld(boolean old) throws IOException;
public abstract boolean keepOldFiles();
public abstract void delete() throws IOException;
@Nullable
public abstract AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteMod.Type type) throws IOException;
public record AddonUpdate(
LocalAddonFile localAddonFile,
RemoteMod.Version currentVersion,
RemoteMod.Version targetVersion,
boolean useRemoteFileName
) {
}
public static class Description {
private final List<LocalAddonFile.Description.Part> parts;
public Description(String text) {
this.parts = new ArrayList<>();
this.parts.add(new LocalAddonFile.Description.Part(text, "black"));
}
public Description(List<LocalAddonFile.Description.Part> parts) {
this.parts = parts;
}
public List<LocalAddonFile.Description.Part> getParts() {
return parts;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
for (LocalAddonFile.Description.Part part : parts) {
builder.append(part.text);
}
return builder.toString();
}
public String toStringSingleLine() {
return toString().lines().map(String::trim).filter(StringUtils::isNotBlank).collect(Collectors.joining(" | "));
}
public static class Part {
private final String text;
private final String color;
public Part(String text) {
this(text, "");
}
public Part(String text, String color) {
this.text = Objects.requireNonNull(text);
this.color = Objects.requireNonNull(color);
}
public String getText() {
return text;
}
public String getColor() {
return color;
}
}
}
}
@@ -0,0 +1,119 @@
/*
* 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.mod;
import org.jackhuang.hmcl.game.GameRepository;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.locks.ReentrantLock;
public abstract class LocalAddonManager<T extends LocalAddonFile> {
public static final String DISABLED_EXTENSION = ".disabled";
public static final String OLD_EXTENSION = ".old";
public static String getLocalAddonName(Path file) {
return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
}
protected final ReentrantLock lock = new ReentrantLock();
protected final Set<@NotNull T> localFiles = new LinkedHashSet<>();
protected final GameRepository repository;
protected final String id;
public LocalAddonManager(GameRepository gameRepository, String versionId) {
this.repository = gameRepository;
this.id = versionId;
}
public GameRepository getRepository() {
return repository;
}
public String getInstanceId() {
return id;
}
public abstract Path getDirectory();
public abstract void refresh() throws IOException;
public abstract Comparator<T> getComparator();
public @Unmodifiable List<T> getLocalFiles() throws IOException {
lock.lock();
try {
return localFiles.stream().sorted(getComparator()).toList();
} finally {
lock.unlock();
}
}
public Path setOld(T modFile, boolean old) throws IOException {
lock.lock();
try {
Path newPath;
if (old) {
newPath = backupFile(modFile.getFile());
localFiles.remove(modFile);
} else {
newPath = restoreFile(modFile.getFile());
localFiles.add(modFile);
}
return newPath;
} finally {
lock.unlock();
}
}
private Path backupFile(Path file) throws IOException {
Path newPath = file.resolveSibling(
StringUtils.addSuffix(
StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION),
OLD_EXTENSION
)
);
if (Files.exists(file)) {
Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING);
}
return newPath;
}
private Path restoreFile(Path file) throws IOException {
Path newPath = file.resolveSibling(
StringUtils.removeSuffix(FileUtils.getName(file), OLD_EXTENSION)
);
if (Files.exists(file)) {
Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING);
}
return newPath;
}
}
@@ -23,12 +23,9 @@ import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.*;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -36,7 +33,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*
* @author huangyuhui
*/
public final class LocalModFile implements Comparable<LocalModFile> {
public final class LocalModFile extends LocalAddonFile implements Comparable<LocalModFile> {
private Path file;
private final ModManager modManager;
@@ -56,6 +53,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
}
public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath) {
super();
this.modManager = modManager;
this.mod = mod;
this.file = file;
@@ -85,7 +83,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
}
};
fileName = FileUtils.getNameWithoutExtension(ModManager.getModName(file));
fileName = FileUtils.getNameWithoutExtension(LocalAddonManager.getLocalAddonName(file));
if (isOld()) {
mod.getOldFiles().add(this);
@@ -102,6 +100,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
return mod;
}
@Override
public Path getFile() {
return file;
}
@@ -154,6 +153,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
activeProperty.set(active);
}
@Override
public String getFileName() {
return fileName;
}
@@ -162,6 +162,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
return modManager.isOld(file);
}
@Override
public void setOld(boolean old) throws IOException {
file = modManager.setOld(this, old);
@@ -174,13 +175,27 @@ public final class LocalModFile implements Comparable<LocalModFile> {
}
}
public void disable() throws IOException {
@Override
public boolean keepOldFiles() {
return true;
}
@Override
public void markDisabled() throws IOException {
file = modManager.disableMod(file);
}
public ModUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteModRepository repository) throws IOException {
Optional<RemoteMod.Version> currentVersion = repository.getRemoteVersionByLocalFile(this, file);
if (!currentVersion.isPresent()) return null;
@Override
public void delete() throws IOException {
Files.deleteIfExists(file);
}
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteMod.Type type) throws IOException {
RemoteModRepository repository = type.getRepoForType(RemoteModRepository.Type.MOD);
if (repository == null) return null;
Optional<RemoteMod.Version> currentVersion = repository.getRemoteVersionByLocalFile(file);
if (currentVersion.isEmpty()) return null;
List<RemoteMod.Version> remoteVersions = repository.getRemoteVersionsById(downloadProvider, currentVersion.get().getModid())
.filter(version -> version.getGameVersions().contains(gameVersion))
.filter(version -> version.getLoaders().contains(getModLoaderType()))
@@ -188,7 +203,7 @@ public final class LocalModFile implements Comparable<LocalModFile> {
.sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed())
.toList();
if (remoteVersions.isEmpty()) return null;
return new ModUpdate(this, currentVersion.get(), remoteVersions.get(0));
return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), true);
}
@Override
@@ -205,76 +220,4 @@ public final class LocalModFile implements Comparable<LocalModFile> {
public int hashCode() {
return Objects.hash(getFileName());
}
public static class ModUpdate {
private final LocalModFile localModFile;
private final RemoteMod.Version currentVersion;
private final RemoteMod.Version candidate;
public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, RemoteMod.Version candidate) {
this.localModFile = localModFile;
this.currentVersion = currentVersion;
this.candidate = candidate;
}
public LocalModFile getLocalMod() {
return localModFile;
}
public RemoteMod.Version getCurrentVersion() {
return currentVersion;
}
public RemoteMod.Version getCandidate() {
return candidate;
}
}
public static class Description {
private final List<Part> parts;
public Description(String text) {
this.parts = new ArrayList<>();
this.parts.add(new Part(text, "black"));
}
public Description(List<Part> parts) {
this.parts = parts;
}
public List<Part> getParts() {
return parts;
}
@Override
public String toString() {
StringBuilder builder = new StringBuilder();
for (Part part : parts) {
builder.append(part.text);
}
return builder.toString();
}
public static class Part {
private final String text;
private final String color;
public Part(String text) {
this(text, "");
}
public Part(String text, String color) {
this.text = Objects.requireNonNull(text);
this.color = Objects.requireNonNull(color);
}
public String getText() {
return text;
}
public String getColor() {
return color;
}
}
}
}
@@ -21,10 +21,9 @@ public enum ModLoaderType {
UNKNOWN,
FORGE,
CLEANROOM,
NEO_FORGED,
NEO_FORGE,
FABRIC,
QUILT,
LITE_LOADER,
LEGACY_FABRIC,
PACK
LEGACY_FABRIC
}
@@ -35,7 +35,9 @@ import java.util.*;
import static org.jackhuang.hmcl.util.Pair.pair;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
public final class ModManager {
public final class ModManager extends LocalAddonManager<LocalModFile> {
public static final List<String> MOD_EXTENSIONS = List.of("jar", "litemod");
@FunctionalInterface
private interface ModMetadataReader {
LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException;
@@ -47,42 +49,29 @@ 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),
pair(PackMcMeta::fromFile, ModLoaderType.PACK)
pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT)
);
map.put("zip", zipReaders);
map.put("jar", zipReaders);
map.put("litemod", List.of(pair(LiteModMetadata::fromFile, ModLoaderType.LITE_LOADER)));
READERS = map;
}
private final GameRepository repository;
private final String id;
private final TreeSet<LocalModFile> localModFiles = new TreeSet<>();
private final HashMap<Pair<String, ModLoaderType>, LocalMod> localMods = new HashMap<>();
private LibraryAnalyzer analyzer;
private boolean loaded = false;
public ModManager(GameRepository repository, String id) {
this.repository = repository;
this.id = id;
super(repository, id);
}
public GameRepository getRepository() {
return repository;
}
public String getInstanceId() {
return id;
}
public Path getModsDirectory() {
@Override
public Path getDirectory() {
return repository.getModsDirectory(id);
}
@@ -91,12 +80,22 @@ public final class ModManager {
}
public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) {
return localMods.computeIfAbsent(pair(modId, modLoaderType),
x -> new LocalMod(x.getKey(), x.getValue()));
lock.lock();
try {
return localMods.computeIfAbsent(pair(modId, modLoaderType),
x -> new LocalMod(x.getKey(), x.getValue()));
} finally {
lock.unlock();
}
}
public boolean hasMod(String modId, ModLoaderType modLoaderType) {
return localMods.containsKey(pair(modId, modLoaderType));
lock.lock();
try {
return localMods.containsKey(pair(modId, modLoaderType));
} finally {
lock.unlock();
}
}
private void addModInfo(Path file) {
@@ -161,137 +160,128 @@ public final class ModManager {
getLocalMod(fileNameWithoutExtension, ModLoaderType.UNKNOWN),
file,
fileNameWithoutExtension,
new LocalModFile.Description("litemod".equals(extension) ? "LiteLoader Mod" : "")
new LocalAddonFile.Description("litemod".equals(extension) ? "LiteLoader Mod" : "")
);
}
if (!modInfo.isOld()) {
localModFiles.add(modInfo);
localFiles.add(modInfo);
}
}
public void refreshMods() throws IOException {
localModFiles.clear();
localMods.clear();
@Override
public void refresh() throws IOException {
lock.lock();
try {
localFiles.clear();
localMods.clear();
analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null);
analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null);
boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE)
|| analyzer.has(LibraryAnalyzer.LibraryType.QUILT);
boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE)
|| analyzer.has(LibraryAnalyzer.LibraryType.QUILT);
if (Files.isDirectory(getModsDirectory())) {
try (DirectoryStream<Path> modsDirectoryStream = Files.newDirectoryStream(getModsDirectory())) {
for (Path subitem : modsDirectoryStream) {
if (supportSubfolders && Files.isDirectory(subitem) && !".connector".equalsIgnoreCase(subitem.getFileName().toString())) {
try (DirectoryStream<Path> subitemDirectoryStream = Files.newDirectoryStream(subitem)) {
for (Path subsubitem : subitemDirectoryStream) {
addModInfo(subsubitem);
if (Files.isDirectory(getDirectory())) {
try (DirectoryStream<Path> modsDirectoryStream = Files.newDirectoryStream(getDirectory())) {
for (Path subitem : modsDirectoryStream) {
if (supportSubfolders && Files.isDirectory(subitem) && !".connector".equalsIgnoreCase(subitem.getFileName().toString())) {
try (DirectoryStream<Path> subitemDirectoryStream = Files.newDirectoryStream(subitem)) {
for (Path subsubitem : subitemDirectoryStream) {
addModInfo(subsubitem);
}
}
} else {
addModInfo(subitem);
}
} else {
addModInfo(subitem);
}
}
}
loaded = true;
} finally {
lock.unlock();
}
loaded = true;
}
public @Unmodifiable List<LocalModFile> getMods() throws IOException {
if (!loaded)
refreshMods();
return List.copyOf(localModFiles);
@Override
public Comparator<LocalModFile> getComparator() {
return LocalModFile::compareTo;
}
public @Unmodifiable List<LocalModFile> getLocalFiles() throws IOException {
lock.lock();
try {
if (!loaded)
refresh();
return super.getLocalFiles();
} finally {
lock.unlock();
}
}
public void addMod(Path file) throws IOException {
if (!isFileNameMod(file))
throw new IllegalArgumentException("File " + file + " is not a valid mod file.");
if (!loaded)
refreshMods();
lock.lock();
try {
if (!loaded)
refresh();
Path modsDirectory = getModsDirectory();
Files.createDirectories(modsDirectory);
Path modsDirectory = getDirectory();
Files.createDirectories(modsDirectory);
Path newFile = modsDirectory.resolve(file.getFileName());
FileUtils.copyFile(file, newFile);
Path newFile = modsDirectory.resolve(file.getFileName());
FileUtils.copyFile(file, newFile);
addModInfo(newFile);
addModInfo(newFile);
} finally {
lock.unlock();
}
}
public void removeMods(LocalModFile... localModFiles) throws IOException {
for (LocalModFile localModFile : localModFiles) {
Files.deleteIfExists(localModFile.getFile());
localModFile.delete();
}
}
public void rollback(LocalModFile from, LocalModFile to) throws IOException {
if (!loaded) {
throw new IllegalStateException("ModManager Not loaded");
}
if (!localModFiles.contains(from)) {
throw new IllegalStateException("Rolling back an unknown mod " + from.getFileName());
}
if (from.isOld()) {
throw new IllegalArgumentException("Rolling back an old mod " + from.getFileName());
}
if (!to.isOld()) {
throw new IllegalArgumentException("Rolling back to an old path " + to.getFileName());
}
if (from.getFileName().equals(to.getFileName())) {
// We cannot roll back to the mod with the same name.
return;
}
lock.lock();
try {
if (!loaded) {
throw new IllegalStateException("ModManager Not loaded");
}
if (!localFiles.contains(from)) {
throw new IllegalStateException("Rolling back an unknown mod " + from.getFileName());
}
if (from.isOld()) {
throw new IllegalArgumentException("Rolling back an old mod " + from.getFileName());
}
if (!to.isOld()) {
throw new IllegalArgumentException("Rolling back to an old path " + to.getFileName());
}
if (from.getFileName().equals(to.getFileName())) {
// We cannot roll back to the mod with the same name.
return;
}
LocalMod mod = Objects.requireNonNull(from.getMod());
if (mod != to.getMod()) {
throw new IllegalArgumentException("Rolling back mod " + from.getFileName() + " to a different mod " + to.getFileName());
}
if (!mod.getFiles().contains(from)
|| !mod.getOldFiles().contains(to)) {
throw new IllegalStateException("LocalMod state corrupt");
}
LocalMod mod = Objects.requireNonNull(from.getMod());
if (mod != to.getMod()) {
throw new IllegalArgumentException("Rolling back mod " + from.getFileName() + " to a different mod " + to.getFileName());
}
if (!mod.getFiles().contains(from)
|| !mod.getOldFiles().contains(to)) {
throw new IllegalStateException("LocalMod state corrupt");
}
boolean active = from.isActive();
from.setActive(true);
from.setOld(true);
to.setOld(false);
to.setActive(active);
}
private Path backupMod(Path file) throws IOException {
Path newPath = file.resolveSibling(
StringUtils.addSuffix(
StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION),
OLD_EXTENSION
)
);
if (Files.exists(file)) {
Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING);
boolean active = from.isActive();
from.setActive(true);
from.setOld(true);
to.setOld(false);
to.setActive(active);
} finally {
lock.unlock();
}
return newPath;
}
private Path restoreMod(Path file) throws IOException {
Path newPath = file.resolveSibling(
StringUtils.removeSuffix(FileUtils.getName(file), OLD_EXTENSION)
);
if (Files.exists(file)) {
Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING);
}
return newPath;
}
public Path setOld(LocalModFile modFile, boolean old) throws IOException {
Path newPath;
if (old) {
newPath = backupMod(modFile.getFile());
localModFiles.remove(modFile);
} else {
newPath = restoreMod(modFile.getFile());
localModFiles.add(modFile);
}
return newPath;
}
public Path disableMod(Path file) throws IOException {
@@ -314,10 +304,6 @@ public final class ModManager {
return enabled;
}
public static String getModName(Path file) {
return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
}
public boolean isOld(Path file) {
return FileUtils.getName(file).endsWith(OLD_EXTENSION);
}
@@ -327,8 +313,8 @@ public final class ModManager {
}
public static boolean isFileNameMod(Path file) {
String name = getModName(file);
return name.endsWith(".zip") || name.endsWith(".jar") || name.endsWith(".litemod");
String name = getLocalAddonName(file);
return MOD_EXTENSIONS.contains(FileUtils.getExtension(name).toLowerCase(Locale.ROOT));
}
public static boolean isFileMod(Path modFile) {
@@ -353,11 +339,6 @@ public final class ModManager {
return true;
}
if (Files.exists(fs.getPath("pack.mcmeta"))) {
// resource pack, data pack
return true;
}
return false;
} catch (IOException e) {
return false;
@@ -365,22 +346,19 @@ public final class ModManager {
}
/**
* Check if "mods" directory has mod file named "fileName" no matter the mod is disabled,upgraded or not
* Check if "mods" directory has mod file named "fileName" no matter the mod is disabled, upgraded or not
*
* @param fileName name of the file whose existence is being checked
* @return true if the file exists
*/
public boolean hasSimpleMod(String fileName) {
return Files.exists(getModsDirectory().resolve(StringUtils.removeSuffix(fileName, DISABLED_EXTENSION)))
|| Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION)))
|| Files.exists(getModsDirectory().resolve(StringUtils.removeSuffix(fileName, OLD_EXTENSION)))
|| Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, OLD_EXTENSION)));
return Files.exists(getDirectory().resolve(StringUtils.removeSuffix(fileName, DISABLED_EXTENSION)))
|| Files.exists(getDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION)))
|| Files.exists(getDirectory().resolve(StringUtils.removeSuffix(fileName, OLD_EXTENSION)))
|| Files.exists(getDirectory().resolve(StringUtils.addSuffix(fileName, OLD_EXTENSION)));
}
public Path getSimpleModPath(String fileName) {
return getModsDirectory().resolve(fileName);
return getDirectory().resolve(fileName);
}
public static final String DISABLED_EXTENSION = ".disabled";
public static final String OLD_EXTENSION = ".old";
}
@@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DownloadProvider;
import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
import org.jackhuang.hmcl.task.FileDownloadTask;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.time.Instant;
@@ -41,7 +42,7 @@ public final class RemoteMod {
public Stream<RemoteMod.Version> loadVersions(RemoteModRepository modRepository, DownloadProvider downloadProvider) throws IOException {
throw new IOException();
}
});
}, RemoteModRepository.Type.MOD);
private final String slug;
private final String author;
@@ -51,8 +52,9 @@ public final class RemoteMod {
private final String pageUrl;
private final String iconUrl;
private final IMod data;
private final RemoteModRepository.Type repoType;
public RemoteMod(String slug, String author, String title, String description, List<String> categories, String pageUrl, String iconUrl, IMod data) {
public RemoteMod(String slug, String author, String title, String description, List<String> categories, String pageUrl, String iconUrl, IMod data, RemoteModRepository.Type repoType) {
this.slug = slug;
this.author = author;
this.title = title;
@@ -61,6 +63,7 @@ public final class RemoteMod {
this.pageUrl = pageUrl;
this.iconUrl = iconUrl;
this.data = data;
this.repoType = repoType;
}
public String getSlug() {
@@ -95,6 +98,10 @@ public final class RemoteMod {
return data;
}
public RemoteModRepository.Type getRepositoryType() {
return repoType;
}
public enum VersionType {
Release,
Beta,
@@ -188,17 +195,56 @@ public final class RemoteMod {
}
public enum Type {
CURSEFORGE(CurseForgeRemoteModRepository.MODS),
MODRINTH(ModrinthRemoteModRepository.MODS);
CURSEFORGE(
CurseForgeRemoteModRepository.MODS,
CurseForgeRemoteModRepository.RESOURCE_PACKS,
CurseForgeRemoteModRepository.SHADERS,
CurseForgeRemoteModRepository.WORLDS,
CurseForgeRemoteModRepository.MODPACKS,
CurseForgeRemoteModRepository.CUSTOMIZATIONS
),
MODRINTH(
ModrinthRemoteModRepository.MODS,
ModrinthRemoteModRepository.RESOURCE_PACKS,
ModrinthRemoteModRepository.SHADER_PACKS,
null,
ModrinthRemoteModRepository.MODPACKS,
null
);
private final RemoteModRepository remoteModRepository;
public final RemoteModRepository modRepo;
public final RemoteModRepository resourcePackRepo;
public final RemoteModRepository shaderPackRepo;
public final RemoteModRepository worldRepo;
public final RemoteModRepository modpackRepo;
public final RemoteModRepository customizationRepo;
public RemoteModRepository getRemoteModRepository() {
return this.remoteModRepository;
@Nullable
public RemoteModRepository getRepoForType(RemoteModRepository.Type type) {
return switch (type) {
case MOD -> modRepo;
case RESOURCE_PACK -> resourcePackRepo;
case SHADER_PACK -> shaderPackRepo;
case WORLD -> worldRepo;
case MODPACK -> modpackRepo;
case CUSTOMIZATION -> customizationRepo;
};
}
Type(RemoteModRepository remoteModRepository) {
this.remoteModRepository = remoteModRepository;
Type(
RemoteModRepository modRepo,
RemoteModRepository resourcePackRepo,
RemoteModRepository shaderPackRepo,
RemoteModRepository worldRepo,
RemoteModRepository modpackRepo,
RemoteModRepository customizationRepo
) {
this.modRepo = modRepo;
this.resourcePackRepo = resourcePackRepo;
this.shaderPackRepo = shaderPackRepo;
this.worldRepo = worldRepo;
this.modpackRepo = modpackRepo;
this.customizationRepo = customizationRepo;
}
}
@@ -88,7 +88,7 @@ public interface RemoteModRepository {
SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder)
throws IOException;
Optional<RemoteMod.Version> getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException;
Optional<RemoteMod.Version> getRemoteVersionByLocalFile(Path file) throws IOException;
RemoteMod getModById(DownloadProvider downloadProvider, String id) throws IOException;
@@ -102,27 +102,6 @@ public interface RemoteModRepository {
Stream<Category> getCategories() throws IOException;
class Category {
private final Object self;
private final String id;
private final List<Category> subcategories;
public Category(Object self, String id, List<Category> subcategories) {
this.self = self;
this.id = id;
this.subcategories = subcategories;
}
public Object getSelf() {
return self;
}
public String getId() {
return id;
}
public List<Category> getSubcategories() {
return subcategories;
}
record Category(Object self, String id, List<Category> subcategories) {
}
}
@@ -212,7 +212,7 @@ public class CurseAddon implements RemoteMod.IMod {
return modRepository.getRemoteVersionsById(downloadProvider, Integer.toString(id));
}
public RemoteMod toMod() {
public RemoteMod toMod(RemoteModRepository.Type type) {
String iconUrl = "";
if (logo != null) {
if (StringUtils.isNotBlank(logo.getThumbnailUrl()))
@@ -229,7 +229,8 @@ public class CurseAddon implements RemoteMod.IMod {
categories.stream().map(category -> Integer.toString(category.getId())).collect(Collectors.toList()),
links.websiteUrl,
iconUrl,
this
this,
type
);
}
@@ -598,7 +599,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())
);
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more