diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java index 1bfe196fc..0fc129d41 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Launcher.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl; +import static org.jackhuang.hmcl.util.Lang.thread; import static org.jackhuang.hmcl.util.Logging.LOG; import com.jfoenix.concurrency.JFXUtilities; @@ -26,9 +27,11 @@ import javafx.stage.Stage; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.util.*; import java.io.File; +import java.io.IOException; import java.net.URISyntaxException; import java.net.URL; import java.net.URLClassLoader; @@ -36,6 +39,7 @@ import java.nio.file.Paths; import java.util.LinkedList; import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.logging.Level; public final class Launcher extends Application { @@ -51,15 +55,13 @@ public final class Launcher extends Application { primaryStage.setResizable(false); primaryStage.setScene(Controllers.getScene()); - /* - UPDATE: check update - UPDATE_CHECKER.process(false) - .then(Task.of(Schedulers.javafx(), () -> { - if (UPDATE_CHECKER.isOutOfDate()) - Controllers.showUpdate(); - })) - .start(); - */ + thread(() -> { + try { + UpdateChecker.checkUpdate(); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to check for update", e); + } + }); primaryStage.show(); } catch (Throwable e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Main.java b/HMCL/src/main/java/org/jackhuang/hmcl/Main.java index 596d46911..50a188970 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Main.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Main.java @@ -35,17 +35,20 @@ import javax.net.ssl.X509TrustManager; import javax.swing.JOptionPane; import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.upgrade.UpdateHandler; public final class Main { public static void main(String[] args) { - /* UPDATE: perform auto-update from local source */ - checkJavaFX(); checkDirectoryPath(); checkDSTRootCAX3(); checkConfigPermission(); + if (UpdateHandler.processArguments(args)) { + return; + } + ConfigHolder.init(); Launcher.main(args); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/event/OutOfDateEvent.java b/HMCL/src/main/java/org/jackhuang/hmcl/event/OutOfDateEvent.java deleted file mode 100644 index fe6fbfd4b..000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/event/OutOfDateEvent.java +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Hello Minecraft! Launcher. - * Copyright (C) 2018 huangyuhui - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see {http://www.gnu.org/licenses/}. - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jackhuang.hmcl.util.VersionNumber; - -/** - * - * Result: Deny if do not upgrade HMCL. - * - * @author huangyuhui - */ -public final class OutOfDateEvent extends Event { - private final VersionNumber version; - - public OutOfDateEvent(Object source, VersionNumber version) { - super(source); - this.version = version; - } - - public VersionNumber getVersion() { - return version; - } - - @Override - public boolean hasResult() { - return true; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", getSource()) - .append("version", getVersion()) - .toString(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 504a24ec1..73feac4c0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -179,12 +179,6 @@ public final class Controllers { decorator.showPage(node); } - public static void showUpdate() { - if (stage == null) // shut down - return; - getLeftPaneController().showUpdate(); - } - public static boolean isStopped() { return decorator == null; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java index 78e13b930..84a4c07f7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/CrashWindow.java @@ -31,6 +31,7 @@ import javafx.stage.Stage; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.upgrade.UpdateChecker; /** * @author huangyuhui @@ -39,7 +40,7 @@ public class CrashWindow extends Stage { public CrashWindow(String text) { Label lblCrash = new Label(); - if (false/* UPDATE: current version is outdated */) + if (UpdateChecker.isOutdated()) lblCrash.setText(i18n("launcher.crash_out_dated")); else lblCrash.setText(i18n("launcher.crash")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java index 5addd5a91..3b6febb0d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/LeftPaneController.java @@ -54,6 +54,7 @@ import org.jackhuang.hmcl.ui.construct.ClassTitle; import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; import org.jackhuang.hmcl.ui.construct.IconedItem; import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.MappedObservableList; @@ -92,10 +93,20 @@ public final class LeftPaneController { public LeftPaneController(AdvancedListBox leftPane) { this.leftPane = leftPane; - this.launcherSettingsItem = Lang.apply(new IconedItem(SVG.gear(Theme.blackFillBinding(), 20, 20), i18n("settings.launcher")), iconedItem -> { - iconedItem.prefWidthProperty().bind(leftPane.widthProperty()); - iconedItem.setOnMouseClicked(e -> Controllers.navigate(Controllers.getSettingsPage())); - }); + launcherSettingsItem = new IconedItem(SVG.gear(Theme.blackFillBinding(), 20, 20)); + + launcherSettingsItem.getLabel().textProperty().bind( + new When(UpdateChecker.outdatedProperty()) + .then(i18n("update.found")) + .otherwise(i18n("settings.launcher"))); + + launcherSettingsItem.getLabel().textFillProperty().bind( + new When(UpdateChecker.outdatedProperty()) + .then(Color.RED) + .otherwise(Color.BLACK)); + + launcherSettingsItem.prefWidthProperty().bind(leftPane.widthProperty()); + launcherSettingsItem.setOnMouseClicked(e -> Controllers.navigate(Controllers.getSettingsPage())); leftPane .add(new ClassTitle(i18n("account").toUpperCase(), Lang.apply(new JFXButton(), button -> { @@ -228,11 +239,6 @@ public final class LeftPaneController { Platform.runLater(() -> profilePane.getChildren().setAll(list)); } - public void showUpdate() { - launcherSettingsItem.getLabel().setText(i18n("update.found")); - launcherSettingsItem.getLabel().setTextFill(Color.RED); - } - private boolean checkedModpack = false; private static boolean showNewAccount = true; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java index 55f52a8d7..bc13aaaaf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/SettingsPage.java @@ -20,6 +20,8 @@ package org.jackhuang.hmcl.ui; import com.jfoenix.controls.*; import com.jfoenix.effects.JFXDepthManager; import javafx.application.Platform; +import javafx.beans.InvalidationListener; +import javafx.beans.WeakInvalidationListener; import javafx.beans.binding.Bindings; import javafx.beans.binding.When; import javafx.beans.property.ObjectProperty; @@ -38,10 +40,14 @@ import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.text.Font; import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.construct.FontComboBox; import org.jackhuang.hmcl.ui.construct.MultiFileItem; import org.jackhuang.hmcl.ui.construct.Validator; import org.jackhuang.hmcl.ui.wizard.DecoratorPage; +import org.jackhuang.hmcl.upgrade.RemoteVersion; +import org.jackhuang.hmcl.upgrade.UpdateChecker; +import org.jackhuang.hmcl.upgrade.UpdateHandler; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.i18n.Locales; @@ -104,6 +110,8 @@ public final class SettingsPage extends StackPane implements DecoratorPage { private ObjectProperty selectedProxyType; + private InvalidationListener updateListener; + public SettingsPage() { FXUtils.loadFXML(this, "/assets/fxml/setting.fxml"); @@ -193,8 +201,29 @@ public final class SettingsPage extends StackPane implements DecoratorPage { .orElse(i18n("launcher.common_directory.disabled")), config().commonDirectoryProperty(), config().commonDirTypeProperty())); + // ==== Update ==== FXUtils.installTooltip(btnUpdate, i18n("update.tooltip")); - checkUpdate(); + updateListener = any -> { + btnUpdate.setVisible(UpdateChecker.isOutdated()); + + if (UpdateChecker.isOutdated()) { + lblUpdateSub.setText(i18n("update.newest_version", UpdateChecker.getLatestVersion().getVersion())); + lblUpdateSub.getStyleClass().setAll("update-label"); + + lblUpdate.setText(i18n("update.found")); + lblUpdate.getStyleClass().setAll("update-label"); + } else { + lblUpdateSub.setText(i18n("update.latest")); + lblUpdateSub.getStyleClass().setAll("subtitle-label"); + + lblUpdate.setText(i18n("update")); + lblUpdate.getStyleClass().setAll(); + } + }; + UpdateChecker.latestVersionProperty().addListener(new WeakInvalidationListener(updateListener)); + UpdateChecker.outdatedProperty().addListener(new WeakInvalidationListener(updateListener)); + updateListener.invalidated(null); + // ==== // ==== Background ==== backgroundItem.loadChildren(Collections.singletonList( @@ -236,26 +265,12 @@ public final class SettingsPage extends StackPane implements DecoratorPage { this.title.set(title); } - public void checkUpdate() { - btnUpdate.setVisible(false /* UPDATE: current version is outdated */); - - if (false /* UPDATE: current version is outdated */) { - lblUpdateSub.setText(i18n("update.newest_version", /* UPDATE: latest version number */"")); - lblUpdateSub.getStyleClass().setAll("update-label"); - - lblUpdate.setText(i18n("update.found")); - lblUpdate.getStyleClass().setAll("update-label"); - } else { - lblUpdateSub.setText(i18n("update.latest")); - lblUpdateSub.getStyleClass().setAll("subtitle-label"); - - lblUpdate.setText(i18n("update")); - lblUpdate.getStyleClass().setAll(); - } - } - @FXML private void onUpdate() { - /* UPDATE: Launcher.UPDATE_CHECKER.checkOutdate();*/ + RemoteVersion target = UpdateChecker.getLatestVersion(); + if (target == null) { + return; + } + UpdateHandler.updateFrom(target); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/ExecutableHeaderHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/ExecutableHeaderHelper.java new file mode 100644 index 000000000..7aee62983 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/ExecutableHeaderHelper.java @@ -0,0 +1,124 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.READ; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; +import static java.nio.file.StandardOpenOption.WRITE; +import static org.jackhuang.hmcl.util.Lang.mapOf; +import static org.jackhuang.hmcl.util.Pair.pair; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileChannel.MapMode; +import java.nio.file.Path; +import java.util.Map; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import org.jackhuang.hmcl.util.IOUtils; + +/** + * Helper class for adding/removing executable header from HMCL file. + * + * @author yushijinhun + */ +final class ExecutableHeaderHelper { + private ExecutableHeaderHelper() {} + + private static Map suffix2header = mapOf( + pair("exe", "assets/HMCLauncher.exe")); + + private static Optional getSuffix(Path file) { + String filename = file.getFileName().toString(); + int idxDot = filename.lastIndexOf('.'); + if (idxDot < 0) { + return Optional.empty(); + } else { + return Optional.of(filename.substring(idxDot + 1)); + } + } + + private static Optional readHeader(ZipFile zip, String suffix) throws IOException { + String location = suffix2header.get(suffix); + if (location != null) { + ZipEntry entry = zip.getEntry(location); + if (entry != null && !entry.isDirectory()) { + try (InputStream in = zip.getInputStream(entry)) { + return Optional.of(IOUtils.readFullyAsByteArray(in)); + } + } + } + return Optional.empty(); + } + + private static int detectHeaderLength(ZipFile zip, FileChannel channel) throws IOException { + ByteBuffer buf = channel.map(MapMode.READ_ONLY, 0, channel.size()); + suffixLoop: for (String suffix : suffix2header.keySet()) { + Optional header = readHeader(zip, suffix); + if (header.isPresent()) { + buf.rewind(); + for (byte b : header.get()) { + if (!buf.hasRemaining() || b != buf.get()) { + continue suffixLoop; + } + } + return header.get().length; + } + } + return 0; + } + + /** + * Copies the executable and removes its header. + */ + public static void copyWithoutHeader(Path from, Path to) throws IOException { + try ( + FileChannel in = FileChannel.open(from, READ); + FileChannel out = FileChannel.open(to, CREATE, WRITE, TRUNCATE_EXISTING); + ZipFile zip = new ZipFile(from.toFile()) + ) { + in.transferTo(detectHeaderLength(zip, in), Long.MAX_VALUE, out); + } + } + + /** + * Copies the executable and appends the header according to the suffix. + */ + public static void copyWithHeader(Path from, Path to) throws IOException { + try ( + FileChannel in = FileChannel.open(from, READ); + FileChannel out = FileChannel.open(to, CREATE, WRITE, TRUNCATE_EXISTING); + ZipFile zip = new ZipFile(from.toFile()) + ) { + Optional suffix = getSuffix(to); + if (suffix.isPresent()) { + Optional header = readHeader(zip, suffix.get()); + if (header.isPresent()) { + out.write(ByteBuffer.wrap(header.get())); + } + } + + in.transferTo(detectHeaderLength(zip, in), Long.MAX_VALUE, out); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalRepository.java new file mode 100644 index 000000000..95aaa7336 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalRepository.java @@ -0,0 +1,114 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import static org.jackhuang.hmcl.util.Logging.LOG; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import java.util.logging.Level; + +import org.jackhuang.hmcl.Launcher; +import org.jackhuang.hmcl.task.FileDownloadTask; + +/** + * A class used to manage the local HMCL repository. + * + * @author yushijinhun + */ +final class LocalRepository { + private LocalRepository() {} + + private static Path localStorage = Launcher.HMCL_DIRECTORY.toPath().resolve("hmcl.jar"); + + /** + * Gets the current stored executable in local repository. + */ + public static Optional getStored() { + if (!Files.isRegularFile(localStorage)) { + return Optional.empty(); + } + try (JarFile jar = new JarFile(localStorage.toFile())) { + Attributes attributes = jar.getManifest().getMainAttributes(); + String version = Optional.ofNullable(attributes.getValue("Implementation-Version")) + .orElseThrow(() -> new IOException("Missing Implementation-Version")); + return Optional.of(new LocalVersion(version, localStorage)); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to read HMCL jar: " + localStorage, e); + return Optional.empty(); + } + } + + /** + * Creates a task that downloads the given version to local repository. + */ + public static FileDownloadTask downloadFromRemote(RemoteVersion version) { + try { + return new FileDownloadTask(new URL(version.getUrl()), localStorage.toFile(), version.getIntegrityCheck()); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + } + + /** + * Copies the current HMCL executable to local repository. + */ + public static void downloadFromCurrent() { + Optional current = LocalVersion.current(); + if (current.isPresent()) { + Path currentPath = current.get().getLocation(); + if (!Files.isRegularFile(currentPath)) { + LOG.warning("Failed to download " + current.get() + ", it isn't a file"); + return; + } + if (isSameAsLocalStorage(currentPath)) { + LOG.warning("Trying to download from self, ignored"); + return; + } + LOG.info("Downloading " + current.get()); + try { + Files.createDirectories(localStorage.getParent()); + ExecutableHeaderHelper.copyWithoutHeader(current.get().getLocation(), localStorage); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to download " + current.get(), e); + } + } + } + + /** + * Writes the executable stored in local repository to the given location. + */ + public static void applyTo(Path target) throws IOException { + if (isSameAsLocalStorage(target)) { + throw new IOException("Cannot apply update to self"); + } + + LOG.info("Applying update to " + target); + ExecutableHeaderHelper.copyWithHeader(localStorage, target); + } + + private static boolean isSameAsLocalStorage(Path path) { + return path.toAbsolutePath().equals(localStorage.toAbsolutePath()); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalVersion.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalVersion.java new file mode 100644 index 000000000..bc9fb5b5d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/LocalVersion.java @@ -0,0 +1,96 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.jackhuang.hmcl.util.Logging.LOG; + +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.util.Optional; +import java.util.logging.Level; + +import org.jackhuang.hmcl.Main; +import org.jackhuang.hmcl.Metadata; + +class LocalVersion { + + public static Optional current() { + CodeSource codeSource = Main.class.getProtectionDomain().getCodeSource(); + if (codeSource == null) { + return Optional.empty(); + } + + URL url = codeSource.getLocation(); + if (url == null) { + return Optional.empty(); + } + + String pathString = url.getFile(); + if (pathString.isEmpty()) { + return Optional.empty(); + } + + try { + pathString = URLDecoder.decode(pathString, UTF_8.name()); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + + Path path; + try { + path = Paths.get(pathString); + } catch (InvalidPathException e) { + LOG.log(Level.WARNING, "Invalid path: " + pathString, e); + return Optional.empty(); + } + + if (!Files.isRegularFile(path)) { + return Optional.empty(); + } + + return Optional.of(new LocalVersion(Metadata.VERSION, path)); + } + + private String version; + private Path location; + + public LocalVersion(String version, Path location) { + this.version = version; + this.location = location; + } + + public String getVersion() { + return version; + } + + public Path getLocation() { + return location; + } + + @Override + public String toString() { + return "[" + version + " at " + location + "]"; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/RemoteVersion.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/RemoteVersion.java new file mode 100644 index 000000000..0f5c6e14f --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/RemoteVersion.java @@ -0,0 +1,71 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import java.io.IOException; +import java.util.Optional; + +import org.jackhuang.hmcl.task.FileDownloadTask.IntegrityCheck; +import org.jackhuang.hmcl.util.JsonUtils; +import org.jackhuang.hmcl.util.NetworkUtils; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +public class RemoteVersion { + + public static RemoteVersion fetch(String url) throws IOException { + try { + JsonObject response = JsonUtils.fromNonNullJson(NetworkUtils.doGet(NetworkUtils.toURL(url)), JsonObject.class); + String version = Optional.ofNullable(response.get("version")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("version is missing")); + String downloadUrl = Optional.ofNullable(response.get("jar")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("jar is missing")); + String sha1 = Optional.ofNullable(response.get("jarsha1")).map(JsonElement::getAsString).orElseThrow(() -> new IOException("jarsha1 is missing")); + return new RemoteVersion(version, downloadUrl, new IntegrityCheck("SHA-1", sha1)); + } catch (JsonParseException e) { + throw new IOException("Malformed response", e); + } + } + + private String version; + private String url; + private IntegrityCheck integrityCheck; + + public RemoteVersion(String version, String url, IntegrityCheck integrityCheck) { + this.version = version; + this.url = url; + this.integrityCheck = integrityCheck; + } + + public String getVersion() { + return version; + } + + public String getUrl() { + return url; + } + + public IntegrityCheck getIntegrityCheck() { + return integrityCheck; + } + + @Override + public String toString() { + return "[" + version + " from " + url + "]"; + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java new file mode 100644 index 000000000..6c8adee7d --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateChecker.java @@ -0,0 +1,98 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import static org.jackhuang.hmcl.util.VersionNumber.asVersion; + +import java.io.IOException; + +import org.jackhuang.hmcl.Metadata; + +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.beans.value.ObservableBooleanValue; + +public final class UpdateChecker { + private UpdateChecker() {} + + private static ObjectProperty latestVersion = new SimpleObjectProperty<>(); + private static StringProperty updateSource = new SimpleStringProperty("http://localhost/hmcl/update_link"); + private static BooleanBinding outdated = Bindings.createBooleanBinding( + () -> { + RemoteVersion latest = latestVersion.get(); + if (latest == null || isDevelopmentVersion(Metadata.VERSION)) { + return false; + } else { + return asVersion(latest.getVersion()).compareTo(asVersion(Metadata.VERSION)) > 0; + } + }, + latestVersion); + + public static RemoteVersion getLatestVersion() { + return latestVersion.get(); + } + + public static ReadOnlyObjectProperty latestVersionProperty() { + return latestVersion; + } + + public static String getUpdateSource() { + return updateSource.get(); + } + + public static void setUpdateSource(String updateSource) { + UpdateChecker.updateSource.set(updateSource); + } + + public static StringProperty updateSourceProperty() { + return updateSource; + } + + public static boolean isOutdated() { + return outdated.get(); + } + + public static ObservableBooleanValue outdatedProperty() { + return outdated; + } + + public static void checkUpdate() throws IOException { + String source = updateSource.get(); + if (source == null) { + return; + } + + RemoteVersion fetched = RemoteVersion.fetch(source); + Platform.runLater(() -> { + if (source.equals(updateSource.get())) { + latestVersion.set(fetched); + } + }); + } + + private static boolean isDevelopmentVersion(String version) { + return version.contains("@") || // eg. @develop@ + version.contains("SNAPSHOT"); // eg. 3.1.SNAPSHOT + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java new file mode 100644 index 000000000..fb309a5d6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/upgrade/UpdateHandler.java @@ -0,0 +1,237 @@ +/* + * Hello Minecraft! Launcher. + * Copyright (C) 2018 huangyuhui + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see {http://www.gnu.org/licenses/}. + */ +package org.jackhuang.hmcl.upgrade; + +import static org.jackhuang.hmcl.ui.FXUtils.checkFxUserThread; +import static org.jackhuang.hmcl.util.IntVersionNumber.isIntVersionNumber; +import static org.jackhuang.hmcl.util.Lang.thread; +import static org.jackhuang.hmcl.util.Logging.LOG; +import static org.jackhuang.hmcl.util.VersionNumber.asVersion; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; + +import javax.swing.JOptionPane; + +import org.jackhuang.hmcl.Launcher; +import org.jackhuang.hmcl.Main; +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.MessageBox; +import org.jackhuang.hmcl.util.JavaVersion; +import org.jackhuang.hmcl.util.StringUtils; + +import javafx.application.Platform; +import javafx.scene.layout.Region; + +public final class UpdateHandler { + private UpdateHandler() {} + + /** + * @return whether to exit + */ + public static boolean processArguments(String[] args) { + if (!isIntVersionNumber(Metadata.VERSION)) { + return false; + } + + if (isNestedApplication()) { + // updated from old versions + try { + performMigration(); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to perform migration", e); + JOptionPane.showMessageDialog(null, i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e), "Error", JOptionPane.ERROR_MESSAGE); + } + return true; + } + + if (args.length == 2 && args[0].equals("--apply-to")) { + try { + applyUpdate(Paths.get(args[1])); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to apply update", e); + JOptionPane.showMessageDialog(null, i18n("fatal.apply_update_failure", Metadata.PUBLISH_URL) + "\n" + StringUtils.getStackTrace(e), "Error", JOptionPane.ERROR_MESSAGE); + } + return true; + } + + if (isFirstLaunchAfterUpgrade()) { + JOptionPane.showMessageDialog(null, i18n("fatal.migration_requires_manual_reboot"), "Info", JOptionPane.INFORMATION_MESSAGE); + return true; + } + + Optional local = LocalRepository.getStored(); + if (local.isPresent()) { + int difference = asVersion(local.get().getVersion()).compareTo(asVersion(Metadata.VERSION)); + + if (difference < 0) { + LocalRepository.downloadFromCurrent(); + + } else if (difference > 0) { + Optional current = LocalVersion.current(); + if (current.isPresent()) { + try { + requestUpdate(local.get().getLocation(), current.get().getLocation()); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to update from local repository", e); + return false; + } + return true; + } else { + return false; + } + } + + } else { + LocalRepository.downloadFromCurrent(); + } + + return false; + } + + private static void requestUpdate(Path updateTo, Path self) throws IOException { + startJava(updateTo, "--apply-to", self.toString()); + } + + private static void applyUpdate(Path target) throws IOException { + LocalRepository.applyTo(target); + startJava(target); + } + + private static void startJava(Path jar, String... appArgs) throws IOException { + List commandline = new ArrayList<>(); + commandline.add(JavaVersion.fromCurrentEnvironment().getBinary().getAbsolutePath()); + commandline.add("-jar"); + commandline.add(jar.toAbsolutePath().toString()); + for (String arg : appArgs) { + commandline.add(arg); + } + LOG.info("Starting process: " + commandline); + new ProcessBuilder(commandline) + .directory(Paths.get("").toAbsolutePath().toFile()) + .inheritIO() + .start(); + } + + public static void updateFrom(RemoteVersion version) { + checkFxUserThread(); + + Task task = LocalRepository.downloadFromRemote(version); + TaskExecutor executor = task.executor(); + Region dialog = Controllers.taskDialog(executor, i18n("message.downloading"), "", null); + thread(() -> { + boolean success = task.test(); + Platform.runLater(() -> dialog.fireEvent(new DialogCloseEvent())); + if (success) { + try { + Optional current = LocalVersion.current(); + Optional stored = LocalRepository.getStored(); + if (!current.isPresent()) { + throw new IOException("Failed to find current HMCL location"); + } + if (!stored.isPresent()) { + throw new IOException("Failed to find local repository, this shouldn't happen"); + } + requestUpdate(stored.get().getLocation(), current.get().getLocation()); + System.exit(0); + } catch (IOException e) { + LOG.log(Level.WARNING, "Failed to update to " + version, e); + Platform.runLater(() -> Controllers.dialog(StringUtils.getStackTrace(e), i18n("update.failed"), MessageBox.ERROR_MESSAGE)); + return; + } + } else { + Throwable e = task.getLastException(); + LOG.log(Level.WARNING, "Failed to update to " + version, e); + Platform.runLater(() -> Controllers.dialog(e.toString(), i18n("update.failed"), MessageBox.ERROR_MESSAGE)); + } + }); + } + + // ==== support for old versions === + private static void performMigration() throws IOException { + LOG.info("Migrating from old versions"); + + Path location = getParentApplicationLocation() + .orElseThrow(() -> new IOException("Failed to get parent application location")); + + Optional local = LocalRepository.getStored(); + if (!local.isPresent() || + asVersion(local.get().getVersion()).compareTo(asVersion(Metadata.VERSION)) < 0) { + LocalRepository.downloadFromCurrent(); + } + local = LocalRepository.getStored(); + if (!local.isPresent()) { + throw new IOException("Failed to find local repository"); + } + + requestUpdate(local.get().getLocation(), location); + } + + /** + * This method must be called from the main thread. + */ + private static boolean isNestedApplication() { + StackTraceElement[] stacktrace = Thread.currentThread().getStackTrace(); + for (int i = 0; i < stacktrace.length; i++) { + StackTraceElement element = stacktrace[i]; + if (Main.class.getName().equals(element.getClassName())) { + // we've reached the main method + if (i + 1 == stacktrace.length) { + return false; + } else { + return true; + } + } + } + return false; + } + + private static Optional getParentApplicationLocation() { + String command = System.getProperty("sun.java.command"); + if (command != null) { + Path path = Paths.get(command); + if (Files.isRegularFile(path)) { + return Optional.of(path.toAbsolutePath()); + } + } + return Optional.empty(); + } + + private static boolean isFirstLaunchAfterUpgrade() { + Optional currentPath = LocalVersion.current().map(LocalVersion::getLocation); + if (currentPath.isPresent()) { + Path updated = Launcher.HMCL_DIRECTORY.toPath().resolve("HMCL-" + Metadata.VERSION + ".jar"); + if (currentPath.get().toAbsolutePath().equals(updated.toAbsolutePath())) { + return true; + } + } + return false; + } + // ==== +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/CrashReporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/CrashReporter.java index 24f84e93b..3b4a2d5f1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/CrashReporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/CrashReporter.java @@ -22,6 +22,8 @@ import javafx.application.Platform; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.ui.CrashWindow; import org.jackhuang.hmcl.ui.construct.MessageBox; +import org.jackhuang.hmcl.upgrade.UpdateChecker; + import static java.util.Collections.newSetFromMap; import static org.jackhuang.hmcl.util.Logging.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -105,7 +107,7 @@ public class CrashReporter implements Thread.UncaughtExceptionHandler { if (checkThrowable(e)) { Platform.runLater(() -> new CrashWindow(text).show()); - if (true /* UPDATE: current version is not outdated */) { + if (!UpdateChecker.isOutdated()) { reportToServer(text); } } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 5d437c476..ef9c2442f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -94,6 +94,8 @@ extension.sh=Bash shell fatal.missing_javafx=JavaFX is missing.\nIf you are using Java 11 or later, please downgrade to Java 8 or 10.\nIf you are using OpenJDK, please ensure OpenJFX is included. fatal.missing_dst_root_ca_x3=The DST Root CA X3 certificate is missing on the current Java platform.\nYou can still use HMCL, but HMCL will be unable to connect to some sites (such as sites that use certificates issued by Let's Encrypt), which may cause HMCL not to function properly.\nPlease upgrade your Java to 8u101 or later to resolve the problem. fatal.config_access_denied=The configuration at "%s" is not readable or writable.\nPlease grant HMCL read and write access to this file and its parent directory. +fatal.migration_requires_manual_reboot=The upgrade is about to be completed. Please reopen HMCL. +fatal.apply_update_failure=We're sorry that HMCL couldn't finish the upgrade because something went wrong.\nBut you can still manually finish the upgrade by downloading HMCL from %s.\nPlease consider reporting this issue to us. folder.config=Configs folder.coremod=Core Mod @@ -309,7 +311,7 @@ settings.type=Version setting type settings.type.special=Specialized version settings(will not affect other versions) update=Update -update.failed=Failed to check for updates. +update.failed=Failed to perform upgrade update.found=Update Available! update.newest_version=Latest version: %s update.latest=This is latest Version. diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 2635abde1..9afce9597 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -94,6 +94,8 @@ extension.sh=Bash 腳本 fatal.missing_javafx=JavaFX 缺失。\n如果您使用的是 Java 11 或更高版本,請降級到 Java 8 或 10。\n如果您使用的是 OpenJDK,請確保其包含 OpenJFX。 fatal.missing_dst_root_ca_x3=當前 Java 平台缺少 DST Root CA X3 證書。\n您依然可以使用 HMCL,但將無法連接到部分站點(如使用 Let's Encrypt 證書的站點),這可能會使 HMCL 無法正常工作。\n請將您的 Java 升級到 8u101 以上以解決此問題。 fatal.config_access_denied=HMCL 無法讀取或寫入位於 "%s" 的配置文件。\n請授予 HMCL 對該文件及其父目錄的讀寫權限。 +fatal.migration_requires_manual_reboot=HMCL 即將完成升級,請重新打開 HMCL。 +fatal.apply_update_failure=我們很抱歉 HMCL 無法自動完成升級,因為出現了一些問題。\n但你依然可以從 %s 處手動下載 HMCL 來完成升級。\n請考慮向我們反饋該問題。 folder.config=配置文件夾 folder.coremod=核心MOD文件夾 @@ -309,7 +311,7 @@ settings.type=版本設置類型 settings.type.special=單獨版本設置(不會影響到其他版本的設定) update=啓動器更新 -update.failed=檢查更新失敗 +update.failed=更新失敗 update.found=發現更新 update.newest_version=最新版本爲:%s update.latest=當前版本爲最新版本 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 1d6f1b651..a870b6e27 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -94,6 +94,8 @@ extension.sh=Bash 脚本 fatal.missing_javafx=JavaFX 缺失。\n如果您使用的是 Java 11 或更高版本,请降级到 Java 8 或 10。\n如果您使用的是 OpenJDK,请确保其包含 OpenJFX。 fatal.missing_dst_root_ca_x3=当前 Java 平台缺少 DST Root CA X3 证书。\n您依然可以使用 HMCL,但将无法连接到部分站点(如使用 Let's Encrypt 证书的站点),这可能会使 HMCL 无法正常工作。\n请将您的 Java 升级到 8u101 以上以解决此问题。 fatal.config_access_denied=HMCL 无法读取或写入位于 "%s" 的配置文件。\n请授予 HMCL 对该文件及其父目录的读写权限。 +fatal.migration_requires_manual_reboot=HMCL 即将完成升级,请重新打开 HMCL。 +fatal.apply_update_failure=我们很抱歉 HMCL 无法自动完成升级,因为出现了一些问题。\n但你依然可以从 %s 处手动下载 HMCL 来完成升级。\n请考虑向我们反馈该问题。 folder.config=配置文件夹 folder.coremod=核心MOD文件夹 @@ -309,7 +311,7 @@ settings.type=版本设置类型 settings.type.special=单独版本设置(不会影响到其他版本的设定) update=启动器更新 -update.failed=检查更新失败 +update.failed=更新失败 update.found=发现更新 update.newest_version=最新版本为:%s update.latest=当前版本为最新版本