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 3 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
115 changes: 114 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,107 @@ private void exportGameCrashInfo() {
});
}

private void uploadGameLog(JFXButton uploadButton) {
Alert alert;
uploadButton.setDisable(true);
Path latestLog = repository.getRunDirectory(version.getId()).toPath().resolve("logs/latest.log");
String log = null;
try {
log = FileUtils.readText(latestLog);
} catch (IOException ex) {
uploadButton.setDisable(false);
alert = new Alert(Alert.AlertType.WARNING, i18n("logwindow.upload_game_logs.failed") + "\n" + StringUtils.getStackTrace(ex));
alert.setTitle(i18n("logwindow.upload_game_logs"));
alert.showAndWait();
return;
}

GameLogUploader.UploadResult result = GameLogUploader.upload(GameLogUploader.HostingPlatform.MCLOGS, latestLog, log);
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;
}
}

private void uploadGameCrashInfo(JFXButton uploadCrashButton) {
uploadCrashButton.setDisable(true);
Path logFile = Paths.get("minecraft-exported-crash-info-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH-mm-ss")) + ".zip").toAbsolutePath();

CompletableFuture.supplyAsync(() ->
logs.stream().map(Pair::getKey).collect(Collectors.joining(OperatingSystem.LINE_SEPARATOR)))
.thenComposeAsync(logs ->
LogExporter.exportLogs(logFile, repository, launchOptions.getVersionName(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString()))
.handleAsync((result, exception) -> {
try {
if (exception == null) {
GameLogUploader.UploadResult uploadResult = GameLogUploader.upload(
GameLogUploader.HostingPlatform.FILE_IO,
logFile,
new byte[]{}
);
if (uploadResult == null) {
LOG.warning("Failed to upload game crash info");
Platform.runLater(() -> {
uploadCrashButton.setDisable(false);
Alert alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed"));
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
});
return null;
} else {
LOG.info("Uploaded game crash info to " + uploadResult.getUrl());
Platform.runLater(() -> {
Clipboard.getSystemClipboard().setContent(new ClipboardContent() {{
putString(uploadResult.getUrl());
}});

Alert alert = new Alert(
Alert.AlertType.INFORMATION,
i18n("logwindow.upload_game_logs.copied") + "\n" + uploadResult.getUrl() + "\n"
+ i18n("logwindow.upload_game_crash_logs.expires_time", uploadResult.getRaw())
);
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
});
return null;
}
} else {
LOG.warning("Failed to export game crash info", exception);
Platform.runLater(() -> {
uploadCrashButton.setDisable(false);
Alert alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed"));
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
});
}
return null;
}
catch (Exception e) {
LOG.warning("Failed to upload game crash info", e);
Platform.runLater(() -> {
uploadCrashButton.setDisable(false);
Alert alert = new Alert(Alert.AlertType.WARNING, i18n("settings.launcher.launcher_log.export.failed"));
alert.setTitle(i18n("settings.launcher.launcher_log.export"));
alert.showAndWait();
});
return null;
}
});
}

private final class View extends VBox {

View() {
Expand Down Expand Up @@ -431,11 +537,18 @@ 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 uploadCrashButton = FXUtils.newRaisedButton(i18n("logwindow.upload_game_crash_logs"));
uploadCrashButton.setOnMouseClicked(e -> uploadGameCrashInfo(uploadCrashButton));

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, uploadCrashButton, 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
5 changes: 5 additions & 0 deletions HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,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