Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dda30274a4 | ||
|
|
2b43ee24a1 | ||
|
|
66f1e60433 | ||
|
|
3c3e2b8cfc | ||
|
|
365bb089c5 | ||
|
|
87af44328b | ||
|
|
8731faaac7 | ||
|
|
5abc1f1f9d | ||
|
|
40b4ecd42a | ||
|
|
e0f5c4d91e | ||
|
|
35ecd58460 | ||
|
|
f29c2bc202 | ||
|
|
361e928e73 | ||
|
|
484b65b513 | ||
|
|
19f02e3967 | ||
|
|
495da6b6b0 | ||
|
|
168c4ac708 | ||
|
|
7e8a6679cc | ||
|
|
e90d5185f6 | ||
|
|
8a89baee09 | ||
|
|
7f4c5fc4a0 | ||
|
|
75789c774d | ||
|
|
f0a16b3d14 | ||
|
|
710e69751f | ||
|
|
938d8647ce | ||
|
|
4b4dd592c3 | ||
|
|
225dba7448 | ||
|
|
5b1414428e | ||
|
|
cb467ae240 | ||
|
|
9416c0cfb3 | ||
|
|
16ea98c95a | ||
|
|
a8552a92d1 | ||
|
|
36d71bd14e | ||
|
|
cbe6554390 | ||
|
|
1b65e0b6d7 | ||
|
|
bd9ae189f8 | ||
|
|
867a04dfba | ||
|
|
dd7703c7a9 | ||
|
|
756b95ca8c | ||
|
|
ac0a0c7604 | ||
|
|
9ee611c0f6 |
No files matched your search
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
|
||||
ij_jspx_keep_indents_on_empty_lines = false
|
||||
|
||||
[{*.markdown,*.md}]
|
||||
max_line_length = 200
|
||||
ij_markdown_force_one_space_after_blockquote_symbol = true
|
||||
ij_markdown_force_one_space_after_header_symbol = true
|
||||
ij_markdown_force_one_space_after_list_bullet = true
|
||||
|
||||
@@ -22,4 +22,4 @@ jobs:
|
||||
java-version: '17'
|
||||
java-package: 'jdk+fx'
|
||||
- name: Check Codes
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel
|
||||
run: ./gradlew checkstyle checkTranslations --no-daemon --parallel --stacktrace
|
||||
@@ -2,8 +2,8 @@ name: Check Update
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '30 * * * *'
|
||||
# schedule:
|
||||
# - cron: '30 * * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -13,39 +13,27 @@ jobs:
|
||||
if: ${{ github.repository_owner == 'HMCL-dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
- name: Fetch tags
|
||||
run: git fetch --all --tags
|
||||
- name: Install tools
|
||||
run: sudo apt-get install -y jq
|
||||
- name: Fetch last version
|
||||
run: |
|
||||
wget -O ci.json https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/api/json
|
||||
|
||||
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
|
||||
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
|
||||
|
||||
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
|
||||
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
|
||||
|
||||
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
|
||||
|
||||
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
|
||||
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
|
||||
echo "HMCL_TAG_NAME=v$HMCL_VERSION" >> $GITHUB_ENV
|
||||
run: ./gradlew checkUpdateDev --no-daemon --info --stacktrace
|
||||
- name: Check for existing tags
|
||||
run: if [ -z "$(git tag -l "$HMCL_TAG_NAME")" ]; then echo "continue=true" >> $GITHUB_ENV; fi
|
||||
- name: Download artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
env:
|
||||
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL/lastSuccessfulBuild/artifact/HMCL/build/libs
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
|
||||
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"
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -78,39 +66,28 @@ jobs:
|
||||
needs: dev-check-update
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '25'
|
||||
- name: Fetch tags
|
||||
run: git fetch --all --tags
|
||||
- name: Install tools
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Fetch last version
|
||||
run: |
|
||||
wget -O ci.json https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/api/json
|
||||
|
||||
export HMCL_EXE_FILE_NAME=`cat ci.json | jq -M -r '.artifacts[] | select(.fileName | endswith(".exe")) | .fileName'`
|
||||
if [ -z `echo $HMCL_EXE_FILE_NAME | grep -E "^HMCL-[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\.exe\$"` ]; then exit 1; fi
|
||||
|
||||
export HMCL_VERSION=`echo "${HMCL_EXE_FILE_NAME%.exe}" | tail -c +6`
|
||||
export HMCL_COMMIT_SHA=`cat ci.json | jq -M -r '.actions[] | select(._class == "hudson.plugins.git.util.BuildData") | .lastBuiltRevision.SHA1'`
|
||||
|
||||
if [ "${#HMCL_COMMIT_SHA}" != 40 ]; then exit 1; fi
|
||||
|
||||
echo "HMCL_VERSION=$HMCL_VERSION" >> $GITHUB_ENV
|
||||
echo "HMCL_COMMIT_SHA=$HMCL_COMMIT_SHA" >> $GITHUB_ENV
|
||||
echo "HMCL_TAG_NAME=release-$HMCL_VERSION" >> $GITHUB_ENV
|
||||
run: ./gradlew checkUpdateStable --no-daemon --info --stacktrace
|
||||
- name: Check for existing tags
|
||||
run: if ! git tag -l | grep -q "$HMCL_TAG_NAME"; then echo "continue=true" >> $GITHUB_ENV; fi
|
||||
- name: Download artifacts
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.jar.sha256"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh"
|
||||
wget "$DOWNLOAD_BASE_URL/HMCL-$HMCL_VERSION.sh.sha256"
|
||||
env:
|
||||
DOWNLOAD_BASE_URL: https://ci.huangyuhui.net/job/HMCL-stable/lastSuccessfulBuild/artifact/HMCL/build/libs
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.exe.sha256"
|
||||
wget "$HMCL_CI_DOWNLOAD_BASE_URI/HMCL-$HMCL_VERSION.jar"
|
||||
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"
|
||||
- name: Generate release note
|
||||
if: ${{ env.continue == 'true' }}
|
||||
run: |
|
||||
@@ -122,9 +99,9 @@ jobs:
|
||||
echo "" >> RELEASE_NOTE
|
||||
echo "| File Name | SHA-256 Checksum |" >> RELEASE_NOTE
|
||||
echo "| --- | --- |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.exe]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.exe) | \`$(cat HMCL-$HMCL_VERSION.exe.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.jar]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.jar) | \`$(cat HMCL-$HMCL_VERSION.jar.sha256)\` |" >> RELEASE_NOTE
|
||||
echo "| [HMCL-$HMCL_VERSION.sh]($GH_DOWNLOAD_BASE_URL/release-$HMCL_VERSION/HMCL-$HMCL_VERSION.sh) | \`$(cat HMCL-$HMCL_VERSION.sh.sha256)\` |" >> RELEASE_NOTE
|
||||
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
|
||||
env:
|
||||
GH_DOWNLOAD_BASE_URL: https://github.com/HMCL-dev/HMCL/releases/download
|
||||
- name: Create release
|
||||
@@ -139,3 +116,4 @@ jobs:
|
||||
target_commitish: ${{ env.HMCL_COMMIT_SHA }}
|
||||
name: ${{ env.HMCL_TAG_NAME }}
|
||||
tag_name: ${{ env.HMCL_TAG_NAME }}
|
||||
prerelease: true
|
||||
@@ -27,8 +27,24 @@ jobs:
|
||||
CURSEFORGE_API_KEY: ${{ secrets.CURSEFORGE_API_KEY }}
|
||||
- name: Get short SHA
|
||||
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
|
||||
- name: Upload Artifacts
|
||||
- name: Upload JAR
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}
|
||||
path: HMCL/build/libs
|
||||
name: HMCL-${{ env.SHORT_SHA }}-jar
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.jar
|
||||
HMCL/build/libs/HMCL-*.jar.sha256
|
||||
- name: Upload EXE
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-exe
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.exe
|
||||
HMCL/build/libs/HMCL-*.exe.sha256
|
||||
- name: Upload SH
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: HMCL-${{ env.SHORT_SHA }}-sh
|
||||
path: |
|
||||
HMCL/build/libs/HMCL-*.sh
|
||||
HMCL/build/libs/HMCL-*.sh.sha256
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
.gradle
|
||||
|
||||
|
||||
+80
-35
@@ -1,5 +1,11 @@
|
||||
import org.jackhuang.hmcl.gradle.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.ci.GitHubActionUtils
|
||||
import org.jackhuang.hmcl.gradle.ci.JenkinsUtils
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
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.utils.PropertiesUtils
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
@@ -13,30 +19,36 @@ plugins {
|
||||
alias(libs.plugins.shadow)
|
||||
}
|
||||
|
||||
val isOfficial = System.getenv("HMCL_SIGNATURE_KEY") != null
|
||||
|| (System.getenv("GITHUB_REPOSITORY_OWNER") == "HMCL-dev" && System.getenv("GITHUB_BASE_REF")
|
||||
.isNullOrEmpty())
|
||||
val projectConfig = PropertiesUtils.load(rootProject.file("config/project.properties").toPath())
|
||||
|
||||
val isOfficial = JenkinsUtils.IS_ON_CI || GitHubActionUtils.IS_ON_OFFICIAL_REPO
|
||||
|
||||
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt().let { number ->
|
||||
val offset = System.getenv("BUILD_NUMBER_OFFSET")?.toInt() ?: 0
|
||||
if (number != null) {
|
||||
(number - offset).toString()
|
||||
} else {
|
||||
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
|
||||
val prefix = if (isOfficial) "dev" else "unofficial"
|
||||
if (!shortCommit.isNullOrEmpty()) "$prefix-$shortCommit" else "SNAPSHOT"
|
||||
}
|
||||
}
|
||||
val versionRoot = System.getenv("VERSION_ROOT") ?: "3.6"
|
||||
val versionType = System.getenv("VERSION_TYPE") ?: if (isOfficial) "nightly" else "unofficial"
|
||||
val versionRoot = System.getenv("VERSION_ROOT") ?: projectConfig.getProperty("versionRoot") ?: "3"
|
||||
|
||||
val microsoftAuthId = System.getenv("MICROSOFT_AUTH_ID") ?: ""
|
||||
val microsoftAuthSecret = System.getenv("MICROSOFT_AUTH_SECRET") ?: ""
|
||||
val curseForgeApiKey = System.getenv("CURSEFORGE_API_KEY") ?: ""
|
||||
|
||||
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE")
|
||||
val launcherExe = System.getenv("HMCL_LAUNCHER_EXE") ?: ""
|
||||
|
||||
version = "$versionRoot.$buildNumber"
|
||||
val buildNumber = System.getenv("BUILD_NUMBER")?.toInt()
|
||||
if (buildNumber != null) {
|
||||
version = if (JenkinsUtils.IS_ON_CI && versionType == "dev") {
|
||||
"$versionRoot.0.$buildNumber"
|
||||
} else {
|
||||
"$versionRoot.$buildNumber"
|
||||
}
|
||||
} else {
|
||||
val shortCommit = System.getenv("GITHUB_SHA")?.lowercase()?.substring(0, 7)
|
||||
version = if (shortCommit.isNullOrBlank()) {
|
||||
"$versionRoot.SNAPSHOT"
|
||||
} else if (isOfficial) {
|
||||
"$versionRoot.dev-$shortCommit"
|
||||
} else {
|
||||
"$versionRoot.unofficial-$shortCommit"
|
||||
}
|
||||
}
|
||||
|
||||
val embedResources by configurations.registering
|
||||
|
||||
@@ -47,7 +59,7 @@ dependencies {
|
||||
implementation(libs.twelvemonkeys.imageio.webp)
|
||||
implementation(libs.java.info)
|
||||
|
||||
if (launcherExe == null) {
|
||||
if (launcherExe.isBlank()) {
|
||||
implementation(libs.hmclauncher)
|
||||
}
|
||||
|
||||
@@ -109,8 +121,25 @@ tasks.compileJava {
|
||||
options.compilerArgs.add("--add-exports=java.base/jdk.internal.loader=ALL-UNNAMED")
|
||||
}
|
||||
|
||||
val addOpens = listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
|
||||
val hmclProperties = buildList {
|
||||
add("hmcl.version" to project.version.toString())
|
||||
add("hmcl.add-opens" to addOpens.joinToString(" "))
|
||||
System.getenv("GITHUB_SHA")?.let {
|
||||
add("hmcl.version.hash" to it)
|
||||
}
|
||||
@@ -137,22 +166,6 @@ val createPropertiesFile by tasks.registering {
|
||||
}
|
||||
}
|
||||
|
||||
val addOpens = listOf(
|
||||
"java.base/java.lang",
|
||||
"java.base/java.lang.reflect",
|
||||
"java.base/jdk.internal.loader",
|
||||
"javafx.base/com.sun.javafx.binding",
|
||||
"javafx.base/com.sun.javafx.event",
|
||||
"javafx.base/com.sun.javafx.runtime",
|
||||
"javafx.graphics/javafx.css",
|
||||
"javafx.graphics/com.sun.javafx.stage",
|
||||
"javafx.graphics/com.sun.prism",
|
||||
"javafx.controls/com.sun.javafx.scene.control",
|
||||
"javafx.controls/com.sun.javafx.scene.control.behavior",
|
||||
"javafx.controls/javafx.scene.control.skin",
|
||||
"jdk.attach/sun.tools.attach",
|
||||
)
|
||||
|
||||
tasks.jar {
|
||||
enabled = false
|
||||
dependsOn(tasks["shadowJar"])
|
||||
@@ -192,7 +205,7 @@ tasks.shadowJar {
|
||||
"Enable-Native-Access" to "ALL-UNNAMED"
|
||||
)
|
||||
|
||||
if (launcherExe != null) {
|
||||
if (launcherExe.isNotBlank()) {
|
||||
into("assets") {
|
||||
from(file(launcherExe))
|
||||
}
|
||||
@@ -206,11 +219,20 @@ tasks.shadowJar {
|
||||
|
||||
tasks.processResources {
|
||||
dependsOn(createPropertiesFile)
|
||||
dependsOn(upsideDownTranslate)
|
||||
dependsOn(createLocaleNamesResourceBundle)
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
into("assets/") {
|
||||
from(hmclPropertiesFile)
|
||||
from(embedResources)
|
||||
}
|
||||
|
||||
into("assets/lang") {
|
||||
from(createLanguageList.map { it.outputFile })
|
||||
from(upsideDownTranslate.map { it.outputFile })
|
||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
@@ -344,6 +366,29 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||
}
|
||||
|
||||
// l10n
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
|
||||
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
|
||||
}
|
||||
|
||||
val createLanguageList by tasks.registering(CreateLanguageList::class) {
|
||||
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||
resourceBundleBaseName.set("I18N")
|
||||
additionalLanguages.set(listOf("en-Qabs"))
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
|
||||
// mcmod data
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModData") {
|
||||
|
||||
@@ -46,21 +46,7 @@ public final class EntryPoint {
|
||||
createHMCLDirectories();
|
||||
LOG.start(Metadata.HMCL_CURRENT_DIRECTORY.resolve("logs"));
|
||||
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU")))
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
checkDirectoryPath();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
|
||||
@@ -80,6 +66,71 @@ public final class EntryPoint {
|
||||
System.exit(exitCode);
|
||||
}
|
||||
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
String animationFrameRate = System.getenv("HMCL_ANIMATION_FRAME_RATE");
|
||||
if (animationFrameRate != null) {
|
||||
LOG.info("HMCL_ANIMATION_FRAME_RATE: " + animationFrameRate);
|
||||
|
||||
try {
|
||||
if (Integer.parseInt(animationFrameRate) <= 0)
|
||||
throw new NumberFormatException(animationFrameRate);
|
||||
|
||||
System.getProperties().putIfAbsent("javafx.animation.pulse", animationFrameRate);
|
||||
} catch (NumberFormatException e) {
|
||||
LOG.warning("Invalid animation frame rate: " + animationFrameRate);
|
||||
}
|
||||
}
|
||||
|
||||
String uiScale = System.getProperty("hmcl.uiScale", System.getenv("HMCL_UI_SCALE"));
|
||||
if (uiScale != null) {
|
||||
uiScale = uiScale.trim();
|
||||
|
||||
LOG.info("HMCL_UI_SCALE: " + uiScale);
|
||||
|
||||
try {
|
||||
float scaleValue;
|
||||
if (uiScale.endsWith("%")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 1)) / 100.0f;
|
||||
} else if (uiScale.endsWith("dpi") || uiScale.endsWith("DPI")) {
|
||||
scaleValue = Integer.parseInt(uiScale.substring(0, uiScale.length() - 3)) / 96.0f;
|
||||
} else {
|
||||
scaleValue = Float.parseFloat(uiScale);
|
||||
}
|
||||
|
||||
float lowerBound;
|
||||
float upperBound;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
// JavaFX behavior may be abnormal when the DPI scaling factor is too high
|
||||
lowerBound = 0.25f;
|
||||
upperBound = 4f;
|
||||
} else {
|
||||
lowerBound = 0.01f;
|
||||
upperBound = 10f;
|
||||
}
|
||||
|
||||
if (scaleValue >= lowerBound && scaleValue <= upperBound) {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) {
|
||||
System.getProperties().putIfAbsent("glass.win.uiScale", uiScale);
|
||||
} else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
LOG.warning("macOS does not support setting UI scale, so it will be ignored");
|
||||
} else {
|
||||
System.getProperties().putIfAbsent("glass.gtk.uiScale", uiScale);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("UI scale out of range: " + uiScale);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Invalid UI scale: " + uiScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void createHMCLDirectories() {
|
||||
if (!Files.isDirectory(Metadata.HMCL_CURRENT_DIRECTORY)) {
|
||||
try {
|
||||
|
||||
@@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
Locale locale = Locale.getDefault();
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return;
|
||||
|
||||
/*
|
||||
* 1.0 : No language option, do not set for these versions
|
||||
@@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
|
||||
String region = locale.getCountry();
|
||||
|
||||
return switch (LocaleUtils.getISO1Language(locale)) {
|
||||
return switch (LocaleUtils.getRootLanguage(locale)) {
|
||||
case "es" -> "es_ES";
|
||||
case "ja" -> "ja_JP";
|
||||
case "ru" -> "ru_RU";
|
||||
@@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
yield "zh_CN";
|
||||
}
|
||||
case "en" -> {
|
||||
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
|
||||
yield "en_UD";
|
||||
}
|
||||
|
||||
yield "";
|
||||
}
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ public final class HMCLModpackProvider implements ModpackProvider {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
private static class HMCLModpack extends Modpack {
|
||||
private final static class HMCLModpack extends Modpack {
|
||||
@Override
|
||||
public Task<?> getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, String name) {
|
||||
return new HMCLModpackInstallTask(((HMCLGameRepository) dependencyManager.getGameRepository()).getProfile(), zipFile, this, name);
|
||||
|
||||
@@ -58,6 +58,7 @@ import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.stream.Collectors;
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
@@ -706,6 +707,7 @@ public final class LauncherHelper {
|
||||
*/
|
||||
private final class HMCLProcessListener implements ProcessListener {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final HMCLGameRepository repository;
|
||||
private final Version version;
|
||||
private final LaunchOptions launchOptions;
|
||||
@@ -849,21 +851,27 @@ public final class LauncherHelper {
|
||||
level = Lang.requireNonNullElse(Log4jLevel.guessLevel(log), Log4jLevel.INFO);
|
||||
logBuffer.add(new Log(log, level));
|
||||
} else {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
logs.addLast(new Log(log, level));
|
||||
if (logs.size() > Log.getLogLines())
|
||||
logs.removeFirst();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
if (!lwjgl) {
|
||||
String lowerCaseLog = log.toLowerCase(Locale.ROOT);
|
||||
if (!detectWindow || lowerCaseLog.contains("lwjgl version") || lowerCaseLog.contains("lwjgl openal")) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (!lwjgl) {
|
||||
lwjgl = true;
|
||||
finishLaunch();
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -888,9 +896,12 @@ public final class LauncherHelper {
|
||||
|
||||
// Game crashed before opening the game window.
|
||||
if (!lwjgl) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (!lwjgl)
|
||||
finishLaunch();
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -904,7 +915,15 @@ public final class LauncherHelper {
|
||||
|
||||
}
|
||||
|
||||
public static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
|
||||
|
||||
public static int countMangedProcesses() {
|
||||
PROCESSES.removeIf(it -> {
|
||||
ManagedProcess process = it.get();
|
||||
return process == null || !process.isRunning();
|
||||
});
|
||||
return PROCESSES.size();
|
||||
}
|
||||
|
||||
public static void stopManagedProcesses() {
|
||||
while (!PROCESSES.isEmpty())
|
||||
|
||||
@@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -222,7 +221,7 @@ public final class Config implements Observable {
|
||||
}
|
||||
|
||||
@SerializedName("localization")
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(Locales.DEFAULT);
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
public ObjectProperty<SupportedLocale> localizationProperty() {
|
||||
return localization;
|
||||
@@ -251,6 +250,21 @@ public final class Config implements Observable {
|
||||
this.promptedVersion.set(promptedVersion);
|
||||
}
|
||||
|
||||
@SerializedName("acceptPreviewUpdate")
|
||||
private final BooleanProperty acceptPreviewUpdate = new SimpleBooleanProperty(false);
|
||||
|
||||
public BooleanProperty acceptPreviewUpdateProperty() {
|
||||
return acceptPreviewUpdate;
|
||||
}
|
||||
|
||||
public boolean isAcceptPreviewUpdate() {
|
||||
return acceptPreviewUpdate.get();
|
||||
}
|
||||
|
||||
public void setAcceptPreviewUpdate(boolean acceptPreviewUpdate) {
|
||||
this.acceptPreviewUpdate.set(acceptPreviewUpdate);
|
||||
}
|
||||
|
||||
@SerializedName("shownTips")
|
||||
private final ObservableMap<String, Object> shownTips = FXCollections.observableHashMap();
|
||||
|
||||
|
||||
@@ -31,23 +31,21 @@ import java.io.FileNotFoundException;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.AccessDeniedException;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.task.FetchTask.DEFAULT_CONCURRENCY;
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class DownloadProviders {
|
||||
private DownloadProviders() {}
|
||||
private DownloadProviders() {
|
||||
}
|
||||
|
||||
private static DownloadProvider currentDownloadProvider;
|
||||
private static final DownloadProviderWrapper provider;
|
||||
|
||||
public static final Map<String, DownloadProvider> providersById;
|
||||
public static final Map<String, DownloadProvider> rawProviders;
|
||||
@@ -59,6 +57,7 @@ public final class DownloadProviders {
|
||||
public static final String DEFAULT_PROVIDER_ID = "balanced";
|
||||
public static final String DEFAULT_RAW_PROVIDER_ID = "bmclapi";
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static final InvalidationListener observer;
|
||||
|
||||
static {
|
||||
@@ -68,71 +67,68 @@ public final class DownloadProviders {
|
||||
|
||||
MOJANG = new MojangDownloadProvider();
|
||||
BMCLAPI = new BMCLAPIDownloadProvider(bmclapiRoot);
|
||||
rawProviders = mapOf(
|
||||
pair("mojang", MOJANG),
|
||||
pair("bmclapi", BMCLAPI)
|
||||
rawProviders = Map.of(
|
||||
"mojang", MOJANG,
|
||||
"bmclapi", BMCLAPI
|
||||
);
|
||||
|
||||
AdaptedDownloadProvider fileProvider = new AdaptedDownloadProvider();
|
||||
fileProvider.setDownloadProviderCandidates(Arrays.asList(BMCLAPI, MOJANG));
|
||||
fileProvider.setDownloadProviderCandidates(List.of(BMCLAPI, MOJANG));
|
||||
BalancedDownloadProvider balanced = new BalancedDownloadProvider(MOJANG, BMCLAPI);
|
||||
|
||||
providersById = mapOf(
|
||||
pair("official", new AutoDownloadProvider(MOJANG, fileProvider)),
|
||||
pair("balanced", new AutoDownloadProvider(balanced, fileProvider)),
|
||||
pair("mirror", new AutoDownloadProvider(BMCLAPI, fileProvider)));
|
||||
providersById = Map.of(
|
||||
"official", new AutoDownloadProvider(MOJANG, fileProvider),
|
||||
"balanced", new AutoDownloadProvider(balanced, fileProvider),
|
||||
"mirror", new AutoDownloadProvider(BMCLAPI, fileProvider));
|
||||
|
||||
observer = FXUtils.observeWeak(() -> {
|
||||
FetchTask.setDownloadExecutorConcurrency(
|
||||
config().getAutoDownloadThreads() ? DEFAULT_CONCURRENCY : config().getDownloadThreads());
|
||||
}, config().autoDownloadThreadsProperty(), config().downloadThreadsProperty());
|
||||
|
||||
provider = new DownloadProviderWrapper(MOJANG);
|
||||
}
|
||||
|
||||
static void init() {
|
||||
FXUtils.onChangeAndOperate(config().versionListSourceProperty(), versionListSource -> {
|
||||
if (!providersById.containsKey(versionListSource)) {
|
||||
config().setVersionListSource(DEFAULT_PROVIDER_ID);
|
||||
return;
|
||||
InvalidationListener onChangeDownloadSource = observable -> {
|
||||
String versionListSource = Objects.requireNonNullElse(config().getVersionListSource(), "");
|
||||
if (config().isAutoChooseDownloadType()) {
|
||||
DownloadProvider currentDownloadProvider = providersById.get(versionListSource);
|
||||
if (currentDownloadProvider == null)
|
||||
currentDownloadProvider = Objects.requireNonNull(providersById.get(DEFAULT_PROVIDER_ID),
|
||||
"default provider is null");
|
||||
|
||||
provider.setProvider(currentDownloadProvider);
|
||||
} else {
|
||||
provider.setProvider(fileDownloadProvider);
|
||||
}
|
||||
};
|
||||
config().versionListSourceProperty().addListener(onChangeDownloadSource);
|
||||
config().autoChooseDownloadTypeProperty().addListener(onChangeDownloadSource);
|
||||
|
||||
currentDownloadProvider = Optional.ofNullable(providersById.get(versionListSource))
|
||||
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
|
||||
});
|
||||
|
||||
if (!rawProviders.containsKey(config().getDownloadType())) {
|
||||
config().setDownloadType(DEFAULT_RAW_PROVIDER_ID);
|
||||
}
|
||||
onChangeDownloadSource.invalidated(null);
|
||||
|
||||
FXUtils.onChangeAndOperate(config().downloadTypeProperty(), downloadType -> {
|
||||
DownloadProvider primary = Optional.ofNullable(rawProviders.get(downloadType))
|
||||
.orElse(rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
|
||||
fileDownloadProvider.setDownloadProviderCandidates(
|
||||
Stream.concat(
|
||||
Stream.of(primary),
|
||||
rawProviders.values().stream().filter(x -> x != primary)
|
||||
).collect(Collectors.toList())
|
||||
);
|
||||
DownloadProvider primary = Objects.requireNonNullElseGet(
|
||||
rawProviders.get(Objects.requireNonNullElse(downloadType, "")),
|
||||
() -> rawProviders.get(DEFAULT_RAW_PROVIDER_ID));
|
||||
|
||||
List<DownloadProvider> providers = new ArrayList<>(rawProviders.size());
|
||||
providers.add(primary);
|
||||
for (DownloadProvider provider : rawProviders.values()) {
|
||||
if (provider != primary)
|
||||
providers.add(provider);
|
||||
}
|
||||
|
||||
fileDownloadProvider.setDownloadProviderCandidates(providers);
|
||||
});
|
||||
}
|
||||
|
||||
public static String getPrimaryDownloadProviderId() {
|
||||
String downloadType = config().getDownloadType();
|
||||
if (providersById.containsKey(downloadType))
|
||||
return downloadType;
|
||||
else
|
||||
return DEFAULT_PROVIDER_ID;
|
||||
}
|
||||
|
||||
public static DownloadProvider getDownloadProviderByPrimaryId(String primaryId) {
|
||||
return Optional.ofNullable(providersById.get(primaryId))
|
||||
.orElse(providersById.get(DEFAULT_PROVIDER_ID));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current primary preferred download provider
|
||||
*/
|
||||
public static DownloadProvider getDownloadProvider() {
|
||||
return config().isAutoChooseDownloadType() ? currentDownloadProvider : fileDownloadProvider;
|
||||
return provider;
|
||||
}
|
||||
|
||||
public static String localizeErrorMessage(Throwable exception) {
|
||||
|
||||
@@ -48,4 +48,8 @@ public enum VersionIconType {
|
||||
public Image getIcon() {
|
||||
return FXUtils.newBuiltinImage(resourceUrl);
|
||||
}
|
||||
|
||||
public Image getIcon(int size) {
|
||||
return FXUtils.newBuiltinImage(resourceUrl, size, size, true, true);
|
||||
}
|
||||
}
|
||||
@@ -771,6 +771,7 @@ public final class VersionSetting implements Cloneable, Observable {
|
||||
obj.addProperty("wrapper", src.getWrapper());
|
||||
obj.addProperty("fullscreen", src.isFullscreen());
|
||||
obj.addProperty("noJVMArgs", src.isNoJVMArgs());
|
||||
obj.addProperty("noOptimizingJVMArgs", src.isNoOptimizingJVMArgs());
|
||||
obj.addProperty("notCheckGame", src.isNotCheckGame());
|
||||
obj.addProperty("notCheckJVM", src.isNotCheckJVM());
|
||||
obj.addProperty("notPatchNatives", src.isNotPatchNatives());
|
||||
@@ -841,6 +842,7 @@ public final class VersionSetting implements Cloneable, Observable {
|
||||
vs.setNativesDir(Optional.ofNullable(obj.get("nativesDir")).map(JsonElement::getAsString).orElse(""));
|
||||
vs.setFullscreen(Optional.ofNullable(obj.get("fullscreen")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNoJVMArgs(Optional.ofNullable(obj.get("noJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNoOptimizingJVMArgs(Optional.ofNullable(obj.get("noOptimizingJVMArgs")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotCheckGame(Optional.ofNullable(obj.get("notCheckGame")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotCheckJVM(Optional.ofNullable(obj.get("notCheckJVM")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
vs.setNotPatchNatives(Optional.ofNullable(obj.get("notPatchNatives")).map(JsonElement::getAsBoolean).orElse(false));
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
import org.jackhuang.hmcl.setting.Accounts;
|
||||
import org.jackhuang.hmcl.task.DownloadException;
|
||||
import org.jackhuang.hmcl.task.GetTask;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.InvocationDispatcher;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
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.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class TerracottaManager {
|
||||
private TerracottaManager() {
|
||||
}
|
||||
|
||||
private static final AtomicReference<TerracottaState> STATE_V = new AtomicReference<>(TerracottaState.Bootstrap.INSTANCE);
|
||||
private static final ReadOnlyObjectWrapper<TerracottaState> STATE = new ReadOnlyObjectWrapper<>(STATE_V.getPlain());
|
||||
private static final InvocationDispatcher<TerracottaState> STATE_D = InvocationDispatcher.runOn(Platform::runLater, STATE::set);
|
||||
|
||||
static {
|
||||
Task.runAsync(() -> {
|
||||
if (TerracottaMetadata.PROVIDER == null) {
|
||||
setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.OS));
|
||||
LOG.warning("Terracotta hasn't support your OS: " + org.jackhuang.hmcl.util.platform.Platform.SYSTEM_PLATFORM);
|
||||
} else {
|
||||
switch (TerracottaMetadata.PROVIDER.status()) {
|
||||
case NOT_EXIST -> setState(new TerracottaState.Uninitialized(false));
|
||||
case LEGACY_VERSION -> setState(new TerracottaState.Uninitialized(true));
|
||||
case READY -> launch(setState(new TerracottaState.Launching()));
|
||||
}
|
||||
}
|
||||
}).whenComplete(exception -> {
|
||||
if (exception != null) {
|
||||
compareAndSet(TerracottaState.Bootstrap.INSTANCE, new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public static ReadOnlyObjectProperty<TerracottaState> stateProperty() {
|
||||
return STATE.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
static {
|
||||
Lang.thread(() -> {
|
||||
while (true) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.PortSpecific portSpecific)) {
|
||||
LockSupport.parkNanos(500_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
int port = portSpecific.port;
|
||||
int index = state instanceof TerracottaState.Ready ready ? ready.index : Integer.MIN_VALUE;
|
||||
|
||||
TerracottaState next;
|
||||
try {
|
||||
next = new GetTask(URI.create(String.format("http://127.0.0.1:%d/state", port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenApplyAsync(jsonString -> {
|
||||
TerracottaState.Ready object = JsonUtils.fromNonNullJson(jsonString, TypeToken.get(TerracottaState.Ready.class));
|
||||
if (object.index <= index) {
|
||||
return null;
|
||||
}
|
||||
|
||||
object.port = port;
|
||||
return object;
|
||||
})
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.run();
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Cannot fetch state from Terracotta.", e);
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
|
||||
if (next != null) {
|
||||
compareAndSet(state, next);
|
||||
}
|
||||
|
||||
LockSupport.parkNanos(500_000);
|
||||
}
|
||||
}, "Terracotta Background Daemon", true);
|
||||
}
|
||||
|
||||
public static boolean validate(Path file) {
|
||||
return FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME);
|
||||
}
|
||||
|
||||
public static TerracottaState.Preparing install(@Nullable Path file) {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (file != null && !FileUtils.getName(file).equalsIgnoreCase(TerracottaMetadata.PACKAGE_NAME)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
TerracottaState.Preparing preparing;
|
||||
if (state instanceof TerracottaState.Preparing it) {
|
||||
preparing = it;
|
||||
} else {
|
||||
preparing = new TerracottaState.Preparing(new ReadOnlyDoubleWrapper(-1));
|
||||
}
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
return file != null ? TarFileTree.open(file) : null;
|
||||
}).thenComposeAsync(Schedulers.javafx(), tree -> {
|
||||
return getProvider().install(preparing, tree).whenComplete(exception -> {
|
||||
if (tree != null) {
|
||||
tree.close();
|
||||
}
|
||||
if (exception != null) {
|
||||
throw exception;
|
||||
}
|
||||
});
|
||||
}).whenComplete(exception -> {
|
||||
if (exception == null) {
|
||||
try {
|
||||
TerracottaMetadata.removeLegacyVersionFiles();
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to remove legacy terracotta files.", e);
|
||||
}
|
||||
|
||||
TerracottaState.Launching launching = new TerracottaState.Launching();
|
||||
if (compareAndSet(preparing, launching)) {
|
||||
launch(launching);
|
||||
}
|
||||
} else if (exception instanceof ITerracottaProvider.ArchiveFileMissingException) {
|
||||
LOG.warning("Cannot install terracotta from local package.", exception);
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
} else if (exception instanceof DownloadException) {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.NETWORK));
|
||||
} else {
|
||||
compareAndSet(preparing, new TerracottaState.Fatal(TerracottaState.Fatal.Type.INSTALL));
|
||||
}
|
||||
}).start();
|
||||
|
||||
return setState(preparing);
|
||||
}
|
||||
|
||||
private static ITerracottaProvider getProvider() {
|
||||
ITerracottaProvider provider = TerracottaMetadata.PROVIDER;
|
||||
if (provider == null) {
|
||||
throw new AssertionError("Terracotta Provider must NOT be null.");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
public static TerracottaState recover(@Nullable Path file) {
|
||||
FXUtils.checkFxUserThread();
|
||||
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (!(state instanceof TerracottaState.Fatal && ((TerracottaState.Fatal) state).isRecoverable())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (getProvider().status()) {
|
||||
case NOT_EXIST, LEGACY_VERSION -> install(file);
|
||||
case READY -> {
|
||||
TerracottaState.Launching launching = setState(new TerracottaState.Launching());
|
||||
launch(launching);
|
||||
yield launching;
|
||||
}
|
||||
};
|
||||
} catch (NullPointerException | IOException e) {
|
||||
LOG.warning("Cannot determine Terracotta state.", e);
|
||||
return setState(new TerracottaState.Fatal(TerracottaState.Fatal.Type.UNKNOWN));
|
||||
}
|
||||
}
|
||||
|
||||
private static void launch(TerracottaState.Launching state) {
|
||||
Task.supplyAsync(() -> {
|
||||
Path path = Files.createTempDirectory(String.format("hmcl-terracotta-%d", ThreadLocalRandom.current().nextLong())).resolve("http").toAbsolutePath();
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(getProvider().ofCommandLine(path)));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
long exitTime = -1;
|
||||
while (true) {
|
||||
if (Files.exists(path)) {
|
||||
JsonObject object = JsonUtils.fromNonNullJson(Files.readString(path), JsonObject.class);
|
||||
return object.get("port").getAsInt();
|
||||
}
|
||||
|
||||
if (!process.isRunning()) {
|
||||
if (exitTime == -1) {
|
||||
exitTime = System.currentTimeMillis();
|
||||
} else if (System.currentTimeMillis() - exitTime >= 10000) {
|
||||
throw new IllegalStateException("Process has exited for 10s.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}).whenComplete(Schedulers.javafx(), (port, exception) -> {
|
||||
TerracottaState next;
|
||||
if (exception == null) {
|
||||
next = new TerracottaState.Unknown(port);
|
||||
} else {
|
||||
next = new TerracottaState.Fatal(TerracottaState.Fatal.Type.TERRACOTTA);
|
||||
}
|
||||
compareAndSet(state, next);
|
||||
}).start();
|
||||
}
|
||||
|
||||
public static Task<String> exportLogs() {
|
||||
if (STATE_V.get() instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(URI.create(String.format("http://127.0.0.1:%d/log?fetch=true", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
}
|
||||
return Task.completed(null);
|
||||
}
|
||||
|
||||
public static TerracottaState.Waiting setWaiting() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(URI.create(String.format("http://127.0.0.1:%d/state/ide", portSpecific.port)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.start();
|
||||
return new TerracottaState.Waiting(-1, -1, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getPlayerName() {
|
||||
Account account = Accounts.getSelectedAccount();
|
||||
return account != null ? account.getCharacter() : i18n("terracotta.player_anonymous");
|
||||
}
|
||||
|
||||
public static TerracottaState.HostScanning setScanning() {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/scanning?player=%s", portSpecific.port, getPlayerName()))
|
||||
).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||
|
||||
return new TerracottaState.HostScanning(-1, -1, null);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static Task<TerracottaState.GuestStarting> setGuesting(String room) {
|
||||
TerracottaState state = STATE_V.get();
|
||||
if (state instanceof TerracottaState.PortSpecific portSpecific) {
|
||||
return new GetTask(NetworkUtils.toURI(String.format(
|
||||
"http://127.0.0.1:%d/state/guesting?room=%s&player=%s", portSpecific.port, room, getPlayerName()
|
||||
)))
|
||||
.setSignificance(Task.TaskSignificance.MINOR)
|
||||
.thenSupplyAsync(() -> new TerracottaState.GuestStarting(-1, -1, null))
|
||||
.setSignificance(Task.TaskSignificance.MINOR);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T extends TerracottaState> T setState(T value) {
|
||||
if (value == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
STATE_V.set(value);
|
||||
STATE_D.accept(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static boolean compareAndSet(TerracottaState previous, TerracottaState next) {
|
||||
if (next == null) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
|
||||
if (STATE_V.compareAndSet(previous, next)) {
|
||||
STATE_D.accept(next);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OSVersion;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class TerracottaMetadata {
|
||||
private TerracottaMetadata() {
|
||||
}
|
||||
|
||||
public record Link(@SerializedName("desc") LocalizedText description, String link) {
|
||||
}
|
||||
|
||||
private record Config(
|
||||
@SerializedName("version_legacy") String legacy,
|
||||
@SerializedName("version_recent") List<String> recent,
|
||||
@SerializedName("version_latest") String latest,
|
||||
|
||||
@SerializedName("classifiers") Map<String, String> classifiers,
|
||||
@SerializedName("downloads") List<String> downloads,
|
||||
@SerializedName("links") List<Link> links
|
||||
) {
|
||||
private TerracottaNative of(String classifier) {
|
||||
List<URI> links = new ArrayList<>(this.downloads.size());
|
||||
for (String download : this.downloads) {
|
||||
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
|
||||
}
|
||||
|
||||
String hash = Objects.requireNonNull(this.classifiers.get(classifier), String.format("Classifier %s doesn't exist.", classifier));
|
||||
if (!hash.startsWith("sha256:")) {
|
||||
throw new IllegalArgumentException(String.format("Invalid hash value %s for classifier %s.", hash, classifier));
|
||||
}
|
||||
hash = hash.substring("sha256:".length());
|
||||
|
||||
return new TerracottaNative(
|
||||
Collections.unmodifiableList(links),
|
||||
Metadata.DEPENDENCIES_DIRECTORY.resolve(
|
||||
String.format("terracotta/%s/terracotta-%s-%s", this.latest, this.latest, classifier)
|
||||
).toAbsolutePath(),
|
||||
new FileDownloadTask.IntegrityCheck("SHA-256", hash)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static final ITerracottaProvider PROVIDER;
|
||||
public static final String PACKAGE_NAME;
|
||||
private static final List<Link> PACKAGE_LINKS;
|
||||
|
||||
private static final Pattern LEGACY;
|
||||
private static final List<String> RECENT;
|
||||
private static final String LATEST;
|
||||
|
||||
static {
|
||||
Config config;
|
||||
try (InputStream is = TerracottaMetadata.class.getResourceAsStream("/assets/terracotta.json")) {
|
||||
config = JsonUtils.fromNonNullJsonFully(is, Config.class);
|
||||
} catch (IOException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
|
||||
LEGACY = Pattern.compile(config.legacy);
|
||||
RECENT = config.recent;
|
||||
LATEST = config.latest;
|
||||
|
||||
ProviderContext context = locateProvider(config);
|
||||
PROVIDER = context != null ? context.provider() : null;
|
||||
PACKAGE_NAME = context != null ? String.format("terracotta-%s-%s-pkg.tar.gz", config.latest, context.branch) : null;
|
||||
|
||||
if (context != null) {
|
||||
List<Link> packageLinks = new ArrayList<>(config.links.size());
|
||||
for (Link link : config.links) {
|
||||
packageLinks.add(new Link(
|
||||
link.description,
|
||||
link.link.replace("${version}", LATEST)
|
||||
.replace("${classifier}", context.branch)
|
||||
));
|
||||
}
|
||||
|
||||
Collections.shuffle(packageLinks);
|
||||
PACKAGE_LINKS = Collections.unmodifiableList(packageLinks);
|
||||
} else {
|
||||
PACKAGE_LINKS = null;
|
||||
}
|
||||
}
|
||||
|
||||
private record ProviderContext(ITerracottaProvider provider, String branch) {
|
||||
ProviderContext(ITerracottaProvider provider, String system, String arch) {
|
||||
this(provider, system + "-" + arch);
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Link> getPackageLinks() {
|
||||
return PACKAGE_LINKS;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static ProviderContext locateProvider(Config config) {
|
||||
String architecture = switch (Architecture.SYSTEM_ARCH) {
|
||||
case X86_64 -> "x86_64";
|
||||
case ARM64 -> "arm64";
|
||||
default -> null;
|
||||
};
|
||||
if (architecture == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return switch (OperatingSystem.CURRENT_OS) {
|
||||
case WINDOWS -> {
|
||||
if (OperatingSystem.SYSTEM_VERSION.isAtLeast(OSVersion.WINDOWS_8_1)) {
|
||||
yield new ProviderContext(
|
||||
new GeneralProvider(config.of(String.format("windows-%s.exe", architecture))),
|
||||
"windows", architecture
|
||||
);
|
||||
}
|
||||
yield null;
|
||||
}
|
||||
case LINUX -> new ProviderContext(
|
||||
new GeneralProvider(config.of(String.format("linux-%s", architecture))),
|
||||
"linux", architecture
|
||||
);
|
||||
case MACOS -> new ProviderContext(
|
||||
new MacOSProvider(
|
||||
config.of(String.format("macos-%s.pkg", architecture)),
|
||||
config.of(String.format("macos-%s", architecture))
|
||||
),
|
||||
"macos", architecture
|
||||
);
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
public static void removeLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (LATEST.equals(name) || RECENT.contains(name) || !LEGACY.matcher(name).matches()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
FileUtils.deleteDirectory(path);
|
||||
} catch (IOException e) {
|
||||
LOG.warning(String.format("Unable to remove legacy terracotta files: %s", path), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean hasLegacyVersionFiles() throws IOException {
|
||||
try (DirectoryStream<Path> terracotta = Files.newDirectoryStream(Metadata.DEPENDENCIES_DIRECTORY.resolve("terracotta").toAbsolutePath())) {
|
||||
for (Path path : terracotta) {
|
||||
String name = FileUtils.getName(path);
|
||||
if (!LATEST.equals(name) && (RECENT.contains(name) || LEGACY.matcher(name).matches())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CancellationException;
|
||||
|
||||
public final class TerracottaNative {
|
||||
private final List<URI> links;
|
||||
private final FileDownloadTask.IntegrityCheck checking;
|
||||
private final Path path;
|
||||
|
||||
public TerracottaNative(List<URI> links, Path path, FileDownloadTask.IntegrityCheck checking) {
|
||||
this.links = links;
|
||||
this.path = path;
|
||||
this.checking = checking;
|
||||
}
|
||||
|
||||
public Path getPath() {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Task<?> install(ITerracottaProvider.Context context, @Nullable TarFileTree tree) {
|
||||
if (tree == null) {
|
||||
return new FileDownloadTask(links, path, checking) {
|
||||
@Override
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
Context delegate = super.getContext(response, checkETag, bmclapiHash);
|
||||
return new Context() {
|
||||
@Override
|
||||
public void withResult(boolean success) {
|
||||
delegate.withResult(success);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buffer, int offset, int len) throws IOException {
|
||||
if (!context.hasInstallFence()) {
|
||||
throw new CancellationException("User has installed terracotta from local archives.");
|
||||
}
|
||||
delegate.write(buffer, offset, len);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (isSuccess() && !context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
delegate.close();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return Task.runAsync(() -> {
|
||||
String name = FileUtils.getName(path);
|
||||
TarArchiveEntry entry = tree.getRoot().getFiles().get(name);
|
||||
if (entry == null) {
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Cannot exact entry: " + name);
|
||||
}
|
||||
|
||||
if (!context.requestInstallFence()) {
|
||||
throw new CancellationException();
|
||||
}
|
||||
|
||||
Files.createDirectories(path.toAbsolutePath().getParent());
|
||||
|
||||
MessageDigest digest = DigestUtils.getDigest(checking.getAlgorithm());
|
||||
try (
|
||||
InputStream stream = tree.getInputStream(entry);
|
||||
OutputStream os = Files.newOutputStream(path)
|
||||
) {
|
||||
stream.transferTo(new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) throws IOException {
|
||||
os.write(b);
|
||||
digest.update((byte) b);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte @NotNull [] buffer, int offset, int len) throws IOException {
|
||||
os.write(buffer, offset, len);
|
||||
digest.update(buffer, offset, len);
|
||||
}
|
||||
});
|
||||
}
|
||||
String checksum = HexFormat.of().formatHex(digest.digest());
|
||||
if (!checksum.equalsIgnoreCase(checking.getChecksum())) {
|
||||
Files.delete(path);
|
||||
throw new ITerracottaProvider.ArchiveFileMissingException("Incorrect checksum (" + checking.getAlgorithm() + "), expected: " + checking.getChecksum() + ", actual: " + checksum);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ITerracottaProvider.Status status() throws IOException {
|
||||
if (Files.exists(path)) {
|
||||
if (DigestUtils.digestToString(checking.getAlgorithm(), path).equalsIgnoreCase(checking.getChecksum())) {
|
||||
return ITerracottaProvider.Status.READY;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (TerracottaMetadata.hasLegacyVersionFiles()) {
|
||||
return ITerracottaProvider.Status.LEGACY_VERSION;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Logger.LOG.warning("Cannot determine whether legacy versions exist.");
|
||||
}
|
||||
return ITerracottaProvider.Status.NOT_EXIST;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import javafx.beans.property.ReadOnlyDoubleProperty;
|
||||
import javafx.beans.property.ReadOnlyDoubleWrapper;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.util.gson.JsonSubtype;
|
||||
import org.jackhuang.hmcl.util.gson.JsonType;
|
||||
import org.jackhuang.hmcl.util.gson.TolerableValidationException;
|
||||
import org.jackhuang.hmcl.util.gson.Validation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public abstract sealed class TerracottaState {
|
||||
protected TerracottaState() {
|
||||
}
|
||||
|
||||
public boolean isUIFakeState() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final class Bootstrap extends TerracottaState {
|
||||
static final Bootstrap INSTANCE = new Bootstrap();
|
||||
|
||||
private Bootstrap() {
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Uninitialized extends TerracottaState {
|
||||
private final boolean hasLegacy;
|
||||
|
||||
Uninitialized(boolean hasLegacy) {
|
||||
this.hasLegacy = hasLegacy;
|
||||
}
|
||||
|
||||
public boolean hasLegacy() {
|
||||
return hasLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Preparing extends TerracottaState implements ITerracottaProvider.Context {
|
||||
private final ReadOnlyDoubleWrapper progress;
|
||||
|
||||
private final AtomicBoolean installFence = new AtomicBoolean(false);
|
||||
|
||||
Preparing(ReadOnlyDoubleWrapper progress) {
|
||||
this.progress = progress;
|
||||
}
|
||||
|
||||
public ReadOnlyDoubleProperty progressProperty() {
|
||||
return progress.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void bindProgress(ObservableValue<? extends Number> value) {
|
||||
progress.bind(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean requestInstallFence() {
|
||||
return installFence.compareAndSet(false, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasInstallFence() {
|
||||
return !installFence.get();
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Launching extends TerracottaState {
|
||||
Launching() {
|
||||
}
|
||||
}
|
||||
|
||||
static abstract sealed class PortSpecific extends TerracottaState {
|
||||
transient int port;
|
||||
|
||||
protected PortSpecific(int port) {
|
||||
this.port = port;
|
||||
}
|
||||
}
|
||||
|
||||
@JsonType(
|
||||
property = "state",
|
||||
subtypes = {
|
||||
@JsonSubtype(clazz = Waiting.class, name = "waiting"),
|
||||
@JsonSubtype(clazz = HostScanning.class, name = "host-scanning"),
|
||||
@JsonSubtype(clazz = HostStarting.class, name = "host-starting"),
|
||||
@JsonSubtype(clazz = HostOK.class, name = "host-ok"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-connecting"),
|
||||
@JsonSubtype(clazz = GuestStarting.class, name = "guest-starting"),
|
||||
@JsonSubtype(clazz = GuestOK.class, name = "guest-ok"),
|
||||
@JsonSubtype(clazz = Exception.class, name = "exception"),
|
||||
}
|
||||
)
|
||||
static abstract sealed class Ready extends PortSpecific {
|
||||
@SerializedName("index")
|
||||
final int index;
|
||||
|
||||
@SerializedName("state")
|
||||
private final String state;
|
||||
|
||||
Ready(int port, int index, String state) {
|
||||
super(port);
|
||||
this.index = index;
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUIFakeState() {
|
||||
return this.index == -1;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Unknown extends PortSpecific {
|
||||
Unknown(int port) {
|
||||
super(port);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Waiting extends Ready {
|
||||
Waiting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostScanning extends Ready {
|
||||
HostScanning(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostStarting extends Ready {
|
||||
HostStarting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class HostOK extends Ready implements Validation {
|
||||
@SerializedName("room")
|
||||
private final String code;
|
||||
|
||||
@SerializedName("profile_index")
|
||||
private final int profileIndex;
|
||||
|
||||
@SerializedName("profiles")
|
||||
private final List<TerracottaProfile> profiles;
|
||||
|
||||
HostOK(int port, int index, String state, String code, int profileIndex, List<TerracottaProfile> profiles) {
|
||||
super(port, index, state);
|
||||
this.code = code;
|
||||
this.profileIndex = profileIndex;
|
||||
this.profiles = profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (code == null) {
|
||||
throw new JsonParseException("code is null");
|
||||
}
|
||||
if (profiles == null) {
|
||||
throw new JsonParseException("profiles is null");
|
||||
}
|
||||
}
|
||||
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public List<TerracottaProfile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return state instanceof HostOK hostOK && this.index - hostOK.index <= profileIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestStarting extends Ready {
|
||||
GuestStarting(int port, int index, String state) {
|
||||
super(port, index, state);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class GuestOK extends Ready implements Validation {
|
||||
@SerializedName("url")
|
||||
private final String url;
|
||||
|
||||
@SerializedName("profile_index")
|
||||
private final int profileIndex;
|
||||
|
||||
@SerializedName("profiles")
|
||||
private final List<TerracottaProfile> profiles;
|
||||
|
||||
GuestOK(int port, int index, String state, String url, int profileIndex, List<TerracottaProfile> profiles) {
|
||||
super(port, index, state);
|
||||
this.url = url;
|
||||
this.profileIndex = profileIndex;
|
||||
this.profiles = profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (profiles == null) {
|
||||
throw new JsonParseException("profiles is null");
|
||||
}
|
||||
}
|
||||
|
||||
public String getUrl() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public List<TerracottaProfile> getProfiles() {
|
||||
return profiles;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isForkOf(TerracottaState state) {
|
||||
return state instanceof GuestOK guestOK && this.index - guestOK.index <= profileIndex;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Exception extends Ready implements Validation {
|
||||
public enum Type {
|
||||
PING_HOST_FAIL,
|
||||
PING_HOST_RST,
|
||||
GUEST_ET_CRASH,
|
||||
HOST_ET_CRASH,
|
||||
PING_SERVER_RST,
|
||||
SCAFFOLDING_INVALID_RESPONSE
|
||||
}
|
||||
|
||||
private static final TerracottaState.Exception.Type[] LOOKUP = Type.values();
|
||||
|
||||
@SerializedName("type")
|
||||
private final int type;
|
||||
|
||||
Exception(int port, int index, String state, int type) {
|
||||
super(port, index, state);
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validate() throws JsonParseException, TolerableValidationException {
|
||||
if (type < 0 || type >= LOOKUP.length) {
|
||||
throw new JsonParseException(String.format("Type must between [0, %s)", LOOKUP.length));
|
||||
}
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return LOOKUP[type];
|
||||
}
|
||||
}
|
||||
|
||||
public static final class Fatal extends TerracottaState {
|
||||
public enum Type {
|
||||
OS,
|
||||
NETWORK,
|
||||
INSTALL,
|
||||
TERRACOTTA,
|
||||
UNKNOWN;
|
||||
}
|
||||
|
||||
private final Type type;
|
||||
|
||||
public Fatal(Type type) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public Type getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isRecoverable() {
|
||||
return this.type != Type.OS && this.type != Type.UNKNOWN;
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-28
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2020 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
* 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
|
||||
@@ -15,33 +15,15 @@
|
||||
* 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.event;
|
||||
package org.jackhuang.hmcl.terracotta.profile;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huang
|
||||
*/
|
||||
public class FailedEvent<T> extends Event {
|
||||
|
||||
private final int failedTime;
|
||||
private T newResult;
|
||||
|
||||
public FailedEvent(Object source, int failedTime, T newResult) {
|
||||
super(source);
|
||||
this.failedTime = failedTime;
|
||||
this.newResult = newResult;
|
||||
}
|
||||
|
||||
public int getFailedTime() {
|
||||
return failedTime;
|
||||
}
|
||||
|
||||
public T getNewResult() {
|
||||
return newResult;
|
||||
}
|
||||
|
||||
public void setNewResult(T newResult) {
|
||||
this.newResult = newResult;
|
||||
}
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public enum ProfileKind {
|
||||
@SerializedName("HOST")
|
||||
HOST,
|
||||
@SerializedName("LOCAL")
|
||||
LOCAL,
|
||||
@SerializedName("GUEST")
|
||||
GUEST
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.terracotta.profile;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
public final class TerracottaProfile {
|
||||
@SerializedName("machine_id")
|
||||
private final String machineID;
|
||||
|
||||
@SerializedName("name")
|
||||
private final String name;
|
||||
|
||||
@SerializedName("vendor")
|
||||
private final String vendor;
|
||||
|
||||
@SerializedName("kind")
|
||||
private final ProfileKind type;
|
||||
|
||||
private TerracottaProfile(String machineID, String name, String vendor, ProfileKind type) {
|
||||
this.machineID = machineID;
|
||||
this.name = name;
|
||||
this.vendor = vendor;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public String getMachineID() {
|
||||
return machineID;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public String getVendor() {
|
||||
return vendor;
|
||||
}
|
||||
|
||||
public ProfileKind getType() {
|
||||
return type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GeneralProvider implements ITerracottaProvider {
|
||||
private final TerracottaNative target;
|
||||
|
||||
public GeneralProvider(TerracottaNative target) {
|
||||
this.target = target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
return target.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
Task<?> task = target.install(context, tree);
|
||||
context.bindProgress(task.progressProperty());
|
||||
if (OperatingSystem.CURRENT_OS.isLinuxOrBSD()) {
|
||||
task = task.thenRunAsync(() -> Files.setPosixFilePermissions(target.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)));
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
return List.of(target.getPath().toString(), "--hmcl", path.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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.terracotta.provider;
|
||||
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
public interface ITerracottaProvider {
|
||||
enum Status {
|
||||
NOT_EXIST,
|
||||
LEGACY_VERSION,
|
||||
READY
|
||||
}
|
||||
|
||||
interface Context {
|
||||
void bindProgress(ObservableValue<? extends Number> value);
|
||||
|
||||
boolean requestInstallFence();
|
||||
|
||||
boolean hasInstallFence();
|
||||
}
|
||||
|
||||
abstract class ProviderException extends IOException {
|
||||
public ProviderException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ProviderException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ProviderException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
final class ArchiveFileMissingException extends ProviderException {
|
||||
public ArchiveFileMissingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public ArchiveFileMissingException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
Status status() throws IOException;
|
||||
|
||||
Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException;
|
||||
|
||||
List<String> ofCommandLine(Path path);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* 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.terracotta.provider;
|
||||
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaNative;
|
||||
import org.jackhuang.hmcl.util.platform.ManagedProcess;
|
||||
import org.jackhuang.hmcl.util.platform.SystemUtils;
|
||||
import org.jackhuang.hmcl.util.tree.TarFileTree;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public final class MacOSProvider implements ITerracottaProvider {
|
||||
public final TerracottaNative installer, binary;
|
||||
|
||||
public MacOSProvider(TerracottaNative installer, TerracottaNative binary) {
|
||||
this.installer = installer;
|
||||
this.binary = binary;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Status status() throws IOException {
|
||||
assert binary != null;
|
||||
|
||||
if (!Files.exists(Path.of("/Applications/terracotta.app"))) {
|
||||
return Status.NOT_EXIST;
|
||||
}
|
||||
|
||||
return binary.status();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Task<?> install(Context context, @Nullable TarFileTree tree) throws IOException {
|
||||
assert installer != null && binary != null;
|
||||
|
||||
Task<?> installerTask = installer.install(context, tree);
|
||||
Task<?> binaryTask = binary.install(context, tree);
|
||||
context.bindProgress(installerTask.progressProperty().add(binaryTask.progressProperty()).multiply(0.4)); // (1 + 1) * 0.4 = 0.8
|
||||
|
||||
return Task.allOf(
|
||||
installerTask.thenComposeAsync(() -> {
|
||||
ManagedProcess process = new ManagedProcess(new ProcessBuilder(
|
||||
"osascript",
|
||||
"-e",
|
||||
String.format(
|
||||
"do shell script \"installer -pkg %s -target /Applications\" with prompt \"%s\" with administrator privileges",
|
||||
installer.getPath(),
|
||||
i18n("terracotta.sudo_installing")
|
||||
)
|
||||
));
|
||||
process.pumpInputStream(SystemUtils::onLogLine);
|
||||
process.pumpErrorStream(SystemUtils::onLogLine);
|
||||
|
||||
return Task.fromCompletableFuture(process.getProcess().onExit());
|
||||
}),
|
||||
binaryTask.thenRunAsync(() -> Files.setPosixFilePermissions(binary.getPath(), Set.of(
|
||||
PosixFilePermission.OWNER_READ,
|
||||
PosixFilePermission.OWNER_WRITE,
|
||||
PosixFilePermission.OWNER_EXECUTE,
|
||||
PosixFilePermission.GROUP_READ,
|
||||
PosixFilePermission.GROUP_EXECUTE,
|
||||
PosixFilePermission.OTHERS_READ,
|
||||
PosixFilePermission.OTHERS_EXECUTE
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> ofCommandLine(Path path) {
|
||||
assert binary != null;
|
||||
|
||||
return List.of(binary.getPath().toString(), "--hmcl", path.toString());
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import javafx.stage.StageStyle;
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Launcher;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.game.ModpackHelper;
|
||||
import org.jackhuang.hmcl.java.JavaManager;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
@@ -55,8 +56,10 @@ import org.jackhuang.hmcl.ui.download.DownloadPage;
|
||||
import org.jackhuang.hmcl.ui.download.ModpackInstallWizardProvider;
|
||||
import org.jackhuang.hmcl.ui.main.LauncherSettingsPage;
|
||||
import org.jackhuang.hmcl.ui.main.RootPage;
|
||||
import org.jackhuang.hmcl.ui.terracotta.TerracottaPage;
|
||||
import org.jackhuang.hmcl.ui.versions.GameListPage;
|
||||
import org.jackhuang.hmcl.ui.versions.VersionPage;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
@@ -108,6 +111,7 @@ public final class Controllers {
|
||||
return accountListPage;
|
||||
});
|
||||
private static Lazy<LauncherSettingsPage> settingsPage = new Lazy<>(LauncherSettingsPage::new);
|
||||
private static Lazy<TerracottaPage> terracottaPage = new Lazy<>(TerracottaPage::new);
|
||||
|
||||
private Controllers() {
|
||||
}
|
||||
@@ -150,6 +154,11 @@ public final class Controllers {
|
||||
return downloadPage.get();
|
||||
}
|
||||
|
||||
// FXThread
|
||||
public static Node getTerracottaPage() {
|
||||
return terracottaPage.get();
|
||||
}
|
||||
|
||||
// FXThread
|
||||
public static DecoratorController getDecorator() {
|
||||
return decorator;
|
||||
@@ -424,6 +433,30 @@ public final class Controllers {
|
||||
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
||||
}
|
||||
|
||||
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> resolve.run());
|
||||
btnYes.setDisable(true);
|
||||
|
||||
int countdown = 10;
|
||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||
for (int i = 0; i < countdown; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||
}
|
||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnYes.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
confirmAction(text, title, MessageType.WARNING, btnYes, () -> {
|
||||
timeline.stop();
|
||||
cancel.run();
|
||||
});
|
||||
timeline.play();
|
||||
}
|
||||
|
||||
public static CompletableFuture<String> prompt(String title, FutureCallback<String> onResult) {
|
||||
return prompt(title, onResult, "");
|
||||
}
|
||||
@@ -470,6 +503,10 @@ public final class Controllers {
|
||||
Controllers.getSettingsPage().showFeedback();
|
||||
Controllers.navigate(Controllers.getSettingsPage());
|
||||
break;
|
||||
case "hmcl://game/launch":
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
FXUtils.openLink(href);
|
||||
|
||||
@@ -93,10 +93,7 @@ import java.nio.file.PathMatcher;
|
||||
import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -1218,7 +1215,6 @@ public final class FXUtils {
|
||||
public static JFXButton newBorderButton(String text) {
|
||||
JFXButton button = new JFXButton(text);
|
||||
button.getStyleClass().add("jfx-button-border");
|
||||
button.setButtonType(JFXButton.ButtonType.RAISED);
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -1370,6 +1366,27 @@ public final class FXUtils {
|
||||
});
|
||||
}
|
||||
|
||||
public static <T> void onScroll(Node node, List<T> list,
|
||||
ToIntFunction<List<T>> finder,
|
||||
Consumer<T> updater
|
||||
) {
|
||||
node.addEventHandler(ScrollEvent.SCROLL, event -> {
|
||||
double deltaY = event.getDeltaY();
|
||||
if (deltaY == 0)
|
||||
return;
|
||||
|
||||
int index = finder.applyAsInt(list);
|
||||
if (index < 0) return;
|
||||
if (deltaY > 0) // up
|
||||
index--;
|
||||
else // down
|
||||
index++;
|
||||
|
||||
updater.accept(list.get((index + list.size()) % list.size()));
|
||||
event.consume();
|
||||
});
|
||||
}
|
||||
|
||||
public static void copyOnDoubleClick(Labeled label) {
|
||||
label.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY && e.getClickCount() == 2) {
|
||||
|
||||
@@ -64,11 +64,9 @@ public class ListPageSkin extends SkinBase<ListPage<?>> {
|
||||
vBox.setAlignment(Pos.BOTTOM_RIGHT);
|
||||
vBox.setPickOnBounds(false);
|
||||
|
||||
JFXButton btnAdd = new JFXButton();
|
||||
JFXButton btnAdd = FXUtils.newRaisedButton("");
|
||||
FXUtils.setLimitWidth(btnAdd, 40);
|
||||
FXUtils.setLimitHeight(btnAdd, 40);
|
||||
btnAdd.getStyleClass().add("jfx-button-raised-round");
|
||||
btnAdd.setButtonType(JFXButton.ButtonType.RAISED);
|
||||
btnAdd.setGraphic(SVG.ADD.createIcon(Theme.whiteFill(), -1));
|
||||
btnAdd.setOnAction(e -> skinnable.add());
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import javafx.stage.Stage;
|
||||
import org.jackhuang.hmcl.game.GameDumpGenerator;
|
||||
import org.jackhuang.hmcl.game.Log;
|
||||
import org.jackhuang.hmcl.setting.StyleSheets;
|
||||
import org.jackhuang.hmcl.ui.construct.NoneMultipleSelectionModel;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.CircularArrayList;
|
||||
@@ -311,6 +312,7 @@ public final class LogWindow extends Stage {
|
||||
Holder<Object> lastCell = new Holder<>();
|
||||
listView.setCellFactory(x -> new ListCell<Log>() {
|
||||
{
|
||||
x.setSelectionModel(new NoneMultipleSelectionModel<>());
|
||||
getStyleClass().add("log-window-list-cell");
|
||||
Region clippedContainer = (Region) listView.lookup(".clipped-container");
|
||||
if (clippedContainer != null) {
|
||||
|
||||
@@ -48,11 +48,22 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
setBody(new ProgressIndicator());
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
|
||||
boolean isPreview = remoteVersion.isPreview();
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
Document document = Jsoup.parse(new URL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("#nowchange");
|
||||
String id = null;
|
||||
Node node = null;
|
||||
if (isPreview) {
|
||||
id = "nowpreview";
|
||||
node = document.selectFirst("#" + id);
|
||||
}
|
||||
if (node == null) {
|
||||
id = "nowchange";
|
||||
node = document.selectFirst("#" + id);
|
||||
}
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName()))
|
||||
throw new IOException("Cannot find #nowchange in document");
|
||||
throw new IOException("Cannot find current changelog in document");
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
@@ -60,7 +71,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
});
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName()) && !"nowchange".equals(node.attr("id"))) {
|
||||
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
|
||||
break;
|
||||
}
|
||||
renderer.appendNode(node);
|
||||
|
||||
@@ -21,7 +21,6 @@ import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.canvas.Canvas;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import org.jackhuang.hmcl.auth.Account;
|
||||
@@ -77,25 +76,9 @@ public class AccountAdvancedListItem extends AdvancedListItem {
|
||||
|
||||
setActionButtonVisible(false);
|
||||
|
||||
setOnScroll(event -> {
|
||||
double deltaY = event.getDeltaY();
|
||||
if (deltaY == 0)
|
||||
return;
|
||||
|
||||
Account current = account.get();
|
||||
if (current == null) return;
|
||||
|
||||
ObservableList<Account> accounts = Accounts.getAccounts();
|
||||
int currentIndex = accounts.indexOf(current);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
if (deltaY > 0) // up
|
||||
currentIndex--;
|
||||
else // down
|
||||
currentIndex++;
|
||||
|
||||
Accounts.setSelectedAccount(accounts.get((currentIndex + accounts.size()) % accounts.size()));
|
||||
});
|
||||
FXUtils.onScroll(this, Accounts.getAccounts(),
|
||||
accounts -> accounts.indexOf(account.get()),
|
||||
Accounts::setSelectedAccount);
|
||||
}
|
||||
|
||||
public ObjectProperty<Account> accountProperty() {
|
||||
|
||||
@@ -19,9 +19,6 @@ package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import com.jfoenix.controls.*;
|
||||
import com.jfoenix.validation.base.ValidatorBase;
|
||||
import javafx.animation.KeyFrame;
|
||||
import javafx.animation.KeyValue;
|
||||
import javafx.animation.Timeline;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.NamedArg;
|
||||
import javafx.beans.binding.BooleanBinding;
|
||||
@@ -39,7 +36,6 @@ import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextInputControl;
|
||||
import javafx.scene.layout.*;
|
||||
|
||||
import javafx.util.Duration;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.auth.AccountFactory;
|
||||
import org.jackhuang.hmcl.auth.CharacterSelector;
|
||||
@@ -268,33 +264,10 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
};
|
||||
|
||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> doCreate.run());
|
||||
btnYes.setDisable(true);
|
||||
|
||||
int countdown = 10;
|
||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||
for (int i = 0; i < countdown; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||
}
|
||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnYes.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
Controllers.confirmAction(
|
||||
i18n("account.methods.offline.name.invalid"), i18n("message.warning"),
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
btnYes,
|
||||
() -> {
|
||||
timeline.stop();
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
}
|
||||
);
|
||||
timeline.play();
|
||||
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
});
|
||||
} else {
|
||||
doCreate.run();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.ui.construct;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.Pane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -40,8 +41,16 @@ public class AdvancedListBox extends ScrollPane {
|
||||
setFitToHeight(true);
|
||||
setFitToWidth(true);
|
||||
setHbarPolicy(ScrollBarPolicy.NEVER);
|
||||
setVbarPolicy(ScrollBarPolicy.NEVER);
|
||||
|
||||
container.getStyleClass().add("advanced-list-box-content");
|
||||
|
||||
this.addEventFilter(MouseEvent.MOUSE_ENTERED, event -> {
|
||||
if (container.getHeight() > getHeight())
|
||||
setVbarPolicy(ScrollBarPolicy.AS_NEEDED);
|
||||
});
|
||||
this.addEventFilter(MouseEvent.MOUSE_EXITED,
|
||||
event -> setVbarPolicy(ScrollBarPolicy.NEVER));
|
||||
}
|
||||
|
||||
public AdvancedListBox add(Node child) {
|
||||
|
||||
@@ -73,8 +73,7 @@ final class ComponentListCell extends StackPane {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void updateLayout() {
|
||||
if (content instanceof ComponentList) {
|
||||
ComponentList list = (ComponentList) content;
|
||||
if (content instanceof ComponentList list) {
|
||||
content.getStyleClass().remove("options-list");
|
||||
content.getStyleClass().add("options-sublist");
|
||||
|
||||
@@ -130,7 +129,10 @@ final class ComponentListCell extends StackPane {
|
||||
groupNode.getChildren().add(headerRippler);
|
||||
|
||||
VBox container = new VBox();
|
||||
container.setPadding(new Insets(8, 16, 10, 16));
|
||||
boolean hasPadding = !(list instanceof ComponentSublist subList) || subList.hasComponentPadding();
|
||||
if (hasPadding) {
|
||||
container.setPadding(new Insets(8, 16, 10, 16));
|
||||
}
|
||||
FXUtils.setLimitHeight(container, 0);
|
||||
FXUtils.setOverflowHidden(container);
|
||||
container.getChildren().setAll(content);
|
||||
@@ -149,7 +151,8 @@ final class ComponentListCell extends StackPane {
|
||||
}
|
||||
|
||||
Platform.runLater(() -> {
|
||||
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + 8 + 10) * (expanded ? 1 : -1);
|
||||
// FIXME: ComponentSubList without padding must have a 4 pixel padding for displaying a border radius.
|
||||
double newAnimatedHeight = (list.prefHeight(list.getWidth()) + (hasPadding ? 8 + 10 : 4)) * (expanded ? 1 : -1);
|
||||
double newHeight = expanded ? getHeight() + newAnimatedHeight : prefHeight(list.getWidth());
|
||||
double contentHeight = expanded ? newAnimatedHeight : 0;
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
package org.jackhuang.hmcl.ui.construct;
|
||||
|
||||
import javafx.beans.DefaultProperty;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.scene.Node;
|
||||
|
||||
@@ -27,6 +29,7 @@ public class ComponentSublist extends ComponentList {
|
||||
|
||||
private final ObjectProperty<Node> headerLeft = new SimpleObjectProperty<>(this, "headerLeft");
|
||||
private final ObjectProperty<Node> headerRight = new SimpleObjectProperty<>(this, "headerRight");
|
||||
private final BooleanProperty componentPadding = new SimpleBooleanProperty(this, "componentPadding", true);
|
||||
|
||||
public ComponentSublist() {
|
||||
super();
|
||||
@@ -55,4 +58,16 @@ public class ComponentSublist extends ComponentList {
|
||||
public void setHeaderRight(Node headerRight) {
|
||||
this.headerRight.set(headerRight);
|
||||
}
|
||||
|
||||
public boolean hasComponentPadding() {
|
||||
return componentPadding.get();
|
||||
}
|
||||
|
||||
public BooleanProperty componentPaddingProperty() {
|
||||
return componentPadding;
|
||||
}
|
||||
|
||||
public void setComponentPadding(boolean componentPadding) {
|
||||
this.componentPadding.set(componentPadding);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.input.MouseButton;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -61,7 +63,12 @@ public class OptionToggleButton extends StackPane {
|
||||
toggleButton.setSize(8);
|
||||
FXUtils.setLimitHeight(toggleButton, 30);
|
||||
|
||||
FXUtils.onClicked(container, () -> toggleButton.setSelected(!toggleButton.isSelected()));
|
||||
container.addEventHandler(MouseEvent.MOUSE_CLICKED, e -> {
|
||||
if (e.getButton() == MouseButton.PRIMARY) {
|
||||
toggleButton.setSelected(!toggleButton.isSelected());
|
||||
e.consume();
|
||||
}
|
||||
});
|
||||
|
||||
FXUtils.onChangeAndOperate(subtitleProperty(), subtitle -> {
|
||||
if (StringUtils.isNotBlank(subtitle)) {
|
||||
|
||||
@@ -191,7 +191,7 @@ public class RipplerContainer extends StackPane {
|
||||
return StyleableProperties.FACTORY.getCssMetaData();
|
||||
}
|
||||
|
||||
private static class StyleableProperties {
|
||||
private final static class StyleableProperties {
|
||||
private static final StyleablePropertyFactory<RipplerContainer> FACTORY = new StyleablePropertyFactory<>(StackPane.getClassCssMetaData());
|
||||
|
||||
private static final CssMetaData<RipplerContainer, Paint> RIPPLER_FILL = FACTORY.createPaintCssMetaData("-jfx-rippler-fill", s -> s.ripplerFill, Color.rgb(0, 200, 255));
|
||||
|
||||
@@ -225,7 +225,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
|
||||
}
|
||||
}
|
||||
|
||||
private class HeadersRegion extends StackPane {
|
||||
private final class HeadersRegion extends StackPane {
|
||||
private SideAction action;
|
||||
private final ObjectProperty<Side> side = new SimpleObjectProperty<Side>() {
|
||||
@Override
|
||||
@@ -379,7 +379,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
|
||||
}
|
||||
}
|
||||
|
||||
private class Top extends Horizontal {
|
||||
private final class Top extends Horizontal {
|
||||
@Override
|
||||
public void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
@@ -404,7 +404,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
|
||||
}
|
||||
}
|
||||
|
||||
private class Bottom extends Horizontal {
|
||||
private final class Bottom extends Horizontal {
|
||||
@Override
|
||||
public void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
@@ -519,7 +519,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
|
||||
}
|
||||
}
|
||||
|
||||
private class Left extends Vertical {
|
||||
private final class Left extends Vertical {
|
||||
@Override
|
||||
public void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
@@ -543,7 +543,7 @@ public class TabHeader extends Control implements TabControl, PageAware {
|
||||
}
|
||||
}
|
||||
|
||||
private class Right extends Vertical {
|
||||
private final class Right extends Vertical {
|
||||
@Override
|
||||
public void layoutChildren() {
|
||||
super.layoutChildren();
|
||||
|
||||
@@ -40,6 +40,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
public class TaskExecutorDialogPane extends BorderPane {
|
||||
private TaskExecutor executor;
|
||||
private TaskCancellationAction onCancel;
|
||||
@SuppressWarnings({"unused", "FieldCanBeLocal"})
|
||||
private final Consumer<FetchTask.SpeedEvent> speedEventHandler;
|
||||
|
||||
private final Label lblTitle;
|
||||
@@ -84,7 +85,7 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
}
|
||||
});
|
||||
|
||||
speedEventHandler = speedEvent -> {
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
String unit = "B/s";
|
||||
double speed = speedEvent.getSpeed();
|
||||
if (speed > 1024) {
|
||||
@@ -95,11 +96,10 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
speed /= 1024;
|
||||
unit = "MiB/s";
|
||||
}
|
||||
double finalSpeed = speed;
|
||||
String finalUnit = unit;
|
||||
Platform.runLater(() -> lblProgress.setText(String.format("%.1f %s", finalSpeed, finalUnit)));
|
||||
};
|
||||
FileDownloadTask.speedEvent.channel(FetchTask.SpeedEvent.class).registerWeak(speedEventHandler);
|
||||
|
||||
String message = String.format("%.1f %s", speed, unit);
|
||||
Platform.runLater(() -> lblProgress.setText(message));
|
||||
});
|
||||
|
||||
onEscPressed(this, btnCancel::fire);
|
||||
}
|
||||
|
||||
@@ -82,8 +82,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
|
||||
|
||||
BorderPane descriptionPane = new BorderPane();
|
||||
{
|
||||
btnDescription = new JFXButton(i18n("modpack.description"));
|
||||
btnDescription.getStyleClass().add("jfx-button-border");
|
||||
btnDescription = FXUtils.newBorderButton(i18n("modpack.description"));
|
||||
btnDescription.setOnAction(e -> onDescribe());
|
||||
descriptionPane.setLeft(btnDescription);
|
||||
|
||||
|
||||
+2
-2
@@ -287,7 +287,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
|
||||
* the mark position becomes invalid.
|
||||
* @see InputStream#reset()
|
||||
*/
|
||||
public synchronized void mark(int readlimit) {
|
||||
public void mark(int readlimit) {
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -302,7 +302,7 @@ public class PartialInflaterInputStream extends FilterInputStream {
|
||||
* @see InputStream#mark(int)
|
||||
* @see IOException
|
||||
*/
|
||||
public synchronized void reset() throws IOException {
|
||||
public void reset() throws IOException {
|
||||
throw new IOException("mark/reset not supported");
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,7 @@ import org.jackhuang.hmcl.util.Holder;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
|
||||
@@ -115,6 +116,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
|
||||
ImageView titleIcon = new ImageView(FXUtils.newBuiltinImage("/assets/img/icon-title.png"));
|
||||
Label titleLabel = new Label(Metadata.FULL_TITLE);
|
||||
if (I18n.isUpsideDown()) {
|
||||
titleIcon.setRotate(180);
|
||||
titleLabel.setRotate(180);
|
||||
}
|
||||
titleLabel.getStyleClass().add("jfx-decorator-title");
|
||||
titleNode.getChildren().setAll(titleIcon, titleLabel);
|
||||
|
||||
@@ -208,20 +213,11 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
launchPane.getStyleClass().add("launch-pane");
|
||||
launchPane.setMaxWidth(230);
|
||||
launchPane.setMaxHeight(55);
|
||||
launchPane.setOnScroll(event -> {
|
||||
double deltaY = event.getDeltaY();
|
||||
if (deltaY == 0)
|
||||
return;
|
||||
|
||||
FXUtils.onScroll(launchPane, versions, list -> {
|
||||
String currentId = getCurrentGame();
|
||||
int index = Lang.indexWhere(versions, instance -> instance.getId().equals(currentId));
|
||||
if (index < 0) return;
|
||||
if (deltaY > 0) // up
|
||||
index--;
|
||||
else // down
|
||||
index++;
|
||||
profile.setSelectedVersion(versions.get((index + versions.size()) % versions.size()).getId());
|
||||
});
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, it -> profile.setSelectedVersion(it.getId()));
|
||||
|
||||
StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT);
|
||||
{
|
||||
JFXButton launchButton = new JFXButton();
|
||||
@@ -435,6 +431,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Profile getProfile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
public String getCurrentGame() {
|
||||
return currentGame.get();
|
||||
}
|
||||
@@ -447,6 +447,10 @@ public final class MainPage extends StackPane implements DecoratorPage {
|
||||
this.currentGame.set(currentGame);
|
||||
}
|
||||
|
||||
public ObservableList<Version> getVersions() {
|
||||
return versions;
|
||||
}
|
||||
|
||||
public boolean isShowUpdate() {
|
||||
return showUpdate.get();
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ public class PersonalizationPage extends StackPane {
|
||||
}
|
||||
|
||||
Label textOpacity = new Label();
|
||||
FXUtils.setLimitWidth(textOpacity, 35);
|
||||
|
||||
StringBinding valueBinding = Bindings.createStringBinding(() -> ((int) slider.getValue()) + "%", slider.valueProperty());
|
||||
textOpacity.textProperty().bind(valueBinding);
|
||||
|
||||
@@ -154,6 +154,10 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
Versions.modifyGameSettings(profile, version);
|
||||
}
|
||||
});
|
||||
FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> {
|
||||
String currentId = getSkinnable().getMainPage().getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, it -> getSkinnable().getMainPage().getProfile().setSelectedVersion(it.getId()));
|
||||
|
||||
// third item in left sidebar
|
||||
AdvancedListItem gameItem = new AdvancedListItem();
|
||||
@@ -178,11 +182,11 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
launcherSettingsItem.setOnAction(e -> Controllers.navigate(Controllers.getSettingsPage()));
|
||||
|
||||
// sixth item in left sidebar
|
||||
AdvancedListItem chatItem = new AdvancedListItem();
|
||||
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
||||
chatItem.setActionButtonVisible(false);
|
||||
chatItem.setTitle(i18n("chat"));
|
||||
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
AdvancedListItem terracottaItem = new AdvancedListItem();
|
||||
terracottaItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
terracottaItem.setActionButtonVisible(false);
|
||||
terracottaItem.setTitle(i18n("terracotta"));
|
||||
terracottaItem.setOnAction(e -> Controllers.navigate(Controllers.getTerracottaPage()));
|
||||
|
||||
// the left sidebar
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
@@ -194,8 +198,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
.add(downloadItem)
|
||||
.startCategory(i18n("settings.launcher.general").toUpperCase(Locale.ROOT))
|
||||
.add(launcherSettingsItem)
|
||||
.add(chatItem)
|
||||
;
|
||||
.add(terracottaItem);
|
||||
|
||||
// the root page, with the sidebar in left, navigator in center.
|
||||
setLeft(sideBar);
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.tukaani.xz.XZInputStream;
|
||||
@@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView {
|
||||
FXUtils.smoothScrolling(scroll);
|
||||
|
||||
// ==== Languages ====
|
||||
cboLanguage.getItems().setAll(Locales.LOCALES);
|
||||
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
|
||||
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
|
||||
|
||||
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
|
||||
@@ -114,9 +114,12 @@ public final class SettingsPage extends SettingsView {
|
||||
chkUpdateStable.setUserData(UpdateChannel.STABLE);
|
||||
ObjectProperty<UpdateChannel> updateChannel = selectedItemPropertyFor(updateChannelGroup, UpdateChannel.class);
|
||||
updateChannel.set(UpdateChannel.getChannel());
|
||||
updateChannel.addListener((a, b, newValue) -> {
|
||||
UpdateChecker.requestCheckUpdate(newValue);
|
||||
});
|
||||
|
||||
InvalidationListener checkUpdateListener = e -> {
|
||||
UpdateChecker.requestCheckUpdate(updateChannel.get(), previewPane.isSelected());
|
||||
};
|
||||
updateChannel.addListener(checkUpdateListener);
|
||||
previewPane.selectedProperty().addListener(checkUpdateListener);
|
||||
// ====
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
|
||||
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
@@ -58,6 +58,7 @@ public abstract class SettingsView extends StackPane {
|
||||
protected final JFXRadioButton chkUpdateStable;
|
||||
protected final JFXRadioButton chkUpdateDev;
|
||||
protected final JFXButton btnUpdate;
|
||||
protected final OptionToggleButton previewPane;
|
||||
protected final ScrollPane scroll;
|
||||
|
||||
public SettingsView() {
|
||||
@@ -147,6 +148,14 @@ public abstract class SettingsView extends StackPane {
|
||||
settingsPane.getContent().add(updatePane);
|
||||
}
|
||||
|
||||
{
|
||||
previewPane = new OptionToggleButton();
|
||||
previewPane.setTitle(i18n("update.preview"));
|
||||
previewPane.selectedProperty().bindBidirectional(config().acceptPreviewUpdateProperty());
|
||||
|
||||
settingsPane.getContent().add(previewPane);
|
||||
}
|
||||
|
||||
{
|
||||
fileCommonLocation = new MultiFileItem<>();
|
||||
fileCommonLocationSublist = new ComponentSublist();
|
||||
@@ -162,9 +171,8 @@ public abstract class SettingsView extends StackPane {
|
||||
));
|
||||
|
||||
{
|
||||
JFXButton cleanButton = new JFXButton(i18n("launcher.cache_directory.clean"));
|
||||
JFXButton cleanButton = FXUtils.newBorderButton(i18n("launcher.cache_directory.clean"));
|
||||
cleanButton.setOnAction(e -> clearCacheDirectory());
|
||||
cleanButton.getStyleClass().add("jfx-button-border");
|
||||
|
||||
fileCommonLocationSublist.setHeaderRight(cleanButton);
|
||||
}
|
||||
@@ -216,9 +224,8 @@ public abstract class SettingsView extends StackPane {
|
||||
if (LOG.getLogFile() == null)
|
||||
openLogFolderButton.setDisable(true);
|
||||
|
||||
JFXButton logButton = new JFXButton(i18n("settings.launcher.launcher_log.export"));
|
||||
JFXButton logButton = FXUtils.newBorderButton(i18n("settings.launcher.launcher_log.export"));
|
||||
logButton.setOnAction(e -> onExportLogs());
|
||||
logButton.getStyleClass().add("jfx-button-border");
|
||||
|
||||
HBox buttonBox = new HBox();
|
||||
buttonBox.setSpacing(10);
|
||||
|
||||
@@ -27,6 +27,7 @@ import javafx.scene.layout.HBox;
|
||||
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.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.PageCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
@@ -68,9 +69,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
|
||||
actions.setPadding(new Insets(8));
|
||||
actions.setAlignment(Pos.CENTER_RIGHT);
|
||||
|
||||
JFXButton saveButton = new JFXButton(i18n("button.save"));
|
||||
saveButton.getStyleClass().add("jfx-button-raised");
|
||||
saveButton.setButtonType(JFXButton.ButtonType.RAISED);
|
||||
JFXButton saveButton = FXUtils.newRaisedButton(i18n("button.save"));
|
||||
saveButton.setOnAction(e -> {
|
||||
try {
|
||||
save();
|
||||
@@ -80,9 +79,7 @@ public final class NBTEditorPage extends SpinnerPane implements DecoratorPage {
|
||||
}
|
||||
});
|
||||
|
||||
JFXButton cancelButton = new JFXButton(i18n("button.cancel"));
|
||||
cancelButton.getStyleClass().add("jfx-button-raised");
|
||||
cancelButton.setButtonType(JFXButton.ButtonType.RAISED);
|
||||
JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel"));
|
||||
cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent()));
|
||||
onEscPressed(this, cancelButton::fire);
|
||||
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import com.jfoenix.controls.JFXProgressBar;
|
||||
import javafx.beans.property.DoubleProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.SimpleDoubleProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.WeakChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.text.TextFlow;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaManager;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaState;
|
||||
import org.jackhuang.hmcl.terracotta.profile.TerracottaProfile;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.WeakListenerHolder;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentList;
|
||||
import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||
import org.jackhuang.hmcl.ui.construct.MessageDialogPane;
|
||||
import org.jackhuang.hmcl.ui.construct.RipplerContainer;
|
||||
import org.jackhuang.hmcl.ui.construct.SpinnerPane;
|
||||
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.Zipper;
|
||||
import org.jackhuang.hmcl.util.logging.Logger;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaControllerPage extends StackPane {
|
||||
private static final ObjectProperty<TerracottaState> UI_STATE = new SimpleObjectProperty<>();
|
||||
|
||||
static {
|
||||
FXUtils.onChangeAndOperate(TerracottaManager.stateProperty(), state -> {
|
||||
if (state != null) {
|
||||
UI_STATE.set(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
/* FIXME: It's sucked to have such a long logic, containing UI for all states defined in TerracottaState, with unclear control flows.
|
||||
Consider moving UI into multiple files for each state respectively. */
|
||||
public TerracottaControllerPage() {
|
||||
TransitionPane transition = new TransitionPane();
|
||||
|
||||
ObjectProperty<String> statusProperty = new SimpleObjectProperty<>();
|
||||
DoubleProperty progressProperty = new SimpleDoubleProperty();
|
||||
ObservableList<Node> nodesProperty = FXCollections.observableList(new ArrayList<>());
|
||||
|
||||
FXUtils.applyDragListener(this, path -> {
|
||||
TerracottaState state = UI_STATE.get();
|
||||
|
||||
if (state instanceof TerracottaState.Uninitialized ||
|
||||
state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence() ||
|
||||
state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK
|
||||
) {
|
||||
return Files.isReadable(path) && FileUtils.getName(path).toLowerCase(Locale.ROOT).endsWith(".tar.gz");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}, files -> {
|
||||
Path path = files.get(0);
|
||||
|
||||
if (!TerracottaManager.validate(path)) {
|
||||
Controllers.dialog(
|
||||
i18n("terracotta.from_local.file_name_mismatch", TerracottaMetadata.PACKAGE_NAME, FileUtils.getName(path)),
|
||||
i18n("message.error"),
|
||||
MessageDialogPane.MessageType.ERROR
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
TerracottaState state = UI_STATE.get(), next;
|
||||
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
|
||||
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
|
||||
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, () -> {
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next = TerracottaManager.install(path);
|
||||
} else if (state instanceof TerracottaState.Fatal fatal && fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||
next = TerracottaManager.recover(path);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (next != null) {
|
||||
UI_STATE.set(next);
|
||||
}
|
||||
});
|
||||
|
||||
ChangeListener<TerracottaState> listener = (_uiState, legacyState, state) -> {
|
||||
if (legacyState != null && legacyState.isUIFakeState() && !state.isUIFakeState() && legacyState.getClass() == state.getClass()) {
|
||||
return;
|
||||
}
|
||||
|
||||
progressProperty.unbind();
|
||||
|
||||
if (state instanceof TerracottaState.Bootstrap) {
|
||||
statusProperty.set(i18n("terracotta.status.bootstrap"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Uninitialized uninitialized) {
|
||||
String fork = uninitialized.hasLegacy() ? "update" : "not_exist";
|
||||
|
||||
statusProperty.set(i18n("terracotta.status.uninitialized." + fork));
|
||||
progressProperty.set(0);
|
||||
|
||||
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||
body.setLineSpacing(4);
|
||||
|
||||
LineButton download = LineButton.of();
|
||||
download.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||
download.setTitle(i18n(String.format("terracotta.status.uninitialized.%s.title", fork)));
|
||||
download.setSubtitle(i18n("terracotta.status.uninitialized.desc"));
|
||||
download.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(download, () -> {
|
||||
if (uninitialized.hasLegacy()) {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
} else {
|
||||
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, () -> {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(body, download, getThirdPartyDownloadNodes());
|
||||
} else if (state instanceof TerracottaState.Preparing) {
|
||||
statusProperty.set(i18n("terracotta.status.preparing"));
|
||||
progressProperty.bind(((TerracottaState.Preparing) state).progressProperty());
|
||||
nodesProperty.setAll(getThirdPartyDownloadNodes());
|
||||
} else if (state instanceof TerracottaState.Launching) {
|
||||
statusProperty.set(i18n("terracotta.status.launching"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Unknown) {
|
||||
statusProperty.set(i18n("terracotta.status.unknown"));
|
||||
progressProperty.set(-1);
|
||||
nodesProperty.setAll();
|
||||
} else if (state instanceof TerracottaState.Waiting) {
|
||||
statusProperty.set(i18n("terracotta.status.waiting"));
|
||||
progressProperty.set(1);
|
||||
|
||||
TextFlow flow = FXUtils.segmentToTextFlow(i18n("terracotta.confirm.desc"), Controllers::onHyperlinkAction);
|
||||
flow.setLineSpacing(4);
|
||||
|
||||
LineButton host = LineButton.of();
|
||||
host.setLeftIcon(SVG.HOST);
|
||||
host.setTitle(i18n("terracotta.status.waiting.host.title"));
|
||||
host.setSubtitle(i18n("terracotta.status.waiting.host.desc"));
|
||||
host.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(host, () -> {
|
||||
if (LauncherHelper.countMangedProcesses() >= 1) {
|
||||
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||
if (s1 != null) {
|
||||
UI_STATE.set(s1);
|
||||
}
|
||||
} else {
|
||||
Controllers.dialog(new MessageDialogPane.Builder(
|
||||
i18n("terracotta.status.waiting.host.launch.desc"),
|
||||
i18n("terracotta.status.waiting.host.launch.title"),
|
||||
MessageDialogPane.MessageType.QUESTION
|
||||
).addAction(i18n("version.launch"), () -> {
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
}).addCancel(i18n("terracotta.status.waiting.host.launch.skip"), () -> {
|
||||
TerracottaState.HostScanning s1 = TerracottaManager.setScanning();
|
||||
if (s1 != null) {
|
||||
UI_STATE.set(s1);
|
||||
}
|
||||
}).addCancel(() -> {
|
||||
}).build());
|
||||
}
|
||||
});
|
||||
|
||||
LineButton guest = LineButton.of();
|
||||
guest.setLeftIcon(SVG.ADD_CIRCLE);
|
||||
guest.setTitle(i18n("terracotta.status.waiting.guest.title"));
|
||||
guest.setSubtitle(i18n("terracotta.status.waiting.guest.desc"));
|
||||
guest.setRightIcon(SVG.ARROW_FORWARD);
|
||||
FXUtils.onClicked(guest, () -> {
|
||||
Controllers.prompt(i18n("terracotta.status.waiting.guest.prompt.title"), (code, resolve, reject) -> {
|
||||
Task<TerracottaState.GuestStarting> task = TerracottaManager.setGuesting(code);
|
||||
if (task != null) {
|
||||
task.whenComplete(Schedulers.javafx(), (s, e) -> {
|
||||
if (e != null) {
|
||||
reject.accept(i18n("terracotta.status.waiting.guest.prompt.invalid"));
|
||||
} else {
|
||||
resolve.run();
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}).setSignificance(Task.TaskSignificance.MINOR).start();
|
||||
} else {
|
||||
resolve.run();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
nodesProperty.setAll(flow, host, guest);
|
||||
} else if (state instanceof TerracottaState.HostScanning) {
|
||||
statusProperty.set(i18n("terracotta.status.scanning"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
TextFlow body = FXUtils.segmentToTextFlow(i18n("terracotta.status.scanning.desc"), Controllers::onHyperlinkAction);
|
||||
body.setLineSpacing(4);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.scanning.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(body, room);
|
||||
} else if (state instanceof TerracottaState.HostStarting) {
|
||||
statusProperty.set(i18n("terracotta.status.host_starting"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.host_starting.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.HostOK hostOK) {
|
||||
if (hostOK.isForkOf(legacyState)) {
|
||||
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(hostOK.getProfiles());
|
||||
return;
|
||||
} else {
|
||||
String cs = hostOK.getCode();
|
||||
|
||||
statusProperty.set(i18n("terracotta.status.host_ok"));
|
||||
progressProperty.set(1);
|
||||
|
||||
VBox code = new VBox(4);
|
||||
code.setAlignment(Pos.CENTER);
|
||||
{
|
||||
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
|
||||
{
|
||||
ClipboardContent cp = new ClipboardContent();
|
||||
cp.putString(cs);
|
||||
Clipboard.getSystemClipboard().setContent(cp);
|
||||
}
|
||||
|
||||
// FIXME: The implementation to display Room Code is ambiguous. Consider using a clearer JavaFX Element in the future.
|
||||
TextField label = new TextField(cs);
|
||||
label.setEditable(false);
|
||||
label.setFocusTraversable(false);
|
||||
label.setAlignment(Pos.CENTER);
|
||||
label.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;");
|
||||
VBox.setMargin(label, new Insets(10, 0, 10, 0));
|
||||
label.setScaleX(1.8);
|
||||
label.setScaleY(1.8);
|
||||
holder.add(FXUtils.onWeakChange(label.selectedTextProperty(), string -> {
|
||||
if (string != null && !string.isEmpty() && !cs.equals(string)) {
|
||||
label.selectAll();
|
||||
}
|
||||
}));
|
||||
|
||||
code.getChildren().setAll(desc, label);
|
||||
}
|
||||
FXUtils.onClicked(code, () -> FXUtils.copyText(cs));
|
||||
|
||||
LineButton copy = LineButton.of();
|
||||
copy.setLeftIcon(SVG.CONTENT_COPY);
|
||||
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
|
||||
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
|
||||
FXUtils.onClicked(copy, () -> FXUtils.copyText(cs));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.host_ok.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(code, copy, back, new PlayerProfileUI(hostOK.getProfiles()));
|
||||
}
|
||||
} else if (state instanceof TerracottaState.GuestStarting) {
|
||||
statusProperty.set(i18n("terracotta.status.guest_starting"));
|
||||
progressProperty.set(-1);
|
||||
|
||||
LineButton room = LineButton.of();
|
||||
room.setLeftIcon(SVG.ARROW_BACK);
|
||||
room.setTitle(i18n("terracotta.back"));
|
||||
room.setSubtitle(i18n("terracotta.status.guest_starting.back"));
|
||||
FXUtils.onClicked(room, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(room);
|
||||
} else if (state instanceof TerracottaState.GuestOK guestOK) {
|
||||
if (guestOK.isForkOf(legacyState)) {
|
||||
((PlayerProfileUI) nodesProperty.get(nodesProperty.size() - 1)).updateProfiles(guestOK.getProfiles());
|
||||
return;
|
||||
} else {
|
||||
statusProperty.set(i18n("terracotta.status.guest_ok"));
|
||||
progressProperty.set(1);
|
||||
|
||||
LineButton tutorial = LineButton.of();
|
||||
tutorial.setTitle(i18n("terracotta.status.guest_ok.title"));
|
||||
tutorial.setSubtitle(i18n("terracotta.status.guest_ok.desc", guestOK.getUrl()));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.guest_ok.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
nodesProperty.setAll(tutorial, back, new PlayerProfileUI(guestOK.getProfiles()));
|
||||
}
|
||||
} else if (state instanceof TerracottaState.Exception exception) {
|
||||
statusProperty.set(i18n("terracotta.status.exception.desc." + exception.getType().name().toLowerCase(Locale.ROOT)));
|
||||
progressProperty.set(1);
|
||||
nodesProperty.setAll();
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
back.setTitle(i18n("terracotta.back"));
|
||||
back.setSubtitle(i18n("terracotta.status.exception.back"));
|
||||
FXUtils.onClicked(back, () -> {
|
||||
TerracottaState.Waiting s = TerracottaManager.setWaiting();
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
SpinnerPane exportLog = new SpinnerPane();
|
||||
LineButton exportLogInner = LineButton.of();
|
||||
exportLogInner.setLeftIcon(SVG.OUTPUT);
|
||||
exportLogInner.setTitle(i18n("terracotta.export_log"));
|
||||
exportLogInner.setSubtitle(i18n("terracotta.export_log.desc"));
|
||||
exportLog.setContent(exportLogInner);
|
||||
exportLog.getProperties().put("ComponentList.noPadding", true);
|
||||
// FIXME: SpinnerPane loses its content width in loading state.
|
||||
exportLog.minHeightProperty().bind(back.heightProperty());
|
||||
|
||||
FXUtils.onClicked(exportLogInner, () -> {
|
||||
exportLog.setLoading(true);
|
||||
|
||||
TerracottaManager.exportLogs().thenAcceptAsync(Schedulers.io(), data -> {
|
||||
if (data == null || data.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Path path = Path.of("terracotta-log-" + LocalDateTime.now().format(
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")
|
||||
) + ".zip").toAbsolutePath();
|
||||
try (Zipper zipper = new Zipper(path)) {
|
||||
zipper.putTextFile(data, StandardCharsets.UTF_8, "terracotta.log");
|
||||
try (OutputStream os = zipper.putStream("hmcl-latest.log")) {
|
||||
Logger.LOG.exportLogs(os);
|
||||
}
|
||||
}
|
||||
FXUtils.showFileInExplorer(path);
|
||||
}).thenRunAsync(
|
||||
() -> Thread.sleep(3000)
|
||||
).whenComplete(
|
||||
Schedulers.javafx(),
|
||||
e -> exportLog.setLoading(false)
|
||||
).start();
|
||||
});
|
||||
|
||||
nodesProperty.setAll(back, exportLog);
|
||||
} else if (state instanceof TerracottaState.Fatal fatal) {
|
||||
String message = i18n("terracotta.status.fatal." + fatal.getType().name().toLowerCase(Locale.ROOT));
|
||||
|
||||
statusProperty.set(message);
|
||||
progressProperty.set(1);
|
||||
|
||||
if (fatal.isRecoverable()) {
|
||||
LineButton retry = LineButton.of();
|
||||
retry.setLeftIcon(SVG.RESTORE);
|
||||
retry.setTitle(i18n("terracotta.status.fatal.retry"));
|
||||
retry.setSubtitle(message);
|
||||
FXUtils.onClicked(retry, () -> {
|
||||
TerracottaState s = TerracottaManager.recover(null);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
});
|
||||
|
||||
if (fatal.getType() == TerracottaState.Fatal.Type.NETWORK) {
|
||||
nodesProperty.setAll(retry, getThirdPartyDownloadNodes());
|
||||
} else {
|
||||
nodesProperty.setAll(retry);
|
||||
}
|
||||
} else {
|
||||
nodesProperty.setAll();
|
||||
}
|
||||
} else {
|
||||
throw new AssertionError(state.getClass().getName());
|
||||
}
|
||||
|
||||
ComponentList components = new ComponentList();
|
||||
{
|
||||
VBox statusPane = new VBox(8);
|
||||
VBox.setMargin(statusPane, new Insets(0, 0, 0, 4));
|
||||
{
|
||||
Label status = new Label();
|
||||
status.textProperty().bind(statusProperty);
|
||||
JFXProgressBar progress = new JFXProgressBar();
|
||||
progress.progressProperty().bind(progressProperty);
|
||||
progress.setMaxWidth(Double.MAX_VALUE);
|
||||
|
||||
statusPane.getChildren().setAll(status, progress);
|
||||
}
|
||||
|
||||
ObservableList<Node> children = components.getContent();
|
||||
children.add(statusPane);
|
||||
children.addAll(nodesProperty);
|
||||
}
|
||||
|
||||
transition.setContent(components, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||
};
|
||||
listener.changed(UI_STATE, null, UI_STATE.get());
|
||||
holder.add(listener);
|
||||
UI_STATE.addListener(new WeakChangeListener<>(listener));
|
||||
|
||||
VBox content = new VBox(10);
|
||||
content.getChildren().addAll(ComponentList.createComponentListTitle(i18n("terracotta.status")), transition);
|
||||
content.setPadding(new Insets(10));
|
||||
content.setFillWidth(true);
|
||||
|
||||
ScrollPane scrollPane = new ScrollPane(content);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
scrollPane.setFitToWidth(true);
|
||||
|
||||
getChildren().setAll(scrollPane);
|
||||
}
|
||||
|
||||
private ComponentList getThirdPartyDownloadNodes() {
|
||||
ComponentSublist locals = new ComponentSublist();
|
||||
locals.setComponentPadding(false);
|
||||
|
||||
LineButton header = LineButton.of(false);
|
||||
header.setLeftImage(FXUtils.newBuiltinImage("/assets/img/terracotta.png"));
|
||||
header.setTitle(i18n("terracotta.from_local.title"));
|
||||
header.setSubtitle(i18n("terracotta.from_local.desc"));
|
||||
locals.setHeaderLeft(header);
|
||||
|
||||
for (TerracottaMetadata.Link link : TerracottaMetadata.getPackageLinks()) {
|
||||
HBox node = new HBox();
|
||||
node.setAlignment(Pos.CENTER_LEFT);
|
||||
node.setPadding(new Insets(10, 16, 10, 16));
|
||||
|
||||
Label description = new Label(link.description().getText(I18n.getLocale().getCandidateLocales()));
|
||||
HBox placeholder = new HBox();
|
||||
HBox.setHgrow(placeholder, Priority.ALWAYS);
|
||||
Node icon = SVG.OPEN_IN_NEW.createIcon(Theme.blackFill(), 16);
|
||||
node.getChildren().setAll(description, placeholder, icon);
|
||||
|
||||
String url = link.link();
|
||||
RipplerContainer container = new RipplerContainer(node);
|
||||
container.setOnMouseClicked(ev -> Controllers.dialog(
|
||||
i18n("terracotta.from_local.guide", TerracottaMetadata.PACKAGE_NAME),
|
||||
i18n("message.info"), MessageDialogPane.MessageType.INFO,
|
||||
() -> FXUtils.openLink(url)
|
||||
));
|
||||
container.getProperties().put("ComponentList.noPadding", true);
|
||||
locals.getContent().add(container);
|
||||
}
|
||||
return locals;
|
||||
}
|
||||
|
||||
private static final class LineButton extends RipplerContainer {
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
private final TwoLineListItem middle = new TwoLineListItem();
|
||||
private final ObjectProperty<Node> left = new SimpleObjectProperty<>();
|
||||
private final ObjectProperty<Node> right = new SimpleObjectProperty<>();
|
||||
|
||||
public static LineButton of() {
|
||||
return of(true);
|
||||
}
|
||||
|
||||
public static LineButton of(boolean padding) {
|
||||
HBox container = new HBox();
|
||||
if (padding) {
|
||||
container.setPadding(new Insets(10, 16, 10, 16));
|
||||
}
|
||||
container.setAlignment(Pos.CENTER_LEFT);
|
||||
container.setCursor(Cursor.HAND);
|
||||
container.setSpacing(16);
|
||||
|
||||
LineButton button = new LineButton(container);
|
||||
VBox spacing = new VBox();
|
||||
HBox.setHgrow(spacing, Priority.ALWAYS);
|
||||
button.holder.add(FXUtils.observeWeak(() -> {
|
||||
List<Node> nodes = new ArrayList<>(4);
|
||||
Node left = button.left.get();
|
||||
if (left != null) {
|
||||
nodes.add(left);
|
||||
}
|
||||
|
||||
nodes.add(button.middle);
|
||||
nodes.add(spacing);
|
||||
|
||||
Node right = button.right.get();
|
||||
if (right != null) {
|
||||
nodes.add(right);
|
||||
}
|
||||
|
||||
container.getChildren().setAll(nodes);
|
||||
}, button.middle.titleProperty(), button.middle.subtitleProperty(), button.left, button.right));
|
||||
button.getProperties().put("ComponentList.noPadding", true);
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
private LineButton(Node container) {
|
||||
super(container);
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.middle.setTitle(title);
|
||||
}
|
||||
|
||||
public void setSubtitle(String subtitle) {
|
||||
this.middle.setSubtitle(subtitle);
|
||||
}
|
||||
|
||||
public void setLeftImage(Image left) {
|
||||
this.left.set(new ImageView(left));
|
||||
}
|
||||
|
||||
public void setLeftIcon(SVG left) {
|
||||
this.left.set(left.createIcon(Theme.blackFill(), 28));
|
||||
}
|
||||
|
||||
public void setRightIcon(SVG right) {
|
||||
this.right.set(right.createIcon(Theme.blackFill(), 28));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class PlayerProfileUI extends VBox {
|
||||
private final TransitionPane transition;
|
||||
|
||||
public PlayerProfileUI(List<TerracottaProfile> profiles) {
|
||||
super(8);
|
||||
VBox.setMargin(this, new Insets(0, 0, 0, 4));
|
||||
{
|
||||
Label status = new Label();
|
||||
status.setText(i18n("terracotta.player_list"));
|
||||
|
||||
transition = new TransitionPane();
|
||||
getChildren().setAll(status, transition);
|
||||
|
||||
updateProfiles(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateProfiles(List<TerracottaProfile> profiles) {
|
||||
VBox pane = new VBox(8);
|
||||
|
||||
for (TerracottaProfile profile : profiles) {
|
||||
TwoLineListItem item = new TwoLineListItem();
|
||||
item.setTitle(profile.getName());
|
||||
item.setSubtitle(profile.getVendor());
|
||||
item.getTags().setAll(TwoLineListItem.createTagLabel(
|
||||
i18n("terracotta.player_kind." + profile.getType().name().toLowerCase(Locale.ROOT)))
|
||||
);
|
||||
|
||||
pane.getChildren().add(item);
|
||||
}
|
||||
|
||||
this.transition.setContent(pane, ContainerAnimations.SWIPE_LEFT_FADE_SHORT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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.terracotta;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.PageAware;
|
||||
import org.jackhuang.hmcl.ui.construct.TabHeader;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||
private final ReadOnlyObjectWrapper<State> state = new ReadOnlyObjectWrapper<>(State.fromTitle(i18n("terracotta.terracotta")));
|
||||
private final TabHeader tab;
|
||||
private final TabHeader.Tab<TerracottaControllerPage> statusPage = new TabHeader.Tab<>("statusPage");
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
|
||||
public TerracottaPage() {
|
||||
statusPage.setNodeSupplier(TerracottaControllerPage::new);
|
||||
tab = new TabHeader(statusPage);
|
||||
tab.select(statusPage);
|
||||
|
||||
transitionPane.setContent(statusPage.getNode(), ContainerAnimations.NONE);
|
||||
FXUtils.onChange(tab.getSelectionModel().selectedItemProperty(), newValue -> {
|
||||
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
AdvancedListItem chatItem = new AdvancedListItem();
|
||||
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
||||
chatItem.setActionButtonVisible(false);
|
||||
chatItem.setTitle(i18n("chat"));
|
||||
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
|
||||
AdvancedListItem easytierItem = new AdvancedListItem();
|
||||
easytierItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
easytierItem.setActionButtonVisible(false);
|
||||
easytierItem.setTitle(i18n("terracotta.easytier"));
|
||||
easytierItem.setOnAction(e -> FXUtils.openLink("https://easytier.cn/"));
|
||||
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE)
|
||||
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
|
||||
.add(chatItem)
|
||||
.add(easytierItem);
|
||||
FXUtils.setLimitWidth(sideBar, 200);
|
||||
setLeft(sideBar);
|
||||
|
||||
setCenter(transitionPane);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageShown() {
|
||||
tab.onPageShown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageHidden() {
|
||||
tab.onPageHidden();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ReadOnlyObjectProperty<State> stateProperty() {
|
||||
return state.getReadOnlyProperty();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.SelectionMode;
|
||||
import javafx.scene.control.SkinBase;
|
||||
import javafx.scene.image.Image;
|
||||
@@ -45,6 +46,7 @@ import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository;
|
||||
import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Theme;
|
||||
import org.jackhuang.hmcl.setting.VersionIconType;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
@@ -64,6 +66,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.ref.SoftReference;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -81,7 +84,7 @@ import static org.jackhuang.hmcl.util.StringUtils.isNotBlank;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
private final TransitionPane toolbarPane;
|
||||
private final HBox searchBar;
|
||||
@@ -279,6 +282,85 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<Image> loadModIcon(LocalModFile modFile, int size) {
|
||||
return Task.supplyAsync(() -> {
|
||||
List<String> iconPaths = new ArrayList<>();
|
||||
|
||||
if (StringUtils.isNotBlank(modFile.getLogoPath())) {
|
||||
iconPaths.add(modFile.getLogoPath());
|
||||
}
|
||||
|
||||
iconPaths.addAll(List.of(
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"mod_logo.png",
|
||||
"pack.png",
|
||||
"logoFile.png",
|
||||
"assets/icon.png",
|
||||
"assets/logo.png",
|
||||
"assets/mod_icon.png",
|
||||
"assets/mod_logo.png",
|
||||
"META-INF/icon.png",
|
||||
"META-INF/logo.png",
|
||||
"META-INF/mod_icon.png",
|
||||
"textures/icon.png",
|
||||
"textures/logo.png",
|
||||
"textures/mod_icon.png",
|
||||
"resources/icon.png",
|
||||
"resources/logo.png",
|
||||
"resources/mod_icon.png"
|
||||
));
|
||||
|
||||
String modId = modFile.getId();
|
||||
if (StringUtils.isNotBlank(modId)) {
|
||||
iconPaths.addAll(List.of(
|
||||
"assets/" + modId + "/icon.png",
|
||||
"assets/" + modId + "/logo.png",
|
||||
"assets/" + modId.replace("-", "") + "/icon.png",
|
||||
"assets/" + modId.replace("-", "") + "/logo.png",
|
||||
modId + ".png",
|
||||
modId + "-logo.png",
|
||||
modId + "-icon.png",
|
||||
modId + "_logo.png",
|
||||
modId + "_icon.png",
|
||||
"textures/" + modId + "/icon.png",
|
||||
"textures/" + modId + "/logo.png",
|
||||
"resources/" + modId + "/icon.png",
|
||||
"resources/" + modId + "/logo.png"
|
||||
));
|
||||
}
|
||||
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modFile.getFile())) {
|
||||
for (String path : iconPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, size, size, true, true);
|
||||
if (!image.isError() &&
|
||||
image.getWidth() > 0 &&
|
||||
image.getHeight() > 0 &&
|
||||
Math.abs(image.getWidth() - image.getHeight()) < 1) {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load mod icons", e);
|
||||
}
|
||||
|
||||
VersionIconType defaultIcon = switch (modFile.getModLoaderType()) {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGED -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
case CLEANROOM -> VersionIconType.CLEANROOM;
|
||||
default -> VersionIconType.COMMAND;
|
||||
};
|
||||
|
||||
return defaultIcon.getIcon(size);
|
||||
});
|
||||
}
|
||||
|
||||
static class ModInfoObject extends RecursiveTreeObject<ModInfoObject> implements Comparable<ModInfoObject> {
|
||||
private final BooleanProperty active;
|
||||
private final LocalModFile localModFile;
|
||||
@@ -286,22 +368,25 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
private final String message;
|
||||
private final ModTranslations.Mod mod;
|
||||
|
||||
private SoftReference<Image> iconCache;
|
||||
|
||||
ModInfoObject(LocalModFile localModFile) {
|
||||
this.localModFile = localModFile;
|
||||
this.active = localModFile.activeProperty();
|
||||
|
||||
StringBuilder title = new StringBuilder(localModFile.getName());
|
||||
if (isNotBlank(localModFile.getVersion()))
|
||||
title.append(" ").append(localModFile.getVersion());
|
||||
this.title = title.toString();
|
||||
|
||||
StringBuilder message = new StringBuilder(localModFile.getFileName());
|
||||
if (isNotBlank(localModFile.getGameVersion()))
|
||||
message.append(", ").append(i18n("mods.game.version")).append(": ").append(localModFile.getGameVersion());
|
||||
if (isNotBlank(localModFile.getAuthors()))
|
||||
message.append(", ").append(i18n("archive.author")).append(": ").append(localModFile.getAuthors());
|
||||
this.message = message.toString();
|
||||
this.title = localModFile.getName();
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
if (isNotBlank(localModFile.getId())) {
|
||||
parts.add(localModFile.getId());
|
||||
}
|
||||
if (isNotBlank(localModFile.getVersion())) {
|
||||
parts.add(localModFile.getVersion());
|
||||
}
|
||||
if (isNotBlank(localModFile.getGameVersion())) {
|
||||
parts.add(i18n("game.version") + ": " + localModFile.getGameVersion());
|
||||
}
|
||||
this.message = String.join(", ", parts);
|
||||
this.mod = ModTranslations.MOD.getModById(localModFile.getId());
|
||||
}
|
||||
|
||||
@@ -334,77 +419,45 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
titleContainer.setSpacing(8);
|
||||
|
||||
ImageView imageView = new ImageView();
|
||||
Task.supplyAsync(() -> {
|
||||
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(modInfo.getModInfo().getFile())) {
|
||||
String logoPath = modInfo.getModInfo().getLogoPath();
|
||||
if (StringUtils.isNotBlank(logoPath)) {
|
||||
Path iconPath = fs.getPath(logoPath);
|
||||
if (Files.exists(iconPath)) {
|
||||
try {
|
||||
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
|
||||
if (!image.isError() && image.getWidth() == image.getHeight())
|
||||
return image;
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load image " + logoPath, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<String> defaultPaths = new ArrayList<>(Arrays.asList(
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"mod_logo.png",
|
||||
"pack.png",
|
||||
"logoFile.png"
|
||||
));
|
||||
|
||||
String id = modInfo.getModInfo().getId();
|
||||
if (StringUtils.isNotBlank(id)) {
|
||||
defaultPaths.addAll(Arrays.asList(
|
||||
"assets/" + id + "/icon.png",
|
||||
"assets/" + id.replace("-", "") + "/icon.png",
|
||||
id + ".png",
|
||||
id + "-logo.png",
|
||||
id + "-icon.png",
|
||||
id + "_logo.png",
|
||||
id + "_icon.png"
|
||||
));
|
||||
}
|
||||
|
||||
for (String path : defaultPaths) {
|
||||
Path iconPath = fs.getPath(path);
|
||||
if (Files.exists(iconPath)) {
|
||||
Image image = FXUtils.loadImage(iconPath, 40, 40, true, true);
|
||||
if (!image.isError() && image.getWidth() == image.getHeight())
|
||||
return image;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOG.warning("Failed to load icon", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}).whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
if (image != null) {
|
||||
imageView.setImage(image);
|
||||
} else {
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 40, 40, true, true));
|
||||
}
|
||||
}).start();
|
||||
FXUtils.limitSize(imageView, 40, 40);
|
||||
loadModIcon(modInfo.getModInfo(), 40)
|
||||
.whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
imageView.setImage(image);
|
||||
}).start();
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
title.addTag(modInfo.getModInfo().getVersion());
|
||||
if (modInfo.getMod() != null) {
|
||||
title.addTag(modInfo.getMod().getDisplayName());
|
||||
}
|
||||
title.setSubtitle(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
|
||||
List<String> subtitleParts = new ArrayList<>();
|
||||
subtitleParts.add(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitleParts.add(modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitleParts.add(modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitleParts.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
}
|
||||
title.setSubtitle(String.join(", ", subtitleParts));
|
||||
|
||||
titleContainer.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), title);
|
||||
setHeading(titleContainer);
|
||||
|
||||
Label description = new Label(modInfo.getModInfo().getDescription().toString());
|
||||
description.setWrapText(true);
|
||||
FXUtils.copyOnDoubleClick(description);
|
||||
setBody(description);
|
||||
|
||||
ScrollPane descriptionPane = new ScrollPane(description);
|
||||
FXUtils.smoothScrolling(descriptionPane);
|
||||
descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER);
|
||||
descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED);
|
||||
descriptionPane.setFitToWidth(true);
|
||||
|
||||
setBody(descriptionPane);
|
||||
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getId())) {
|
||||
for (Pair<String, ? extends RemoteModRepository> item : Arrays.asList(
|
||||
@@ -512,6 +565,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
|
||||
final class ModInfoListCell extends MDListCell<ModInfoObject> {
|
||||
JFXCheckBox checkBox = new JFXCheckBox();
|
||||
ImageView imageView = new ImageView();
|
||||
TwoLineListItem content = new TwoLineListItem();
|
||||
JFXButton restoreButton = new JFXButton();
|
||||
JFXButton infoButton = new JFXButton();
|
||||
@@ -528,6 +582,11 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
content.setMouseTransparent(true);
|
||||
setSelectable();
|
||||
|
||||
imageView.setFitWidth(24);
|
||||
imageView.setFitHeight(24);
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.setImage(FXUtils.newBuiltinImage("/assets/img/command.png", 24, 24, true, true));
|
||||
|
||||
restoreButton.getStyleClass().add("toggle-icon4");
|
||||
restoreButton.setGraphic(FXUtils.limitingSize(SVG.RESTORE.createIcon(Theme.blackFill(), 24), 24, 24));
|
||||
|
||||
@@ -539,7 +598,7 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
infoButton.getStyleClass().add("toggle-icon4");
|
||||
infoButton.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(Theme.blackFill(), 24), 24, 24));
|
||||
|
||||
container.getChildren().setAll(checkBox, content, restoreButton, revealButton, infoButton);
|
||||
container.getChildren().setAll(checkBox, imageView, content, restoreButton, revealButton, infoButton);
|
||||
|
||||
StackPane.setMargin(container, new Insets(8));
|
||||
getContainer().getChildren().setAll(container);
|
||||
@@ -548,6 +607,19 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
@Override
|
||||
protected void updateControl(ModInfoObject dataItem, boolean empty) {
|
||||
if (empty) return;
|
||||
|
||||
SoftReference<Image> iconCache = dataItem.iconCache;
|
||||
Image icon;
|
||||
if (iconCache != null && (icon = iconCache.get()) != null) {
|
||||
imageView.setImage(icon);
|
||||
} else {
|
||||
loadModIcon(dataItem.getModInfo(), 24)
|
||||
.whenComplete(Schedulers.javafx(), (image, exception) -> {
|
||||
dataItem.iconCache = new SoftReference<>(image);
|
||||
imageView.setImage(image);
|
||||
}).start();
|
||||
}
|
||||
|
||||
content.setTitle(dataItem.getTitle());
|
||||
content.getTags().clear();
|
||||
switch (dataItem.getModInfo().getModLoaderType()) {
|
||||
@@ -571,9 +643,14 @@ class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
break;
|
||||
}
|
||||
if (dataItem.getMod() != null && I18n.isUseChinese()) {
|
||||
content.addTag(dataItem.getMod().getDisplayName());
|
||||
if (isNotBlank(dataItem.getSubtitle())) {
|
||||
content.setSubtitle(dataItem.getSubtitle() + ", " + dataItem.getMod().getDisplayName());
|
||||
} else {
|
||||
content.setSubtitle(dataItem.getMod().getDisplayName());
|
||||
}
|
||||
} else {
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
}
|
||||
content.setSubtitle(dataItem.getSubtitle());
|
||||
if (booleanProperty != null) {
|
||||
checkBox.selectedProperty().unbindBidirectional(booleanProperty);
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
copyGlobalPane.setLeft(label);
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
|
||||
JFXButton copyAll = new JFXButton(i18n("settings.game.copy_global.copy_all"));
|
||||
JFXButton copyAll = FXUtils.newBorderButton(i18n("settings.game.copy_global.copy_all"));
|
||||
copyAll.disableProperty().bind(modpack);
|
||||
copyGlobalPane.setRight(copyAll);
|
||||
copyAll.setOnAction(e -> Controllers.confirm(i18n("settings.game.copy_global.copy_all.confirm"), null, () -> {
|
||||
@@ -209,7 +209,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
|
||||
PropertyUtils.copyProperties(profile.getGlobal(), lastVersionSetting, name -> !ignored.contains(name));
|
||||
}, null));
|
||||
copyAll.getStyleClass().add("jfx-button-border");
|
||||
BorderPane.setAlignment(copyAll, Pos.CENTER_RIGHT);
|
||||
}
|
||||
|
||||
@@ -456,7 +455,7 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
showAdvancedSettingPane.setLeft(label);
|
||||
BorderPane.setAlignment(label, Pos.CENTER_LEFT);
|
||||
|
||||
JFXButton button = new JFXButton(i18n("settings.advanced.modify"));
|
||||
JFXButton button = FXUtils.newBorderButton(i18n("settings.advanced.modify"));
|
||||
button.setOnAction(e -> {
|
||||
if (lastVersionSetting != null) {
|
||||
if (advancedVersionSettingPage == null)
|
||||
@@ -465,7 +464,6 @@ public final class VersionSettingsPage extends StackPane implements DecoratorPag
|
||||
Controllers.navigate(advancedVersionSettingPage);
|
||||
}
|
||||
});
|
||||
button.getStyleClass().add("jfx-button-border");
|
||||
showAdvancedSettingPane.setRight(button);
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.util.Optional;
|
||||
|
||||
public final class RemoteVersion {
|
||||
|
||||
public static RemoteVersion fetch(UpdateChannel channel, String url) throws IOException {
|
||||
public static RemoteVersion fetch(UpdateChannel channel, boolean preview, String url) throws IOException {
|
||||
try {
|
||||
JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(url), JsonObject.class);
|
||||
String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing"));
|
||||
@@ -37,7 +37,7 @@ public final class RemoteVersion {
|
||||
String jarHash = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElse(null);
|
||||
boolean force = Optional.ofNullable(response.get("force")).map(JsonElement::getAsBoolean).orElse(false);
|
||||
if (jarUrl != null && jarHash != null) {
|
||||
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), force);
|
||||
return new RemoteVersion(channel, version, jarUrl, Type.JAR, new IntegrityCheck("SHA-1", jarHash), preview, force);
|
||||
} else {
|
||||
throw new IOException("No download url is available");
|
||||
}
|
||||
@@ -51,14 +51,16 @@ public final class RemoteVersion {
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -82,6 +84,10 @@ public final class RemoteVersion {
|
||||
return integrityCheck;
|
||||
}
|
||||
|
||||
public boolean isPreview() {
|
||||
return preview;
|
||||
}
|
||||
|
||||
public boolean isForce() {
|
||||
return force;
|
||||
}
|
||||
|
||||
@@ -27,14 +27,15 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.LinkedHashMap;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Lang.thread;
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.config;
|
||||
import static org.jackhuang.hmcl.util.Lang.*;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
|
||||
public final class UpdateChecker {
|
||||
private UpdateChecker() {}
|
||||
private UpdateChecker() {
|
||||
}
|
||||
|
||||
private static final ObjectProperty<RemoteVersion> latestVersion = new SimpleObjectProperty<>();
|
||||
private static final BooleanBinding outdated = Bindings.createBooleanBinding(
|
||||
@@ -55,7 +56,7 @@ public final class UpdateChecker {
|
||||
private static final ReadOnlyBooleanWrapper checkingUpdate = new ReadOnlyBooleanWrapper(false);
|
||||
|
||||
public static void init() {
|
||||
requestCheckUpdate(UpdateChannel.getChannel());
|
||||
requestCheckUpdate(UpdateChannel.getChannel(), config().isAcceptPreviewUpdate());
|
||||
}
|
||||
|
||||
public static RemoteVersion getLatestVersion() {
|
||||
@@ -82,16 +83,17 @@ public final class UpdateChecker {
|
||||
return checkingUpdate.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
private static RemoteVersion checkUpdate(UpdateChannel channel) throws IOException {
|
||||
private static RemoteVersion checkUpdate(UpdateChannel channel, boolean preview) throws IOException {
|
||||
if (!IntegrityChecker.DISABLE_SELF_INTEGRITY_CHECK && !IntegrityChecker.isSelfVerified()) {
|
||||
throw new IOException("Self verification failed");
|
||||
}
|
||||
|
||||
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, mapOf(
|
||||
pair("version", Metadata.VERSION),
|
||||
pair("channel", channel.channelName)));
|
||||
var query = new LinkedHashMap<String, String>();
|
||||
query.put("version", Metadata.VERSION);
|
||||
query.put("channel", preview ? channel.channelName + "-preview" : channel.channelName);
|
||||
|
||||
return RemoteVersion.fetch(channel, url);
|
||||
String url = NetworkUtils.withQuery(Metadata.HMCL_UPDATE_URL, query);
|
||||
return RemoteVersion.fetch(channel, preview, url);
|
||||
}
|
||||
|
||||
private static boolean isDevelopmentVersion(String version) {
|
||||
@@ -99,7 +101,7 @@ public final class UpdateChecker {
|
||||
version.contains("SNAPSHOT"); // eg. 3.5.SNAPSHOT
|
||||
}
|
||||
|
||||
public static void requestCheckUpdate(UpdateChannel channel) {
|
||||
public static void requestCheckUpdate(UpdateChannel channel, boolean preview) {
|
||||
Platform.runLater(() -> {
|
||||
if (isCheckingUpdate())
|
||||
return;
|
||||
@@ -108,8 +110,8 @@ public final class UpdateChecker {
|
||||
thread(() -> {
|
||||
RemoteVersion result = null;
|
||||
try {
|
||||
result = checkUpdate(channel);
|
||||
LOG.info("Latest version (" + channel + ") is " + result);
|
||||
result = checkUpdate(channel, preview);
|
||||
LOG.info("Latest version (" + channel + ", preview=" + preview + ") is " + result);
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to check for update", e);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.ChecksumMismatchException;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Platform;
|
||||
|
||||
import javax.swing.*;
|
||||
@@ -62,7 +63,6 @@ import java.util.List;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.jar.Manifest;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
@@ -253,14 +253,7 @@ public final class SelfDependencyPatcher {
|
||||
.map(DependencyDescriptor::localPath)
|
||||
.toArray(Path[]::new);
|
||||
|
||||
String addOpens = null;
|
||||
try (InputStream input = SelfDependencyPatcher.class.getResourceAsStream("/META-INF/MANIFEST.MF")) {
|
||||
if (input != null)
|
||||
addOpens = new Manifest(input).getMainAttributes().getValue("Add-Opens");
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to read MANIFEST.MF file", e);
|
||||
}
|
||||
|
||||
String addOpens = JarUtils.getAttribute("hmcl.add-opens", null);
|
||||
JavaFXPatcher.patch(modules, jars, addOpens != null ? addOpens.split(" ") : new String[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -34,7 +33,7 @@ public final class I18n {
|
||||
private I18n() {
|
||||
}
|
||||
|
||||
private static volatile SupportedLocale locale = Locales.DEFAULT;
|
||||
private static volatile SupportedLocale locale = SupportedLocale.DEFAULT;
|
||||
|
||||
public static void setLocale(SupportedLocale locale) {
|
||||
I18n.locale = locale;
|
||||
@@ -44,6 +43,10 @@ public final class I18n {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public static boolean isUpsideDown() {
|
||||
return LocaleUtils.getScript(locale.getLocale()).equals("Qabs");
|
||||
}
|
||||
|
||||
public static boolean isUseChinese() {
|
||||
return LocaleUtils.isChinese(locale.getLocale());
|
||||
}
|
||||
@@ -71,6 +74,11 @@ public final class I18n {
|
||||
else
|
||||
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
|
||||
}
|
||||
|
||||
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
|
||||
return UpsideDownUtils.translate(version.getSelfVersion());
|
||||
}
|
||||
|
||||
return version.getSelfVersion();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class Locales {
|
||||
private Locales() {
|
||||
}
|
||||
|
||||
|
||||
public static final SupportedLocale DEFAULT;
|
||||
|
||||
static {
|
||||
String language = System.getenv("HMCL_LANGUAGE");
|
||||
DEFAULT = new SupportedLocale(true, "def",
|
||||
StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language));
|
||||
}
|
||||
|
||||
/**
|
||||
* English
|
||||
*/
|
||||
public static final SupportedLocale EN = new SupportedLocale("en");
|
||||
|
||||
/**
|
||||
* Spanish
|
||||
*/
|
||||
public static final SupportedLocale ES = new SupportedLocale("es");
|
||||
|
||||
/**
|
||||
* Russian
|
||||
*/
|
||||
public static final SupportedLocale RU = new SupportedLocale("ru");
|
||||
|
||||
/**
|
||||
* Ukrainian
|
||||
*/
|
||||
public static final SupportedLocale UK = new SupportedLocale("uk");
|
||||
|
||||
/**
|
||||
* Japanese
|
||||
*/
|
||||
public static final SupportedLocale JA = new SupportedLocale("ja");
|
||||
|
||||
/**
|
||||
* Chinese (Simplified)
|
||||
*/
|
||||
public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS);
|
||||
|
||||
/**
|
||||
* Chinese (Traditional)
|
||||
*/
|
||||
public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT);
|
||||
|
||||
/**
|
||||
* Wenyan (Classical Chinese)
|
||||
*/
|
||||
public static final SupportedLocale WENYAN = new SupportedLocale("lzh");
|
||||
|
||||
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
|
||||
|
||||
public static SupportedLocale getLocaleByName(String name) {
|
||||
if (name == null) return DEFAULT;
|
||||
|
||||
for (SupportedLocale locale : LOCALES) {
|
||||
if (locale.getName().equalsIgnoreCase(name))
|
||||
return locale;
|
||||
}
|
||||
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
@JsonAdapter(SupportedLocale.TypeAdapter.class)
|
||||
public static final class SupportedLocale {
|
||||
private final boolean isDefault;
|
||||
private final String name;
|
||||
private final Locale locale;
|
||||
private ResourceBundle resourceBundle;
|
||||
private DateTimeFormatter dateTimeFormatter;
|
||||
private List<Locale> candidateLocales;
|
||||
|
||||
SupportedLocale(boolean isDefault, String name, Locale locale) {
|
||||
this.isDefault = isDefault;
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
SupportedLocale(String name) {
|
||||
this(false, name, Locale.forLanguageTag(name));
|
||||
}
|
||||
|
||||
SupportedLocale(String name, Locale locale) {
|
||||
this(false, name, locale);
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public String getDisplayName(SupportedLocale inLocale) {
|
||||
if (isDefault()) {
|
||||
try {
|
||||
return inLocale.getResourceBundle().getString("lang.default");
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for default locale", e);
|
||||
return "Default";
|
||||
}
|
||||
}
|
||||
|
||||
Locale inJavaLocale = inLocale.getLocale();
|
||||
if (LocaleUtils.isISO3Language(inJavaLocale.getLanguage())) {
|
||||
String iso1 = LocaleUtils.getISO1Language(inJavaLocale);
|
||||
if (LocaleUtils.isISO1Language(iso1)) {
|
||||
Locale.Builder builder = new Locale.Builder()
|
||||
.setLocale(inJavaLocale)
|
||||
.setLanguage(iso1);
|
||||
|
||||
if (inJavaLocale.getScript().isEmpty())
|
||||
builder.setScript(LocaleUtils.getScript(inJavaLocale));
|
||||
|
||||
inJavaLocale = builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.locale.getLanguage().equals("lzh")) {
|
||||
if (inJavaLocale.getLanguage().equals("zh"))
|
||||
return "文言";
|
||||
|
||||
String name = locale.getDisplayName(inJavaLocale);
|
||||
return name.equals("lzh") || name.equals("Literary Chinese")
|
||||
? "Chinese (Classical)"
|
||||
: name;
|
||||
}
|
||||
|
||||
return locale.getDisplayName(inJavaLocale);
|
||||
}
|
||||
|
||||
public ResourceBundle getResourceBundle() {
|
||||
ResourceBundle bundle = resourceBundle;
|
||||
if (resourceBundle == null)
|
||||
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public List<Locale> getCandidateLocales() {
|
||||
if (candidateLocales == null)
|
||||
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
|
||||
return candidateLocales;
|
||||
}
|
||||
|
||||
public String i18n(String key, Object... formatArgs) {
|
||||
try {
|
||||
return String.format(getResourceBundle().getString(key), formatArgs);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
} catch (IllegalFormatException e) {
|
||||
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
|
||||
}
|
||||
|
||||
return key + Arrays.toString(formatArgs);
|
||||
}
|
||||
|
||||
public String i18n(String key) {
|
||||
try {
|
||||
return getResourceBundle().getString(key);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
if (locale.getLanguage().equals("lzh"))
|
||||
return WenyanUtils.formatDateTime(time);
|
||||
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String getFcMatchPattern() {
|
||||
String language = locale.getLanguage();
|
||||
String region = locale.getCountry();
|
||||
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return "";
|
||||
|
||||
if (LocaleUtils.isChinese(locale)) {
|
||||
String lang;
|
||||
String charset;
|
||||
|
||||
String script = LocaleUtils.getScript(locale);
|
||||
switch (script) {
|
||||
case "Hans":
|
||||
lang = region.equals("SG") || region.equals("MY")
|
||||
? "zh-" + region
|
||||
: "zh-CN";
|
||||
charset = "0x6e38,0x620f";
|
||||
break;
|
||||
case "Hant":
|
||||
lang = region.equals("HK") || region.equals("MO")
|
||||
? "zh-" + region
|
||||
: "zh-TW";
|
||||
charset = "0x904a,0x6232";
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return ":lang=" + lang + ":charset=" + charset;
|
||||
}
|
||||
|
||||
return region.isEmpty() ? language : language + "-" + region;
|
||||
}
|
||||
|
||||
public boolean isSameLanguage(SupportedLocale other) {
|
||||
return LocaleUtils.getISO1Language(this.getLocale())
|
||||
.equals(LocaleUtils.getISO1Language(other.getLocale()));
|
||||
}
|
||||
|
||||
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
|
||||
@Override
|
||||
public void write(JsonWriter out, SupportedLocale value) throws IOException {
|
||||
out.value(value.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SupportedLocale read(JsonReader in) throws IOException {
|
||||
return getLocaleByName(in.nextString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public final class MinecraftWiki {
|
||||
|
||||
private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$");
|
||||
|
||||
public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) {
|
||||
public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) {
|
||||
String wikiVersion = version.getSelfVersion();
|
||||
var gameVersion = GameVersionNumber.asGameVersion(wikiVersion);
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class MinecraftWiki {
|
||||
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
|
||||
|
||||
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
|
||||
return getWikiLink(Locales.ZH_HANT, version);
|
||||
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
|
||||
} else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
|
||||
return locale.i18n("wiki.version.game.snapshot", translatedVersion);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonAdapter(SupportedLocale.TypeAdapter.class)
|
||||
public final class SupportedLocale {
|
||||
public static final SupportedLocale DEFAULT = new SupportedLocale();
|
||||
|
||||
private static final ConcurrentMap<Locale, SupportedLocale> LOCALES = new ConcurrentHashMap<>();
|
||||
|
||||
public static List<SupportedLocale> getSupportedLocales() {
|
||||
List<SupportedLocale> list = new ArrayList<>();
|
||||
list.add(DEFAULT);
|
||||
|
||||
InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json");
|
||||
if (locales != null) {
|
||||
try (locales) {
|
||||
list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class)));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load languages.json", e);
|
||||
}
|
||||
}
|
||||
return List.copyOf(list);
|
||||
}
|
||||
|
||||
public static SupportedLocale getLocale(Locale locale) {
|
||||
return LOCALES.computeIfAbsent(locale, SupportedLocale::new);
|
||||
}
|
||||
|
||||
public static SupportedLocale getLocaleByName(String name) {
|
||||
if (name == null || name.isBlank() || "def".equals(name) || "default".equals(name))
|
||||
return DEFAULT;
|
||||
|
||||
return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-')));
|
||||
}
|
||||
|
||||
private final boolean isDefault;
|
||||
private final String name;
|
||||
private final Locale locale;
|
||||
private ResourceBundle resourceBundle;
|
||||
private ResourceBundle localeNamesBundle;
|
||||
private DateTimeFormatter dateTimeFormatter;
|
||||
private List<Locale> candidateLocales;
|
||||
|
||||
SupportedLocale() {
|
||||
this.isDefault = true;
|
||||
this.name = "def"; // TODO: Change to "default" after updating the Config format
|
||||
|
||||
String language = System.getenv("HMCL_LANGUAGE");
|
||||
this.locale = StringUtils.isBlank(language)
|
||||
? LocaleUtils.SYSTEM_DEFAULT
|
||||
: Locale.forLanguageTag(language);
|
||||
}
|
||||
|
||||
SupportedLocale(Locale locale) {
|
||||
this.isDefault = false;
|
||||
this.name = locale.toLanguageTag();
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public String getDisplayName(SupportedLocale inLocale) {
|
||||
if (isDefault()) {
|
||||
try {
|
||||
return inLocale.getResourceBundle().getString("lang.default");
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for default locale", e);
|
||||
return "Default";
|
||||
}
|
||||
}
|
||||
|
||||
Locale currentLocale = this.getLocale();
|
||||
|
||||
String language = currentLocale.getLanguage();
|
||||
String subLanguage;
|
||||
{
|
||||
String parentLanguage = LocaleUtils.getParentLanguage(language);
|
||||
if (parentLanguage != null) {
|
||||
subLanguage = language;
|
||||
language = parentLanguage;
|
||||
} else {
|
||||
subLanguage = "";
|
||||
}
|
||||
}
|
||||
|
||||
ResourceBundle localeNames = inLocale.getLocaleNamesBundle();
|
||||
|
||||
String languageDisplayName = language;
|
||||
try {
|
||||
languageDisplayName = localeNames.getString(language);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for language " + language, e);
|
||||
}
|
||||
|
||||
// Currently, HMCL does not support any locales with regions or variants, so they are not handled for now
|
||||
List<String> subTags = Stream.of(subLanguage, currentLocale.getScript())
|
||||
.filter(it -> !it.isEmpty())
|
||||
.map(it -> {
|
||||
try {
|
||||
return localeNames.getString(it);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name of " + it, e);
|
||||
}
|
||||
return it;
|
||||
}).toList();
|
||||
|
||||
return subTags.isEmpty()
|
||||
? languageDisplayName
|
||||
: languageDisplayName + " (" + String.join(", ", subTags) + ")";
|
||||
}
|
||||
|
||||
public ResourceBundle getResourceBundle() {
|
||||
ResourceBundle bundle = resourceBundle;
|
||||
if (resourceBundle == null)
|
||||
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public ResourceBundle getLocaleNamesBundle() {
|
||||
ResourceBundle bundle = localeNamesBundle;
|
||||
if (localeNamesBundle == null)
|
||||
localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public List<Locale> getCandidateLocales() {
|
||||
if (candidateLocales == null)
|
||||
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
|
||||
return candidateLocales;
|
||||
}
|
||||
|
||||
public String i18n(String key, Object... formatArgs) {
|
||||
try {
|
||||
return String.format(getResourceBundle().getString(key), formatArgs);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
} catch (IllegalFormatException e) {
|
||||
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
|
||||
}
|
||||
|
||||
return key + Arrays.toString(formatArgs);
|
||||
}
|
||||
|
||||
public String i18n(String key) {
|
||||
try {
|
||||
return getResourceBundle().getString(key);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
|
||||
return UpsideDownUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
if (locale.getLanguage().equals("lzh")) {
|
||||
return WenyanUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String getFcMatchPattern() {
|
||||
String language = locale.getLanguage();
|
||||
String region = locale.getCountry();
|
||||
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return "";
|
||||
|
||||
if (LocaleUtils.isChinese(locale)) {
|
||||
String lang;
|
||||
String charset;
|
||||
|
||||
String script = LocaleUtils.getScript(locale);
|
||||
switch (script) {
|
||||
case "Hans":
|
||||
lang = region.equals("SG") || region.equals("MY")
|
||||
? "zh-" + region
|
||||
: "zh-CN";
|
||||
charset = "0x6e38,0x620f";
|
||||
break;
|
||||
case "Hant":
|
||||
lang = region.equals("HK") || region.equals("MO")
|
||||
? "zh-" + region
|
||||
: "zh-TW";
|
||||
charset = "0x904a,0x6232";
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return ":lang=" + lang + ":charset=" + charset;
|
||||
}
|
||||
|
||||
return region.isEmpty() ? language : language + "-" + region;
|
||||
}
|
||||
|
||||
public boolean isSameLanguage(SupportedLocale other) {
|
||||
return LocaleUtils.getRootLanguage(this.getLocale())
|
||||
.equals(LocaleUtils.getRootLanguage(other.getLocale()));
|
||||
}
|
||||
|
||||
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
|
||||
@Override
|
||||
public void write(JsonWriter out, SupportedLocale value) throws IOException {
|
||||
out.value(value.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SupportedLocale read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.NULL)
|
||||
return DEFAULT;
|
||||
|
||||
String language = in.nextString();
|
||||
return getLocaleByName(switch (language) {
|
||||
// TODO: Remove these compatibility codes after updating the Config format
|
||||
case "zh_CN" -> "zh-Hans"; // For compatibility
|
||||
case "zh" -> "zh-Hant"; // For compatibility
|
||||
default -> language;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public final class UpsideDownUtils {
|
||||
private static final Map<Integer, Integer> MAPPER = loadMap();
|
||||
|
||||
private static Map<Integer, Integer> loadMap() {
|
||||
var map = new LinkedHashMap<Integer, Integer>();
|
||||
|
||||
InputStream inputStream = UpsideDownUtils.class.getResourceAsStream("/assets/lang/upside_down.txt");
|
||||
if (inputStream != null) {
|
||||
try (inputStream) {
|
||||
new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
if (line.isBlank() || line.startsWith("#"))
|
||||
return;
|
||||
|
||||
if (line.length() != 2) {
|
||||
LOG.warning("Invalid line: " + line);
|
||||
return;
|
||||
}
|
||||
|
||||
map.put((int) line.charAt(0), (int) line.charAt(1));
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Failed to load upside_down.txt", e);
|
||||
}
|
||||
} else {
|
||||
LOG.warning("upside_down.txt not found");
|
||||
}
|
||||
return Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
public static String translate(String str) {
|
||||
StringBuilder builder = new StringBuilder(str.length());
|
||||
str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch)));
|
||||
return builder.reverse().toString();
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
return translate(BASE_FORMATTER.format(time));
|
||||
}
|
||||
|
||||
private UpsideDownUtils() {
|
||||
}
|
||||
}
|
||||
@@ -68,5 +68,15 @@
|
||||
"title" : "Java Animated PNG",
|
||||
"subtitle" : "Copyright (C) 2015 Andrew Ellerton.\nLicensed under the Apache 2.0 License.",
|
||||
"externalLink" : "https://github.com/aellerton/japng"
|
||||
},
|
||||
{
|
||||
"title": "Terracotta",
|
||||
"subtitle": "Copyright (C) 2025 Burning_TNT.\nAll rights reserved.",
|
||||
"externalLink": "https://github.com/burningtnt/Terracotta"
|
||||
},
|
||||
{
|
||||
"title": "EasyTier",
|
||||
"subtitle": "Copyright 2024-present Easytier Programme within The Commons Conservancy",
|
||||
"externalLink": "https://github.com/EasyTier/EasyTier"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
@@ -692,7 +692,7 @@ input.url=The input must be a valid URL.
|
||||
|
||||
install=New Instance
|
||||
install.change_version=Change Version
|
||||
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
|
||||
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
|
||||
install.change_version.process=Change Version Process
|
||||
install.failed=Failed to install
|
||||
install.failed.downloading=Failed to download some required files.
|
||||
@@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i
|
||||
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
|
||||
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
|
||||
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
|
||||
install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s.
|
||||
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
|
||||
install.installer.change_version=%s Incompatible
|
||||
install.installer.choose=Choose Your %s Version
|
||||
install.installer.cleanroom=Cleanroom
|
||||
@@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi
|
||||
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
|
||||
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
|
||||
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
|
||||
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s.
|
||||
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
|
||||
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
|
||||
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
|
||||
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
|
||||
@@ -906,7 +906,7 @@ modpack.installing=Installing modpack
|
||||
modpack.installing.given=Installing %s modpack
|
||||
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
|
||||
modpack.invalid=Invalid modpack, you can try downloading it again.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
|
||||
modpack.name=Modpack Name
|
||||
modpack.not_a_valid_name=Invalid modpack name.
|
||||
modpack.origin=Source
|
||||
@@ -1236,7 +1236,7 @@ search.first_page=First
|
||||
search.previous_page=Previous
|
||||
search.next_page=Next
|
||||
search.last_page=Last
|
||||
search.page_n=%d / %s
|
||||
search.page_n=%1$d / %2$s
|
||||
|
||||
selector.choose=Choose
|
||||
selector.choose_file=Choose file
|
||||
@@ -1256,6 +1256,7 @@ settings.advanced.custom_commands.hint=The following environment variables are p
|
||||
\ · $INST_JAVA: java binary used for launch.\n\
|
||||
\ · $INST_FORGE: set if Forge is installed.\n\
|
||||
\ · $INST_NEOFORGE: set if NeoForge is installed.\n\
|
||||
\ · $INST_CLEANROOM: set if Cleanroom is installed.\n\
|
||||
\ · $INST_LITELOADER: set if LiteLoader is installed.\n\
|
||||
\ · $INST_OPTIFINE: set if OptiFine is installed.\n\
|
||||
\ · $INST_FABRIC: set if Fabric is installed.\n\
|
||||
@@ -1333,7 +1334,7 @@ settings.game.java_directory.bit=%s bit
|
||||
settings.game.java_directory.choose=Choose Java
|
||||
settings.game.java_directory.invalid=Incorrect Java path
|
||||
settings.game.java_directory.version=Specify Java Version
|
||||
settings.game.java_directory.template=%s (%s)
|
||||
settings.game.java_directory.template=%1$s (%2$s)
|
||||
settings.game.management=Manage
|
||||
settings.game.working_directory=Working Directory
|
||||
settings.game.working_directory.choose=Choose the working directory
|
||||
@@ -1412,6 +1413,76 @@ sponsor.hmcl=Hello Minecraft! Launcher is a FOSS Minecraft launcher that allows
|
||||
system.architecture=Architecture
|
||||
system.operating_system=Operating System
|
||||
|
||||
terracotta=Multiplayer
|
||||
terracotta.easytier=About EasyTier
|
||||
terracotta.terracotta=Terracotta | Multiplayer
|
||||
terracotta.status=Lobby
|
||||
terracotta.back=Exit
|
||||
terracotta.sudo_installing=HMCL must verify your password before installing Multiplayer Core
|
||||
terracotta.from_local.title=Third-party download channels for Multiplayer Core
|
||||
terracotta.from_local.desc=In some areas, the built-in default download channel may be unstable.
|
||||
terracotta.from_local.guide=Please download Multiplayer Core package named %s. Once downloaded, drag the file into the current page to install it.
|
||||
terracotta.from_local.file_name_mismatch=You should download the Multiplayer Core package named %1$s instead of %2$s
|
||||
terracotta.export_log=Exports the Multiplayer Core log
|
||||
terracotta.export_log.desc=Gathering more information for analysis
|
||||
terracotta.status.bootstrap=Gathering information
|
||||
terracotta.status.uninitialized.not_exist=Multiplayer Core: Not Downloaded
|
||||
terracotta.status.uninitialized.not_exist.title=Download Multiplayer Core (~ 8MiB)
|
||||
terracotta.status.uninitialized.update=Multiplayer Core: Update Available
|
||||
terracotta.status.uninitialized.update.title=Update Multiplayer Core (~ 8MiB)
|
||||
terracotta.status.uninitialized.desc=You legally promise to strictly abide by all laws and regulations of your country or region during the multiplayer process.
|
||||
terracotta.confirm.title=User Notice
|
||||
terracotta.confirm.desc=Terracotta is a third-party open source free software, which has little relationship with HMCL.\n\
|
||||
Terracotta is based on P2P, so that the final experience depends greatly on your network condition.\n\
|
||||
You promise to strictly abide by all laws and regulations in your country or region when playing online.
|
||||
terracotta.status.preparing=Multiplayer Core: Downloading (DO NOT exit HMCL)
|
||||
terracotta.status.launching=Multiplayer Core: Initializing
|
||||
terracotta.status.unknown=Multiplayer Core: Initializing
|
||||
terracotta.status.waiting=Multiplayer Core: Ready
|
||||
terracotta.status.waiting.host.title=I want to host a session
|
||||
terracotta.status.waiting.host.desc=Create a room and generate an invite code to play with friends
|
||||
terracotta.status.waiting.host.launch.title=You seem to have forgotten to launch the game
|
||||
terracotta.status.waiting.host.launch.desc=No running game found
|
||||
terracotta.status.waiting.host.launch.skip=Game has launched
|
||||
terracotta.status.waiting.guest.title=I want to join a session
|
||||
terracotta.status.waiting.guest.desc=Enter the invite code from the host player to join the game world
|
||||
terracotta.status.waiting.guest.prompt.title=Please enter the invite code from the host
|
||||
terracotta.status.waiting.guest.prompt.invalid=Invalid invite code
|
||||
terracotta.status.scanning=Scanning LAN worlds
|
||||
terracotta.status.scanning.desc=Please <a href="hmcl://game/launch">start the game</a>, open a world, press ESC, select "Open to LAN", then select "Start LAN World".
|
||||
terracotta.status.scanning.back=This will also stop scanning LAN worlds.
|
||||
terracotta.status.host_starting=Room Creating
|
||||
terracotta.status.host_starting.back=This will stop creating the room.
|
||||
terracotta.status.host_ok=Room created
|
||||
terracotta.status.host_ok.code=Invitation code (Copied)
|
||||
terracotta.status.host_ok.code.copy=Copy invitation code
|
||||
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
|
||||
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
|
||||
terracotta.status.guest_starting=Joining room
|
||||
terracotta.status.guest_starting.back=This will not stop other guests from joining the room.
|
||||
terracotta.status.guest_ok=Room Joined
|
||||
terracotta.status.guest_ok.back=This will not stop other guests from joining the room.
|
||||
terracotta.status.guest_ok.title=Please launch the game, select Multiplayer, and double-click Terracotta Lobby.
|
||||
terracotta.status.guest_ok.desc=Backup address: %s
|
||||
terracotta.status.exception.back=Please try again
|
||||
terracotta.status.exception.desc.ping_host_fail=Failed to join room: Room is closed or network unstable
|
||||
terracotta.status.exception.desc.ping_host_rst=Room connection lost: Room is closed or network unstable
|
||||
terracotta.status.exception.desc.guest_et_crash=Failed to join room: EasyTier crashed, please report this issue to developers
|
||||
terracotta.status.exception.desc.host_et_crash=Failed to create room: EasyTier crashed, please report this issue to developers
|
||||
terracotta.status.exception.desc.ping_server_rst=Room closed: You exited the game world, room closed automatically
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=Invalid Protocol:Host has sent invalid response, please report this issue to developers
|
||||
terracotta.status.fatal.retry=Retry
|
||||
terracotta.status.fatal.os=Sorry, HMCL cannot enable Terracotta | Multiplayer on your operating system or architecture. Please use a more modern operating system.
|
||||
terracotta.status.fatal.network=Failed to download Multiplayer Core. Please check your network connection and try again.
|
||||
terracotta.status.fatal.install=Fatal Error: Unable to install Multiplayer Core.
|
||||
terracotta.status.fatal.terracotta=Fatal Error: Unable to connect to Multiplayer Core.
|
||||
terracotta.status.fatal.unknown=Fatal Error: Unknown.
|
||||
terracotta.player_list=Player List
|
||||
terracotta.player_anonymous=Anonymous Player
|
||||
terracotta.player_kind.host=Host
|
||||
terracotta.player_kind.local=Yourself
|
||||
terracotta.player_kind.guest=Guest
|
||||
|
||||
unofficial.hint=You are using an unofficial build of HMCL. We cannot guarantee its security.
|
||||
|
||||
update=Update
|
||||
@@ -1442,6 +1513,7 @@ update.note=Beta and Nightly channels may have more features or fixes, but they
|
||||
update.latest=This is the latest version
|
||||
update.no_browser=Cannot open in system browser. But we copied the link to your clipboard, and you can open it manually.
|
||||
update.tooltip=Update
|
||||
update.preview=Try out the HMCL version
|
||||
|
||||
version=Games
|
||||
version.name=Instance Name
|
||||
@@ -1479,7 +1551,7 @@ version.manage.manage=Edit Instance
|
||||
version.manage.manage.title=Edit Instance - %1s
|
||||
version.manage.redownload_assets_index=Update Game Assets
|
||||
version.manage.remove=Delete Instance
|
||||
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%s"? You can still find its files in your recycle bin by the name of "%s".
|
||||
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
|
||||
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
|
||||
version.manage.remove_assets=Delete All Assets
|
||||
version.manage.remove_libraries=Delete All Libraries
|
||||
@@ -1493,7 +1565,7 @@ version.update=Update Modpack
|
||||
|
||||
warning.java_interpreted_mode=HMCL is running in an interpreted Java environment, which will greatly affect performance.\n\
|
||||
\n\
|
||||
We recommend using a Java with JIT support to launch HMCL for the best experience.
|
||||
We recommend using a Java with JIT support to open HMCL for the best experience.
|
||||
warning.software_rendering=HMCL is currently using software rendering, which will greatly affect performance.
|
||||
|
||||
wiki.tooltip=Minecraft Wiki Page
|
||||
|
||||
@@ -180,6 +180,7 @@ button.change_source=Cambiar fuente de descarga
|
||||
button.clear=Limpiar
|
||||
button.copy_and_exit=Copiar y salir
|
||||
button.delete=Borrar
|
||||
button.do_not_show_again=No volver a mostrar
|
||||
button.edit=Editar
|
||||
button.install=Instalar
|
||||
button.export=Exportar
|
||||
@@ -391,6 +392,9 @@ fatal.config_loading_failure=No se pueden cargar los archivos de configuración.
|
||||
Para macOS, intente colocar HMCL en un lugar con permiso que no sea «Escritorio», «Descargas» y «Documentos» y vuelva a intentarlo.
|
||||
fatal.config_loading_failure.unix=No se pudo cargar el archivo de configuración porque fue creado por el usuario «%1$s».\n\
|
||||
Por favor, abra HMCL como usuario root (no recomendado), o ejecute el siguiente comando en el terminal para cambiar la propiedad del archivo de configuración al usuario actual:\n%2$s
|
||||
fatal.config_unsupported_version=El archivo de configuración actual fue creado por una versión más reciente del lanzador, y esta versión del lanzador no puede cargarlo correctamente.\n\
|
||||
Por favor, actualice y reinicie el lanzador.\n\
|
||||
Antes de actualizar el lanzador, cualquier configuración que modifiques no se guardará.
|
||||
fatal.mac_app_translocation=El sistema operativo aísla Hello Minecraft! Launcher en un directorio temporal debido a los mecanismos de seguridad de macOS.\n\
|
||||
Por favor, mueve HMCL a un directorio diferente antes de intentar abrirlo. De lo contrario, tus ajustes y datos de juego podrían perderse tras reiniciar.\n\
|
||||
¿Todavía quieres continuar?
|
||||
@@ -707,6 +711,7 @@ install.failed.optifine_forge_1.17=Para Minecraft 1.17.1, Forge sólo es compati
|
||||
install.failed.version_mismatch=Este cargador requiere la versión del juego %s, pero la instalada es %s.
|
||||
install.installer.change_version=%s Incompatible
|
||||
install.installer.choose=Elija su versión %s
|
||||
install.installer.cleanroom=Cleanroom
|
||||
install.installer.depend=Requiere %s
|
||||
install.installer.do_not_install=No instalar
|
||||
install.installer.fabric=Fabric
|
||||
@@ -778,6 +783,7 @@ launch.advice=%s ¿Todavía quieres continuar?
|
||||
launch.advice.multi=Se han detectado los siguientes problemas:\n\n%s\n\nEstos problemas pueden impedir que inicies el juego o afectar a la experiencia de juego.\n\n¿Todavía quieres continuar?
|
||||
launch.advice.java.auto=La versión actual de Java no es compatible con la instancia.\n\nHaga clic en «Sí» para elegir automáticamente la versión de Java más compatible. O bien, puede navegar hasta «Config. Global/Específica de instancia → Java» para elegir uno usted mismo.
|
||||
launch.advice.java.modded_java_7=Minecraft 1.7.2 y versiones anteriores requieren Java 7 o anterior.
|
||||
launch.advice.cleanroom=Cleanroom solo se puede ejecutar en Java 21 o versiones posteriores. Por favor, actualice su versión de Java.
|
||||
launch.advice.corrected=Hemos resuelto el problema de Java. Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
|
||||
launch.advice.uncorrected=Si aún desea utilizar la versión de Java que elija, puede desactivar «No comprobar la compatibilidad con JVM» en «Config. Global/Específica de instancia → Configuración avanzada».
|
||||
launch.advice.different_platform=Se recomienda la versión de 64 bits de Java para tu dispositivo, pero has instalado una de 32 bits.
|
||||
@@ -1156,6 +1162,7 @@ world.info.player.food_level=Nivel de hambre
|
||||
world.info.player.game_type=Modo de juego
|
||||
world.info.player.game_type.adventure=Aventura
|
||||
world.info.player.game_type.creative=Creativo
|
||||
world.info.player.game_type.hardcore=Extremo
|
||||
world.info.player.game_type.spectator=Espectador
|
||||
world.info.player.game_type.survival=Supervivencia
|
||||
world.info.player.health=Salud
|
||||
@@ -1255,6 +1262,7 @@ settings.advanced.custom_commands.hint=Se proporcionan las siguientes variables
|
||||
\ · $INST_JAVA: binario de java utilizado para la ejecución\n\
|
||||
\ · $INST_FORGE: disponible si Forge está instalado.\n\
|
||||
\ · $INST_NEOFORGE: disponible si NeoForge está instalado.\n\
|
||||
\ · $INST_CLEANROOM: disponible si Cleanroom está instalado.\n\
|
||||
\ · $INST_LITELOADER: disponible si LiteLoader está instalado.\n\
|
||||
\ · $INST_OPTIFINE: disponible si OptiFine está instalado.\n\
|
||||
\ · $INST_FABRIC: disponible si Fabric está instalado.\n\
|
||||
@@ -1288,6 +1296,7 @@ settings.advanced.natives_directory.hint=Esta opción está pensada sólo para u
|
||||
\n\
|
||||
Antes de proceder, por favor, asegúrese de que todas las bibliotecas (por ejemplo, lwjgl.dll, libopenal.so) se proporcionan en su directorio deseado.
|
||||
settings.advanced.no_jvm_args=No añadir argumentos JVM por defecto
|
||||
settings.advanced.no_optimizing_jvm_args=No añadir argumentos de optimización de JVM
|
||||
settings.advanced.precall_command=Comando pre-lanzamiento:
|
||||
settings.advanced.precall_command.prompt=El comando se ejecuta antes de que los juegos se lance
|
||||
settings.advanced.process_priority=Prioridad del proceso
|
||||
@@ -1344,6 +1353,7 @@ settings.launcher=Configuración del launcher
|
||||
settings.launcher.appearance=Apariencia
|
||||
settings.launcher.common_path.tooltip=Esta aplicación pondrá todos los activos y dependencias del juego aquí. Si hay bibliotecas existentes en el directorio del juego, el launcher preferirá usarlas primero.
|
||||
settings.launcher.debug=Depuración
|
||||
settings.launcher.disable_auto_game_options=No cambiar el idioma del juego
|
||||
settings.launcher.download=Descargas
|
||||
settings.launcher.download.threads=Hilos
|
||||
settings.launcher.download.threads.auto=Determinar automáticamente
|
||||
@@ -1483,6 +1493,12 @@ version.search.prompt=Introduzca el nombre de la versión para buscar.
|
||||
version.settings=Configuración
|
||||
version.update=Actualizar Modpack
|
||||
|
||||
warning.java_interpreted_mode=El lanzador se ejecuta en un entorno Java interpretado, lo que afectará considerablemente al rendimiento.\n\
|
||||
\n\
|
||||
Recomendamos utilizar Java con soporte JIT para ejecutar el lanzador y obtener la mejor experiencia.
|
||||
|
||||
warning.software_rendering=El lanzador utiliza actualmente renderización por software, lo que afectará considerablemente al rendimiento.
|
||||
|
||||
wiki.tooltip=Página de Minecraft Wiki
|
||||
wiki.version.game=https://es.minecraft.wiki/w/Java_Edition_%s
|
||||
wiki.version.game.snapshot=https://es.minecraft.wiki/w/Java_Edition_%s
|
||||
|
||||
@@ -379,7 +379,7 @@ fatal.config_change_owner_root=你正在使用 root 帳戶開啟 Hello Minecraft
|
||||
fatal.config_in_temp_dir=你正在暫存目錄中開啟 Hello Minecraft! Launcher,你的設定和遊戲資料可能會遺失。建議將 HMCL 移動至其他位置再開啟。\n是否繼續開啟?
|
||||
fatal.config_loading_failure=Hello Minecraft! Launcher 無法載入設定檔案。\n請確保 HMCL 對「%s」目錄及該目錄下的檔案擁有讀寫權限。
|
||||
fatal.config_loading_failure.unix=Hello Minecraft! Launcher 無法載入設定檔案,因為設定檔案是由使用者「%1$s」建立的。\n請使用 root 帳戶開啟 HMCL (不推薦),或在終端中執行以下指令將設定檔案的所有權變更為目前使用者:\n%2$s
|
||||
fatal.config_unsupported_version=當前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
|
||||
fatal.config_unsupported_version=目前設定檔案是由更高版本的 Hello Minecraft! Launcher 建立的,目前版本的 HMCL 無法正常載入。請更新並重新啟動 HMCL。\n在更新啟動器之前,你所做的所有設定更改都不會被保存。
|
||||
fatal.mac_app_translocation=由於 macOS 的安全機制,Hello Minecraft! Launcher 被系統隔離至暫存目錄中。\n請將 HMCL 移動到其他目錄後再嘗試開啟,否則你的設定和遊戲資料可能會在重啟後遺失。\n是否繼續開啟?
|
||||
fatal.migration_requires_manual_reboot=Hello Minecraft! Launcher 即將升級完成,請重新開啟 HMCL。
|
||||
fatal.apply_update_failure=我們很抱歉 Hello Minecraft! Launcher 無法自動完成升級程式,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 HMCL 來完成升級。
|
||||
@@ -1049,6 +1049,7 @@ settings.advanced.custom_commands.hint=自訂指令被呼叫時將包含如下
|
||||
\ · $INST_JAVA: 遊戲執行使用的 Java 路徑;\n\
|
||||
\ · $INST_FORGE: 若安裝了 Forge,將會存在本環境變數;\n\
|
||||
\ · $INST_NEOFORGE: 若安裝了 NeoForge,將會存在本環境變數;\n\
|
||||
\ · $INST_CLEANROOM: 若安裝了 Cleanroom,將會存在本環境變數;\n\
|
||||
\ · $INST_LITELOADER: 若安裝了 LiteLoader,將會存在本環境變數;\n\
|
||||
\ · $INST_OPTIFINE: 若安裝了 OptiFine,將會存在本環境變數;\n\
|
||||
\ · $INST_FABRIC: 若安裝了 Fabric,將會存在本環境變數;\n\
|
||||
@@ -1080,7 +1081,7 @@ settings.advanced.natives_directory.default=預設 (由啟動器提供遊戲本
|
||||
settings.advanced.natives_directory.default.version_id=<實例名稱>
|
||||
settings.advanced.natives_directory.hint=本選項提供給 Apple Silicon 等未受遊戲官方支援的平台來自訂遊戲本機庫。如果你不知道本選項的含義,請你不要修改本選項,否則會導致遊戲無法啟動。\n\n如果你要修改本選項,你需要保證自訂目錄下有遊戲所需的本機庫檔案,如 lwjgl.dll (liblwjgl.so), openal.dll (libopenal.so) 等檔案。啟動器不會幫你補全缺少的本機庫檔案。\n\n注意:建議指定的本機庫檔案路徑使用全英文字元,否則可能導致遊戲啟動失敗。
|
||||
settings.advanced.no_jvm_args=不新增預設的 Java 虛擬機參數
|
||||
settings.advanced.no_optimizing_jvm_args=不自動添加 Java 虛擬機優化參數
|
||||
settings.advanced.no_optimizing_jvm_args=不自動新增 Java 虛擬機最佳化參數
|
||||
settings.advanced.precall_command=遊戲啟動前執行指令
|
||||
settings.advanced.precall_command.prompt=將在遊戲啟動前呼叫使用
|
||||
settings.advanced.process_priority=處理程式優先度
|
||||
@@ -1200,6 +1201,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一個免費、自由、開源的 Mine
|
||||
system.architecture=架構
|
||||
system.operating_system=作業系統
|
||||
|
||||
terracotta=多人遊戲
|
||||
terracotta.easytier=關於 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦聯機
|
||||
terracotta.status=聯機大廳
|
||||
terracotta.back=退出
|
||||
terracotta.sudo_installing=HMCL 需要驗證您的密碼才能安裝線上核心
|
||||
terracotta.from_local.title=線上核心第三方下載管道
|
||||
terracotta.from_local.desc=在部分地區,內建的預設下載管道可能不穩定或連線緩慢
|
||||
terracotta.from_local.guide=您應下載名為 %s 的線上核心套件。下載完成後,請將檔案拖曳到目前介面來安裝。
|
||||
terracotta.from_local.file_name_mismatch=您應該下載名為 %1$s 的線上核心包,而非 %2$s
|
||||
terracotta.export_log=匯出線上核心日誌
|
||||
terracotta.export_log.desc=為分析錯誤提供更多信息
|
||||
terracotta.status.bootstrap=正在收集資訊
|
||||
terracotta.status.uninitialized.not_exist=未下載聯機核心
|
||||
terracotta.status.uninitialized.not_exist.title=下載聯機核心(約 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新聯機核心
|
||||
terracotta.status.uninitialized.update.title=更新聯機核心(約 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承諾,在多人聯機全過程中,您將嚴格遵守您所在國家或地區的全部法律法規
|
||||
terracotta.confirm.title=使用者須知
|
||||
terracotta.confirm.desc=陶瓦聯機是第三方開源自由軟體,與 HMCL 無強關聯性。\n\
|
||||
多人連線基於 p2p,最終線上體驗和您的網路情況有較大關係。\n\
|
||||
您承諾,在多人連線全過程中,您將嚴格遵守您所在國家或地區的全部法律法規。
|
||||
terracotta.status.preparing=正在下載聯機核心(請勿退出啟動器)
|
||||
terracotta.status.launching=正在初始化聯機核心
|
||||
terracotta.status.unknown=正在初始化聯機核心
|
||||
terracotta.status.waiting=聯機核心已就緒
|
||||
terracotta.status.waiting.host.title=我想當房主
|
||||
terracotta.status.waiting.host.desc=建立房間並產生邀請碼,與好友一起暢玩
|
||||
terracotta.status.waiting.host.launch.title=您似乎忘記啟動遊戲了
|
||||
terracotta.status.waiting.host.launch.desc=未能找到正在執行的遊戲
|
||||
terracotta.status.waiting.host.launch.skip=遊戲已啟動
|
||||
terracotta.status.waiting.guest.title=我想當房客
|
||||
terracotta.status.waiting.guest.desc=輸入房主提供的邀請碼加入遊戲世界
|
||||
terracotta.status.waiting.guest.prompt.title=請輸入房主提供的邀請碼
|
||||
terracotta.status.waiting.guest.prompt.invalid=邀請碼錯誤
|
||||
terracotta.status.scanning=正在掃描區域網路世界
|
||||
terracotta.status.scanning.desc=請<a href="hmcl://game/launch">啟動遊戲</a>,進入單人存檔,按 ESC 鍵,選擇「在區網上公開」,點擊「開始區網世界」。
|
||||
terracotta.status.scanning.back=這將同時停止掃描區域網路世界。
|
||||
terracotta.status.host_starting=正在建立房間
|
||||
terracotta.status.host_starting.back=這將會取消建立房間。
|
||||
terracotta.status.host_ok=已建立房間
|
||||
terracotta.status.host_ok.code=邀請碼(已自動複製到剪貼簿)
|
||||
terracotta.status.host_ok.code.copy=複製邀請碼
|
||||
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
|
||||
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
|
||||
terracotta.status.guest_starting=正在加入房間
|
||||
terracotta.status.guest_starting.back=這不會影響其他房客加入目前房間。
|
||||
terracotta.status.guest_ok=已加入房間
|
||||
terracotta.status.guest_ok.back=這不會影響其他房客加入目前房間。
|
||||
terracotta.status.guest_ok.title=請啟動遊戲,選擇多人遊戲,雙擊進入陶瓦聯機大廳。
|
||||
terracotta.status.guest_ok.desc=備用連線位址:%s
|
||||
terracotta.status.exception.back=可再試一次
|
||||
terracotta.status.exception.desc.ping_host_fail=加入房間失敗:房間已關閉或網路不穩定
|
||||
terracotta.status.exception.desc.ping_host_rst=房間連線中斷:房間已關閉或網路不穩定
|
||||
terracotta.status.exception.desc.guest_et_crash=加入房間失敗:EasyTier 已崩潰,請向開發者回報該問題
|
||||
terracotta.status.exception.desc.host_et_crash=建立房間失敗:EasyTier 已崩潰,請向開發者回報問題
|
||||
terracotta.status.exception.desc.ping_server_rst=房間已關閉:您已退出遊戲存檔,房間已自動關閉
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=協議錯誤:房主發送了錯誤的回應資料,請向開發者回報該問題
|
||||
terracotta.status.fatal.retry=重試
|
||||
terracotta.status.fatal.os=抱歉,HMCL 不能在您的作業系統或架構上啟用多人連線。請使用更主流的作業系統
|
||||
terracotta.status.fatal.network=未能下載線上核心。請檢查網路連接,然後再試一次
|
||||
terracotta.status.fatal.install=嚴重錯誤:無法安裝線上核心
|
||||
terracotta.status.fatal.terracotta=嚴重錯誤:無法與線上核心通訊
|
||||
terracotta.status.fatal.unknown=嚴重錯誤:原因未知
|
||||
terracotta.player_list=玩家列表
|
||||
terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
|
||||
unofficial.hint=你正在使用第三方提供的 HMCL。我們無法保證其安全性,請注意甄別。
|
||||
|
||||
update=啟動器更新
|
||||
@@ -1226,6 +1297,7 @@ update.note=開發版與預覽版包含更多的功能以及錯誤修復,但
|
||||
update.latest=目前版本為最新版本
|
||||
update.no_browser=無法開啟瀏覽器。網址已經複製到剪貼簿了,你可以手動複製網址開啟頁面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前預覽 HMCL 版本
|
||||
|
||||
version=遊戲
|
||||
version.name=遊戲實例名稱
|
||||
@@ -1275,8 +1347,8 @@ version.search.prompt=輸入版本名稱進行搜尋
|
||||
version.settings=遊戲設定
|
||||
version.update=更新模組包
|
||||
|
||||
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 啟動 HMCL 以獲得最佳體驗。
|
||||
warning.software_rendering=HMCL 正在使用軟體渲染,效能將會受到很大影響。
|
||||
warning.java_interpreted_mode=HMCL 正在執行於直譯器模式的 Java 環境中,效能將會受到很大影響。\n我們建議你使用支援 JIT 的 Java 開啟 HMCL 以獲得最佳體驗。
|
||||
warning.software_rendering=HMCL 正在使用軟體繪製,效能將會受到很大影響。
|
||||
|
||||
wiki.tooltip=Minecraft Wiki 頁面
|
||||
wiki.version.game=https://zh.minecraft.wiki/w/Java版%s
|
||||
|
||||
@@ -1211,6 +1211,76 @@ sponsor.hmcl=Hello Minecraft! Launcher 是一个免费、自由、开放源代
|
||||
system.architecture=架构
|
||||
system.operating_system=操作系统
|
||||
|
||||
terracotta=多人联机
|
||||
terracotta.easytier=关于 EasyTier
|
||||
terracotta.terracotta=Terracotta | 陶瓦联机
|
||||
terracotta.status=联机大厅
|
||||
terracotta.back=退出
|
||||
terracotta.sudo_installing=HMCL 需要验证您的密码才能安装联机核心
|
||||
terracotta.from_local.title=联机核心第三方下载渠道
|
||||
terracotta.from_local.desc=在部分地区,HMCL 内置的默认下载渠道可能不稳定或连接缓慢
|
||||
terracotta.from_local.guide=您应当下载名为 %s 的联机核心包。下载完成后,请将文件拖入当前界面来安装。
|
||||
terracotta.from_local.file_name_mismatch=您应当下载名为 %1$s 的联机核心包,而非 %2$s
|
||||
terracotta.export_log=导出联机核心日志
|
||||
terracotta.export_log.desc=为分析错误提供更多信息
|
||||
terracotta.status.bootstrap=正在收集信息
|
||||
terracotta.status.uninitialized.not_exist=未下载联机核心
|
||||
terracotta.status.uninitialized.not_exist.title=下载联机核心(约 8MiB)
|
||||
terracotta.status.uninitialized.update=需更新联机核心
|
||||
terracotta.status.uninitialized.update.title=更新联机核心(约 8MiB)
|
||||
terracotta.status.uninitialized.desc=您承诺,在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规
|
||||
terracotta.confirm.title=用户须知
|
||||
terracotta.confirm.desc=陶瓦联机是第三方开源自由软件,与 HMCL 无强关联性。\n\
|
||||
多人联机基于 p2p,最终联机体验和您的网络情况有较大关系。\n\
|
||||
在多人联机全过程中,您将严格遵守您所在国家或地区的全部法律法规。
|
||||
terracotta.status.preparing=正在下载联机核心(请勿退出启动器)
|
||||
terracotta.status.launching=正在初始化联机核心
|
||||
terracotta.status.unknown=正在初始化联机核心
|
||||
terracotta.status.waiting=联机核心已就绪
|
||||
terracotta.status.waiting.host.title=我想当房主
|
||||
terracotta.status.waiting.host.desc=创建房间并生成邀请码,与好友一起畅玩
|
||||
terracotta.status.waiting.host.launch.title=您似乎忘记启动游戏了
|
||||
terracotta.status.waiting.host.launch.desc=未能找到正在运行的游戏
|
||||
terracotta.status.waiting.host.launch.skip=游戏已启动
|
||||
terracotta.status.waiting.guest.title=我想当房客
|
||||
terracotta.status.waiting.guest.desc=输入房主提供的邀请码加入游戏世界
|
||||
terracotta.status.waiting.guest.prompt.title=请输入房主提供的邀请码
|
||||
terracotta.status.waiting.guest.prompt.invalid=邀请码错误
|
||||
terracotta.status.scanning=正在扫描局域网世界
|
||||
terracotta.status.scanning.desc=请<a href="hmcl://game/launch">启动游戏</a>,进入单人存档,按下 ESC 键,选择对局域网开放,点击创建局域网世界。
|
||||
terracotta.status.scanning.back=这将同时停止扫描局域网世界。
|
||||
terracotta.status.host_starting=正在启动房间
|
||||
terracotta.status.host_starting.back=这将会取消创建房间。
|
||||
terracotta.status.host_ok=已启动房间
|
||||
terracotta.status.host_ok.code=邀请码(已自动复制到剪贴板)
|
||||
terracotta.status.host_ok.code.copy=复制邀请码
|
||||
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
|
||||
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
|
||||
terracotta.status.guest_starting=正在加入房间
|
||||
terracotta.status.guest_starting.back=这不会影响其他房客加入当前房间。
|
||||
terracotta.status.guest_ok=已加入房间
|
||||
terracotta.status.guest_ok.back=这不会影响其他房客加入当前房间。
|
||||
terracotta.status.guest_ok.title=请启动游戏,选择多人游戏,双击进入陶瓦联机大厅。
|
||||
terracotta.status.guest_ok.desc=备用联机地址:%s
|
||||
terracotta.status.exception.back=可再试一次
|
||||
terracotta.status.exception.desc.ping_host_fail=加入房间失败:房间已关闭或网络不稳定
|
||||
terracotta.status.exception.desc.ping_host_rst=房间连接断开:房间已关闭或网络不稳定
|
||||
terracotta.status.exception.desc.guest_et_crash=加入房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||
terracotta.status.exception.desc.host_et_crash=创建房间失败:EasyTier 已崩溃,请向开发者反馈该问题
|
||||
terracotta.status.exception.desc.ping_server_rst=房间已关闭:您已退出游戏存档,房间已自动关闭
|
||||
terracotta.status.exception.desc.scaffolding_invalid_response=协议错误:房主发送了错误的响应数据,请向开发者反馈该问题
|
||||
terracotta.status.fatal.retry=重试
|
||||
terracotta.status.fatal.os=抱歉,HMCL 不能在您的操作系统或架构上启用多人联机。请使用更主流的操作系统
|
||||
terracotta.status.fatal.network=未能下载联机核心。请检查网络连接,然后再试一次
|
||||
terracotta.status.fatal.install=严重错误:无法安装联机核心
|
||||
terracotta.status.fatal.terracotta=严重错误:无法与联机核心通讯
|
||||
terracotta.status.fatal.unknown=严重错误:原因未知
|
||||
terracotta.player_list=玩家列表
|
||||
terracotta.player_anonymous=匿名玩家
|
||||
terracotta.player_kind.host=房主
|
||||
terracotta.player_kind.local=你
|
||||
terracotta.player_kind.guest=房客
|
||||
|
||||
unofficial.hint=你正在使用非官方构建的 HMCL。我们无法保证其安全性,请注意甄别。
|
||||
|
||||
update=启动器更新
|
||||
@@ -1237,6 +1307,7 @@ update.note=开发版与预览版包含更多的功能以及错误修复,但
|
||||
update.latest=当前版本为最新版本
|
||||
update.no_browser=无法打开浏览器。网址已经复制到剪贴板,你可以手动粘贴网址打开页面。
|
||||
update.tooltip=更新
|
||||
update.preview=提前预览 HMCL 版本
|
||||
|
||||
version=游戏
|
||||
version.name=游戏实例名称
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version_legacy": "0\\.3\\.[89]-rc.[0-9]",
|
||||
"version_recent": [
|
||||
"0.3.9-rc.9"
|
||||
],
|
||||
"version_latest": "0.3.9-rc.10",
|
||||
"classifiers": {
|
||||
"linux-arm64": "sha256:d5d386e9936d99dd8c31cdab2098a9db04bf55263faf5cd27d388139a42dee0b",
|
||||
"linux-x86_64": "sha256:c43f111460c777315f12081d934729c08cc67b1a2048fdf3ae451e2682157dd8",
|
||||
"macos-arm64": "sha256:49cbf5880aa94551951abda0e225bbbd9c43b9078ead8a738f5b995085d6022c",
|
||||
"macos-arm64.pkg": "sha256:756831f50d67ed2382f8507dfcc94fcc32d6082e087cd4f6a2fc40ae39f25cdf",
|
||||
"macos-x86_64": "sha256:fdf519f018ce4b3d5b4be06b86dcf9990e94b4010db9dac391175d917214cc01",
|
||||
"macos-x86_64.pkg": "sha256:fc030f0205aa3667033c79ac514045bb7c94ae0175dc5946ea920eefee7ad997",
|
||||
"windows-arm64.exe": "sha256:4b395093b0acaea85a36ce1e569c206d9dfa7e11bfe70409f00bc6c6100df88e",
|
||||
"windows-x86_64.exe": "sha256:2b8add8f6330452d29faaa944ac63c35452e61c5a4ff009069d2c3017c7186c6"
|
||||
},
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://ghfast.top/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cdn.crashmc.com/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cp.zkitefly.eu.org/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"desc": {
|
||||
"default": "GitHub Release",
|
||||
"zh": "GitHub 发布页",
|
||||
"zh-Hant": "GitHub 發布頁"
|
||||
},
|
||||
"link": "https://github.com/burningtnt/Terracotta/releases/tag/v${version}"
|
||||
},
|
||||
{
|
||||
"desc": {
|
||||
"default": "Tencent QQ Group",
|
||||
"zh": "QQ 群",
|
||||
"zh-Hant": "QQ 群"
|
||||
},
|
||||
"link": "https://qm.qq.com/cgi-bin/qm/qr?k=nIf5u5xQ3LXEP4ZEmLQtfjtpppjgHfI5&jump_from=webapi&authKey=sXStlPuGzhD1JyAhyExd2OwjzZkRf3x7bAEb/j1xNX1wrQcDdg71qPrhumIm6pyf"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
boot.unsupported_java_version=HMCL requiere Java 17 o posterior para ejecutarse, pero sigue admitiendo el inicio de juegos con Java 6~16.\nPor favor, instala la última versión de Java e intenta ejecutar HMCL de nuevo.\nPuedes conservar tu versión antigua de Java. HMCL puede detectar y gestionar múltiples instalaciones de Java,\ny seleccionará automáticamente la versión de Java adecuada para tu juego.
|
||||
@@ -271,7 +271,7 @@ public class OAuth {
|
||||
}
|
||||
}
|
||||
|
||||
private static class DeviceTokenResponse extends ErrorResponse {
|
||||
private final static class DeviceTokenResponse extends ErrorResponse {
|
||||
@SerializedName("user_code")
|
||||
public String userCode;
|
||||
|
||||
@@ -291,7 +291,7 @@ public class OAuth {
|
||||
public int interval;
|
||||
}
|
||||
|
||||
private static class TokenResponse extends ErrorResponse {
|
||||
private final static class TokenResponse extends ErrorResponse {
|
||||
@SerializedName("token_type")
|
||||
public String tokenType;
|
||||
|
||||
@@ -329,7 +329,7 @@ public class OAuth {
|
||||
* redirect URI used to obtain the authorization
|
||||
* code.","correlation_id":"??????"}
|
||||
*/
|
||||
public static class AuthorizationResponse extends ErrorResponse {
|
||||
public final static class AuthorizationResponse extends ErrorResponse {
|
||||
@SerializedName("token_type")
|
||||
public String tokenType;
|
||||
|
||||
@@ -352,7 +352,7 @@ public class OAuth {
|
||||
public String foci;
|
||||
}
|
||||
|
||||
private static class RefreshResponse extends ErrorResponse {
|
||||
private final static class RefreshResponse extends ErrorResponse {
|
||||
@SerializedName("expires_in")
|
||||
int expiresIn;
|
||||
|
||||
|
||||
@@ -321,16 +321,16 @@ public class MicrosoftService {
|
||||
public static final long ADD_FAMILY = 2148916238L;
|
||||
}
|
||||
|
||||
public static class XBox400Exception extends AuthenticationException {
|
||||
public final static class XBox400Exception extends AuthenticationException {
|
||||
}
|
||||
|
||||
public static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
|
||||
public final static class NoMinecraftJavaEditionProfileException extends AuthenticationException {
|
||||
}
|
||||
|
||||
public static class NoXuiException extends AuthenticationException {
|
||||
public final static class NoXuiException extends AuthenticationException {
|
||||
}
|
||||
|
||||
private static class XBoxLiveAuthenticationResponseDisplayClaims {
|
||||
private final static class XBoxLiveAuthenticationResponseDisplayClaims {
|
||||
List<Map<Object, Object>> xui;
|
||||
}
|
||||
|
||||
@@ -356,7 +356,7 @@ public class MicrosoftService {
|
||||
* XErr Candidates: 2148916233 = missing XBox account 2148916238 = child account
|
||||
* not linked to a family
|
||||
*/
|
||||
private static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
|
||||
private final static class XBoxLiveAuthenticationResponse extends MicrosoftErrorResponse {
|
||||
@SerializedName("IssueInstant")
|
||||
String issueInstant;
|
||||
|
||||
@@ -370,7 +370,7 @@ public class MicrosoftService {
|
||||
XBoxLiveAuthenticationResponseDisplayClaims displayClaims;
|
||||
}
|
||||
|
||||
private static class MinecraftLoginWithXBoxResponse {
|
||||
private final static class MinecraftLoginWithXBoxResponse {
|
||||
@SerializedName("username")
|
||||
String username;
|
||||
|
||||
@@ -387,14 +387,14 @@ public class MicrosoftService {
|
||||
int expiresIn;
|
||||
}
|
||||
|
||||
private static class MinecraftStoreResponseItem {
|
||||
private final static class MinecraftStoreResponseItem {
|
||||
@SerializedName("name")
|
||||
String name;
|
||||
@SerializedName("signature")
|
||||
String signature;
|
||||
}
|
||||
|
||||
private static class MinecraftStoreResponse extends MinecraftErrorResponse {
|
||||
private final static class MinecraftStoreResponse extends MinecraftErrorResponse {
|
||||
@SerializedName("items")
|
||||
List<MinecraftStoreResponseItem> items;
|
||||
|
||||
@@ -405,7 +405,7 @@ public class MicrosoftService {
|
||||
String keyId;
|
||||
}
|
||||
|
||||
public static class MinecraftProfileResponseSkin implements Validation {
|
||||
public final static class MinecraftProfileResponseSkin implements Validation {
|
||||
public String id;
|
||||
public String state;
|
||||
public String url;
|
||||
|
||||
@@ -34,7 +34,7 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
@@ -244,7 +244,7 @@ public class Skin {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
return new Context() {
|
||||
final ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
@@ -260,7 +260,7 @@ public class Skin {
|
||||
setResult(new ByteArrayInputStream(baos.toByteArray()));
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheBytes(connection, baos.toByteArray());
|
||||
repository.cacheBytes(response, baos.toByteArray());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,11 +246,11 @@ public class YggdrasilService {
|
||||
}
|
||||
}
|
||||
|
||||
private static class TextureResponse {
|
||||
private final static class TextureResponse {
|
||||
public Map<TextureType, Texture> textures;
|
||||
}
|
||||
|
||||
private static class AuthenticationResponse extends ErrorResponse {
|
||||
private final static class AuthenticationResponse extends ErrorResponse {
|
||||
public String accessToken;
|
||||
public String clientToken;
|
||||
public GameProfile selectedProfile;
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.download;
|
||||
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -29,10 +30,10 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
public class AdaptedDownloadProvider implements DownloadProvider {
|
||||
|
||||
private List<DownloadProvider> downloadProviderCandidates;
|
||||
private @Unmodifiable List<DownloadProvider> downloadProviderCandidates;
|
||||
|
||||
public void setDownloadProviderCandidates(List<DownloadProvider> downloadProviderCandidates) {
|
||||
this.downloadProviderCandidates = new ArrayList<>(downloadProviderCandidates);
|
||||
this.downloadProviderCandidates = List.copyOf(downloadProviderCandidates);
|
||||
}
|
||||
|
||||
public DownloadProvider getPreferredDownloadProvider() {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.download;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public final class DownloadProviderWrapper implements DownloadProvider {
|
||||
|
||||
private DownloadProvider provider;
|
||||
|
||||
public DownloadProviderWrapper(DownloadProvider provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public DownloadProvider getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
public void setProvider(DownloadProvider provider) {
|
||||
this.provider = Objects.requireNonNull(provider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<URI> getAssetObjectCandidates(String assetObjectLocation) {
|
||||
return getProvider().getAssetObjectCandidates(assetObjectLocation);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getVersionListURL() {
|
||||
return getProvider().getVersionListURL();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAssetBaseURL() {
|
||||
return getProvider().getAssetBaseURL();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String injectURL(String baseURL) {
|
||||
return getProvider().injectURL(baseURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<URI> injectURLWithCandidates(String baseURL) {
|
||||
return getProvider().injectURLWithCandidates(baseURL);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<URI> injectURLsWithCandidates(List<String> urls) {
|
||||
return getProvider().injectURLsWithCandidates(urls);
|
||||
}
|
||||
|
||||
@Override
|
||||
public VersionList<?> getVersionListById(String id) {
|
||||
return getProvider().getVersionListById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getConcurrency() {
|
||||
return getProvider().getConcurrency();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -62,7 +62,7 @@ public final class CleanroomVersionList extends VersionList<CleanroomRemoteVersi
|
||||
});
|
||||
}
|
||||
|
||||
private static class ReleaseResult {
|
||||
private final static class ReleaseResult {
|
||||
String name;
|
||||
String created_at;
|
||||
}
|
||||
|
||||
@@ -17,29 +17,32 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.event;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
public final class EventBus {
|
||||
/// @author huangyuhui
|
||||
public final class EventBus extends ClassValue<EventManager<?>> {
|
||||
|
||||
private final ConcurrentHashMap<Class<?>, EventManager<?>> events = new ConcurrentHashMap<>();
|
||||
public static final EventBus EVENT_BUS = new EventBus();
|
||||
|
||||
private EventBus() {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected EventManager<?> computeValue(@NotNull Class<?> type) {
|
||||
return new EventManager<>();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T extends Event> EventManager<T> channel(Class<T> clazz) {
|
||||
return (EventManager<T>) events.computeIfAbsent(clazz, ignored -> new EventManager<>());
|
||||
return (EventManager<T>) get(clazz);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Event.Result fireEvent(Event obj) {
|
||||
LOG.info(obj + " gets fired");
|
||||
|
||||
return channel((Class<Event>) obj.getClass()).fireEvent(obj);
|
||||
return ((EventManager<Event>) get(obj.getClass())).fireEvent(obj);
|
||||
}
|
||||
|
||||
public static final EventBus EVENT_BUS = new EventBus();
|
||||
}
|
||||
@@ -18,26 +18,28 @@
|
||||
package org.jackhuang.hmcl.event;
|
||||
|
||||
import org.jackhuang.hmcl.util.SimpleMultimap;
|
||||
import org.jetbrains.annotations.Contract;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.EnumMap;
|
||||
import java.util.concurrent.CopyOnWriteArraySet;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
/// @author huangyuhui
|
||||
public final class EventManager<T extends Event> {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final SimpleMultimap<EventPriority, Consumer<T>, CopyOnWriteArraySet<Consumer<T>>> handlers
|
||||
= new SimpleMultimap<>(() -> new EnumMap<>(EventPriority.class), CopyOnWriteArraySet::new);
|
||||
|
||||
@Contract("_ -> param1")
|
||||
public Consumer<T> registerWeak(Consumer<T> consumer) {
|
||||
register(new WeakListener(consumer));
|
||||
return consumer;
|
||||
}
|
||||
|
||||
@Contract("_, _ -> param1")
|
||||
public Consumer<T> registerWeak(Consumer<T> consumer, EventPriority priority) {
|
||||
register(new WeakListener(consumer), priority);
|
||||
return consumer;
|
||||
@@ -47,9 +49,13 @@ public final class EventManager<T extends Event> {
|
||||
register(consumer, EventPriority.NORMAL);
|
||||
}
|
||||
|
||||
public synchronized void register(Consumer<T> consumer, EventPriority priority) {
|
||||
if (!handlers.get(priority).contains(consumer))
|
||||
handlers.put(priority, consumer);
|
||||
public void register(Consumer<T> consumer, EventPriority priority) {
|
||||
lock.lock();
|
||||
try {
|
||||
handlers.get(priority).add(consumer);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void register(Runnable runnable) {
|
||||
@@ -60,23 +66,30 @@ public final class EventManager<T extends Event> {
|
||||
register(t -> runnable.run(), priority);
|
||||
}
|
||||
|
||||
public synchronized Event.Result fireEvent(T event) {
|
||||
for (EventPriority priority : EventPriority.values()) {
|
||||
for (Consumer<T> handler : handlers.get(priority))
|
||||
handler.accept(event);
|
||||
public Event.Result fireEvent(T event) {
|
||||
lock.lock();
|
||||
try {
|
||||
for (EventPriority priority : EventPriority.values()) {
|
||||
for (Consumer<T> handler : handlers.get(priority))
|
||||
handler.accept(event);
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
if (event.hasResult())
|
||||
return event.getResult();
|
||||
else
|
||||
return Event.Result.DEFAULT;
|
||||
return event.hasResult() ? event.getResult() : Event.Result.DEFAULT;
|
||||
}
|
||||
|
||||
public synchronized void unregister(Consumer<T> consumer) {
|
||||
handlers.removeValue(consumer);
|
||||
public void unregister(Consumer<T> consumer) {
|
||||
lock.lock();
|
||||
try {
|
||||
handlers.removeValue(consumer);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private class WeakListener implements Consumer<T> {
|
||||
private final class WeakListener implements Consumer<T> {
|
||||
private final WeakReference<Consumer<T>> ref;
|
||||
|
||||
public WeakListener(Consumer<T> listener) {
|
||||
|
||||
@@ -279,7 +279,7 @@ public final class World {
|
||||
if (isLocked()) {
|
||||
throw new WorldLockedException("The world " + getFile() + " has been locked");
|
||||
}
|
||||
FileUtils.deleteDirectory(file);
|
||||
FileUtils.forceDelete(file);
|
||||
}
|
||||
|
||||
public CompoundTag readLevelDat() throws IOException {
|
||||
|
||||
@@ -24,7 +24,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
@@ -45,6 +45,9 @@ public final class CacheFileTask extends FetchTask<Path> {
|
||||
public CacheFileTask(@NotNull URI uri) {
|
||||
super(List.of(uri));
|
||||
setName(uri.toString());
|
||||
|
||||
if (!NetworkUtils.isHttpUri(uri))
|
||||
throw new IllegalArgumentException(uri.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -69,8 +72,9 @@ public final class CacheFileTask extends FetchTask<Path> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
assert checkETag;
|
||||
assert response != null;
|
||||
|
||||
Path temp = Files.createTempFile("hmcl-download-", null);
|
||||
OutputStream fileOutput = Files.newOutputStream(temp);
|
||||
@@ -99,7 +103,7 @@ public final class CacheFileTask extends FetchTask<Path> {
|
||||
}
|
||||
|
||||
try {
|
||||
setResult(repository.cacheRemoteFile(connection, temp));
|
||||
setResult(repository.cacheRemoteFile(response, temp));
|
||||
} finally {
|
||||
try {
|
||||
Files.deleteIfExists(temp);
|
||||
|
||||
@@ -19,31 +19,43 @@ package org.jackhuang.hmcl.task;
|
||||
|
||||
import org.jackhuang.hmcl.event.Event;
|
||||
import org.jackhuang.hmcl.event.EventBus;
|
||||
import org.jackhuang.hmcl.util.CacheRepository;
|
||||
import org.jackhuang.hmcl.util.DigestUtils;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.ToStringBuilder;
|
||||
import org.jackhuang.hmcl.event.EventManager;
|
||||
import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.ContentEncoding;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.io.ResponseCodeException;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public abstract class FetchTask<T> extends Task<T> {
|
||||
private static final HttpClient HTTP_CLIENT;
|
||||
|
||||
static {
|
||||
boolean useHttp2 = !"false".equalsIgnoreCase(System.getProperty("hmcl.http2"));
|
||||
|
||||
HTTP_CLIENT = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(NetworkUtils.TIME_OUT))
|
||||
.version(useHttp2 ? HttpClient.Version.HTTP_2 : HttpClient.Version.HTTP_1_1)
|
||||
.build();
|
||||
}
|
||||
|
||||
protected static final int DEFAULT_RETRY = 3;
|
||||
|
||||
protected final List<URI> uris;
|
||||
@@ -58,7 +70,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
if (this.uris.isEmpty())
|
||||
throw new IllegalArgumentException("At least one URL is required");
|
||||
|
||||
setExecutor(download());
|
||||
setExecutor(DOWNLOAD_EXECUTOR);
|
||||
}
|
||||
|
||||
public void setRetry(int retry) {
|
||||
@@ -79,12 +91,14 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
|
||||
protected abstract EnumCheckETag shouldCheckETag();
|
||||
|
||||
protected abstract Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException;
|
||||
private Context getContext() throws IOException {
|
||||
return getContext(null, false, null);
|
||||
}
|
||||
|
||||
protected abstract Context getContext(@Nullable HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException;
|
||||
|
||||
@Override
|
||||
public void execute() throws Exception {
|
||||
Exception exception = null;
|
||||
URI failedURI = null;
|
||||
boolean checkETag;
|
||||
switch (shouldCheckETag()) {
|
||||
case CHECK_E_TAG:
|
||||
@@ -97,174 +111,258 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
return;
|
||||
}
|
||||
|
||||
int repeat = 0;
|
||||
download:
|
||||
for (URI uri : uris) {
|
||||
if (checkETag) {
|
||||
// Handle cache
|
||||
ArrayList<DownloadException> exceptions = null;
|
||||
|
||||
if (SEMAPHORE != null)
|
||||
SEMAPHORE.acquire();
|
||||
try {
|
||||
for (URI uri : uris) {
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(uri, true);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
if (NetworkUtils.isHttpUri(uri))
|
||||
downloadHttp(uri, checkETag);
|
||||
else
|
||||
downloadNotHttp(uri);
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
} catch (DownloadException e) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
exceptions.add(e);
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException ignored) {
|
||||
// Cancelled
|
||||
} finally {
|
||||
if (SEMAPHORE != null)
|
||||
SEMAPHORE.release();
|
||||
}
|
||||
|
||||
for (int retryTime = 0; retryTime < retry; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
break download;
|
||||
if (exceptions != null) {
|
||||
DownloadException last = exceptions.remove(exceptions.size() - 1);
|
||||
for (DownloadException exception : exceptions) {
|
||||
last.addSuppressed(exception);
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
}
|
||||
|
||||
private void download(Context context,
|
||||
InputStream inputStream,
|
||||
long contentLength,
|
||||
ContentEncoding contentEncoding) throws IOException, InterruptedException {
|
||||
try (var ignored = context;
|
||||
var counter = new CounterInputStream(inputStream);
|
||||
var input = contentEncoding.wrap(counter)) {
|
||||
long lastDownloaded = 0L;
|
||||
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
|
||||
while (true) {
|
||||
if (isCancelled()) break;
|
||||
|
||||
int len = input.read(buffer);
|
||||
if (len == -1) break;
|
||||
|
||||
context.write(buffer, 0, len);
|
||||
|
||||
if (contentLength >= 0) {
|
||||
// Update progress information per second
|
||||
updateProgress(counter.downloaded, contentLength);
|
||||
}
|
||||
|
||||
List<URI> redirects = null;
|
||||
String bmclapiHash = null;
|
||||
try {
|
||||
beforeDownload(uri);
|
||||
updateProgress(0);
|
||||
updateDownloadSpeed(counter.downloaded - lastDownloaded);
|
||||
lastDownloaded = counter.downloaded;
|
||||
}
|
||||
|
||||
URLConnection conn = NetworkUtils.createConnection(uri);
|
||||
if (isCancelled())
|
||||
throw new InterruptedException();
|
||||
|
||||
if (conn instanceof HttpURLConnection httpConnection) {
|
||||
httpConnection.setRequestProperty("Accept-Encoding", "gzip");
|
||||
updateDownloadSpeed(counter.downloaded - lastDownloaded);
|
||||
|
||||
if (checkETag) repository.injectConnection(httpConnection);
|
||||
Map<String, List<String>> requestProperties = httpConnection.getRequestProperties();
|
||||
if (contentLength >= 0 && counter.downloaded != contentLength)
|
||||
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
|
||||
|
||||
bmclapiHash = httpConnection.getHeaderField("x-bmclapi-hash");
|
||||
if (DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
|
||||
if (cache.isPresent()) {
|
||||
useCachedResult(cache.get());
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
bmclapiHash = null;
|
||||
}
|
||||
context.withResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
int code = httpConnection.getResponseCode();
|
||||
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
|
||||
if (redirects == null) {
|
||||
redirects = new ArrayList<>();
|
||||
} else if (redirects.size() >= 20) {
|
||||
httpConnection.disconnect();
|
||||
throw new IOException("Too much redirects");
|
||||
}
|
||||
|
||||
String location = httpConnection.getHeaderField("Location");
|
||||
|
||||
httpConnection.disconnect();
|
||||
if (StringUtils.isBlank(location))
|
||||
throw new IOException("Redirected to an empty location");
|
||||
|
||||
URI target = NetworkUtils.toURI(httpConnection.getURL())
|
||||
.resolve(NetworkUtils.toURI(location));
|
||||
redirects.add(target);
|
||||
|
||||
if (!NetworkUtils.isHttpUri(target))
|
||||
throw new IOException("Redirected to not http URI: " + target);
|
||||
|
||||
HttpURLConnection redirected = NetworkUtils.createHttpConnection(target);
|
||||
redirected.setUseCaches(checkETag);
|
||||
requestProperties
|
||||
.forEach((key, values) ->
|
||||
values.forEach(element ->
|
||||
redirected.addRequestProperty(key, element)));
|
||||
httpConnection = redirected;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
conn = httpConnection;
|
||||
int responseCode = ((HttpURLConnection) conn).getResponseCode();
|
||||
|
||||
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
// Handle cache
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(NetworkUtils.toURI(conn.getURL()), false);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
} catch (CacheRepository.CacheExpiredException e) {
|
||||
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
|
||||
repository.removeRemoteEntry(conn.getURL().toURI());
|
||||
// Now we must reconnect the server since 304 may result in empty content,
|
||||
// if we want to redownload the file, we must reconnect the server without etag settings.
|
||||
retryTime--;
|
||||
continue;
|
||||
}
|
||||
} else if (responseCode / 100 == 4) {
|
||||
throw new FileNotFoundException(uri.toString());
|
||||
} else if (responseCode / 100 != 2) {
|
||||
throw new ResponseCodeException(uri, responseCode);
|
||||
}
|
||||
}
|
||||
|
||||
long contentLength = conn.getContentLengthLong();
|
||||
var encoding = ContentEncoding.fromConnection(conn);
|
||||
try (var context = getContext(conn, checkETag, bmclapiHash);
|
||||
var counter = new CounterInputStream(conn.getInputStream());
|
||||
var input = encoding.wrap(counter)) {
|
||||
long lastDownloaded = 0L;
|
||||
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
|
||||
while (true) {
|
||||
if (isCancelled()) break;
|
||||
|
||||
int len = input.read(buffer);
|
||||
if (len == -1) break;
|
||||
|
||||
context.write(buffer, 0, len);
|
||||
|
||||
if (contentLength >= 0) {
|
||||
// Update progress information per second
|
||||
updateProgress(counter.downloaded, contentLength);
|
||||
}
|
||||
|
||||
updateDownloadSpeed(counter.downloaded - lastDownloaded);
|
||||
lastDownloaded = counter.downloaded;
|
||||
}
|
||||
|
||||
if (isCancelled()) break download;
|
||||
|
||||
updateDownloadSpeed(counter.downloaded - lastDownloaded);
|
||||
|
||||
if (contentLength >= 0 && counter.downloaded != contentLength)
|
||||
throw new IOException("Unexpected file size: " + counter.downloaded + ", expected: " + contentLength);
|
||||
|
||||
context.withResult(true);
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (FileNotFoundException ex) {
|
||||
failedURI = uri;
|
||||
exception = ex;
|
||||
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
|
||||
break; // we will not try this URL again
|
||||
} catch (IOException ex) {
|
||||
failedURI = uri;
|
||||
exception = ex;
|
||||
LOG.warning("Failed to download " + uri + ", repeat times: " + (++repeat) + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
}
|
||||
private void downloadHttp(URI uri, boolean checkETag) throws DownloadException, InterruptedException {
|
||||
if (checkETag) {
|
||||
// Handle cache
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(uri, true);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
if (exception != null)
|
||||
throw new DownloadException(failedURI, exception);
|
||||
ArrayList<IOException> exceptions = null;
|
||||
|
||||
for (int retryTime = 0; retryTime < retry; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
|
||||
List<URI> redirects = null;
|
||||
try {
|
||||
beforeDownload(uri);
|
||||
updateProgress(0);
|
||||
|
||||
HttpResponse<InputStream> response;
|
||||
String bmclapiHash;
|
||||
|
||||
URI currentURI = uri;
|
||||
|
||||
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
|
||||
headers.put("accept-encoding", "gzip");
|
||||
if (checkETag)
|
||||
headers.putAll(repository.injectConnection(uri));
|
||||
|
||||
do {
|
||||
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(currentURI);
|
||||
requestBuilder.timeout(Duration.ofMillis(NetworkUtils.TIME_OUT));
|
||||
headers.forEach(requestBuilder::header);
|
||||
response = HTTP_CLIENT.send(requestBuilder.build(), BODY_HANDLER);
|
||||
|
||||
bmclapiHash = response.headers().firstValue("x-bmclapi-hash").orElse(null);
|
||||
if (DigestUtils.isSha1Digest(bmclapiHash)) {
|
||||
Optional<Path> cache = repository.checkExistentFile(null, "SHA-1", bmclapiHash);
|
||||
if (cache.isPresent()) {
|
||||
useCachedResult(cache.get());
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
int code = response.statusCode();
|
||||
if (code >= 300 && code <= 308 && code != 306 && code != 304) {
|
||||
if (redirects == null) {
|
||||
redirects = new ArrayList<>();
|
||||
} else if (redirects.size() >= 20) {
|
||||
throw new IOException("Too much redirects");
|
||||
}
|
||||
|
||||
String location = response.headers().firstValue("Location").orElse(null);
|
||||
if (StringUtils.isBlank(location))
|
||||
throw new IOException("Redirected to an empty location");
|
||||
|
||||
URI target = currentURI.resolve(NetworkUtils.encodeLocation(location));
|
||||
redirects.add(target);
|
||||
|
||||
if (!NetworkUtils.isHttpUri(target))
|
||||
throw new IOException("Redirected to not http URI: " + target);
|
||||
|
||||
currentURI = target;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} while (true);
|
||||
|
||||
int responseCode = response.statusCode();
|
||||
if (responseCode == HttpURLConnection.HTTP_NOT_MODIFIED) {
|
||||
// Handle cache
|
||||
try {
|
||||
Path cache = repository.getCachedRemoteFile(currentURI, false);
|
||||
useCachedResult(cache);
|
||||
LOG.info("Using cached file for " + NetworkUtils.dropQuery(uri));
|
||||
return;
|
||||
} catch (CacheRepository.CacheExpiredException e) {
|
||||
LOG.info("Cache expired for " + NetworkUtils.dropQuery(uri));
|
||||
} catch (IOException e) {
|
||||
LOG.warning("Unable to use cached file, redownload " + NetworkUtils.dropQuery(uri), e);
|
||||
repository.removeRemoteEntry(currentURI);
|
||||
// Now we must reconnect the server since 304 may result in empty content,
|
||||
// if we want to redownload the file, we must reconnect the server without etag settings.
|
||||
retryTime--;
|
||||
continue;
|
||||
}
|
||||
} else if (responseCode / 100 == 4) {
|
||||
throw new FileNotFoundException(uri.toString());
|
||||
} else if (responseCode / 100 != 2) {
|
||||
throw new ResponseCodeException(uri, responseCode);
|
||||
}
|
||||
|
||||
long contentLength = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
var contentEncoding = ContentEncoding.fromResponse(response);
|
||||
|
||||
download(getContext(response, checkETag, bmclapiHash),
|
||||
response.body(),
|
||||
contentLength,
|
||||
contentEncoding);
|
||||
return;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found" + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (IOException ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
|
||||
exceptions.add(ex);
|
||||
|
||||
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime + (redirects == null ? "" : ", redirects: " + redirects), ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw toDownloadException(uri, null, exceptions);
|
||||
}
|
||||
|
||||
private void downloadNotHttp(URI uri) throws DownloadException, InterruptedException {
|
||||
ArrayList<IOException> exceptions = null;
|
||||
for (int retryTime = 0; retryTime < retry; retryTime++) {
|
||||
if (isCancelled()) {
|
||||
throw new InterruptedException();
|
||||
}
|
||||
|
||||
try {
|
||||
beforeDownload(uri);
|
||||
updateProgress(0);
|
||||
|
||||
URLConnection conn = NetworkUtils.createConnection(uri);
|
||||
download(getContext(),
|
||||
conn.getInputStream(),
|
||||
conn.getContentLengthLong(),
|
||||
ContentEncoding.fromConnection(conn));
|
||||
return;
|
||||
} catch (FileNotFoundException ex) {
|
||||
LOG.warning("Failed to download " + uri + ", not found", ex);
|
||||
|
||||
throw toDownloadException(uri, ex, exceptions); // we will not try this URL again
|
||||
} catch (IOException ex) {
|
||||
if (exceptions == null)
|
||||
exceptions = new ArrayList<>();
|
||||
|
||||
exceptions.add(ex);
|
||||
LOG.warning("Failed to download " + uri + ", repeat times: " + retryTime, ex);
|
||||
}
|
||||
}
|
||||
|
||||
throw toDownloadException(uri, null, exceptions);
|
||||
}
|
||||
|
||||
private static DownloadException toDownloadException(URI uri, @Nullable IOException last, @Nullable ArrayList<IOException> exceptions) {
|
||||
if (exceptions == null || exceptions.isEmpty()) {
|
||||
return new DownloadException(uri, last != null
|
||||
? last
|
||||
: new IOException("No exceptions"));
|
||||
} else {
|
||||
if (last == null)
|
||||
last = exceptions.remove(exceptions.size() - 1);
|
||||
|
||||
for (IOException e : exceptions) {
|
||||
last.addSuppressed(e);
|
||||
}
|
||||
return new DownloadException(uri, last);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Timer timer = new Timer("DownloadSpeedRecorder", true);
|
||||
private static final AtomicLong downloadSpeed = new AtomicLong(0L);
|
||||
public static final EventBus speedEvent = new EventBus();
|
||||
public static final EventManager<SpeedEvent> SPEED_EVENT = EventBus.EVENT_BUS.channel(SpeedEvent.class);
|
||||
|
||||
static {
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
speedEvent.channel(SpeedEvent.class).fireEvent(new SpeedEvent(speedEvent, downloadSpeed.getAndSet(0)));
|
||||
SPEED_EVENT.fireEvent(new SpeedEvent(SPEED_EVENT, downloadSpeed.getAndSet(0)));
|
||||
}
|
||||
}, 0, 1000);
|
||||
}
|
||||
@@ -326,7 +424,7 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
|
||||
public abstract void write(byte[] buffer, int offset, int len) throws IOException;
|
||||
|
||||
public final void withResult(boolean success) {
|
||||
public void withResult(boolean success) {
|
||||
this.success = success;
|
||||
}
|
||||
|
||||
@@ -341,48 +439,74 @@ public abstract class FetchTask<T> extends Task<T> {
|
||||
CACHED
|
||||
}
|
||||
|
||||
private static final HttpResponse.BodyHandler<InputStream> BODY_HANDLER = responseInfo -> {
|
||||
if (responseInfo.statusCode() / 100 == 2)
|
||||
return HttpResponse.BodySubscribers.ofInputStream();
|
||||
else
|
||||
return HttpResponse.BodySubscribers.replacing(null);
|
||||
};
|
||||
|
||||
public static int DEFAULT_CONCURRENCY = Math.min(Runtime.getRuntime().availableProcessors() * 4, 64);
|
||||
private static int downloadExecutorConcurrency = DEFAULT_CONCURRENCY;
|
||||
private static volatile ThreadPoolExecutor DOWNLOAD_EXECUTOR;
|
||||
|
||||
/**
|
||||
* Get singleton instance of the thread pool for file downloading.
|
||||
*
|
||||
* @return Thread pool for FetchTask
|
||||
*/
|
||||
protected static ExecutorService download() {
|
||||
if (DOWNLOAD_EXECUTOR == null) {
|
||||
synchronized (Schedulers.class) {
|
||||
if (DOWNLOAD_EXECUTOR == null) {
|
||||
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
// For Java 21 or later, DOWNLOAD_EXECUTOR dispatches tasks to virtual threads, and concurrency is controlled by SEMAPHORE.
|
||||
// For versions earlier than Java 21, DOWNLOAD_EXECUTOR is a ThreadPoolExecutor, SEMAPHORE is null, and concurrency is controlled by the thread pool size.
|
||||
|
||||
private static final ExecutorService DOWNLOAD_EXECUTOR;
|
||||
private static final @Nullable Semaphore SEMAPHORE;
|
||||
|
||||
static {
|
||||
ExecutorService executorService = Schedulers.newVirtualThreadPerTaskExecutor("Download");
|
||||
if (executorService != null) {
|
||||
DOWNLOAD_EXECUTOR = executorService;
|
||||
SEMAPHORE = new Semaphore(DEFAULT_CONCURRENCY);
|
||||
} else {
|
||||
DOWNLOAD_EXECUTOR = threadPool("Download", true, downloadExecutorConcurrency, 10, TimeUnit.SECONDS);
|
||||
SEMAPHORE = null;
|
||||
}
|
||||
|
||||
return DOWNLOAD_EXECUTOR;
|
||||
}
|
||||
|
||||
@FXThread
|
||||
public static void setDownloadExecutorConcurrency(int concurrency) {
|
||||
concurrency = Math.max(concurrency, 1);
|
||||
synchronized (Schedulers.class) {
|
||||
downloadExecutorConcurrency = concurrency;
|
||||
|
||||
ThreadPoolExecutor downloadExecutor = DOWNLOAD_EXECUTOR;
|
||||
if (downloadExecutor != null) {
|
||||
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
|
||||
downloadExecutor.setMaximumPoolSize(concurrency);
|
||||
downloadExecutor.setCorePoolSize(concurrency);
|
||||
} else {
|
||||
downloadExecutor.setCorePoolSize(concurrency);
|
||||
downloadExecutor.setMaximumPoolSize(concurrency);
|
||||
int prevDownloadExecutorConcurrency = downloadExecutorConcurrency;
|
||||
int change = concurrency - prevDownloadExecutorConcurrency;
|
||||
if (change == 0)
|
||||
return;
|
||||
|
||||
downloadExecutorConcurrency = concurrency;
|
||||
if (SEMAPHORE != null) {
|
||||
if (change > 0) {
|
||||
SEMAPHORE.release(change);
|
||||
} else {
|
||||
int permits = -change;
|
||||
if (!SEMAPHORE.tryAcquire(permits)) {
|
||||
Schedulers.io().execute(() -> {
|
||||
try {
|
||||
for (int i = 0; i < permits; i++) {
|
||||
SEMAPHORE.acquire();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
throw new AssertionError("Unreachable", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var downloadExecutor = (ThreadPoolExecutor) DOWNLOAD_EXECUTOR;
|
||||
|
||||
if (downloadExecutor.getMaximumPoolSize() <= concurrency) {
|
||||
downloadExecutor.setMaximumPoolSize(concurrency);
|
||||
downloadExecutor.setCorePoolSize(concurrency);
|
||||
} else {
|
||||
downloadExecutor.setCorePoolSize(concurrency);
|
||||
downloadExecutor.setMaximumPoolSize(concurrency);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int getDownloadExecutorConcurrency() {
|
||||
synchronized (Schedulers.class) {
|
||||
return downloadExecutorConcurrency;
|
||||
}
|
||||
return downloadExecutorConcurrency;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.file.FileSystem;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -185,7 +185,7 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) throws IOException {
|
||||
Path temp = Files.createTempFile(null, null);
|
||||
|
||||
String algorithm;
|
||||
@@ -260,7 +260,7 @@ public class FileDownloadTask extends FetchTask<Void> {
|
||||
}
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheRemoteFile(connection, file);
|
||||
repository.cacheRemoteFile(response, file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,9 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -59,9 +61,11 @@ public final class GetTask extends FetchTask<String> {
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Context getContext(URLConnection connection, boolean checkETag, String bmclapiHash) {
|
||||
int length = connection.getContentLength();
|
||||
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : length);
|
||||
protected Context getContext(HttpResponse<?> response, boolean checkETag, String bmclapiHash) {
|
||||
long length = -1;
|
||||
if (response != null)
|
||||
length = response.headers().firstValueAsLong("content-length").orElse(-1L);
|
||||
final var baos = new ByteArrayOutputStream(length <= 0 ? 8192 : (int) length);
|
||||
|
||||
return new Context() {
|
||||
@Override
|
||||
@@ -73,11 +77,15 @@ public final class GetTask extends FetchTask<String> {
|
||||
public void close() throws IOException {
|
||||
if (!isSuccess()) return;
|
||||
|
||||
String result = baos.toString(NetworkUtils.getCharsetFromContentType(connection.getContentType()));
|
||||
Charset charset = StandardCharsets.UTF_8;
|
||||
if (response != null)
|
||||
charset = NetworkUtils.getCharsetFromContentType(response.headers().firstValue("content-type").orElse(null));
|
||||
|
||||
String result = baos.toString(charset);
|
||||
setResult(result);
|
||||
|
||||
if (checkETag) {
|
||||
repository.cacheText(connection, result);
|
||||
repository.cacheText(response, result);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -18,61 +18,103 @@
|
||||
package org.jackhuang.hmcl.task;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.MethodType;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.threadPool;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
*
|
||||
* @author huangyuhui
|
||||
*/
|
||||
/// @author huangyuhui
|
||||
public final class Schedulers {
|
||||
|
||||
private Schedulers() {
|
||||
}
|
||||
|
||||
private static volatile ExecutorService IO_EXECUTOR;
|
||||
private static final @Nullable Function<String, ExecutorService> NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR;
|
||||
|
||||
/**
|
||||
* Get singleton instance of the thread pool for I/O operations,
|
||||
* usually for reading files from disk, or Internet connections.
|
||||
*
|
||||
* This thread pool has no more than 4 threads, and number of threads will get
|
||||
* reduced if concurrency is less than thread number.
|
||||
*
|
||||
* @return Thread pool for I/O operations.
|
||||
*/
|
||||
public static ExecutorService io() {
|
||||
if (IO_EXECUTOR == null) {
|
||||
synchronized (Schedulers.class) {
|
||||
if (IO_EXECUTOR == null) {
|
||||
IO_EXECUTOR = threadPool("IO", true, 4, 10, TimeUnit.SECONDS);
|
||||
}
|
||||
static {
|
||||
if (Runtime.version().feature() >= 21) {
|
||||
try {
|
||||
MethodHandles.Lookup lookup = MethodHandles.publicLookup();
|
||||
|
||||
Class<?> vtBuilderCls = Class.forName("java.lang.Thread$Builder$OfVirtual");
|
||||
|
||||
MethodHandle ofVirtualHandle = lookup.findStatic(Thread.class, "ofVirtual", MethodType.methodType(vtBuilderCls));
|
||||
MethodHandle setNameHandle = lookup.findVirtual(vtBuilderCls, "name", MethodType.methodType(vtBuilderCls, String.class, long.class));
|
||||
MethodHandle toFactoryHandle = lookup.findVirtual(vtBuilderCls, "factory", MethodType.methodType(ThreadFactory.class));
|
||||
MethodHandle newThreadPerTaskExecutorFactory = lookup.findStatic(Executors.class, "newThreadPerTaskExecutor", MethodType.methodType(ExecutorService.class, ThreadFactory.class));
|
||||
|
||||
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = name -> {
|
||||
try {
|
||||
Object virtualThreadBuilder = ofVirtualHandle.invoke();
|
||||
setNameHandle.invoke(virtualThreadBuilder, name, 1L);
|
||||
ThreadFactory threadFactory = (ThreadFactory) toFactoryHandle.invoke(virtualThreadBuilder);
|
||||
|
||||
return (ExecutorService) newThreadPerTaskExecutorFactory.invokeExact(threadFactory);
|
||||
} catch (Throwable e) {
|
||||
throw new AssertionError("Unreachable", e);
|
||||
}
|
||||
};
|
||||
} catch (Throwable e) {
|
||||
throw new AssertionError("Unreachable", e);
|
||||
}
|
||||
} else {
|
||||
NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// @return Returns null if the Java version is below 21, otherwise always returns a non-null value.
|
||||
public static ExecutorService newVirtualThreadPerTaskExecutor(String name) {
|
||||
if (NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return IO_EXECUTOR;
|
||||
return NEW_VIRTUAL_THREAD_PER_TASK_EXECUTOR.apply(name);
|
||||
}
|
||||
|
||||
/// This thread pool is suitable for network and local I/O operations.
|
||||
///
|
||||
/// For Java 21 or later, all tasks will be dispatched to virtual threads.
|
||||
///
|
||||
/// @return Thread pool for I/O operations.
|
||||
public static ExecutorService io() {
|
||||
return Holder.IO_EXECUTOR;
|
||||
}
|
||||
|
||||
public static Executor javafx() {
|
||||
return Platform::runLater;
|
||||
}
|
||||
|
||||
/// Default thread pool, equivalent to [ForkJoinPool#commonPool()].
|
||||
///
|
||||
/// It is recommended to perform computation tasks on this thread pool. For I/O operations, please use [#io()].
|
||||
public static Executor defaultScheduler() {
|
||||
return ForkJoinPool.commonPool();
|
||||
}
|
||||
|
||||
public static synchronized void shutdown() {
|
||||
public static void shutdown() {
|
||||
LOG.info("Shutting down executor services.");
|
||||
|
||||
// shutdownNow will interrupt all threads.
|
||||
// So when we want to close the app, no threads need to be waited for finish.
|
||||
// Sometimes it resolves the problem that the app does not exit.
|
||||
}
|
||||
|
||||
if (IO_EXECUTOR != null)
|
||||
IO_EXECUTOR.shutdownNow();
|
||||
private static final class Holder {
|
||||
private static final ExecutorService IO_EXECUTOR;
|
||||
|
||||
static {
|
||||
//noinspection resource
|
||||
ExecutorService vtExecutor = newVirtualThreadPerTaskExecutor("IO");
|
||||
IO_EXECUTOR = vtExecutor != null
|
||||
? vtExecutor
|
||||
: Executors.newCachedThreadPool(Lang.counterThreadFactory("IO", true));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,12 +23,13 @@ import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.channels.Channels;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
@@ -184,15 +185,35 @@ public class CacheRepository {
|
||||
}
|
||||
}
|
||||
|
||||
public void injectConnection(HttpURLConnection conn) {
|
||||
conn.setUseCaches(true);
|
||||
|
||||
URI uri;
|
||||
public @NotNull Map<String, String> injectConnection(URI uri) {
|
||||
try {
|
||||
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(conn.getURL()));
|
||||
uri = NetworkUtils.dropQuery(uri);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
ETagItem eTagItem;
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
eTagItem = index.get(uri);
|
||||
} finally {
|
||||
lock.readLock().unlock();
|
||||
}
|
||||
if (eTagItem == null) return Map.of();
|
||||
if (eTagItem.eTag != null)
|
||||
return Map.of("if-none-match", eTagItem.eTag);
|
||||
// if (eTagItem.getRemoteLastModified() != null)
|
||||
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
public void injectConnection(URI uri, HttpRequest.Builder requestBuilder) {
|
||||
try {
|
||||
uri = NetworkUtils.dropQuery(uri);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return;
|
||||
}
|
||||
|
||||
ETagItem eTagItem;
|
||||
lock.readLock().lock();
|
||||
try {
|
||||
@@ -202,25 +223,25 @@ public class CacheRepository {
|
||||
}
|
||||
if (eTagItem == null) return;
|
||||
if (eTagItem.eTag != null)
|
||||
conn.setRequestProperty("If-None-Match", eTagItem.eTag);
|
||||
requestBuilder.header("if-none-match", eTagItem.eTag);
|
||||
// if (eTagItem.getRemoteLastModified() != null)
|
||||
// conn.setRequestProperty("If-Modified-Since", eTagItem.getRemoteLastModified());
|
||||
}
|
||||
|
||||
public Path cacheRemoteFile(URLConnection connection, Path downloaded) throws IOException {
|
||||
return cacheData(connection, () -> {
|
||||
public Path cacheRemoteFile(HttpResponse<?> response, Path downloaded) throws IOException {
|
||||
return cacheData(response, () -> {
|
||||
String hash = DigestUtils.digestToString(SHA1, downloaded);
|
||||
Path cached = cacheFile(downloaded, SHA1, hash);
|
||||
return new CacheResult(hash, cached);
|
||||
});
|
||||
}
|
||||
|
||||
public Path cacheText(URLConnection connection, String text) throws IOException {
|
||||
return cacheBytes(connection, text.getBytes(UTF_8));
|
||||
public Path cacheText(HttpResponse<?> response, String text) throws IOException {
|
||||
return cacheBytes(response, text.getBytes(UTF_8));
|
||||
}
|
||||
|
||||
public Path cacheBytes(URLConnection connection, byte[] bytes) throws IOException {
|
||||
return cacheData(connection, () -> {
|
||||
public Path cacheBytes(HttpResponse<?> response, byte[] bytes) throws IOException {
|
||||
return cacheData(response, () -> {
|
||||
String hash = DigestUtils.digestToString(SHA1, bytes);
|
||||
Path cached = getFile(SHA1, hash);
|
||||
Files.createDirectories(cached.getParent());
|
||||
@@ -231,20 +252,15 @@ public class CacheRepository {
|
||||
|
||||
private static final Pattern MAX_AGE = Pattern.compile("(s-maxage|max-age)=(?<time>[0-9]+)");
|
||||
|
||||
private Path cacheData(URLConnection connection, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
|
||||
String eTag = connection.getHeaderField("ETag");
|
||||
private Path cacheData(HttpResponse<?> response, ExceptionalSupplier<CacheResult, IOException> cacheSupplier) throws IOException {
|
||||
String eTag = response.headers().firstValue("etag").orElse(null);
|
||||
if (StringUtils.isBlank(eTag)) return null;
|
||||
URI uri;
|
||||
try {
|
||||
uri = NetworkUtils.dropQuery(NetworkUtils.toURI(connection.getURL()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
URI uri = NetworkUtils.dropQuery(response.uri());
|
||||
long expires = 0L;
|
||||
|
||||
expires:
|
||||
try {
|
||||
String cacheControl = connection.getHeaderField("Cache-Control");
|
||||
String cacheControl = response.headers().firstValue("cache-control").orElse(null);
|
||||
if (StringUtils.isNotBlank(cacheControl)) {
|
||||
if (cacheControl.contains("no-store"))
|
||||
return null;
|
||||
@@ -257,7 +273,7 @@ public class CacheRepository {
|
||||
}
|
||||
}
|
||||
|
||||
String expiresHeader = connection.getHeaderField("Expires");
|
||||
String expiresHeader = response.headers().firstValue("expires").orElse(null);
|
||||
if (StringUtils.isNotBlank(expiresHeader)) {
|
||||
expires = ZonedDateTime.parse(expiresHeader.trim(), DateTimeFormatter.RFC_1123_DATE_TIME)
|
||||
.toInstant().toEpochMilli();
|
||||
@@ -266,7 +282,7 @@ public class CacheRepository {
|
||||
LOG.warning("Failed to parse expires time", e);
|
||||
}
|
||||
|
||||
String lastModified = connection.getHeaderField("Last-Modified");
|
||||
String lastModified = response.headers().firstValue("last-modified").orElse(null);
|
||||
|
||||
CacheResult cacheResult = cacheSupplier.get();
|
||||
ETagItem eTagItem = new ETagItem(uri.toString(),
|
||||
|
||||
@@ -21,15 +21,14 @@ import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* When {@link #accept(T)} is called, this class invokes the handler on another thread.
|
||||
* If {@link #accept(T)} is called more than one time before the handler starts processing,
|
||||
* the handler will only be invoked once, taking the latest argument as its input.
|
||||
*
|
||||
* @author yushijinhun
|
||||
*/
|
||||
/// When [#accept(T)] is called, this class invokes the handler on another thread.
|
||||
/// If [#accept(T)] is called more than one time before the handler starts processing,
|
||||
/// the handler will only be invoked once, taking the latest argument as its input.
|
||||
///
|
||||
/// @author yushijinhun
|
||||
public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
/// @param executor The executor must dispatch all tasks to a single thread.
|
||||
public static <T> InvocationDispatcher<T> runOn(Executor executor, Consumer<T> action) {
|
||||
return new InvocationDispatcher<>(executor, action);
|
||||
}
|
||||
@@ -47,9 +46,10 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
public void accept(T t) {
|
||||
if (pendingArg.getAndSet(new Holder<>(t)) == null) {
|
||||
executor.execute(() -> {
|
||||
synchronized (InvocationDispatcher.this) {
|
||||
action.accept(pendingArg.getAndSet(null).value);
|
||||
}
|
||||
// If the executor supports multiple underlying threads,
|
||||
// we need to add synchronization, but for now we can omit it :)
|
||||
// synchronized (InvocationDispatcher.this)
|
||||
action.accept(pendingArg.getAndSet(null).value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,14 +236,22 @@ public final class Lang {
|
||||
}
|
||||
|
||||
public static ThreadPoolExecutor threadPool(String name, boolean daemon, int threads, long timeout, TimeUnit timeunit) {
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(
|
||||
threads, threads,
|
||||
timeout, timeunit,
|
||||
new LinkedBlockingQueue<>(),
|
||||
counterThreadFactory(name, daemon));
|
||||
pool.allowCoreThreadTimeOut(true);
|
||||
return pool;
|
||||
}
|
||||
|
||||
public static ThreadFactory counterThreadFactory(String name, boolean daemon) {
|
||||
AtomicInteger counter = new AtomicInteger(1);
|
||||
ThreadPoolExecutor pool = new ThreadPoolExecutor(threads, threads, timeout, timeunit, new LinkedBlockingQueue<>(), r -> {
|
||||
return r -> {
|
||||
Thread t = new Thread(r, name + "-" + counter.getAndIncrement());
|
||||
t.setDaemon(daemon);
|
||||
return t;
|
||||
});
|
||||
pool.allowCoreThreadTimeOut(true);
|
||||
return pool;
|
||||
};
|
||||
}
|
||||
|
||||
public static int parseInt(Object string, int defaultValue) {
|
||||
|
||||
+8
-61
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.ResourceBundle;
|
||||
@@ -31,7 +30,7 @@ import java.util.ResourceBundle;
|
||||
/// - For all Chinese locales, `zh-CN` is always added to the candidate list. If `zh-Hans` already exists in the candidate list,
|
||||
/// `zh-CN` is inserted before `zh`; otherwise, it is inserted after `zh`.
|
||||
/// - For all Traditional Chinese locales, `zh-TW` is always added to the candidate list (before `zh`).
|
||||
/// - For all [supported][LocaleUtils#toISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list.
|
||||
///
|
||||
/// @author Glavo
|
||||
@@ -42,67 +41,15 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control {
|
||||
public DefaultResourceBundleControl() {
|
||||
}
|
||||
|
||||
private static List<Locale> ensureEditable(List<Locale> list) {
|
||||
return list instanceof ArrayList<?>
|
||||
? list
|
||||
: new ArrayList<>(list);
|
||||
@Override
|
||||
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
|
||||
return LocaleUtils.getCandidateLocales(locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
|
||||
if (locale.getLanguage().isEmpty())
|
||||
return getCandidateLocales(baseName, Locale.ENGLISH);
|
||||
else if (LocaleUtils.isChinese(locale)) {
|
||||
String script = locale.getScript();
|
||||
|
||||
if (script.isEmpty()) {
|
||||
script = LocaleUtils.getScript(locale);
|
||||
if (!script.isEmpty())
|
||||
return getCandidateLocales(baseName, new Locale.Builder()
|
||||
.setLocale(locale)
|
||||
.setScript(script)
|
||||
.build());
|
||||
}
|
||||
}
|
||||
|
||||
String language = locale.getLanguage();
|
||||
|
||||
List<Locale> locales = super.getCandidateLocales(baseName, locale);
|
||||
|
||||
// Is ISO 639-3 language tag
|
||||
if (language.length() == 3) {
|
||||
String iso1 = LocaleUtils.toISO1Language(locale.getLanguage());
|
||||
|
||||
if (iso1.length() == 2) {
|
||||
locales = ensureEditable(locales);
|
||||
locales.removeIf(it -> !it.getLanguage().equals(language));
|
||||
|
||||
locales.addAll(getCandidateLocales(baseName, new Locale.Builder()
|
||||
.setLocale(locale)
|
||||
.setLanguage(iso1)
|
||||
.build()));
|
||||
}
|
||||
} else if (language.equals("zh")) {
|
||||
if (locales.contains(LocaleUtils.LOCALE_ZH_HANT) && !locales.contains(Locale.TRADITIONAL_CHINESE)) {
|
||||
locales = ensureEditable(locales);
|
||||
int chineseIdx = locales.indexOf(Locale.CHINESE);
|
||||
if (chineseIdx >= 0)
|
||||
locales.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
|
||||
}
|
||||
|
||||
if (!locales.contains(Locale.SIMPLIFIED_CHINESE)) {
|
||||
int chineseIdx = locales.indexOf(Locale.CHINESE);
|
||||
|
||||
if (chineseIdx >= 0) {
|
||||
locales = ensureEditable(locales);
|
||||
if (locales.contains(LocaleUtils.LOCALE_ZH_HANS))
|
||||
locales.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
|
||||
else
|
||||
locales.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return locales;
|
||||
public Locale getFallbackLocale(String baseName, Locale locale) {
|
||||
// By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup.
|
||||
// Since we always use the base bundle as the English resource file, we want to suppress this behavior.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
@@ -25,7 +27,16 @@ import org.jetbrains.annotations.Unmodifiable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
@@ -42,6 +53,61 @@ public final class LocaleUtils {
|
||||
|
||||
public static final String DEFAULT_LANGUAGE_KEY = "default";
|
||||
|
||||
private static final Map<String, String> subLanguageToParent = new HashMap<>();
|
||||
private static final Map<String, String> iso3To2 = new HashMap<>();
|
||||
|
||||
static {
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2) {
|
||||
LOG.warning("Invalid line in sublanguages.csv: " + line);
|
||||
continue;
|
||||
}
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
subLanguageToParent.put(languages[i], parent);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load sublanguages.csv", e);
|
||||
}
|
||||
|
||||
try {
|
||||
// Line Format: (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] parts = line.split(",", 3);
|
||||
if (parts.length != 2) {
|
||||
LOG.warning("Invalid line in iso_languages.csv: " + line);
|
||||
continue;
|
||||
}
|
||||
|
||||
iso3To2.put(parts[1], parts[0]);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load iso_languages.csv", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Locale getInstance(String language, String script, String region,
|
||||
String variant) {
|
||||
Locale.Builder builder = new Locale.Builder();
|
||||
if (!language.isEmpty()) builder.setLanguage(language);
|
||||
if (!script.isEmpty()) builder.setScript(script);
|
||||
if (!region.isEmpty()) builder.setRegion(region);
|
||||
if (!variant.isEmpty()) builder.setVariant(variant);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/// Convert a locale to the language key.
|
||||
///
|
||||
/// The language key is in the format of BCP 47 language tag.
|
||||
@@ -52,24 +118,38 @@ public final class LocaleUtils {
|
||||
: locale.stripExtensions().toLanguageTag();
|
||||
}
|
||||
|
||||
public static boolean isISO1Language(String language) {
|
||||
return language.length() == 2;
|
||||
public static @NotNull String getRootLanguage(Locale locale) {
|
||||
return getRootLanguage(locale.getLanguage());
|
||||
}
|
||||
|
||||
public static boolean isISO3Language(String language) {
|
||||
return language.length() == 3;
|
||||
}
|
||||
|
||||
public static @NotNull String getISO1Language(Locale locale) {
|
||||
String language = locale.getLanguage();
|
||||
/// - If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage;
|
||||
/// - If `language` is an ISO 639 alpha-3 language code and there is a corresponding ISO 639 alpha-2 language code, return the ISO 639 alpha-2 code;
|
||||
/// - If `language` is empty, return `en`;
|
||||
/// - Otherwise, return the `language`.
|
||||
public static @NotNull String getRootLanguage(String language) {
|
||||
if (language.isEmpty()) return "en";
|
||||
return isISO3Language(language) ? toISO1Language(language) : language;
|
||||
if (language.length() <= 2)
|
||||
return language;
|
||||
|
||||
String iso2 = mapToISO2Language(language);
|
||||
if (iso2 != null)
|
||||
return iso2;
|
||||
|
||||
String parent = getParentLanguage(language);
|
||||
return parent != null ? parent : language;
|
||||
}
|
||||
|
||||
/// Get the script of the locale. If the script is empty and the language is Chinese,
|
||||
/// the script will be inferred based on the language, the region and the variant.
|
||||
public static @NotNull String getScript(Locale locale) {
|
||||
if (locale.getScript().isEmpty()) {
|
||||
if (isEnglish(locale)) {
|
||||
if ("UD".equals(locale.getCountry())) {
|
||||
return "Qabs";
|
||||
}
|
||||
}
|
||||
|
||||
if (isChinese(locale)) {
|
||||
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
|
||||
return "Latn";
|
||||
@@ -83,10 +163,94 @@ public final class LocaleUtils {
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
|
||||
|
||||
public static @NotNull List<Locale> getCandidateLocales(Locale locale) {
|
||||
return DefaultResourceBundleControl.INSTANCE.getCandidateLocales("", locale);
|
||||
return CANDIDATE_LOCALES.computeIfAbsent(locale, LocaleUtils::createCandidateLocaleList);
|
||||
}
|
||||
|
||||
// -------------
|
||||
|
||||
private static List<Locale> createCandidateLocaleList(Locale locale) {
|
||||
String language = locale.getLanguage();
|
||||
if (language.isEmpty())
|
||||
return List.of(Locale.ENGLISH, Locale.ROOT);
|
||||
|
||||
String script = getScript(locale);
|
||||
String region = locale.getCountry();
|
||||
List<String> variants = locale.getVariant().isEmpty()
|
||||
? List.of()
|
||||
: List.of(locale.getVariant().split("[_\\-]"));
|
||||
|
||||
ArrayList<Locale> result = new ArrayList<>();
|
||||
do {
|
||||
String currentLanguage;
|
||||
|
||||
if (language.length() <= 2) {
|
||||
currentLanguage = language;
|
||||
} else {
|
||||
String iso2 = mapToISO2Language(language);
|
||||
currentLanguage = iso2 != null
|
||||
? iso2
|
||||
: language;
|
||||
}
|
||||
|
||||
addCandidateLocales(result, currentLanguage, script, region, variants);
|
||||
} while ((language = getParentLanguage(language)) != null);
|
||||
|
||||
result.add(Locale.ROOT);
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
private static void addCandidateLocales(ArrayList<Locale> list,
|
||||
String language,
|
||||
String script,
|
||||
String region,
|
||||
List<String> variants) {
|
||||
if (!variants.isEmpty()) {
|
||||
for (String v : variants) {
|
||||
list.add(getInstance(language, script, region, v));
|
||||
}
|
||||
}
|
||||
if (!region.isEmpty()) {
|
||||
list.add(getInstance(language, script, region, ""));
|
||||
}
|
||||
if (!script.isEmpty()) {
|
||||
list.add(getInstance(language, script, "", ""));
|
||||
if (!variants.isEmpty()) {
|
||||
for (String v : variants) {
|
||||
list.add(getInstance(language, "", region, v));
|
||||
}
|
||||
}
|
||||
if (!region.isEmpty()) {
|
||||
list.add(getInstance(language, "", region, ""));
|
||||
}
|
||||
}
|
||||
|
||||
list.add(getInstance(language, "", "", ""));
|
||||
|
||||
if (language.equals("zh")) {
|
||||
if (list.contains(LocaleUtils.LOCALE_ZH_HANT) && !list.contains(Locale.TRADITIONAL_CHINESE)) {
|
||||
int chineseIdx = list.indexOf(Locale.CHINESE);
|
||||
if (chineseIdx >= 0)
|
||||
list.add(chineseIdx, Locale.TRADITIONAL_CHINESE);
|
||||
}
|
||||
|
||||
if (!list.contains(Locale.SIMPLIFIED_CHINESE)) {
|
||||
int chineseIdx = list.indexOf(Locale.CHINESE);
|
||||
|
||||
if (chineseIdx >= 0) {
|
||||
if (list.contains(LocaleUtils.LOCALE_ZH_HANS))
|
||||
list.add(chineseIdx, Locale.SIMPLIFIED_CHINESE);
|
||||
else
|
||||
list.add(chineseIdx + 1, Locale.SIMPLIFIED_CHINESE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------
|
||||
|
||||
public static <T> @Nullable T getByCandidateLocales(Map<String, T> map, List<Locale> candidateLocales) {
|
||||
for (Locale locale : candidateLocales) {
|
||||
String key = toLanguageKey(locale);
|
||||
@@ -178,29 +342,27 @@ public final class LocaleUtils {
|
||||
|
||||
// ---
|
||||
|
||||
/// Try to convert ISO 639-3 language codes to ISO 639-1 language codes.
|
||||
public static String toISO1Language(String languageTag) {
|
||||
return switch (languageTag) {
|
||||
case "eng" -> "en";
|
||||
case "spa" -> "es";
|
||||
case "jpa" -> "ja";
|
||||
case "rus" -> "ru";
|
||||
case "ukr" -> "uk";
|
||||
case "zho", "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
|
||||
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh";
|
||||
default -> languageTag;
|
||||
};
|
||||
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
|
||||
/// Returns `null` if there is no corresponding ISO 639 alpha-2 language code.
|
||||
public static @Nullable String mapToISO2Language(String iso3Language) {
|
||||
return iso3To2.get(iso3Language);
|
||||
}
|
||||
|
||||
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage; otherwise, return `null`.
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return subLanguageToParent.get(language);
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getISO1Language(locale));
|
||||
return "en".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
public static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
|
||||
public static final Set<String> CHINESE_LATN_VARIANTS = Set.of("pinyin", "wadegile", "tongyong");
|
||||
|
||||
public static boolean isChinese(Locale locale) {
|
||||
return "zh".equals(getISO1Language(locale));
|
||||
return "zh".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
private LocaleUtils() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URLConnection;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
@@ -52,5 +53,16 @@ public enum ContentEncoding {
|
||||
}
|
||||
}
|
||||
|
||||
public static @NotNull ContentEncoding fromResponse(HttpResponse<?> connection) throws IOException {
|
||||
String encoding = connection.headers().firstValue("content-encoding").orElse("");
|
||||
if (encoding.isEmpty() || "identity".equals(encoding)) {
|
||||
return IDENTITY;
|
||||
} else if ("gzip".equalsIgnoreCase(encoding)) {
|
||||
return GZIP;
|
||||
} else {
|
||||
throw new IOException("Unsupported content encoding: " + encoding);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract InputStream wrap(InputStream inputStream) throws IOException;
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.jackhuang.hmcl.util.io;
|
||||
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalPredicate;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.Charset;
|
||||
@@ -153,6 +154,31 @@ public final class Zipper implements Closeable {
|
||||
zos.closeEntry();
|
||||
}
|
||||
|
||||
public OutputStream putStream(String path) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||
return new OutputStream() {
|
||||
public void write(int b) throws IOException {
|
||||
zos.write(b);
|
||||
}
|
||||
|
||||
public void write(@NotNull byte[] b) throws IOException {
|
||||
zos.write(b);
|
||||
}
|
||||
|
||||
public void write(@NotNull byte[] b, int off, int len) throws IOException {
|
||||
zos.write(b, off, len);
|
||||
}
|
||||
|
||||
public void flush() throws IOException {
|
||||
zos.flush();
|
||||
}
|
||||
|
||||
public void close() throws IOException {
|
||||
zos.closeEntry();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public void putLines(Stream<String> lines, String path) throws IOException {
|
||||
zos.putNextEntry(new ZipEntry(normalize(path)));
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import javafx.beans.value.ObservableValue;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@@ -144,6 +145,7 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
|
||||
|
||||
private static final class AsyncMappedBinding<T, U> extends BindingMapping<T, U> {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private boolean initialized = false;
|
||||
private T prev;
|
||||
private U value;
|
||||
@@ -159,13 +161,16 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
|
||||
}
|
||||
|
||||
private void tryUpdateValue(T currentPrev) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if ((initialized && Objects.equals(prev, currentPrev))
|
||||
|| isComputing(currentPrev)) {
|
||||
return;
|
||||
}
|
||||
computing = true;
|
||||
computingPrev = currentPrev;
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
CompletableFuture<? extends U> task;
|
||||
@@ -189,7 +194,8 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
|
||||
}
|
||||
|
||||
private void valueUpdate(T currentPrev, U computed) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (isComputing(currentPrev)) {
|
||||
computing = false;
|
||||
computingPrev = null;
|
||||
@@ -197,15 +203,20 @@ public abstract class BindingMapping<T, U> extends ObjectBinding<U> {
|
||||
value = computed;
|
||||
initialized = true;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
private void valueUpdateFailed(T currentPrev) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (isComputing(currentPrev)) {
|
||||
computing = false;
|
||||
computingPrev = null;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import java.util.concurrent.CancellationException;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
import org.jackhuang.hmcl.util.function.ExceptionalFunction;
|
||||
@@ -37,6 +38,7 @@ import javafx.beans.binding.ObjectBinding;
|
||||
*/
|
||||
public final class ObservableCache<K, V, E extends Exception> {
|
||||
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final ExceptionalFunction<K, V, E> source;
|
||||
private final BiConsumer<K, Throwable> exceptionHandler;
|
||||
private final V fallbackValue;
|
||||
@@ -54,22 +56,29 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
}
|
||||
|
||||
public Optional<V> getImmediately(K key) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
return Optional.ofNullable(cache.get(key));
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public void put(K key, V value) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
cache.put(key, value);
|
||||
invalidated.remove(key);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
Platform.runLater(observable::invalidate);
|
||||
}
|
||||
|
||||
private CompletableFuture<V> query(K key, Executor executor) {
|
||||
CompletableFuture<V> future;
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
CompletableFuture<V> prev = pendings.get(key);
|
||||
if (prev != null) {
|
||||
return prev;
|
||||
@@ -77,6 +86,8 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
future = new CompletableFuture<>();
|
||||
pendings.put(key, future);
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
executor.execute(() -> {
|
||||
@@ -84,18 +95,24 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
try {
|
||||
result = source.apply(key);
|
||||
} catch (Throwable ex) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
pendings.remove(key);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
exceptionHandler.accept(key, ex);
|
||||
future.completeExceptionally(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
cache.put(key, result);
|
||||
invalidated.remove(key);
|
||||
pendings.remove(key, future);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
future.complete(result);
|
||||
Platform.runLater(observable::invalidate);
|
||||
@@ -106,11 +123,14 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
|
||||
public V get(K key) {
|
||||
V cached;
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
cached = cache.get(key);
|
||||
if (cached != null && !invalidated.containsKey(key)) {
|
||||
return cached;
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -143,7 +163,9 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
return Bindings.createObjectBinding(() -> {
|
||||
V result;
|
||||
boolean refresh;
|
||||
synchronized (this) {
|
||||
|
||||
lock.lock();
|
||||
try {
|
||||
result = cache.get(key);
|
||||
if (result == null) {
|
||||
result = fallbackValue;
|
||||
@@ -151,6 +173,8 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
} else {
|
||||
refresh = invalidated.containsKey(key);
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
if (!quiet && refresh) {
|
||||
query(key, executor);
|
||||
@@ -160,10 +184,13 @@ public final class ObservableCache<K, V, E extends Exception> {
|
||||
}
|
||||
|
||||
public void invalidate(K key) {
|
||||
synchronized (this) {
|
||||
lock.lock();
|
||||
try {
|
||||
if (cache.containsKey(key)) {
|
||||
invalidated.put(key, Boolean.TRUE);
|
||||
}
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
Platform.runLater(observable::invalidate);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ public final class Logger {
|
||||
private static volatile String[] accessTokens = new String[0];
|
||||
|
||||
public static synchronized void registerAccessToken(String token) {
|
||||
if (token == null || token.length() <= 1)
|
||||
return;
|
||||
|
||||
final String[] oldAccessTokens = accessTokens;
|
||||
final String[] newAccessTokens = Arrays.copyOf(oldAccessTokens, oldAccessTokens.length + 1);
|
||||
|
||||
|
||||
@@ -22,17 +22,17 @@ import org.jackhuang.hmcl.util.Lang;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* The managed process.
|
||||
*
|
||||
* @author huangyuhui
|
||||
* @see org.jackhuang.hmcl.launch.ExitWaiter
|
||||
* @see org.jackhuang.hmcl.launch.StreamPump
|
||||
*/
|
||||
/// The managed process.
|
||||
///
|
||||
/// @author huangyuhui
|
||||
/// <!-- @see org.jackhuang.hmcl.launch.ExitWaiter -->
|
||||
/// @see org.jackhuang.hmcl.launch.StreamPump
|
||||
public final class ManagedProcess {
|
||||
private final ReentrantLock lock = new ReentrantLock();
|
||||
private final Process process;
|
||||
private final List<String> commands;
|
||||
private final String classpath;
|
||||
@@ -54,7 +54,7 @@ public final class ManagedProcess {
|
||||
*/
|
||||
public ManagedProcess(Process process, List<String> commands) {
|
||||
this.process = process;
|
||||
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
|
||||
this.commands = List.copyOf(commands);
|
||||
this.classpath = null;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public final class ManagedProcess {
|
||||
*/
|
||||
public ManagedProcess(Process process, List<String> commands, String classpath) {
|
||||
this.process = process;
|
||||
this.commands = Collections.unmodifiableList(new ArrayList<>(commands));
|
||||
this.commands = List.copyOf(commands);
|
||||
this.classpath = classpath;
|
||||
}
|
||||
|
||||
@@ -111,9 +111,11 @@ public final class ManagedProcess {
|
||||
*
|
||||
* @see #addLine
|
||||
*/
|
||||
public synchronized List<String> getLines(Predicate<String> lineFilter) {
|
||||
public List<String> getLines(Predicate<String> lineFilter) {
|
||||
lock.lock();
|
||||
|
||||
if (lineFilter == null)
|
||||
return Collections.unmodifiableList(Arrays.asList(lines.toArray(new String[0])));
|
||||
return List.copyOf(lines);
|
||||
|
||||
ArrayList<String> res = new ArrayList<>();
|
||||
for (String line : this.lines) {
|
||||
@@ -123,8 +125,13 @@ public final class ManagedProcess {
|
||||
return Collections.unmodifiableList(res);
|
||||
}
|
||||
|
||||
public synchronized void addLine(String line) {
|
||||
lines.add(line);
|
||||
public void addLine(String line) {
|
||||
lock.lock();
|
||||
try {
|
||||
lines.add(line);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,15 +140,20 @@ public final class ManagedProcess {
|
||||
* If a thread is monitoring this raw process,
|
||||
* you are required to add the instance by this method.
|
||||
*/
|
||||
public synchronized void addRelatedThread(Thread thread) {
|
||||
relatedThreads.add(thread);
|
||||
public void addRelatedThread(Thread thread) {
|
||||
lock.lock();
|
||||
try {
|
||||
relatedThreads.add(thread);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void pumpInputStream(Consumer<String> onLogLine) {
|
||||
public void pumpInputStream(Consumer<String> onLogLine) {
|
||||
addRelatedThread(Lang.thread(new StreamPump(process.getInputStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessInputStreamPump", true));
|
||||
}
|
||||
|
||||
public synchronized void pumpErrorStream(Consumer<String> onLogLine) {
|
||||
public void pumpErrorStream(Consumer<String> onLogLine) {
|
||||
addRelatedThread(Lang.thread(new StreamPump(process.getErrorStream(), onLogLine, OperatingSystem.NATIVE_CHARSET), "ProcessErrorStreamPump", true));
|
||||
}
|
||||
|
||||
@@ -172,8 +184,13 @@ public final class ManagedProcess {
|
||||
destroyRelatedThreads();
|
||||
}
|
||||
|
||||
public synchronized void destroyRelatedThreads() {
|
||||
relatedThreads.forEach(Thread::interrupt);
|
||||
public void destroyRelatedThreads() {
|
||||
lock.lock();
|
||||
try {
|
||||
relatedThreads.forEach(Thread::interrupt);
|
||||
} finally {
|
||||
lock.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -112,7 +112,7 @@ public final class SystemUtils {
|
||||
return Thread.currentThread().getContextClassLoader().getResource("com/sun/tools/attach/VirtualMachine.class") != null;
|
||||
}
|
||||
|
||||
private static void onLogLine(String log) {
|
||||
public static void onLogLine(String log) {
|
||||
LOG.info(log);
|
||||
}
|
||||
}
|
||||
@@ -24,15 +24,20 @@ import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Closeable {
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Closeable {
|
||||
|
||||
public static ArchiveFileTree<?, ?> open(Path file) throws IOException {
|
||||
Path namePath = file.getFileName();
|
||||
@@ -50,23 +55,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
|
||||
}
|
||||
}
|
||||
|
||||
protected final F file;
|
||||
protected final Dir<E> root = new Dir<>("");
|
||||
protected final R reader;
|
||||
protected final Dir<E> root = new Dir<>("", "");
|
||||
|
||||
public ArchiveFileTree(F file) {
|
||||
this.file = file;
|
||||
public ArchiveFileTree(R reader) {
|
||||
this.reader = reader;
|
||||
}
|
||||
|
||||
public F getFile() {
|
||||
return file;
|
||||
public R getReader() {
|
||||
return reader;
|
||||
}
|
||||
|
||||
public Dir<E> getRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
public @Nullable E getEntry(@NotNull String entryPath) {
|
||||
Dir<E> dir = root;
|
||||
String fileName;
|
||||
if (entryPath.indexOf('/') < 0) {
|
||||
fileName = entryPath;
|
||||
if (fileName.isEmpty())
|
||||
return root.getEntry();
|
||||
} else {
|
||||
String[] path = entryPath.split("/");
|
||||
if (path.length == 0)
|
||||
return root.getEntry();
|
||||
|
||||
for (int i = 0; i < path.length - 1; i++) {
|
||||
String item = path[i];
|
||||
if (item.isEmpty())
|
||||
continue;
|
||||
dir = dir.getSubDirs().get(item);
|
||||
if (dir == null)
|
||||
return null;
|
||||
}
|
||||
|
||||
fileName = path[path.length - 1];
|
||||
E entry = dir.getFiles().get(fileName);
|
||||
if (entry != null)
|
||||
return entry;
|
||||
}
|
||||
|
||||
Dir<E> subDir = dir.getSubDirs().get(fileName);
|
||||
return subDir != null ? subDir.getEntry() : null;
|
||||
}
|
||||
|
||||
protected void addEntry(E entry) throws IOException {
|
||||
String[] path = entry.getName().split("/");
|
||||
List<String> pathList = Arrays.asList(path);
|
||||
|
||||
Dir<E> dir = root;
|
||||
|
||||
@@ -81,7 +118,9 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
|
||||
throw new IOException("A file and a directory have the same name: " + entry.getName());
|
||||
}
|
||||
|
||||
dir = dir.subDirs.computeIfAbsent(item, Dir::new);
|
||||
final int nameEnd = i + 1;
|
||||
dir = dir.subDirs.computeIfAbsent(item, name ->
|
||||
new Dir<>(name, String.join("/", pathList.subList(0, nameEnd))));
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
@@ -106,6 +145,55 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
|
||||
|
||||
public abstract InputStream getInputStream(E entry) throws IOException;
|
||||
|
||||
public @NotNull InputStream getInputStream(String entryPath) throws IOException {
|
||||
E entry = getEntry(entryPath);
|
||||
if (entry == null)
|
||||
throw new FileNotFoundException("Entry not found: " + entryPath);
|
||||
return getInputStream(entry);
|
||||
}
|
||||
|
||||
public byte[] readBinaryEntry(@NotNull E entry) throws IOException {
|
||||
try (InputStream input = getInputStream(entry)) {
|
||||
return input.readAllBytes();
|
||||
}
|
||||
}
|
||||
|
||||
public String readTextEntry(@NotNull String entryPath) throws IOException {
|
||||
E entry = getEntry(entryPath);
|
||||
if (entry == null)
|
||||
throw new FileNotFoundException("Entry not found: " + entryPath);
|
||||
return readTextEntry(entry);
|
||||
}
|
||||
|
||||
public String readTextEntry(@NotNull E entry) throws IOException {
|
||||
return new String(readBinaryEntry(entry), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
protected void copyAttributes(@NotNull E source, @NotNull Path targetFile) throws IOException {
|
||||
FileTime lastModifiedTime = source.getLastModifiedTime();
|
||||
if (lastModifiedTime != null)
|
||||
Files.setLastModifiedTime(targetFile, lastModifiedTime);
|
||||
}
|
||||
|
||||
public void extractTo(@NotNull String entryPath, @NotNull Path targetFile) throws IOException {
|
||||
E entry = getEntry(entryPath);
|
||||
if (entry == null)
|
||||
throw new FileNotFoundException("Entry not found: " + entryPath);
|
||||
|
||||
extractTo(entry, targetFile);
|
||||
}
|
||||
|
||||
public void extractTo(@NotNull E entry, @NotNull Path targetFile) throws IOException {
|
||||
try (InputStream input = getInputStream(entry)) {
|
||||
Files.copy(input, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
try {
|
||||
copyAttributes(entry, targetFile);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to copy attributes to " + targetFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract boolean isLink(E entry);
|
||||
|
||||
public abstract String getLink(E entry) throws IOException;
|
||||
@@ -117,13 +205,15 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
|
||||
|
||||
public static final class Dir<E extends ArchiveEntry> {
|
||||
private final String name;
|
||||
private final String fullName;
|
||||
private E entry;
|
||||
|
||||
final Map<String, Dir<E>> subDirs = new HashMap<>();
|
||||
final Map<String, E> files = new HashMap<>();
|
||||
|
||||
public Dir(String name) {
|
||||
public Dir(String name, String fullName) {
|
||||
this.name = name;
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public boolean isRoot() {
|
||||
@@ -134,6 +224,11 @@ public abstract class ArchiveFileTree<F, E extends ArchiveEntry> implements Clos
|
||||
return name;
|
||||
}
|
||||
|
||||
/// Get the normalized full path. Leading `/` and all `.` in the path will be removed.
|
||||
public @NotNull String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public @Nullable E getEntry() {
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,13 @@ package org.jackhuang.hmcl.util.tree;
|
||||
|
||||
import kala.compress.archivers.tar.TarArchiveEntry;
|
||||
import kala.compress.archivers.tar.TarArchiveReader;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.BasicFileAttributeView;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
|
||||
/**
|
||||
@@ -98,9 +100,22 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
|
||||
this.shutdownHook = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void copyAttributes(@NotNull TarArchiveEntry source, @NotNull Path targetFile) throws IOException {
|
||||
var fileAttributeView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
|
||||
if (fileAttributeView == null)
|
||||
return;
|
||||
|
||||
fileAttributeView.setTimes(
|
||||
source.getLastModifiedTime(),
|
||||
source.getLastAccessTime(),
|
||||
source.getCreationTime()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream(TarArchiveEntry entry) throws IOException {
|
||||
return file.getInputStream(entry);
|
||||
return reader.getInputStream(entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -121,7 +136,7 @@ public final class TarFileTree extends ArchiveFileTree<TarArchiveReader, TarArch
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
try {
|
||||
file.close();
|
||||
reader.close();
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
Runtime.getRuntime().removeShutdownHook(shutdownHook);
|
||||
|
||||
@@ -20,9 +20,17 @@ package org.jackhuang.hmcl.util.tree;
|
||||
import kala.compress.archivers.zip.ZipArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributeView;
|
||||
import java.nio.file.attribute.PosixFileAttributeView;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.EnumSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
@@ -51,12 +59,53 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
if (closeReader)
|
||||
file.close();
|
||||
reader.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("OctalInteger")
|
||||
protected void copyAttributes(@NotNull ZipArchiveEntry source, @NotNull Path targetFile) throws IOException {
|
||||
BasicFileAttributeView targetView = Files.getFileAttributeView(targetFile, PosixFileAttributeView.class);
|
||||
|
||||
// target might not support posix even if source does
|
||||
if (targetView == null)
|
||||
targetView = Files.getFileAttributeView(targetFile, BasicFileAttributeView.class);
|
||||
|
||||
if (targetView == null)
|
||||
return;
|
||||
|
||||
targetView.setTimes(
|
||||
source.getLastModifiedTime(),
|
||||
source.getLastAccessTime(),
|
||||
source.getCreationTime()
|
||||
);
|
||||
|
||||
int unixMode = source.getUnixMode();
|
||||
if (unixMode != 0 && targetView instanceof PosixFileAttributeView posixView) {
|
||||
Set<PosixFilePermission> permissions = EnumSet.noneOf(PosixFilePermission.class);
|
||||
|
||||
// Owner permissions
|
||||
if ((unixMode & 0400) != 0) permissions.add(PosixFilePermission.OWNER_READ);
|
||||
if ((unixMode & 0200) != 0) permissions.add(PosixFilePermission.OWNER_WRITE);
|
||||
if ((unixMode & 0100) != 0) permissions.add(PosixFilePermission.OWNER_EXECUTE);
|
||||
|
||||
// Group permissions
|
||||
if ((unixMode & 0040) != 0) permissions.add(PosixFilePermission.GROUP_READ);
|
||||
if ((unixMode & 0020) != 0) permissions.add(PosixFilePermission.GROUP_WRITE);
|
||||
if ((unixMode & 0010) != 0) permissions.add(PosixFilePermission.GROUP_EXECUTE);
|
||||
|
||||
// Others permissions
|
||||
if ((unixMode & 0004) != 0) permissions.add(PosixFilePermission.OTHERS_READ);
|
||||
if ((unixMode & 0002) != 0) permissions.add(PosixFilePermission.OTHERS_WRITE);
|
||||
if ((unixMode & 0001) != 0) permissions.add(PosixFilePermission.OTHERS_EXECUTE);
|
||||
|
||||
posixView.setPermissions(permissions);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream(ZipArchiveEntry entry) throws IOException {
|
||||
return getFile().getInputStream(entry);
|
||||
return getReader().getInputStream(entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -66,7 +115,7 @@ public final class ZipFileTree extends ArchiveFileTree<ZipArchiveReader, ZipArch
|
||||
|
||||
@Override
|
||||
public String getLink(ZipArchiveEntry entry) throws IOException {
|
||||
return getFile().getUnixSymlink(entry);
|
||||
return getReader().getUnixSymlink(entry);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -831,4 +831,19 @@
|
||||
1.21.7-rc2
|
||||
1.21.7
|
||||
1.21.8-rc1
|
||||
1.21.8
|
||||
1.21.8
|
||||
25w31a
|
||||
25w32a
|
||||
25w33a
|
||||
25w34a
|
||||
25w34b
|
||||
25w35a
|
||||
25w36a
|
||||
25w36b
|
||||
25w37a
|
||||
1.21.9-pre1
|
||||
1.21.9-pre2
|
||||
1.21.9-pre3
|
||||
1.21.9-pre4
|
||||
1.21.9-rc1
|
||||
1.21.9
|
||||
@@ -0,0 +1,187 @@
|
||||
aa,aar
|
||||
ab,abk
|
||||
ae,ave
|
||||
af,afr
|
||||
ak,aka
|
||||
am,amh
|
||||
an,arg
|
||||
ar,ara
|
||||
as,asm
|
||||
av,ava
|
||||
ay,aym
|
||||
az,aze
|
||||
ba,bak
|
||||
be,bel
|
||||
bg,bul
|
||||
bh,bih
|
||||
bi,bis
|
||||
bm,bam
|
||||
bn,ben
|
||||
bo,bod
|
||||
br,bre
|
||||
bs,bos
|
||||
ca,cat
|
||||
ce,che
|
||||
ch,cha
|
||||
co,cos
|
||||
cr,cre
|
||||
cs,ces
|
||||
cu,chu
|
||||
cv,chv
|
||||
cy,cym
|
||||
da,dan
|
||||
de,deu
|
||||
dv,div
|
||||
dz,dzo
|
||||
ee,ewe
|
||||
el,ell
|
||||
en,eng
|
||||
eo,epo
|
||||
es,spa
|
||||
et,est
|
||||
eu,eus
|
||||
fa,fas
|
||||
ff,ful
|
||||
fi,fin
|
||||
fj,fij
|
||||
fo,fao
|
||||
fr,fra
|
||||
fy,fry
|
||||
ga,gle
|
||||
gd,gla
|
||||
gl,glg
|
||||
gn,grn
|
||||
gu,guj
|
||||
gv,glv
|
||||
ha,hau
|
||||
he,heb
|
||||
hi,hin
|
||||
ho,hmo
|
||||
hr,hrv
|
||||
ht,hat
|
||||
hu,hun
|
||||
hy,hye
|
||||
hz,her
|
||||
ia,ina
|
||||
id,ind
|
||||
ie,ile
|
||||
ig,ibo
|
||||
ii,iii
|
||||
ik,ipk
|
||||
in,ind
|
||||
io,ido
|
||||
is,isl
|
||||
it,ita
|
||||
iu,iku
|
||||
iw,heb
|
||||
ja,jpn
|
||||
ji,yid
|
||||
jv,jav
|
||||
ka,kat
|
||||
kg,kon
|
||||
ki,kik
|
||||
kj,kua
|
||||
kk,kaz
|
||||
kl,kal
|
||||
km,khm
|
||||
kn,kan
|
||||
ko,kor
|
||||
kr,kau
|
||||
ks,kas
|
||||
ku,kur
|
||||
kv,kom
|
||||
kw,cor
|
||||
ky,kir
|
||||
la,lat
|
||||
lb,ltz
|
||||
lg,lug
|
||||
li,lim
|
||||
ln,lin
|
||||
lo,lao
|
||||
lt,lit
|
||||
lu,lub
|
||||
lv,lav
|
||||
mg,mlg
|
||||
mh,mah
|
||||
mi,mri
|
||||
mk,mkd
|
||||
ml,mal
|
||||
mn,mon
|
||||
mo,mol
|
||||
mr,mar
|
||||
ms,msa
|
||||
mt,mlt
|
||||
my,mya
|
||||
na,nau
|
||||
nb,nob
|
||||
nd,nde
|
||||
ne,nep
|
||||
ng,ndo
|
||||
nl,nld
|
||||
nn,nno
|
||||
no,nor
|
||||
nr,nbl
|
||||
nv,nav
|
||||
ny,nya
|
||||
oc,oci
|
||||
oj,oji
|
||||
om,orm
|
||||
or,ori
|
||||
os,oss
|
||||
pa,pan
|
||||
pi,pli
|
||||
pl,pol
|
||||
ps,pus
|
||||
pt,por
|
||||
qu,que
|
||||
rm,roh
|
||||
rn,run
|
||||
ro,ron
|
||||
ru,rus
|
||||
rw,kin
|
||||
sa,san
|
||||
sc,srd
|
||||
sd,snd
|
||||
se,sme
|
||||
sg,sag
|
||||
si,sin
|
||||
sk,slk
|
||||
sl,slv
|
||||
sm,smo
|
||||
sn,sna
|
||||
so,som
|
||||
sq,sqi
|
||||
sr,srp
|
||||
ss,ssw
|
||||
st,sot
|
||||
su,sun
|
||||
sv,swe
|
||||
sw,swa
|
||||
ta,tam
|
||||
te,tel
|
||||
tg,tgk
|
||||
th,tha
|
||||
ti,tir
|
||||
tk,tuk
|
||||
tl,tgl
|
||||
tn,tsn
|
||||
to,ton
|
||||
tr,tur
|
||||
ts,tso
|
||||
tt,tat
|
||||
tw,twi
|
||||
ty,tah
|
||||
ug,uig
|
||||
uk,ukr
|
||||
ur,urd
|
||||
uz,uzb
|
||||
ve,ven
|
||||
vi,vie
|
||||
vo,vol
|
||||
wa,wln
|
||||
wo,wol
|
||||
xh,xho
|
||||
yi,yid
|
||||
za,zha
|
||||
zh,zho
|
||||
zu,zul
|
||||
|
@@ -0,0 +1 @@
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
|
@@ -0,0 +1,71 @@
|
||||
aɐ
|
||||
bq
|
||||
cɔ
|
||||
dp
|
||||
eǝ
|
||||
fɟ
|
||||
gᵷ
|
||||
hɥ
|
||||
iᴉ
|
||||
jɾ
|
||||
kʞ
|
||||
lꞁ
|
||||
mɯ
|
||||
nu
|
||||
oo
|
||||
pd
|
||||
qb
|
||||
rɹ
|
||||
ss
|
||||
tʇ
|
||||
un
|
||||
vʌ
|
||||
wʍ
|
||||
xx
|
||||
yʎ
|
||||
zz
|
||||
AⱯ
|
||||
Bᗺ
|
||||
CƆ
|
||||
Dᗡ
|
||||
EƎ
|
||||
FℲ
|
||||
G⅁
|
||||
HH
|
||||
II
|
||||
Jſ
|
||||
Kʞ
|
||||
LꞀ
|
||||
MW
|
||||
NN
|
||||
OO
|
||||
PԀ
|
||||
QὉ
|
||||
Rᴚ
|
||||
SS
|
||||
T⟘
|
||||
U∩
|
||||
VΛ
|
||||
WM
|
||||
XX
|
||||
Yʎ
|
||||
ZZ
|
||||
00
|
||||
1Ɩ
|
||||
2ᘔ
|
||||
3Ɛ
|
||||
4ㄣ
|
||||
5ϛ
|
||||
69
|
||||
7ㄥ
|
||||
88
|
||||
96
|
||||
_‾
|
||||
,'
|
||||
;⸵
|
||||
.˙
|
||||
?¿
|
||||
!¡
|
||||
//
|
||||
\\
|
||||
',
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user