Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcc17472f | ||
|
|
7b85f0dfce | ||
|
|
b8a830dd40 | ||
|
|
ce368caa67 | ||
|
|
850ad82aa1 | ||
|
|
552276e673 | ||
|
|
e79855685e | ||
|
|
ad5afe7e06 | ||
|
|
8210c77de9 | ||
|
|
b11b298312 | ||
|
|
5c605c58d2 | ||
|
|
01d4100a2f | ||
|
|
d07eb2f748 | ||
|
|
345bb176fd | ||
|
|
83a38fadf0 | ||
|
|
9980dc8aea | ||
|
|
7dfbf76b69 | ||
|
|
7f2f77f54f |
No files matched your search
@@ -0,0 +1,3 @@
|
||||
code_review:
|
||||
pull_request_opened:
|
||||
code_review: false
|
||||
@@ -1,147 +0,0 @@
|
||||
name: PR Size Label
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
Label:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Label PR by filtered changed lines
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const owner = context.repo.owner
|
||||
const repo = context.repo.repo
|
||||
const ignoredFilePatterns = [
|
||||
/(?:^|\/)\.cnb\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.gemini\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.gitee\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.github\/.+\.ya?ml$/i,
|
||||
/(?:^|\/)\.idea\/.+\.xml$/i,
|
||||
/(?:^|\/)\.editorconfig$/i,
|
||||
/(?:^|\/)\.gitattributes$/i,
|
||||
/(?:^|\/)\.gitignore$/i,
|
||||
/(?:^|\/).+\.kts$/i,
|
||||
/(?:^|\/).+\.md$/i,
|
||||
/(?:^|\/)LICENSE(?:_.*)?$/i,
|
||||
/(?:^|\/)gradlew(?:\.bat|\.ps1)?$/i,
|
||||
/(?:^|\/)gradle\/wrapper\/gradle-wrapper\.properties$/i,
|
||||
/(?:^|\/)lang\/.+\.properties$/i,
|
||||
/(?:^|\/)l10n\/.+\.properties$/i,
|
||||
/(?:^|\/)checkstyle\.xml$/i,
|
||||
/(?:^|\/).+\.(?:png|jpe?g|gif|ico|svg|json|ttf|woff2?|webp|avif|jxl|heic|mp4|webm|mp3|ogg|wav|txt|toml)$/i
|
||||
]
|
||||
|
||||
async function processPR(issue_number) {
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
|
||||
const countedFiles = []
|
||||
const ignoredFiles = []
|
||||
let totalChanges = 0
|
||||
|
||||
for (const file of files) {
|
||||
const ignored = ignoredFilePatterns.some(pattern => pattern.test(file.filename))
|
||||
if (ignored) {
|
||||
ignoredFiles.push(file.filename)
|
||||
continue
|
||||
}
|
||||
|
||||
const fileChanges = file.changes ?? ((file.additions || 0) + (file.deletions || 0))
|
||||
totalChanges += fileChanges
|
||||
countedFiles.push(`${file.filename} (${fileChanges})`)
|
||||
}
|
||||
|
||||
let targetLabel
|
||||
if (totalChanges <= 9) targetLabel = '1+'
|
||||
else if (totalChanges <= 39) targetLabel = '10+'
|
||||
else if (totalChanges <= 99) targetLabel = '40+'
|
||||
else if (totalChanges <= 499) targetLabel = '100+'
|
||||
else if (totalChanges <= 999) targetLabel = '500+'
|
||||
else if (totalChanges <= 1999) targetLabel = '1000+'
|
||||
else if (totalChanges <= 4999) targetLabel = '2000+'
|
||||
else targetLabel = '5000+'
|
||||
|
||||
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
|
||||
const sizeLabels = currentLabels
|
||||
.map(label => label.name)
|
||||
.filter(name => /^\d+\+$/.test(name))
|
||||
|
||||
for (const name of sizeLabels) {
|
||||
if (name !== targetLabel) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name,
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentLabels.some(label => label.name === targetLabel)) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [targetLabel],
|
||||
})
|
||||
}
|
||||
|
||||
core.info(`Pull request #${issue_number} has ${totalChanges} counted changed lines, applied ${targetLabel}.`)
|
||||
core.info(`Ignored files: ${ignoredFiles.length ? ignoredFiles.join(', ') : 'none'}`)
|
||||
core.info(`Counted files: ${countedFiles.length ? countedFiles.join(', ') : 'none'}`)
|
||||
}
|
||||
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
core.info('Manual trigger detected. Fetching all open pull requests...')
|
||||
|
||||
const openPRs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
})
|
||||
|
||||
const unlabelledPRs = openPRs.filter(pr => {
|
||||
return !pr.labels.some(label => /^\d+\+$/.test(label.name))
|
||||
})
|
||||
|
||||
core.info(`Found ${openPRs.length} open pull request(s), ${unlabelledPRs.length} of them lack a size label.`)
|
||||
|
||||
for (const pr of unlabelledPRs) {
|
||||
core.info(`----------------------------------------`)
|
||||
core.info(`Processing unlabelled pull request #${pr.number}: ${pr.title}`)
|
||||
await processPR(pr.number)
|
||||
}
|
||||
} else {
|
||||
const pr = context.payload.pull_request
|
||||
if (!pr) {
|
||||
core.info('No pull request found in payload, skipping.')
|
||||
return
|
||||
}
|
||||
await processPR(pr.number)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ if (buildNumber != null) {
|
||||
}
|
||||
}
|
||||
|
||||
val embedResources = configurations.register("embedResources")
|
||||
val embedResources by configurations.registering
|
||||
|
||||
dependencies {
|
||||
implementation(project(":HMCLCore"))
|
||||
@@ -166,7 +166,7 @@ val hmclProperties = buildList {
|
||||
}
|
||||
|
||||
val hmclPropertiesFile = layout.buildDirectory.file("hmcl.properties")
|
||||
val createPropertiesFile = tasks.register("createPropertiesFile") {
|
||||
val createPropertiesFile by tasks.registering {
|
||||
outputs.file(hmclPropertiesFile)
|
||||
hmclProperties.forEach { (k, v) -> inputs.property(k, v) }
|
||||
|
||||
@@ -258,7 +258,7 @@ tasks.processResources {
|
||||
|
||||
fun artifactFile(ext: String) = jarPath.resolveSibling(jarPath.nameWithoutExtension + '.' + ext)
|
||||
|
||||
val makeExecutables = tasks.register("makeExecutables") {
|
||||
val makeExecutables by tasks.registering {
|
||||
val extensions = listOf("exe", "sh")
|
||||
|
||||
dependsOn(tasks.jar)
|
||||
@@ -286,7 +286,7 @@ val makeExecutables = tasks.register("makeExecutables") {
|
||||
}
|
||||
}
|
||||
|
||||
val makeDeb = tasks.register("makeDeb", CreateDeb::class) {
|
||||
val makeDeb by tasks.registering(CreateDeb::class) {
|
||||
dependsOn(makeExecutables)
|
||||
|
||||
val debFile = layout.file(provider { artifactFile("deb") })
|
||||
@@ -438,19 +438,19 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate = tasks.register<UpsideDownTranslate>("upsideDownTranslate") {
|
||||
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 = tasks.register<CreateLanguageList>("createLanguageList") {
|
||||
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 = tasks.register<CreateLocaleNamesResourceBundle>("createLocaleNamesResourceBundle") {
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
|
||||
@@ -52,6 +52,8 @@ public final class EntryPoint {
|
||||
|
||||
checkWine();
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) {
|
||||
System.getProperties().putIfAbsent("apple.awt.application.appearance", "system");
|
||||
if (!isInsideMacAppBundle())
|
||||
@@ -72,6 +74,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_LOCAL_HOME)) {
|
||||
try {
|
||||
@@ -158,8 +225,15 @@ public final class EntryPoint {
|
||||
|
||||
private static void checkWine() {
|
||||
if (OperatingSystem.isRunningUnderWine()) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
LOG.warning("HMCL is running under Wine or its distributions!");
|
||||
showWarning(i18n("fatal.wine_warning"));
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, i18n("fatal.wine_warning"), i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,21 +273,10 @@ public final class EntryPoint {
|
||||
}
|
||||
}
|
||||
|
||||
static void showWarning(String message) {
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
int result = JOptionPane.showOptionDialog(null, message, i18n("message.warning"), JOptionPane.OK_CANCEL_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE, null, null, null);
|
||||
|
||||
if (result == JOptionPane.CANCEL_OPTION || result == JOptionPane.CLOSED_OPTION) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates that a fatal error has occurred, and that the application cannot start.
|
||||
*/
|
||||
static void showErrorAndExit(String message) {
|
||||
private static void showErrorAndExit(String message) {
|
||||
SwingUtils.showErrorDialog(message);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.DataFormat;
|
||||
import javafx.stage.Screen;
|
||||
import javafx.stage.Stage;
|
||||
import javafx.util.Duration;
|
||||
@@ -47,9 +49,6 @@ import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.JarUtils;
|
||||
import org.jackhuang.hmcl.util.platform.*;
|
||||
|
||||
import javax.swing.*;
|
||||
import java.awt.*;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.management.ManagementFactory;
|
||||
@@ -61,6 +60,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.DecimalFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -98,7 +98,18 @@ public final class Launcher extends Application {
|
||||
}
|
||||
|
||||
try {
|
||||
initializeSettingsRuntime();
|
||||
try {
|
||||
SettingsManager.init();
|
||||
initializeSettingsRuntime();
|
||||
} catch (SambaException e) {
|
||||
showAlert(AlertType.WARNING, i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigInTempDir();
|
||||
checkConfigOwner();
|
||||
showAlert(AlertType.ERROR, i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
// https://lapcatsoftware.com/articles/app-translocation.html
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS
|
||||
@@ -263,6 +274,48 @@ public final class Launcher extends Application {
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(configDirectory.toString());
|
||||
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
ButtonType copyAndExit = new ButtonType(i18n("button.copy_and_exit"));
|
||||
|
||||
if (showAlert(AlertType.ERROR,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
copyAndExit, ButtonType.CLOSE) == copyAndExit) {
|
||||
Clipboard.getSystemClipboard()
|
||||
.setContent(Collections.singletonMap(DataFormat.PLAIN_TEXT, command));
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() throws Exception {
|
||||
Controllers.onApplicationStop();
|
||||
@@ -323,19 +376,6 @@ public final class Launcher extends Application {
|
||||
|
||||
Lang.thread(SystemInfo::initialize, "Detection System Information", true);
|
||||
|
||||
try {
|
||||
SettingsManager.init();
|
||||
} catch (SambaException e) {
|
||||
EntryPoint.showWarning(i18n("fatal.samba"));
|
||||
} catch (IOException e) {
|
||||
LOG.error("Failed to load config", e);
|
||||
checkConfigOwner();
|
||||
SwingUtils.showErrorDialog(i18n("fatal.config_loading_failure", SettingsManager.localConfigDirectory()));
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
setupJavaFXVMOptions();
|
||||
|
||||
launch(Launcher.class, args);
|
||||
} catch (Throwable e) { // Fucking JavaFX will suppress the exception and will break our crash reporter.
|
||||
CRASH_REPORTER.uncaughtException(Thread.currentThread(), e);
|
||||
@@ -368,127 +408,5 @@ public final class Launcher extends Application {
|
||||
});
|
||||
}
|
||||
|
||||
private static void setupJavaFXVMOptions() {
|
||||
if ("true".equalsIgnoreCase(System.getenv("HMCL_FORCE_GPU"))) {
|
||||
LOG.info("HMCL_FORCE_GPU: true");
|
||||
System.getProperties().putIfAbsent("prism.forceGPU", "true");
|
||||
}
|
||||
|
||||
setUpAnimationFrameRate:
|
||||
{
|
||||
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);
|
||||
}
|
||||
break setUpAnimationFrameRate;
|
||||
}
|
||||
}
|
||||
|
||||
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 checkConfigOwner() {
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS)
|
||||
return;
|
||||
|
||||
String userName = System.getProperty("user.name");
|
||||
Path configDirectory = SettingsManager.localConfigDirectory();
|
||||
if (!Files.exists(configDirectory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String owner;
|
||||
try {
|
||||
owner = Files.getOwner(configDirectory).getName();
|
||||
} catch (IOException ioe) {
|
||||
LOG.warning("Failed to get file owner", ioe);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Files.isWritable(configDirectory) || userName.equals("root") || userName.equals(owner))
|
||||
return;
|
||||
|
||||
ArrayList<String> files = new ArrayList<>();
|
||||
files.add(configDirectory.toString());
|
||||
if (Files.exists(Metadata.HMCL_USER_HOME))
|
||||
files.add(Metadata.HMCL_USER_HOME.toString());
|
||||
|
||||
Path mcDir = Paths.get(".minecraft").toAbsolutePath().normalize();
|
||||
if (Files.exists(mcDir))
|
||||
files.add(mcDir.toString());
|
||||
|
||||
String command = new CommandBuilder().addAll("sudo", "chown", "-R", userName).addAll(files).toString();
|
||||
SwingUtils.initLookAndFeel();
|
||||
|
||||
Object[] options = {i18n("button.copy_and_exit"), i18n("button.cancel")};
|
||||
int result = JOptionPane.showOptionDialog(null,
|
||||
i18n("fatal.config_loading_failure.unix", owner, command),
|
||||
i18n("message.error"),
|
||||
JOptionPane.DEFAULT_OPTION,
|
||||
JOptionPane.ERROR_MESSAGE,
|
||||
null,
|
||||
options,
|
||||
options[0]);
|
||||
|
||||
if (result == 0) {
|
||||
try {
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(command), null);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to copy command to clipboard", e);
|
||||
}
|
||||
}
|
||||
EntryPoint.exit(1);
|
||||
}
|
||||
|
||||
public static final CrashReporter CRASH_REPORTER = new CrashReporter(true);
|
||||
}
|
||||
@@ -297,7 +297,7 @@ public final class HMCLGameRepository extends DefaultGameRepository {
|
||||
newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY);
|
||||
newGameSettings.runningDirectoryProperty().setValue("");
|
||||
initInstanceGameSettings(dstId, newGameSettings);
|
||||
saveGameSettings(dstId);
|
||||
saveGameSettingsSync(dstId);
|
||||
|
||||
Path dstGameDir = getRunDirectory(dstId);
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onInvalidating;
|
||||
|
||||
/// Persistent configuration for a game directory.
|
||||
@JsonAdapter(GameDirectory.Serializer.class)
|
||||
@NotNullByDefault
|
||||
@@ -116,7 +118,7 @@ public final class GameDirectory implements Observable {
|
||||
this.path = new SimpleObjectProperty<>(this, "path", Objects.requireNonNull(path));
|
||||
this.legacyGameSettings = new SimpleObjectProperty<>(this, "legacyGameSettings", legacyGameSettings);
|
||||
|
||||
addPropertyChangedListener(arg -> invalidate());
|
||||
addPropertyChangedListener(onInvalidating(this::invalidate));
|
||||
}
|
||||
|
||||
/// Returns a debug string containing the game directory path and display metadata.
|
||||
|
||||
@@ -480,19 +480,17 @@ public final class LauncherSettings extends ObservableSetting implements JsonSch
|
||||
|
||||
/// Whether UI animations are disabled.
|
||||
@SerializedName("animationDisabled")
|
||||
private final ObjectProperty<@Nullable Boolean> animationDisabled = new SimpleObjectProperty<>();
|
||||
private final BooleanProperty animationDisabled = new SimpleBooleanProperty(
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE
|
||||
|| !JavaRuntime.CURRENT_JIT_ENABLED
|
||||
|| !FXUtils.GPU_ACCELERATION_ENABLED
|
||||
);
|
||||
|
||||
/// Returns the UI animation disable property.
|
||||
public ObjectProperty<@Nullable Boolean> animationDisabledProperty() {
|
||||
public BooleanProperty animationDisabledProperty() {
|
||||
return animationDisabled;
|
||||
}
|
||||
|
||||
public boolean isAnimationDisabled() {
|
||||
// Avoid accessing FXUtils too early during startup
|
||||
return Objects.requireNonNullElseGet(animationDisabled.get(), () ->
|
||||
FXUtils.REDUCED_MOTION == Boolean.TRUE || !JavaRuntime.CURRENT_JIT_ENABLED || !FXUtils.GPU_ACCELERATION_ENABLED);
|
||||
}
|
||||
|
||||
// Networks
|
||||
|
||||
/// Whether HMCL automatically selects the number of download threads.
|
||||
|
||||
@@ -47,6 +47,7 @@ public enum VersionIconType {
|
||||
case FORGE -> VersionIconType.FORGE;
|
||||
case NEO_FORGE -> VersionIconType.NEO_FORGE;
|
||||
case FABRIC -> VersionIconType.FABRIC;
|
||||
case LEGACY_FABRIC -> VersionIconType.LEGACY_FABRIC;
|
||||
case QUILT -> VersionIconType.QUILT;
|
||||
case LITE_LOADER -> VersionIconType.CHICKEN;
|
||||
case CLEANROOM -> VersionIconType.CLEANROOM;
|
||||
|
||||
@@ -22,8 +22,6 @@ import com.jfoenix.controls.JFXDialogLayout;
|
||||
import com.jfoenix.controls.JFXSpinner;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
|
||||
import org.glavo.url.WebURL;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
@@ -36,6 +34,8 @@ import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Node;
|
||||
|
||||
import java.net.URL;
|
||||
|
||||
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
@@ -59,7 +59,7 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
// Downgrade update, no need to display changelog
|
||||
return null;
|
||||
|
||||
Document document = Jsoup.parse(WebURL.toURL(url), 30 * 1000);
|
||||
Document document = Jsoup.parse(new URL(url), 30 * 1000);
|
||||
Node node = document.selectFirst("h1[data-version=\"%s\"]".formatted(targetVersion));
|
||||
|
||||
if (node == null || !"h1".equals(node.nodeName())) {
|
||||
|
||||
@@ -37,7 +37,7 @@ public final class AnimationUtils {
|
||||
public static void init() {
|
||||
}
|
||||
|
||||
private static final boolean ENABLED = !settings().isAnimationDisabled();
|
||||
private static final boolean ENABLED = !settings().animationDisabledProperty().get();
|
||||
private static final boolean PLAY_WINDOW_ANIMATION = ENABLED && !OperatingSystem.CURRENT_OS.isLinuxOrBSD();
|
||||
|
||||
public static boolean isAnimationEnabled() {
|
||||
|
||||
@@ -21,12 +21,11 @@ import com.jfoenix.controls.JFXButton;
|
||||
import javafx.application.Platform;
|
||||
import javafx.beans.property.StringProperty;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.*;
|
||||
import org.jackhuang.hmcl.task.FetchTask;
|
||||
import org.jackhuang.hmcl.task.TaskExecutor;
|
||||
import org.jackhuang.hmcl.task.TaskListener;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
@@ -68,18 +67,16 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
center.getChildren().setAll(lblTitle, taskListPane);
|
||||
}
|
||||
|
||||
HBox bottom = new HBox();
|
||||
bottom.setAlignment(Pos.CENTER_LEFT);
|
||||
bottom.setPadding(new Insets(0, 8, 8, 8));
|
||||
bottom.setSpacing(8);
|
||||
BorderPane bottom = new BorderPane();
|
||||
this.setBottom(bottom);
|
||||
bottom.setPadding(new Insets(0, 8, 8, 8));
|
||||
{
|
||||
lblProgress = new Label();
|
||||
Region spacer = new Region();
|
||||
HBox.setHgrow(spacer, Priority.ALWAYS);
|
||||
bottom.setLeft(lblProgress);
|
||||
|
||||
btnCancel = new JFXButton(i18n("button.cancel"));
|
||||
btnCancel.getStyleClass().add("dialog-cancel");
|
||||
bottom.getChildren().setAll(lblProgress, spacer, btnCancel);
|
||||
bottom.setRight(btnCancel);
|
||||
}
|
||||
|
||||
setCancel(cancel);
|
||||
|
||||
@@ -22,7 +22,10 @@ import com.jfoenix.controls.JFXComboBox;
|
||||
import com.jfoenix.controls.JFXSlider;
|
||||
import com.jfoenix.controls.JFXTextField;
|
||||
import javafx.beans.InvalidationListener;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.Property;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.SimpleObjectProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.collections.FXCollections;
|
||||
import javafx.collections.ObservableList;
|
||||
@@ -32,11 +35,7 @@ import javafx.geometry.Pos;
|
||||
import javafx.geometry.Rectangle2D;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.ContentDisplay;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextFormatter;
|
||||
import javafx.scene.control.Tooltip;
|
||||
import javafx.scene.control.*;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.*;
|
||||
import javafx.stage.FileChooser;
|
||||
@@ -1721,6 +1720,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
updating.value = false;
|
||||
}
|
||||
refresh.invalidated(instance);
|
||||
|
||||
fireEvent(new VersionPage.WorkingDirChangedEvent());
|
||||
});
|
||||
|
||||
currentSetting.addListener((observable, oldValue, newValue) -> {
|
||||
@@ -1809,6 +1810,9 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
updating.value = false;
|
||||
}
|
||||
refresh.invalidated(instance);
|
||||
|
||||
fireEvent(new VersionPage.WorkingDirChangedEvent());
|
||||
|
||||
event.consume();
|
||||
});
|
||||
|
||||
@@ -1828,6 +1832,8 @@ public final class GameSettingsPage<S extends GameSettings> extends StackPane
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
|
||||
fireEvent(new VersionPage.WorkingDirChangedEvent());
|
||||
});
|
||||
|
||||
currentSetting.addListener((observable, oldValue, newValue) -> {
|
||||
|
||||
@@ -40,6 +40,7 @@ import org.jackhuang.hmcl.ui.image.apng.chunks.PngFrameControl;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngException;
|
||||
import org.jackhuang.hmcl.ui.image.apng.error.PngIntegrityException;
|
||||
import org.jackhuang.hmcl.ui.image.internal.AnimationImageImpl;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -50,13 +51,16 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/**
|
||||
* @author Glavo
|
||||
*/
|
||||
/// Provides image-format detection and loaders for image formats not handled
|
||||
/// directly by JavaFX.
|
||||
///
|
||||
/// @author Glavo
|
||||
@NotNullByDefault
|
||||
public final class ImageUtils {
|
||||
|
||||
// ImageLoaders
|
||||
@@ -81,38 +85,37 @@ public final class ImageUtils {
|
||||
LoaderParameters parameters = new LoaderParameters();
|
||||
parameters.autoStartAnimations = false;
|
||||
|
||||
SVGImage image;
|
||||
Supplier<Image> supplier = () -> {
|
||||
SVGImage image = SVGLoader.load(content, parameters);
|
||||
var snapshotParameters = new SnapshotParameters();
|
||||
snapshotParameters.setFill(Color.TRANSPARENT);
|
||||
|
||||
if (requestedWidth <= 0. || requestedHeight <= 0.) {
|
||||
return image.toImage(snapshotParameters);
|
||||
}
|
||||
|
||||
double scaleX = requestedWidth / image.getScaledWidth();
|
||||
double scaleY = requestedHeight / image.getScaledHeight();
|
||||
|
||||
if (preserveRatio || scaleX == scaleY) {
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
return image.scale(scale).toImage(snapshotParameters);
|
||||
} else {
|
||||
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
|
||||
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
|
||||
}
|
||||
};
|
||||
|
||||
@Nullable Image image;
|
||||
|
||||
if (Platform.isFxApplicationThread()) {
|
||||
image = SVGLoader.load(content, parameters);
|
||||
image = supplier.get();
|
||||
} else {
|
||||
// TODO: Currently, SVGLoader.load(...) requires the javafx.swing module if it operates on a non-JavaFX thread.
|
||||
image = CompletableFuture.supplyAsync(
|
||||
() -> SVGLoader.load(content, parameters),
|
||||
Schedulers.javafx()
|
||||
).get();
|
||||
image = CompletableFuture.supplyAsync(supplier, Schedulers.javafx()).get();
|
||||
}
|
||||
|
||||
if (image == null)
|
||||
throw new IOException("Failed to load SVG image");
|
||||
|
||||
var snapshotParameters = new SnapshotParameters();
|
||||
snapshotParameters.setFill(Color.TRANSPARENT);
|
||||
|
||||
if (requestedWidth <= 0. || requestedHeight <= 0.) {
|
||||
return image.toImage(snapshotParameters);
|
||||
}
|
||||
|
||||
double scaleX = requestedWidth / image.getScaledWidth();
|
||||
double scaleY = requestedHeight / image.getScaledHeight();
|
||||
|
||||
if (preserveRatio || scaleX == scaleY) {
|
||||
double scale = Math.min(scaleX, scaleY);
|
||||
return image.scale(scale).toImage(snapshotParameters);
|
||||
} else {
|
||||
// FIXME: Use DEFAULT_SVG_SNAPSHOT_PARAMS
|
||||
return image.toImageScaled(ScaleQuality.RENDER_QUALITY, scaleX, scaleY);
|
||||
}
|
||||
return image;
|
||||
};
|
||||
|
||||
public static final ImageLoader APNG = (input, requestedWidth, requestedHeight, preserveRatio, smooth) -> {
|
||||
@@ -198,6 +201,7 @@ public final class ImageUtils {
|
||||
|
||||
// ------
|
||||
|
||||
/// Maximum number of leading bytes inspected when selecting an image loader.
|
||||
public static final int HEADER_BUFFER_SIZE = 1024;
|
||||
|
||||
private static final byte[] RIFF_HEADER = {'R', 'I', 'F', 'F'};
|
||||
@@ -209,12 +213,272 @@ public final class ImageUtils {
|
||||
&& Arrays.equals(headerBuffer, 8, 12, WEBP_HEADER, 0, 4);
|
||||
}
|
||||
|
||||
private static final byte[] SVG_HEADER = "<svg".getBytes(StandardCharsets.US_ASCII);
|
||||
/// ASCII local name of an SVG document element.
|
||||
private static final String SVG_ELEMENT_NAME = "svg";
|
||||
|
||||
// This is currently a simple check, more complex checks can be considered in the future
|
||||
/// ASCII prefix of an XML comment.
|
||||
private static final String XML_COMMENT_START = "<!--";
|
||||
|
||||
/// ASCII suffix of an XML comment.
|
||||
private static final String XML_COMMENT_END = "-->";
|
||||
|
||||
/// ASCII prefix of an XML processing instruction.
|
||||
private static final String XML_PROCESSING_INSTRUCTION_START = "<?";
|
||||
|
||||
/// ASCII suffix of an XML processing instruction.
|
||||
private static final String XML_PROCESSING_INSTRUCTION_END = "?>";
|
||||
|
||||
/// Case-sensitive ASCII prefix of an XML document type declaration.
|
||||
private static final String XML_DOCTYPE_START = "<!DOCTYPE";
|
||||
|
||||
/// Returns whether the supplied prefix identifies an SVG XML document.
|
||||
///
|
||||
/// An optional UTF-8 byte-order mark, XML whitespace, comments, processing
|
||||
/// instructions, and a document type declaration may precede the document
|
||||
/// element. The method identifies the document element only; it does not
|
||||
/// validate the remainder of the XML document. XML markup must use its
|
||||
/// ASCII byte representation; UTF-16 and UTF-32 prefixes are not recognized.
|
||||
///
|
||||
/// @param headerBuffer leading bytes of the candidate image
|
||||
/// @return whether the first XML document element is `svg`
|
||||
public static boolean isSVG(byte[] headerBuffer) {
|
||||
return headerBuffer.length > SVG_HEADER.length
|
||||
&& Arrays.equals(headerBuffer, 0, SVG_HEADER.length, SVG_HEADER, 0, SVG_HEADER.length);
|
||||
int offset = hasUtf8ByteOrderMark(headerBuffer) ? 3 : 0;
|
||||
|
||||
while (true) {
|
||||
offset = skipXmlWhitespace(headerBuffer, offset);
|
||||
|
||||
if (isSvgElementStart(headerBuffer, offset))
|
||||
return true;
|
||||
|
||||
if (matchesAscii(headerBuffer, offset, XML_COMMENT_START)) {
|
||||
offset = indexAfterAscii(
|
||||
headerBuffer,
|
||||
offset + XML_COMMENT_START.length(),
|
||||
XML_COMMENT_END
|
||||
);
|
||||
} else if (matchesAscii(headerBuffer, offset, XML_PROCESSING_INSTRUCTION_START)) {
|
||||
offset = indexAfterAscii(
|
||||
headerBuffer,
|
||||
offset + XML_PROCESSING_INSTRUCTION_START.length(),
|
||||
XML_PROCESSING_INSTRUCTION_END
|
||||
);
|
||||
} else if (isDoctypeStart(headerBuffer, offset)) {
|
||||
offset = skipDoctype(headerBuffer, offset);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (offset < 0)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the byte array starts with a UTF-8 byte-order mark.
|
||||
///
|
||||
/// @param data bytes to inspect
|
||||
/// @return whether `data` begins with `EF BB BF`
|
||||
private static boolean hasUtf8ByteOrderMark(byte[] data) {
|
||||
return svgScanLimit(data) >= 3
|
||||
&& data[0] == (byte) 0xef
|
||||
&& data[1] == (byte) 0xbb
|
||||
&& data[2] == (byte) 0xbf;
|
||||
}
|
||||
|
||||
/// Returns the bounded length available to SVG detection.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @return the lesser of the array length and [#HEADER_BUFFER_SIZE]
|
||||
private static int svgScanLimit(byte[] data) {
|
||||
return Math.min(data.length, HEADER_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
/// Skips XML whitespace from an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start inclusive offset at which to begin
|
||||
/// @return the first offset that does not contain XML whitespace
|
||||
private static int skipXmlWhitespace(byte[] data, int start) {
|
||||
int limit = svgScanLimit(data);
|
||||
while (start < limit && isXmlWhitespace(data[start]))
|
||||
start++;
|
||||
return start;
|
||||
}
|
||||
|
||||
/// Returns whether an SVG document-element qualified name begins at an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of a possible element opening delimiter
|
||||
/// @return whether the unprefixed or prefixed element has the local name `svg`
|
||||
private static boolean isSvgElementStart(byte[] data, int start) {
|
||||
int limit = svgScanLimit(data);
|
||||
if (start < 0 || start >= limit || data[start] != '<')
|
||||
return false;
|
||||
|
||||
int nameStart = start + 1;
|
||||
int localNameStart = nameStart;
|
||||
boolean hasPrefix = false;
|
||||
|
||||
for (int index = nameStart; index < limit; index++) {
|
||||
byte value = data[index];
|
||||
if (value == '>' || isXmlWhitespace(value)
|
||||
|| value == '/' && index + 1 < limit && data[index + 1] == '>') {
|
||||
return index - localNameStart == SVG_ELEMENT_NAME.length()
|
||||
&& matchesAscii(data, localNameStart, SVG_ELEMENT_NAME);
|
||||
}
|
||||
|
||||
if (value == ':') {
|
||||
if (hasPrefix || index == nameStart)
|
||||
return false;
|
||||
hasPrefix = true;
|
||||
localNameStart = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean nameStartCharacter = index == nameStart || index == localNameStart;
|
||||
if (nameStartCharacter
|
||||
? !isAsciiXmlNameStart(value)
|
||||
: !isAsciiXmlNamePart(value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns whether a byte can begin an ASCII XML name component.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is an ASCII letter or underscore
|
||||
private static boolean isAsciiXmlNameStart(byte value) {
|
||||
return value >= 'A' && value <= 'Z'
|
||||
|| value >= 'a' && value <= 'z'
|
||||
|| value == '_';
|
||||
}
|
||||
|
||||
/// Returns whether a byte can continue an ASCII XML name component.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is accepted after the first name character
|
||||
private static boolean isAsciiXmlNamePart(byte value) {
|
||||
return isAsciiXmlNameStart(value)
|
||||
|| value >= '0' && value <= '9'
|
||||
|| value == '-'
|
||||
|| value == '.';
|
||||
}
|
||||
|
||||
/// Returns whether an XML document type declaration begins at an offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of a possible declaration
|
||||
/// @return whether the case-sensitive keyword is followed by XML whitespace
|
||||
private static boolean isDoctypeStart(byte[] data, int start) {
|
||||
int keywordEnd = start + XML_DOCTYPE_START.length();
|
||||
return matchesAscii(data, start, XML_DOCTYPE_START)
|
||||
&& keywordEnd < svgScanLimit(data)
|
||||
&& isXmlWhitespace(data[keywordEnd]);
|
||||
}
|
||||
|
||||
/// Skips an XML document type declaration, including its internal subset.
|
||||
///
|
||||
/// Quoted values, comments, and processing instructions are ignored while
|
||||
/// locating the declaration-closing `>`.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset of the `<!DOCTYPE` prefix
|
||||
/// @return the offset after the declaration, or `-1` if it is incomplete
|
||||
private static int skipDoctype(byte[] data, int start) {
|
||||
int internalSubsetDepth = 0;
|
||||
byte quote = 0;
|
||||
int limit = svgScanLimit(data);
|
||||
|
||||
for (int index = start + XML_DOCTYPE_START.length(); index < limit; index++) {
|
||||
byte value = data[index];
|
||||
|
||||
if (quote != 0) {
|
||||
if (value == quote)
|
||||
quote = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value == '\'' || value == '"') {
|
||||
quote = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesAscii(data, index, XML_COMMENT_START)) {
|
||||
int commentEnd = indexAfterAscii(
|
||||
data,
|
||||
index + XML_COMMENT_START.length(),
|
||||
XML_COMMENT_END
|
||||
);
|
||||
if (commentEnd < 0)
|
||||
return -1;
|
||||
index = commentEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (matchesAscii(data, index, XML_PROCESSING_INSTRUCTION_START)) {
|
||||
int instructionEnd = indexAfterAscii(
|
||||
data,
|
||||
index + XML_PROCESSING_INSTRUCTION_START.length(),
|
||||
XML_PROCESSING_INSTRUCTION_END
|
||||
);
|
||||
if (instructionEnd < 0)
|
||||
return -1;
|
||||
index = instructionEnd - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value == '[') {
|
||||
internalSubsetDepth++;
|
||||
} else if (value == ']' && internalSubsetDepth > 0) {
|
||||
internalSubsetDepth--;
|
||||
} else if (value == '>' && internalSubsetDepth == 0) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Finds an ASCII sequence in a byte array.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start inclusive offset at which to begin searching
|
||||
/// @param asciiValue ASCII sequence to find
|
||||
/// @return the offset after the first match, or `-1` if no match exists
|
||||
private static int indexAfterAscii(byte[] data, int start, String asciiValue) {
|
||||
int lastStart = svgScanLimit(data) - asciiValue.length();
|
||||
for (int index = start; index <= lastStart; index++) {
|
||||
if (matchesAscii(data, index, asciiValue))
|
||||
return index + asciiValue.length();
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// Compares an ASCII string with bytes at an array offset.
|
||||
///
|
||||
/// @param data source bytes
|
||||
/// @param start offset at which to compare
|
||||
/// @param asciiValue ASCII sequence to compare
|
||||
/// @return whether the complete sequence matches
|
||||
private static boolean matchesAscii(byte[] data, int start, String asciiValue) {
|
||||
if (start < 0 || start > svgScanLimit(data) - asciiValue.length())
|
||||
return false;
|
||||
|
||||
for (int index = 0; index < asciiValue.length(); index++) {
|
||||
if (Byte.toUnsignedInt(data[start + index]) != asciiValue.charAt(index))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Returns whether a byte is XML whitespace.
|
||||
///
|
||||
/// @param value byte to inspect
|
||||
/// @return whether the byte is space, tab, carriage return, or line feed
|
||||
private static boolean isXmlWhitespace(byte value) {
|
||||
return value == ' ' || value == '\t' || value == '\r' || value == '\n';
|
||||
}
|
||||
|
||||
private static final byte[] PNG_HEADER = {
|
||||
@@ -249,7 +513,7 @@ public final class ImageUtils {
|
||||
|
||||
ByteBuffer buffer = ByteBuffer.wrap(headerBuffer, 8, headerBuffer.length - 8);
|
||||
|
||||
PngChunkHeader header;
|
||||
@Nullable PngChunkHeader header;
|
||||
while ((header = PngChunkHeader.readHeader(buffer)) != null) {
|
||||
// https://wiki.mozilla.org/APNG_Specification#Structure
|
||||
// To be recognized as an APNG, an `acTL` chunk must appear in the stream before any `IDAT` chunks.
|
||||
@@ -436,7 +700,7 @@ public final class ImageUtils {
|
||||
}
|
||||
}
|
||||
|
||||
PngAnimationControl animationControl = sequence.getAnimationControl();
|
||||
@Nullable PngAnimationControl animationControl = sequence.getAnimationControl();
|
||||
int cycleCount;
|
||||
if (animationControl != null) {
|
||||
cycleCount = animationControl.numPlays();
|
||||
@@ -465,6 +729,7 @@ public final class ImageUtils {
|
||||
return argb;
|
||||
}
|
||||
|
||||
/// Prevents instantiation.
|
||||
private ImageUtils() {
|
||||
}
|
||||
}
|
||||
@@ -1171,8 +1171,7 @@ public class PersonalizationPage extends StackPane {
|
||||
|
||||
LineToggleButton animationButton = new LineToggleButton();
|
||||
appearanceList.getContent().add(animationButton);
|
||||
animationButton.setSelected(settings().isAnimationDisabled());
|
||||
FXUtils.onChange(animationButton.selectedProperty(), value -> settings().animationDisabledProperty().set(value));
|
||||
animationButton.selectedProperty().bindBidirectional(settings().animationDisabledProperty());
|
||||
animationButton.setTitle(i18n("settings.launcher.turn_off_animations"));
|
||||
animationButton.setSubtitle(i18n("settings.take_effect_after_restart"));
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import java.nio.file.Path;
|
||||
|
||||
/// @author Glavo
|
||||
public enum NBTFileType {
|
||||
COMPRESSED("nbt", "dat", "dat_old") {
|
||||
COMPRESSED("dat", "dat_old") {
|
||||
@Override
|
||||
public Tag read(Path file) throws IOException {
|
||||
return NBTCodec.of().readTag(file);
|
||||
|
||||
@@ -59,12 +59,12 @@ public class GameAdvancedListItem extends AdvancedListItem {
|
||||
if (repositoryChanged) {
|
||||
repository = GameDirectoryManager.getSelectedRepository();
|
||||
onVersionIconChangedListener = repository.onVersionIconChanged.registerWeak(event -> {
|
||||
this.loadVersion();
|
||||
FXUtils.runInFX(this::loadVersion);
|
||||
});
|
||||
|
||||
if (!repository.isLoaded()) {
|
||||
onRefreshedVersionsListener = EventBus.EVENT_BUS.channel(RefreshedVersionsEvent.class)
|
||||
.registerWeak(event -> loadVersion());
|
||||
.registerWeak(event -> FXUtils.runInFX(this::loadVersion));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ public final class ModListPage extends ListPageBase<ModListPageSkin.ModInfoObjec
|
||||
public void add() {
|
||||
FileChooser chooser = new FileChooser();
|
||||
chooser.setTitle(i18n("mods.add.title"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.litemod"));
|
||||
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("extension.mod"), "*.jar", "*.zip", "*.litemod"));
|
||||
List<Path> res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage()));
|
||||
|
||||
if (res == null) return;
|
||||
|
||||
@@ -379,14 +379,11 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
ModInfoDialog(ModInfoObject modInfo) {
|
||||
HBox titleContainer = new HBox();
|
||||
titleContainer.setSpacing(8);
|
||||
titleContainer.setPadding(new Insets(0, 0, 12, 0));
|
||||
|
||||
Stage stage = Controllers.getStage();
|
||||
prefWidthProperty().bind(stage.widthProperty().multiply(0.7));
|
||||
maxWidthProperty().bind(stage.widthProperty().multiply(0.7));
|
||||
|
||||
var imageContainer = new ImageContainer(40);
|
||||
titleContainer.setAlignment(Pos.CENTER_LEFT);
|
||||
modInfo.loadIcon(imageContainer, null);
|
||||
|
||||
TwoLineListItem title = new TwoLineListItem();
|
||||
@@ -395,13 +392,13 @@ final class ModListPageSkin extends SkinBase<ModListPage> {
|
||||
else
|
||||
title.setTitle(modInfo.getModInfo().getName());
|
||||
|
||||
StringJoiner subtitle = new StringJoiner("\n");
|
||||
subtitle.add(i18n("archive.file.name") + ": " + FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
StringJoiner subtitle = new StringJoiner(" | ");
|
||||
subtitle.add(FileUtils.getName(modInfo.getModInfo().getFile()));
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getGameVersion())) {
|
||||
subtitle.add(i18n("mods.game.version") + ": " + modInfo.getModInfo().getGameVersion());
|
||||
subtitle.add(modInfo.getModInfo().getGameVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getVersion())) {
|
||||
subtitle.add(i18n("archive.version") + ": " + modInfo.getModInfo().getVersion());
|
||||
subtitle.add(modInfo.getModInfo().getVersion());
|
||||
}
|
||||
if (StringUtils.isNotBlank(modInfo.getModInfo().getAuthors())) {
|
||||
subtitle.add(i18n("archive.author") + ": " + modInfo.getModInfo().getAuthors());
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -152,7 +152,7 @@ scientists-dream;145;;Scientific Dreams;;
|
||||
abandend;146;;AbandEnd;;
|
||||
tech-expansion;147;;Tech Expansion;;
|
||||
minefanstasyworld;148;;我的幻想世界;MineFantasyWorld;
|
||||
slightly-vanilla-flavoured;149;;Slightly Vanilla Flavoured;;
|
||||
slightly-vanilla-flavoured;149;;Slightly Flavoured / Slightly Vanilla Flavoured;;
|
||||
all-the-mods;150;;All the Mods;;ATM1
|
||||
crackpack-3;151;;Crackpack 3;;
|
||||
valhelsia-3;152;;Valhelsia 3;;
|
||||
@@ -213,7 +213,7 @@ better-minecraft-plus;207;;Better Minecraft [PLUS];;
|
||||
parasites-by-forge-labs;208;;Parasites by Forge Labs;;
|
||||
create-live;209;;Create Live;;
|
||||
airaka-tech;211;;艾瑞卡科技;Airaka Tech;
|
||||
;212;;TeaCon;;
|
||||
;212;;TeaCon整合包;TeaCon;
|
||||
originssmp;213;;起源SMP;OriginsSMP;
|
||||
create-live-2;214;;Create Live 2;;
|
||||
origin-smp-copy;215;;Origin - SMP;;
|
||||
@@ -252,7 +252,7 @@ better-minecraft-otv-edition;249;;Better Minecraft (OfflineTV Edition);;
|
||||
evolution-reset;250;;进化:重启;Evolution: Reset;ER
|
||||
the-nuclear-wasteland;251;;核荒原;The Nuclear Wasteland;
|
||||
blessed-or-cursed-expedition-to-infinite-force;252;;祝福或诅咒:无限力量之征;Blessed or Cursed: Expedition to Infinite Force;BoC
|
||||
;253;;边界生存;Border Survival;BSV
|
||||
;253;;边界生存:存世;Border Survival;BSV
|
||||
medieval-mc-forge-mmc1;255;;Medieval Minecraft;;MMC
|
||||
heavens-of-sorcery;256;;巫术天堂;Heavens of Sorcery;
|
||||
herodotus;257;;希罗多德;Herodotus-Authentic Edition:Official;HDS
|
||||
@@ -498,7 +498,7 @@ other-body;504;;Other Body;;
|
||||
;506;;莱姆星云;Lime Nebula;LN
|
||||
void-genesis;507;;Void Genesis;;
|
||||
create-flavored;508;;Create Flavored;;
|
||||
terrafirmagreg-vintage;509;;TerraFirmaGreg;;TFG
|
||||
terrafirmagreg-vintage;509;;群峦格雷;TerraFirmaGreg;TFG
|
||||
create-astral;510;;机械动力:星辰;Create: Astral;
|
||||
;511;;起源创造;CreateOrigin;CO
|
||||
random-item-skyblock-recondite-i;512;;Random Item Island - Recondite I;;
|
||||
@@ -516,7 +516,7 @@ pick-the-stars;523;;摘星;Pick The Stars;PTS
|
||||
earth-technology;524;;Earth Technology;;
|
||||
;525;;核乐不为;;ZMC
|
||||
ftb-stoneblock-3;526;;FTB StoneBlock 3;;
|
||||
create-mod-plus;527;;Create +;;
|
||||
create-mod-plus;527;;Create+;;
|
||||
multiplayer-optimization;528;;RawDiamondMC的优化包;RawDiamondMC's optimization pack;MPO
|
||||
chroma-endless;529;;Chroma Endless;;
|
||||
ragnamod-vi-in-the-sky;530;;Ragnamod VI In The Sky;;
|
||||
@@ -548,7 +548,7 @@ gregic-skies;555;;Gregic Skies;;
|
||||
;557;;齿轮颂歌:暮光;Gear Carol;
|
||||
pluma;558;;Pluma, a Journey to the Future!;;Pluma
|
||||
all-the-mods-7-to-the-sky;559;;All the Mods 7 - To the Sky;;ATM7S
|
||||
;560;;月见苍穹传说;TskimiCanopyCraft;TSCC
|
||||
;560;;月见苍穹传说;TsukiCupolaCraft;TSCC
|
||||
;561;;最后的战役:劫后余生;The Last Stand:Aftermath;LSA/TLS
|
||||
;562;;小猪配平;Peppa Pig's Burning Broom;2P2B
|
||||
zerblands-remastered;563;;Zerblands-Remastered;;
|
||||
@@ -672,7 +672,7 @@ a-new-technological-era;681;;A New Technological Era;;ANTE
|
||||
musketeer;683;;Musketeer - One for All;;
|
||||
;684;;匠造之传;The Legend of Tinker;TLT
|
||||
;685;;我的骑士:怪物大乱斗;;
|
||||
;686;;塔可努的原生优化整合包;TechNoob's Vanilla Boost Pack;TNVBP
|
||||
;686;;塔可努的原生优化整合包;TNF-Vanilla / TechNoob's Vanilla Boost Pack;TNVBP
|
||||
;687;;灵茗屿空岛;LMY SKY;
|
||||
;688;;红石生电优化;Redstone Survival Optimization;RSO
|
||||
;689;;恐惧深渊;Abyss of Fear;AoF
|
||||
@@ -734,7 +734,7 @@ life-in-the-village-3;740;;Life in the village 3;;LitV3
|
||||
cook-in-skyblock;746;;天空厨房;Cook in skyland;CiS
|
||||
;747;;被恐惧支配 1;Dominated by Fear 1;DoF
|
||||
new-vanilla-newhome;748;;新的原版;New Vanilla;NVA
|
||||
;749;;峦屿梦星群;Terrafirma Rescue Unofficial;TFRU
|
||||
tfru;749;;峦屿梦星群;Terrafirma Rescue Unofficial;TFRU
|
||||
stoneopolis;750;;Stoneopolis;;
|
||||
ftb-skies-expert;751;;FTB Skies Expert;;
|
||||
wmshd-pack-what-mojang-should-have-done;752;;What Mojang Should Have Done;;WMSHD
|
||||
@@ -806,7 +806,6 @@ la;818;;胶兽大冒险;Latex Advanture;LA
|
||||
mystics-monstrosity;819;;Mystic's Monstrosity;;
|
||||
;820;;更完美的MC;More Perfect Minecraft;MPMC
|
||||
;821;;动画世界:烂柯人;AnimateCraft: Lankeren;
|
||||
;822;;New Create;;NC
|
||||
land-of-memories;823;;回忆之地;;LOM
|
||||
;824;;异世界冒险2;;
|
||||
ragnamod-vii;825;;Ragnamod VII;;
|
||||
@@ -866,7 +865,7 @@ create-vanilla-exploration;879;;Vanilla Create Exploration;;
|
||||
progress;880;;Progress: Create;;
|
||||
aeon-automata;881;;Aeon Automata;;AA
|
||||
;882;;被恐惧支配2:收容失效;Dominated by Fear 2 Containment Breach;DoF2
|
||||
tensioncraft;883;;剑拔弩张之时;;TC
|
||||
;883;;剑拔弩张之时;;TC
|
||||
project-architect-2;884;;Project Architect 2;;
|
||||
;885;;接入铁路网络:我的世界铁路;MTR Railway Network Access;RNA-MTR
|
||||
create-mekanized;886;;Create: Mekanized;;
|
||||
@@ -923,24 +922,23 @@ setgamedifficulty-reika;936;;SetGameDifficulty Reika;;
|
||||
epic-journey-gtceu;937;;Epic Journey GTCEu;;
|
||||
terrafirmacraft-leap-the-top;938;;飞渡群峦;TerraFirmaCraft:Leap The Top;TFC:LTT
|
||||
gregtech-skybound;939;;Gregtech: Skybound;;
|
||||
simple-tfc;940;;简单群峦;Simple TFC;
|
||||
simple-tfc;940;;简单群峦;Simple TFC;SPTFC
|
||||
ftb-presents-direwolf20-s14;941;;FTB Presents Direwolf20 1.21;;
|
||||
final-judgment-save-the-world;942;;Final Judgment:Save the world;;
|
||||
;943;;极端世界:重置;Extreme World Reset;EWR
|
||||
explore-everything;944;;空岛:探索;Explore Everything;SEE
|
||||
epic-journey-gtceu-conquerors-edition;945;;Epic Journey GTCEu - Conqueror's Edition;;
|
||||
the-undead-blooms;946;;亡灵绽放 | 真菌孢子启示录 |;The Undead Blooms | Fungal Spore Apocalypse |;
|
||||
;947;;星地征程;Starland Journey;SJ
|
||||
;948;;希望与绝望-惊变100天;Hope and Despair;had
|
||||
;947;;星地征程;Starland Journey re;SJRE
|
||||
;948;;希望与绝望-惊变100天;Hope and Despair;HAD
|
||||
;949;;梦篡光阴;Dreams Distort Overture;DDO
|
||||
;950;;最后的光芒;The Last Light;TLL
|
||||
endil-expert-edition;951;;Endil's Expert Edition;;EEE
|
||||
peace-of-mind;952;;Peace of Mind;;
|
||||
distantly-optimized;953;;Distantly Optimized;;DO
|
||||
farming-experience;954;;Farming Experience;;
|
||||
hungry-for-tech-2;955;;Hungry For Tech 2;;
|
||||
hungry-for-tech-2;955;;渴望科技2;Hungry For Tech 2;
|
||||
remain;957;;Remain;;
|
||||
;958;;末日狂徒;;
|
||||
gregtania6-skyblock;959;;Gregtania6 Skyblock;;
|
||||
harmonious-engineering;960;;Harmonious Engineering;;
|
||||
valley-of-farming-and-ranching;961;;农牧物语;Valley of Farming and Ranching;
|
||||
@@ -985,7 +983,7 @@ industrial-machines;1000;;Industrial Machines;;
|
||||
auto-terrafirmacraft-tfc-create;1001;;Auto-TerraFirmaCraft (TFC + Create);;ATFC
|
||||
;1002;;时间之书:星的日记;The Book of Time: A Star's Journal;TASL
|
||||
;1003;;特摄世界;Tokusatsu World;TW
|
||||
journey-of-reincarnation;1004;;转世之旅;Tensura: Journey of Reincarnation;
|
||||
;1004;;转世之旅;Tensura: Journey of Reincarnation;
|
||||
;1005;;冒险与休闲;Adventure and Leisure;AL
|
||||
enigmatica10;1006;;Enigmatica 10;;E10
|
||||
mc-chocolate-edition;1007;;Chocolate Edition Minecraft;;
|
||||
@@ -1002,7 +1000,7 @@ kumo-o-tagayasu;1017;;耕云钓月;Kumo O Tagayasu;KOTS
|
||||
;1018;;无限战争4;Infinity War4;INF4
|
||||
;1019;;群峦传说:工业长路;;
|
||||
;1020;;自然冒险 新生;Nature Adventure Newborn;NAB
|
||||
hmbo;1021;;羽舲的基础优化;Hanekmio's Basis Optimization;
|
||||
asig;1021;;浅忆基础整合;Asai Integrated Modpack;
|
||||
create-star-valley;1022;;Create: Gears of History;;
|
||||
;1023;;阿尔法计划;AlphaPlan;AP
|
||||
spell-dimension;1024;;咒次元;Spell Dimension;SD
|
||||
@@ -1015,7 +1013,7 @@ terminal;1030;;终末;Terminal;
|
||||
isolated-crystal-3;1031;;异次元水晶 3;Isolated Crystal 3;
|
||||
;1032;;机械动力:无限总是小于零;Create:Infinity Always Smaller Than Zero;CIASZ
|
||||
;1033;;踪灭I-起源;;
|
||||
;1034;;踪灭II;;
|
||||
;1034;;踪灭II;Endtime II;
|
||||
rabbits-dawn-craft;1035;;某兔子的破晓之界;A Certain Rabbit's DawnCraft - Dawn of the Deep Soul;RDC
|
||||
;1036;;铁砧天空;;ASKY
|
||||
grand-architect-journey;1037;;伟大工程巡礼;Grand Architecture Journey;GAJ
|
||||
@@ -1075,7 +1073,7 @@ roguelike-adventures-and-dungeons-3;1090;;冒险与地牢3;Roguelike Adventures
|
||||
wheelchair-gregtech;1093;;Wheelchair Gregtech;;
|
||||
egged-up;1094;;Egged Up;;
|
||||
;1095;;香草纪元:食旅纪行;VanillaEra: FaresChron;VEFC
|
||||
;1096;;愚者;The Fool;TF
|
||||
the-fool;1096;;愚者;The Fool;TF
|
||||
;1097;;时间之书2:星梦指南;BookofTime II Stardream Compass;BKSC
|
||||
;1098;;群峦之上:无垠伊土;EndlessFirma;EnF
|
||||
;1099;;血月低语:暗夜惊魂;Bloodmoon's Whisper;BW
|
||||
@@ -1119,11 +1117,11 @@ life-in-the-village-4;1136;;Life in the village 4;;LITV4
|
||||
real-island-2-modular-craft;1137;;真实空岛2:模块化之旅;Real Island 2 - Modular Craft;RI2
|
||||
;1138;;奥术咖啡馆;Arcane Cofferia;
|
||||
unyielding;1139;;Unyielding;;
|
||||
;1140;;剑与王国;;SNK
|
||||
;1140;;剑与王国;;sword_and_kingdom
|
||||
the-fungal-infection-coop;1141;;The Fungal Infection | Co-Op Base Defense;;
|
||||
mc-eternal-2;1142;;永恒的MC 2;MC Eternal 2;MCE2
|
||||
solo-leveling-reawakening;1143;;Solo Leveling - Reawakening;;
|
||||
create-ultimate-selection;1144;;Create: Ultimate Selection - QUESTS NOW!;;
|
||||
create-ultimate-selection;1144;;Create: Ultimate Selection;;
|
||||
;1145;;元素觉醒;Elemental Awakening;
|
||||
endless-horror-the-ultimate-horror-modpack;1146;;Endless Horror - The ULTIMATE Horror Experience;;
|
||||
;1147;;锻造之旅;;
|
||||
@@ -1145,13 +1143,13 @@ blue-archive-x-mineraft;1163;;我的世界x蔚蓝档案;BlueArchive x Minecraft;
|
||||
;1164;;Fresh & Smooth;;
|
||||
;1165;;机动重启工程:风之旅;The Caravan of Winds;TCW
|
||||
;1166;;Furry Fandom Club Boss Rush Official Offline;;FFCBR
|
||||
immersive-ultra;1167;;极致沉浸;Immersive Ultra;
|
||||
;1167;;极致沉浸;Immersive Ultra;
|
||||
;1168;;Polymania!;;
|
||||
survivearchive;1169;;SurviveArchive - A Blue Archive modpack;;
|
||||
beloong;1170;;化龍;BeLoong;
|
||||
;1171;;畅游自然;;SiF
|
||||
;1171;;畅游自然;;SiN
|
||||
;1172;;退休工艺;Retirement Craft;RmC
|
||||
;1173;;溯光暗潮:云影;NS's Random Gunfight;NRG
|
||||
;1173;;溯光暗潮;TRACING LIGHT · DARK TIDE;NRG
|
||||
;1174;;雪狐的基础整合;SF Integrated Modpack;SFI
|
||||
;1175;;硬核惊变;Hardcore Surprise;HS
|
||||
society-sunlit-valley;1176;;Society: Sunlit Valley;;
|
||||
@@ -1179,7 +1177,7 @@ repalmon;1198;;Re帕鲁梦;RePalmon;RPLM
|
||||
structural-ingenuity;1199;;结构型奇思妙想;Structural Ingenuity;
|
||||
arcaneaxis-ars;1200;;秘法象限;ArcaneAxis;ARS
|
||||
gregtech-easy;1201;;格雷科技简单版;GregTech Easy;GTE
|
||||
;1202;;机境交响;Creation Exploring the Road of;CETR
|
||||
create-exploring-the-path-of;1202;;机境交响;Creation Exploring the Road of;CETR
|
||||
multiblockful-skies;1203;;Multiblockful Skies;;
|
||||
;1204;;刀剑物语;SwordStory;SS
|
||||
;1205;;无垠征途;The Unbounded Expedition;TUE
|
||||
@@ -1194,13 +1192,13 @@ farmy-skyblock;1213;;Farmy Skyblock;;
|
||||
bloody-skyblock-modpack;1214;;Bloody Skyblock;;
|
||||
starages;1215;;Star Space Ages;;
|
||||
create-spectral-cuisine;1216;;Create: Spectral Cuisine;;
|
||||
project-infinity-0-1;1217;;Project Infinity 0.1;;
|
||||
project-infinity-0-1;1217;;无限计划;Project Infinity 0.1;
|
||||
create-live-6;1218;;Create Live 6;;
|
||||
;1219;;全面包围-硬核惊变;Full encirclement;FLET
|
||||
;1220;;科波菲尔;Copperfield;CPF
|
||||
;1221;;试用核心/I.O.P的水槽基础整合;Trial Essential;
|
||||
tnp-limitless-8;1222;;TNP Limitless 8;;
|
||||
;1223;;重度机械症;Mechanomania;
|
||||
mechanomania;1223;;重度机械症;Mechanomania;
|
||||
;1224;;寻此苦旅3;Per Aspera3;PA3
|
||||
calamity-rpg-iii-wounds-of-the-world;1225;;Calamity RPG III : Wounds of the World;;
|
||||
;1226;;烛枫公会优化整合包;;ZFGH
|
||||
@@ -1221,17 +1219,17 @@ erstwhile-journey-2;1240;;往昔之旅2;Erstwhile Journey 2;EJ2
|
||||
;1241;;诡厄:使徒;;GRAE
|
||||
;1242;;时间之书 : 星之旅行-重制版;BookofTime StarTravel;BKSL
|
||||
cook-in-skyland-elegant-feast;1243;;天空厨房:典雅盛宴;Cook in Skyland Elegant Feast;CiS2
|
||||
ftb-stoneblock-4;1244;;FTB StoneBlock 4;;SB4
|
||||
ftb-stoneblock-4;1244;;石头世界4;FTB StoneBlock 4;SB4
|
||||
techevdiscovery;1245;;TechEv || Discovery;;
|
||||
g3-project;1246;;G3 project;;
|
||||
;1247;;新州;Xin Zhou;XZ
|
||||
;1247;;新州;Xinzhou;XZ
|
||||
emc-and-fe-plus3;1248;;EMC & FE +++;;E&F2
|
||||
terracraft-confluence;1249;;泰拉工艺:汇流;TerraCraft:Confluence;TCC
|
||||
;1250;;探索领域;Explore Realm;ER
|
||||
beyond-cosmo;1251;;Beyond Cosmo;;
|
||||
;1252;;闲居物语;Travel Architecture Dreams;TAD
|
||||
;1252;;闲居物语;Travel Serere Homestead;TSH
|
||||
high-altitude-alchemy;1253;;High-Altitude Alchemy;;
|
||||
;1254;;机械动力: 界位工程师;Create Boundary Engineer;CBE
|
||||
create-boundary-engineer;1254;;机械动力: 界位工程师;Create Boundary Engineer;CBE
|
||||
skyopolis-evolved;1255;;Skyopolis: Evolved;;
|
||||
;1256;;IFR的1.12.2优化整合包;IFR's Forgified Optimization;IFO
|
||||
;1257;;无穷匠艺;;
|
||||
@@ -1262,7 +1260,7 @@ mythcrafts;1281;;MYTHCRAFT 5;;MC
|
||||
;1282;;牧场物语;Harvest Moon;
|
||||
;1283;;逃逸:寄生末日;SRP: Escape Parasite Doom;SRP:epd
|
||||
;1284;;Ice and M;;IAM
|
||||
;1285;;秋水;autumn floods;AF
|
||||
autumn-floods;1285;;秋水;autumn floods;AF
|
||||
;1286;;V2O5;;
|
||||
;1287;;地牢冒险者;Dungeon Adventurer;DA
|
||||
ultramodern-optimization;1289;;超现代优化;Ultramodern optimization;UO
|
||||
@@ -1273,14 +1271,14 @@ pandemonium-nexus-anarchic-aeon;1293;;万象枢纽:无序纪元;Pandemonium Ne
|
||||
;1294;;菌染亵渎;Fungal Infection Blasphemy;FIB
|
||||
;1295;;Ice and M:Sky;;IAMS
|
||||
;1296;;TUFF PERFORMANCE;;
|
||||
;1297;;灾厄巫咒;;
|
||||
;1297;;灾厄巫咒;Curlamoety;
|
||||
;1298;;子弹呼啸 : 重制;Bullet Whizz : Remake;BWR
|
||||
mybiomesobeautiful;1299;;My Biomes Is So Beautiful;;
|
||||
create-capitalist;1300;;Create Capitalist;;
|
||||
diving-beyond;1301;;Diving Beyond;;
|
||||
;1302;;奇星·奥术;Mystic Star_Arcane;
|
||||
guild-adventures;1303;;Guild Adventures;;
|
||||
;1304;;艾斯爱慕:新视野;Spore and M:New Horizons;SMNH
|
||||
;1304;;艾斯爱慕:无限构件:现代工业;Spore and M:Infinity Mechanism:Modern Industrialization;SMIM
|
||||
otherworld-dnd;1305;;Otherworld [Dungeons & Dragons];;
|
||||
;1306;;奇幻起源;Fantasy Origins;
|
||||
projects;1307;;ProjectS;;
|
||||
@@ -1318,7 +1316,7 @@ farming-crossing-6-dinner-party;1334;;Farming Crossing 6: Dinner Party!;;
|
||||
;1339;;机械动力:枪火不断;Create: Gunfire Never Stops;CGNS
|
||||
;1340;;逆转未来;Battle For The Future;BFF
|
||||
day-of-mind;1341;;DayOfMind;;DOM
|
||||
;1342;;机械动力:探险生存 2;Create Adventure Survival 2;
|
||||
create-adventure-survival-ii;1342;;机械动力:探险生存 2;Create Adventure Survival II;
|
||||
;1343;;云游四海;WAYFARING;WF
|
||||
;1344;;阿卡迪亚的天启;The Apocalypse of Arcadia;TAOA
|
||||
;1345;;纯净原版・焕新;Pure Vanilla Renewal;PVR
|
||||
@@ -1327,4 +1325,110 @@ day-of-mind;1341;;DayOfMind;;DOM
|
||||
dragns-o-suite-pack;1348;;DragN's O-Suite Pack;;
|
||||
;1349;;机械动力:工业与牧歌;Create: Industry and Pastoral;CIAP
|
||||
;1350;;灾厄纪行;;
|
||||
beebeeblock-the-skyhive;1351;;蜂蜂空坊;Beebeeblock: The Skyhive;BTS
|
||||
beebeeblock-the-skyhive;1351;;蜂蜂空坊;Beebeeblock: The Skyhive;BBB
|
||||
the-last-world-shines;1352;;末世生辉;The Last World Shines;TLWS
|
||||
;1353;;MineOptimiz;;
|
||||
journey-to-infinity;1354;;Journey To Infinity;;
|
||||
;1355;;混元登仙;;HATI
|
||||
flying-path;1356;;飞舞之路;Flying Path;
|
||||
create-craft-quiet;1357;;机械动力:匠途闲境;Create:Craft&Quiet;ccq
|
||||
sky-new-horizons;1358;;Sky New Horizons;;SkyNH
|
||||
;1359;;天启之路;The Path Of Revelation;TPOR
|
||||
mana-and-mechanisms;1360;;Mana & Mechanisms;;
|
||||
;1361;;胶兽异界之旅Ⅱ;;
|
||||
mechanical-dragon;1362;;龙之机械;Dragon create;DC
|
||||
;1363;;香草精华;;VE
|
||||
submerged-2;1364;;Seaopolis Submerged 2;;
|
||||
;1365;;天煞孤星;A STAR DOOMED TO LONELINESS;ALONG
|
||||
;1366;;全亡灵启示录;All Undead Apocalypse;AUA
|
||||
;1367;;空流整合包;SkyFlow;
|
||||
;1368;;飞升者的陨落;Fall of Ascension;FOA
|
||||
;1369;;万华工艺录;KaleidoCraftia;KlC
|
||||
;1370;;多方块笙息空岛;;DSK
|
||||
etbw2-demo;1371;;探索光明的世界2;Explore the Bright World 2;EtBW2
|
||||
stellarisworkshop;1372;;孤星工坊;StellarisWorkshop;GTSW
|
||||
enders-journey-beyond-the-end;1373;;Ender's Journey - Beyond The End;;
|
||||
mysticalblockreloaded;1374;;Mystical Block Reloaded;;
|
||||
cozy-mizuno-minecraft;1375;;A Cozy Mizuno World;;
|
||||
;1376;;新生异土;Tensura Neo Otherworld;
|
||||
extraordinary-energy-modern;1377;;Extraordinary Energy : Modern;;
|
||||
;1378;;铁砧工艺-七天创世;;
|
||||
avaritia-project;1379;;Avaritia Project;;
|
||||
after-the-nukes;1380;;After The Nukes;;
|
||||
abyssal-ascent;1381;;Abyssal Ascent;;
|
||||
desolate-planet;1382;;Desolate Planet;;
|
||||
;1383;;德雷马尔:神化;Drehmal: Apotheosis;DHA
|
||||
project-arc-light-the-hanging-pavilion;1384;;弧光计划:空中楼阁;Project Arc Light: The Hanging Pavilion;PAHP
|
||||
;1385;;文明:创世;Civilization Isolaria;
|
||||
blockitecture;1386;;方块建筑学;Blockitecture (Block Architecture);
|
||||
;1388;;曙光空岛;;
|
||||
;1389;;千界一梦;The Oneiropolis;TOP
|
||||
;1390;;乌托星岛·创世旅途 3.0;EpicUraNepAdventure 3.0;EUNA3
|
||||
itaj;1391;;It Takes a Journey;;ITAJ
|
||||
taltale-pack;1392;;Taltale Pack;;
|
||||
waccyys-fantasia;1393;;waccyy's Fantasia;;
|
||||
all-the-mods-11;1394;;All The Mods 11;;ATM11
|
||||
create-live-7;1395;;Create Live 7;;
|
||||
;1396;;锻造之旅—无尽征途;;wjzt
|
||||
;1397;;魔法现代化工业笙息空岛;;MXSK
|
||||
nuclear-tech-integrated;1398;;核科技:集成;Nuclear Tech: Integrated;
|
||||
crashed;1399;;Crashed;;
|
||||
;1400;;格雷科技:万子科技;Greg Tech : Wanzi Technology;GTWT
|
||||
moddedtogether;1401;;Modded Together;;
|
||||
;1402;;附魔工程;;
|
||||
;1403;;机械动力:航空学新视野2.0;Create Aero: New Horizons2.0;CANH
|
||||
;1404;;Better with Datapack;;
|
||||
restlesshorizons;1405;;Restless Horizons;;RH
|
||||
;1406;;虚空行者;Void Wayfarer;VW
|
||||
;1407;;模块化整合包:农业;Modular Modpack: Agriculture;MMA
|
||||
the-song-of-crafting;1408;;合成颂歌;The Song of Crafting;
|
||||
awakening;1409;;AMP - Awakening Classic;;
|
||||
;1410;;末世再现;;CATT
|
||||
;1411;;死亡突围:朝花暮拾;DeadoutRe:Duskora;DRD
|
||||
craftoria-2;1412;;Craftoria 2;;
|
||||
;1413;;这是一个水槽包;This is the sink pack;
|
||||
;1414;;绝境中的生存;Survival in Desperation;
|
||||
;1415;;等价无尽机械;PeojectE Avaritia Create;
|
||||
;1416;;破灭与重生:破晓之途;Ruins and Rebirth:Path to Daybreak;RRPD
|
||||
;1417;;最后那些天-低性能优化版;Those Last Day Boost;
|
||||
;1418;;涅槃;Nirvana;UPN
|
||||
enigmatic-skies;1419;;Enigmatic Skies;;
|
||||
;1420;;Spark+ 原版红石增强优化;Spark+ Vanilla Redstone Optimization;SVRO
|
||||
;1421;;混沌之地;TheChaoticnia;
|
||||
;1422;;消逝的曙光;Dusk of Dawn;DOW
|
||||
cook-in-skyland-recaked;1423;;天空厨房:重制版;Cook in Skyland Recaked;CiSR
|
||||
rlcraft-fanmade;1424;;RLCraft Fanmade;;
|
||||
unaltered;1425;;Unaltered+;;
|
||||
sendims-banforges;1426;;千界万锻;Myriad Dimensional Forge;SDBF
|
||||
;1427;;超级优化;Super Optimized;SO
|
||||
society-sunlit-cobblemon;1428;;Society: Sunlit Cobblemon;;
|
||||
create-aeronautica;1429;;Create Aeronautica;;
|
||||
;1430;;群星入侵;Astrincursion;
|
||||
;1431;;涟漪之篇;As Written By Whom;AWBW
|
||||
starbuncles-gift;1432;;星宝石兽的礼物;starbuncles gift;
|
||||
;1433;;Ember Peril: Silent Expanse;;EPSE
|
||||
sky-bees-reborn;1434;;Sky Bees Reborn;;
|
||||
who-am-i-not-me;1435;;我非我;Who Am I - Not Me;
|
||||
all-the-hacks;1436;;All The Hacks;;ATH
|
||||
;1437;;新:模拟大都市;;NSUK
|
||||
;1438;;异界工匠;;
|
||||
;1439;;工业升级指导版;Industrial upgrade 101;IU101
|
||||
;1440;;假面骑士全明星编年史;;KRASC
|
||||
;1441;;极速优化;Ultra Fast Optimization;UFO
|
||||
new-age-science-and-technology;1442;;新时代科技;New Age Science and Technology;NAST
|
||||
;1443;;屎生万物;EverythingFromPoop;EFP
|
||||
;1444;;天顶征途;Zenith Conquest;
|
||||
;1445;;瓶中伊甸;Eden in the Flask;EITF
|
||||
aircrashed-engineer-ii;1446;;坠机工程师2;Aircrashed Engineer II;ACEII
|
||||
star-avaritia;1447;;寰宇星光;Star Avaritia;SA
|
||||
astrids-fnaf-pack;1448;;Astrid's FNAF Pack;;
|
||||
hp-create-world-of-gears;1449;;HP - Create World of Gears;;
|
||||
the-cursed-wanderer-reborn;1450;;咒游诸界:重生;The Cursed Wanderer: Reborn;TCWR
|
||||
;1451;;机械之旅;Mechanical Create;
|
||||
;1452;;探索MC小马的世界;Explore MLP World;EPW
|
||||
tech101;1453;;科技入门学;Tech101;
|
||||
endfield-new-horizons;1454;;终末地工业:新视野;Endfield:New Horizons;efnh
|
||||
dungeon-train;1455;;地牢列车;Dungeon train;
|
||||
project-arc-light-dimensional-rift;1456;;弧光计划:维度裂隙;Project Arc Light - Dimensional Rift;PA2
|
||||
;1457;;恶意世界;HostileCraft;HC
|
||||
adj;1458;;A Distant Journey;;ADJ
|
||||
File diff suppressed because it is too large.
Load diff
@@ -17,17 +17,26 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.ui.image;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/// Verifies image-format detection from bounded input prefixes.
|
||||
@NotNullByDefault
|
||||
public final class ImageUtilsTest {
|
||||
|
||||
/// Reads the image-loader detection prefix of a test resource.
|
||||
///
|
||||
/// @param fileName resource file name
|
||||
/// @return up to [ImageUtils#HEADER_BUFFER_SIZE] leading bytes
|
||||
private static byte[] readHeaderBuffer(String fileName) {
|
||||
try (var input = Files.newInputStream(Path.of("src/test/resources/image/" + fileName))) {
|
||||
return input.readNBytes(ImageUtils.HEADER_BUFFER_SIZE);
|
||||
@@ -37,6 +46,15 @@ public final class ImageUtilsTest {
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes an XML fixture without introducing non-ASCII bytes.
|
||||
///
|
||||
/// @param value fixture text
|
||||
/// @return US-ASCII bytes
|
||||
private static byte[] asciiBytes(String value) {
|
||||
return value.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
/// Verifies APNG detection distinguishes animated and static PNG files.
|
||||
@Test
|
||||
public void testIsApng() {
|
||||
assertTrue(ImageUtils.isApng(readHeaderBuffer("16x16.apng")));
|
||||
@@ -47,6 +65,7 @@ public final class ImageUtilsTest {
|
||||
assertFalse(ImageUtils.isApng(readHeaderBuffer("16x16-animation-lossy.webp")));
|
||||
}
|
||||
|
||||
/// Verifies WebP detection recognizes all supported WebP variants.
|
||||
@Test
|
||||
public void testIsWebP() {
|
||||
assertFalse(ImageUtils.isWebP(readHeaderBuffer("16x16.apng")));
|
||||
@@ -57,4 +76,66 @@ public final class ImageUtilsTest {
|
||||
assertTrue(ImageUtils.isWebP(readHeaderBuffer("16x16-animation-lossy.webp")));
|
||||
}
|
||||
|
||||
/// Verifies SVG detection accepts XML prologs and an SVG document element.
|
||||
@Test
|
||||
public void testIsSVG() {
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes("<svg/>")));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(" \t\r\n<svg viewBox='0 0 16 16'>")));
|
||||
assertTrue(ImageUtils.isSVG(
|
||||
"\ufeff<?xml version='1.0' encoding='UTF-8'?>\n<svg/>"
|
||||
.getBytes(StandardCharsets.UTF_8)
|
||||
));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(
|
||||
"<?xml version='1.0'?>"
|
||||
+ "<?xml-stylesheet href='<svg.css'?>"
|
||||
+ "<!-- <svg/> -->"
|
||||
+ "<svg xmlns='http://www.w3.org/2000/svg'>"
|
||||
)));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(
|
||||
"<!DOCTYPE svg ["
|
||||
+ "<!ENTITY quoted ']> <svg>'>"
|
||||
+ "<!-- ]> <svg -->"
|
||||
+ "<!ELEMENT svg ANY>"
|
||||
+ "]><svg/>"
|
||||
)));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(
|
||||
"<!--" + "x".repeat(ImageUtils.HEADER_BUFFER_SIZE - 13) + "--><svg/>"
|
||||
)));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(
|
||||
"<svg:svg xmlns:svg='http://www.w3.org/2000/svg'/>"
|
||||
)));
|
||||
assertTrue(ImageUtils.isSVG(asciiBytes(
|
||||
"<image:svg xmlns:image='http://www.w3.org/2000/svg'/>"
|
||||
)));
|
||||
}
|
||||
|
||||
/// Verifies SVG detection rejects incomplete, embedded, and non-SVG roots.
|
||||
@Test
|
||||
public void testIsNotSVG() {
|
||||
assertFalse(ImageUtils.isSVG(new byte[0]));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<svg")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<svg-image/>")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<svg:circle/>")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<image:svg-icon/>")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<SVG/>")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("plain text <svg/>")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes("<!-- <svg/> -->")));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes(
|
||||
"<?xml version='1.0'?><html><svg/></html>"
|
||||
)));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes(
|
||||
"<!DOCTYPE svg [<!-- incomplete ]> <svg -->"
|
||||
)));
|
||||
assertFalse(ImageUtils.isSVG(asciiBytes(
|
||||
"<!--" + "x".repeat(ImageUtils.HEADER_BUFFER_SIZE) + "--><svg/>"
|
||||
)));
|
||||
}
|
||||
|
||||
/// Verifies sniffing routes a prefixed SVG document to the SVG loader.
|
||||
@Test
|
||||
public void testGuessLoaderDetectsPrefixedSVG() {
|
||||
assertSame(ImageUtils.SVG, ImageUtils.guessLoader(asciiBytes(
|
||||
"<svg:svg xmlns:svg='http://www.w3.org/2000/svg'/>"
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
public static final List<String> MOD_EXTENSIONS = List.of("jar", "litemod");
|
||||
public static final List<String> MOD_EXTENSIONS = List.of("jar", "zip", "litemod");
|
||||
|
||||
@FunctionalInterface
|
||||
private interface ModMetadataReader {
|
||||
@@ -58,6 +58,7 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
|
||||
);
|
||||
|
||||
map.put("jar", zipReaders);
|
||||
map.put("zip", zipReaders);
|
||||
map.put("litemod", List.of(pair(LiteModMetadata::fromFile, ModLoaderType.LITE_LOADER)));
|
||||
|
||||
READERS = map;
|
||||
|
||||
+57
-13
@@ -32,17 +32,19 @@ import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.SeekableByteChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.Checksum;
|
||||
|
||||
import static org.jackhuang.hmcl.util.Lang.mapOf;
|
||||
import static org.jackhuang.hmcl.util.Pair.pair;
|
||||
@@ -184,23 +186,65 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream stream = Files.newInputStream(file)) {
|
||||
byte[] buf = new byte[1024];
|
||||
int len;
|
||||
while ((len = stream.read(buf, 0, buf.length)) != -1) {
|
||||
/// Calculates the CurseForge fingerprint without retaining the filtered file in memory.
|
||||
static long calculateFingerprint(Path file) throws IOException {
|
||||
try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) {
|
||||
long startPosition = channel.position();
|
||||
|
||||
byte[] bufferArray = new byte[1024 * 1024];
|
||||
ByteBuffer buffer = ByteBuffer.wrap(bufferArray);
|
||||
|
||||
long filteredLength = 0;
|
||||
while (channel.read(buffer) > 0) {
|
||||
int len = buffer.position();
|
||||
for (int i = 0; i < len; i++) {
|
||||
byte b = buf[i];
|
||||
byte b = bufferArray[i];
|
||||
if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) {
|
||||
baos.write(b);
|
||||
filteredLength++;
|
||||
}
|
||||
}
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1));
|
||||
channel.position(startPosition);
|
||||
|
||||
Checksum hasher = MurmurHash2.hash32(filteredLength, 1);
|
||||
while (channel.read(buffer) > 0) {
|
||||
int len = buffer.position();
|
||||
|
||||
int pos = 0;
|
||||
while (pos < len) {
|
||||
byte b = bufferArray[pos];
|
||||
if (b == 0x9 || b == 0xa || b == 0xd || b == 0x20) {
|
||||
break;
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (pos < len) {
|
||||
int pos2 = pos + 1;
|
||||
while (pos2 < len) {
|
||||
byte b = bufferArray[pos2];
|
||||
if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) {
|
||||
bufferArray[pos++] = b;
|
||||
}
|
||||
pos2++;
|
||||
}
|
||||
}
|
||||
|
||||
hasher.update(bufferArray, 0, pos);
|
||||
buffer.clear();
|
||||
}
|
||||
return hasher.getValue();
|
||||
} catch (IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the remote CurseForge version matching a local file.
|
||||
@Override
|
||||
public Optional<RemoteAddon.Version> getRemoteVersionByLocalFile(Path file) throws IOException {
|
||||
long hash = calculateFingerprint(file);
|
||||
if (hash == 811513880) { // Workaround for https://github.com/HMCL-dev/HMCL/issues/4597
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
import java.util.zip.Checksum;
|
||||
|
||||
/**
|
||||
* Implementation of the MurmurHash2 32-bit and 64-bit hash functions.
|
||||
@@ -48,6 +52,7 @@ import java.nio.charset.StandardCharsets;
|
||||
* Original MurmurHash2 c++ code</a>
|
||||
* @since 1.13
|
||||
*/
|
||||
@NotNullByDefault
|
||||
public final class MurmurHash2 {
|
||||
|
||||
// Constants for 32-bit variant
|
||||
@@ -64,6 +69,177 @@ public final class MurmurHash2 {
|
||||
private MurmurHash2() {
|
||||
}
|
||||
|
||||
/// Creates a streaming MurmurHash2 32-bit checksum for exactly `length` bytes.
|
||||
///
|
||||
/// The length is incorporated into the initial hash state using its low 32 bits. Calls to
|
||||
/// [Checksum#update(int)] and [Checksum#update(byte[], int, int)] may divide the input at
|
||||
/// arbitrary byte boundaries. [Checksum#getValue()] returns the hash as an unsigned 32-bit
|
||||
/// value represented by a `long`, and does not change the checksum state.
|
||||
///
|
||||
/// The returned checksum verifies the number of supplied bytes when `getValue()` is called.
|
||||
/// If too few bytes have been supplied, the caller may continue updating the checksum and call
|
||||
/// `getValue()` again. Once too many bytes have been supplied, [Checksum#reset()] must be called
|
||||
/// before a value can be obtained. Resetting retains the configured length and seed. The
|
||||
/// returned checksum is mutable and is not safe for concurrent use.
|
||||
///
|
||||
/// @param length the exact number of input bytes
|
||||
/// @param seed the initial seed value
|
||||
/// @return a new checksum initialized with the specified length and seed
|
||||
/// @throws IllegalArgumentException if `length` is negative
|
||||
public static Checksum hash32(final long length, final int seed) {
|
||||
if (length < 0) {
|
||||
throw new IllegalArgumentException("length must not be negative: " + length);
|
||||
}
|
||||
return new Hash32Checksum(length, seed);
|
||||
}
|
||||
|
||||
/// Computes a MurmurHash2 32-bit value from updates whose total length is known in advance.
|
||||
private static final class Hash32Checksum implements Checksum {
|
||||
/// The exact number of bytes required before the hash can be obtained.
|
||||
private final long expectedLength;
|
||||
|
||||
/// The hash state restored by [#reset()].
|
||||
private final int initialHash;
|
||||
|
||||
/// The hash state after all complete four-byte blocks received so far.
|
||||
private int hash;
|
||||
|
||||
/// The number of bytes counted before [#inputLengthExceeded] becomes `true`.
|
||||
private long inputLength;
|
||||
|
||||
/// Whether more than [#expectedLength] bytes have been received.
|
||||
private boolean inputLengthExceeded;
|
||||
|
||||
/// Up to three unprocessed bytes packed in little-endian order.
|
||||
private int tail;
|
||||
|
||||
/// The number of bytes currently stored in [#tail].
|
||||
private int tailLength;
|
||||
|
||||
/// Creates a checksum with a precomputed initial hash state.
|
||||
///
|
||||
/// @param expectedLength the exact number of input bytes
|
||||
/// @param seed the initial seed value
|
||||
private Hash32Checksum(long expectedLength, int seed) {
|
||||
this.expectedLength = expectedLength;
|
||||
this.initialHash = seed ^ (int) expectedLength;
|
||||
this.hash = initialHash;
|
||||
}
|
||||
|
||||
/// Incorporates the low eight bits of `value` into this checksum.
|
||||
///
|
||||
/// @param value the value whose low eight bits are incorporated
|
||||
@Override
|
||||
public void update(int value) {
|
||||
addInputLength(1);
|
||||
appendByte(value);
|
||||
}
|
||||
|
||||
/// Incorporates `length` bytes beginning at `offset` into this checksum.
|
||||
///
|
||||
/// @param data the array containing the input bytes
|
||||
/// @param offset the offset of the first input byte
|
||||
/// @param length the number of bytes to incorporate
|
||||
@Override
|
||||
public void update(byte[] data, int offset, int length) {
|
||||
Objects.checkFromIndexSize(offset, length, data.length);
|
||||
addInputLength(length);
|
||||
|
||||
int index = offset;
|
||||
final int end = offset + length;
|
||||
|
||||
while (tailLength != 0 && index < end) {
|
||||
appendByte(data[index++]);
|
||||
}
|
||||
|
||||
while (index <= end - Integer.BYTES) {
|
||||
mixBlock(ByteArray.getIntLE(data, index));
|
||||
index += Integer.BYTES;
|
||||
}
|
||||
|
||||
while (index < end) {
|
||||
appendByte(data[index++]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the MurmurHash2 value after verifying the exact input length.
|
||||
///
|
||||
/// @return the unsigned 32-bit hash value represented by a `long`
|
||||
/// @throws IllegalStateException if the number of supplied bytes differs from the expected
|
||||
/// length
|
||||
@Override
|
||||
public long getValue() {
|
||||
if (inputLengthExceeded) {
|
||||
throw new IllegalStateException(
|
||||
"Expected " + expectedLength + " bytes, but received more than expected");
|
||||
}
|
||||
if (inputLength != expectedLength) {
|
||||
throw new IllegalStateException(
|
||||
"Expected " + expectedLength + " bytes, but received " + inputLength);
|
||||
}
|
||||
|
||||
int result = hash;
|
||||
if (tailLength != 0) {
|
||||
result ^= tail;
|
||||
result *= M32;
|
||||
}
|
||||
|
||||
result ^= result >>> 13;
|
||||
result *= M32;
|
||||
result ^= result >>> 15;
|
||||
return Integer.toUnsignedLong(result);
|
||||
}
|
||||
|
||||
/// Restores this checksum to its initial state while retaining its expected length and seed.
|
||||
@Override
|
||||
public void reset() {
|
||||
hash = initialHash;
|
||||
inputLength = 0;
|
||||
inputLengthExceeded = false;
|
||||
tail = 0;
|
||||
tailLength = 0;
|
||||
}
|
||||
|
||||
/// Records that `length` more input bytes have been supplied.
|
||||
///
|
||||
/// @param length the non-negative number of additional bytes
|
||||
private void addInputLength(int length) {
|
||||
if (inputLengthExceeded) {
|
||||
return;
|
||||
}
|
||||
if (length > expectedLength - inputLength) {
|
||||
inputLengthExceeded = true;
|
||||
} else {
|
||||
inputLength += length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffers one byte and mixes the resulting block when four bytes are available.
|
||||
///
|
||||
/// @param value the value whose low eight bits are appended
|
||||
private void appendByte(int value) {
|
||||
tail |= (value & 0xff) << (tailLength * Byte.SIZE);
|
||||
tailLength++;
|
||||
if (tailLength == Integer.BYTES) {
|
||||
mixBlock(tail);
|
||||
tail = 0;
|
||||
tailLength = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixes one little-endian four-byte block into the current hash state.
|
||||
///
|
||||
/// @param block the block to mix
|
||||
private void mixBlock(int block) {
|
||||
int mixedBlock = block;
|
||||
mixedBlock *= M32;
|
||||
mixedBlock ^= mixedBlock >>> R32;
|
||||
mixedBlock *= M32;
|
||||
hash *= M32;
|
||||
hash ^= mixedBlock;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a 32-bit hash from byte array with the given length and seed.
|
||||
*
|
||||
|
||||
@@ -137,14 +137,14 @@ public final class CompressingUtils {
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFile(Path zipFile, Charset charset) throws IOException {
|
||||
return new ZipArchiveReader(zipFile, charset);
|
||||
return new ZipArchiveReader(zipFile, charset, true, true);
|
||||
}
|
||||
|
||||
public static ZipArchiveReader openZipFileWithPossibleEncoding(Path zipFile, Charset possibleEncoding) throws IOException {
|
||||
if (possibleEncoding == null)
|
||||
possibleEncoding = StandardCharsets.UTF_8;
|
||||
|
||||
ZipArchiveReader zipReader = new ZipArchiveReader(Files.newByteChannel(zipFile));
|
||||
ZipArchiveReader zipReader = new ZipArchiveReader(zipFile, StandardCharsets.UTF_8, true, true);
|
||||
|
||||
Charset suitableEncoding;
|
||||
try {
|
||||
@@ -161,7 +161,7 @@ public final class CompressingUtils {
|
||||
}
|
||||
|
||||
zipReader.close();
|
||||
return new ZipArchiveReader(Files.newByteChannel(zipFile), suitableEncoding);
|
||||
return new ZipArchiveReader(zipFile, suitableEncoding, true, true);
|
||||
}
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
package org.jackhuang.hmcl.util.tree;
|
||||
|
||||
import kala.compress.archivers.ArchiveEntry;
|
||||
import kala.compress.archivers.zip.ZipArchiveReader;
|
||||
import org.jackhuang.hmcl.util.io.CompressingUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.UnmodifiableView;
|
||||
@@ -44,7 +44,7 @@ public abstract class ArchiveFileTree<R, E extends ArchiveEntry> implements Clos
|
||||
|
||||
String name = namePath.toString();
|
||||
if (name.endsWith(".jar") || name.endsWith(".zip")) {
|
||||
return new ZipFileTree(new ZipArchiveReader(file));
|
||||
return CompressingUtils.openZipTree(file);
|
||||
} else if (name.endsWith(".tar") || name.endsWith(".tar.gz") || name.endsWith(".tgz")) {
|
||||
return TarFileTree.open(file);
|
||||
} else {
|
||||
|
||||
@@ -933,4 +933,21 @@
|
||||
26.2-pre-6
|
||||
26.2-rc-1
|
||||
26.2-rc-2
|
||||
26.2
|
||||
26.2
|
||||
26.3-snapshot-1
|
||||
26.3-snapshot-2
|
||||
26.3-snapshot-3
|
||||
26.3-snapshot-4
|
||||
26.3-snapshot-5
|
||||
26.3-snapshot-6
|
||||
26.3-snapshot-7
|
||||
26.3-snapshot-8
|
||||
26.3-snapshot-9
|
||||
26.3-snapshot-10
|
||||
26.3-pre-1
|
||||
26.3-pre-2
|
||||
26.3-pre-3
|
||||
26.3-rc-1
|
||||
26.3-rc-2
|
||||
26.3-rc-3
|
||||
26.3
|
||||
-54
@@ -1,54 +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.addon.curse;
|
||||
|
||||
import org.jackhuang.hmcl.util.MurmurHash2;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class CurseForgeRemoteAddonRepositoryTest {
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
public void testMurmurHash() throws Exception {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try (InputStream is = Files.newInputStream(Paths.get("C:\\Users\\huang\\Downloads\\JustEnoughCalculation-1.16.5-3.8.5.jar"))) {
|
||||
byte[] buf = new byte[1024];
|
||||
int len;
|
||||
while ((len = is.read(buf, 0, buf.length)) > 0) {
|
||||
for (int i = 0; i < len; i++) {
|
||||
byte b = buf[i];
|
||||
if (b != 9 && b != 10 && b != 13 && b != 32) {
|
||||
baos.write(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
long hash = Integer.toUnsignedLong(MurmurHash2.hash32(baos.toByteArray(), baos.size(), 1));
|
||||
|
||||
assertEquals(hash, 3333498611L);
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.addon.repository;
|
||||
|
||||
import org.jackhuang.hmcl.util.MurmurHash2;
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Random;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/// Tests CurseForge fingerprint calculation for local files.
|
||||
@NotNullByDefault
|
||||
public final class CurseForgeRemoteAddonRepositoryTest {
|
||||
|
||||
/// Verifies that streaming calculation remains identical to the previous in-memory algorithm.
|
||||
@Test
|
||||
public void calculatesFingerprintWithoutChangingTheResult(@TempDir Path tempDir) throws IOException {
|
||||
byte[] boundarySample = new byte[8192 + 17];
|
||||
new Random(0).nextBytes(boundarySample);
|
||||
for (int i = 0; i < boundarySample.length; i += 97) {
|
||||
boundarySample[i] = 0x20;
|
||||
}
|
||||
|
||||
byte[][] samples = {
|
||||
{},
|
||||
{0x9, 0xa, 0xd, 0x20},
|
||||
{1},
|
||||
{1, 2},
|
||||
{1, 2, 3},
|
||||
{1, 2, 3, 4},
|
||||
{1, 0x20, 2, 0xa, 3, 0xd, 4, 0x9, 5},
|
||||
boundarySample
|
||||
};
|
||||
|
||||
for (int i = 0; i < samples.length; i++) {
|
||||
byte[] sample = samples[i];
|
||||
Path file = tempDir.resolve("sample-" + i);
|
||||
Files.write(file, sample);
|
||||
|
||||
ByteArrayOutputStream filtered = new ByteArrayOutputStream();
|
||||
for (byte b : sample) {
|
||||
if (b != 0x9 && b != 0xa && b != 0xd && b != 0x20) {
|
||||
filtered.write(b);
|
||||
}
|
||||
}
|
||||
long expected = Integer.toUnsignedLong(MurmurHash2.hash32(filtered.toByteArray(), filtered.size(), 1));
|
||||
|
||||
assertEquals(expected, CurseForgeRemoteAddonRepository.calculateFingerprint(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import org.jetbrains.annotations.NotNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.Random;
|
||||
import java.util.zip.Checksum;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/// Tests the streaming MurmurHash2 checksum implementation.
|
||||
@NotNullByDefault
|
||||
public final class MurmurHash2Test {
|
||||
/// Compares whole-array, bulk, byte-wise, and fragmented updates across lengths and seeds.
|
||||
@Test
|
||||
public void testStreamingHash32AcrossLengthsAndSeeds() {
|
||||
byte[] data = new byte[513];
|
||||
new Random(0x6d75726d75724cL).nextBytes(data);
|
||||
|
||||
for (int length = 0; length <= data.length; length++) {
|
||||
assertStreamingHash32(data, length, 0);
|
||||
assertStreamingHash32(data, length, -1);
|
||||
assertStreamingHash32(data, length, 0x9747b28c);
|
||||
assertStreamingHash32(data, length, 0x9747b28c ^ length * 31);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares every two-part split while reading from a non-zero source-array offset.
|
||||
@Test
|
||||
public void testStreamingHash32AtEverySplitPoint() {
|
||||
byte[] data = new byte[73];
|
||||
new Random(0x73706c697473L).nextBytes(data);
|
||||
|
||||
int sourceOffset = 5;
|
||||
byte[] paddedData = new byte[sourceOffset + data.length + 7];
|
||||
System.arraycopy(data, 0, paddedData, sourceOffset, data.length);
|
||||
|
||||
for (int length = 0; length <= data.length; length++) {
|
||||
int seed = Integer.rotateLeft(0x4d757232, length & 31);
|
||||
long expected = hash32(data, length, seed);
|
||||
|
||||
for (int split = 0; split <= length; split++) {
|
||||
Checksum checksum = MurmurHash2.hash32(length, seed);
|
||||
checksum.update(paddedData, sourceOffset, split);
|
||||
checksum.update(paddedData, sourceOffset + split, length - split);
|
||||
assertEquals(expected, checksum.getValue(),
|
||||
"length=" + length + ", split=" + split);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares interleaved single-byte and bulk updates after every possible short prefix.
|
||||
@Test
|
||||
public void testStreamingHash32WithMixedUpdates() {
|
||||
byte[] data = new byte[129];
|
||||
new Random(0x6d69786564L).nextBytes(data);
|
||||
|
||||
for (int length = 0; length <= data.length; length++) {
|
||||
int seed = 0x13579bdf ^ length;
|
||||
long expected = hash32(data, length, seed);
|
||||
|
||||
for (int prefixLength = 0; prefixLength <= Math.min(length, 7); prefixLength++) {
|
||||
Checksum checksum = MurmurHash2.hash32(length, seed);
|
||||
int offset = 0;
|
||||
|
||||
while (offset < prefixLength) {
|
||||
checksum.update(data[offset++]);
|
||||
}
|
||||
|
||||
// Exercise an empty bulk update both with and without a buffered tail.
|
||||
checksum.update(data, offset, 0);
|
||||
|
||||
while (offset < length) {
|
||||
int chunkLength = Math.min(offset % 5 + 1, length - offset);
|
||||
checksum.update(data, offset, chunkLength);
|
||||
offset += chunkLength;
|
||||
|
||||
if (offset < length) {
|
||||
checksum.update(data[offset++]);
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(expected, checksum.getValue(),
|
||||
"length=" + length + ", prefixLength=" + prefixLength);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares array-backed, read-only, direct, and empty-buffer updates.
|
||||
@Test
|
||||
public void testStreamingHash32WithByteBuffers() {
|
||||
byte[] data = new byte[8207];
|
||||
new Random(0x627566666572L).nextBytes(data);
|
||||
int seed = 0x2468ace0;
|
||||
long expected = hash32(data, data.length, seed);
|
||||
|
||||
int sourceOffset = 3;
|
||||
byte[] paddedData = new byte[sourceOffset + data.length + 4];
|
||||
System.arraycopy(data, 0, paddedData, sourceOffset, data.length);
|
||||
ByteBuffer arrayBackedBuffer = ByteBuffer.wrap(paddedData);
|
||||
arrayBackedBuffer.position(sourceOffset);
|
||||
arrayBackedBuffer.limit(sourceOffset + data.length);
|
||||
assertTrue(arrayBackedBuffer.hasArray());
|
||||
|
||||
Checksum arrayBackedChecksum = MurmurHash2.hash32(data.length, seed);
|
||||
arrayBackedChecksum.update(arrayBackedBuffer);
|
||||
assertEquals(arrayBackedBuffer.limit(), arrayBackedBuffer.position());
|
||||
assertEquals(expected, arrayBackedChecksum.getValue());
|
||||
|
||||
ByteBuffer readOnlyBuffer = ByteBuffer.wrap(data).asReadOnlyBuffer();
|
||||
assertFalse(readOnlyBuffer.hasArray());
|
||||
|
||||
Checksum readOnlyChecksum = MurmurHash2.hash32(data.length, seed);
|
||||
readOnlyChecksum.update(readOnlyBuffer);
|
||||
assertEquals(readOnlyBuffer.limit(), readOnlyBuffer.position());
|
||||
assertEquals(expected, readOnlyChecksum.getValue());
|
||||
|
||||
ByteBuffer directBuffer = ByteBuffer.allocateDirect(sourceOffset + data.length);
|
||||
directBuffer.position(sourceOffset);
|
||||
directBuffer.put(data);
|
||||
directBuffer.position(sourceOffset);
|
||||
assertFalse(directBuffer.hasArray());
|
||||
|
||||
Checksum directChecksum = MurmurHash2.hash32(data.length, seed);
|
||||
directChecksum.update(directBuffer);
|
||||
assertEquals(directBuffer.limit(), directBuffer.position());
|
||||
assertEquals(expected, directChecksum.getValue());
|
||||
|
||||
ByteBuffer emptyBuffer = ByteBuffer.allocate(4);
|
||||
emptyBuffer.position(2);
|
||||
emptyBuffer.limit(2);
|
||||
|
||||
Checksum emptyFirstChecksum = MurmurHash2.hash32(data.length, seed);
|
||||
emptyFirstChecksum.update(emptyBuffer);
|
||||
emptyFirstChecksum.update(data);
|
||||
assertEquals(2, emptyBuffer.position());
|
||||
assertEquals(expected, emptyFirstChecksum.getValue());
|
||||
}
|
||||
|
||||
/// Verifies that single-byte updates use only the low eight bits of each value.
|
||||
@Test
|
||||
public void testStreamingHash32SingleByteValues() {
|
||||
byte[] data = {0x12, (byte) 0xff, (byte) 0x80, 0x00};
|
||||
int seed = 0x12345678;
|
||||
|
||||
Checksum checksum = MurmurHash2.hash32(data.length, seed);
|
||||
checksum.update(0x112);
|
||||
checksum.update(-1);
|
||||
checksum.update(0xabcdef80);
|
||||
checksum.update(0x100);
|
||||
|
||||
assertEquals(hash32(data, data.length, seed), checksum.getValue());
|
||||
}
|
||||
|
||||
/// Verifies that rejected array ranges leave the checksum state unchanged.
|
||||
@Test
|
||||
public void testStreamingHash32ArrayRangeValidation() {
|
||||
byte[] data = {0x12, 0x34, 0x56};
|
||||
int seed = 0x12345678;
|
||||
Checksum checksum = MurmurHash2.hash32(data.length, seed);
|
||||
|
||||
assertThrows(NullPointerException.class, () -> checksum.update(null, 0, 0));
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, -1, 1));
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, 0, -1));
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> checksum.update(data, data.length, 1));
|
||||
|
||||
checksum.update(data);
|
||||
assertEquals(hash32(data, data.length, seed), checksum.getValue());
|
||||
}
|
||||
|
||||
/// Verifies that `getValue()` rejects incomplete and excessive input without finalizing state.
|
||||
@Test
|
||||
public void testExactLengthValidation() {
|
||||
byte[] data = {0x12, 0x34, 0x56, 0x78, (byte) 0x9a};
|
||||
int seed = 0x12345678;
|
||||
long expected = hash32(data, data.length, seed);
|
||||
|
||||
Checksum checksum = MurmurHash2.hash32(data.length, seed);
|
||||
checksum.update(data, 0, data.length - 1);
|
||||
assertThrows(IllegalStateException.class, checksum::getValue);
|
||||
|
||||
checksum.update(data[data.length - 1]);
|
||||
assertEquals(expected, checksum.getValue());
|
||||
|
||||
checksum.update(0xff);
|
||||
checksum.update(0xfe);
|
||||
assertThrows(IllegalStateException.class, checksum::getValue);
|
||||
|
||||
checksum.reset();
|
||||
checksum.update(data, 0, data.length);
|
||||
assertEquals(expected, checksum.getValue());
|
||||
}
|
||||
|
||||
/// Verifies the valid range of the declared input length.
|
||||
@Test
|
||||
public void testLengthRange() {
|
||||
assertThrows(IllegalArgumentException.class, () -> MurmurHash2.hash32(-1, 0));
|
||||
|
||||
Checksum checksum = MurmurHash2.hash32(1L << 32, 0);
|
||||
assertThrows(IllegalStateException.class, checksum::getValue);
|
||||
}
|
||||
|
||||
/// Compares the principal update styles with the byte-array implementation.
|
||||
///
|
||||
/// @param data the input array
|
||||
/// @param length the number of bytes to hash
|
||||
/// @param seed the initial seed value
|
||||
private static void assertStreamingHash32(byte[] data, int length, int seed) {
|
||||
long expected = hash32(data, length, seed);
|
||||
|
||||
byte[] exactData = Arrays.copyOf(data, length);
|
||||
Checksum wholeArray = MurmurHash2.hash32(length, seed);
|
||||
wholeArray.update(exactData);
|
||||
assertEquals(expected, wholeArray.getValue());
|
||||
|
||||
Checksum bulk = MurmurHash2.hash32(length, seed);
|
||||
bulk.update(data, 0, length);
|
||||
assertEquals(expected, bulk.getValue());
|
||||
assertEquals(expected, bulk.getValue());
|
||||
|
||||
Checksum byteWise = MurmurHash2.hash32(length, seed);
|
||||
for (int i = 0; i < length; i++) {
|
||||
byteWise.update(data[i]);
|
||||
}
|
||||
assertEquals(expected, byteWise.getValue());
|
||||
|
||||
Checksum fragmented = MurmurHash2.hash32(length, seed);
|
||||
int offset = 0;
|
||||
while (offset < length) {
|
||||
int chunkLength = Math.min(offset % 7 + 1, length - offset);
|
||||
fragmented.update(data, offset, chunkLength);
|
||||
offset += chunkLength;
|
||||
}
|
||||
assertEquals(expected, fragmented.getValue());
|
||||
}
|
||||
|
||||
/// Returns the unsigned value produced by the byte-array implementation.
|
||||
///
|
||||
/// @param data the input array
|
||||
/// @param length the number of bytes to hash
|
||||
/// @param seed the initial seed value
|
||||
/// @return the unsigned 32-bit hash value represented by a `long`
|
||||
private static long hash32(byte[] data, int length, int seed) {
|
||||
return Integer.toUnsignedLong(MurmurHash2.hash32(data, length, seed));
|
||||
}
|
||||
|
||||
/// Prevents instantiation.
|
||||
private MurmurHash2Test() {
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ dependencies {
|
||||
implementation(libs.jna)
|
||||
implementation(libs.kala.compress.tar)
|
||||
implementation(libs.kala.compress.ar)
|
||||
implementation(libs.weburl)
|
||||
}
|
||||
|
||||
java {
|
||||
|
||||
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.gradle.mod;
|
||||
import com.google.gson.*;
|
||||
import com.google.gson.annotations.JsonAdapter;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.glavo.url.WebURL;
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
@@ -33,7 +34,6 @@ import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
@@ -58,7 +58,7 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
private static final String MOD_SEPARATOR = ",";
|
||||
|
||||
private static final Pattern[] CURSEFORGE_PATTERNS = {
|
||||
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons|scripts)/+(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/(minecraft|Minecraft|minecraft-bedrock)/(mc-mods|data-packs|modpacks|customization|mc-addons|texture-packs|customization/configuration|addons|scripts|bukkit-plugins)/+(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/projects/(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/mc-mods/minecraft/(?<modid>[\\w-]+)(/(.*?))?$"),
|
||||
Pattern.compile("^/legacy/mc-mods/minecraft/(\\d+)-(?<modid>[\\w-]+)"),
|
||||
@@ -70,27 +70,6 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
.replace(">", ">");
|
||||
}
|
||||
|
||||
private static String parseCurseforge(String url) {
|
||||
URI res = URI.create(url.replace(" ", "%20"));
|
||||
|
||||
if (!"http".equals(res.getScheme()) && !"https".equals(res.getScheme())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if ("edge.forgecdn.net".equals(res.getHost())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
for (Pattern pattern : CURSEFORGE_PATTERNS) {
|
||||
Matcher matcher = pattern.matcher(res.getPath());
|
||||
if (matcher.matches()) {
|
||||
return matcher.group("modid");
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static final Pattern MCMOD_PATTERN =
|
||||
Pattern.compile("^https://www\\.mcmod\\.cn/(class|modpack)/(?<modid>\\d+)\\.html$");
|
||||
|
||||
@@ -169,7 +148,8 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
}
|
||||
|
||||
if (chineseName.contains(S) || subName.contains(S)) {
|
||||
throw new GradleException("Error: " + chineseName);
|
||||
LOGGER.warn("Error chinese name: {}", chineseName);
|
||||
continue;
|
||||
}
|
||||
|
||||
String curseforgeId = "";
|
||||
@@ -180,13 +160,40 @@ public abstract class ParseModDataTask extends DefaultTask {
|
||||
List<ModData.Link> mcmodLinks = links.get("mcmod");
|
||||
|
||||
if (curseforgeLinks != null && !curseforgeLinks.isEmpty()) {
|
||||
boolean reportError = true;
|
||||
|
||||
for (ModData.Link link : curseforgeLinks) {
|
||||
curseforgeId = parseCurseforge(link.url);
|
||||
WebURL res = WebURL.parse(link.url);
|
||||
|
||||
if (!"http".equals(res.getScheme()) && !"https".equals(res.getScheme())) {
|
||||
curseforgeId = "";
|
||||
break;
|
||||
}
|
||||
|
||||
if (Set.of(
|
||||
"files.xmdhs.com",
|
||||
"edge.forgecdn.net",
|
||||
"minecraft.curseforge.com",
|
||||
"mediafilez.forgecdn.net"
|
||||
).contains(res.getHost())) {
|
||||
reportError = false;
|
||||
curseforgeId = "";
|
||||
break;
|
||||
}
|
||||
|
||||
for (Pattern pattern : CURSEFORGE_PATTERNS) {
|
||||
Matcher matcher = pattern.matcher(res.getPath());
|
||||
if (matcher.matches()) {
|
||||
curseforgeId = matcher.group("modid");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!curseforgeId.isEmpty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (curseforgeId.isEmpty()) {
|
||||
if (curseforgeId.isEmpty() && reportError) {
|
||||
LOGGER.warn("Error curseforge: {}", chineseName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
versionRoot=3.17
|
||||
versionRoot=3.16
|
||||
@@ -1,7 +1,7 @@
|
||||
[versions]
|
||||
hmclauncher = "3.7.0.1"
|
||||
jetbrains-annotations = "26.1.0"
|
||||
kala-compress = "1.27.1-3"
|
||||
kala-compress = "1.27.1-5"
|
||||
simple-png-javafx = "0.3.0"
|
||||
gson = "2.13.2"
|
||||
tomlj = "1.1.1"
|
||||
@@ -13,7 +13,7 @@ hello-nbt = "0.3.0"
|
||||
nanohttpd = "2.3.1"
|
||||
jsoup = "1.21.2"
|
||||
chardet = "2.5.0"
|
||||
fxsvgimage = "1.3"
|
||||
fxsvgimage = "1.9.1"
|
||||
jna = "5.18.1"
|
||||
pci-ids = "0.4.0"
|
||||
java-info = "1.0"
|
||||
|
||||
Reference in new issue
Block a user