From 8fbd006b494ce4c6ab284d9eeab8692195984151 Mon Sep 17 00:00:00 2001 From: Bryson Steck Date: Sun, 14 May 2023 13:37:55 -0600 Subject: turns out it just doesnt work on linux, reverting and organizing --- .../kotlin/xyz/brysonsteck/serverfordummies/App.kt | 2 +- .../xyz/brysonsteck/serverfordummies/Download.kt | 117 ---- .../serverfordummies/PrimaryController.kt | 618 -------------------- .../serverfordummies/controllers/InfoController.kt | 48 +- .../controllers/PrimaryController.kt | 632 +++++++++++++++++++++ .../serverfordummies/server/Download.kt | 119 ++++ .../brysonsteck/serverfordummies/app-256x256.png | Bin 74811 -> 0 bytes .../xyz/brysonsteck/serverfordummies/app.png | Bin 598693 -> 75026 bytes .../xyz/brysonsteck/serverfordummies/info.fxml | 6 +- .../xyz/brysonsteck/serverfordummies/primary.fxml | 2 +- 10 files changed, 784 insertions(+), 760 deletions(-) delete mode 100644 app/src/main/kotlin/xyz/brysonsteck/serverfordummies/Download.kt delete mode 100644 app/src/main/kotlin/xyz/brysonsteck/serverfordummies/PrimaryController.kt create mode 100644 app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/PrimaryController.kt create mode 100644 app/src/main/kotlin/xyz/brysonsteck/serverfordummies/server/Download.kt delete mode 100644 app/src/main/resources/xyz/brysonsteck/serverfordummies/app-256x256.png diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/App.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/App.kt index 0a3cab9..3ff7c46 100644 --- a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/App.kt +++ b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/App.kt @@ -15,7 +15,7 @@ class App : Application() { override fun start(stage: Stage) { var scene = Scene(loadFXML("primary"), 963.0, 713.0) - stage.icons.add(Image(this.javaClass.getResourceAsStream("app-256x256.png"))) + stage.icons.add(Image(this.javaClass.getResourceAsStream("app.png"))) stage.setResizable(false) stage.title = "Server For Dummies" stage.scene = scene diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/Download.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/Download.kt deleted file mode 100644 index b98b511..0000000 --- a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/Download.kt +++ /dev/null @@ -1,117 +0,0 @@ -import java.io.*; -import java.net.*; -import java.util.*; - -class Download: Runnable { - public enum class Status { - DOWNLOADING, PAUSED, COMPLETE, CANCELLED, ERROR - } - public var size: Int - public var downloaded: Int - public var contentLength: Int - public var status: Status - - private final val MAX_BUFFER_SIZE: Number = 1024 - - private var url: URL - private var dir: String - - constructor (url: URL, dir: String) { - this.url = url - this.dir = dir - size = -1 - downloaded = 0 - status = Status.DOWNLOADING - contentLength = 1 - } - - public fun start() { - val thread = Thread(this) - thread.start() - } - - private fun getFilename(url: URL): String { - val filename = url.getFile() - return filename.substring(filename.lastIndexOf('/') + 1) - } - - override fun run() { - var stream: InputStream? = null - var file: RandomAccessFile? = null - - try { - // Open connection to URL. - var connection = url.openConnection() as HttpURLConnection; - - // Specify what portion of file to download. - connection.setRequestProperty("Range", "bytes=" + downloaded + "-"); - - // Connect to server. - connection.connect(); - - // Make sure response code is in the 200 range. - if (connection.responseCode / 100 != 2) { - status = Status.ERROR - } - - // Check for valid content length. - contentLength = connection.getContentLength(); - if (contentLength < 1) { - status = Status.ERROR - } - - /* Set the size for this download if it - hasn't been already set. */ - if (size == -1) { - size = contentLength; - } - - // Open file and seek to the end of it. - file = RandomAccessFile(dir + getFilename(url), "rw"); - file.seek(downloaded.toLong()); - - stream = connection.getInputStream(); - while (status == Status.DOWNLOADING) { - /* Size buffer according to how much of the - file is left to download. */ - val buffer: ByteArray; - if (size - downloaded > MAX_BUFFER_SIZE as Int) { - buffer = ByteArray(MAX_BUFFER_SIZE) - } else { - buffer = ByteArray(size - downloaded); - } - - // Read from server into buffer. - val read = stream.read(buffer); - if (read == -1) - break; - - // Write buffer to file. - file.write(buffer, 0, read); - downloaded += read; - } - - /* Change status to complete if this point was - reached because downloading has finished. */ - if (status == Status.DOWNLOADING) { - status = Status.COMPLETE; - } - } catch (e: Exception) { - status = Status.ERROR - } finally { - // Close file. - if (file != null) { - try { - file.close(); - } catch (e: Exception) {} - } - - // Close connection to server. - if (stream != null) { - try { - stream.close(); - } catch (e: Exception) {} - } - } - } -} diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/PrimaryController.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/PrimaryController.kt deleted file mode 100644 index f5eab39..0000000 --- a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/PrimaryController.kt +++ /dev/null @@ -1,618 +0,0 @@ -package xyz.brysonsteck.serverfordummies - -import kotlinx.coroutines.* -import kotlinx.coroutines.javafx.JavaFx -import org.rauschig.jarchivelib.* - -import java.io.File -import java.io.IOException -import java.io.BufferedReader -import java.io.InputStreamReader -import java.awt.Checkbox -import java.awt.Desktop -import java.util.Properties -import java.net.URL -import java.net.URI - -import javafx.beans.value.ChangeListener -import javafx.beans.value.ObservableValue -import javafx.concurrent.Task -import javafx.beans.property.BooleanProperty -import javafx.collections.FXCollections -import javafx.fxml.FXML -import javafx.fxml.FXMLLoader -import javafx.geometry.Insets -import javafx.scene.control.Button -import javafx.scene.control.ChoiceBox -import javafx.scene.control.Label -import javafx.scene.control.TextField -import javafx.scene.control.Spinner -import javafx.scene.control.TitledPane -import javafx.scene.control.ButtonBar -import javafx.scene.control.CheckBox -import javafx.scene.control.ProgressBar -import javafx.scene.control.Hyperlink -import javafx.scene.layout.Border -import javafx.scene.layout.BorderStroke -import javafx.scene.layout.GridPane -import javafx.scene.layout.Pane -import javafx.scene.layout.HBox -import javafx.scene.layout.VBox -import javafx.scene.text.TextAlignment -import javafx.scene.text.Text -import javafx.scene.Scene -import javafx.scene.input.MouseEvent -import javafx.scene.image.Image -import javafx.scene.image.ImageView -import javafx.stage.FileChooser -import javafx.stage.FileChooser.ExtensionFilter -import javafx.stage.DirectoryChooser -import javafx.stage.Modality -import javafx.stage.Stage -import javafx.event.EventHandler -import org.rauschig.jarchivelib.* - -import Download -import xyz.brysonsteck.serverfordummies.server.Server -import xyz.brysonsteck.serverfordummies.App - -class PrimaryController { - @FXML - lateinit private var currentDirectoryLabel: Label - @FXML - lateinit private var worldNameField: TextField - @FXML - lateinit private var seedField: TextField - @FXML - lateinit private var portSpinner: Spinner - @FXML - lateinit private var difficultyBox: ChoiceBox - @FXML - lateinit private var gamemodeBox: ChoiceBox - @FXML - lateinit private var worldTypeBox: ChoiceBox - @FXML - lateinit private var worldSettingsPane: HBox - @FXML - lateinit private var parentPane: Pane - @FXML - lateinit private var directoryPane: Pane - @FXML - lateinit private var buttonBar: ButtonBar - @FXML - lateinit private var flightCheckbox: CheckBox - @FXML - lateinit private var netherCheckbox: CheckBox - @FXML - lateinit private var structuresCheckbox: CheckBox - @FXML - lateinit private var pvpCheckbox: CheckBox - @FXML - lateinit private var whitelistCheckbox: CheckBox - @FXML - lateinit private var cmdBlocksCheckbox: CheckBox - @FXML - lateinit private var playerCountCheckbox: CheckBox - @FXML - lateinit private var maxPlayersSpinner: Spinner - @FXML - lateinit private var maxSizeSpinner: Spinner - @FXML - lateinit private var memorySpinner: Spinner - @FXML - lateinit private var spawnSpinner: Spinner - @FXML - lateinit private var simulationSpinner: Spinner - @FXML - lateinit private var renderSpinner: Spinner - @FXML - lateinit private var maxTickSpinner: Spinner - @FXML - lateinit private var statusBar: Label - @FXML - lateinit private var progressBar: ProgressBar - @FXML - lateinit private var startButton: Button - @FXML - lateinit private var buildButton: Button - @FXML - lateinit private var defaultsButton: Button - - lateinit private var server: Server - private var building = false - private var directory = "" - private var asyncResult = false - private var started = false - - @FXML - public fun initialize() { - difficultyBox.items = FXCollections.observableArrayList( - "Peaceful", - "Easy", - "Normal", - "Hard", - "Hardcore" - ) - difficultyBox.value = "Normal" - difficultyBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> - onChoiceBoxChange("difficulty", difficultyBox.items[new as Int]) - } - gamemodeBox.items = FXCollections.observableArrayList( - "Survival", - "Creative", - "Adventure", - "Spectator" - ) - gamemodeBox.value = "Survival" - gamemodeBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> - onChoiceBoxChange("gamemode", gamemodeBox.items[new as Int]) - } - worldTypeBox.items = FXCollections.observableArrayList( - "Normal", - "Superflat", - "Large Biomes", - "Amplified" - ) - worldTypeBox.value = "Normal" - worldTypeBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> - onChoiceBoxChange("world-type", worldTypeBox.items[new as Int]) - } - } - - @FXML - private fun onDirectoryButtonClick() { - val dirChooser = DirectoryChooser() - dirChooser.title = "Open a server directory" - dirChooser.initialDirectory = File(System.getProperty("user.home")) - val result = dirChooser.showDialog(null) - if (result != null) { - currentDirectoryLabel.text = result.absolutePath - server = Server() - val res = loadServerDir(result.absolutePath) - if (res) { - parentPane.isDisable = false - worldSettingsPane.isDisable = false - buildButton.isDisable = false - defaultsButton.isDisable = false - } else { - currentDirectoryLabel.text = "" - parentPane.isDisable = true - worldSettingsPane.isDisable = true - startButton.isDisable = true - buildButton.isDisable = true - defaultsButton.isDisable = true - } - } - } - - @FXML - private fun onWorldNameChange() { - - } - - @FXML - private fun onSeedChange() { - - } - - @FXML - private fun onPortChange() { - - } - - @FXML - private fun onCheckboxClick() { - - } - - @FXML - private fun onSpinnerChange() { - - } - - private fun onChoiceBoxChange(box: String, selection: String) { - - } - - @FXML - private fun onInfo() { - val stage = Stage() - val scene = Scene(FXMLLoader(App().javaClass.getResource("info.fxml")).load(), 398.0, 358.0) - stage.icons.add(Image(App().javaClass.getResourceAsStream("app-256x256.png"))) - stage.setResizable(false) - stage.initModality(Modality.APPLICATION_MODAL); - stage.title = "About ServerCraft" - stage.scene = scene - stage.show() - } - - @FXML - private fun onBuild() { - if (building) { - building = false - return; - } - building = true - worldSettingsPane.isDisable = true - directoryPane.isDisable = true - parentPane.isDisable = true - startButton.isDisable = true - defaultsButton.isDisable = true - buildButton.text = "Cancel Build" - - @Suppress("OPT_IN_USAGE") - GlobalScope.launch(Dispatchers.Default) { - progressBar.isVisible = true - var javaFile = "" - var archiver = ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) - val os = System.getProperty("os.name").lowercase() - when { - os.contains("win") -> { - javaFile = "openjdk-20.0.1_windows-x64_bin.zip" - archiver = ArchiverFactory.createArchiver(ArchiveFormat.ZIP) - } - os.contains("linux") -> { - javaFile = "openjdk-20.0.1_linux-x64_bin.tar.gz" - } - os.contains("mac") -> { - javaFile = "openjdk-20.0.1_macos-x64_bin.tar.gz" - } - } - - // download files - val downloads = mapOf( - "Java 20" to "https://download.java.net/java/GA/jdk20.0.1/b4887098932d415489976708ad6d1a4b/9/GPL/${javaFile}", - "BuildTools" to "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar", - ) - val destinations = mapOf ( - "Java 20" to directory + "ServerForDummies" + File.separator + "Java" + File.separator, - "BuildTools" to directory + "ServerForDummies" + File.separator + "Spigot" + File.separator - ) - val spigotBuilt = File(destinations["BuildTools"]).exists() - val javaExtracted = File(destinations["Java 20"] + "jdk-20.0.1").exists() - destinations.forEach { - File(it.value).mkdir() - } - downloads.forEach { - if (it.key == "Java 20" && javaExtracted) { - return@forEach - } - withContext(Dispatchers.JavaFx){ - statusBar.text = "Downloading ${it.key}..." - progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS - } - val download = Download(URL(it.value), destinations[it.key]!!) - download.start() - while (download.status == Download.Status.DOWNLOADING) { - var prog = (download.downloaded.toDouble() / download.contentLength.toDouble()) - // for whatever reason I need to print something to the screen in order for it to update the progress bar - print("") - if (prog >= 0.01) { - withContext(Dispatchers.JavaFx) {progressBar.progress = prog} - } - if (!building) download.status = Download.Status.CANCELLED - Thread.sleep(300) - } - } - - // extract java archive - if (building && !javaExtracted) { - withContext(Dispatchers.JavaFx) { - progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS - statusBar.text = "Extracting Java archive..." - } - var stream = archiver.stream(File(directory + "ServerForDummies" + File.separator + "Java" + File.separator + javaFile)) - val dest = File(directory + "ServerForDummies" + File.separator + "Java") - var entries = 0.0 - while(stream.getNextEntry() != null && building) { - entries++ - } - stream = archiver.stream(File(directory + "ServerForDummies" + File.separator + "Java" + File.separator + javaFile)) - var entry = stream.getNextEntry() - var currentEntry = 0.0 - do { - withContext(Dispatchers.JavaFx) {progressBar.progress = currentEntry/entries} - entry.extract(dest) - entry = stream.getNextEntry() - currentEntry++ - } while (entry != null && building) - } - - if (building) { - withContext(Dispatchers.JavaFx) { - progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS - statusBar.text = "Building Minecraft Server..." - } - val builder = ProcessBuilder("java", "-jar", "BuildTools.jar", "--rev", "latest", "-o", ".." + File.separator + ".." + File.separator) - builder.directory(File(directory + "ServerForDummies" + File.separator + "Spigot")) - val proc = builder.start() - val reader = InputStreamReader(proc.inputStream) - val br = BufferedReader(reader) - try { - var line = br.readLine() - var currentline = 0.0 - while (line != null) { - if (!building) { - proc.destroy() - } - println(line) - line = br.readLine() - currentline++ - if (currentline > 15) { - withContext(Dispatchers.JavaFx) {progressBar.progress = if (spigotBuilt) {currentline/1100.0} else {currentline/14122.0} } - } - } - } catch (e: IOException) { - println("Stream closed") - } - } - - progressBar.isVisible = false - withContext(Dispatchers.JavaFx){ - worldSettingsPane.isDisable = false - directoryPane.isDisable = false - parentPane.isDisable = false - defaultsButton.isDisable = false - buildButton.text = "Build Server" - statusBar.text = if (building) { - findServerJar() - buildButton.text = "Rebuild Server" - startButton.isDisable = false - "Ready." - } else { - "Server build cancelled." - } - building = false; - } - } - } - - @FXML - private fun onStart() { - if (started) { - createDialog("warning", "You should only kill the server if\nabsolutely necessary. Data loss may occur.\nContinue anyway?", "Yes", "No", false) - return; - } - if (!File(directory + "eula.txt").exists()) { - val res = eulaDialog() - if (res) { - File(directory + "eula.txt").writeText("eula=true") - } else { - return; - } - } - started = true - statusBar.text = "The Minecraft Server is now running. Shutdown the server to unlock the settings." - worldSettingsPane.isDisable = true - directoryPane.isDisable = true - parentPane.isDisable = true - buildButton.isDisable = true - defaultsButton.isDisable = true - startButton.text = "Kill Server" - @Suppress("OPT_IN_USAGE") - GlobalScope.launch(Dispatchers.Default) { - val builder = ProcessBuilder("java", "-jar", "${server.jar}") - builder.directory(File(directory)) - val proc = builder.start() - val reader = InputStreamReader(proc.inputStream) - val br = BufferedReader(reader) - try { - var line = br.readLine() - while (line != null) { - if (asyncResult) { - proc.destroy() - } - println(line); - line = br.readLine() - } - } catch (e: IOException) { - println("Stream closed") - } - withContext(Dispatchers.JavaFx) { - statusBar.text = if (asyncResult) { - asyncResult = false - "Server killed." - } else { - "Server stopped." - } - worldSettingsPane.isDisable = false - directoryPane.isDisable = false - parentPane.isDisable = false - buildButton.isDisable = false - defaultsButton.isDisable = false - startButton.text = "Start Server" - started = false - } - } - } - - private fun eulaDialog(): Boolean { - var result = false - val resources = App().javaClass.getResource("icons/warning.png") - val dialog = Stage() - dialog.setResizable(false) - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.title = directory - val scenePane = Pane() - val dialogScene = Scene(scenePane, 400.0, 150.0); - val imagePane = Pane() - val icon = Image("$resources") - imagePane.layoutX = 14.0 - imagePane.layoutY = 14.0 - imagePane.scaleX = 0.7 - imagePane.scaleY = 0.7 - imagePane.children.add(ImageView(icon)) - val label = Label("Do you agree to the terms of the Minecraft End User License Agreement?") - label.isWrapText = true - label.layoutX = 115.0 - label.layoutY = 40.0 - val buttonBar = ButtonBar() - buttonBar.buttonOrder = "L+R" - buttonBar.padding = Insets(10.0, 10.0, 10.0, 10.0) - buttonBar.layoutX = 0.0 - buttonBar.layoutY = 107.0 - buttonBar.prefWidth = 400.0 - val noButton = Button("I Disagree") - noButton.onMouseClicked = EventHandler() { - result = false - dialog.hide() - } - noButton.isDefaultButton = true - val yesButton = Button("I Agree") - yesButton.onMouseClicked = EventHandler() { - result = true - dialog.hide() - } - val eula = Button("View EULA") - eula.onMouseClicked = EventHandler() { - val desktop = Desktop.getDesktop() - if (desktop.isSupported(Desktop.Action.BROWSE)) { - println("made it!") - try { - desktop.browse(URI("https://account.mojang.com/documents/minecraft_eula")) - } catch (e: Exception) { - println(e) - } - } - } - ButtonBar.setButtonData(eula, ButtonBar.ButtonData.LEFT) - ButtonBar.setButtonData(noButton, ButtonBar.ButtonData.RIGHT) - ButtonBar.setButtonData(yesButton, ButtonBar.ButtonData.RIGHT) - buttonBar.buttons.add(eula) - buttonBar.buttons.add(noButton) - buttonBar.buttons.add(yesButton) - scenePane.children.addAll(imagePane, label, buttonBar) - dialog.setScene(dialogScene); - dialog.showAndWait(); - return result - } - - private fun createDialog(type: String, msg: String, yes: String, no: String, hold: Boolean): Boolean { - var result = false - val resources = App().javaClass.getResource("icons/$type.png") - val dialog = Stage() - dialog.setResizable(false) - dialog.initModality(Modality.APPLICATION_MODAL); - dialog.title = directory - val scenePane = Pane() - val dialogScene = Scene(scenePane, 400.0, 150.0); - val imagePane = Pane() - val icon = Image("$resources") - imagePane.layoutX = 14.0 - imagePane.layoutY = 14.0 - imagePane.scaleX = 0.7 - imagePane.scaleY = 0.7 - imagePane.children.add(ImageView(icon)) - val label = Label(msg) - label.isWrapText = true - label.layoutX = 115.0 - label.layoutY = if (type == "warning") {10.0} else {40.0} - val buttonBar = ButtonBar() - buttonBar.padding = Insets(10.0, 10.0, 10.0, 10.0) - buttonBar.layoutX = 0.0 - buttonBar.layoutY = 107.0 - buttonBar.prefWidth = 400.0 - val noButton = Button(no) - noButton.onMouseClicked = EventHandler() { - if (hold) { - result = false - } else { - asyncResult = false - } - dialog.hide() - } - val yesButton = Button(yes) - yesButton.onMouseClicked = EventHandler() { - if (hold) { - result = true - } else { - asyncResult = true - } - dialog.hide() - } - yesButton.isDefaultButton = true - buttonBar.buttons.add(noButton) - buttonBar.buttons.add(yesButton) - scenePane.children.addAll(imagePane, label, buttonBar) - dialog.setScene(dialogScene); - if (hold) { - dialog.showAndWait(); - } else { - dialog.show(); - } - return result - } - - private fun loadServerDir(dir: String): Boolean { - directory = dir - if (!File(directory).isDirectory) { - return false; - } - - if (directory[directory.length-1] != File.separatorChar) - directory += File.separatorChar - - val hasDummy = File(directory + "ServerForDummies").isDirectory - val hasProperties = File(directory + File.separator + "server.properties").isFile - val hasServer = findServerJar() - - if (hasDummy && hasServer) { - // server complete, just read jproperties - statusBar.text = "Server found!" - startButton.isDisable = false - buildButton.text = "Rebuild Server" - } else if (hasDummy && !hasServer && hasProperties) { - // just needs to be built - startButton.isDisable = true - statusBar.text = "Server needs to be built before starting." - } else if (!hasDummy && hasServer) { - // server created externally - val result = createDialog("warning", "This server directory was not created by \nServerForDummies. Errors may occur; copying\nthe world directories to a new folder may be\nsafer. Proceed anyway?", "Yes", "No", true) - statusBar.text = "Ready." - if (result) { - startButton.isDisable = false - } - return result - } else { - // assume clean directory - val result = createDialog("info", "There is no server in this directory.\nCreate one?", "Yes", "No", true) - if (result) { - File(directory + "ServerForDummies").mkdir() - startButton.isDisable = true - buildButton.text = "Build Server" - } - statusBar.text = "Ready." - return result - } - - return true; - } - - private fun findServerJar(): Boolean { - // search for spigot jar - // major version - for (i in 25 downTo 8) { - // patch number - for (j in 15 downTo 0) { - var spigotFile: String = "" - if (j == 0) - spigotFile += "spigot-1.$i.jar" - else - spigotFile += "spigot-1.$i.$j.jar"; - - if (File(directory + spigotFile).isFile) { - server.jar = directory + spigotFile - return true - } - } - } - - // try vanilla server if no spigot server - if (File(directory + "server.jar").isFile) { - server.jar = directory + "server.jar" - return true - } - - return false - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/InfoController.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/InfoController.kt index 5f98c83..a0b7915 100644 --- a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/InfoController.kt +++ b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/InfoController.kt @@ -23,38 +23,46 @@ class InfoController { private fun openHyperlink(e: ActionEvent) { val link = e.source as Hyperlink link.isVisited = false - val split = link.id.split('_') + val split = link.id.split('_').toMutableList() + split.add("") + val os = System.getProperty("os.name").lowercase() val desktop = Desktop.getDesktop() - if (desktop.isSupported(Desktop.Action.BROWSE)) { - println("hi") - try { - when { - split[1].equals("email") -> { - println("email") - desktop.browse(URI("mailto:" + websites[split[0]])) + try { + when { + split[1].equals("email") -> { + if (!os.contains("linux")) { + desktop.browse(URI("mailto:" + emails[split[0]])) + } else { + Runtime.getRuntime().exec("xdg-open mailto:" + emails[split[0]]) } - split[1].equals("website") -> { - println("website") + } + split[1].equals("website") -> { + if (!os.contains("linux")) { desktop.browse(URI(websites[split[0]])) + } else { + Runtime.getRuntime().exec("xdg-open " + websites[split[0]]) } - split[0].equals("source") -> { - println("source") + } + split[0].equals("source") -> { + if (!os.contains("linux")) { desktop.browse(URI(source)) + } else { + Runtime.getRuntime().exec("xdg-open " + source) } - split[0].equals("license") -> { - println("license") + } + split[0].equals("license") -> { + println("license") + if (!os.contains("linux")) { desktop.browse(URI(license)) - } - else -> { - println("unknown") + } else { + Runtime.getRuntime().exec("xdg-open " + license) } } - } catch (e: Exception) { - println(e) } + } catch (e: Exception) { + println(e) } - println("done") } @FXML diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/PrimaryController.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/PrimaryController.kt new file mode 100644 index 0000000..81a860e --- /dev/null +++ b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/controllers/PrimaryController.kt @@ -0,0 +1,632 @@ +package xyz.brysonsteck.serverfordummies.controllers + +import kotlinx.coroutines.* +import kotlinx.coroutines.javafx.JavaFx +import org.rauschig.jarchivelib.* + +import java.io.File +import java.io.IOException +import java.io.BufferedReader +import java.io.InputStreamReader +import java.awt.Checkbox +import java.awt.Desktop +import java.util.Properties +import java.net.URL +import java.net.URI + +import javafx.beans.value.ChangeListener +import javafx.beans.value.ObservableValue +import javafx.concurrent.Task +import javafx.beans.property.BooleanProperty +import javafx.collections.FXCollections +import javafx.fxml.FXML +import javafx.fxml.FXMLLoader +import javafx.geometry.Insets +import javafx.scene.control.Button +import javafx.scene.control.ChoiceBox +import javafx.scene.control.Label +import javafx.scene.control.TextField +import javafx.scene.control.Spinner +import javafx.scene.control.TitledPane +import javafx.scene.control.ButtonBar +import javafx.scene.control.CheckBox +import javafx.scene.control.ProgressBar +import javafx.scene.control.Hyperlink +import javafx.scene.layout.Border +import javafx.scene.layout.BorderStroke +import javafx.scene.layout.GridPane +import javafx.scene.layout.Pane +import javafx.scene.layout.HBox +import javafx.scene.layout.VBox +import javafx.scene.text.TextAlignment +import javafx.scene.text.Text +import javafx.scene.Scene +import javafx.scene.input.MouseEvent +import javafx.scene.image.Image +import javafx.scene.image.ImageView +import javafx.stage.FileChooser +import javafx.stage.FileChooser.ExtensionFilter +import javafx.stage.DirectoryChooser +import javafx.stage.Modality +import javafx.stage.Stage +import javafx.event.EventHandler +import org.rauschig.jarchivelib.* + +import xyz.brysonsteck.serverfordummies.server.Server +import xyz.brysonsteck.serverfordummies.server.Download +import xyz.brysonsteck.serverfordummies.App + +class PrimaryController { + @FXML + lateinit private var currentDirectoryLabel: Label + @FXML + lateinit private var worldNameField: TextField + @FXML + lateinit private var seedField: TextField + @FXML + lateinit private var portSpinner: Spinner + @FXML + lateinit private var difficultyBox: ChoiceBox + @FXML + lateinit private var gamemodeBox: ChoiceBox + @FXML + lateinit private var worldTypeBox: ChoiceBox + @FXML + lateinit private var worldSettingsPane: HBox + @FXML + lateinit private var parentPane: Pane + @FXML + lateinit private var directoryPane: Pane + @FXML + lateinit private var buttonBar: ButtonBar + @FXML + lateinit private var flightCheckbox: CheckBox + @FXML + lateinit private var netherCheckbox: CheckBox + @FXML + lateinit private var structuresCheckbox: CheckBox + @FXML + lateinit private var pvpCheckbox: CheckBox + @FXML + lateinit private var whitelistCheckbox: CheckBox + @FXML + lateinit private var cmdBlocksCheckbox: CheckBox + @FXML + lateinit private var playerCountCheckbox: CheckBox + @FXML + lateinit private var maxPlayersSpinner: Spinner + @FXML + lateinit private var maxSizeSpinner: Spinner + @FXML + lateinit private var memorySpinner: Spinner + @FXML + lateinit private var spawnSpinner: Spinner + @FXML + lateinit private var simulationSpinner: Spinner + @FXML + lateinit private var renderSpinner: Spinner + @FXML + lateinit private var maxTickSpinner: Spinner + @FXML + lateinit private var statusBar: Label + @FXML + lateinit private var progressBar: ProgressBar + @FXML + lateinit private var startButton: Button + @FXML + lateinit private var buildButton: Button + @FXML + lateinit private var defaultsButton: Button + + lateinit private var server: Server + private var building = false + private var directory = "" + private var asyncResult = false + private var started = false + + @FXML + public fun initialize() { + difficultyBox.items = FXCollections.observableArrayList( + "Peaceful", + "Easy", + "Normal", + "Hard", + "Hardcore" + ) + difficultyBox.value = "Normal" + difficultyBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> + onChoiceBoxChange("difficulty", difficultyBox.items[new as Int]) + } + gamemodeBox.items = FXCollections.observableArrayList( + "Survival", + "Creative", + "Adventure", + "Spectator" + ) + gamemodeBox.value = "Survival" + gamemodeBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> + onChoiceBoxChange("gamemode", gamemodeBox.items[new as Int]) + } + worldTypeBox.items = FXCollections.observableArrayList( + "Normal", + "Superflat", + "Large Biomes", + "Amplified" + ) + worldTypeBox.value = "Normal" + worldTypeBox.selectionModel.selectedIndexProperty().addListener { _, _, new -> + onChoiceBoxChange("world-type", worldTypeBox.items[new as Int]) + } + } + + @FXML + private fun onDirectoryButtonClick() { + val dirChooser = DirectoryChooser() + dirChooser.title = "Open a server directory" + dirChooser.initialDirectory = File(System.getProperty("user.home")) + val result = dirChooser.showDialog(null) + if (result != null) { + currentDirectoryLabel.text = result.absolutePath + server = Server() + val res = loadServerDir(result.absolutePath) + if (res) { + parentPane.isDisable = false + worldSettingsPane.isDisable = false + buildButton.isDisable = false + defaultsButton.isDisable = false + } else { + currentDirectoryLabel.text = "" + parentPane.isDisable = true + worldSettingsPane.isDisable = true + startButton.isDisable = true + buildButton.isDisable = true + defaultsButton.isDisable = true + } + } + } + + @FXML + private fun onWorldNameChange() { + + } + + @FXML + private fun onSeedChange() { + + } + + @FXML + private fun onPortChange() { + + } + + @FXML + private fun onCheckboxClick() { + + } + + @FXML + private fun onSpinnerChange() { + + } + + private fun onChoiceBoxChange(box: String, selection: String) { + + } + + @FXML + private fun onInfo() { + val stage = Stage() + val scene = Scene(FXMLLoader(App().javaClass.getResource("info.fxml")).load(), 398.0, 358.0) + stage.icons.add(Image(App().javaClass.getResourceAsStream("app.png"))) + stage.setResizable(false) + stage.initModality(Modality.APPLICATION_MODAL); + stage.title = "About ServerCraft" + stage.scene = scene + stage.show() + } + + @FXML + private fun onBuild() { + if (building) { + building = false + return; + } + building = true + worldSettingsPane.isDisable = true + directoryPane.isDisable = true + parentPane.isDisable = true + startButton.isDisable = true + defaultsButton.isDisable = true + buildButton.text = "Cancel Build" + + @Suppress("OPT_IN_USAGE") + GlobalScope.launch(Dispatchers.Default) { + progressBar.isVisible = true + var javaFile = "" + var archiver = ArchiverFactory.createArchiver(ArchiveFormat.TAR, CompressionType.GZIP) + val os = System.getProperty("os.name").lowercase() + when { + os.contains("win") -> { + javaFile = "openjdk-20.0.1_windows-x64_bin.zip" + archiver = ArchiverFactory.createArchiver(ArchiveFormat.ZIP) + } + os.contains("linux") -> { + javaFile = "openjdk-20.0.1_linux-x64_bin.tar.gz" + } + os.contains("mac") -> { + javaFile = "openjdk-20.0.1_macos-x64_bin.tar.gz" + } + } + + // download files + val downloads = mapOf( + "Java 20" to "https://download.java.net/java/GA/jdk20.0.1/b4887098932d415489976708ad6d1a4b/9/GPL/${javaFile}", + "BuildTools" to "https://hub.spigotmc.org/jenkins/job/BuildTools/lastSuccessfulBuild/artifact/target/BuildTools.jar", + ) + val destinations = mapOf ( + "Java 20" to directory + "ServerForDummies" + File.separator + "Java" + File.separator, + "BuildTools" to directory + "ServerForDummies" + File.separator + "Spigot" + File.separator + ) + val spigotBuilt = File(destinations["BuildTools"]).exists() + val javaExtracted = File(destinations["Java 20"] + "jdk-20.0.1").exists() + destinations.forEach { + File(it.value).mkdir() + } + downloads.forEach { + if (it.key == "Java 20" && javaExtracted) { + return@forEach + } + withContext(Dispatchers.JavaFx){ + statusBar.text = "Downloading ${it.key}..." + progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS + } + val download = Download(URL(it.value), destinations[it.key]!!) + download.start() + while (download.status == Download.Status.DOWNLOADING) { + var prog = (download.downloaded.toDouble() / download.contentLength.toDouble()) + // for whatever reason I need to print something to the screen in order for it to update the progress bar + print("") + if (prog >= 0.01) { + withContext(Dispatchers.JavaFx) {progressBar.progress = prog} + } + if (!building) download.status = Download.Status.CANCELLED + Thread.sleep(300) + } + } + + // extract java archive + if (building && !javaExtracted) { + withContext(Dispatchers.JavaFx) { + progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS + statusBar.text = "Extracting Java archive..." + } + var stream = archiver.stream(File(directory + "ServerForDummies" + File.separator + "Java" + File.separator + javaFile)) + val dest = File(directory + "ServerForDummies" + File.separator + "Java") + var entries = 0.0 + while(stream.getNextEntry() != null && building) { + entries++ + } + stream = archiver.stream(File(directory + "ServerForDummies" + File.separator + "Java" + File.separator + javaFile)) + var entry = stream.getNextEntry() + var currentEntry = 0.0 + do { + withContext(Dispatchers.JavaFx) {progressBar.progress = currentEntry/entries} + entry.extract(dest) + entry = stream.getNextEntry() + currentEntry++ + } while (entry != null && building) + } + + if (building) { + withContext(Dispatchers.JavaFx) { + progressBar.progress = ProgressBar.INDETERMINATE_PROGRESS + statusBar.text = "Building Minecraft Server..." + } + val builder = ProcessBuilder("java", "-jar", "BuildTools.jar", "--rev", "latest", "-o", ".." + File.separator + ".." + File.separator) + builder.directory(File(directory + "ServerForDummies" + File.separator + "Spigot")) + val proc = builder.start() + val reader = InputStreamReader(proc.inputStream) + val br = BufferedReader(reader) + try { + var line = br.readLine() + var currentline = 0.0 + while (line != null) { + if (!building) { + proc.destroy() + } + println(line) + line = br.readLine() + currentline++ + if (currentline > 15) { + withContext(Dispatchers.JavaFx) {progressBar.progress = if (spigotBuilt) {currentline/1100.0} else {currentline/14122.0} } + } + } + } catch (e: IOException) { + println("Stream closed") + } + } + + progressBar.isVisible = false + withContext(Dispatchers.JavaFx){ + worldSettingsPane.isDisable = false + directoryPane.isDisable = false + parentPane.isDisable = false + defaultsButton.isDisable = false + buildButton.text = "Build Server" + statusBar.text = if (building) { + findServerJar() + buildButton.text = "Rebuild Server" + startButton.isDisable = false + "Ready." + } else { + "Server build cancelled." + } + building = false; + } + } + } + + @FXML + private fun onStart() { + if (started) { + createDialog("warning", "You should only kill the server if\nabsolutely necessary. Data loss may occur.\nContinue anyway?", "Yes", "No", false) + return; + } + if (!File(directory + "eula.txt").exists()) { + val res = eulaDialog() + if (res) { + File(directory + "eula.txt").writeText("eula=true") + } else { + return; + } + } + started = true + statusBar.text = "The Minecraft Server is now running. Shutdown the server to unlock the settings." + worldSettingsPane.isDisable = true + directoryPane.isDisable = true + parentPane.isDisable = true + buildButton.isDisable = true + defaultsButton.isDisable = true + startButton.text = "Kill Server" + @Suppress("OPT_IN_USAGE") + GlobalScope.launch(Dispatchers.Default) { + val builder = ProcessBuilder("java", "-jar", "${server.jar}") + builder.directory(File(directory)) + val proc = builder.start() + val reader = InputStreamReader(proc.inputStream) + val br = BufferedReader(reader) + try { + var line = br.readLine() + while (line != null) { + if (asyncResult) { + withContext(Dispatchers.JavaFx) { + statusBar.text = "Killing Minecraft server..." + startButton.isDisable = true + } + proc.destroy() + } + println(line); + line = br.readLine() + } + } catch (e: IOException) { + println("Stream closed") + } + withContext(Dispatchers.JavaFx) { + statusBar.text = if (asyncResult) { + asyncResult = false + "Server killed." + } else { + "Server stopped." + } + worldSettingsPane.isDisable = false + directoryPane.isDisable = false + parentPane.isDisable = false + buildButton.isDisable = false + defaultsButton.isDisable = false + startButton.isDisable = false + startButton.text = "Start Server" + started = false + } + } + } + + private fun eulaDialog(): Boolean { + var result = false + val resources = App().javaClass.getResource("icons/warning.png") + val dialog = Stage() + dialog.icons.add(Image(this.javaClass.getResourceAsStream("app.png"))) + dialog.setResizable(false) + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.title = directory + val scenePane = Pane() + val dialogScene = Scene(scenePane, 400.0, 150.0); + val imagePane = Pane() + val icon = Image("$resources") + imagePane.layoutX = 14.0 + imagePane.layoutY = 14.0 + imagePane.scaleX = 0.7 + imagePane.scaleY = 0.7 + imagePane.children.add(ImageView(icon)) + val label = Label("Do you agree to the terms of the Minecraft End User License Agreement?") + label.isWrapText = true + label.layoutX = 115.0 + label.layoutY = 40.0 + val buttonBar = ButtonBar() + buttonBar.buttonOrder = "L+R" + buttonBar.padding = Insets(10.0, 10.0, 10.0, 10.0) + buttonBar.layoutX = 0.0 + buttonBar.layoutY = 107.0 + buttonBar.prefWidth = 400.0 + val noButton = Button("I Disagree") + noButton.onMouseClicked = EventHandler() { + result = false + dialog.hide() + } + noButton.isDefaultButton = true + val yesButton = Button("I Agree") + yesButton.onMouseClicked = EventHandler() { + result = true + dialog.hide() + } + val eula = Button("View EULA") + eula.onMouseClicked = EventHandler() { + val desktop = Desktop.getDesktop() + if (desktop.isSupported(Desktop.Action.BROWSE)) { + // most likely running on Windows or macOS + try { + desktop.browse(URI("https://account.mojang.com/documents/minecraft_eula")) + } catch (e: Exception) { + println(e) + } + } else { + // assume running on linux + try { + Runtime.getRuntime().exec("xdg-open https://account.mojang.com/documents/minecraft_eula"); + } catch (e: Exception) { + println(e) + } + } + } + ButtonBar.setButtonData(eula, ButtonBar.ButtonData.LEFT) + ButtonBar.setButtonData(noButton, ButtonBar.ButtonData.RIGHT) + ButtonBar.setButtonData(yesButton, ButtonBar.ButtonData.RIGHT) + buttonBar.buttons.add(eula) + buttonBar.buttons.add(noButton) + buttonBar.buttons.add(yesButton) + scenePane.children.addAll(imagePane, label, buttonBar) + dialog.setScene(dialogScene); + dialog.showAndWait(); + return result + } + + private fun createDialog(type: String, msg: String, yes: String, no: String, hold: Boolean): Boolean { + var result = false + val resources = App().javaClass.getResource("icons/$type.png") + val dialog = Stage() + dialog.icons.add(Image(this.javaClass.getResourceAsStream("app.png"))) + dialog.setResizable(false) + dialog.initModality(Modality.APPLICATION_MODAL); + dialog.title = directory + val scenePane = Pane() + val dialogScene = Scene(scenePane, 400.0, 150.0); + val imagePane = Pane() + val icon = Image("$resources") + imagePane.layoutX = 14.0 + imagePane.layoutY = 14.0 + imagePane.scaleX = 0.7 + imagePane.scaleY = 0.7 + imagePane.children.add(ImageView(icon)) + val label = Label(msg) + label.isWrapText = true + label.layoutX = 115.0 + label.layoutY = if (type == "warning") {10.0} else {40.0} + val buttonBar = ButtonBar() + buttonBar.padding = Insets(10.0, 10.0, 10.0, 10.0) + buttonBar.layoutX = 0.0 + buttonBar.layoutY = 107.0 + buttonBar.prefWidth = 400.0 + val noButton = Button(no) + noButton.onMouseClicked = EventHandler() { + if (hold) { + result = false + } else { + asyncResult = false + } + dialog.hide() + } + val yesButton = Button(yes) + yesButton.onMouseClicked = EventHandler() { + if (hold) { + result = true + } else { + asyncResult = true + } + dialog.hide() + } + yesButton.isDefaultButton = true + buttonBar.buttons.add(noButton) + buttonBar.buttons.add(yesButton) + scenePane.children.addAll(imagePane, label, buttonBar) + dialog.setScene(dialogScene); + if (hold) { + dialog.showAndWait(); + } else { + dialog.show(); + } + return result + } + + private fun loadServerDir(dir: String): Boolean { + directory = dir + if (!File(directory).isDirectory) { + return false; + } + + if (directory[directory.length-1] != File.separatorChar) + directory += File.separatorChar + + val hasDummy = File(directory + "ServerForDummies").isDirectory + val hasProperties = File(directory + File.separator + "server.properties").isFile + val hasServer = findServerJar() + + if (hasDummy && hasServer) { + // server complete, just read jproperties + statusBar.text = "Server found!" + startButton.isDisable = false + buildButton.text = "Rebuild Server" + } else if (hasDummy && !hasServer && hasProperties) { + // just needs to be built + startButton.isDisable = true + statusBar.text = "Server needs to be built before starting." + } else if (!hasDummy && hasServer) { + // server created externally + val result = createDialog("warning", "This server directory was not created by \nServerForDummies. Errors may occur; copying\nthe world directories to a new folder may be\nsafer. Proceed anyway?", "Yes", "No", true) + statusBar.text = "Ready." + if (result) { + startButton.isDisable = false + } + return result + } else { + // assume clean directory + val result = createDialog("info", "There is no server in this directory.\nCreate one?", "Yes", "No", true) + if (result) { + File(directory + "ServerForDummies").mkdir() + startButton.isDisable = true + buildButton.text = "Build Server" + } + statusBar.text = "Ready." + return result + } + + return true; + } + + private fun findServerJar(): Boolean { + // search for spigot jar + // major version + for (i in 25 downTo 8) { + // patch number + for (j in 15 downTo 0) { + var spigotFile: String = "" + if (j == 0) + spigotFile += "spigot-1.$i.jar" + else + spigotFile += "spigot-1.$i.$j.jar"; + + if (File(directory + spigotFile).isFile) { + server.jar = directory + spigotFile + return true + } + } + } + + // try vanilla server if no spigot server + if (File(directory + "server.jar").isFile) { + server.jar = directory + "server.jar" + return true + } + + return false + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/server/Download.kt b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/server/Download.kt new file mode 100644 index 0000000..d7e5aed --- /dev/null +++ b/app/src/main/kotlin/xyz/brysonsteck/serverfordummies/server/Download.kt @@ -0,0 +1,119 @@ +package xyz.brysonsteck.serverfordummies.server + +import java.io.*; +import java.net.*; +import java.util.*; + +class Download: Runnable { + public enum class Status { + DOWNLOADING, PAUSED, COMPLETE, CANCELLED, ERROR + } + public var size: Int + public var downloaded: Int + public var contentLength: Int + public var status: Status + + private final val MAX_BUFFER_SIZE: Number = 1024 + + private var url: URL + private var dir: String + + constructor (url: URL, dir: String) { + this.url = url + this.dir = dir + size = -1 + downloaded = 0 + status = Status.DOWNLOADING + contentLength = 1 + } + + public fun start() { + val thread = Thread(this) + thread.start() + } + + private fun getFilename(url: URL): String { + val filename = url.getFile() + return filename.substring(filename.lastIndexOf('/') + 1) + } + + override fun run() { + var stream: InputStream? = null + var file: RandomAccessFile? = null + + try { + // Open connection to URL. + var connection = url.openConnection() as HttpURLConnection; + + // Specify what portion of file to download. + connection.setRequestProperty("Range", "bytes=" + downloaded + "-"); + + // Connect to server. + connection.connect(); + + // Make sure response code is in the 200 range. + if (connection.responseCode / 100 != 2) { + status = Status.ERROR + } + + // Check for valid content length. + contentLength = connection.getContentLength(); + if (contentLength < 1) { + status = Status.ERROR + } + + /* Set the size for this download if it + hasn't been already set. */ + if (size == -1) { + size = contentLength; + } + + // Open file and seek to the end of it. + file = RandomAccessFile(dir + getFilename(url), "rw"); + file.seek(downloaded.toLong()); + + stream = connection.getInputStream(); + while (status == Status.DOWNLOADING) { + /* Size buffer according to how much of the + file is left to download. */ + val buffer: ByteArray; + if (size - downloaded > MAX_BUFFER_SIZE as Int) { + buffer = ByteArray(MAX_BUFFER_SIZE) + } else { + buffer = ByteArray(size - downloaded); + } + + // Read from server into buffer. + val read = stream.read(buffer); + if (read == -1) + break; + + // Write buffer to file. + file.write(buffer, 0, read); + downloaded += read; + } + + /* Change status to complete if this point was + reached because downloading has finished. */ + if (status == Status.DOWNLOADING) { + status = Status.COMPLETE; + } + } catch (e: Exception) { + status = Status.ERROR + } finally { + // Close file. + if (file != null) { + try { + file.close(); + } catch (e: Exception) {} + } + + // Close connection to server. + if (stream != null) { + try { + stream.close(); + } catch (e: Exception) {} + } + } + } +} diff --git a/app/src/main/resources/xyz/brysonsteck/serverfordummies/app-256x256.png b/app/src/main/resources/xyz/brysonsteck/serverfordummies/app-256x256.png deleted file mode 100644 index a21cb9b..0000000 Binary files a/app/src/main/resources/xyz/brysonsteck/serverfordummies/app-256x256.png and /dev/null differ diff --git a/app/src/main/resources/xyz/brysonsteck/serverfordummies/app.png b/app/src/main/resources/xyz/brysonsteck/serverfordummies/app.png index 342258e..ceb633a 100644 Binary files a/app/src/main/resources/xyz/brysonsteck/serverfordummies/app.png and b/app/src/main/resources/xyz/brysonsteck/serverfordummies/app.png differ diff --git a/app/src/main/resources/xyz/brysonsteck/serverfordummies/info.fxml b/app/src/main/resources/xyz/brysonsteck/serverfordummies/info.fxml index 4c82385..1457d46 100644 --- a/app/src/main/resources/xyz/brysonsteck/serverfordummies/info.fxml +++ b/app/src/main/resources/xyz/brysonsteck/serverfordummies/info.fxml @@ -48,8 +48,8 @@ - - + + @@ -61,7 +61,7 @@ -