Log window category and show lines

This commit is contained in:
huangyuhui 2017-08-21 20:59:32 +08:00
parent be202a6e1a
commit a67bfabea4
13 changed files with 304 additions and 44 deletions

View File

@ -31,12 +31,13 @@ import org.jackhuang.hmcl.task.*
import org.jackhuang.hmcl.ui.* import org.jackhuang.hmcl.ui.*
import org.jackhuang.hmcl.util.JavaProcess import org.jackhuang.hmcl.util.JavaProcess
import org.jackhuang.hmcl.util.Log4jLevel import org.jackhuang.hmcl.util.Log4jLevel
import java.util.concurrent.ConcurrentSkipListSet import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
object LauncherHelper { object LauncherHelper {
private val launchingStepsPane = LaunchingStepsPane() private val launchingStepsPane = LaunchingStepsPane()
val PROCESS = ConcurrentSkipListSet<JavaProcess>() val PROCESS = ConcurrentLinkedQueue<JavaProcess>()
fun launch() { fun launch() {
val profile = Settings.selectedProfile val profile = Settings.selectedProfile
@ -76,6 +77,7 @@ object LauncherHelper {
}) })
.then { it.get<DefaultLauncher>("launcher").launchAsync() } .then { it.get<DefaultLauncher>("launcher").launchAsync() }
.then(task { .then(task {
PROCESS.add(it[DefaultLauncher.LAUNCH_ASYNC_ID])
if (setting.launcherVisibility == LauncherVisibility.CLOSE) if (setting.launcherVisibility == LauncherVisibility.CLOSE)
Main.stop() Main.stop()
}) })
@ -120,6 +122,7 @@ object LauncherHelper {
private lateinit var process: JavaProcess private lateinit var process: JavaProcess
private var lwjgl = false private var lwjgl = false
private var logWindow: LogWindow? = null private var logWindow: LogWindow? = null
private val logs = LinkedList<Pair<String, Log4jLevel>>()
override fun setProcess(process: JavaProcess) { override fun setProcess(process: JavaProcess) {
this.process = process this.process = process
@ -129,12 +132,21 @@ object LauncherHelper {
} }
override fun onLog(log: String, level: Log4jLevel) { 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)) if (level.lessOrEqual(Log4jLevel.ERROR))
System.err.print(log) System.err.print(log)
else else
System.out.print(log) 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: ")) { if (!lwjgl && log.contains("LWJGL Version: ")) {
lwjgl = true lwjgl = true
@ -162,6 +174,16 @@ object LauncherHelper {
} }
override fun onExit(exitCode: Int, exitType: ProcessListener.ExitType) { 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) checkExit(launcherVisibility)
} }
@ -181,8 +203,11 @@ object LauncherHelper {
} }
} }
fun stopManagedProcess() { fun stopManagedProcesses() {
PROCESS.forEach(JavaProcess::stop) synchronized(PROCESS) {
while (PROCESS.isNotEmpty())
PROCESS.poll()?.stop()
}
} }
enum class LoadingState { enum class LoadingState {

View File

@ -200,6 +200,12 @@ object Settings {
SETTINGS.fontSize = value.size SETTINGS.fontSize = value.size
} }
var logLines: Int
get() = maxOf(SETTINGS.logLines, 100)
set(value) {
SETTINGS.logLines = value
}
var downloadProvider: DownloadProvider var downloadProvider: DownloadProvider
get() = when (SETTINGS.downloadtype) { get() = when (SETTINGS.downloadtype) {
0 -> MojangDownloadProvider 0 -> MojangDownloadProvider

View File

@ -17,49 +17,133 @@
*/ */
package org.jackhuang.hmcl.ui 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.concurrent.Worker
import javafx.fxml.FXML
import javafx.scene.Scene import javafx.scene.Scene
import javafx.scene.control.ToggleButton
import javafx.scene.image.Image
import javafx.scene.layout.StackPane import javafx.scene.layout.StackPane
import javafx.scene.web.WebEngine import javafx.scene.web.WebEngine
import javafx.scene.web.WebView import javafx.scene.web.WebView
import javafx.stage.Stage import javafx.stage.Stage
import org.jackhuang.hmcl.game.LauncherHelper
import org.jackhuang.hmcl.i18n import org.jackhuang.hmcl.i18n
import org.jackhuang.hmcl.setting.Settings import org.jackhuang.hmcl.setting.Settings
import org.jackhuang.hmcl.util.Log4jLevel import org.jackhuang.hmcl.util.Log4jLevel
import org.jackhuang.hmcl.util.inc
import org.jackhuang.hmcl.util.readFullyAsString import org.jackhuang.hmcl.util.readFullyAsString
import org.w3c.dom.Document import org.w3c.dom.Document
import org.w3c.dom.Node import org.w3c.dom.Node
import java.util.concurrent.Callable
class LogWindow : Stage() { class LogWindow : Stage() {
val contentPane = WebView() val fatalProperty = SimpleIntegerProperty(0)
val rootPane = StackPane().apply { val errorProperty = SimpleIntegerProperty(0)
children.setAll(contentPane) val warnProperty = SimpleIntegerProperty(0)
} val infoProperty = SimpleIntegerProperty(0)
val engine: WebEngine val debugProperty = SimpleIntegerProperty(0)
lateinit var body: Node
lateinit var document: Document val impl = LogWindowImpl()
init { init {
scene = Scene(rootPane, 800.0, 480.0) scene = Scene(impl, 800.0, 480.0)
scene.stylesheets.addAll(*stylesheets)
title = i18n("logwindow.title") title = i18n("logwindow.title")
icons += Image("/assets/img/icon.png")
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)
}
}
} }
fun logLine(line: String, level: Log4jLevel) { fun logLine(line: String, level: Log4jLevel) {
body.appendChild(contentPane.engine.document.createElement("div").apply { impl.body.appendChild(impl.engine.document.createElement("div").apply {
setAttribute("style", "background-color: #${level.color.toString().substring(2)};")
textContent = line 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<String>
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()")
}
} }
} }

View File

@ -794,6 +794,20 @@
-fx-background-color: null; -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 * * JFX Spinner *

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.layout.*?>
<?import com.jfoenix.controls.JFXButton?>
<?import javafx.scene.web.WebView?>
<?import javafx.scene.control.Label?>
<?import com.jfoenix.controls.JFXComboBox?>
<?import javafx.collections.FXCollections?>
<?import java.lang.String?>
<?import javafx.scene.control.ToggleButton?>
<?import javafx.scene.control.ToggleGroup?>
<fx:root xmlns="http://javafx.com/javafx"
xmlns:fx="http://javafx.com/fxml"
type="StackPane"
style="-fx-background-color: white; -fx-padding: 3 0 3 0;">
<VBox spacing="3">
<BorderPane style="-fx-padding: 0 3 0 3;">
<left>
<HBox alignment="CENTER_LEFT" style="-fx-padding: 0 0 0 4;" spacing="3">
<Label text="%logwindow.show_lines" />
<JFXComboBox fx:id="cboLines">
<items>
<FXCollections fx:factory="observableArrayList">
<String fx:value="500" />
<String fx:value="2000" />
<String fx:value="5000" />
</FXCollections>
</items>
</JFXComboBox>
</HBox>
</left>
<right>
<HBox spacing="3">
<ToggleButton styleClass="log-toggle" style="-fx-background-color: #F7A699;" fx:id="btnFatals" text="0 fatals" selected="true">
<toggleGroup><ToggleGroup /></toggleGroup>
</ToggleButton>
<ToggleButton styleClass="log-toggle" style="-fx-background-color: #FFCCBB;" fx:id="btnErrors" text="0 errors" selected="true">
<toggleGroup><ToggleGroup /></toggleGroup>
</ToggleButton>
<ToggleButton styleClass="log-toggle" style="-fx-background-color: #FFEECC;" fx:id="btnWarns" text="0 warns" selected="true">
<toggleGroup><ToggleGroup /></toggleGroup>
</ToggleButton>
<ToggleButton styleClass="log-toggle" style="-fx-background-color: #FBFBFB;" fx:id="btnInfos" text="0 infos" selected="true">
<toggleGroup><ToggleGroup /></toggleGroup>
</ToggleButton>
<ToggleButton styleClass="log-toggle" style="-fx-background-color: #EEE9E0;" fx:id="btnDebugs" text="0 debugs" selected="true">
<toggleGroup><ToggleGroup /></toggleGroup>
</ToggleButton>
</HBox>
</right>
</BorderPane>
<StackPane style="-fx-border: 1 0 1 0; -fx-border-color: #dddddd;" VBox.vgrow="ALWAYS">
<WebView fx:id="webView" />
</StackPane>
<HBox alignment="CENTER_RIGHT" style="-fx-padding: 0 3 0 3;" spacing="3">
<JFXButton onMouseClicked="#onTerminateGame" text="%logwindow.terminate_game" />
<JFXButton onMouseClicked="#onClear" text="%ui.button.clear" />
</HBox>
</VBox>
</fx:root>

View File

@ -8,6 +8,7 @@
margin: 0; margin: 0;
overflow-x: hidden; overflow-x: hidden;
} }
div { div {
font: ${FONT}; font: ${FONT};
margin: 0px; margin: 0px;
@ -16,10 +17,55 @@
border-width: 0 0 1px 0; border-width: 0 0 1px 0;
border-color: #dddddd; 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; }
</style> </style>
<script> <script>
var colors = ["fatal", "error", "warn", "info", "debug", "trace"]
var limitedLogs = 100;
function appendLog(log, level) {
var e = document.createElement("div");
e.textContent = log;
document.body.appendChild(e);
checkNewLog(level);
}
function checkNewLog(level) {
var e = document.body.lastElementChild;
e.className = level;
redisplay(e);
while (document.body.children.length > limitedLogs)
removeFirst()
}
function scrollToBottom() { function scrollToBottom() {
window.scrollTo(0, document.body.scrollHeight) window.scrollTo(0, document.body.scrollHeight);
}
function removeFirst() {
document.body.removeChild(document.body.firstElementChild);
}
function clear() {
while (document.body.children.length > 0) removeFirst();
}
function specific(newColors) {
colors = newColors;
var c = document.body.children;
for (var i = 0; i < c.length; ++i)
redisplay(c[i]);
}
function redisplay(div) {
var flag = false
for (var j = 0; j < colors.length; ++j) {
if (div.className == colors[j]) {
flag = true;
break;
}
div.hidden = div.className != colors[j];
}
div.hidden = !flag;
} }
</script> </script>
</head> </head>

View File

@ -286,11 +286,11 @@ open class DefaultLauncher(repository: GameRepository, versionId: String, accoun
private fun startMonitors(javaProcess: JavaProcess, processListener: ProcessListener, isDaemon: Boolean = true) { private fun startMonitors(javaProcess: JavaProcess, processListener: ProcessListener, isDaemon: Boolean = true) {
processListener.setProcess(javaProcess) 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 javaProcess.relatedThreads += logHandler
val stdout = thread(name = "stdout-pump", isDaemon = isDaemon, block = StreamPump(javaProcess.process.inputStream, { logHandler.newLine(it) } )::run) val stdout = thread(name = "stdout-pump", isDaemon = isDaemon, block = StreamPump(javaProcess.process.inputStream, { logHandler.newLine(it) } )::run)
javaProcess.relatedThreads += stdout 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 += stderr
javaProcess.relatedThreads += thread(name = "exit-waiter", isDaemon = isDaemon, block = ExitWaiter(javaProcess, listOf(stdout, stderr), { exitCode, exitType -> logHandler.onStopped(); processListener.onExit(exitCode, exitType) })::run) javaProcess.relatedThreads += thread(name = "exit-waiter", isDaemon = isDaemon, block = ExitWaiter(javaProcess, listOf(stdout, stderr), { exitCode, exitType -> logHandler.onStopped(); processListener.onExit(exitCode, exitType) })::run)
} }

View File

@ -15,13 +15,15 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see {http://www.gnu.org/licenses/}. * 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.EVENT_BUS
import org.jackhuang.hmcl.event.JVMLaunchFailedEvent import org.jackhuang.hmcl.event.JVMLaunchFailedEvent
import org.jackhuang.hmcl.event.JavaProcessExitedAbnormallyEvent import org.jackhuang.hmcl.event.JavaProcessExitedAbnormallyEvent
import org.jackhuang.hmcl.event.JavaProcessStoppedEvent 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.* import java.util.*
/** /**
@ -36,10 +38,7 @@ internal class ExitWaiter(val process: JavaProcess, val joins: Collection<Thread
joins.forEach { it.join() } joins.forEach { it.join() }
val exitCode = process.exitCode val exitCode = process.exitCode
val lines = LinkedList<String>() val errorLines = process.lines.filter(::guessLogLineError)
lines.addAll(process.stdErrLines)
lines.addAll(process.stdOutLines)
val errorLines = lines.filter(::guessLogLineError)
val exitType: ProcessListener.ExitType val exitType: ProcessListener.ExitType
// LaunchWrapper will catch the exception logged and will exit normally. // LaunchWrapper will catch the exception logged and will exit normally.
if (exitCode != 0 || errorLines.containsOne("Unable to launch")) { if (exitCode != 0 || errorLines.containsOne("Unable to launch")) {

View File

@ -24,10 +24,12 @@ import org.xml.sax.Attributes
import org.xml.sax.InputSource import org.xml.sax.InputSource
import org.xml.sax.helpers.DefaultHandler import org.xml.sax.helpers.DefaultHandler
import org.xml.sax.helpers.XMLReaderFactory import org.xml.sax.helpers.XMLReaderFactory
import java.io.InterruptedIOException
import java.io.PipedInputStream import java.io.PipedInputStream
import java.io.PipedOutputStream import java.io.PipedOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* 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. * 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 outputStream = PipedOutputStream()
private val inputStream = PipedInputStream(outputStream) private val inputStream = PipedInputStream(outputStream)
private val interrupted = AtomicBoolean(false)
override fun run() { override fun run() {
name = "log4j-handler" name = "log4j-handler"
newLine("<output>") newLine("<output>")
reader.parse(InputSource(inputStream)) try {
reader.parse(InputSource(inputStream))
} catch (e: InterruptedIOException) {
// Game has been interrupted.
interrupted.set(true)
}
} }
fun onStopped() { fun onStopped() {
if (interrupted.get())
return
Scheduler.NEW_THREAD.schedule { Scheduler.NEW_THREAD.schedule {
newLine("</output>")?.get() if (!interrupted.get()) {
outputStream.close() newLine("</output>")?.get()
join() outputStream.close()
join()
}
}!!.get() }!!.get()
} }

View File

@ -28,7 +28,9 @@ interface ProcessListener {
fun setProcess(process: JavaProcess) {} 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 * @param log the log
*/ */

View File

@ -15,8 +15,10 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see {http://www.gnu.org/licenses/}. * 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.IOException
import java.io.InputStream import java.io.InputStream
import java.util.logging.Level import java.util.logging.Level

View File

@ -17,6 +17,7 @@
*/ */
package org.jackhuang.hmcl.util package org.jackhuang.hmcl.util
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
class JavaProcess( class JavaProcess(
@ -24,8 +25,7 @@ class JavaProcess(
val commands: List<String> val commands: List<String>
) { ) {
val properties = mutableMapOf<String, Any>() val properties = mutableMapOf<String, Any>()
val stdOutLines: MutableCollection<String> = ConcurrentLinkedQueue<String>() val lines: Queue<String> = ConcurrentLinkedQueue<String>()
val stdErrLines: MutableCollection<String> = ConcurrentLinkedQueue<String>()
val relatedThreads = mutableListOf<Thread>() val relatedThreads = mutableListOf<Thread>()
val isRunning: Boolean = try { val isRunning: Boolean = try {
process.exitValue() process.exitValue()

View File

@ -18,6 +18,8 @@
package org.jackhuang.hmcl.util package org.jackhuang.hmcl.util
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import javafx.scene.input.Clipboard
import javafx.scene.input.ClipboardContent
import java.io.File import java.io.File
import java.lang.management.ManagementFactory import java.lang.management.ManagementFactory
import java.nio.charset.Charset import java.nio.charset.Charset
@ -65,5 +67,12 @@ enum class OS {
val SYSTEM_VERSION: String by lazy { System.getProperty("os.version") } val SYSTEM_VERSION: String by lazy { System.getProperty("os.version") }
val SYSTEM_ARCH: String by lazy { System.getProperty("os.arch") } val SYSTEM_ARCH: String by lazy { System.getProperty("os.arch") }
fun setClipboard(string: String) {
val clipboard = Clipboard.getSystemClipboard()
clipboard.setContent(ClipboardContent().apply {
putString(string)
})
}
} }
} }