diff --git a/HMCL/src/main/kotlin/org/jackhuang/hmcl/game/LauncherHelper.kt b/HMCL/src/main/kotlin/org/jackhuang/hmcl/game/LauncherHelper.kt index 08738729c..b829ac30d 100644 --- a/HMCL/src/main/kotlin/org/jackhuang/hmcl/game/LauncherHelper.kt +++ b/HMCL/src/main/kotlin/org/jackhuang/hmcl/game/LauncherHelper.kt @@ -31,12 +31,13 @@ import org.jackhuang.hmcl.task.* import org.jackhuang.hmcl.ui.* import org.jackhuang.hmcl.util.JavaProcess import org.jackhuang.hmcl.util.Log4jLevel -import java.util.concurrent.ConcurrentSkipListSet +import java.util.* +import java.util.concurrent.ConcurrentLinkedQueue object LauncherHelper { private val launchingStepsPane = LaunchingStepsPane() - val PROCESS = ConcurrentSkipListSet() + val PROCESS = ConcurrentLinkedQueue() fun launch() { val profile = Settings.selectedProfile @@ -76,6 +77,7 @@ object LauncherHelper { }) .then { it.get("launcher").launchAsync() } .then(task { + PROCESS.add(it[DefaultLauncher.LAUNCH_ASYNC_ID]) if (setting.launcherVisibility == LauncherVisibility.CLOSE) Main.stop() }) @@ -120,6 +122,7 @@ object LauncherHelper { private lateinit var process: JavaProcess private var lwjgl = false private var logWindow: LogWindow? = null + private val logs = LinkedList>() override fun setProcess(process: JavaProcess) { this.process = process @@ -129,12 +132,21 @@ object LauncherHelper { } override fun onLog(log: String, level: Log4jLevel) { + var newLog = log + for ((original, replacement) in forbiddenTokens) + newLog = newLog.replace(original, replacement) + if (level.lessOrEqual(Log4jLevel.ERROR)) System.err.print(log) else System.out.print(log) - runOnUiThread { logWindow?.logLine(log, level) } + runOnUiThread { + logs += log to level + if (logs.size > Settings.logLines) + logs.removeFirst() + logWindow?.logLine(log, level) + } if (!lwjgl && log.contains("LWJGL Version: ")) { lwjgl = true @@ -162,6 +174,16 @@ object LauncherHelper { } override fun onExit(exitCode: Int, exitType: ProcessListener.ExitType) { + if (exitType != ProcessListener.ExitType.NORMAL && logWindow == null){ + runOnUiThread { + LogWindow().apply { + for ((line, level) in logs) + logLine(line, level) + show() + } + } + } + checkExit(launcherVisibility) } @@ -181,8 +203,11 @@ object LauncherHelper { } } - fun stopManagedProcess() { - PROCESS.forEach(JavaProcess::stop) + fun stopManagedProcesses() { + synchronized(PROCESS) { + while (PROCESS.isNotEmpty()) + PROCESS.poll()?.stop() + } } enum class LoadingState { diff --git a/HMCL/src/main/kotlin/org/jackhuang/hmcl/setting/Settings.kt b/HMCL/src/main/kotlin/org/jackhuang/hmcl/setting/Settings.kt index 3244faec8..49e53d6df 100644 --- a/HMCL/src/main/kotlin/org/jackhuang/hmcl/setting/Settings.kt +++ b/HMCL/src/main/kotlin/org/jackhuang/hmcl/setting/Settings.kt @@ -200,6 +200,12 @@ object Settings { SETTINGS.fontSize = value.size } + var logLines: Int + get() = maxOf(SETTINGS.logLines, 100) + set(value) { + SETTINGS.logLines = value + } + var downloadProvider: DownloadProvider get() = when (SETTINGS.downloadtype) { 0 -> MojangDownloadProvider diff --git a/HMCL/src/main/kotlin/org/jackhuang/hmcl/ui/LogWindow.kt b/HMCL/src/main/kotlin/org/jackhuang/hmcl/ui/LogWindow.kt index e9567ecc9..73a1b4bfe 100644 --- a/HMCL/src/main/kotlin/org/jackhuang/hmcl/ui/LogWindow.kt +++ b/HMCL/src/main/kotlin/org/jackhuang/hmcl/ui/LogWindow.kt @@ -17,49 +17,133 @@ */ package org.jackhuang.hmcl.ui +import com.jfoenix.controls.JFXComboBox +import javafx.beans.Observable +import javafx.beans.binding.Bindings +import javafx.beans.property.SimpleIntegerProperty import javafx.concurrent.Worker +import javafx.fxml.FXML import javafx.scene.Scene +import javafx.scene.control.ToggleButton +import javafx.scene.image.Image import javafx.scene.layout.StackPane import javafx.scene.web.WebEngine import javafx.scene.web.WebView import javafx.stage.Stage +import org.jackhuang.hmcl.game.LauncherHelper import org.jackhuang.hmcl.i18n import org.jackhuang.hmcl.setting.Settings import org.jackhuang.hmcl.util.Log4jLevel +import org.jackhuang.hmcl.util.inc import org.jackhuang.hmcl.util.readFullyAsString import org.w3c.dom.Document import org.w3c.dom.Node +import java.util.concurrent.Callable class LogWindow : Stage() { - val contentPane = WebView() - val rootPane = StackPane().apply { - children.setAll(contentPane) - } - val engine: WebEngine - lateinit var body: Node - lateinit var document: Document + val fatalProperty = SimpleIntegerProperty(0) + val errorProperty = SimpleIntegerProperty(0) + val warnProperty = SimpleIntegerProperty(0) + val infoProperty = SimpleIntegerProperty(0) + val debugProperty = SimpleIntegerProperty(0) + + val impl = LogWindowImpl() init { - scene = Scene(rootPane, 800.0, 480.0) + scene = Scene(impl, 800.0, 480.0) + scene.stylesheets.addAll(*stylesheets) title = i18n("logwindow.title") - - contentPane.onScroll - engine = contentPane.engine - engine.loadContent(javaClass.getResourceAsStream("/assets/log-window-content.html").readFullyAsString().replace("\${FONT}", "${Settings.font.size}px \"${Settings.font.family}\"")) - engine.loadWorker.stateProperty().addListener { _, _, newValue -> - if (newValue == Worker.State.SUCCEEDED) { - document = engine.document - body = document.getElementsByTagName("body").item(0) - } - } - + icons += Image("/assets/img/icon.png") } fun logLine(line: String, level: Log4jLevel) { - body.appendChild(contentPane.engine.document.createElement("div").apply { - setAttribute("style", "background-color: #${level.color.toString().substring(2)};") + impl.body.appendChild(impl.engine.document.createElement("div").apply { textContent = line }) - engine.executeScript("scrollToBottom()") + impl.engine.executeScript("checkNewLog(\"${level.name.toLowerCase()}\");scrollToBottom();") + + when (level) { + Log4jLevel.FATAL -> fatalProperty.inc() + Log4jLevel.ERROR -> errorProperty.inc() + Log4jLevel.WARN -> warnProperty.inc() + Log4jLevel.INFO -> infoProperty.inc() + Log4jLevel.DEBUG -> debugProperty.inc() + else -> {} + } + } + + inner class LogWindowImpl: StackPane() { + @FXML lateinit var webView: WebView + @FXML lateinit var btnFatals: ToggleButton + @FXML lateinit var btnErrors: ToggleButton + @FXML lateinit var btnWarns: ToggleButton + @FXML lateinit var btnInfos: ToggleButton + @FXML lateinit var btnDebugs: ToggleButton + + @FXML lateinit var cboLines: JFXComboBox + val engine: WebEngine + lateinit var body: Node + lateinit var document: Document + + init { + loadFXML("/assets/fxml/log.fxml") + + engine = webView.engine + engine.loadContent(javaClass.getResourceAsStream("/assets/log-window-content.html").readFullyAsString().replace("\${FONT}", "${Settings.font.size}px \"${Settings.font.family}\"")) + engine.loadWorker.stateProperty().addListener { _, _, newValue -> + if (newValue == Worker.State.SUCCEEDED) { + document = engine.document + body = document.getElementsByTagName("body").item(0) + engine.executeScript("limitedLogs=${Settings.logLines};") + } + } + + var flag = false + for (i in cboLines.items) { + if (i == Settings.logLines.toString()) { + cboLines.selectionModel.select(i) + flag = true + } + } + cboLines.selectionModel.selectedItemProperty().addListener { _, _, newValue -> + Settings.logLines = newValue.toInt() + engine.executeScript("limitedLogs=${Settings.logLines};") + } + if (!flag) { + cboLines.selectionModel.select(0) + } + + btnFatals.textProperty().bind(Bindings.createStringBinding(Callable { fatalProperty.get().toString() + " fatals" }, fatalProperty)) + btnErrors.textProperty().bind(Bindings.createStringBinding(Callable { errorProperty.get().toString() + " errors" }, errorProperty)) + btnWarns.textProperty().bind(Bindings.createStringBinding(Callable { warnProperty.get().toString() + " warns" }, warnProperty)) + btnInfos.textProperty().bind(Bindings.createStringBinding(Callable { infoProperty.get().toString() + " infos" }, infoProperty)) + btnDebugs.textProperty().bind(Bindings.createStringBinding(Callable { debugProperty.get().toString() + " debugs" }, debugProperty)) + + btnFatals.selectedProperty().addListener(this::specificChanged) + btnErrors.selectedProperty().addListener(this::specificChanged) + btnWarns.selectedProperty().addListener(this::specificChanged) + btnInfos.selectedProperty().addListener(this::specificChanged) + btnDebugs.selectedProperty().addListener(this::specificChanged) + } + + private fun specificChanged(observable: Observable) { + var res = "" + if (btnFatals.isSelected) res += "\"fatal\", " + if (btnErrors.isSelected) res += "\"error\", " + if (btnWarns.isSelected) res += "\"warn\", " + if (btnInfos.isSelected) res += "\"info\", " + if (btnDebugs.isSelected) res += "\"debug\", " + if (res.isNotBlank()) + res = res.substringBeforeLast(", ") + engine.executeScript("specific([$res])") + } + + fun onTerminateGame() { + LauncherHelper.stopManagedProcesses() + } + + fun onClear() { + engine.executeScript("clear()") + } } } \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/css/jfoenix-main-demo.css b/HMCL/src/main/resources/assets/css/jfoenix-main-demo.css index f0dc95ffc..bfa4cbc73 100644 --- a/HMCL/src/main/resources/assets/css/jfoenix-main-demo.css +++ b/HMCL/src/main/resources/assets/css/jfoenix-main-demo.css @@ -794,6 +794,20 @@ -fx-background-color: null; } +.log-toggle:selected { + -fx-background-color: transparent; + -fx-border: 1px; + -fx-border-color: black; + -fx-text-fill: black; +} + +.log-toggle { + -fx-background-color: transparent; + -fx-border: 1px; + -fx-border-color: gray; + -fx-text-fill: gray; +} + /******************************************************************************* * * * JFX Spinner * diff --git a/HMCL/src/main/resources/assets/fxml/log.fxml b/HMCL/src/main/resources/assets/fxml/log.fxml new file mode 100644 index 000000000..bdb6459d2 --- /dev/null +++ b/HMCL/src/main/resources/assets/fxml/log.fxml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/HMCL/src/main/resources/assets/log-window-content.html b/HMCL/src/main/resources/assets/log-window-content.html index 56278a63b..5ed798c1c 100644 --- a/HMCL/src/main/resources/assets/log-window-content.html +++ b/HMCL/src/main/resources/assets/log-window-content.html @@ -8,6 +8,7 @@ margin: 0; overflow-x: hidden; } + div { font: ${FONT}; margin: 0px; @@ -16,10 +17,55 @@ border-width: 0 0 1px 0; border-color: #dddddd; } + + .fatal { background-color: #F7A699; } + .error { background-color: #FFCCBB; } + .warn { background-color: #FFEECC; } + .info { background-color: #FFFFFF; } + .debug { background-color: #EEE9E0; } + .trace { background-color: blue; } diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/DefaultLauncher.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/DefaultLauncher.kt index 4134fb37f..b2ba95912 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/DefaultLauncher.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/DefaultLauncher.kt @@ -286,11 +286,11 @@ open class DefaultLauncher(repository: GameRepository, versionId: String, accoun private fun startMonitors(javaProcess: JavaProcess, processListener: ProcessListener, isDaemon: Boolean = true) { processListener.setProcess(javaProcess) - val logHandler = Log4jHandler { line, level -> processListener.onLog(line, level); javaProcess.stdOutLines += line }.apply { start() } + val logHandler = Log4jHandler { line, level -> processListener.onLog(line, level); javaProcess.lines += line }.apply { start() } javaProcess.relatedThreads += logHandler val stdout = thread(name = "stdout-pump", isDaemon = isDaemon, block = StreamPump(javaProcess.process.inputStream, { logHandler.newLine(it) } )::run) javaProcess.relatedThreads += stdout - val stderr = thread(name = "stderr-pump", isDaemon = isDaemon, block = StreamPump(javaProcess.process.errorStream, { processListener.onLog(it + OS.LINE_SEPARATOR, Log4jLevel.ERROR); javaProcess.stdErrLines += it })::run) + val stderr = thread(name = "stderr-pump", isDaemon = isDaemon, block = StreamPump(javaProcess.process.errorStream, { processListener.onLog(it + OS.LINE_SEPARATOR, Log4jLevel.ERROR); javaProcess.lines += it })::run) javaProcess.relatedThreads += stderr javaProcess.relatedThreads += thread(name = "exit-waiter", isDaemon = isDaemon, block = ExitWaiter(javaProcess, listOf(stdout, stderr), { exitCode, exitType -> logHandler.onStopped(); processListener.onExit(exitCode, exitType) })::run) } diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/ExitWaiter.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ExitWaiter.kt similarity index 90% rename from HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/ExitWaiter.kt rename to HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ExitWaiter.kt index e9ffb70ad..ad8d7ef1d 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/ExitWaiter.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ExitWaiter.kt @@ -15,13 +15,15 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see {http://www.gnu.org/licenses/}. */ -package org.jackhuang.hmcl.util +package org.jackhuang.hmcl.launch import org.jackhuang.hmcl.event.EVENT_BUS import org.jackhuang.hmcl.event.JVMLaunchFailedEvent import org.jackhuang.hmcl.event.JavaProcessExitedAbnormallyEvent import org.jackhuang.hmcl.event.JavaProcessStoppedEvent -import org.jackhuang.hmcl.launch.ProcessListener +import org.jackhuang.hmcl.util.JavaProcess +import org.jackhuang.hmcl.util.containsOne +import org.jackhuang.hmcl.util.guessLogLineError import java.util.* /** @@ -36,10 +38,7 @@ internal class ExitWaiter(val process: JavaProcess, val joins: Collection() - lines.addAll(process.stdErrLines) - lines.addAll(process.stdOutLines) - val errorLines = lines.filter(::guessLogLineError) + val errorLines = process.lines.filter(::guessLogLineError) val exitType: ProcessListener.ExitType // LaunchWrapper will catch the exception logged and will exit normally. if (exitCode != 0 || errorLines.containsOne("Unable to launch")) { diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/Log4jHandler.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/Log4jHandler.kt index a0a025b6e..e506f3ed5 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/Log4jHandler.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/Log4jHandler.kt @@ -24,10 +24,12 @@ import org.xml.sax.Attributes import org.xml.sax.InputSource import org.xml.sax.helpers.DefaultHandler import org.xml.sax.helpers.XMLReaderFactory +import java.io.InterruptedIOException import java.io.PipedInputStream import java.io.PipedOutputStream import java.text.SimpleDateFormat import java.util.* +import java.util.concurrent.atomic.AtomicBoolean /** * This class is to parse log4j classic XML layout logging, since only vanilla Minecraft will enable this layout. @@ -38,18 +40,28 @@ internal class Log4jHandler(val callback: (String, Log4jLevel) -> Unit) : Thread } private val outputStream = PipedOutputStream() private val inputStream = PipedInputStream(outputStream) + private val interrupted = AtomicBoolean(false) override fun run() { name = "log4j-handler" newLine("") - reader.parse(InputSource(inputStream)) + try { + reader.parse(InputSource(inputStream)) + } catch (e: InterruptedIOException) { + // Game has been interrupted. + interrupted.set(true) + } } fun onStopped() { + if (interrupted.get()) + return Scheduler.NEW_THREAD.schedule { - newLine("")?.get() - outputStream.close() - join() + if (!interrupted.get()) { + newLine("")?.get() + outputStream.close() + join() + } }!!.get() } diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ProcessListener.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ProcessListener.kt index c50cd0172..29a15708d 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ProcessListener.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/ProcessListener.kt @@ -28,7 +28,9 @@ interface ProcessListener { fun setProcess(process: JavaProcess) {} /** - * Called when receiving a log from stdout + * Called when receiving a log from stdout/stderr. + * + * Does not guarantee that this method is thread safe. * * @param log the log */ diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/StreamPump.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/StreamPump.kt similarity index 92% rename from HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/StreamPump.kt rename to HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/StreamPump.kt index 028db36bf..6a7f7977e 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/StreamPump.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/launch/StreamPump.kt @@ -15,8 +15,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see {http://www.gnu.org/licenses/}. */ -package org.jackhuang.hmcl.util +package org.jackhuang.hmcl.launch +import org.jackhuang.hmcl.util.LOG +import org.jackhuang.hmcl.util.SYSTEM_CHARSET import java.io.IOException import java.io.InputStream import java.util.logging.Level diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/JavaProcess.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/JavaProcess.kt index 51ad98427..1349839c5 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/JavaProcess.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/JavaProcess.kt @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.util +import java.util.* import java.util.concurrent.ConcurrentLinkedQueue class JavaProcess( @@ -24,8 +25,7 @@ class JavaProcess( val commands: List ) { val properties = mutableMapOf() - val stdOutLines: MutableCollection = ConcurrentLinkedQueue() - val stdErrLines: MutableCollection = ConcurrentLinkedQueue() + val lines: Queue = ConcurrentLinkedQueue() val relatedThreads = mutableListOf() val isRunning: Boolean = try { process.exitValue() diff --git a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/OS.kt b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/OS.kt index 8fba31722..446260fc6 100644 --- a/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/OS.kt +++ b/HMCLCore/src/main/kotlin/org/jackhuang/hmcl/util/OS.kt @@ -18,6 +18,8 @@ package org.jackhuang.hmcl.util import com.google.gson.annotations.SerializedName +import javafx.scene.input.Clipboard +import javafx.scene.input.ClipboardContent import java.io.File import java.lang.management.ManagementFactory import java.nio.charset.Charset @@ -65,5 +67,12 @@ enum class OS { val SYSTEM_VERSION: String by lazy { System.getProperty("os.version") } val SYSTEM_ARCH: String by lazy { System.getProperty("os.arch") } + + fun setClipboard(string: String) { + val clipboard = Clipboard.getSystemClipboard() + clipboard.setContent(ClipboardContent().apply { + putString(string) + }) + } } } \ No newline at end of file