Added first version with auto-JRE downloading!
This commit is contained in:
parent
359d1aa1b8
commit
16b7a1e653
|
@ -17,6 +17,7 @@ public class Launcher extends Application {
|
||||||
public static final Path BASE_DIR = Path.of(System.getProperty("user.home"), ".ace-of-shades");
|
public static final Path BASE_DIR = Path.of(System.getProperty("user.home"), ".ace-of-shades");
|
||||||
public static final Path VERSIONS_DIR = BASE_DIR.resolve("versions");
|
public static final Path VERSIONS_DIR = BASE_DIR.resolve("versions");
|
||||||
public static final Path PROFILES_FILE = BASE_DIR.resolve("profiles.json");
|
public static final Path PROFILES_FILE = BASE_DIR.resolve("profiles.json");
|
||||||
|
public static final Path JRE_PATH = BASE_DIR.resolve("jre");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void start(Stage stage) throws IOException {
|
public void start(Stage stage) throws IOException {
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
package nl.andrewl.aos2_launcher;
|
package nl.andrewl.aos2_launcher;
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import com.google.gson.GsonBuilder;
|
|
||||||
import com.google.gson.JsonArray;
|
|
||||||
import com.google.gson.JsonObject;
|
|
||||||
import javafx.application.Platform;
|
import javafx.application.Platform;
|
||||||
import javafx.beans.binding.BooleanBinding;
|
import javafx.beans.binding.BooleanBinding;
|
||||||
import javafx.beans.property.ObjectProperty;
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
@ -15,9 +11,13 @@ import javafx.fxml.FXML;
|
||||||
import javafx.scene.Node;
|
import javafx.scene.Node;
|
||||||
import javafx.scene.control.Button;
|
import javafx.scene.control.Button;
|
||||||
import javafx.scene.control.Label;
|
import javafx.scene.control.Label;
|
||||||
|
import javafx.scene.control.ProgressBar;
|
||||||
|
import javafx.scene.control.ProgressIndicator;
|
||||||
import javafx.scene.input.MouseEvent;
|
import javafx.scene.input.MouseEvent;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
import nl.andrewl.aos2_launcher.model.Profile;
|
import nl.andrewl.aos2_launcher.model.Profile;
|
||||||
|
import nl.andrewl.aos2_launcher.model.ProfileSet;
|
||||||
|
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||||
import nl.andrewl.aos2_launcher.model.Server;
|
import nl.andrewl.aos2_launcher.model.Server;
|
||||||
import nl.andrewl.aos2_launcher.view.BindingUtil;
|
import nl.andrewl.aos2_launcher.view.BindingUtil;
|
||||||
import nl.andrewl.aos2_launcher.view.EditProfileDialog;
|
import nl.andrewl.aos2_launcher.view.EditProfileDialog;
|
||||||
|
@ -25,37 +25,32 @@ import nl.andrewl.aos2_launcher.view.ProfileView;
|
||||||
import nl.andrewl.aos2_launcher.view.ServerView;
|
import nl.andrewl.aos2_launcher.view.ServerView;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
public class MainViewController {
|
public class MainViewController implements ProgressReporter {
|
||||||
@FXML
|
@FXML public Button playButton;
|
||||||
public Button playButton;
|
@FXML public Button editProfileButton;
|
||||||
@FXML
|
@FXML public Button removeProfileButton;
|
||||||
public Button editProfileButton;
|
@FXML public VBox profilesVBox;
|
||||||
@FXML
|
@FXML public VBox serversVBox;
|
||||||
public Button removeProfileButton;
|
@FXML public Label selectedProfileLabel;
|
||||||
@FXML
|
@FXML public Label selectedServerLabel;
|
||||||
public VBox profilesVBox;
|
|
||||||
@FXML
|
|
||||||
public VBox serversVBox;
|
|
||||||
@FXML
|
|
||||||
public Label selectedProfileLabel;
|
|
||||||
@FXML
|
|
||||||
public Label selectedServerLabel;
|
|
||||||
|
|
||||||
private final ObservableList<Profile> profiles = FXCollections.observableArrayList();
|
@FXML public VBox progressVBox;
|
||||||
|
@FXML public Label progressLabel;
|
||||||
|
@FXML public ProgressBar progressBar;
|
||||||
|
|
||||||
|
private final ProfileSet profileSet = new ProfileSet();
|
||||||
private final ObservableList<Server> servers = FXCollections.observableArrayList();
|
private final ObservableList<Server> servers = FXCollections.observableArrayList();
|
||||||
private final ObjectProperty<Server> selectedServer = new SimpleObjectProperty<>(null);
|
private final ObjectProperty<Server> selectedServer = new SimpleObjectProperty<>(null);
|
||||||
private final ObjectProperty<Profile> selectedProfile = new SimpleObjectProperty<>(null);
|
|
||||||
|
|
||||||
private final ServersFetcher serversFetcher = new ServersFetcher();
|
private final ServersFetcher serversFetcher = new ServersFetcher();
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void initialize() {
|
public void initialize() {
|
||||||
BindingUtil.mapContent(serversVBox.getChildren(), servers, ServerView::new);
|
BindingUtil.mapContent(serversVBox.getChildren(), servers, ServerView::new);
|
||||||
BindingUtil.mapContent(profilesVBox.getChildren(), profiles, ProfileView::new);
|
BindingUtil.mapContent(profilesVBox.getChildren(), profileSet.getProfiles(), ProfileView::new);
|
||||||
selectedProfile.addListener((observable, oldValue, newValue) -> {
|
profileSet.selectedProfileProperty().addListener((observable, oldValue, newValue) -> {
|
||||||
if (newValue == null) {
|
if (newValue == null) {
|
||||||
selectedProfileLabel.setText("None");
|
selectedProfileLabel.setText("None");
|
||||||
} else {
|
} else {
|
||||||
|
@ -69,16 +64,16 @@ public class MainViewController {
|
||||||
selectedServerLabel.setText(newValue.getName());
|
selectedServerLabel.setText(newValue.getName());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
BooleanBinding playBind = selectedProfile.isNull().or(selectedServer.isNull());
|
BooleanBinding playBind = profileSet.selectedProfileProperty().isNull().or(selectedServer.isNull());
|
||||||
playButton.disableProperty().bind(playBind);
|
playButton.disableProperty().bind(playBind);
|
||||||
editProfileButton.disableProperty().bind(selectedProfile.isNull());
|
editProfileButton.disableProperty().bind(profileSet.selectedProfileProperty().isNull());
|
||||||
removeProfileButton.disableProperty().bind(selectedProfile.isNull());
|
removeProfileButton.disableProperty().bind(profileSet.selectedProfileProperty().isNull());
|
||||||
|
|
||||||
profilesVBox.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
profilesVBox.addEventHandler(MouseEvent.MOUSE_CLICKED, event -> {
|
||||||
Node target = (Node) event.getTarget();
|
Node target = (Node) event.getTarget();
|
||||||
while (target != null) {
|
while (target != null) {
|
||||||
if (target instanceof ProfileView view) {
|
if (target instanceof ProfileView view) {
|
||||||
if (view.getProfile().equals(selectedProfile.get()) && event.isControlDown()) {
|
if (view.getProfile().equals(profileSet.getSelectedProfile()) && event.isControlDown()) {
|
||||||
selectProfile(null);
|
selectProfile(null);
|
||||||
} else if (!event.isControlDown() && event.getClickCount() == 2) {
|
} else if (!event.isControlDown() && event.getClickCount() == 2) {
|
||||||
selectProfile(view);
|
selectProfile(view);
|
||||||
|
@ -107,7 +102,10 @@ public class MainViewController {
|
||||||
}
|
}
|
||||||
selectServer(null);
|
selectServer(null);
|
||||||
});
|
});
|
||||||
loadProfiles();
|
progressVBox.managedProperty().bind(progressVBox.visibleProperty());
|
||||||
|
progressVBox.setVisible(false);
|
||||||
|
profileSet.loadOrCreateStandardFile();
|
||||||
|
updateProfileViewSelectedClass();
|
||||||
refreshServers();
|
refreshServers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,101 +125,98 @@ public class MainViewController {
|
||||||
@FXML
|
@FXML
|
||||||
public void addProfile() {
|
public void addProfile() {
|
||||||
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow());
|
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow());
|
||||||
dialog.showAndWait().ifPresent(profiles::add);
|
dialog.showAndWait().ifPresent(profileSet::addNewProfile);
|
||||||
saveProfiles();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void editProfile() {
|
public void editProfile() {
|
||||||
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow(), selectedProfile.get());
|
EditProfileDialog dialog = new EditProfileDialog(profilesVBox.getScene().getWindow(), profileSet.getSelectedProfile());
|
||||||
dialog.showAndWait();
|
dialog.showAndWait();
|
||||||
saveProfiles();
|
profileSet.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void removeProfile() {
|
public void removeProfile() {
|
||||||
if (selectedProfile.getValue() != null) {
|
profileSet.removeSelectedProfile();
|
||||||
profiles.remove(selectedProfile.getValue());
|
|
||||||
saveProfiles();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@FXML
|
@FXML
|
||||||
public void play() {
|
public void play() {
|
||||||
Profile profile = this.selectedProfile.get();
|
Profile profile = profileSet.getSelectedProfile();
|
||||||
Server server = this.selectedServer.get();
|
Server server = this.selectedServer.get();
|
||||||
VersionFetcher.INSTANCE.ensureVersionIsDownloaded(profile.getClientVersion())
|
SystemVersionValidator.getJreExecutablePath(this)
|
||||||
.thenAccept(path -> {
|
.thenAccept(jrePath -> {
|
||||||
|
VersionFetcher.INSTANCE.ensureVersionIsDownloaded(profile.getClientVersion(), this)
|
||||||
|
.thenAccept(clientJarPath -> {
|
||||||
try {
|
try {
|
||||||
Process p = new ProcessBuilder()
|
Process p = new ProcessBuilder()
|
||||||
.command("java", "-jar", path.toAbsolutePath().toString())
|
.command(
|
||||||
|
jrePath.toAbsolutePath().toString(),
|
||||||
|
"-jar",
|
||||||
|
clientJarPath.toAbsolutePath().toString()
|
||||||
|
)
|
||||||
.directory(Launcher.BASE_DIR.toFile())
|
.directory(Launcher.BASE_DIR.toFile())
|
||||||
.inheritIO()
|
.inheritIO()
|
||||||
.start();
|
.start();
|
||||||
|
p.wait();
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void selectProfile(ProfileView view) {
|
private void selectProfile(ProfileView view) {
|
||||||
Profile profile = view == null ? null : view.getProfile();
|
Profile profile = view == null ? null : view.getProfile();
|
||||||
selectedProfile.set(profile);
|
profileSet.selectProfile(profile);
|
||||||
|
updateProfileViewSelectedClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProfileViewSelectedClass() {
|
||||||
PseudoClass selectedClass = PseudoClass.getPseudoClass("selected");
|
PseudoClass selectedClass = PseudoClass.getPseudoClass("selected");
|
||||||
for (var node : profilesVBox.getChildren()) {
|
for (var node : profilesVBox.getChildren()) {
|
||||||
node.pseudoClassStateChanged(selectedClass, false);
|
ProfileView view = (ProfileView) node;
|
||||||
}
|
view.pseudoClassStateChanged(selectedClass, view.getProfile().equals(profileSet.getSelectedProfile()));
|
||||||
if (view != null) {
|
|
||||||
view.pseudoClassStateChanged(selectedClass, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void selectServer(ServerView view) {
|
private void selectServer(ServerView view) {
|
||||||
Server server = view == null ? null : view.getServer();
|
Server server = view == null ? null : view.getServer();
|
||||||
selectedServer.set(server);
|
selectedServer.set(server);
|
||||||
|
updateServerViewSelectedClass();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateServerViewSelectedClass() {
|
||||||
PseudoClass selectedClass = PseudoClass.getPseudoClass("selected");
|
PseudoClass selectedClass = PseudoClass.getPseudoClass("selected");
|
||||||
for (var node : serversVBox.getChildren()) {
|
for (var node : serversVBox.getChildren()) {
|
||||||
node.pseudoClassStateChanged(selectedClass, false);
|
ServerView view = (ServerView) node;
|
||||||
}
|
view.pseudoClassStateChanged(selectedClass, view.getServer().equals(selectedServer.get()));
|
||||||
if (view != null) {
|
|
||||||
view.pseudoClassStateChanged(selectedClass, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadProfiles() {
|
@Override
|
||||||
if (!Files.exists(Launcher.PROFILES_FILE)) return;
|
public void enableProgress() {
|
||||||
try (var reader = Files.newBufferedReader(Launcher.PROFILES_FILE)) {
|
Platform.runLater(() -> {
|
||||||
profiles.clear();
|
progressVBox.setVisible(true);
|
||||||
JsonArray array = new Gson().fromJson(reader, JsonArray.class);
|
progressBar.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS);
|
||||||
for (var element : array) {
|
progressLabel.setText(null);
|
||||||
if (element.isJsonObject()) {
|
});
|
||||||
JsonObject obj = element.getAsJsonObject();
|
|
||||||
Profile profile = new Profile();
|
|
||||||
profile.setName(obj.get("name").getAsString());
|
|
||||||
profile.setDescription(obj.get("description").getAsString());
|
|
||||||
profile.setClientVersion(obj.get("clientVersion").getAsString());
|
|
||||||
profiles.add(profile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveProfiles() {
|
@Override
|
||||||
JsonArray array = new JsonArray(profiles.size());
|
public void disableProgress() {
|
||||||
for (var profile : profiles) {
|
Platform.runLater(() -> progressVBox.setVisible(false));
|
||||||
JsonObject obj = new JsonObject();
|
|
||||||
obj.addProperty("name", profile.getName());
|
|
||||||
obj.addProperty("description", profile.getDescription());
|
|
||||||
obj.addProperty("clientVersion", profile.getClientVersion());
|
|
||||||
array.add(obj);
|
|
||||||
}
|
|
||||||
Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
|
||||||
try (var writer = Files.newBufferedWriter(Launcher.PROFILES_FILE)) {
|
|
||||||
gson.toJson(array, writer);
|
|
||||||
} catch (IOException e) {
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setActionText(String text) {
|
||||||
|
Platform.runLater(() -> progressLabel.setText(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setProgress(double progress) {
|
||||||
|
Platform.runLater(() -> progressBar.setProgress(progress));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,21 @@
|
||||||
package nl.andrewl.aos2_launcher;
|
package nl.andrewl.aos2_launcher;
|
||||||
|
|
||||||
|
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||||
|
import nl.andrewl.aos2_launcher.util.FileUtils;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.function.BiPredicate;
|
||||||
|
|
||||||
public class SystemVersionValidator {
|
public class SystemVersionValidator {
|
||||||
private static final String os = System.getProperty("os.name").trim().toLowerCase();
|
private static final String os = System.getProperty("os.name").trim().toLowerCase();
|
||||||
private static final String arch = System.getProperty("os.arch").trim().toLowerCase();
|
private static final String arch = System.getProperty("os.arch").trim().toLowerCase();
|
||||||
|
@ -15,6 +31,8 @@ public class SystemVersionValidator {
|
||||||
private static final boolean ARCH_ARM = arch.equals("arm");
|
private static final boolean ARCH_ARM = arch.equals("arm");
|
||||||
private static final boolean ARCH_ARM32 = arch.equals("arm32");
|
private static final boolean ARCH_ARM32 = arch.equals("arm32");
|
||||||
|
|
||||||
|
private static final String JRE_DOWNLOAD_URL = "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.4+8/";
|
||||||
|
|
||||||
public static String getPreferredVersionSuffix() {
|
public static String getPreferredVersionSuffix() {
|
||||||
if (OS_LINUX) {
|
if (OS_LINUX) {
|
||||||
if (ARCH_AARCH64) return "linux-aarch64";
|
if (ARCH_AARCH64) return "linux-aarch64";
|
||||||
|
@ -32,4 +50,85 @@ public class SystemVersionValidator {
|
||||||
System.err.println("Couldn't determine the preferred OS/ARCH version. Defaulting to windows-amd64.");
|
System.err.println("Couldn't determine the preferred OS/ARCH version. Defaulting to windows-amd64.");
|
||||||
return "windows-amd64";
|
return "windows-amd64";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static CompletableFuture<Path> getJreExecutablePath(ProgressReporter progressReporter) {
|
||||||
|
Optional<Path> optionalExecutablePath = findJreExecutable();
|
||||||
|
return optionalExecutablePath.map(CompletableFuture::completedFuture)
|
||||||
|
.orElseGet(() -> downloadAppropriateJre(progressReporter));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CompletableFuture<Path> downloadAppropriateJre(ProgressReporter progressReporter) {
|
||||||
|
progressReporter.enableProgress();
|
||||||
|
progressReporter.setActionText("Downloading JRE...");
|
||||||
|
HttpClient httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
|
||||||
|
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().GET().timeout(Duration.ofMinutes(5));
|
||||||
|
String preferredJreName = getPreferredJreName();
|
||||||
|
String url = JRE_DOWNLOAD_URL + preferredJreName;
|
||||||
|
HttpRequest req = requestBuilder.uri(URI.create(url)).build();
|
||||||
|
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofInputStream())
|
||||||
|
.thenApplyAsync(resp -> {
|
||||||
|
if (resp.statusCode() == 200) {
|
||||||
|
// Download sequentially, and update the progress.
|
||||||
|
try {
|
||||||
|
if (Files.exists(Launcher.JRE_PATH)) {
|
||||||
|
FileUtils.deleteRecursive(Launcher.JRE_PATH);
|
||||||
|
}
|
||||||
|
Files.createDirectory(Launcher.JRE_PATH);
|
||||||
|
Path jreArchiveFile = Launcher.JRE_PATH.resolve(preferredJreName);
|
||||||
|
FileUtils.downloadWithProgress(jreArchiveFile, resp, progressReporter);
|
||||||
|
progressReporter.setProgress(-1); // Indefinite progress.
|
||||||
|
progressReporter.setActionText("Unpacking JRE...");
|
||||||
|
ProcessBuilder pb = new ProcessBuilder().inheritIO();
|
||||||
|
if (OS_LINUX || OS_MAC) {
|
||||||
|
pb.command("tar", "-xzf", jreArchiveFile.toAbsolutePath().toString(), "-C", Launcher.JRE_PATH.toAbsolutePath().toString());
|
||||||
|
} else if (OS_WINDOWS) {
|
||||||
|
pb.command("powershell", "-command", "\"Expand-Archive -Force '" + jreArchiveFile.toAbsolutePath() + "' '" + Launcher.JRE_PATH.toAbsolutePath() + "'\"");
|
||||||
|
}
|
||||||
|
Process process = pb.start();
|
||||||
|
int result = process.waitFor();
|
||||||
|
if (result != 0) throw new IOException("Archive extraction process exited with non-zero code: " + result);
|
||||||
|
Files.delete(jreArchiveFile);
|
||||||
|
progressReporter.setActionText("Looking for java executable...");
|
||||||
|
Optional<Path> optionalExecutablePath = findJreExecutable();
|
||||||
|
if (optionalExecutablePath.isEmpty()) throw new IOException("Couldn't find java executable.");
|
||||||
|
progressReporter.disableProgress();
|
||||||
|
return optionalExecutablePath.get();
|
||||||
|
} catch (IOException | InterruptedException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("JRE download failed: " + resp.statusCode());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Optional<Path> findJreExecutable() {
|
||||||
|
if (!Files.exists(Launcher.JRE_PATH)) return Optional.empty();
|
||||||
|
BiPredicate<Path, BasicFileAttributes> pred = (path, basicFileAttributes) -> {
|
||||||
|
String filename = path.getFileName().toString();
|
||||||
|
return Files.isExecutable(path) && (filename.equals("java") || filename.equals("java.exe"));
|
||||||
|
};
|
||||||
|
try (var s = Files.find(Launcher.JRE_PATH, 3, pred)) {
|
||||||
|
return s.findFirst();
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String getPreferredJreName() {
|
||||||
|
if (OS_LINUX) {
|
||||||
|
if (ARCH_AARCH64) return "OpenJDK17U-jre_aarch64_linux_hotspot_17.0.4_8.tar.gz";
|
||||||
|
if (ARCH_AMD64) return "OpenJDK17U-jre_x64_linux_hotspot_17.0.4_8.tar.gz";
|
||||||
|
if (ARCH_ARM || ARCH_ARM32) return "OpenJDK17U-jre_arm_linux_hotspot_17.0.4_8.tar.gz";
|
||||||
|
} else if (OS_MAC) {
|
||||||
|
if (ARCH_AARCH64) return "OpenJDK17U-jre_aarch64_mac_hotspot_17.0.4_8.tar.gz";
|
||||||
|
if (ARCH_X86_64) return "OpenJDK17U-jre_x64_mac_hotspot_17.0.4_8.tar.gz";
|
||||||
|
} else if (OS_WINDOWS) {
|
||||||
|
if (ARCH_AARCH64 || ARCH_AMD64) return "OpenJDK17U-jre_x64_windows_hotspot_17.0.4_8.zip";
|
||||||
|
if (ARCH_X86) return "OpenJDK17U-jre_x86-32_windows_hotspot_17.0.4_8.zip";
|
||||||
|
}
|
||||||
|
System.err.println("Couldn't determine the preferred JRE version. Defaulting to x64_windows.");
|
||||||
|
return "OpenJDK17U-jre_x64_windows_hotspot_17.0.4_8.zip";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import com.google.gson.Gson;
|
||||||
import com.google.gson.JsonArray;
|
import com.google.gson.JsonArray;
|
||||||
import com.google.gson.JsonObject;
|
import com.google.gson.JsonObject;
|
||||||
import nl.andrewl.aos2_launcher.model.ClientVersionRelease;
|
import nl.andrewl.aos2_launcher.model.ClientVersionRelease;
|
||||||
|
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||||
|
import nl.andrewl.aos2_launcher.util.FileUtils;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
|
@ -98,7 +100,7 @@ public class VersionFetcher {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<Path> ensureVersionIsDownloaded(String versionTag) {
|
public CompletableFuture<Path> ensureVersionIsDownloaded(String versionTag, ProgressReporter progressReporter) {
|
||||||
try (var s = Files.list(Launcher.VERSIONS_DIR)) {
|
try (var s = Files.list(Launcher.VERSIONS_DIR)) {
|
||||||
Optional<Path> optionalFile = s.filter(f -> isVersionFile(f) && versionTag.equals(extractVersion(f)))
|
Optional<Path> optionalFile = s.filter(f -> isVersionFile(f) && versionTag.equals(extractVersion(f)))
|
||||||
.findFirst();
|
.findFirst();
|
||||||
|
@ -106,11 +108,15 @@ public class VersionFetcher {
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
return CompletableFuture.failedFuture(e);
|
return CompletableFuture.failedFuture(e);
|
||||||
}
|
}
|
||||||
return getRelease(versionTag)
|
progressReporter.enableProgress();
|
||||||
.thenComposeAsync(this::downloadVersion);
|
progressReporter.setActionText("Downloading client " + versionTag + "...");
|
||||||
|
var future = getRelease(versionTag)
|
||||||
|
.thenComposeAsync(release -> downloadVersion(release, progressReporter));
|
||||||
|
future.thenRun(progressReporter::disableProgress);
|
||||||
|
return future;
|
||||||
}
|
}
|
||||||
|
|
||||||
private CompletableFuture<Path> downloadVersion(ClientVersionRelease release) {
|
private CompletableFuture<Path> downloadVersion(ClientVersionRelease release, ProgressReporter progressReporter) {
|
||||||
System.out.println("Downloading version " + release.tag());
|
System.out.println("Downloading version " + release.tag());
|
||||||
HttpRequest req = HttpRequest.newBuilder(URI.create(release.assetsUrl()))
|
HttpRequest req = HttpRequest.newBuilder(URI.create(release.assetsUrl()))
|
||||||
.GET().timeout(Duration.ofSeconds(3)).build();
|
.GET().timeout(Duration.ofSeconds(3)).build();
|
||||||
|
@ -140,10 +146,19 @@ public class VersionFetcher {
|
||||||
.GET().timeout(Duration.ofMinutes(5)).build();
|
.GET().timeout(Duration.ofMinutes(5)).build();
|
||||||
Path file = Launcher.VERSIONS_DIR.resolve(fileName);
|
Path file = Launcher.VERSIONS_DIR.resolve(fileName);
|
||||||
System.out.printf("Downloading %s to %s.%n", fileName, file.toAbsolutePath());
|
System.out.printf("Downloading %s to %s.%n", fileName, file.toAbsolutePath());
|
||||||
return httpClient.sendAsync(downloadRequest, HttpResponse.BodyHandlers.ofFile(file))
|
return httpClient.sendAsync(downloadRequest, HttpResponse.BodyHandlers.ofInputStream())
|
||||||
.thenApplyAsync(resp -> {
|
.thenApplyAsync(resp -> {
|
||||||
System.out.println(resp);
|
if (resp.statusCode() == 200) {
|
||||||
return resp.body();
|
// Download sequentially, and update the progress.
|
||||||
|
try {
|
||||||
|
FileUtils.downloadWithProgress(file, resp, progressReporter);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("Version download failed.");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,15 +3,27 @@ package nl.andrewl.aos2_launcher.model;
|
||||||
import javafx.beans.property.SimpleStringProperty;
|
import javafx.beans.property.SimpleStringProperty;
|
||||||
import javafx.beans.property.StringProperty;
|
import javafx.beans.property.StringProperty;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
public class Profile {
|
public class Profile {
|
||||||
|
private final UUID id;
|
||||||
private final StringProperty name;
|
private final StringProperty name;
|
||||||
private final StringProperty description;
|
private final StringProperty description;
|
||||||
private final StringProperty clientVersion;
|
private final StringProperty clientVersion;
|
||||||
|
|
||||||
public Profile() {
|
public Profile() {
|
||||||
this.name = new SimpleStringProperty("");
|
this(UUID.randomUUID(), "", null, null);
|
||||||
this.description = new SimpleStringProperty(null);
|
}
|
||||||
this.clientVersion = new SimpleStringProperty(null);
|
|
||||||
|
public Profile(UUID id, String name, String description, String clientVersion) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = new SimpleStringProperty(name);
|
||||||
|
this.description = new SimpleStringProperty(description);
|
||||||
|
this.clientVersion = new SimpleStringProperty(clientVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getName() {
|
public String getName() {
|
||||||
|
|
|
@ -0,0 +1,141 @@
|
||||||
|
package nl.andrewl.aos2_launcher.model;
|
||||||
|
|
||||||
|
import com.google.gson.*;
|
||||||
|
import javafx.beans.property.ObjectProperty;
|
||||||
|
import javafx.beans.property.SimpleObjectProperty;
|
||||||
|
import javafx.collections.FXCollections;
|
||||||
|
import javafx.collections.ObservableList;
|
||||||
|
import nl.andrewl.aos2_launcher.Launcher;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Model for managing the set of profiles in the app.
|
||||||
|
*/
|
||||||
|
public class ProfileSet {
|
||||||
|
private final ObservableList<Profile> profiles;
|
||||||
|
private final ObjectProperty<Profile> selectedProfile;
|
||||||
|
private Path lastFileUsed = null;
|
||||||
|
|
||||||
|
public ProfileSet() {
|
||||||
|
this.profiles = FXCollections.observableArrayList();
|
||||||
|
this.selectedProfile = new SimpleObjectProperty<>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProfileSet(Path file) throws IOException {
|
||||||
|
this();
|
||||||
|
load(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addNewProfile(Profile profile) {
|
||||||
|
profiles.add(profile);
|
||||||
|
selectedProfile.set(profile);
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeProfile(Profile profile) {
|
||||||
|
if (profile == null) return;
|
||||||
|
boolean removed = profiles.remove(profile);
|
||||||
|
if (removed) {
|
||||||
|
if (selectedProfile.get() != null && selectedProfile.get().equals(profile)) {
|
||||||
|
selectedProfile.set(null);
|
||||||
|
}
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSelectedProfile() {
|
||||||
|
removeProfile(getSelectedProfile());
|
||||||
|
}
|
||||||
|
|
||||||
|
public void selectProfile(Profile profile) {
|
||||||
|
if (!profiles.contains(profile)) return;
|
||||||
|
selectedProfile.set(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void load(Path file) throws IOException {
|
||||||
|
try (var reader = Files.newBufferedReader(file)) {
|
||||||
|
JsonObject data = new Gson().fromJson(reader, JsonObject.class);
|
||||||
|
profiles.clear();
|
||||||
|
JsonElement selectedProfileIdElement = data.get("selectedProfileId");
|
||||||
|
UUID selectedProfileId = selectedProfileIdElement.isJsonNull() ? null : UUID.fromString(selectedProfileIdElement.getAsString());
|
||||||
|
JsonArray profilesArray = data.getAsJsonArray("profiles");
|
||||||
|
for (JsonElement element : profilesArray) {
|
||||||
|
JsonObject profileObj = element.getAsJsonObject();
|
||||||
|
UUID id = UUID.fromString(profileObj.get("id").getAsString());
|
||||||
|
String name = profileObj.get("name").getAsString();
|
||||||
|
String description = profileObj.get("description").getAsString();
|
||||||
|
String clientVersion = profileObj.get("clientVersion").getAsString();
|
||||||
|
Profile profile = new Profile(id, name, description, clientVersion);
|
||||||
|
profiles.add(profile);
|
||||||
|
if (selectedProfileId != null && selectedProfileId.equals(profile.getId())) {
|
||||||
|
selectedProfile.set(profile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastFileUsed = file;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void loadOrCreateStandardFile() {
|
||||||
|
if (!Files.exists(Launcher.PROFILES_FILE)) {
|
||||||
|
try {
|
||||||
|
save(Launcher.PROFILES_FILE);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
load(Launcher.PROFILES_FILE);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save(Path file) throws IOException {
|
||||||
|
Gson gson = new GsonBuilder().setPrettyPrinting().create();
|
||||||
|
JsonObject data = new JsonObject();
|
||||||
|
String selectedProfileId = selectedProfile.getValue() == null ? null : selectedProfile.getValue().getId().toString();
|
||||||
|
data.addProperty("selectedProfileId", selectedProfileId);
|
||||||
|
JsonArray profilesArray = new JsonArray(profiles.size());
|
||||||
|
for (Profile profile : profiles) {
|
||||||
|
JsonObject obj = new JsonObject();
|
||||||
|
obj.addProperty("id", profile.getId().toString());
|
||||||
|
obj.addProperty("name", profile.getName());
|
||||||
|
obj.addProperty("description", profile.getDescription());
|
||||||
|
obj.addProperty("clientVersion", profile.getClientVersion());
|
||||||
|
profilesArray.add(obj);
|
||||||
|
}
|
||||||
|
data.add("profiles", profilesArray);
|
||||||
|
try (var writer = Files.newBufferedWriter(file)) {
|
||||||
|
gson.toJson(data, writer);
|
||||||
|
}
|
||||||
|
lastFileUsed = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void save() {
|
||||||
|
if (lastFileUsed != null) {
|
||||||
|
try {
|
||||||
|
save(lastFileUsed);
|
||||||
|
} catch (IOException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableList<Profile> getProfiles() {
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Profile getSelectedProfile() {
|
||||||
|
return selectedProfile.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObjectProperty<Profile> selectedProfileProperty() {
|
||||||
|
return selectedProfile;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,8 @@
|
||||||
|
package nl.andrewl.aos2_launcher.model;
|
||||||
|
|
||||||
|
public interface ProgressReporter {
|
||||||
|
void enableProgress();
|
||||||
|
void disableProgress();
|
||||||
|
void setActionText(String text);
|
||||||
|
void setProgress(double progress);
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
package nl.andrewl.aos2_launcher.util;
|
||||||
|
|
||||||
|
import nl.andrewl.aos2_launcher.model.ProgressReporter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.file.FileVisitResult;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.SimpleFileVisitor;
|
||||||
|
import java.nio.file.attribute.BasicFileAttributes;
|
||||||
|
import java.text.CharacterIterator;
|
||||||
|
import java.text.StringCharacterIterator;
|
||||||
|
|
||||||
|
public class FileUtils {
|
||||||
|
public static String humanReadableByteCountSI(long bytes) {
|
||||||
|
if (-1000 < bytes && bytes < 1000) {
|
||||||
|
return bytes + " B";
|
||||||
|
}
|
||||||
|
CharacterIterator ci = new StringCharacterIterator("kMGTPE");
|
||||||
|
while (bytes <= -999_950 || bytes >= 999_950) {
|
||||||
|
bytes /= 1000;
|
||||||
|
ci.next();
|
||||||
|
}
|
||||||
|
return String.format("%.1f %cB", bytes / 1000.0, ci.current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String humanReadableByteCountBin(long bytes) {
|
||||||
|
long absB = bytes == Long.MIN_VALUE ? Long.MAX_VALUE : Math.abs(bytes);
|
||||||
|
if (absB < 1024) {
|
||||||
|
return bytes + " B";
|
||||||
|
}
|
||||||
|
long value = absB;
|
||||||
|
CharacterIterator ci = new StringCharacterIterator("KMGTPE");
|
||||||
|
for (int i = 40; i >= 0 && absB > 0xfffccccccccccccL >> i; i -= 10) {
|
||||||
|
value >>= 10;
|
||||||
|
ci.next();
|
||||||
|
}
|
||||||
|
value *= Long.signum(bytes);
|
||||||
|
return String.format("%.1f %ciB", value / 1024.0, ci.current());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void deleteRecursive(Path p) throws IOException {
|
||||||
|
Files.walkFileTree(p, new SimpleFileVisitor<>() {
|
||||||
|
@Override
|
||||||
|
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
|
||||||
|
Files.delete(file);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||||
|
Files.delete(dir);
|
||||||
|
return FileVisitResult.CONTINUE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void downloadWithProgress(Path outputFile, HttpResponse<InputStream> resp, ProgressReporter reporter) throws IOException {
|
||||||
|
reporter.setProgress(0);
|
||||||
|
long size = resp.headers().firstValueAsLong("Content-Length").orElse(1);
|
||||||
|
try (var out = Files.newOutputStream(outputFile); var in = resp.body()) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
long bytesRead = 0;
|
||||||
|
while (bytesRead < size) {
|
||||||
|
int readCount = in.read(buffer);
|
||||||
|
out.write(buffer, 0, readCount);
|
||||||
|
bytesRead += readCount;
|
||||||
|
reporter.setProgress((double) bytesRead / size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,25 +1,18 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
<?import javafx.geometry.*?>
|
||||||
<?import javafx.scene.control.*?>
|
<?import javafx.scene.control.*?>
|
||||||
<?import javafx.scene.layout.*?>
|
<?import javafx.scene.layout.*?>
|
||||||
<VBox
|
<?import javafx.scene.text.*?>
|
||||||
maxHeight="-Infinity"
|
|
||||||
maxWidth="-Infinity"
|
<VBox maxHeight="1.7976931348623157E308" maxWidth="1.7976931348623157E308" minHeight="300.0" minWidth="300.0" prefHeight="400.0" prefWidth="600.0" xmlns="http://javafx.com/javafx/16" xmlns:fx="http://javafx.com/fxml/1" fx:controller="nl.andrewl.aos2_launcher.MainViewController">
|
||||||
minHeight="300.0"
|
|
||||||
minWidth="300.0"
|
|
||||||
prefHeight="400.0"
|
|
||||||
prefWidth="600.0"
|
|
||||||
xmlns="http://javafx.com/javafx/16"
|
|
||||||
xmlns:fx="http://javafx.com/fxml/1"
|
|
||||||
fx:controller="nl.andrewl.aos2_launcher.MainViewController"
|
|
||||||
>
|
|
||||||
<TabPane tabClosingPolicy="UNAVAILABLE" VBox.vgrow="ALWAYS">
|
<TabPane tabClosingPolicy="UNAVAILABLE" VBox.vgrow="ALWAYS">
|
||||||
<Tab text="Profiles">
|
<Tab text="Profiles">
|
||||||
<VBox>
|
<VBox>
|
||||||
<HBox VBox.vgrow="NEVER" alignment="CENTER" styleClass="button-bar">
|
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||||
<Button text="Add Profile" onAction="#addProfile"/>
|
<Button onAction="#addProfile" text="Add Profile" />
|
||||||
<Button fx:id="editProfileButton" text="Edit Profile" onAction="#editProfile"/>
|
<Button fx:id="editProfileButton" onAction="#editProfile" text="Edit Profile" />
|
||||||
<Button fx:id="removeProfileButton" text="Remove Profile" onAction="#removeProfile"/>
|
<Button fx:id="removeProfileButton" onAction="#removeProfile" text="Remove Profile" />
|
||||||
</HBox>
|
</HBox>
|
||||||
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
||||||
<VBox fx:id="profilesVBox" styleClass="banner-list" />
|
<VBox fx:id="profilesVBox" styleClass="banner-list" />
|
||||||
|
@ -28,8 +21,8 @@
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab text="Servers">
|
<Tab text="Servers">
|
||||||
<VBox>
|
<VBox>
|
||||||
<HBox VBox.vgrow="NEVER" alignment="CENTER" styleClass="button-bar">
|
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||||
<Button text="Refresh" onAction="#refreshServers"/>
|
<Button onAction="#refreshServers" text="Refresh" />
|
||||||
</HBox>
|
</HBox>
|
||||||
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
<ScrollPane fitToWidth="true" VBox.vgrow="ALWAYS">
|
||||||
<VBox fx:id="serversVBox" styleClass="banner-list" />
|
<VBox fx:id="serversVBox" styleClass="banner-list" />
|
||||||
|
@ -37,15 +30,32 @@
|
||||||
</VBox>
|
</VBox>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
<HBox alignment="CENTER" VBox.vgrow="NEVER" styleClass="button-bar">
|
<HBox alignment="CENTER" styleClass="button-bar" VBox.vgrow="NEVER">
|
||||||
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" spacing="5">
|
<HBox alignment="CENTER_LEFT" spacing="5" HBox.hgrow="NEVER">
|
||||||
<Label text="Profile: " />
|
<Label text="Profile: " />
|
||||||
<Label fx:id="selectedProfileLabel" text="None" />
|
<Label fx:id="selectedProfileLabel" text="None" />
|
||||||
</HBox>
|
</HBox>
|
||||||
<Button fx:id="playButton" mnemonicParsing="false" text="Play" onAction="#play"/>
|
<Button fx:id="playButton" mnemonicParsing="false" onAction="#play" text="Play" />
|
||||||
<HBox alignment="CENTER_LEFT" HBox.hgrow="NEVER" spacing="5">
|
<HBox alignment="CENTER_LEFT" spacing="5" HBox.hgrow="NEVER">
|
||||||
<Label text="Server: " />
|
<Label text="Server: " />
|
||||||
<Label fx:id="selectedServerLabel" text="None" />
|
<Label fx:id="selectedServerLabel" text="None" />
|
||||||
</HBox>
|
</HBox>
|
||||||
</HBox>
|
</HBox>
|
||||||
|
<VBox fx:id="progressVBox" VBox.vgrow="NEVER">
|
||||||
|
<children>
|
||||||
|
<AnchorPane VBox.vgrow="NEVER">
|
||||||
|
<children>
|
||||||
|
<Label fx:id="progressLabel" text="Work in progress..." AnchorPane.bottomAnchor="0.0" AnchorPane.leftAnchor="0.0" AnchorPane.topAnchor="0.0">
|
||||||
|
<font>
|
||||||
|
<Font size="10.0" />
|
||||||
|
</font>
|
||||||
|
</Label>
|
||||||
|
<ProgressBar fx:id="progressBar" prefWidth="200.0" progress="0.0" AnchorPane.bottomAnchor="0.0" AnchorPane.rightAnchor="0.0" AnchorPane.topAnchor="0.0" />
|
||||||
|
</children>
|
||||||
|
<padding>
|
||||||
|
<Insets bottom="5.0" left="5.0" right="5.0" top="5.0" />
|
||||||
|
</padding>
|
||||||
|
</AnchorPane>
|
||||||
|
</children>
|
||||||
|
</VBox>
|
||||||
</VBox>
|
</VBox>
|
||||||
|
|
Loading…
Reference in New Issue