Skip to content

实现 #2971 在游戏崩溃窗口添加上传崩溃信息功能 #3145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
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
63 changes: 63 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
import org.jackhuang.hmcl.util.io.Zipper;
import org.jackhuang.hmcl.util.platform.OperatingSystem;

import java.io.FileInputStream;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.management.ManagementFactory;
import java.nio.charset.StandardCharsets;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
Expand Down Expand Up @@ -85,6 +87,67 @@ public static CompletableFuture<Void> exportLogs(Path zipFile, DefaultGameReposi
});
}

private static StringBuilder appendFileWithDivider(StringBuilder logsText, String fileName, String fileData) {
return logsText.append("\n")
.append("[00:00:00] $> FILE DATA START [ \"")
.append(fileName)
.append("\" ] ")
.append("=====================================================")
.append("=====================================================")
.append("\n\n\n\n\n")
.append(fileData)
.append("\n\n\n\n\n")
.append("[00:00:00] $> FILE DATA END [ \"")
.append(fileName)
.append("\" ] ")
.append("=====================================================")
.append("=====================================================")
.append("\n\n\n");
}

public static StringBuilder exportLogsText(DefaultGameRepository gameRepository, String versionId, String logs, String launchScript) {
Path runDirectory = gameRepository.getRunDirectory(versionId).toPath();
Path baseDirectory = gameRepository.getBaseDirectory().toPath();
List<String> versions = new ArrayList<>();

String currentVersionId = versionId;
HashSet<String> resolvedSoFar = new HashSet<>();
while (true) {
if (resolvedSoFar.contains(currentVersionId)) break;
resolvedSoFar.add(currentVersionId);
Version currentVersion = gameRepository.getVersion(currentVersionId);
versions.add(currentVersionId);

if (StringUtils.isNotBlank(currentVersion.getInheritsFrom())) {
currentVersionId = currentVersion.getInheritsFrom();
} else {
break;
}
}

StringBuilder logsText = new StringBuilder();
appendFileWithDivider(logsText, "hmcl.log", LOG.getLogs());
appendFileWithDivider(logsText, "minecraft.log", logs);
appendFileWithDivider(logsText,
OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh", Logger.filterForbiddenToken(launchScript)
);
try{
for (String id : versions) {
Path versionJson = baseDirectory.resolve("versions").resolve(id).resolve(id + ".json");
if (Files.exists(versionJson)) {
try(FileInputStream fis = new FileInputStream(versionJson.toFile())) {
byte[] bytes = new byte[fis.available()];
fis.read(bytes);
appendFileWithDivider(logsText, id + ".json", new String(bytes, StandardCharsets.UTF_8));
}
}
}
}catch (IOException e){
throw new UncheckedIOException(e);
}
return logsText;
}

private static void processLogs(Path directory, String fileExtension, String logDirectory, Zipper zipper) {
try (DirectoryStream<Path> stream = Files.newDirectoryStream(directory, fileExtension)) {
long processStartTime = ManagementFactory.getRuntimeMXBean().getStartTime();
Expand Down
48 changes: 47 additions & 1 deletion HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
package org.jackhuang.hmcl.ui;

import com.jfoenix.controls.JFXButton;
import javafx.application.Platform;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
Expand All @@ -29,6 +30,8 @@
import javafx.scene.control.Alert;
import javafx.scene.control.Label;
import javafx.scene.control.ScrollPane;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;
Expand All @@ -43,6 +46,7 @@
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.construct.TwoLineListItem;
import org.jackhuang.hmcl.util.GameLogUploader;
import org.jackhuang.hmcl.util.Log4jLevel;
import org.jackhuang.hmcl.util.logging.Logger;
import org.jackhuang.hmcl.util.Pair;
Expand All @@ -58,6 +62,7 @@
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.CompletableFuture;
Expand Down Expand Up @@ -298,6 +303,43 @@ private void exportGameCrashInfo() {
});
}

private void uploadGameLog(JFXButton uploadButton) {
Alert alert;
uploadButton.setDisable(true);
Path latestLog = repository.getRunDirectory(version.getId()).toPath().resolve("logs/latest.log");
try{
String logs = FileUtils.readText(latestLog);
StringBuilder logAllInOne = LogExporter.exportLogsText(repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString());

GameLogUploader.UploadResult result = GameLogUploader.upload(GameLogUploader.HostingPlatform.MCLOGS, latestLog, logAllInOne.toString());
if (result != null) {
LOG.info("Uploaded game logs to " + result.getUrl());
Clipboard.getSystemClipboard().setContent(new ClipboardContent() {{
putString(result.getUrl());
}});
alert = new Alert(Alert.AlertType.INFORMATION, i18n("logwindow.upload_game_logs.copied") + "\n" + result.getUrl());
alert.setTitle(i18n("logwindow.upload_game_logs"));
alert.showAndWait();
return;
}else{
LOG.warning("Failed to upload game logs");
uploadButton.setDisable(false);
alert = new Alert(Alert.AlertType.WARNING, i18n("logwindow.upload_game_logs.failed"));
alert.setTitle(i18n("logwindow.upload_game_logs"));
alert.showAndWait();
return;
}
}
catch (IOException ex){
LOG.warning("Failed to upload game logs", ex);
uploadButton.setDisable(false);
alert = new Alert(Alert.AlertType.WARNING, i18n("logwindow.upload_game_logs.failed"));
alert.setTitle(i18n("logwindow.upload_game_logs"));
alert.showAndWait();
return;
}
}

private final class View extends VBox {

View() {
Expand Down Expand Up @@ -431,11 +473,15 @@ private final class View extends VBox {
helpButton.setOnAction(e -> FXUtils.openLink("https://docs.hmcl.net/help.html"));
runInFX(() -> FXUtils.installFastTooltip(helpButton, i18n("logwindow.help")));

JFXButton uploadLogButton = FXUtils.newRaisedButton(i18n("logwindow.upload_game_logs"));
uploadLogButton.setOnMouseClicked(e -> uploadGameLog(uploadLogButton));



toolBar.setPadding(new Insets(8));
toolBar.setSpacing(8);
toolBar.getStyleClass().add("jfx-tool-bar");
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, helpButton);
toolBar.getChildren().setAll(exportGameCrashInfoButton, logButton, helpButton, uploadLogButton);
}

getChildren().setAll(titlePane, infoPane, moddedPane, gameDirPane, toolBar);
Expand Down
199 changes: 199 additions & 0 deletions HMCL/src/main/java/org/jackhuang/hmcl/util/GameLogUploader.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package org.jackhuang.hmcl.util;

import com.google.gson.Gson;
import org.jackhuang.hmcl.util.io.HttpRequest;
import org.jackhuang.hmcl.util.logging.Logger;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;

public class GameLogUploader {

public enum HostingPlatform {
/*
* HTTP: POST https://api.mclo.gs/1/log
* Data Type: application/x-www-form-urlencoded
* Field: content
* Type: string
* Description: The raw log file content as string. Maximum length is 10MiB and 25k lines, will be shortened if necessary.
*/
MCLOGS("mclo.gs", "https://mclo.gs/", "https://docs.mclo.gs/api/v1/log"),
/*
* HTTP: POST https://file.io/
* Data Type: multipart/form-data
* Fields:
* file string($binary)
expires
maxDownloads integer
autoDelete boolean
*/
FILE_IO("file.io", "https://www.file.io/", "https://www.file.io/developers")



;

private final String name;
private final String url;
private final String documents;

HostingPlatform(String name, String url, String documents) {
this.name = name;
this.url = url;
this.documents = documents;
}
}

public static class UploadResult {
private String url;
private String id;
private String raw;



public UploadResult(String url, String id, String raw) {
this.url = url;
this.id = id;
this.raw = raw;
}

public String getUrl() {
return url;
}

public String getId() {
return id;
}

public String getRaw() {
return raw;
}
}

public static UploadResult upload(HostingPlatform platform, Path filepath, String content) {
return upload(platform, filepath, content.getBytes(StandardCharsets.UTF_8));
}

public static UploadResult upload(HostingPlatform platform, Path filepath, byte[] content) {
Gson gson = new Gson();
try {
switch (platform) {
case MCLOGS: {
HttpRequest.HttpPostRequest request = HttpRequest.POST("https://api.mclo.gs/1/log");
request.header("Content-Type", "application/x-www-form-urlencoded");
HashMap<String, String> payload = new HashMap<>();

payload.put("content", new String(content, StandardCharsets.UTF_8));
request.form(payload);

String response = request.getString();
Map<String, Object> json = gson.fromJson(response, Map.class);
if (!json.containsKey("success")) {
return null;
}
if ((boolean) json.get("success")) {
return new UploadResult(
(String) json.get("url"),
(String) json.get("id"),
(String) json.get("raw")
);
}
return null;
}
case FILE_IO: {
String boundary = Long.toHexString(System.currentTimeMillis()); // 创建一个唯一的边界字符串
String LINE_FEED = "\r\n";
URL url = new URL("https://file.io/");

HttpURLConnection httpConn = (HttpURLConnection) url.openConnection();

httpConn.setDoOutput(true);
httpConn.setDoInput(true);
httpConn.setRequestMethod("POST");
httpConn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
httpConn.setRequestProperty("Accept", "application/json");

FileInputStream fileInputStream = new FileInputStream(filepath.toFile());
OutputStream outputStream = httpConn.getOutputStream();
PrintWriter writer = new PrintWriter(new OutputStreamWriter(outputStream, StandardCharsets.UTF_8), true);

writer.append("--").append(boundary).append(LINE_FEED);
writer.append("Content-Disposition: form-data; name=\"file\"; filename=\"").append(filepath.getFileName().toString()).append("\"").append(LINE_FEED);
writer.append("Content-Type: application/octet-stream").append(LINE_FEED);
writer.append(LINE_FEED);
writer.flush();
// outputStream.write(content);
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fileInputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
writer.append(LINE_FEED);

writer.append("--" + boundary).append(LINE_FEED);
writer.append("Content-Disposition: form-data; name=\"autoDelete\"").append(LINE_FEED);
writer.append(LINE_FEED);
writer.append("true").append(LINE_FEED);

writer.append("--" + boundary).append(LINE_FEED);
writer.append("Content-Disposition: form-data; name=\"expires\"").append(LINE_FEED);
writer.append(LINE_FEED);
writer.append("7d").append(LINE_FEED);

writer.append("--").append(boundary).append("--").append(LINE_FEED).append(LINE_FEED);
writer.flush();

int responseCode = httpConn.getResponseCode();
Logger.LOG.info("Http Response Code: " + responseCode);
if(responseCode != 200){
BufferedReader err = new BufferedReader(new InputStreamReader(httpConn.getErrorStream()));
String errLine;
StringBuilder errResponse = new StringBuilder();
while ((errLine = err.readLine()) != null) {
errResponse.append(errLine);
}
Logger.LOG.error("Error response from file.io: " + errResponse);
return null;
}


BufferedReader in = new BufferedReader(new InputStreamReader(httpConn.getInputStream()));
String inputLine;
StringBuilder response = new StringBuilder();

while ((inputLine = in.readLine()) != null) {
response.append(inputLine);
}
Logger.LOG.info("Response from file.io: " + response);
in.close();

Map<String, Object> json = gson.fromJson(response.toString(), Map.class);
if (!json.containsKey("success")) {
return null;
}
if ((boolean) json.get("success")) {
return new UploadResult(
(String) json.get("link"),
(String) json.get("key"),
(String) json.get("expires")
);
}
return null;
}
default:
return null;
}
} catch (Exception ex) {
Logger.LOG.error("Failed to upload game log", ex);
return null;
}
}
}
5 changes: 5 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N.properties
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,11 @@ logwindow.terminate_game=Kill Game Process
logwindow.title=Log
logwindow.help=You can go to the HMCL community and find others for help
logwindow.autoscroll=Auto-scroll
logwindow.upload_game_logs=Upload Game Logs
logwindow.upload_game_logs.failed=Upload Failed
logwindow.upload_game_logs.copied=Copied URL to clipboard
logwindow.upload_game_crash_logs.expires_time=Expire: %s
logwindow.upload_game_crash_logs=Upload Crash Logs
logwindow.export_game_crash_logs=Export Crash Logs
logwindow.export_dump.dependency_ok.button=Export Game Stack Dump
logwindow.export_dump.dependency_ok.doing_button=Exporting Game Stack Dump (May take up to 15 seconds)
Expand Down
5 changes: 5 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh.properties
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,11 @@ logwindow.terminate_game=結束遊戲處理程式
logwindow.title=記錄
logwindow.help=你可以前往 HMCL 社區,尋找他人幫助
logwindow.autoscroll=自動滾動
logwindow.upload_game_logs=上傳遊戲日誌訊息
logwindow.upload_game_logs.failed=上傳遊戲日誌失敗
logwindow.upload_game_logs.copied=網址已複製到剪貼板
logwindow.upload_game_crash_logs.expires_time=過期時間:%s
logwindow.upload_game_crash_logs=上傳遊戲崩潰訊息
logwindow.export_game_crash_logs=導出遊戲崩潰訊息
logwindow.export_dump.dependency_ok.button=導出遊戲運行棧
logwindow.export_dump.dependency_ok.doing_button=正在導出遊戲運行棧(可能需要 15 秒)
Expand Down
Loading