Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 234 additions & 5 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ public final class LauncherHelper {

private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm";

/// Crash rules that mean the crash really was caused by a Java version mismatch.
///
/// Reused from [CrashReportAnalyzer] rather than matching log text here, so patterns
/// added upstream apply automatically.
private static final Set<CrashReportAnalyzer.Rule> JAVA_MISMATCH_CRASH_RULES = EnumSet.of(
CrashReportAnalyzer.Rule.JAVA_VERSION_IS_TOO_HIGH,
CrashReportAnalyzer.Rule.JDK_9,
CrashReportAnalyzer.Rule.NEED_JDK11,
CrashReportAnalyzer.Rule.MODLAUNCHER_8,
CrashReportAnalyzer.Rule.TOO_OLD_JAVA,
CrashReportAnalyzer.Rule.MAC_JDK_8U261
);

private final HMCLGameInstance gameInstance;
private Account account;
private Path scriptFile;
Expand Down Expand Up @@ -444,7 +457,46 @@ private static Task<JavaRuntime> checkGameState(HMCLGameInstance gameInstance, G
} else if (javaVersionType == JavaVersionType.AUTO || javaVersionType == JavaVersionType.VERSION) {
task = getJavaTask.thenComposeAsync(Schedulers.javafx(), java -> {
if (java != null) {
return Task.completed(java);
// Only warn when HMCL made the decision. When the user picked a specific
// Java version, they know what they selected and own the consequence.
if (javaVersionType != JavaVersionType.AUTO)
return Task.completed(java);

JavaCompatibility compatibility =
JavaCompatibilityEvaluator.evaluate(gameVersion, manifest, java, analyzer);
if (compatibility.isOk())
return Task.completed(java);

// The user already accepted this exact combination, and the game has not
// crashed because of Java since. Do not ask again.
if (isJavaMismatchAcknowledged(gameInstance, compatibility))
return Task.completed(java);

CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Task<JavaRuntime> result = Task.fromCompletableFuture(future);
Runnable breakAction = () -> future.completeExceptionally(
new CancellationException("Launch operation was cancelled by user"));

// Resolved before the dialog is shown: JavaManager.getAllJava() blocks
// until the initial Java scan finishes, so it must not run inside a
// JavaFX callback or it will freeze the UI.
JavaRuntime preferred = findInstalledJava(gameVersion, compatibility.targetMajor());

Controllers.confirm(
i18n("launch.advice.modded_java", compatibility.targetMajor(), gameVersion)
+ "\n\n" + i18n("launch.advice.switch_java", compatibility.targetMajor()),
i18n("message.warning"),
MessageType.WARNING,
() -> switchToExpectedJava(gameInstance, compatibility, preferred, java, future, breakAction),
() -> {
// The user accepted the risk. Remember it per instance so
// this does not come back on every launch.
writeJavaMismatchAcknowledgement(gameInstance,
compatibility.actualMajor() + ":" + compatibility.targetMajor());
future.complete(java);
});

return result;
}

// Reset invalid java version
Expand Down Expand Up @@ -728,6 +780,129 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA))
return task.withStage("launch.state.java");
}

/// The acknowledged Java version mismatch for this instance, in the form
/// {@code actualMajor:expectedMajor}, or an empty string when there is none.
///
/// Read through {@link HMCLGameInstance#getSettings} rather than the effective settings:
/// {@link GameSettings.Effective#getInstance} is null for every instance that has no
/// instance-specific settings file, which is the common case.
private static String readJavaMismatchAcknowledgement(HMCLGameInstance gameInstance) {
GameSettings.Instance instance = gameInstance.getSettings();
if (instance == null)
return "";

String value = instance.javaMismatchAcknowledgedProperty().getValue();
return value != null ? value : "";
}

/// Records the Java version mismatch the user accepted.
///
/// {@link HMCLGameInstance#getSettingsOrCreate} creates the instance settings file when
/// it does not exist yet, which is what makes this persist for instances that had none.
private static void writeJavaMismatchAcknowledgement(HMCLGameInstance gameInstance, String value) {
GameSettings.Instance instance = gameInstance.getSettingsOrCreate();
if (instance != null)
instance.javaMismatchAcknowledgedProperty().setValue(value);
}

private static boolean isJavaMismatchAcknowledged(HMCLGameInstance gameInstance, JavaCompatibility compatibility) {
return (compatibility.actualMajor() + ":" + compatibility.targetMajor())
.equals(readJavaMismatchAcknowledgement(gameInstance));
}

/// Returns an installed runtime that can run this instance on the expected Java version,
/// or null if there is none. See [pickInstalledJava] for the selection rules.
///
/// {@link JavaManager#getAllJava} blocks until the initial Java scan completes, so this
/// is resolved before the confirmation dialog is shown rather than inside its callback.
private static @Nullable JavaRuntime findInstalledJava(@Nullable GameVersionNumber gameVersion, int major) {
try {
return pickInstalledJava(JavaManager.getAllJava(), gameVersion, major);
} catch (InterruptedException e) {
// Preserve the interrupt so the shutdown path can observe it.
Thread.currentThread().interrupt();
return null;
}
}

/// Picks the runtime to offer from {@code candidates}: matching major version, and an
/// architecture that can actually run this instance.
///
/// The architecture filter mirrors {@link JavaManager#findSuitableJava}: AUTO picked the
/// current runtime for a reason, and offering a 32-bit runtime on a 64-bit system (or a
/// non-x86 one for the versions that need it on Windows/macOS ARM64) would trade a
/// warning for a game that cannot allocate its heap.
static @Nullable JavaRuntime pickInstalledJava(
Collection<JavaRuntime> candidates, @Nullable GameVersionNumber gameVersion, int major) {

boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64
&& (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS)
&& (gameVersion == null || gameVersion.compareTo("1.6") < 0);

for (JavaRuntime candidate : candidates) {
if (forceX86 ? !candidate.getArchitecture().isX86()
: candidate.getArchitecture() != Architecture.SYSTEM_ARCH)
continue;

if (candidate.getParsedVersion() == major)
return candidate;
}
return null;
}

/// The Mojang runtime to download for {@code major} on {@code platform}, or null when
/// Mojang publishes none for it.
static @Nullable GameJavaVersion resolveDownloadableJava(Platform platform, int major) {
GameJavaVersion target = GameJavaVersion.get(major);
if (target == null)
return null;

return GameJavaVersion.getSupportedVersions(platform).contains(target) ? target : null;
}

/// Switches to, or downloads, the Java version the instance is expected to run on.
///
/// Note: this deliberately does not call `setting.setJavaAutoSelected()`. Silently
/// rewriting the user's Java selection mode is what makes this warning disappear
/// permanently on subsequent launches.
private static void switchToExpectedJava(
HMCLGameInstance gameInstance,
JavaCompatibility compatibility,
@Nullable JavaRuntime preferred,
@Nullable JavaRuntime currentJava,
CompletableFuture<JavaRuntime> future,
Runnable breakAction) {

if (preferred != null) {
future.complete(preferred);
return;
}

// Mojang does not publish a runtime for every platform (Linux ARM64, for instance).
// Starting a download there can only fail, and failing it cancels a launch the user
// already agreed to, so keep the runtime they have instead.
GameJavaVersion target = resolveDownloadableJava(SYSTEM_PLATFORM, compatibility.targetMajor());
if (target == null) {
LOG.warning("No downloadable Java " + compatibility.targetMajor() + " for " + SYSTEM_PLATFORM
+ ", keeping the current runtime");
if (currentJava != null)
future.complete(currentJava);
else
breakAction.run();
return;
}

downloadJava(target, gameInstance.getRepository())
Comment thread
Chen-Mengze marked this conversation as resolved.
.whenCompleteAsync((downloaded, throwable) -> {
if (throwable == null) {
future.complete(downloaded);
} else {
LOG.warning("Failed to download java", throwable);
breakAction.run();
}
}, Schedulers.javafx());
}

private static CompletableFuture<JavaRuntime> downloadJava(GameJavaVersion javaVersion, HMCLGameRepository repository) {
CompletableFuture<JavaRuntime> future = new CompletableFuture<>();
Controllers.dialog(new MessageDialogPane.Builder(
Expand Down Expand Up @@ -1014,6 +1189,12 @@ public void onExit(int exitCode, ExitType exitType) {
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

// A batch that holds a single log is handed to the FX thread with runLater
// and is never waited for, so the last line can still be sitting in the event
// queue here. Flush it, or the exception that ended the game is missing from
// `logs` when the crash is analysed below.
flushPendingFxEvents();
}

launchingLatch.countDown();
Expand All @@ -1034,12 +1215,64 @@ public void onExit(int exitCode, ExitType exitType) {

if (exitType != ExitType.NORMAL) {
gameInstance.markLaunchedAbnormally();
revokeJavaMismatchAcknowledgement();
runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show());
}

checkExit();
}

/// Revokes the accepted Java version mismatch when the crash was actually caused by a
/// Java version mismatch, so the next launch warns the user again.
///
/// Crashes from unrelated causes leave the acknowledgement intact: the user accepted
/// the risk and it did not materialise, so asking again would be nagging.
///
/// The acknowledgement is stored as {@code actualMajor:expectedMajor} rather than a
/// flag, so a change of either version (a newer Java, an updated instance) asks again
/// instead of staying silent forever.
private void revokeJavaMismatchAcknowledgement() {
// Nothing to revoke. This is also the common case, and it avoids running the
// crash analysis on every launch for users who never dismissed the warning.
if (readJavaMismatchAcknowledgement(gameInstance).isEmpty())
Comment thread
Chen-Mengze marked this conversation as resolved.
Comment thread
Chen-Mengze marked this conversation as resolved.
return;

String rawLog;
lock.lock();
try {
rawLog = logs.stream().map(Log::getLog).collect(Collectors.joining("\n"));
} finally {
lock.unlock();
}

boolean causedByJava = CrashReportAnalyzer.analyze(rawLog).stream()
Comment thread
Chen-Mengze marked this conversation as resolved.
Comment thread
Chen-Mengze marked this conversation as resolved.
.map(CrashReportAnalyzer.Result::rule)
.anyMatch(JAVA_MISMATCH_CRASH_RULES::contains);

if (causedByJava)
Comment thread
Chen-Mengze marked this conversation as resolved.
Comment thread
Chen-Mengze marked this conversation as resolved.
writeJavaMismatchAcknowledgement(gameInstance, "");
}
Comment thread
Chen-Mengze marked this conversation as resolved.
Comment thread
Chen-Mengze marked this conversation as resolved.

/// Waits until every task queued on the JavaFX application thread so far has run.
///
/// [javafx.application.Platform#runLater] executes tasks in submission order, so a
/// task submitted now runs after every task submitted before it.
///
/// `javafx.application.Platform` is spelled out because the on-demand import of
/// `org.jackhuang.hmcl.util.platform` already owns the simple name `Platform` here.
private void flushPendingFxEvents() {
if (javafx.application.Platform.isFxApplicationThread())
return;

CountDownLatch latch = new CountDownLatch(1);
runLater(latch::countDown);
try {
latch.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}

}

private static final Queue<WeakReference<ManagedProcess>> PROCESSES = new ConcurrentLinkedQueue<>();
Expand All @@ -1052,8 +1285,4 @@ public static int countMangedProcesses() {
return PROCESSES.size();
}

public static void stopManagedProcesses() {
while (!PROCESSES.isEmpty())
Optional.ofNullable(PROCESSES.poll()).map(WeakReference::get).ifPresent(ManagedProcess::stop);
}
}
15 changes: 15 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/setting/GameSettings.java
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ public SettingProperty<GameInstanceIconType> iconProperty() {
return icon;
}

/// The Java version mismatch the user has accepted for this instance, in the form
/// {@code actualMajor:expectedMajor}.
///
/// An instance-scoped property rather than an inheritable one: inheritable properties
/// are only readable on an instance once the name is listed in
/// [getOverrideProperties], and that set is maintained by the settings UI, not by
/// [SettingProperty#setValue].
@SerializedName("javaMismatchAcknowledged")
private final SettingProperty<String> javaMismatchAcknowledged = newSettingProperty("javaMismatchAcknowledged", "");

/// Returns the acknowledged Java version mismatch property.
public SettingProperty<String> javaMismatchAcknowledgedProperty() {
return javaMismatchAcknowledged;
}

/// Setting property names overridden by this instance.
@SerializedName("overrideProperties")
private final ObservableSet<String> overrideProperties = FXCollections.observableSet();
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ launch.advice.modlauncher8=The Forge version you are using is not compatible wit
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
launch.advice.require_newer_java_version=The current game version requires Java %s, but we could not find one. Do you want to download one now?
launch.advice.switch_java=Do you want to switch to Java %1$s?
launch.advice.too_large_memory_for_32bit=You have allocated a memory size larger than the memory limitation of the 32-bit Java installation. You may be unable to launch the game.
launch.advice.vanilla_linux_java_8=Minecraft 1.12.2 or earlier only supports Java 8 for the Linux x86-64 platform because the later versions cannot load 32-bit native libraries like liblwjgl.so\n\nPlease download it from java.com or install OpenJDK 8.
launch.advice.vanilla_x86.translation=Minecraft is not fully supported on your platform, so you may experience missing features or even be unable to launch the game.\nYou can download Java for the <b>x86-64</b> architecture <a href="https://learn.microsoft.com/java/openjdk/download">here</a> for a full gaming experience.
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_ar.properties
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,7 @@ launch.advice.modlauncher8=إصدار Forge الذي تستخدمه غير مت
launch.advice.newer_java=أنت تستخدم إصداراً قديماً من Java لتشغيل اللعبة. يُنصح بالتحديث إلى Java 8، وإلا قد تتسبب بعض المودات في تعطل اللعبة.
launch.advice.not_enough_space=لقد خصّصت حجم ذاكرة أكبر من الذاكرة الفعلية المثبّتة (%d ميجابايت) على حاسوبك. قد تواجه أداءً منخفضاً أو عدم القدرة على تشغيل اللعبة.
launch.advice.require_newer_java_version=يتطلب إصدار اللعبة الحالي Java %s، لكن لم نجد أياً. هل تريد تنزيله الآن؟
launch.advice.switch_java=هل تريد التبديل إلى Java %1$s؟
launch.advice.too_large_memory_for_32bit=لقد خصّصت حجم ذاكرة يتجاوز حد تثبيت Java 32-bit. قد لا تتمكن من تشغيل اللعبة.
launch.advice.vanilla_linux_java_8=يدعم Minecraft 1.12.2 أو أقدم Java 8 فقط على منصة Linux x86-64 لأن الإصدارات الأحدث لا تستطيع تحميل المكتبات الأصلية 32-bit مثل liblwjgl.so\n\nيُرجى تنزيله من java.com أو تثبيت OpenJDK 8.
launch.advice.vanilla_x86.translation=Minecraft غير مدعوم بشكل كامل على منصتك، لذا قد تواجه ميزات مفقودة أو عدم القدرة على تشغيل اللعبة.\nيمكنك تنزيل Java لمعمارية <b>x86-64</b> من <a href="https://learn.microsoft.com/java/openjdk/download">هنا</a> لتجربة لعب كاملة.
Expand Down
1 change: 1 addition & 0 deletions HMCL/src/main/resources/assets/lang/I18N_de.properties
Original file line number Diff line number Diff line change
Expand Up @@ -893,6 +893,7 @@ launch.advice.modlauncher8=Die Forge-Version, die Sie verwenden, ist nicht mit d
launch.advice.newer_java=Sie verwenden eine ältere Java-Version, um das Spiel zu starten. Es wird empfohlen, auf Java 8 zu aktualisieren, da sonst einige Mods das Spiel zum Absturz bringen können.
launch.advice.not_enough_space=Sie haben eine Speichergröße zugewiesen, die größer ist als die tatsächlich auf Ihrem Computer installierten %d MiB Speicher. Es kann zu Leistungseinbußen kommen oder das Spiel kann sogar nicht gestartet werden.
launch.advice.require_newer_java_version=Die aktuelle Spielversion erfordert Java %s, aber wir konnten keine finden. Möchten Sie jetzt eine herunterladen?
launch.advice.switch_java=Möchten Sie zu Java %1$s wechseln?
launch.advice.too_large_memory_for_32bit=Sie haben eine Speichergröße zugewiesen, die das Speicherlimit der 32-Bit-Java-Installation überschreitet. Sie können das Spiel möglicherweise nicht starten.
launch.advice.vanilla_linux_java_8=Minecraft 1.12.2 oder früher unterstützt nur Java 8 für die Linux-x86-64-Plattform, da die späteren Versionen keine 32-Bit-nativen Bibliotheken wie liblwjgl.so laden können\n\nBitte laden Sie es von java.com herunter oder installieren Sie OpenJDK 8.
launch.advice.vanilla_x86.translation=Minecraft wird auf Ihrer Plattform nicht vollständig unterstützt, daher fehlen möglicherweise Funktionen oder Sie können das Spiel sogar nicht starten.\nSie können Java für die <b>x86-64</b>-Architektur <a href="https://learn.microsoft.com/java/openjdk/download">hier</a> herunterladen, um ein vollständiges Spielerlebnis zu erhalten.
Expand Down
Loading
Loading