Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ hs_err_pid*
/externalgames
NVIDIA
minecraft-exported-crash-info*
hmcl-exported-logs-*

# gradle build
/build/
Expand Down
49 changes: 38 additions & 11 deletions HMCL/src/main/java/org/jackhuang/hmcl/ui/main/SettingsPage.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.scene.control.ToggleGroup;
import org.jackhuang.hmcl.Metadata;
import org.jackhuang.hmcl.setting.Settings;
import org.jackhuang.hmcl.ui.Controllers;
import org.jackhuang.hmcl.ui.FXUtils;
Expand All @@ -40,10 +41,12 @@
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import static org.jackhuang.hmcl.setting.ConfigHolder.config;
import static org.jackhuang.hmcl.util.Lang.thread;
Expand Down Expand Up @@ -123,22 +126,46 @@ protected void onUpdate() {

@Override
protected void onExportLogs() {
// We cannot determine which file is JUL using.
// So we write all the logs to a new file.
thread(() -> {
Path logFile = Paths.get("hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".log").toAbsolutePath();

LOG.info("Exporting logs to " + logFile);
try (OutputStream output = Files.newOutputStream(logFile)) {
LOG.exportLogs(output);
String nameBase = "hmcl-exported-logs-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss"));
List<Path> recentLogFiles = LOG.findRecentLogFiles(5);

Path outputFile;
try {
if (recentLogFiles.isEmpty()) {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".log");

LOG.info("Exporting latest logs to " + outputFile);
try (OutputStream output = Files.newOutputStream(outputFile)) {
LOG.exportLogs(output);
}
} else {
outputFile = Metadata.CURRENT_DIRECTORY.resolve(nameBase + ".zip");

LOG.info("Exporting latest logs to " + outputFile);
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {

for (Path path : recentLogFiles) {
String zipEntryName = path.getFileName().toString();
Copy link

Copilot AI Aug 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly copying compressed log files (.gz, .xz) into a ZIP archive results in double compression, which is inefficient and may increase file size. Consider detecting compressed files and either decompressing them first or excluding them from the ZIP.

Suggested change
String zipEntryName = path.getFileName().toString();
String zipEntryName = path.getFileName().toString();
// Skip compressed files to avoid double compression
if (zipEntryName.endsWith(".gz") || zipEntryName.endsWith(".xz")) {
LOG.info("Skipping compressed log file: " + zipEntryName);
continue;
}

Copilot uses AI. Check for mistakes.
zos.putNextEntry(new ZipEntry(zipEntryName));
Files.copy(path, zos);
zos.closeEntry();
}

zos.putNextEntry(new ZipEntry("latest.log"));
LOG.exportLogs(zos);
zos.closeEntry();
}
}
} catch (IOException e) {
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(e), null, MessageType.ERROR));
LOG.warning("Failed to export logs", e);
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.failed") + "\n" + StringUtils.getStackTrace(e), null, MessageType.ERROR));
return;
}

Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", logFile)));
FXUtils.showFileInExplorer(logFile);
Platform.runLater(() -> Controllers.dialog(i18n("settings.launcher.launcher_log.export.success", outputFile)));
FXUtils.showFileInExplorer(outputFile);
});
}

Expand Down
160 changes: 120 additions & 40 deletions HMCLCore/src/main/java/org/jackhuang/hmcl/util/logging/Logger.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util.logging;

import org.jackhuang.hmcl.util.Pair;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.tukaani.xz.LZMA2Options;
import org.tukaani.xz.XZOutputStream;

Expand Down Expand Up @@ -127,47 +145,10 @@ private void onExit() {
String caller = CLASS_NAME + ".onExit";

if (logRetention > 0 && logFile != null) {
List<Pair<Path, int[]>> list = new ArrayList<>();
Pattern fileNamePattern = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})T(?<hour>\\d{2})-(?<minute>\\d{2})-(?<second>\\d{2})(\\.(?<n>\\d+))?\\.log(\\.(gz|xz))?");
Path dir = logFile.getParent();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(dir)) {
for (Path path : stream) {
Matcher matcher = fileNamePattern.matcher(path.getFileName().toString());
if (matcher.matches() && Files.isRegularFile(path)) {
int year = Integer.parseInt(matcher.group("year"));
int month = Integer.parseInt(matcher.group("month"));
int day = Integer.parseInt(matcher.group("day"));
int hour = Integer.parseInt(matcher.group("hour"));
int minute = Integer.parseInt(matcher.group("minute"));
int second = Integer.parseInt(matcher.group("second"));
int n = Optional.ofNullable(matcher.group("n")).map(Integer::parseInt).orElse(0);

list.add(Pair.pair(path, new int[]{year, month, day, hour, minute, second, n}));
}
}
} catch (IOException e) {
log(Level.WARNING, caller, "Failed to list log files in " + dir, e);
}

var list = findRecentLogFiles(Integer.MAX_VALUE);
if (list.size() > logRetention) {
list.sort((a, b) -> {
int[] v1 = a.getValue();
int[] v2 = b.getValue();

assert v1.length == v2.length;

for (int i = 0; i < v1.length; i++) {
int c = Integer.compare(v1[i], v2[i]);
if (c != 0)
return c;
}

return 0;
});

for (int i = 0, end = list.size() - logRetention; i < end; i++) {
Path file = list.get(i).getKey();

Path file = list.get(i);
try {
if (!Files.isSameFile(file, logFile)) {
log(Level.INFO, caller, "Delete old log file " + file, null);
Expand Down Expand Up @@ -276,6 +257,40 @@ public Path getLogFile() {
return logFile;
}

public @NotNull List<Path> findRecentLogFiles(int n) {
if (n <= 0 || logFile == null)
return List.of();

var currentLogFile = LogFile.ofFile(logFile);

Path logDir = logFile.getParent();
if (logDir == null || !Files.isDirectory(logDir))
return List.of();

var logFiles = new ArrayList<LogFile>();
try (DirectoryStream<Path> stream = Files.newDirectoryStream(logDir)) {
for (Path path : stream) {
LogFile item = LogFile.ofFile(path);
if (item != null && (currentLogFile == null || item.compareTo(currentLogFile) < 0)) {
logFiles.add(item);
}
}
} catch (IOException e) {
log(Level.WARNING, CLASS_NAME + ".findRecentLogFiles", "Failed to list log files in " + logDir, e);
return List.of();
}
logFiles.sort(Comparator.naturalOrder());

final int resultLength = Math.min(n, logFiles.size());
final int offset = logFiles.size() - resultLength;

var result = new Path[resultLength];
for (int i = 0; i < resultLength; i++) {
result[i] = logFiles.get(i + offset).file;
}
return List.of(result);
}

public void exportLogs(OutputStream output) throws IOException {
Objects.requireNonNull(output);
LogEvent.ExportLog event = new LogEvent.ExportLog(output);
Expand Down Expand Up @@ -352,4 +367,69 @@ public void trace(String msg) {
public void trace(String msg, Throwable exception) {
log(Level.TRACE, CallerFinder.getCaller(), msg, exception);
}

private static final class LogFile implements Comparable<LogFile> {
private static final Pattern FILE_NAME_PATTERN = Pattern.compile("(?<year>\\d{4})-(?<month>\\d{2})-(?<day>\\d{2})T(?<hour>\\d{2})-(?<minute>\\d{2})-(?<second>\\d{2})(\\.(?<n>\\d+))?\\.log(\\.(gz|xz))?");

private static @Nullable LogFile ofFile(Path file) {
if (!Files.isRegularFile(file))
return null;

Matcher matcher = FILE_NAME_PATTERN.matcher(file.getFileName().toString());
if (!matcher.matches())
return null;

int year = Integer.parseInt(matcher.group("year"));
int month = Integer.parseInt(matcher.group("month"));
int day = Integer.parseInt(matcher.group("day"));
int hour = Integer.parseInt(matcher.group("hour"));
int minute = Integer.parseInt(matcher.group("minute"));
int second = Integer.parseInt(matcher.group("second"));
int n = Optional.ofNullable(matcher.group("n")).map(Integer::parseInt).orElse(0);

return new LogFile(file, year, month, day, hour, minute, second, n);
}

private final Path file;
private final int year;
private final int month;
private final int day;
private final int hour;
private final int minute;
private final int second;
private final int n;

private LogFile(Path file, int year, int month, int day, int hour, int minute, int second, int n) {
this.file = file;
this.year = year;
this.month = month;
this.day = day;
this.hour = hour;
this.minute = minute;
this.second = second;
this.n = n;
}

@Override
public int compareTo(@NotNull Logger.LogFile that) {
if (this.year != that.year) return Integer.compare(this.year, that.year);
if (this.month != that.month) return Integer.compare(this.month, that.month);
if (this.day != that.day) return Integer.compare(this.day, that.day);
if (this.hour != that.hour) return Integer.compare(this.hour, that.hour);
if (this.minute != that.minute) return Integer.compare(this.minute, that.minute);
if (this.second != that.second) return Integer.compare(this.second, that.second);
if (this.n != that.n) return Integer.compare(this.n, that.n);
return 0;
}

@Override
public int hashCode() {
return Objects.hash(year, month, day, hour, minute, second, n);
}

@Override
public boolean equals(Object obj) {
return obj instanceof LogFile && compareTo((LogFile) obj) == 0;
}
}
}