diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8db42502b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +gradle/*.sh text eol=lf diff --git a/.gitignore b/.gitignore index f3095c4a0..04fba2efb 100644 --- a/.gitignore +++ b/.gitignore @@ -155,4 +155,5 @@ $RECYCLE.BIN/ /libraries /versions /accounts.dat -/config.json \ No newline at end of file +/config.json +/run diff --git a/README.md b/README.md index 3d24760d5..3adbf729a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,28 @@ You'll probably need a few hours to get everything working. Fortunately, we have For your evaluation, there are sample files provided that you will be able to upload to your site to see if it even works for you. +## Building + +Build with a Java 17 JDK. The Gradle wrapper should also be run by Java 17: + +``` +gradlew.bat build +``` + +For native installer packaging, the bootstrap project provides free per-platform +scripts: + +- Windows: `gradlew.bat :launcher-bootstrap:packageWindows -Pversion=...` (builds `Setup.exe` with NSIS) +- Linux: `./gradlew :launcher-bootstrap:packageLinux -Pversion=...` (builds AppImage + generic `.tar.gz`, optional `.deb` with `-PbuildDeb=true`) +- Linux from Windows + WSL: `gradlew.bat :launcher-bootstrap:packageLinuxWsl -Pversion=... -PwslDistro=Ubuntu` +- macOS: `./gradlew :launcher-bootstrap:packageMac -Pversion=...` (builds `.dmg` and `.tar.gz`) + +All scripts start from the same staged runtime image generated by: + +``` +gradlew.bat :launcher-bootstrap:assembleAppImage +``` + ## Pretty Pictures If you are going to create modpacks with our GUI tool, you'll be seeing this beaut: @@ -70,7 +92,7 @@ Users can select optional mods/features: ![Optional Features](readme/features.png) -The launcher can update itself and you can use it in portable mode too. +The launcher can update itself and stores launcher data in its configured data directory. ### The Light Theme diff --git a/build.bat b/build.bat index 8a44f50d2..408cf78a9 100644 --- a/build.bat +++ b/build.bat @@ -1,3 +1,149 @@ -@echo off -call gradlew clean build -pause \ No newline at end of file +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +set "BUILD_INSTALLER=1" +set "BUILD_WINDOWS=1" +set "BUILD_LINUX=1" +set "LINUX_BACKEND=auto" +set "RESOLVE_ARGS=" +set "ROOT=%~dp0" +set "DOCKER_IMAGE=gradle:8.14.0-jdk17" + +:parse +if "%~1"=="" goto parsed +set "A=%~1" +if /i "!A:~0,2!"=="--" set "A=!A:~2!" +if /i "!A!"=="no-installer" set "BUILD_INSTALLER=0" +if /i "!A!"=="windows" (set "BUILD_WINDOWS=1" & set "BUILD_LINUX=0") +if /i "!A!"=="linux" (set "BUILD_WINDOWS=0" & set "BUILD_LINUX=1") +if /i "!A!"=="docker" set "LINUX_BACKEND=docker" +if /i "!A!"=="wsl" set "LINUX_BACKEND=wsl" +shift +goto parse +:parsed + +if "%BUILD_WINDOWS%%BUILD_LINUX%"=="00" ( + echo ERROR: Nothing to package. + goto :fail +) + +rem Linux-only + Docker: compile and package fully in container (no host JDK). +if "%BUILD_WINDOWS%"=="0" if "%BUILD_LINUX%"=="1" ( + call :pick_linux + if errorlevel 1 goto :fail + if /i "!LINUX_BACKEND_KIND!"=="docker" ( + call :docker_linux_full + set "EXIT_CODE=!ERRORLEVEL!" + pause + exit /b !EXIT_CODE! + ) +) + +if "%BUILD_INSTALLER%"=="0" ( + set "RESOLVE_ARGS=--no-installer" +) else if "%BUILD_WINDOWS%"=="0" ( + set "RESOLVE_ARGS=--no-nsis" +) +call "%ROOT%gradle\resolve-build-env.bat" %RESOLVE_ARGS% +if errorlevel 1 goto :fail +echo Using JAVA_HOME=%JAVA_HOME% + +set "TASKS=" +if "%BUILD_INSTALLER%"=="1" ( + if "%BUILD_WINDOWS%"=="1" set "TASKS=:launcher-bootstrap:packageWindows" + if "%BUILD_LINUX%"=="1" ( + if not defined LINUX_TASK ( + call :pick_linux + if errorlevel 1 goto :fail + ) + if defined TASKS (set "TASKS=!TASKS! !LINUX_TASK!") else set "TASKS=!LINUX_TASK!" + ) + echo Packaging tasks: !TASKS! +) + +call "%ROOT%gradlew" clean build %TASKS% +set "EXIT_CODE=%ERRORLEVEL%" +pause +exit /b %EXIT_CODE% + +:pick_linux +if "%LINUX_BACKEND%"=="docker" ( + call :docker_ok + if errorlevel 1 ( + echo ERROR: --docker set, but Docker unavailable. No WSL fallback. + exit /b 1 + ) + set "LINUX_TASK=:launcher-bootstrap:packageLinuxDocker" + set "LINUX_BACKEND_KIND=docker" + echo Linux backend: Docker + exit /b 0 +) +if "%LINUX_BACKEND%"=="wsl" ( + call :wsl_ok + if errorlevel 1 ( + echo ERROR: --wsl set, but WSL unavailable. No Docker fallback. + exit /b 1 + ) + set "LINUX_TASK=:launcher-bootstrap:packageLinuxWsl" + set "LINUX_BACKEND_KIND=wsl" + echo Linux backend: WSL + exit /b 0 +) +call :docker_ok +if not errorlevel 1 ( + set "LINUX_TASK=:launcher-bootstrap:packageLinuxDocker" + set "LINUX_BACKEND_KIND=docker" + echo Linux backend: Docker + exit /b 0 +) +call :wsl_ok +if not errorlevel 1 ( + set "LINUX_TASK=:launcher-bootstrap:packageLinuxWsl" + set "LINUX_BACKEND_KIND=wsl" + echo Linux backend: WSL + exit /b 0 +) +echo ERROR: Linux packaging needs Docker or WSL. Use --windows to skip. +exit /b 1 + +:docker_linux_full +call :docker_ok +if errorlevel 1 ( + echo ERROR: Docker unavailable. + exit /b 1 +) + +set "REPO_MOUNT=%ROOT:\=/%" +if "%REPO_MOUNT:~-1%"=="/" set "REPO_MOUNT=%REPO_MOUNT:~0,-1%" +set "SCRIPT_MOUNT=%ROOT%gradle\docker-linux-build.sh" +set "SCRIPT_MOUNT=%SCRIPT_MOUNT:\=/%" + +set "BUILD_DEB=false" +set "SKIP_PACKAGE=false" +if "%BUILD_INSTALLER%"=="0" set "SKIP_PACKAGE=true" + +echo Packaging Linux fully in Docker ^(no host JDK^)... +echo Using Docker image: %DOCKER_IMAGE% +docker run --rm --user root --entrypoint bash ^ + -e "BUILD_DEB=%BUILD_DEB%" ^ + -e "SKIP_PACKAGE=%SKIP_PACKAGE%" ^ + -v "%REPO_MOUNT%:/workspace" ^ + -v "%SCRIPT_MOUNT%:/tmp/docker-linux-build.sh:ro" ^ + -w /workspace ^ + "%DOCKER_IMAGE%" ^ + -c "tr -d '\r' nul 2>&1 || exit /b 1 +docker info >nul 2>&1 +exit /b %ERRORLEVEL% + +:wsl_ok +where wsl >nul 2>&1 || exit /b 1 +wsl -- echo WSL_OK >nul 2>&1 +exit /b %ERRORLEVEL% + +:fail +pause +exit /b 1 diff --git a/build.gradle b/build.gradle index 49eea452c..76c5467ca 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { - id "com.github.johnrengelman.shadow" version "7.1.2" - id 'io.freefair.lombok' version '5.3.0' + id "com.gradleup.shadow" version "8.3.6" + id 'io.freefair.lombok' version '8.13.1' } println """ @@ -19,7 +19,7 @@ subprojects { java { toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) + languageVersion.set(JavaLanguageVersion.of(17)) } } @@ -28,7 +28,6 @@ subprojects { } if (JavaVersion.current().isJava8Compatible()) { - // Java 8 turns on doclint which we fail tasks.withType(Javadoc) { options.addStringOption('Xdoclint:none', '-quiet') } @@ -38,38 +37,10 @@ subprojects { workingDir = new File(rootDir, "run/") workingDir.mkdirs() } - - // Work around gradle shadow bug - // see https://github.com/johnrengelman/shadow/issues/713 - afterEvaluate { - startScripts { - dependsOn(shadowJar) - } - - distTar { - dependsOn(shadowJar) - } - - distZip { - dependsOn(shadowJar) - } - - startShadowScripts { - dependsOn(jar) - } - - shadowDistTar { - dependsOn(jar) - } - - shadowDistZip { - dependsOn(jar) - } - } } -task clean { - subprojects { - rootProject.clean.dependsOn tasks.matching { it.name == "clean" } - } +tasks.register('package') { + group = 'distribution' + description = 'Build native launcher installer for the current platform.' + dependsOn ':launcher-bootstrap:package' } diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 249c540bc..6b4f081d6 --- a/build.sh +++ b/build.sh @@ -1,3 +1,22 @@ #!/bin/sh +BUILD_INSTALLER=1 +if [ "$1" = "--no-installer" ] || [ "$1" = "no-installer" ]; then + BUILD_INSTALLER=0 + echo "Skipping native installer packaging." +fi + ./gradlew clean build -read -p "Press any key to continue..." \ No newline at end of file +EXIT_CODE=$? +if [ $EXIT_CODE -ne 0 ]; then + read -p "Press any key to continue..." + exit $EXIT_CODE +fi + +if [ "$BUILD_INSTALLER" = "1" ]; then + echo "Packaging native installer..." + ./gradlew package + EXIT_CODE=$? +fi + +read -p "Press any key to continue..." +exit $EXIT_CODE diff --git a/creator-tools/build.gradle b/creator-tools/build.gradle index 0905e6d0b..d6ead9673 100644 --- a/creator-tools/build.gradle +++ b/creator-tools/build.gradle @@ -1,13 +1,13 @@ plugins { id 'application' - id "com.github.johnrengelman.shadow" + id 'com.gradleup.shadow' id 'io.freefair.lombok' } version = "2.1.0-SNAPSHOT" application { - mainClassName = "com.skcraft.launcher.creator.Creator" + mainClass = "com.skcraft.launcher.creator.Creator" } dependencies { @@ -16,18 +16,10 @@ dependencies { implementation 'com.jidesoft:jide-oss:3.6.18' } + processResources { + def projectVersion = project.version.toString() filesMatching('**/*.properties') { - filter { - it.replace('${project.version}', project.version) - } + filter { it.replace('${project.version}', projectVersion) } } } - -shadowJar { - archiveClassifier.set("") -} - -build { - dependsOn(shadowJar) -} diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/Creator.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/Creator.java index 6f1570761..5ec6bf47c 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/Creator.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/Creator.java @@ -8,6 +8,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; +import com.skcraft.launcher.Configuration; import com.skcraft.launcher.Launcher; import com.skcraft.launcher.creator.controller.WelcomeController; import com.skcraft.launcher.creator.dialog.WelcomeDialog; @@ -15,6 +16,7 @@ import com.skcraft.launcher.creator.model.creator.RecentEntry; import com.skcraft.launcher.creator.model.creator.Workspace; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.swing.LauncherLookAndFeel; import com.skcraft.launcher.swing.SwingHelper; import lombok.Getter; @@ -75,7 +77,9 @@ public static void main(String[] args) throws Exception { SwingUtilities.invokeAndWait(() -> { SwingHelper.setSwingProperties("Modpack Creator"); - SwingHelper.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + File launcherConfigFile = new File(creator.getDataDir(), "launcher/config.json"); + Configuration launcherConfig = Persistence.load(launcherConfigFile, Configuration.class); + LauncherLookAndFeel.install(launcherConfig.getThemeMode()); try { creator.showWelcome(); diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/ManifestEntryController.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/ManifestEntryController.java index c5cc42ee3..c7110aa96 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/ManifestEntryController.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/ManifestEntryController.java @@ -6,6 +6,7 @@ package com.skcraft.launcher.creator.controller; +import com.google.common.base.Strings; import com.skcraft.launcher.creator.dialog.ManifestEntryDialog; import com.skcraft.launcher.creator.model.creator.ManifestEntry; import com.skcraft.launcher.swing.SwingHelper; @@ -28,12 +29,16 @@ public ManifestEntryController(ManifestEntryDialog dialog, ManifestEntry manifes private void copyFrom() { dialog.getIncludeCheck().setSelected(manifestEntry.isSelected()); dialog.getPrioritySpinner().setValue(manifestEntry.getManifestInfo().getPriority()); + SwingHelper.setTextAndResetCaret(dialog.getNewsUrlText(), manifestEntry.getManifestInfo().getNewsUrl()); + SwingHelper.setTextAndResetCaret(dialog.getIconUrlText(), manifestEntry.getManifestInfo().getIconUrl()); SwingHelper.setTextAndResetCaret(dialog.getGameKeysText(), SwingHelper.listToLines(manifestEntry.getGameKeys())); } private void copyTo() { manifestEntry.setSelected(dialog.getIncludeCheck().isSelected()); manifestEntry.getManifestInfo().setPriority((Integer) dialog.getPrioritySpinner().getValue()); + manifestEntry.getManifestInfo().setNewsUrl(Strings.emptyToNull(dialog.getNewsUrlText().getText().trim())); + manifestEntry.getManifestInfo().setIconUrl(Strings.emptyToNull(dialog.getIconUrlText().getText().trim())); manifestEntry.setGameKeys(SwingHelper.linesToList(dialog.getGameKeysText().getText())); } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java index 63f644098..7d6b0d75f 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/PackManagerController.java @@ -15,6 +15,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.skcraft.concurrency.Deferred; import com.skcraft.concurrency.Deferreds; +import com.skcraft.concurrency.ObservableFuture; import com.skcraft.concurrency.SettableProgress; import com.skcraft.launcher.Instance; import com.skcraft.launcher.InstanceList; @@ -22,6 +23,7 @@ import com.skcraft.launcher.auth.OfflineSession; import com.skcraft.launcher.auth.Session; import com.skcraft.launcher.builder.BuilderConfig; +import com.skcraft.launcher.builder.FeaturePattern; import com.skcraft.launcher.builder.FnPatternList; import com.skcraft.launcher.creator.Creator; import com.skcraft.launcher.creator.controller.task.*; @@ -40,6 +42,7 @@ import com.skcraft.launcher.swing.PopupMouseAdapter; import com.skcraft.launcher.swing.SwingHelper; import com.skcraft.launcher.util.MorePaths; +import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; import lombok.Getter; @@ -54,9 +57,11 @@ import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.Calendar; +import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import java.util.regex.Pattern; public class PackManagerController { @@ -159,6 +164,9 @@ private void loadWorkspace() { table.addRowSelectionInterval(0, 0); } + testServer.setListingEntriesSupplier( + () -> workspace != null ? workspace.getPackageListingEntries() : Collections.emptyList()); + return packs; }, SwingExecutor.INSTANCE); @@ -338,7 +346,7 @@ public void mousePressed(MouseEvent e) { if (e.isControlDown()) { SwingHelper.browseDir(optional.get().getDirectory(), frame); } else { - startTest(optional.get(), false); + startTest(optional.get(), false, false); } } } @@ -480,7 +488,7 @@ protected void showPopup(MouseEvent e) { if (optional.isPresent()) { Pack pack = optional.get(); - startTest(pack, false); + startTest(pack, false, false); } }); @@ -489,52 +497,67 @@ protected void showPopup(MouseEvent e) { if (optional.isPresent()) { Pack pack = optional.get(); - startTest(pack, true); + startTest(pack, true, false); } }); - frame.getOptionsMenuItem().addActionListener(e -> { - ConfigurationDialog configDialog = new ConfigurationDialog(frame, launcher); - configDialog.setVisible(true); + frame.getSelectFeaturesMenuItem().addActionListener(e -> { + Optional optional = getSelectedPack(true); + + if (optional.isPresent()) { + Pack pack = optional.get(); + if (!packHasFeatures(pack)) { + SwingHelper.showMessageDialog(frame, + "This pack has no optional features configured.", + "Optional Features", null, JOptionPane.INFORMATION_MESSAGE); + return; + } + startTest(pack, false, true); + } }); - frame.getInstanceOptionsMenuItem().addActionListener(e -> { - Optional selectedPack = getSelectedPack(true); + frame.getVerifyFilesMenuItem().addActionListener(e -> { + Optional optional = getSelectedPack(true); - selectedPack.ifPresent(pack -> { - InstanceList.Enumerator instanceList = launcher.getInstances().createEnumerator(); + if (optional.isPresent()) { + Pack pack = optional.get(); + findTestInstance(pack, instance -> { + new File(instance.getDir(), "update_cache.json").delete(); + instance.setUpdatePending(true); + Persistence.commitAndForget(instance); + startTest(pack, false, false); + }); + } + }); - ListenableFuture future = executor.submit(instanceList); - Futures.addCallback(future, new FutureCallback() { - @Override - public void onSuccess(InstanceList result) { - Instance found = null; + frame.getReinstallModsMenuItem().addActionListener(e -> { + Optional optional = getSelectedPack(true); - for (Instance instance : result.getInstances()) { - if (instance.getName().equals(pack.getCachedConfig().getName())) { - found = instance; - break; - } - } + if (optional.isPresent()) { + Pack pack = optional.get(); + findTestInstance(pack, instance -> { + if (!SwingHelper.confirmDialog(frame, SharedLocale.tr("instance.confirmReinstallMods"), + SharedLocale.tr("confirmTitle"))) { + return; + } - if (found == null) { - SwingHelper.showErrorDialog(frame, "No instance found for that pack - you need " + - "to test the pack first.", "Not Found"); - return; - } + ObservableFuture future = launcher.getInstanceTasks().hardUpdate(frame, instance); + future.addListener(() -> SwingUtilities.invokeLater(() -> startTest(pack, false, false)), + SwingExecutor.INSTANCE); + }); + } + }); - InstanceSettingsDialog.open(frame, found); - } + frame.getOptionsMenuItem().addActionListener(e -> { + ConfigurationDialog configDialog = new ConfigurationDialog(frame, launcher); + configDialog.setVisible(true); + }); - @Override - public void onFailure(Throwable ignored) { - } - }, SwingExecutor.INSTANCE); + frame.getInstanceOptionsMenuItem().addActionListener(e -> { + Optional selectedPack = getSelectedPack(true); - ProgressDialog.showProgress(frame, future, instanceList, "Enumerating instances...", - "Enumerating instances..."); - SwingHelper.addErrorDialogCallback(frame, future); - }); + selectedPack.ifPresent(pack -> findTestInstance(pack, + instance -> InstanceSettingsDialog.open(frame, launcher, instance))); }); frame.getClearInstanceMenuItem().addActionListener(e -> { @@ -672,6 +695,24 @@ private void popupPackMenu(Component component, int x, int y, Pack pack) { menuItem.addActionListener(e -> frame.getInstanceOptionsMenuItem().doClick()); popup.add(menuItem); + popup.addSeparator(); + + if (packHasFeatures(pack)) { + menuItem = new JMenuItem("Optional features..."); + menuItem.addActionListener(e -> frame.getSelectFeaturesMenuItem().doClick()); + popup.add(menuItem); + } + + menuItem = new JMenuItem("Verify files"); + menuItem.addActionListener(e -> frame.getVerifyFilesMenuItem().doClick()); + popup.add(menuItem); + + menuItem = new JMenuItem("Reinstall mods & configs..."); + menuItem.addActionListener(e -> frame.getReinstallModsMenuItem().doClick()); + popup.add(menuItem); + + popup.addSeparator(); + menuItem = new JMenuItem("Build..."); menuItem.addActionListener(e -> frame.getBuildMenuItem().doClick()); popup.add(menuItem); @@ -773,7 +814,7 @@ private void tryAddPackViaDirectory(boolean createNew) { } } - private void startTest(Pack pack, boolean online) { + private void startTest(Pack pack, boolean online, boolean reselectFeatures) { Session session; if (online) { @@ -793,7 +834,8 @@ private void startTest(Pack pack, boolean online) { PackBuilder builder = new PackBuilder(pack, webRoot, version, "staging.json", false, false); InstanceList.Enumerator enumerator = launcher.getInstances().createEnumerator(); - TestLauncher instanceLauncher = new TestLauncher(launcher, frame, pack.getCachedConfig().getName(), session); + TestLauncher instanceLauncher = new TestLauncher(launcher, frame, pack.getCachedConfig().getName(), + session, reselectFeatures); SettableProgress progress = new SettableProgress(builder); @@ -810,6 +852,50 @@ private void startTest(Pack pack, boolean online) { SwingHelper.addErrorDialogCallback(frame, deferred); } + private boolean packHasFeatures(Pack pack) { + BuilderConfig config = pack.getCachedConfig(); + if (config == null) { + return false; + } + List features = config.getFeatures(); + return features != null && !features.isEmpty(); + } + + private void findTestInstance(Pack pack, Consumer callback) { + InstanceList.Enumerator instanceList = launcher.getInstances().createEnumerator(); + + ListenableFuture future = executor.submit(instanceList); + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(InstanceList result) { + Instance found = null; + + for (Instance instance : result.getInstances()) { + if (instance.getName().equals(pack.getCachedConfig().getName())) { + found = instance; + break; + } + } + + if (found == null) { + SwingHelper.showErrorDialog(frame, "No instance found for that pack - you need " + + "to test the pack first.", "Not Found"); + return; + } + + callback.accept(found); + } + + @Override + public void onFailure(Throwable ignored) { + } + }, SwingExecutor.INSTANCE); + + ProgressDialog.showProgress(frame, future, instanceList, "Enumerating instances...", + "Enumerating instances..."); + SwingHelper.addErrorDialogCallback(frame, future); + } + private void buildPack(Pack pack) { String initialVersion = generateVersionFromDate(); BuildOptions options = BuildDialog.showBuildDialog(frame, initialVersion, generateManifestName(pack), distDir); diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/WelcomeController.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/WelcomeController.java index 6a0877990..69e36a11d 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/WelcomeController.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/WelcomeController.java @@ -129,7 +129,7 @@ private void initListeners() { dialog.getRecentList().addMouseListener(new MouseAdapter() { public void mousePressed(MouseEvent e) { if (e.getClickCount() == 2) { - JList table = (JList) e.getSource(); + JList table = dialog.getRecentList(); Point point = e.getPoint(); int selectedIndex = table.locationToIndex(point); if (selectedIndex >= 0) { @@ -148,7 +148,7 @@ public void mousePressed(MouseEvent e) { dialog.getRecentList().addMouseListener(new PopupMouseAdapter() { @Override protected void showPopup(MouseEvent e) { - JList table = (JList) e.getSource(); + JList table = dialog.getRecentList(); Point point = e.getPoint(); int selectedIndex = table.locationToIndex(point); if (selectedIndex >= 0) { diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/ManifestInfoEnumerator.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/ManifestInfoEnumerator.java index 86f6c0f91..856fc9346 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/ManifestInfoEnumerator.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/ManifestInfoEnumerator.java @@ -45,6 +45,8 @@ public List apply(List entries) { for (ManifestEntry entry : entries) { if (entry.getManifestInfo().getLocation().equals(location)) { info.setPriority(entry.getManifestInfo().getPriority()); + info.setNewsUrl(entry.getManifestInfo().getNewsUrl()); + info.setIconUrl(entry.getManifestInfo().getIconUrl()); entry.setManifestInfo(info); found = true; break; diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/TestLauncher.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/TestLauncher.java index f96e983af..0ff78c356 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/TestLauncher.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/controller/task/TestLauncher.java @@ -26,12 +26,18 @@ public class TestLauncher implements Function, ProgressO private final Window window; private final String id; private final Session session; + private final boolean reselectFeatures; public TestLauncher(Launcher launcher, Window window, String id, Session session) { + this(launcher, window, id, session, false); + } + + public TestLauncher(Launcher launcher, Window window, String id, Session session, boolean reselectFeatures) { this.launcher = launcher; this.window = window; this.id = id; this.session = session; + this.reselectFeatures = reselectFeatures; } private Optional findInstance(List instances) { @@ -49,16 +55,22 @@ public Instance apply(InstanceList instanceList) { Optional optional = findInstance(instanceList.getInstances()); if (optional.isPresent()) { + Instance instance = optional.get(); + if (reselectFeatures) { + instance.setUpdatePending(true); + } + LaunchOptions options = new LaunchOptions.Builder() - .setInstance(optional.get()) + .setInstance(instance) .setUpdatePolicy(UpdatePolicy.ALWAYS_UPDATE) .setWindow(window) .setSession(session) + .setReselectFeatures(reselectFeatures) .build(); launcher.getLaunchSupervisor().launch(options); - return optional.get(); + return instance; } else { SwingHelper.showErrorDialog(window, "After generating the necessary files, it appears the modpack can't be found in the " + diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/BuilderConfigDialog.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/BuilderConfigDialog.java index c5c184740..3b8966609 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/BuilderConfigDialog.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/BuilderConfigDialog.java @@ -9,24 +9,57 @@ import com.google.common.base.Strings; import com.jidesoft.swing.SearchableUtils; import com.jidesoft.swing.TableSearchable; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.LauncherUtils; import com.skcraft.launcher.builder.BuilderConfig; import com.skcraft.launcher.builder.FeaturePattern; import com.skcraft.launcher.builder.FnPatternList; +import com.skcraft.launcher.creator.Creator; import com.skcraft.launcher.creator.model.swing.FeaturePatternTableModel; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.model.minecraft.ReleaseList; +import com.skcraft.launcher.model.minecraft.Version; +import com.skcraft.launcher.model.minecraft.runtime.RuntimeInfo; +import com.skcraft.launcher.model.minecraft.runtime.RuntimeList; +import com.skcraft.launcher.model.minecraft.runtime.RuntimePlatform; import com.skcraft.launcher.model.modpack.LaunchModifier; +import com.skcraft.launcher.swing.GroupedComboBox; import com.skcraft.launcher.swing.SwingHelper; import com.skcraft.launcher.swing.TextFieldPopupMenu; +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.HttpRequest; import net.miginfocom.swing.MigLayout; import javax.swing.*; +import javax.swing.border.Border; import java.awt.*; import java.awt.event.KeyEvent; +import java.io.IOException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class BuilderConfigDialog extends JDialog { + private static final Pattern MAJOR_VERSION_PATTERN = Pattern.compile("^(\\d+\\.\\d+)"); + private static final String OTHER_MAJOR_GROUP = "Other"; + private final JTextField nameText = new JTextField(20); private final JTextField titleText = new JTextField(30); - private final JTextField gameVersionText = new JTextField(10); + private final JComboBox gameVersionBox = new JComboBox<>(); + private final JComboBox javaVersionBox = new JComboBox<>(); + private final JSpinner minMemorySpinner = new JSpinner(new SpinnerNumberModel(0, 0, 65536, 128)); + private final JSpinner maxMemorySpinner = new JSpinner(new SpinnerNumberModel(0, 0, 65536, 128)); private final JTextArea launchFlagsArea = new JTextArea(10, 40); private final JTextArea userFilesIncludeArea = new JTextArea(15, 40); private final JTextArea userFilesExcludeArea = new JTextArea(8, 40); @@ -55,10 +88,16 @@ public BuilderConfigDialog(Window parent, BuilderConfig config) { private void initComponents() { nameText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); titleText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); - gameVersionText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); launchFlagsArea.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); userFilesIncludeArea.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); + gameVersionBox.setEditable(true); + gameVersionBox.setRenderer(new GameVersionRenderer()); + Component editorComponent = gameVersionBox.getEditor().getEditorComponent(); + if (editorComponent instanceof JTextField) { + ((JTextField) editorComponent).setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); + } + launchFlagsArea.setFont(nameText.getFont()); userFilesIncludeArea.setFont(nameText.getFont()); userFilesExcludeArea.setFont(nameText.getFont()); @@ -92,8 +131,15 @@ private void initComponents() { return; } - if (gameVersionText.getText().trim().isEmpty()) { - SwingHelper.showErrorDialog(BuilderConfigDialog.this, "The 'Game Version' field must be a Minecraft version.", "Input Error"); + if (getGameVersionText().isEmpty()) { + SwingHelper.showErrorDialog(BuilderConfigDialog.this, + "The 'Game Version' field must be a Minecraft version.", "Input Error"); + return; + } + + RuntimeChoice choice = getSelectedRuntimeChoice(); + if (choice != null && !choice.isDefault() && choice.majorVersion <= 0) { + SwingHelper.showErrorDialog(BuilderConfigDialog.this, "The selected Java version is not usable.", "Input Error"); return; } @@ -120,7 +166,10 @@ private JPanel createMainPanel() { container.add(titleText, "span"); container.add(new JLabel("Game Version:")); - container.add(gameVersionText, "span"); + container.add(gameVersionBox, "span, growx, w 220!"); + + container.add(new JLabel("Java Version:")); + container.add(javaVersionBox, "span"); return container; } @@ -130,6 +179,15 @@ private JPanel createLaunchPanel() { SwingHelper.removeOpaqueness(container); container.setLayout(new MigLayout("insets dialog")); + container.add(new JLabel("Minimum Memory (Xms, MB):")); + container.add(minMemorySpinner, "wrap"); + + container.add(new JLabel("Maximum Memory (Xmx, MB):")); + container.add(maxMemorySpinner, "wrap, gapbottom unrel"); + + SwingHelper.enableSpinnerMouseWheel(minMemorySpinner, maxMemorySpinner); + SwingHelper.linkMinMaxSpinners(minMemorySpinner, maxMemorySpinner); + container.add(new JLabel("Launch Flags:"), "wrap"); container.add(SwingHelper.wrapScrollPane(launchFlagsArea), "span"); @@ -158,53 +216,132 @@ private JPanel createFeaturesPanel() { JButton newButton = new JButton("New..."); JButton editButton = new JButton("Edit..."); JButton deleteButton = new JButton("Delete..."); - - container.add(newButton, "span, split 3, sizegroup bttn"); + JButton moveUpButton = new JButton(SwingHelper.createIcon(Creator.class, "move_up.png")); + JButton moveDownButton = new JButton(SwingHelper.createIcon(Creator.class, "move_down.png")); + moveUpButton.setToolTipText("Move Up"); + moveDownButton.setToolTipText("Move Down"); + moveUpButton.setEnabled(false); + moveDownButton.setEnabled(false); + + container.add(newButton, "span, split 5, sizegroup bttn"); container.add(editButton, "sizegroup bttn"); container.add(deleteButton, "sizegroup bttn"); + container.add(moveUpButton, "w 26!, h 26!"); + container.add(moveDownButton, "w 26!, h 26!"); container.add(SwingHelper.wrapScrollPane(featuresTable), "grow, w 10:100:null, gaptop 10"); + Runnable updateMoveButtons = () -> { + if (featuresModel == null) { + moveUpButton.setEnabled(false); + moveDownButton.setEnabled(false); + return; + } + int index = getSelectedFeatureIndex(); + int rowCount = featuresModel.getRowCount(); + moveUpButton.setEnabled(index > 0); + moveDownButton.setEnabled(index > -1 && index < rowCount - 1); + }; + + featuresTable.getSelectionModel().addListSelectionListener(e -> updateMoveButtons.run()); + newButton.addActionListener(e -> { FeaturePattern pattern = new FeaturePattern(); if (FeaturePatternDialog.showEditor(BuilderConfigDialog.this, pattern)) { featuresModel.addFeature(pattern); + selectFeatureByModelIndex(featuresModel.getRowCount() - 1); + updateMoveButtons.run(); } }); editButton.addActionListener(e -> { - int index = featuresTable.getSelectedRow(); + int index = getSelectedFeatureIndex(); if (index > -1) { FeaturePattern pattern = featuresModel.getFeature(index); FeaturePatternDialog.showEditor(BuilderConfigDialog.this, pattern); featuresModel.fireTableDataChanged(); + selectFeatureByModelIndex(index); + updateMoveButtons.run(); } else { SwingHelper.showErrorDialog(BuilderConfigDialog.this, "Select a feature first.", "No Selection"); } }); deleteButton.addActionListener(e -> { - int index = featuresTable.getSelectedRow(); + int index = getSelectedFeatureIndex(); if (index > -1) { FeaturePattern pattern = featuresModel.getFeature(index); - if (SwingHelper.confirmDialog(BuilderConfigDialog.this, "Are you sure that you want to delete '" + pattern.getFeature().getName() + "'?", "Delete")) { + if (SwingHelper.confirmDialog(BuilderConfigDialog.this, + "Are you sure that you want to delete '" + pattern.getFeature().getName() + "'?", "Delete")) { featuresModel.removeFeature(index); + selectFeatureByModelIndex(Math.min(index, featuresModel.getRowCount() - 1)); + updateMoveButtons.run(); } } else { SwingHelper.showErrorDialog(BuilderConfigDialog.this, "Select a feature first.", "No Selection"); } }); + moveUpButton.addActionListener(e -> { + int index = getSelectedFeatureIndex(); + if (index > 0) { + featuresModel.moveFeatureUp(index); + selectFeatureByModelIndex(index - 1); + updateMoveButtons.run(); + } + }); + + moveDownButton.addActionListener(e -> { + int index = getSelectedFeatureIndex(); + if (index > -1 && index < featuresModel.getRowCount() - 1) { + featuresModel.moveFeatureDown(index); + selectFeatureByModelIndex(index + 1); + updateMoveButtons.run(); + } + }); + return container; } + private int getSelectedFeatureIndex() { + int viewRow = featuresTable.getSelectedRow(); + if (viewRow < 0) { + return -1; + } + return featuresTable.convertRowIndexToModel(viewRow); + } + + private void selectFeatureByModelIndex(int modelIndex) { + if (modelIndex < 0 || modelIndex >= featuresModel.getRowCount()) { + featuresTable.clearSelection(); + return; + } + + int viewIndex = featuresTable.convertRowIndexToView(modelIndex); + if (viewIndex < 0) { + featuresTable.clearSelection(); + return; + } + + featuresTable.setRowSelectionInterval(viewIndex, viewIndex); + featuresTable.scrollRectToVisible(featuresTable.getCellRect(viewIndex, 0, true)); + } + private void copyFrom() { SwingHelper.setTextAndResetCaret(nameText, config.getName()); SwingHelper.setTextAndResetCaret(titleText, config.getTitle()); - SwingHelper.setTextAndResetCaret(gameVersionText, config.getGameVersion()); - SwingHelper.setTextAndResetCaret(launchFlagsArea, SwingHelper.listToLines(config.getLaunchModifier().getFlags())); - SwingHelper.setTextAndResetCaret(userFilesIncludeArea, SwingHelper.listToLines(config.getUserFiles().getInclude())); - SwingHelper.setTextAndResetCaret(userFilesExcludeArea, SwingHelper.listToLines(config.getUserFiles().getExclude())); + loadGameVersions(config.getGameVersion()); + JavaVersion javaVersion = config.getJavaVersion(); + loadRuntimeChoices(javaVersion); + selectRuntimeChoice(javaVersion); + minMemorySpinner.setValue(config.getLaunchModifier().getMinMemory()); + maxMemorySpinner.setValue(config.getLaunchModifier().getMaxMemory()); + SwingHelper.setTextAndResetCaret(launchFlagsArea, + SwingHelper.listToLines(config.getLaunchModifier().getFlags())); + SwingHelper.setTextAndResetCaret(userFilesIncludeArea, + SwingHelper.listToLines(config.getUserFiles().getInclude())); + SwingHelper.setTextAndResetCaret(userFilesExcludeArea, + SwingHelper.listToLines(config.getUserFiles().getExclude())); featuresModel = new FeaturePatternTableModel(config.getFeatures()); featuresTable.setModel(featuresModel); } @@ -212,11 +349,23 @@ private void copyFrom() { private void copyTo() { config.setName(nameText.getText().trim()); config.setTitle(Strings.emptyToNull(titleText.getText().trim())); - config.setGameVersion(gameVersionText.getText().trim()); + config.setGameVersion(getGameVersionText()); + RuntimeChoice choice = getSelectedRuntimeChoice(); + String component = choice != null ? Strings.emptyToNull(choice.component) : null; + if (component == null) { + config.setJavaVersion(null); + } else { + JavaVersion javaVersion = new JavaVersion(); + javaVersion.setComponent(component); + javaVersion.setMajorVersion(choice.majorVersion); + config.setJavaVersion(javaVersion); + } LaunchModifier launchModifier = config.getLaunchModifier(); FnPatternList userFiles = config.getUserFiles(); + launchModifier.setMinMemory((int) minMemorySpinner.getValue()); + launchModifier.setMaxMemory((int) maxMemorySpinner.getValue()); launchModifier.setFlags(SwingHelper.linesToList(launchFlagsArea.getText())); userFiles.setInclude(SwingHelper.linesToList(userFilesIncludeArea.getText())); userFiles.setExclude(SwingHelper.linesToList(userFilesExcludeArea.getText())); @@ -228,4 +377,557 @@ public static boolean showEditor(Window window, BuilderConfig config) { return dialog.saved; } + private String getGameVersionText() { + Component editorComponent = gameVersionBox.getEditor().getEditorComponent(); + if (editorComponent instanceof JTextField) { + return ((JTextField) editorComponent).getText().trim(); + } + Object selected = gameVersionBox.getSelectedItem(); + if (selected instanceof GameVersionOption) { + return ((GameVersionOption) selected).id; + } + return Strings.nullToEmpty(selected != null ? selected.toString() : "").trim(); + } + + private void setGameVersionEditorText(String text) { + Component editorComponent = gameVersionBox.getEditor().getEditorComponent(); + if (editorComponent instanceof JTextField) { + ((JTextField) editorComponent).setText(Strings.nullToEmpty(text)); + } + } + + private void selectGameVersion(String version) { + String selected = Strings.nullToEmpty(version).trim(); + if (selected.isEmpty()) { + gameVersionBox.setSelectedItem(null); + setGameVersionEditorText(""); + return; + } + + ComboBoxModel model = gameVersionBox.getModel(); + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (element instanceof GameVersionOption + && selected.equals(((GameVersionOption) element).id)) { + gameVersionBox.setSelectedItem(element); + setGameVersionEditorText(selected); + return; + } + } + + gameVersionBox.setSelectedItem(null); + setGameVersionEditorText(selected); + } + + private void loadGameVersions(String selectedVersion) { + GroupedComboBox.Model model = new GroupedComboBox.Model(); + + try { + List versions = fetchGameVersions(); + model = buildGroupedGameVersionModel(versions, selectedVersion); + } catch (Exception e) { + SwingHelper.showErrorDialog(this, + "Failed to load Minecraft versions from Mojang. Existing values can still be preserved.", + "Game Versions", e); + String selected = Strings.emptyToNull(Strings.nullToEmpty(selectedVersion).trim()); + if (selected != null) { + model.addGroup(majorGroupForId(selected)); + model.addElement(new GameVersionOption(selected, null)); + } + } + + gameVersionBox.setModel(model); + selectGameVersion(selectedVersion); + } + + private static GroupedComboBox.Model buildGroupedGameVersionModel( + List versions, String selectedVersion) { + GroupedComboBox.Model model = new GroupedComboBox.Model(); + if (versions == null) { + versions = Collections.emptyList(); + } + + List dated = new ArrayList<>(versions); + Map originalIndex = new HashMap<>(); + for (int i = 0; i < dated.size(); i++) { + originalIndex.put(dated.get(i), i); + } + + Comparator descending = (a, b) -> compareByReleaseDate(a, b, originalIndex, false); + + List newestFirst = new ArrayList<>(); + for (Version version : dated) { + String id = version.getId(); + // Skip week-format / other unversioned snapshots (e.g. 24w14a); keep 1.21-pre1, etc. + if (Strings.isNullOrEmpty(id) || parseMajorGroup(id) == null) { + continue; + } + newestFirst.add(version); + } + newestFirst.sort(descending); + + String lastMajor = null; + for (Version version : newestFirst) { + String id = version.getId(); + String major = parseMajorGroup(id); + if (major == null) { + continue; + } + if (!major.equals(lastMajor)) { + model.addGroup(major); + lastMajor = major; + } + model.addElement(new GameVersionOption(id, version.getType())); + } + + String selected = Strings.emptyToNull(Strings.nullToEmpty(selectedVersion).trim()); + if (selected != null && !containsGameVersion(model, selected)) { + insertCustomGameVersion(model, selected); + } + + return model; + } + + private static void insertCustomGameVersion(GroupedComboBox.Model model, String selected) { + String major = majorGroupForId(selected); + int groupIndex = -1; + int insertIndex = model.getSize(); + + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (element instanceof GroupedComboBox.Group) { + if (groupIndex >= 0) { + insertIndex = i; + break; + } + if (major.equals(((GroupedComboBox.Group) element).getLabel())) { + groupIndex = i; + } + } + } + + if (groupIndex < 0) { + model.addGroup(major); + model.addElement(new GameVersionOption(selected, null)); + return; + } + + model.insertElementAt(new GameVersionOption(selected, null), insertIndex); + } + + private static String majorGroupForId(String id) { + String parsed = parseMajorGroup(id); + return parsed != null ? parsed : OTHER_MAJOR_GROUP; + } + + private static String parseMajorGroup(String id) { + Matcher matcher = MAJOR_VERSION_PATTERN.matcher(id); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private static boolean containsGameVersion(ComboBoxModel model, String versionId) { + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (element instanceof GameVersionOption + && versionId.equals(((GameVersionOption) element).id)) { + return true; + } + } + return false; + } + + private static List fetchGameVersions() throws IOException, InterruptedException { + Properties launcherProperties = LauncherUtils.loadProperties( + Launcher.class, "launcher.properties", "com.skcraft.launcher.propertiesFile"); + String versionManifestUrl = launcherProperties.getProperty("versionManifestUrl"); + + ReleaseList releases = HttpRequest.get(HttpRequest.url(versionManifestUrl)) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(ReleaseList.class); + + List versions = releases.getVersions(); + return versions != null ? versions : Collections.emptyList(); + } + + private static int compareByReleaseDate(Version a, Version b, Map originalIndex, + boolean ascending) { + long timeA = parseVersionTime(a); + long timeB = parseVersionTime(b); + boolean datedA = timeA != Long.MIN_VALUE; + boolean datedB = timeB != Long.MIN_VALUE; + + if (datedA && datedB) { + int cmp = Long.compare(timeA, timeB); + return ascending ? cmp : -cmp; + } + // Undated entries always follow dated ones; preserve manifest order among themselves. + if (datedA) { + return -1; + } + if (datedB) { + return 1; + } + + int indexA = originalIndex.getOrDefault(a, 0); + int indexB = originalIndex.getOrDefault(b, 0); + return Integer.compare(indexA, indexB); + } + + private static long parseVersionTime(Version version) { + String timestamp = Strings.emptyToNull(version.getReleaseTime()); + if (timestamp == null) { + timestamp = Strings.emptyToNull(version.getTime()); + } + if (timestamp == null) { + return Long.MIN_VALUE; + } + + try { + return OffsetDateTime.parse(timestamp).toInstant().toEpochMilli(); + } catch (DateTimeParseException ignored) { + } + + try { + return Instant.parse(timestamp).toEpochMilli(); + } catch (DateTimeParseException ignored) { + return Long.MIN_VALUE; + } + } + + private void selectRuntimeChoice(JavaVersion selectedVersion) { + if (selectedVersion == null) { + javaVersionBox.setSelectedIndex(0); + return; + } + + String component = selectedVersion.getComponent(); + ComboBoxModel model = javaVersionBox.getModel(); + for (int i = 0; i < model.getSize(); i++) { + RuntimeChoice choice = model.getElementAt(i); + if (Strings.nullToEmpty(component).equals(choice.component)) { + javaVersionBox.setSelectedItem(choice); + return; + } + } + + for (int i = 0; i < model.getSize(); i++) { + RuntimeChoice choice = model.getElementAt(i); + if (!choice.isDefault() && selectedVersion.getMajorVersion() == choice.majorVersion) { + javaVersionBox.setSelectedItem(choice); + return; + } + } + + javaVersionBox.setSelectedIndex(0); + } + + private RuntimeChoice getSelectedRuntimeChoice() { + return (RuntimeChoice) javaVersionBox.getSelectedItem(); + } + + private void loadRuntimeChoices(JavaVersion selectedVersion) { + DefaultComboBoxModel model = new DefaultComboBoxModel<>(); + model.addElement(RuntimeChoice.useDefault()); + + try { + List runtimeChoices = fetchRuntimeChoices(); + for (RuntimeChoice choice : runtimeChoices) { + model.addElement(choice); + } + } catch (Exception e) { + SwingHelper.showErrorDialog(this, + "Failed to load Java versions from Mojang. Existing values can still be preserved.", + "Java Versions", e); + } + + if (selectedVersion != null && Strings.emptyToNull(selectedVersion.getComponent()) != null + && !containsRuntimeChoice(model, selectedVersion)) { + model.addElement(new RuntimeChoice( + selectedVersion.getComponent(), + selectedVersion.getMajorVersion(), + formatJavaMajorVersion(selectedVersion.getMajorVersion()) + " (saved override)")); + } + + javaVersionBox.setModel(model); + } + + private static boolean containsRuntimeChoice(ComboBoxModel model, JavaVersion selectedVersion) { + for (int i = 0; i < model.getSize(); i++) { + RuntimeChoice choice = model.getElementAt(i); + if (selectedVersion.getComponent().equals(choice.component) + || selectedVersion.getMajorVersion() == choice.majorVersion) { + return true; + } + } + return false; + } + + private static List fetchRuntimeChoices() throws IOException, InterruptedException { + Properties creatorProperties = LauncherUtils.loadProperties( + Creator.class, "creator.properties", "com.skcraft.launcher.creator.propertiesFile"); + String runtimeManifestUrl = Strings.emptyToNull(creatorProperties.getProperty("runtimeManifestUrl")); + if (runtimeManifestUrl == null) { + Properties launcherProperties = LauncherUtils.loadProperties( + Launcher.class, "launcher.properties", "com.skcraft.launcher.propertiesFile"); + runtimeManifestUrl = launcherProperties.getProperty("runtimeManifestUrl"); + } + + RuntimePlatform platform = RuntimePlatform.from(Environment.getInstance()); + RuntimeList runtimes = HttpRequest.get(HttpRequest.url(runtimeManifestUrl)) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(RuntimeList.class); + + Map choicesByMajorVersion = new TreeMap<>(); + if (platform == null || runtimes.getRuntimesByPlatform() == null) { + return new ArrayList<>(); + } + + Map> platformRuntimes = runtimes.getRuntimesByPlatform().get(platform.getId()); + if (platformRuntimes == null) { + return new ArrayList<>(); + } + + for (Map.Entry> entry : platformRuntimes.entrySet()) { + String component = entry.getKey(); + if (!isRuntimeComponent(component) || entry.getValue() == null || entry.getValue().isEmpty()) { + continue; + } + + RuntimeInfo info = entry.getValue().get(0); + String version = info.getVersion() != null ? info.getVersion().getName() : null; + int majorVersion = detectMajorVersion(version); + if (majorVersion <= 0) { + continue; + } + + RuntimeChoice choice = new RuntimeChoice(component, majorVersion, formatJavaMajorVersion(majorVersion)); + RuntimeChoice current = choicesByMajorVersion.get(majorVersion); + if (current == null || isPreferredRuntimeChoice(choice, current)) { + choicesByMajorVersion.put(majorVersion, choice); + } + } + + return new ArrayList<>(choicesByMajorVersion.values()); + } + + private static boolean isRuntimeComponent(String component) { + return "jre-legacy".equals(component) || component.startsWith("java-runtime-"); + } + + private static int detectMajorVersion(String version) { + if (version == null || version.isEmpty()) { + return 0; + } + + if (version.startsWith("1.") && version.length() > 2) { + return parseLeadingInt(version.substring(2)); + } + + return parseLeadingInt(version); + } + + private static boolean isPreferredRuntimeChoice(RuntimeChoice candidate, RuntimeChoice current) { + int candidatePriority = getRuntimeComponentPriority(candidate.component); + int currentPriority = getRuntimeComponentPriority(current.component); + if (candidatePriority != currentPriority) { + return candidatePriority < currentPriority; + } + + return candidate.component.compareTo(current.component) > 0; + } + + private static int getRuntimeComponentPriority(String component) { + if ("jre-legacy".equals(component)) { + return 0; + } + + if (component.contains("snapshot")) { + return 2; + } + + return 1; + } + + private static int parseLeadingInt(String text) { + StringBuilder value = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (!Character.isDigit(c)) { + break; + } + value.append(c); + } + + if (value.length() == 0) { + return 0; + } + + return Integer.parseInt(value.toString()); + } + + private static String formatJavaMajorVersion(int majorVersion) { + return majorVersion == 8 ? "1.8" : String.valueOf(majorVersion); + } + + private static final class GameVersionOption { + private final String id; + private final String type; + + private GameVersionOption(String id, String type) { + this.id = id; + this.type = type; + } + + @Override + public String toString() { + return id; + } + } + + private static final class GameVersionRenderer implements ListCellRenderer { + private final JPanel panel = new JPanel(new BorderLayout(8, 0)); + private final JLabel nameLabel = new JLabel(); + private final JLabel typeLabel = new JLabel(); + + private GameVersionRenderer() { + panel.setOpaque(true); + nameLabel.setOpaque(false); + typeLabel.setOpaque(false); + panel.add(nameLabel, BorderLayout.WEST); + panel.add(typeLabel, BorderLayout.EAST); + } + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + boolean inPopup = index >= 0; + boolean isGroup = value instanceof GroupedComboBox.Group; + + Color background; + Color foreground; + if (!isGroup && isSelected) { + background = list.getSelectionBackground(); + foreground = list.getSelectionForeground(); + } else { + background = list.getBackground(); + foreground = list.getForeground(); + } + panel.setBackground(background); + nameLabel.setForeground(foreground); + typeLabel.setForeground(foreground); + + if (value == null) { + nameLabel.setText(""); + typeLabel.setText(""); + typeLabel.setVisible(false); + panel.setBorder(BorderFactory.createEmptyBorder()); + return panel; + } + + if (isGroup) { + nameLabel.setText(((GroupedComboBox.Group) value).getLabel()); + nameLabel.setFont(list.getFont().deriveFont(Font.BOLD)); + Color disabled = UIManager.getColor("Label.disabledForeground"); + if (disabled != null) { + nameLabel.setForeground(disabled); + } + typeLabel.setText(""); + typeLabel.setVisible(false); + if (inPopup && index > 0) { + Color separator = UIManager.getColor("Separator.foreground"); + if (separator == null) { + separator = UIManager.getColor("Component.borderColor"); + } + Border line = separator != null + ? BorderFactory.createMatteBorder(1, 0, 0, 0, separator) + : BorderFactory.createEmptyBorder(); + panel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(6, 0, 0, 0), + BorderFactory.createCompoundBorder(line, + BorderFactory.createEmptyBorder(2, 8, 2, 8)))); + } else if (inPopup) { + panel.setBorder(BorderFactory.createEmptyBorder(2, 8, 2, 8)); + } else { + panel.setBorder(BorderFactory.createEmptyBorder()); + } + return panel; + } + + GameVersionOption option = (GameVersionOption) value; + nameLabel.setText(option.id); + nameLabel.setFont(list.getFont().deriveFont(Font.PLAIN)); + String typeText = formatVersionType(option.type); + typeLabel.setText(typeText); + typeLabel.setVisible(inPopup && !typeText.isEmpty()); + typeLabel.setFont(list.getFont().deriveFont(Font.PLAIN)); + if (!isSelected) { + Color muted = UIManager.getColor("Label.disabledForeground"); + if (muted != null) { + typeLabel.setForeground(muted); + } + } + panel.setBorder(inPopup + ? BorderFactory.createEmptyBorder(2, 16, 2, 8) + : BorderFactory.createEmptyBorder()); + return panel; + } + } + + private static String formatVersionType(String type) { + if (Strings.isNullOrEmpty(type)) { + return ""; + } + String normalized = type.trim().replace('_', ' '); + if (normalized.isEmpty()) { + return ""; + } + StringBuilder formatted = new StringBuilder(normalized.length()); + boolean capitalizeNext = true; + for (int i = 0; i < normalized.length(); i++) { + char c = normalized.charAt(i); + if (Character.isWhitespace(c)) { + formatted.append(c); + capitalizeNext = true; + } else if (capitalizeNext) { + formatted.append(Character.toUpperCase(c)); + capitalizeNext = false; + } else { + formatted.append(Character.toLowerCase(c)); + } + } + return formatted.toString(); + } + + private static class RuntimeChoice { + private final String component; + private final int majorVersion; + private final String label; + + private RuntimeChoice(String component, int majorVersion, String label) { + this.component = component; + this.majorVersion = majorVersion; + this.label = label; + } + + private static RuntimeChoice useDefault() { + return new RuntimeChoice("", 0, "Use Minecraft default"); + } + + private boolean isDefault() { + return component.isEmpty(); + } + + @Override + public String toString() { + return label; + } + } + } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/FeaturePatternDialog.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/FeaturePatternDialog.java index f57a783ea..5a23b6b14 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/FeaturePatternDialog.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/FeaturePatternDialog.java @@ -26,8 +26,9 @@ public class FeaturePatternDialog extends JDialog { private final JTextField nameText = new JTextField(20); private final JTextArea descArea = new JTextArea(3, 40); - private final JComboBox recommendationCombo = new JComboBox(new RecommendationComboBoxModel()); + private final JComboBox recommendationCombo = new JComboBox(new RecommendationComboBoxModel()); private final JCheckBox selectedCheck = new JCheckBox("Selected by default"); + private final JSpinner minMemoryDeltaSpinner = new JSpinner(new SpinnerNumberModel(0, 0, 65536, 128)); private final JTextArea includeArea = new JTextArea(8, 40); private final JTextArea excludeArea = new JTextArea(3, 40); @@ -69,6 +70,10 @@ private void initComponents() { container.add(selectedCheck, "span"); + container.add(new JLabel("Min Memory Increase (MB):")); + container.add(minMemoryDeltaSpinner, "span"); + SwingHelper.enableSpinnerMouseWheel(minMemoryDeltaSpinner); + container.add(new JLabel("Description:"), "wrap"); container.add(SwingHelper.wrapScrollPane(descArea), "span"); @@ -126,6 +131,7 @@ private void copyFrom() { SwingHelper.setTextAndResetCaret(descArea, pattern.getFeature().getDescription()); recommendationCombo.setSelectedItem(pattern.getFeature().getRecommendation()); selectedCheck.setSelected(pattern.getFeature().isSelected()); + minMemoryDeltaSpinner.setValue(pattern.getFeature().getMinMemoryDelta()); SwingHelper.setTextAndResetCaret(includeArea, NEW_LINE_JOINER.join(pattern.getFilePatterns().getInclude())); SwingHelper.setTextAndResetCaret(excludeArea, NEW_LINE_JOINER.join(pattern.getFilePatterns().getExclude())); } @@ -135,6 +141,7 @@ private void copyTo() { pattern.getFeature().setDescription(descArea.getText().trim()); pattern.getFeature().setRecommendation((Recommendation) recommendationCombo.getSelectedItem()); pattern.getFeature().setSelected(selectedCheck.isSelected()); + pattern.getFeature().setMinMemoryDelta((int) minMemoryDeltaSpinner.getValue()); pattern.getFilePatterns().setInclude(SwingHelper.linesToList(includeArea.getText())); pattern.getFilePatterns().setExclude(SwingHelper.linesToList(excludeArea.getText())); } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/ManifestEntryDialog.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/ManifestEntryDialog.java index 60f3cabbf..3d3be8d76 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/ManifestEntryDialog.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/ManifestEntryDialog.java @@ -18,6 +18,8 @@ public class ManifestEntryDialog extends JDialog { @Getter private final JSpinner prioritySpinner = new JSpinner(); + @Getter private final JTextField newsUrlText = new JTextField(30); + @Getter private final JTextField iconUrlText = new JTextField(30); @Getter private final JTextArea gameKeysText = new JTextArea(5, 30); @Getter private final JCheckBox includeCheck = new JCheckBox("Include in package listing"); @@ -38,7 +40,10 @@ private void initComponents() { gameKeysText.setFont(prioritySpinner.getFont()); prioritySpinner.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); + newsUrlText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); + iconUrlText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); gameKeysText.setComponentPopupMenu(TextFieldPopupMenu.INSTANCE); + SwingHelper.enableSpinnerMouseWheel(prioritySpinner); JPanel container = new JPanel(); container.setLayout(new MigLayout("insets dialog")); @@ -49,6 +54,12 @@ private void initComponents() { container.add(prioritySpinner, "span, split 2, w 50"); container.add(new JLabel("(Greater is higher)")); + container.add(new JLabel("News URL:")); + container.add(newsUrlText, "span"); + + container.add(new JLabel("Icon URL:")); + container.add(iconUrlText, "span"); + container.add(new JLabel("Game Keys:")); container.add(SwingHelper.wrapScrollPane(gameKeysText), "span"); diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/PackManagerFrame.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/PackManagerFrame.java index 4ad17988b..25232e9b2 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/PackManagerFrame.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/dialog/PackManagerFrame.java @@ -17,6 +17,7 @@ import javax.swing.*; import java.awt.*; +import java.awt.event.InputEvent; import java.awt.event.KeyEvent; public class PackManagerFrame extends JFrame { @@ -42,6 +43,9 @@ public class PackManagerFrame extends JFrame { @Getter private final JMenuItem checkProblemsMenuItem = new JMenuItem("Scan for Problems..."); @Getter private final JMenuItem testMenuItem = new JMenuItem("Test"); @Getter private final JMenuItem testOnlineMenuItem = new JMenuItem("Test Online"); + @Getter private final JMenuItem selectFeaturesMenuItem = new JMenuItem("Optional features..."); + @Getter private final JMenuItem verifyFilesMenuItem = new JMenuItem("Verify files"); + @Getter private final JMenuItem reinstallModsMenuItem = new JMenuItem("Reinstall mods & configs..."); @Getter private final JMenuItem optionsMenuItem = new JMenuItem("Test Launcher Options..."); @Getter private final JMenuItem instanceOptionsMenuItem = new JMenuItem("Test Instance Options..."); @Getter private final JMenuItem clearInstanceMenuItem = new JMenuItem("Delete Test Launcher Instances"); @@ -111,16 +115,16 @@ private JToolBar createToolbar() { } private void initMenu() { - int ctrlKeyMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask(); + int ctrlKeyMask = Toolkit.getDefaultToolkit().getMenuShortcutKeyMaskEx(); newPackMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, ctrlKeyMask)); - newPackAtLocationMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, ctrlKeyMask | Event.SHIFT_MASK)); + newPackAtLocationMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, ctrlKeyMask | InputEvent.SHIFT_DOWN_MASK)); editConfigMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_E, ctrlKeyMask)); - openFolderMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, ctrlKeyMask | Event.SHIFT_MASK)); + openFolderMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, ctrlKeyMask | InputEvent.SHIFT_DOWN_MASK)); testMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, 0)); testOnlineMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F6, 0)); - buildMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F10, Event.SHIFT_MASK)); - deployServerMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F9, Event.SHIFT_MASK)); + buildMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F10, InputEvent.SHIFT_DOWN_MASK)); + deployServerMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F9, InputEvent.SHIFT_DOWN_MASK)); docsMenuItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0)); JMenuBar menuBar; @@ -163,6 +167,10 @@ private void initMenu() { menu.add(testMenuItem); menu.add(testOnlineMenuItem); menu.addSeparator(); + menu.add(selectFeaturesMenuItem); + menu.add(verifyFilesMenuItem); + menu.add(reinstallModsMenuItem); + menu.addSeparator(); menu.add(optionsMenuItem); menu.add(instanceOptionsMenuItem); menu.addSeparator(); diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/FeaturePatternTableModel.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/FeaturePatternTableModel.java index 5eb2879b6..aa6a7e02d 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/FeaturePatternTableModel.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/FeaturePatternTableModel.java @@ -9,6 +9,7 @@ import com.skcraft.launcher.builder.FeaturePattern; import javax.swing.table.AbstractTableModel; +import java.util.Collections; import java.util.List; public class FeaturePatternTableModel extends AbstractTableModel { @@ -94,4 +95,22 @@ public void removeFeature(int index) { fireTableDataChanged(); } + public void moveFeatureUp(int index) { + if (index <= 0 || index >= features.size()) { + return; + } + + Collections.swap(features, index, index - 1); + fireTableRowsUpdated(index - 1, index); + } + + public void moveFeatureDown(int index) { + if (index < 0 || index >= features.size() - 1) { + return; + } + + Collections.swap(features, index, index + 1); + fireTableRowsUpdated(index, index + 1); + } + } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ListingType.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ListingType.java index 0056e8f49..e281ffc3e 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ListingType.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ListingType.java @@ -58,6 +58,12 @@ public String generate(List entries) throws IOException { builder.append(" 'version' => '").append(escape(info.getVersion())).append("',\r\n"); builder.append(" 'priority' => ").append(info.getPriority()).append(",\r\n"); builder.append(" 'location' => '").append(escape(info.getLocation())).append("',\r\n"); + if (info.getNewsUrl() != null) { + builder.append(" 'newsUrl' => '").append(escape(info.getNewsUrl())).append("',\r\n"); + } + if (info.getIconUrl() != null) { + builder.append(" 'iconUrl' => '").append(escape(info.getIconUrl())).append("',\r\n"); + } builder.append(");\r\n\r\n"); } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ManifestEntryTableModel.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ManifestEntryTableModel.java index 304fc2b1d..c639f2ea5 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ManifestEntryTableModel.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/ManifestEntryTableModel.java @@ -33,8 +33,10 @@ public String getColumnName(int columnIndex) { case 3: return "Priority"; case 4: - return "Location"; + return "News URL"; case 5: + return "Location"; + case 6: return "Game Keys"; default: return null; @@ -56,10 +58,13 @@ public Class getColumnClass(int columnIndex) { return String.class; case 5: return String.class; + case 6: + return String.class; default: return null; } } + @Override public void setValueAt(Object value, int rowIndex, int columnIndex) { switch (columnIndex) { @@ -88,7 +93,7 @@ public int getRowCount() { @Override public int getColumnCount() { - return 6; + return 7; } @Override @@ -110,8 +115,10 @@ public Object getValueAt(int rowIndex, int columnIndex) { case 3: return entry.getManifestInfo().getPriority(); case 4: - return entry.getManifestInfo().getLocation(); + return entry.getManifestInfo().getNewsUrl(); case 5: + return entry.getManifestInfo().getLocation(); + case 6: List gameKeys = entry.getGameKeys(); return gameKeys != null ? GAME_KEY_JOINER.join(gameKeys) : ""; default: diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/RecommendationComboBoxModel.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/RecommendationComboBoxModel.java index 2bd78c681..29e08a27c 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/RecommendationComboBoxModel.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/model/swing/RecommendationComboBoxModel.java @@ -8,9 +8,10 @@ import com.skcraft.launcher.model.modpack.Feature.Recommendation; -import javax.swing.*; +import javax.swing.AbstractListModel; +import javax.swing.ComboBoxModel; -public class RecommendationComboBoxModel extends AbstractListModel implements ComboBoxModel { +public class RecommendationComboBoxModel extends AbstractListModel implements ComboBoxModel { private Recommendation selection; @@ -30,7 +31,7 @@ public int getSize() { } @Override - public Object getElementAt(int index) { + public Recommendation getElementAt(int index) { if (index == 0) { return null; } else { diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/PackagesHandler.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/PackagesHandler.java index 2468c0c24..88484b6ce 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/PackagesHandler.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/PackagesHandler.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Lists; +import com.skcraft.launcher.creator.model.creator.ManifestEntry; import com.skcraft.launcher.model.modpack.Manifest; import com.skcraft.launcher.model.modpack.ManifestInfo; import com.skcraft.launcher.model.modpack.PackageList; @@ -20,18 +21,27 @@ import java.io.File; import java.io.FileFilter; import java.io.IOException; +import java.util.Collections; import java.util.List; +import java.util.function.Supplier; class PackagesHandler extends AbstractHandler { private final ObjectMapper mapper; private final File baseDir; + private volatile Supplier> listingEntriesSupplier = Collections::emptyList; public PackagesHandler(ObjectMapper mapper, File baseDir) { this.mapper = mapper; this.baseDir = baseDir; } + public void setListingEntriesSupplier(Supplier> listingEntriesSupplier) { + this.listingEntriesSupplier = listingEntriesSupplier != null + ? listingEntriesSupplier + : Collections::emptyList; + } + public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { response.setContentType("text/plain; charset=utf-8"); response.setStatus(HttpServletResponse.SC_OK); @@ -40,6 +50,11 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques PackageList packageList = new PackageList(); packageList.setPackages(packages); + List listingEntries = listingEntriesSupplier.get(); + if (listingEntries == null) { + listingEntries = Collections.emptyList(); + } + File[] files = baseDir.listFiles(new PackageFileFilter()); if (files != null) { for (File file : files) { @@ -49,6 +64,17 @@ public void handle(String target, Request baseRequest, HttpServletRequest reques info.setTitle(manifest.getTitle()); info.setVersion(manifest.getVersion()); info.setLocation(file.getName()); + + for (ManifestEntry entry : listingEntries) { + ManifestInfo listed = entry.getManifestInfo(); + if (listed != null && file.getName().equals(listed.getLocation())) { + info.setPriority(listed.getPriority()); + info.setNewsUrl(listed.getNewsUrl()); + info.setIconUrl(listed.getIconUrl()); + break; + } + } + packages.add(info); } } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServer.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServer.java index a2750e1e2..3ac332ab7 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServer.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServer.java @@ -6,16 +6,26 @@ package com.skcraft.launcher.creator.server; +import com.skcraft.launcher.creator.model.creator.ManifestEntry; import lombok.Getter; import org.eclipse.jetty.server.Server; import org.eclipse.jetty.server.ServerConnector; +import java.util.List; +import java.util.function.Supplier; + public class TestServer { @Getter private final Server server; + private final PackagesHandler packagesHandler; - public TestServer(Server server) { + public TestServer(Server server, PackagesHandler packagesHandler) { this.server = server; + this.packagesHandler = packagesHandler; + } + + public void setListingEntriesSupplier(Supplier> listingEntriesSupplier) { + packagesHandler.setListingEntriesSupplier(listingEntriesSupplier); } public void start() throws Exception { diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServerBuilder.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServerBuilder.java index 2a02ad4cc..301efbe85 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServerBuilder.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/server/TestServerBuilder.java @@ -54,9 +54,11 @@ public TestServer build() { rootContext.setContextPath("/"); rootContext.setHandler(resourceHandler); + PackagesHandler packagesHandler = new PackagesHandler(mapper, baseDir); + ContextHandler packagesContext = new ContextHandler("/packages.json"); packagesContext.setAllowNullPathInfo(true); - packagesContext.setHandler(new PackagesHandler(mapper, baseDir)); + packagesContext.setHandler(packagesHandler); ContextHandler latestContext = new ContextHandler("/latest.json"); latestContext.setAllowNullPathInfo(true); @@ -75,7 +77,7 @@ public TestServer build() { server.addBean(new ErrorHandler()); - return new TestServer(server); + return new TestServer(server, packagesHandler); } } diff --git a/creator-tools/src/main/java/com/skcraft/launcher/creator/swing/BorderCellRenderer.java b/creator-tools/src/main/java/com/skcraft/launcher/creator/swing/BorderCellRenderer.java index 00ca50917..230ff7bf8 100644 --- a/creator-tools/src/main/java/com/skcraft/launcher/creator/swing/BorderCellRenderer.java +++ b/creator-tools/src/main/java/com/skcraft/launcher/creator/swing/BorderCellRenderer.java @@ -10,7 +10,7 @@ import javax.swing.border.Border; import java.awt.*; -public class BorderCellRenderer implements ListCellRenderer { +public class BorderCellRenderer implements ListCellRenderer { private final Border border; private final DefaultListCellRenderer defaultRenderer = new DefaultListCellRenderer(); @@ -19,7 +19,8 @@ public BorderCellRenderer(Border border) { this.border = border; } - public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { JLabel renderer = (JLabel) defaultRenderer.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); renderer.setBorder(border); return renderer; diff --git a/creator-tools/src/main/resources/com/skcraft/launcher/creator/creator.properties b/creator-tools/src/main/resources/com/skcraft/launcher/creator/creator.properties index 4b5c12b3c..7b353ae60 100644 --- a/creator-tools/src/main/resources/com/skcraft/launcher/creator/creator.properties +++ b/creator-tools/src/main/resources/com/skcraft/launcher/creator/creator.properties @@ -5,3 +5,4 @@ # version=${project.version} +runtimeManifestUrl=https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json diff --git a/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_down.png b/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_down.png new file mode 100644 index 000000000..db29a751e Binary files /dev/null and b/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_down.png differ diff --git a/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_up.png b/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_up.png new file mode 100644 index 000000000..f235b693f Binary files /dev/null and b/creator-tools/src/main/resources/com/skcraft/launcher/creator/move_up.png differ diff --git a/gradle/browser-runtime.gradle b/gradle/browser-runtime.gradle new file mode 100644 index 000000000..e6a305be7 --- /dev/null +++ b/gradle/browser-runtime.gradle @@ -0,0 +1,155 @@ +def webViewVersion = '1.0.10' +def swtVersion = '3.130.0' +def swtGroup = 'org.eclipse.platform' +def swtRuntimeRoot = 'natives/swt' +def swtChecksumResourcePath = 'META-INF/skcraft/swt-checksums.properties' + +def hostOs = System.getProperty('os.name').toLowerCase(Locale.ROOT) +def isMacHost = hostOs.contains('mac') || hostOs.contains('darwin') +def hostBackend = isMacHost ? 'wkwebview' : 'swt' +def stageHostSwt = hostBackend == 'swt' + +// Portable launcher checksum data is needed only by SWT runtime platforms. +def swtArtifacts = [ + 'org.eclipse.swt.win32.win32.x86_64', + 'org.eclipse.swt.win32.win32.aarch64', + 'org.eclipse.swt.gtk.linux.x86_64', + 'org.eclipse.swt.gtk.linux.aarch64' +] + +def resolveHostSwtArtifact = { + String arch = System.getProperty('os.arch').toLowerCase(Locale.ROOT) + boolean arm64 = arch.contains('aarch64') || arch.contains('arm64') + + if (hostOs.contains('win')) { + return arm64 ? 'org.eclipse.swt.win32.win32.aarch64' : 'org.eclipse.swt.win32.win32.x86_64' + } else if (isMacHost) { + // Compile-only: macOS runtime uses WKWebView, not this SWT artifact. + return arm64 ? 'org.eclipse.swt.cocoa.macosx.aarch64' : 'org.eclipse.swt.cocoa.macosx.x86_64' + } else if (hostOs.contains('linux')) { + return arm64 ? 'org.eclipse.swt.gtk.linux.aarch64' : 'org.eclipse.swt.gtk.linux.x86_64' + } + + throw new GradleException("Unsupported browser host platform: os=${hostOs}, arch=${arch}") +} + +def hostSwtArtifact = resolveHostSwtArtifact() + +def swtDependency = { String swtArtifact -> + "${swtGroup}:${swtArtifact}:${swtVersion}" +} + +def swtJarFileName = { String swtArtifact -> + "${swtArtifact}-${swtVersion}.jar" +} + +def swtRuntimePath = { String swtArtifact -> + "${swtRuntimeRoot}/${swtVersion}/${swtArtifact}" +} + +def addSwtDependency = { String configurationName, String swtArtifact -> + dependencies.add(configurationName, swtDependency(swtArtifact)) { + transitive = false + } +} + +def configureSwtArtifacts = { Configuration configuration, Iterable artifacts -> + configuration.canBeConsumed = false + configuration.canBeResolved = true + artifacts.each { swtArtifact -> + addSwtDependency(configuration.name, swtArtifact) + } +} + +def addHostSwtRuntime = { String runtimeConfigurationName = 'swtHostRuntime' -> + def runtimeConfiguration = configurations.maybeCreate(runtimeConfigurationName) + runtimeConfiguration.canBeConsumed = false + runtimeConfiguration.canBeResolved = true + + // SWT source still compiles on macOS, although WKWebView is selected at runtime. + addSwtDependency('compileOnly', hostSwtArtifact) + + if (stageHostSwt) { + addSwtDependency(runtimeConfiguration.name, hostSwtArtifact) + tasks.withType(JavaExec).configureEach { + classpath += runtimeConfiguration + } + } +} + +def sha1Hex = { File file -> + def digest = java.security.MessageDigest.getInstance('SHA-1') + file.withInputStream { stream -> + byte[] buffer = new byte[8192] + int read + while ((read = stream.read(buffer)) != -1) { + digest.update(buffer, 0, read) + } + } + digest.digest().collect { String.format('%02x', it & 0xff) }.join() +} + +ext.addSwtChecksumResource = { String configurationName = 'swtChecksumArtifacts' -> + def checksumConfiguration = configurations.maybeCreate(configurationName) + configureSwtArtifacts(checksumConfiguration, swtArtifacts) + + def generatedSwtChecksumsDir = layout.buildDirectory.dir('generated/swt-checksums') + def checksumTask = tasks.register('generateSwtChecksums') { + inputs.property('swtVersion', swtVersion) + inputs.property('swtRuntimeRoot', swtRuntimeRoot) + inputs.property('swtArtifacts', swtArtifacts) + inputs.files(checksumConfiguration) + outputs.dir(generatedSwtChecksumsDir) + + doLast { + def checksumFile = generatedSwtChecksumsDir.get().file(swtChecksumResourcePath).asFile + checksumFile.parentFile.mkdirs() + checksumFile.withWriter('UTF-8') { writer -> + writer.writeLine("version=${swtVersion}") + writer.writeLine("path=${swtRuntimeRoot}") + swtArtifacts.each { swtArtifact -> + def swtFileName = swtJarFileName(swtArtifact) + def swtFile = checksumConfiguration.files.find { it.name == swtFileName } + if (swtFile == null) { + throw new GradleException("Resolved SWT artifact not found: ${swtFileName}") + } + writer.writeLine("${swtArtifact}=${sha1Hex(swtFile)}") + } + } + } + } + + tasks.named('processResources') { + dependsOn checksumTask + from(generatedSwtChecksumsDir) + } +} + +class HostBrowserRuntime { + String backend + boolean stageSwt + def swtConfiguration + String swtJarFileName + String swtRuntimePath +} + +ext.configureHostBrowserRuntime = { String configurationName = 'hostSwtBundle' -> + def swtConfiguration = null + if (stageHostSwt) { + swtConfiguration = configurations.maybeCreate(configurationName) + configureSwtArtifacts(swtConfiguration, [hostSwtArtifact]) + } + + new HostBrowserRuntime( + backend: hostBackend, + stageSwt: stageHostSwt, + swtConfiguration: swtConfiguration, + swtJarFileName: swtJarFileName(hostSwtArtifact), + swtRuntimePath: swtRuntimePath(hostSwtArtifact)) +} + +ext.configureLauncherBrowser = { + dependencies.add('implementation', "ca.weblite:webview:${webViewVersion}") + project.ext.addSwtChecksumResource() + addHostSwtRuntime() +} diff --git a/gradle/docker-linux-build.sh b/gradle/docker-linux-build.sh new file mode 100644 index 000000000..e2f74fa0c --- /dev/null +++ b/gradle/docker-linux-build.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Full Linux build + package inside an official Gradle JDK container. +# Invoked by build.bat --linux when Docker is the backend (no host JDK required). +set -euo pipefail + +export DEBIAN_FRONTEND=noninteractive +export APPIMAGE_EXTRACT_AND_RUN=1 + +missing= +command -v file >/dev/null 2>&1 || missing="$missing file" +if [ "${BUILD_DEB:-false}" = "true" ]; then + command -v fakeroot >/dev/null 2>&1 || missing="$missing fakeroot" +fi +if [ -n "$missing" ]; then + apt-get update -qq + apt-get install -y -qq $missing +fi + +cd /workspace +command -v gradle >/dev/null 2>&1 || { echo "Missing gradle in Docker image." >&2; exit 1; } +command -v java >/dev/null 2>&1 || { echo "Missing Java in Docker image." >&2; exit 1; } + +GRADLE_ARGS=(--no-daemon --project-cache-dir /tmp/skcraft-project-cache clean build) +if [ "${SKIP_PACKAGE:-false}" != "true" ]; then + GRADLE_ARGS+=(:launcher-bootstrap:packageLinux) +fi +if [ "${BUILD_DEB:-false}" = "true" ]; then + GRADLE_ARGS+=(-PbuildDeb=true) +fi + +echo "Running in Docker: gradle ${GRADLE_ARGS[*]}" +gradle "${GRADLE_ARGS[@]}" +echo "Linux artifacts (if packaged): launcher-bootstrap/build/installer/linux" diff --git a/gradle/resolve-build-env.bat b/gradle/resolve-build-env.bat new file mode 100644 index 000000000..5195e29d3 --- /dev/null +++ b/gradle/resolve-build-env.bat @@ -0,0 +1,134 @@ +@echo off +rem Resolve JDK 17 (required) and optionally NSIS for Windows installer packaging. +rem Usage: resolve-build-env.bat [--no-installer|--no-nsis] +rem --no-installer Skip NSIS lookup and set BUILD_INSTALLER=0 +rem --no-nsis Skip NSIS lookup only (Linux/mac packaging on Windows host) +rem Exit code 1 if JDK 17 is not found; 0 otherwise. +rem Sets JAVA_HOME, BUILD_INSTALLER, and optionally MAKENSIS. + +if not defined BUILD_INSTALLER set "BUILD_INSTALLER=1" +set "RESOLVE_NSIS=1" +if /i "%~1"=="--no-installer" ( + set "BUILD_INSTALLER=0" + set "RESOLVE_NSIS=0" +) +if /i "%~1"=="no-installer" ( + set "BUILD_INSTALLER=0" + set "RESOLVE_NSIS=0" +) +if /i "%~1"=="--no-nsis" set "RESOLVE_NSIS=0" +if /i "%~1"=="no-nsis" set "RESOLVE_NSIS=0" + +call :resolve_java_home +if errorlevel 1 goto missing_jdk + +if "%BUILD_INSTALLER%"=="0" goto skip_installer +if "%RESOLVE_NSIS%"=="0" goto skip_nsis + +call :resolve_nsis +if errorlevel 1 goto missing_nsis +echo Using NSIS=%MAKENSIS% +exit /b 0 + +:missing_jdk +echo. +echo Could not find JDK 17. +echo Set JAVA_HOME or JDK17_HOME to a JDK 17 install, then run build.bat again. +echo. +exit /b 1 + +:missing_nsis +echo. +echo ==================================================================== +echo NSIS not found - Windows installer packaging will be skipped +echo ==================================================================== +echo. +echo JARs and other build outputs will still be produced. +echo. +echo To build the Windows Setup.exe, install NSIS 3: +echo winget install NSIS.NSIS +echo Or download from: +echo https://nsis.sourceforge.io/Download +echo. +echo Then run build.bat again. +echo To skip this message, run: build.bat --no-installer +echo. +set "BUILD_INSTALLER=0" +exit /b 0 + +:skip_installer +echo Skipping native installer packaging. +exit /b 0 + +:skip_nsis +exit /b 0 + +:resolve_java_home +if defined JDK17_HOME if exist "%JDK17_HOME%\bin\java.exe" ( + set "JAVA_HOME=%JDK17_HOME%" + exit /b 0 +) + +if defined JAVA_HOME if exist "%JAVA_HOME%\bin\java.exe" ( + exit /b 0 +) + +for /d %%J in ("C:\Program Files\Amazon Corretto\jdk17*") do ( + if exist "%%~J\bin\java.exe" ( + set "JAVA_HOME=%%~J" + exit /b 0 + ) +) + +for /d %%J in ("C:\Program Files\Java\jdk-17*") do ( + if exist "%%~J\bin\java.exe" ( + set "JAVA_HOME=%%~J" + exit /b 0 + ) +) + +for /d %%J in ("C:\Program Files\Eclipse Adoptium\jdk-17*") do ( + if exist "%%~J\bin\java.exe" ( + set "JAVA_HOME=%%~J" + exit /b 0 + ) +) + +for /d %%J in ("C:\Program Files\Microsoft\jdk-17*") do ( + if exist "%%~J\bin\java.exe" ( + set "JAVA_HOME=%%~J" + exit /b 0 + ) +) + +exit /b 1 + +:resolve_nsis +if defined MAKENSIS if exist "%MAKENSIS%" exit /b 0 + +if defined NSIS_HOME if exist "%NSIS_HOME%\makensis.exe" ( + set "MAKENSIS=%NSIS_HOME%\makensis.exe" + exit /b 0 +) + +where makensis >nul 2>&1 +if not errorlevel 1 ( + for /f "delims=" %%F in ('where makensis 2^>nul') do ( + set "MAKENSIS=%%F" + exit /b 0 + ) +) + +set "NSIS_X86=%ProgramFiles(x86)%\NSIS\makensis.exe" +if exist "%NSIS_X86%" ( + set "MAKENSIS=%NSIS_X86%" + exit /b 0 +) + +set "NSIS_X64=%ProgramFiles%\NSIS\makensis.exe" +if exist "%NSIS_X64%" ( + set "MAKENSIS=%NSIS_X64%" + exit /b 0 +) + +exit /b 1 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index aa991fcea..5c82cb032 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/launcher-bootstrap/build.gradle b/launcher-bootstrap/build.gradle index 12969d60d..c700febd0 100644 --- a/launcher-bootstrap/build.gradle +++ b/launcher-bootstrap/build.gradle @@ -1,11 +1,54 @@ plugins { id 'application' - id "com.github.johnrengelman.shadow" + id 'com.gradleup.shadow' id 'io.freefair.lombok' } +def bootstrapProperties = new Properties() +file('src/main/resources/com/skcraft/launcher/bootstrap.properties').withInputStream { + bootstrapProperties.load(it) +} + +def trimAppName = { String value -> + def trimmed = value?.trim() + trimmed ?: project.name +} + +def sanitizeInstallDirName = { String value -> + def sanitized = value.replaceAll(/[^A-Za-z0-9_-]/, '') + sanitized ?: project.name +} + +def resolvePackageAppName = { String platformKey -> + trimAppName((project.findProperty('appName') + ?: bootstrapProperties.getProperty(platformKey) + ?: bootstrapProperties.getProperty('packageAppName', project.name)).toString()) +} + +ext.installerVersion = project.version.toString().replaceAll('-SNAPSHOT', '') +ext.packageAppNameWindows = resolvePackageAppName('packageAppNameWindows') +ext.packageAppNameMac = resolvePackageAppName('packageAppNameMac') +ext.packageAppNameLinux = resolvePackageAppName('packageAppNameLinux') +ext.installDirNameWindows = sanitizeInstallDirName((project.findProperty('installDirName') ?: project.ext.packageAppNameWindows).toString()) +ext.installDirNameLinux = sanitizeInstallDirName((project.findProperty('installDirName') ?: project.ext.packageAppNameLinux).toString()) +def installBaseDirOverride = project.findProperty('installBaseDir') +def configuredInstallBaseDir = installBaseDirOverride ?: bootstrapProperties.getProperty('installBaseDirWindows') +if (configuredInstallBaseDir == null || configuredInstallBaseDir.toString().trim().isEmpty()) { + throw new GradleException('Missing required bootstrap property: installBaseDirWindows') +} +ext.installBaseDirWindows = configuredInstallBaseDir.toString().trim() + +apply from: rootProject.file('gradle/browser-runtime.gradle') +apply from: 'installer/assemble-runtime.gradle' + application { - mainClassName = "com.skcraft.launcher.Bootstrap" + mainClass = "com.skcraft.launcher.Bootstrap" +} + +tasks.named('run', JavaExec) { + if (installBaseDirOverride != null) { + systemProperty 'com.skcraft.launcher.installBaseDir', installBaseDirOverride.toString().trim() + } } dependencies { @@ -13,18 +56,130 @@ dependencies { implementation 'javax.xml.bind:jaxb-api:2.3.1' } + processResources { - filesMatching('**/*.properties') { - filter { - it.replace('${project.version}', project.version) + dependsOn ':launcher:processResources' + + from(project(':launcher').layout.buildDirectory.dir('resources/main')) { + include 'com/skcraft/launcher/launcher.properties' + } +} + +tasks.register('packageWindows', JavaExec) { + group = 'distribution' + description = 'Build Windows Setup.exe (NSIS) via Java packager.' + dependsOn tasks.named('createWindowsLauncher'), tasks.named('downloadWebView2Bootstrapper') + + doFirst { + if (!System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('win')) { + throw new GradleException('packageWindows must be run on Windows.') } } + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.skcraft.launcher.installer.InstallerPackager' + args 'windows', + projectDir.absolutePath, + project.ext.installerVersion, + project.ext.packageAppNameWindows, + project.ext.installDirNameWindows, + project.ext.installBaseDirWindows } -shadowJar { - archiveClassifier.set("") +tasks.register('packageLinux', JavaExec) { + group = 'distribution' + description = 'Build Linux AppImage, tar.gz, and optional DEB package via Java packager.' + dependsOn tasks.named('assembleAppImage') + + doFirst { + if (!System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('linux')) { + throw new GradleException('packageLinux must be run on Linux.') + } + } + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.skcraft.launcher.installer.InstallerPackager' + args 'linux', + projectDir.absolutePath, + project.ext.installerVersion, + (findProperty('buildDeb') ?: 'false').toString(), + project.ext.packageAppNameLinux, + project.ext.installDirNameLinux.toLowerCase(Locale.ROOT) } -build { - dependsOn(shadowJar) +tasks.register('packageLinuxWsl', JavaExec) { + group = 'distribution' + description = 'Build Linux AppImage, tar.gz, and optional DEB from Windows via WSL using Java packager.' + dependsOn tasks.named('shadowJar'), project(':launcher').tasks.named('shadowJar') + mustRunAfter tasks.named('packageWindows') + + doFirst { + if (!System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('win')) { + throw new GradleException('packageLinuxWsl must be run on Windows.') + } + } + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.skcraft.launcher.installer.InstallerPackager' + args 'wsl-linux', + rootProject.projectDir.absolutePath, + project.ext.installerVersion, + (findProperty('buildDeb') ?: 'false').toString(), + project.ext.packageAppNameLinux, + project.ext.installDirNameLinux.toLowerCase(Locale.ROOT), + (findProperty('wslDistro') ?: '').toString() +} + +tasks.register('packageLinuxDocker', JavaExec) { + group = 'distribution' + description = 'Build Linux AppImage, tar.gz, and optional DEB via Docker using Java packager.' + dependsOn tasks.named('shadowJar'), project(':launcher').tasks.named('shadowJar') + mustRunAfter tasks.named('packageWindows') + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.skcraft.launcher.installer.InstallerPackager' + args 'docker-linux', + rootProject.projectDir.absolutePath, + project.ext.installerVersion, + (findProperty('buildDeb') ?: 'false').toString(), + project.ext.packageAppNameLinux, + project.ext.installDirNameLinux.toLowerCase(Locale.ROOT), + (findProperty('dockerImage') ?: '').toString() +} + +tasks.register('packageMac', JavaExec) { + group = 'distribution' + description = 'Build macOS DMG, PKG, and tarball via Java packager.' + dependsOn tasks.named('assembleAppImage') + + doFirst { + if (!System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('mac')) { + throw new GradleException('packageMac must be run on macOS.') + } + } + + classpath = sourceSets.main.runtimeClasspath + mainClass = 'com.skcraft.launcher.installer.InstallerPackager' + args 'mac', + projectDir.absolutePath, + project.ext.installerVersion, + project.ext.packageAppNameMac +} + +tasks.register('package') { + group = 'distribution' + description = 'Build native installer for the current platform.' + + def hostOs = System.getProperty('os.name').toLowerCase(Locale.ROOT) + if (hostOs.contains('win')) { + dependsOn tasks.named('packageWindows') + } else if (hostOs.contains('mac')) { + dependsOn tasks.named('packageMac') + } else if (hostOs.contains('linux')) { + dependsOn tasks.named('packageLinux') + } else { + doFirst { + throw new GradleException('Unsupported OS for packaging.') + } + } } diff --git a/launcher-bootstrap/installer/README.md b/launcher-bootstrap/installer/README.md new file mode 100644 index 000000000..fe3be438b --- /dev/null +++ b/launcher-bootstrap/installer/README.md @@ -0,0 +1,231 @@ +# Native installer packaging + +The bootstrap installer scripts are Windows-first and free to run for private +servers. Cross-platform packaging starts from the same staged app image: + +```bash +./gradlew :launcher-bootstrap:assembleAppImage -Pversion=1.0.0 +``` + +This produces: + +- `launcher-bootstrap/build/app-image/runtime/` +- `launcher-bootstrap/build/app-image/app/launcher-bootstrap.jar` +- `launcher-bootstrap/build/app-image/bootstrap/launcher/.jar` +- `launcher-bootstrap/build/app-image/bootstrap/natives/swt///` +- `launcher-bootstrap/build/app-image/bootstrap/natives/flatlaf///` +- launcher wrappers (`launcher-bootstrap.cmd`, `launcher-bootstrap`) + +On Windows, build a native launcher image before creating installers: + +```bash +./gradlew :launcher-bootstrap:createWindowsLauncher -Pversion=1.0.0 +``` + +This produces `launcher-bootstrap/build/windows-app-image//` +with `.exe` as the executable launcher. `packageAppName` in +`bootstrap.properties` is the **display name** and may contain spaces (for +example `Example Launcher`). A sanitized install-dir id is derived automatically +from `packageAppName` (for example `ExampleLauncher` on Windows, +`examplelauncher` on Linux). Override the display name with `-PappName=...` or +the install-dir id with `-PinstallDirName=...`. + +## Directory layout + +jpackage install artifacts stay at the install root. Bootstrap-managed data +(launcher JAR cache, SWT natives, FlatLaf natives, game runtimes, instances, +config) lives under a `bootstrap/` subfolder. + +Windows installed layout: + +``` +%LOCALAPPDATA%\/ +├── .exe +├── runtime/ # jpackage JDK (runs the bootstrap UI) +├── app/ +│ ├── launcher-bootstrap.jar +│ └── .cfg +├── install.log +├── uninstall.exe +└── bootstrap/ # launcher data root (--dir) + ├── launcher/ + ├── natives/ + │ ├── swt/ + │ └── flatlaf/ + ├── runtimes/ # Mojang game JVMs + ├── instances/ + ├── config.json + └── logs/bootstrap.log +``` + +This keeps jpackage `runtime/` separate from game `runtimes/`. + +## Prerequisites + +- JDK 17 with `jlink` and `jpackage` +- SWT platform JARs are resolved automatically from Maven Central and staged + as native libraries under `bootstrap/natives/swt///` +- FlatLaf natives are staged under `bootstrap/natives/flatlaf///` + +### Windows + +- NSIS 3 (`makensis.exe` on PATH or installed in Program Files) +- WebView2 Evergreen bootstrapper is downloaded automatically when building the Windows installer (`downloadWebView2Bootstrapper`) + +Build Setup EXE: + +```powershell +gradlew.bat :launcher-bootstrap:packageWindows -Pversion=1.0.0 +``` + +Outputs: + +- `launcher-bootstrap/build/installer/windows/ Setup.exe` (for example `Example Launcher Setup.exe`) +- installed shortcuts and post-install launch target `.exe` (not `javaw.exe`) +- launcher data defaults to `%LOCALAPPDATA%\\bootstrap\` +- required `installBaseDirWindows` in `bootstrap.properties` sets the base + directory (`%LOCALAPPDATA%` by default) and supports any `%NAME%` environment + variable expanded on the target machine; override it with + `-PinstallBaseDir="D:\Launchers"` at build/run time +- the installer directory page allows this location to be changed +- `gradlew.bat :launcher-bootstrap:run` uses the same configured default base +- on install/upgrade, the NSIS installer can migrate **all** legacy data from `%USERPROFILE%\Documents\` into `%LOCALAPPDATA%\\bootstrap\`, including unknown files/folders (`legacyHomeFolderWindows` is set in `installer/windows/installer.properties`) +- the import page is shown only when legacy Documents data exists; if bootstrap already exists, import is unchecked by default and the page explains that import deep-merges legacy data and overwrites conflicting files/folders +- import is optional via an **Import data from Documents folder** checkbox (checked by default for fresh installs, unchecked by default when installation data already exists) +- silent install (`/S`) imports legacy data by default when bootstrap is missing; when bootstrap already exists, import is skipped by default — pass `/MIGRATELEGACY=1` to import or `/MIGRATELEGACY=0` to skip +- legacy `launcher/` (and `swt/` if present) in Documents are deleted, not moved; the installer deploys fresh bundled copies under `bootstrap/` +- the legacy Documents folder is fully removed after migration completes +- upgrading from a flat-layout install moves existing root-level data into `bootstrap/` automatically +- the installer replaces `%LOCALAPPDATA%\\bootstrap\launcher\` when the bundled launcher version is **newer or equal** to what is already installed (preserves self-updated JARs only when installed version is newer) +- the installer replaces `%LOCALAPPDATA%\\bootstrap\natives\swt\` **only when the bundled SWT version is newer** than what is already installed (preserves launcher self-updated SWT when re-running an older installer) +- managed caches are installed via **selective extraction**: the installer copies `runtime/`, `app/`, and the exe on every install, and copies `bootstrap/launcher/`, `bootstrap/natives/swt/`, and `bootstrap/natives/flatlaf/` only when gating logic allows (launcher when bundled is newer/equal; SWT and FlatLaf when bundled versions are newer; existing caches are left untouched otherwise) +- installer diagnostics are written to `%LOCALAPPDATA%\\install.log` (copied from the install details list at end of setup; for a full NSIS log including file extraction, run `Setup.exe /LOG="%LOCALAPPDATA%\\install.log"`) +- when WebView2 is not already installed, the installer shows a **WebView2 Runtime** page with an **Install Microsoft Edge WebView2 Runtime (recommended)** checkbox (checked by default); unchecking skips WebView2 installation and the embedded news panel remains unavailable until WebView2 is installed +- the bundled WebView2 bootstrapper runs interactively (`/install`) so users see Microsoft's installer UI, after all launcher files, shortcuts, and registry entries are in place +- silent install (`/S`) installs WebView2 when missing by default; pass `/SKIPWEBVIEW2=1` to skip or `/INSTALLWEBVIEW2=1` to force installation +- WebView2 installation is non-blocking: bootstrapper failure or UAC cancellation logs a warning and setup continues +- WebView2 is a shared system component and is **not** removed during uninstall; only app-specific WebView2 profile data under `bootstrap/webview2/` (or `$INSTDIR\webview2\` for flat layouts) is removed with managed bootstrap data + +Windows uninstall behavior: + +- default uninstall keeps user data under `bootstrap/` (`instances/`, `logs/`, `config.json`, `accounts.dat`, etc.) +- uninstall shows one confirm page with an optional **Delete user data** checkbox (unchecked by default) +- jpackage binaries (`runtime/`, `app/`, exe) are always removed +- managed bootstrap caches (`launcher/`, `natives/swt/`, `natives/flatlaf/`, `temp/`, `webview2/`, `runtimes/`) are always removed +- Minecraft game files (`assets/`, `libraries/`, `versions/`) are kept +- when **Delete user data** is checked, `instances/`, `config.json`, `accounts.dat`, and `logs/` are removed + +### Linux + +- `curl` +- `appimagetool` +- Optional: `zsyncmake` for `.zsync` metadata + +Build AppImage: + +```bash +./gradlew :launcher-bootstrap:packageLinux -Pversion=1.0.0 +``` + +Build AppImage + DEB: + +```bash +./gradlew :launcher-bootstrap:packageLinux -Pversion=1.0.0 -PbuildDeb=true +``` + +Linux outputs always include: + +- `launcher-bootstrap/build/installer/linux/.AppImage` +- `launcher-bootstrap/build/installer/linux/.tar.gz` +- optional `.deb` when `-PbuildDeb=true` (installs to `/opt//`, lowercase on Linux) + +The DEB package uses a `postinstall` script to replace the installing user's +XDG data directory launcher cache +(`~/.local/share//bootstrap/launcher/` by default) and SWT +native cache (`~/.local/share//bootstrap/natives/swt/` by default), +plus FlatLaf native cache +(`~/.local/share//bootstrap/natives/flatlaf/` by default), from the +installed app payload. It also declares GTK/WebKitGTK package +dependencies for SWT Browser +(`libgtk-3-0` and `libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37`). + +AppImage and tar.gz include `bootstrap/launcher/`, `bootstrap/natives/swt/`, and +`bootstrap/natives/flatlaf/` as normal app payload directories, but they do not have an +install hook and do not copy them into the user data directory automatically. + +Build Linux artifacts from Windows via WSL: + +```powershell +gradlew.bat :launcher-bootstrap:packageLinuxWsl -Pversion=1.0.0 -PwslDistro=Ubuntu +``` + +Build AppImage + DEB from Windows via WSL: + +```powershell +gradlew.bat :launcher-bootstrap:packageLinuxWsl -Pversion=1.0.0 -PwslDistro=Ubuntu -PbuildDeb=true +``` + +Build Linux artifacts via Docker (any host with Docker): + +```powershell +gradlew.bat :launcher-bootstrap:packageLinuxDocker -Pversion=1.0.0 +``` + +From Windows with **no host JDK** (full compile inside Docker): + +```bat +build.bat --linux +``` + +That uses the official `gradle:8.14.0-jdk17` image for `clean`, `build`, and `packageLinux`. +Hybrid Windows+Linux builds still use the host JDK for Windows packaging and reuse +host jars inside Docker for the Linux step. + +Build AppImage + DEB via Docker: + +```powershell +gradlew.bat :launcher-bootstrap:packageLinuxDocker -Pversion=1.0.0 -PbuildDeb=true +``` + +Override the container image (default `gradle:8.14.0-jdk17`): + +```powershell +gradlew.bat :launcher-bootstrap:packageLinuxDocker -PdockerImage=gradle:8.14.0-jdk17 +``` + +Or from `build.bat`: + +```bat +build.bat --docker +``` + +### macOS + +Build DMG + PKG + tarball: + +```bash +./gradlew :launcher-bootstrap:packageMac -Pversion=1.0.0 +``` + +macOS packages use `packageAppName` for the `.app` bundle name (spaces allowed, +for example `/Applications/SKCraft Launcher.app`). Outputs: + +- `launcher-bootstrap/build/installer/macos/.dmg` +- `launcher-bootstrap/build/installer/macos/.pkg` +- `launcher-bootstrap/build/installer/macos/.tar.gz` + +The PKG `postinstall` script +replaces bundled launcher, SWT, and FlatLaf data in `~//bootstrap/` +from `bootstrap.properties`. +The macOS output is unsigned by default. Notarization is intentionally out of +scope for this initial workflow. + +## Private fork override + +Bootstrap supports external property overrides: + +- JVM property: `-Dcom.skcraft.launcher.bootstrap.propertiesFile=/path/to/bootstrap.properties` +- Sidecar file: place `bootstrap.properties` next to the bootstrap JAR + +This lets private servers reuse official installers and only override update +URLs. diff --git a/launcher-bootstrap/installer/assemble-runtime.gradle b/launcher-bootstrap/installer/assemble-runtime.gradle new file mode 100644 index 000000000..7da682cbb --- /dev/null +++ b/launcher-bootstrap/installer/assemble-runtime.gradle @@ -0,0 +1,229 @@ +apply from: "${projectDir}/installer/webview2-runtime.gradle" + +def hostOs = System.getProperty('os.name').toLowerCase(Locale.ROOT) + +def runtimeModules = [ + 'java.desktop', + 'java.logging', + 'java.net.http', + 'java.xml', + 'jdk.crypto.ec', + 'jdk.httpserver', + 'jdk.charsets', + 'jdk.unsupported', + 'jdk.zipfs', +] + +def runtimeDir = layout.buildDirectory.dir('runtime') +def appImageDir = layout.buildDirectory.dir('app-image') +def windowsAppImageDir = layout.buildDirectory.dir('windows-app-image') +def launcherShadowJar = project(':launcher').tasks.named('shadowJar') +def bootstrapShadowJar = tasks.named('shadowJar') +def deleteFiles = { Object... paths -> project.delete(paths) } +def copyFiles = { Closure spec -> project.copy(spec) } + +def splashPng = file("${projectDir}/src/main/resources/com/skcraft/launcher/splash.png") + +def hostBrowser = configureHostBrowserRuntime() + +def writeIcoFromPng = { File sourcePng, File targetIco -> + if (!sourcePng.isFile()) { + throw new GradleException("Launcher icon PNG not found at ${sourcePng}.") + } + + def image = javax.imageio.ImageIO.read(sourcePng) + if (image == null) { + throw new GradleException("Failed to read PNG icon at ${sourcePng}.") + } + + byte[] pngBytes = sourcePng.bytes + int width = Math.min(image.width, 256) + int height = Math.min(image.height, 256) + + targetIco.parentFile.mkdirs() + targetIco.withOutputStream { out -> + out.write(0); out.write(0) + out.write(1); out.write(0) + out.write(1); out.write(0) + out.write(width >= 256 ? 0 : width) + out.write(height >= 256 ? 0 : height) + out.write(0); out.write(0) + out.write(1); out.write(0) + out.write(32); out.write(0) + + int size = pngBytes.length + out.write(size & 0xFF) + out.write((size >> 8) & 0xFF) + out.write((size >> 16) & 0xFF) + out.write((size >> 24) & 0xFF) + + int offset = 22 + out.write(offset & 0xFF) + out.write((offset >> 8) & 0xFF) + out.write((offset >> 16) & 0xFF) + out.write((offset >> 24) & 0xFF) + out.write(pngBytes) + } +} + +def jdk17Launcher = project.extensions.getByType(org.gradle.jvm.toolchain.JavaToolchainService).launcherFor { + languageVersion = org.gradle.jvm.toolchain.JavaLanguageVersion.of(17) +} + +tasks.register('createRuntimeImage', Exec) { + def runtimeOutput = runtimeDir.get().asFile + + doFirst { + def javaHome = jdk17Launcher.get().metadata.installationPath.asFile + def jmodsDir = new File(javaHome, 'jmods') + def jlinkExecutable = new File(javaHome, hostOs.contains('win') ? 'bin/jlink.exe' : 'bin/jlink') + + if (!jmodsDir.isDirectory()) { + throw new GradleException("JDK 17 jmods directory not found at ${jmodsDir}. Install a JDK 17 (not a JRE) to package installers.") + } + if (!jlinkExecutable.isFile()) { + throw new GradleException("jlink executable not found at ${jlinkExecutable}. Install a JDK 17 that includes jlink.") + } + deleteFiles(runtimeOutput) + + commandLine jlinkExecutable.absolutePath, + '--module-path', jmodsDir.absolutePath, + '--add-modules', runtimeModules.join(','), + '--strip-debug', + '--no-man-pages', + '--no-header-files', + '--compress=2', + '--output', runtimeOutput.absolutePath + } +} + +tasks.register('assembleAppImage') { + dependsOn bootstrapShadowJar, tasks.named('createRuntimeImage'), launcherShadowJar + + doLast { + def outputDir = appImageDir.get().asFile + def appDir = new File(outputDir, 'app') + def runtimeOutputDir = runtimeDir.get().asFile + def jarFile = bootstrapShadowJar.get().archiveFile.get().asFile + def launcherJarFile = launcherShadowJar.get().archiveFile.get().asFile + def launcherJarName = "${System.currentTimeMillis()}.jar" + + deleteFiles(outputDir) + appDir.mkdirs() + + copyFiles { + from(runtimeOutputDir) + into(new File(outputDir, 'runtime')) + } + + copyFiles { + from(jarFile) + into(appDir) + rename { 'launcher-bootstrap.jar' } + } + + copyFiles { + from(splashPng) + into(appDir) + } + + def bootstrapDir = new File(outputDir, 'bootstrap') + + copyFiles { + from(launcherJarFile) + into(new File(bootstrapDir, 'launcher')) + rename { launcherJarName } + } + + if (hostBrowser.stageSwt) { + copyFiles { + from(hostBrowser.swtConfiguration) + into(new File(bootstrapDir, hostBrowser.swtRuntimePath)) + rename { hostBrowser.swtJarFileName } + } + } + + File launcherPayloadDir = new File(bootstrapDir, 'launcher') + new File(launcherPayloadDir, 'launcher.version').text = project.ext.installerVersion + + def commandScript = new File(outputDir, 'launcher-bootstrap.cmd') + commandScript.text = """@echo off +set SCRIPT_DIR=%~dp0 +"%SCRIPT_DIR%runtime\\bin\\java.exe" -splash:"%SCRIPT_DIR%app\\splash.png" -jar "%SCRIPT_DIR%app\\launcher-bootstrap.jar" %* +""" + + def shellScript = new File(outputDir, 'launcher-bootstrap') + shellScript.text = '''#!/usr/bin/env sh +set -eu +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$SCRIPT_DIR/runtime/bin/java" -splash:"$SCRIPT_DIR/app/splash.png" -jar "$SCRIPT_DIR/app/launcher-bootstrap.jar" "$@" +''' + shellScript.setExecutable(true) + } +} + +tasks.register('createWindowsLauncher', Exec) { + dependsOn tasks.named('assembleAppImage') + + def stagedAppImageDir = appImageDir + def iconSourcePng = file("${projectDir}/src/main/resources/com/skcraft/launcher/bootstrapper_icon.png") + def iconFile = layout.buildDirectory.file('tmp/windows/icon.ico').get().asFile + + inputs.property('packageAppName', project.ext.packageAppNameWindows) + inputs.property('installerVersion', project.ext.installerVersion) + inputs.dir(stagedAppImageDir) + inputs.file(iconSourcePng) + outputs.dir(windowsAppImageDir.map { it.dir(project.ext.packageAppNameWindows) }) + + onlyIf { + hostOs.contains('win') + } + + doFirst { + def appName = project.ext.packageAppNameWindows + def stagedAppImage = stagedAppImageDir.get().asFile + def windowsImageOutputDir = windowsAppImageDir.get().asFile + def javaHome = jdk17Launcher.get().metadata.installationPath.asFile + def jpackageExecutable = new File(javaHome, 'bin/jpackage.exe') + if (!jpackageExecutable.isFile()) { + throw new GradleException("jpackage executable not found at ${jpackageExecutable}. Install a JDK 17 that includes jpackage.") + } + writeIcoFromPng(iconSourcePng, iconFile) + + deleteFiles(windowsImageOutputDir) + + commandLine jpackageExecutable.absolutePath, + '--type', 'app-image', + '--dest', windowsImageOutputDir.absolutePath, + '--name', appName, + '--app-version', project.ext.installerVersion, + '--vendor', 'SKCraft', + '--input', new File(stagedAppImage, 'app').absolutePath, + '--main-jar', 'launcher-bootstrap.jar', + '--main-class', 'com.skcraft.launcher.Bootstrap', + '--runtime-image', new File(stagedAppImage, 'runtime').absolutePath, + '--java-options', '-splash:$APPDIR/splash.png', + '--icon', iconFile.absolutePath + } + + doLast { + def appName = project.ext.packageAppNameWindows + def stagedAppImage = stagedAppImageDir.get().asFile + def windowsImageOutputDir = windowsAppImageDir.get().asFile + def windowsAppDir = new File(windowsImageOutputDir, appName) + copyFiles { + from(new File(stagedAppImage, 'app/splash.png')) + into(new File(windowsAppDir, 'app')) + } + + def bootstrapDir = new File(windowsAppDir, 'bootstrap') + copyFiles { + from(new File(stagedAppImage, 'bootstrap/launcher')) + into(new File(bootstrapDir, 'launcher')) + } + copyFiles { + from(new File(stagedAppImage, "bootstrap/${hostBrowser.swtRuntimePath}")) + into(new File(bootstrapDir, hostBrowser.swtRuntimePath)) + } + } +} diff --git a/launcher-bootstrap/installer/installer.properties b/launcher-bootstrap/installer/installer.properties new file mode 100644 index 000000000..5aa4f1f63 --- /dev/null +++ b/launcher-bootstrap/installer/installer.properties @@ -0,0 +1,6 @@ +# Installer settings (build-time only; not bundled in bootstrap.jar) + +# Legacy user data folders for migration during install/upgrade +legacyHomeFolderWindows=SKCraft Launcher +legacyHomeFolderLinux=skcraft_launcher +legacyHomeFolderMac=.skcraft_launcher diff --git a/launcher-bootstrap/installer/linux/jpackage-resources/postinstall b/launcher-bootstrap/installer/linux/jpackage-resources/postinstall new file mode 100644 index 000000000..ce875f72b --- /dev/null +++ b/launcher-bootstrap/installer/linux/jpackage-resources/postinstall @@ -0,0 +1,47 @@ +#!/bin/sh +set -e + +APP_SOURCE="" +for candidate in \ + "/opt/@LINUX_INSTALL_DIR@/lib/app" \ + "/opt/@LINUX_INSTALL_DIR@" \ + "/usr/lib/@LINUX_INSTALL_DIR@"; do + if [ -d "$candidate/bootstrap/launcher" ]; then + APP_SOURCE="$candidate" + break + fi +done + +if [ -z "$APP_SOURCE" ]; then + exit 0 +fi + +TARGET_USER="${SUDO_USER:-}" +if [ -z "$TARGET_USER" ] || [ "$TARGET_USER" = "root" ]; then + TARGET_USER="$(logname 2>/dev/null || echo root)" +fi + +HOME_DIR="$(getent passwd "$TARGET_USER" 2>/dev/null | cut -d: -f6)" +if [ -z "$HOME_DIR" ]; then + HOME_DIR="${HOME:-/root}" +fi + +DATA_HOME="${XDG_DATA_HOME:-$HOME_DIR/.local/share}" +DATA_DIR="$DATA_HOME/@LINUX_DATA_DIR@" +NATIVES_DIR="$DATA_DIR/natives" + +mkdir -p "$DATA_DIR" +rm -rf "$DATA_DIR/launcher" +cp -R "$APP_SOURCE/bootstrap/launcher" "$DATA_DIR/launcher" + +if [ -d "$APP_SOURCE/bootstrap/natives/swt" ]; then + mkdir -p "$NATIVES_DIR" + rm -rf "$NATIVES_DIR/swt" + cp -R "$APP_SOURCE/bootstrap/natives/swt" "$NATIVES_DIR/swt" +fi + +if command -v chown >/dev/null 2>&1 && [ "$TARGET_USER" != "root" ]; then + chown -R "$TARGET_USER:$TARGET_USER" "$DATA_DIR" 2>/dev/null || true +fi + +exit 0 diff --git a/launcher-bootstrap/installer/macos/jpackage-resources/postinstall b/launcher-bootstrap/installer/macos/jpackage-resources/postinstall new file mode 100644 index 000000000..084dc1168 --- /dev/null +++ b/launcher-bootstrap/installer/macos/jpackage-resources/postinstall @@ -0,0 +1,37 @@ +#!/bin/sh +set -e + +# PKG args: $2 = install location, $3 = target volume +APP_BUNDLE="${3:-/}${2:-/Applications/@MAC_APP_NAME@.app}" +APP_SOURCE="$APP_BUNDLE/Contents/app" + +if [ ! -d "$APP_SOURCE/bootstrap/launcher" ]; then + APP_SOURCE="/Applications/@MAC_APP_NAME@.app/Contents/app" +fi + +if [ ! -d "$APP_SOURCE/bootstrap/launcher" ]; then + echo "Bundled launcher not found under @MAC_APP_NAME@.app" >&2 + exit 1 +fi + +TARGET_USER="$(stat -f %Su /dev/console 2>/dev/null || echo "${SUDO_USER:-}")" +if [ -z "$TARGET_USER" ] || [ "$TARGET_USER" = "root" ]; then + TARGET_USER="${USER:-root}" +fi + +HOME_DIR="$(dscl . -read "/Users/$TARGET_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')" +if [ -z "$HOME_DIR" ]; then + HOME_DIR="${HOME:-/var/root}" +fi + +DATA_DIR="$HOME_DIR/@MAC_DATA_DIR@" +mkdir -p "$DATA_DIR" + +rm -rf "$DATA_DIR/launcher" +cp -R "$APP_SOURCE/bootstrap/launcher" "$DATA_DIR/launcher" + +if [ "$TARGET_USER" != "root" ]; then + chown -R "$TARGET_USER" "$DATA_DIR" 2>/dev/null || true +fi + +exit 0 diff --git a/launcher-bootstrap/installer/webview2-runtime.gradle b/launcher-bootstrap/installer/webview2-runtime.gradle new file mode 100644 index 000000000..94b19cb54 --- /dev/null +++ b/launcher-bootstrap/installer/webview2-runtime.gradle @@ -0,0 +1,34 @@ +ext.webview2BootstrapperUrl = 'https://go.microsoft.com/fwlink/?linkid=2124703' +ext.webview2BootstrapperFileName = 'MicrosoftEdgeWebview2Setup.exe' +ext.webview2RuntimeDir = layout.buildDirectory.dir('webview2-runtime') +def deleteFiles = { Object... paths -> project.delete(paths) } + +tasks.register('downloadWebView2Bootstrapper') { + group = 'distribution' + description = 'Download Microsoft Edge WebView2 Evergreen bootstrapper for the Windows installer.' + + inputs.property('webview2BootstrapperUrl', webview2BootstrapperUrl) + outputs.file(webview2RuntimeDir.map { it.file(webview2BootstrapperFileName) }) + + doLast { + File outputDir = webview2RuntimeDir.get().asFile + File bootstrapper = new File(outputDir, webview2BootstrapperFileName) + + deleteFiles(outputDir) + if (!outputDir.mkdirs() && !outputDir.isDirectory()) { + throw new GradleException("Failed to create WebView2 runtime directory: ${outputDir}") + } + + URL url = new URL(webview2BootstrapperUrl) + logger.lifecycle("Downloading WebView2 bootstrapper from ${url}") + url.openStream().withCloseable { input -> + bootstrapper.withOutputStream { output -> + output << input + } + } + + if (!bootstrapper.isFile() || bootstrapper.length() == 0) { + throw new GradleException("WebView2 bootstrapper download failed.") + } + } +} diff --git a/launcher-bootstrap/installer/windows/include/defines.nsh b/launcher-bootstrap/installer/windows/include/defines.nsh new file mode 100644 index 000000000..68144f228 --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/defines.nsh @@ -0,0 +1,84 @@ +; --- Compile-time defines & installer metadata --- + +!ifndef WindowsInstallerDir + !define WindowsInstallerDir "${__FILEDIR__}\.." +!endif + +!ifndef MyAppVersion + !define MyAppVersion "1.0.0" +!endif + +!ifndef INSTALLER_DEFINES + !ifndef AppName + !define AppName "Example App" + !endif + + !ifndef InstallDirName + !define InstallDirName "${AppName}" + !endif + + !ifndef LegacyHomeFolder + !define LegacyHomeFolder "" + !endif + +!else + !include "${INSTALLER_DEFINES}" +!endif + +!ifndef AppName + !error "AppName must be defined" +!endif + +!ifndef InstallDirName + !define InstallDirName "${AppName}" +!endif + +!ifndef InstallBaseDir + !error "InstallBaseDir must be defined" +!endif + +!ifndef AppId + !define AppId "${InstallDirName}" +!endif + +!ifndef AppUserModelId + !define AppUserModelId "SKCraft.${InstallDirName}" +!endif + +!ifndef AppImageDir + !define AppImageDir "${WindowsInstallerDir}\..\..\build\windows-app-image\${AppName}" +!endif + +!ifndef OutputDir + !define OutputDir "${WindowsInstallerDir}\..\..\build\installer\windows" +!endif + +!ifndef IconIco + !define IconIco "${WindowsInstallerDir}\..\..\build\tmp\windows\icon.ico" +!endif + +!ifndef AppExeName + !define AppExeName "${AppName}.exe" +!endif + +!ifndef SetupFileName + !define SetupFileName "${AppName} Setup.exe" +!endif + +!ifndef WebView2Bootstrapper + !define WebView2Bootstrapper "${WindowsInstallerDir}\..\..\build\webview2-runtime\MicrosoftEdgeWebview2Setup.exe" +!endif + +!define BootstrapSubdir "bootstrap" +!define NativesSubdir "natives" +!define WebView2ClientGuid "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" +!define WebView2BootstrapperFileName "MicrosoftEdgeWebview2Setup.exe" + +Unicode true +Name "${AppName}" +OutFile "${OutputDir}\${SetupFileName}" +InstallDir "${InstallBaseDir}\${InstallDirName}" +RequestExecutionLevel user +SetCompressor /SOLID lzma +ShowInstDetails show +ShowUninstDetails show diff --git a/launcher-bootstrap/installer/windows/include/install.nsh b/launcher-bootstrap/installer/windows/include/install.nsh new file mode 100644 index 000000000..8d8483abb --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/install.nsh @@ -0,0 +1,119 @@ +; --- Install functions --- + +!addplugindir "${WindowsInstallerDir}\plugins\WinShell\x86-unicode" + +!macro CreateAppShortcut SHORTCUT_PATH + SetOutPath "$INSTDIR" + CreateShortcut "${SHORTCUT_PATH}" "$INSTDIR\${AppExeName}" "" "$INSTDIR\${AppExeName}" + WinShell::SetLnkAUMI "${SHORTCUT_PATH}" "${AppUserModelId}" +!macroend + +Function InitInstallLog + ClearErrors + FileOpen $InstallLogFile "$INSTDIR\install.log" w + IfErrors initInstallLogDone + FileWrite $InstallLogFile "Output folder: $INSTDIR$\r$\n" + FileClose $InstallLogFile + initInstallLogDone: +FunctionEnd + +Function AppendInstallLogLine + Exch $InstallLogLine + ClearErrors + FileOpen $InstallLogFile "$INSTDIR\install.log" a + IfErrors appendInstallLogLineDone + FileSeek $InstallLogFile 0 END + FileWrite $InstallLogFile "$InstallLogLine$\r$\n" + FileClose $InstallLogFile + appendInstallLogLineDone: + Pop $InstallLogLine +FunctionEnd + +Function AppendInstallLogSuffix + Pop $InstallLogPrefix + Pop $InstallLogSuffix + ClearErrors + FileOpen $InstallLogFile "$INSTDIR\install.log" a + IfErrors appendInstallLogSuffixDone + FileSeek $InstallLogFile 0 END + FileWrite $InstallLogFile "$InstallLogPrefix$InstallLogSuffix$\r$\n" + FileClose $InstallLogFile + appendInstallLogSuffixDone: +FunctionEnd + +Function LogInstalledPath + Exch $0 + + IfFileExists "$0\*" 0 logInstalledPathFile + Push "$0" + Push "Extracted directory: " + Call AppendInstallLogSuffix + Push "$0" + Call LogInstalledTree + Goto logInstalledPathDone + + logInstalledPathFile: + IfFileExists "$0" 0 logInstalledPathDone + Push "$0" + Push "Extracted file: " + Call AppendInstallLogSuffix + + logInstalledPathDone: + Pop $0 +FunctionEnd + +Function LogInstalledTree + Exch $R0 + Push $R1 + Push $R2 + Push $R3 + + FindFirst $R1 $R2 "$R0\*" + IfErrors logInstalledTreeDone + + logInstalledTreeLoop: + StrCmp $R2 "." logInstalledTreeNext + StrCmp $R2 ".." logInstalledTreeNext + + StrCpy $R3 "$R0\$R2" + IfFileExists "$R3\*" 0 logInstalledTreeFile + + Push "$R3" + Push "Extracted directory: " + Call AppendInstallLogSuffix + Push "$R3" + Call LogInstalledTree + Goto logInstalledTreeNext + + logInstalledTreeFile: + Push "$R3" + Push "Extracted file: " + Call AppendInstallLogSuffix + + logInstalledTreeNext: + ClearErrors + FindNext $R1 $R2 + IfErrors logInstalledTreeClose + Goto logInstalledTreeLoop + + logInstalledTreeClose: + FindClose $R1 + + logInstalledTreeDone: + Pop $R3 + Pop $R2 + Pop $R1 + Pop $R0 +FunctionEnd + +Function DirectoryPageShow + Push $0 + GetDlgItem $0 $HWNDPARENT 1 + SendMessage $0 ${WM_SETTEXT} 0 "STR:Install" + Pop $0 +FunctionEnd + +Function CreateDesktopShortcut + SetShellVarContext current + !insertmacro CreateAppShortcut "$DESKTOP\${AppName}.lnk" +FunctionEnd diff --git a/launcher-bootstrap/installer/windows/include/launcher.nsh b/launcher-bootstrap/installer/windows/include/launcher.nsh new file mode 100644 index 000000000..9ec3ee028 --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/launcher.nsh @@ -0,0 +1,61 @@ +; --- Launcher version gating functions --- + +Function ReadTrimmedVersionFile + ; Input: file path on stack. Output: trimmed first line in $R0, or empty on error. + Exch $0 + Push $1 + StrCpy $R0 "" + ClearErrors + FileOpen $1 $0 r + IfErrors readTrimmedVersionDone + FileRead $1 $R0 + FileClose $1 + + readTrimmedVersionTrimLoop: + StrCpy $1 $R0 1 -1 + StrCmp $1 "$\r" 0 +3 + StrCpy $R0 $R0 -1 + Goto readTrimmedVersionTrimLoop + readTrimmedVersionTrimLoop2: + StrCpy $1 $R0 1 -1 + StrCmp $1 "$\n" 0 readTrimmedVersionDone + StrCpy $R0 $R0 -1 + Goto readTrimmedVersionTrimLoop2 + + readTrimmedVersionDone: + Pop $1 + Pop $0 +FunctionEnd + +Function ShouldInstallBundledLauncher + Push $0 + Push $1 + Push $2 + + StrCpy $ShouldInstallLauncher 1 + + IfFileExists "$DataDir\launcher" 0 shouldInstallLauncherDone + IfFileExists "$DataDir\launcher\launcher.version" 0 checkExistingLauncherJars + + Push "$DataDir\launcher\launcher.version" + Call ReadTrimmedVersionFile + + StrCmp $R0 "" checkExistingLauncherJars + ${VersionCompare} "${MyAppVersion}" $R0 $2 + IntCmp $2 2 keepExistingLauncher shouldInstallLauncherDone shouldInstallLauncherDone + + keepExistingLauncher: + StrCpy $ShouldInstallLauncher 0 + Goto shouldInstallLauncherDone + + checkExistingLauncherJars: + FindFirst $0 $1 "$DataDir\launcher\*.jar" + IfErrors shouldInstallLauncherDone + FindClose $0 + StrCpy $ShouldInstallLauncher 0 + + shouldInstallLauncherDone: + Pop $2 + Pop $1 + Pop $0 +FunctionEnd diff --git a/launcher-bootstrap/installer/windows/include/macros.nsh b/launcher-bootstrap/installer/windows/include/macros.nsh new file mode 100644 index 000000000..c6f73981c --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/macros.nsh @@ -0,0 +1,43 @@ +; --- Shared macros --- + +!macro UninstallBulkRemoveDir PATH + Push "${PATH}" + Call un.BulkRemoveDir +!macroend + +!macro DeleteManagedBootstrapDir DIRNAME + !insertmacro UninstallBulkRemoveDir "$DataDir\${DIRNAME}" +!macroend + +!macro DeleteManagedBootstrapData + !insertmacro DeleteManagedBootstrapDir "launcher" + !insertmacro DeleteManagedBootstrapDir "runtimes" + !insertmacro DeleteManagedBootstrapDir "natives" + !insertmacro DeleteManagedBootstrapDir "temp" + !insertmacro DeleteManagedBootstrapDir "webview2" +!macroend + +!macro DeleteUserBootstrapData + !insertmacro DeleteManagedBootstrapDir "instances" + Delete "$DataDir\config.json" + Delete "$DataDir\accounts.dat" + !insertmacro DeleteManagedBootstrapDir "logs" +!macroend + +!macro DetailPrintLog LINE + DetailPrint "${LINE}" + Push "${LINE}" + Call AppendInstallLogLine +!macroend + +!macro DetailPrintLogSuffix PREFIX VALUE + DetailPrint "${PREFIX}${VALUE}" + Push "${VALUE}" + Push "${PREFIX}" + Call AppendInstallLogSuffix +!macroend + +!macro LogInstalledPath PATH + Push "${PATH}" + Call LogInstalledPath +!macroend diff --git a/launcher-bootstrap/installer/windows/include/migration.nsh b/launcher-bootstrap/installer/windows/include/migration.nsh new file mode 100644 index 000000000..693b0446f --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/migration.nsh @@ -0,0 +1,306 @@ +; --- Legacy and layout migration functions --- + +Function PrepareLegacyMigration + SetShellVarContext current + StrCpy $MigrateLegacyData 0 + StrCpy $MigrateLegacyUserDeclined 0 + StrCpy $MigrateLegacyAvailable 0 + StrCpy $MigrateLegacyBootstrapExists 0 + + StrLen $0 "${LegacyHomeFolder}" + IntCmp $0 0 prepareLegacyMigrationDone + + StrCpy $MigrateLegacyPath "$DOCUMENTS\${LegacyHomeFolder}" + IfFileExists "$MigrateLegacyPath" 0 prepareLegacyMigrationDone + + StrCpy $MigrateLegacyAvailable 1 + + IfFileExists "$INSTDIR\${BootstrapSubdir}" 0 prepareLegacyMigrationFreshInstall + StrCpy $MigrateLegacyBootstrapExists 1 + Goto prepareLegacyMigrationDefaults + + prepareLegacyMigrationFreshInstall: + StrCpy $MigrateLegacyData 1 + + prepareLegacyMigrationDefaults: + + ${If} ${Silent} + ${GetParameters} $0 + ClearErrors + ${GetOptions} $0 "/MIGRATELEGACY=0" $1 + IfErrors +3 + StrCpy $MigrateLegacyData 0 + StrCpy $MigrateLegacyUserDeclined 1 + Goto prepareLegacyMigrationDone + ClearErrors + ${GetOptions} $0 "/MIGRATELEGACY=1" $1 + IfErrors prepareLegacyMigrationDone + StrCpy $MigrateLegacyData 1 + StrCpy $MigrateLegacyUserDeclined 0 + ${EndIf} + + prepareLegacyMigrationDone: +FunctionEnd + +Function MigrateLegacyPageShow + Call PrepareLegacyMigration + IntCmp $MigrateLegacyAvailable 1 0 migratePageSkip + + Push $1 + !insertmacro MUI_HEADER_TEXT "Import Legacy Data" "Choose whether to import data from your Documents folder." + + nsDialogs::Create 1018 + Pop $0 + + ${If} $0 == error + Pop $1 + Abort + ${EndIf} + + StrCpy $1 "Your Documents folder contains launcher data from a previous install.$\r$\n$\r$\n" + StrCpy $1 "$1$MigrateLegacyPath$\r$\n$\r$\n" + IntCmp $MigrateLegacyBootstrapExists 1 migratePageExistingBootstrap migratePageFreshInstall + + migratePageFreshInstall: + StrCpy $1 "$1Import instances, config, accounts, and other files into this installation?" + Goto migratePageCreateControls + + migratePageExistingBootstrap: + StrCpy $1 "$1This installation already has data in $INSTDIR\${BootstrapSubdir}.$\r$\n$\r$\n" + StrCpy $1 \ + "$1Importing will merge legacy files into the existing installation. Conflicting files and folders will be replaced." + + migratePageCreateControls: + ${NSD_CreateLabel} 0 0 100% 72u "" + Pop $0 + ${NSD_SetText} $0 $1 + + ${NSD_CreateCheckbox} 0 78u 100% 12u "Import data from Documents folder" + Pop $MigrateLegacyCheckbox + IntCmp $MigrateLegacyData 1 migratePageCheckImport migratePageUncheckImport + + migratePageCheckImport: + ${NSD_Check} $MigrateLegacyCheckbox + Goto migratePageShowDialog + + migratePageUncheckImport: + ${NSD_Uncheck} $MigrateLegacyCheckbox + + migratePageShowDialog: + nsDialogs::Show + Pop $1 + Return + + migratePageSkip: + Abort +FunctionEnd + +Function MigrateLegacyPageLeave + ${NSD_GetState} $MigrateLegacyCheckbox $MigrateLegacyData + StrCpy $MigrateLegacyUserDeclined 0 + IntCmp $MigrateLegacyData 1 +2 + StrCpy $MigrateLegacyUserDeclined 1 +FunctionEnd + +Function PrintLegacyMigrationSkipReason + IntCmp $MigrateLegacyUserDeclined 1 skipByUser + Goto skipReasonDone + + skipByUser: + !insertmacro DetailPrintLog "Legacy Documents migration skipped by user." + + skipReasonDone: +FunctionEnd + +Function MergeLegacyPath + ; In: dest path on stack, then source path. Deep-merge with overwrite. + Pop $R9 + Pop $R8 + + Push $0 + Push $1 + Push $2 + Push $R0 + Push $R1 + Push $R2 + + IfFileExists "$R9" 0 mergeLegacyPathDone + + IfFileExists "$R9\*" 0 mergeLegacyPathFile + CreateDirectory "$R8" + FindFirst $R2 $1 "$R9\*" + IfErrors mergeLegacyPathRemoveSourceDir + mergeLegacyPathLoop: + StrCmp $1 "." mergeLegacyPathNext + StrCmp $1 ".." mergeLegacyPathNext + Push "$R8\$1" + Push "$R9\$1" + Call MergeLegacyPath + mergeLegacyPathNext: + ClearErrors + FindNext $R2 $1 + IfErrors mergeLegacyPathClose + Goto mergeLegacyPathLoop + mergeLegacyPathClose: + FindClose $R2 + mergeLegacyPathRemoveSourceDir: + RMDir "$R9" + Goto mergeLegacyPathDone + + mergeLegacyPathFile: + IfFileExists "$R8" 0 mergeLegacyPathRenameFile + Delete "$R8" + mergeLegacyPathRenameFile: + Rename "$R9" "$R8" + + mergeLegacyPathDone: + Pop $R2 + Pop $R1 + Pop $R0 + Pop $2 + Pop $1 + Pop $0 +FunctionEnd + +Function MigrateLegacyDocuments + Push $0 + Push $1 + Push $R0 + Push $R1 + Push $R2 + + IntCmp $MigrateLegacyData 1 migrateContinue + Call PrintLegacyMigrationSkipReason + Goto migrateDone + + migrateContinue: + StrLen $0 "${LegacyHomeFolder}" + IntCmp $0 0 migrateDone + + StrCpy $R1 "$DOCUMENTS\${LegacyHomeFolder}" + IfFileExists "$R1" 0 migrateDone + + !insertmacro DetailPrintLogSuffix "Checking legacy launcher data at " "$R1" + + !insertmacro DetailPrintLogSuffix "Migrating legacy launcher data from " "$R1" + CreateDirectory "$DataDir" + + FindFirst $R2 $1 "$R1\*" + IfErrors migrateCleanup + migrateLoop: + StrCmp $1 "." migrateLoopNext + StrCmp $1 ".." migrateLoopNext + StrCmp $1 "launcher" migrateDeleteEntry + StrCmp $1 "swt" migrateDeleteEntry + !insertmacro DetailPrintLogSuffix "Merging legacy entry " "$1" + Push "$DataDir\$1" + Push "$R1\$1" + Call MergeLegacyPath + Goto migrateLoopNext + migrateDeleteEntry: + !insertmacro DetailPrintLogSuffix "Deleting obsolete legacy entry " "$1" + IfFileExists "$R1\$1\*" 0 migrateDeleteFile + RMDir /r "$R1\$1" + Goto migrateLoopNext + migrateDeleteFile: + Delete "$R1\$1" + migrateLoopNext: + ClearErrors + FindNext $R2 $1 + IfErrors migrateLoopClose + Goto migrateLoop + migrateLoopClose: + FindClose $R2 + + migrateCleanup: + RMDir "$R1" + !insertmacro DetailPrintLog "Legacy data migration completed." + + migrateDone: + Pop $R2 + Pop $R1 + Pop $R0 + Pop $1 + Pop $0 +FunctionEnd + +; Moves root-level data from pre-bootstrap flat installs into bootstrap/. +Function MigrateFlatDataLayout + Push $0 + Push $1 + + IfFileExists "$DataDir" flatMigrateDone + + StrCpy $0 0 + IfFileExists "$INSTDIR\launcher" 0 +2 + StrCpy $0 1 + IfFileExists "$INSTDIR\config.json" 0 +2 + StrCpy $0 1 + IfFileExists "$INSTDIR\instances" 0 +2 + StrCpy $0 1 + IntCmp $0 1 0 flatMigrateDone + + !insertmacro DetailPrintLogSuffix "Migrating flat launcher data into " "$DataDir" + CreateDirectory "$DataDir" + + IfFileExists "$INSTDIR\launcher" 0 flatMigrateRuntimes + Rename "$INSTDIR\launcher" "$DataDir\launcher" + flatMigrateRuntimes: + IfFileExists "$INSTDIR\runtimes" 0 flatMigrateInstances + Rename "$INSTDIR\runtimes" "$DataDir\runtimes" + flatMigrateInstances: + IfFileExists "$INSTDIR\instances" 0 flatMigrateAssets + Rename "$INSTDIR\instances" "$DataDir\instances" + flatMigrateAssets: + IfFileExists "$INSTDIR\assets" 0 flatMigrateConfig + Rename "$INSTDIR\assets" "$DataDir\assets" + flatMigrateConfig: + IfFileExists "$INSTDIR\config.json" 0 flatMigrateAccounts + Rename "$INSTDIR\config.json" "$DataDir\config.json" + flatMigrateAccounts: + IfFileExists "$INSTDIR\accounts.dat" 0 flatMigrateLogs + Rename "$INSTDIR\accounts.dat" "$DataDir\accounts.dat" + flatMigrateLogs: + IfFileExists "$INSTDIR\logs" 0 flatMigrateDone + IfFileExists "$DataDir\logs" 0 flatMigrateLogsDir + Rename "$INSTDIR\logs" "$DataDir\logs" + Goto flatMigrateDone + flatMigrateLogsDir: + CreateDirectory "$DataDir\logs" + IfFileExists "$INSTDIR\logs\bootstrap.log" 0 flatMigrateLogsCleanup + Rename "$INSTDIR\logs\bootstrap.log" "$DataDir\logs\bootstrap.log" + flatMigrateLogsCleanup: + RMDir "$INSTDIR\logs" + + flatMigrateDone: + Pop $1 + Pop $0 +FunctionEnd + +Function DeleteObsoleteNativeCaches + IfFileExists "$INSTDIR\swt" 0 deleteObsoleteRootFlatLaf + !insertmacro DetailPrintLogSuffix "Deleting obsolete SWT native cache " "$INSTDIR\swt" + RMDir /r "$INSTDIR\swt" + + deleteObsoleteRootFlatLaf: + IfFileExists "$INSTDIR\flatlaf" 0 deleteObsoleteBootstrapSwt + !insertmacro DetailPrintLogSuffix "Deleting obsolete FlatLaf native cache " "$INSTDIR\flatlaf" + RMDir /r "$INSTDIR\flatlaf" + + deleteObsoleteBootstrapSwt: + IfFileExists "$INSTDIR\${BootstrapSubdir}\swt" 0 deleteObsoleteDataFlatLaf + !insertmacro DetailPrintLogSuffix "Deleting obsolete SWT native cache " "$INSTDIR\${BootstrapSubdir}\swt" + RMDir /r "$INSTDIR\${BootstrapSubdir}\swt" + + deleteObsoleteDataFlatLaf: + IfFileExists "$DataDir\flatlaf" 0 deleteObsoleteNatives + !insertmacro DetailPrintLogSuffix "Deleting obsolete FlatLaf native cache " "$DataDir\flatlaf" + RMDir /r "$DataDir\flatlaf" + + deleteObsoleteNatives: + IfFileExists "$DataDir\${NativesSubdir}" 0 deleteObsoleteNativeCachesDone + !insertmacro DetailPrintLogSuffix "Deleting obsolete native cache " "$DataDir\${NativesSubdir}" + RMDir /r "$DataDir\${NativesSubdir}" + + deleteObsoleteNativeCachesDone: +FunctionEnd diff --git a/launcher-bootstrap/installer/windows/include/uninstall.nsh b/launcher-bootstrap/installer/windows/include/uninstall.nsh new file mode 100644 index 000000000..227c8bba3 --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/uninstall.nsh @@ -0,0 +1,39 @@ +; --- Uninstall functions --- + +Function un.BulkRemoveDir + Exch $0 + Push $1 + IfFileExists "$0" 0 bulkRemoveDirDone + DetailPrint "Removing $0..." + nsExec::Exec 'cmd.exe /C rmdir /s /q "$0"' + Pop $1 + bulkRemoveDirDone: + Pop $1 + Pop $0 +FunctionEnd + +Function un.UninstallConfirmShow + StrCpy $un.DeleteUserData 0 + !insertmacro MUI_HEADER_TEXT "Confirm Removal" "Remove ${AppName} from your computer." + + nsDialogs::Create 1018 + Pop $0 + + ${If} $0 == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0 0 100% 24u "Are you sure you want to completely remove ${AppName} and all of its components?" + Pop $0 + + ${NSD_CreateCheckbox} 0 30u 100% 12u "Delete user data (instances, config, accounts, and logs; Minecraft game files are kept)" + Pop $un.DeleteUserDataCheckbox + ${NSD_Uncheck} $un.DeleteUserDataCheckbox + + nsDialogs::Show +FunctionEnd + +Function un.UninstallConfirmLeave + ${NSD_GetState} $un.DeleteUserDataCheckbox $0 + StrCpy $un.DeleteUserData $0 +FunctionEnd diff --git a/launcher-bootstrap/installer/windows/include/vars.nsh b/launcher-bootstrap/installer/windows/include/vars.nsh new file mode 100644 index 000000000..257b5847f --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/vars.nsh @@ -0,0 +1,20 @@ +; --- Variables --- + +Var ShouldInstallLauncher +Var ShouldInstallWebView2 +Var ForceInstallWebView2 +Var WebView2Installed +Var WebView2Checkbox +Var MigrateLegacyData +Var MigrateLegacyUserDeclined +Var MigrateLegacyAvailable +Var MigrateLegacyBootstrapExists +Var MigrateLegacyCheckbox +Var MigrateLegacyPath +Var DataDir +Var InstallLogFile +Var InstallLogLine +Var InstallLogPrefix +Var InstallLogSuffix +Var un.DeleteUserDataCheckbox +Var un.DeleteUserData diff --git a/launcher-bootstrap/installer/windows/include/webview2.nsh b/launcher-bootstrap/installer/windows/include/webview2.nsh new file mode 100644 index 000000000..8af5262e8 --- /dev/null +++ b/launcher-bootstrap/installer/windows/include/webview2.nsh @@ -0,0 +1,156 @@ +; --- WebView2 page, detection, and install --- + +Function DetectWebView2Runtime + Push $0 + StrCpy $R0 "" + + ClearErrors + ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\${WebView2ClientGuid}" "pv" + IfErrors detectWebView2Hklm32 + StrCmp $0 "" detectWebView2Hklm32 + StrCmp $0 "0.0.0.0" detectWebView2Hklm32 + StrCpy $R0 $0 + Goto detectWebView2Done + + detectWebView2Hklm32: + ClearErrors + ReadRegStr $0 HKLM "Software\Microsoft\EdgeUpdate\Clients\${WebView2ClientGuid}" "pv" + IfErrors detectWebView2HklmWow64 + StrCmp $0 "" detectWebView2HklmWow64 + StrCmp $0 "0.0.0.0" detectWebView2HklmWow64 + StrCpy $R0 $0 + Goto detectWebView2Done + + detectWebView2HklmWow64: + ClearErrors + ReadRegStr $0 HKLM "Software\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WebView2ClientGuid}" "pv" + IfErrors detectWebView2Done + StrCmp $0 "" detectWebView2Done + StrCmp $0 "0.0.0.0" detectWebView2Done + StrCpy $R0 $0 + + detectWebView2Done: + Pop $0 +FunctionEnd + +Function PrepareWebView2 + Push $0 + Push $1 + + StrCpy $ShouldInstallWebView2 0 + StrCpy $ForceInstallWebView2 0 + StrCpy $WebView2Installed 0 + + Call DetectWebView2Runtime + StrCmp $R0 "" prepareWebView2Missing prepareWebView2Installed + + prepareWebView2Installed: + StrCpy $WebView2Installed 1 + Goto prepareWebView2Options + + prepareWebView2Missing: + StrCpy $ShouldInstallWebView2 1 + + prepareWebView2Options: + ${GetParameters} $0 + ClearErrors + ${GetOptions} $0 "/SKIPWEBVIEW2=1" $1 + IfErrors +3 + StrCpy $ShouldInstallWebView2 0 + Goto prepareWebView2Done + ClearErrors + ${GetOptions} $0 "/INSTALLWEBVIEW2=1" $1 + IfErrors prepareWebView2Done + StrCpy $ShouldInstallWebView2 1 + StrCpy $ForceInstallWebView2 1 + + prepareWebView2Done: + Pop $1 + Pop $0 +FunctionEnd + +Function WebView2PageShow + Call PrepareWebView2 + StrCmp $WebView2Installed 1 webView2PageSkip + + !insertmacro MUI_HEADER_TEXT "WebView2 Runtime" "Choose whether to install Microsoft Edge WebView2 Runtime." + + nsDialogs::Create 1018 + Pop $0 + + ${If} $0 == error + Abort + ${EndIf} + + ${NSD_CreateLabel} 0 0 100% 62u \ + "${AppName} uses Microsoft Edge WebView2 for the embedded news panel.$\r$\n$\r$\nWebView2 is not currently installed. Install it now, or skip this step and install it later from Microsoft." + Pop $0 + + ${NSD_CreateCheckbox} 0 72u 100% 12u "Install Microsoft Edge WebView2 Runtime (recommended)" + Pop $WebView2Checkbox + IntCmp $ShouldInstallWebView2 1 webView2PageCheck webView2PageUncheck webView2PageUncheck + + webView2PageCheck: + ${NSD_Check} $WebView2Checkbox + Goto webView2PageShowDialog + + webView2PageUncheck: + ${NSD_Uncheck} $WebView2Checkbox + + webView2PageShowDialog: + nsDialogs::Show + Return + + webView2PageSkip: + Abort +FunctionEnd + +Function WebView2PageLeave + ${NSD_GetState} $WebView2Checkbox $ShouldInstallWebView2 +FunctionEnd + +Function InstallWebView2IfRequested + Push $0 + + StrCmp $ShouldInstallWebView2 1 installWebView2CheckInstalled + Goto installWebView2Done + + installWebView2CheckInstalled: + StrCmp $ForceInstallWebView2 1 installWebView2Start + Call DetectWebView2Runtime + StrCmp $R0 "" installWebView2Start + !insertmacro DetailPrintLogSuffix "Microsoft Edge WebView2 Runtime already installed: " "$R0" + Goto installWebView2Done + + installWebView2Start: + !insertmacro DetailPrintLog "Installing Microsoft Edge WebView2 Runtime." + InitPluginsDir + SetOutPath "$PLUGINSDIR" + File "${WebView2Bootstrapper}" + + ${If} ${Silent} + ExecWait '"$PLUGINSDIR\${WebView2BootstrapperFileName}" /silent /install' $0 + ${Else} + ExecWait '"$PLUGINSDIR\${WebView2BootstrapperFileName}" /install' $0 + ${EndIf} + + IfErrors installWebView2LaunchFailed + StrCmp $0 0 installWebView2Installed installWebView2NonZero + + installWebView2Installed: + !insertmacro DetailPrintLog "Microsoft Edge WebView2 Runtime installer completed." + Goto installWebView2RestoreOutPath + + installWebView2NonZero: + !insertmacro DetailPrintLogSuffix "Microsoft Edge WebView2 Runtime installer exited with code " "$0" + Goto installWebView2RestoreOutPath + + installWebView2LaunchFailed: + !insertmacro DetailPrintLog "Microsoft Edge WebView2 Runtime installer could not be started; continuing." + + installWebView2RestoreOutPath: + SetOutPath "$INSTDIR" + + installWebView2Done: + Pop $0 +FunctionEnd diff --git a/launcher-bootstrap/installer/windows/plugins/WinShell/README.txt b/launcher-bootstrap/installer/windows/plugins/WinShell/README.txt new file mode 100644 index 000000000..8f475d3e3 --- /dev/null +++ b/launcher-bootstrap/installer/windows/plugins/WinShell/README.txt @@ -0,0 +1,6 @@ +WinShell NSIS plugin (Anders, freeware, 20121005) +Source: https://nsis.sourceforge.io/WinShell_plug-in +Used to set System.AppUserModel.ID on Start Menu / desktop shortcuts. + +NSIS 3.12 !addplugindir must point at the architecture folder +(x86-unicode), not the parent WinShell directory. diff --git a/launcher-bootstrap/installer/windows/plugins/WinShell/x86-ansi/WinShell.dll b/launcher-bootstrap/installer/windows/plugins/WinShell/x86-ansi/WinShell.dll new file mode 100644 index 000000000..011428b5b Binary files /dev/null and b/launcher-bootstrap/installer/windows/plugins/WinShell/x86-ansi/WinShell.dll differ diff --git a/launcher-bootstrap/installer/windows/plugins/WinShell/x86-unicode/WinShell.dll b/launcher-bootstrap/installer/windows/plugins/WinShell/x86-unicode/WinShell.dll new file mode 100644 index 000000000..d6c4e3961 Binary files /dev/null and b/launcher-bootstrap/installer/windows/plugins/WinShell/x86-unicode/WinShell.dll differ diff --git a/launcher-bootstrap/installer/windows/setup.nsi b/launcher-bootstrap/installer/windows/setup.nsi new file mode 100644 index 000000000..8f1598fa6 --- /dev/null +++ b/launcher-bootstrap/installer/windows/setup.nsi @@ -0,0 +1,165 @@ +; --- Includes --- + +!include "MUI2.nsh" +!include "nsDialogs.nsh" +!include "LogicLib.nsh" +!include "WordFunc.nsh" +!include "FileFunc.nsh" + +!define WindowsInstallerDir "${__FILEDIR__}" + +!include "include\defines.nsh" +!include "include\vars.nsh" +!include "include\macros.nsh" +!include "include\install.nsh" +!include "include\webview2.nsh" +!include "include\migration.nsh" +!include "include\launcher.nsh" +!include "include\uninstall.nsh" + +; --- MUI pages & language --- + +!define MUI_ABORTWARNING +!define MUI_ICON "${IconIco}" +!define MUI_UNICON "${IconIco}" +!define MUI_FINISHPAGE_RUN "$INSTDIR\${AppExeName}" +!define MUI_FINISHPAGE_RUN_TEXT "Launch ${AppName}" +!define MUI_FINISHPAGE_SHOWREADME "" +!define MUI_FINISHPAGE_SHOWREADME_TEXT "Create a desktop shortcut" +!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateDesktopShortcut + +!insertmacro MUI_PAGE_WELCOME +Page custom MigrateLegacyPageShow MigrateLegacyPageLeave +Page custom WebView2PageShow WebView2PageLeave +!define MUI_PAGE_CUSTOMFUNCTION_SHOW DirectoryPageShow +!insertmacro MUI_PAGE_DIRECTORY +!insertmacro MUI_PAGE_INSTFILES +!insertmacro MUI_PAGE_FINISH + +UninstPage custom un.UninstallConfirmShow un.UninstallConfirmLeave +!insertmacro MUI_UNPAGE_INSTFILES + +!insertmacro MUI_LANGUAGE "English" + +Function .onInit + SetShellVarContext current + ; Expand %NAME% placeholders on the target machine before the directory page. + ExpandEnvStrings $INSTDIR "${InstallBaseDir}\${InstallDirName}" +FunctionEnd + +; --- Install section --- + +Section "${AppName}" SEC_MAIN + SectionIn RO + SetShellVarContext current + + SetOutPath "$INSTDIR" + StrCpy $DataDir "$INSTDIR\${BootstrapSubdir}" + Delete "$INSTDIR\install.log" + SetDetailsPrint both + Call InitInstallLog + + !insertmacro DetailPrintLog "Starting ${AppName} ${MyAppVersion} installation." + + ${If} ${Silent} + Call PrepareLegacyMigration + Call PrepareWebView2 + ${EndIf} + + Call MigrateLegacyDocuments + Call MigrateFlatDataLayout + Call DeleteObsoleteNativeCaches + + Call ShouldInstallBundledLauncher + + !insertmacro DetailPrintLog "Extracting application runtime." + SetOutPath "$INSTDIR" + File /r "${AppImageDir}\runtime" + File /r "${AppImageDir}\app" + File "${AppImageDir}\${AppExeName}" + !insertmacro LogInstalledPath "$INSTDIR\runtime" + !insertmacro LogInstalledPath "$INSTDIR\app" + !insertmacro LogInstalledPath "$INSTDIR\${AppExeName}" + + IntCmp $ShouldInstallLauncher 1 installLauncher keepLauncher + + installLauncher: + !insertmacro DetailPrintLog "Installing bundled launcher ${MyAppVersion}." + RMDir /r "$DataDir\launcher" + SetOutPath "$DataDir\launcher" + File /r "${AppImageDir}\bootstrap\launcher\*" + !insertmacro LogInstalledPath "$DataDir\launcher" + Goto installSwt + + keepLauncher: + !insertmacro DetailPrintLog "Keeping existing launcher cache (installed version/update URL policy keeps local files)." + + installSwt: + !insertmacro DetailPrintLog "Installing SWT runtime jar." + SetOutPath "$DataDir\${NativesSubdir}\swt" + File /r "${AppImageDir}\bootstrap\natives\swt\*" + !insertmacro LogInstalledPath "$DataDir\${NativesSubdir}\swt" + + IfFileExists "$DataDir\launcher\*.*" 0 missingLauncherDir + !insertmacro DetailPrintLogSuffix "Launcher jar present at " "$DataDir\launcher" + IfFileExists "$DataDir\${NativesSubdir}\swt\*.*" 0 missingSwtDir + !insertmacro DetailPrintLogSuffix "SWT runtime payload present at " "$DataDir\${NativesSubdir}\swt" + Goto appPayloadDone + + missingLauncherDir: + !insertmacro DetailPrintLog "Bundled launcher directory was not found." + Abort + + missingSwtDir: + !insertmacro DetailPrintLog "SWT runtime payload was not found." + Abort + + appPayloadDone: + !insertmacro DetailPrintLog "Writing uninstaller, shortcuts, and uninstall registry entries." + + WriteUninstaller "$INSTDIR\uninstall.exe" + + CreateDirectory "$SMPROGRAMS\${AppName}" + !insertmacro CreateAppShortcut "$SMPROGRAMS\${AppName}\${AppName}.lnk" + CreateShortcut "$SMPROGRAMS\${AppName}\Uninstall ${AppName}.lnk" "$INSTDIR\uninstall.exe" "" "$INSTDIR\${AppExeName}" + + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "DisplayName" "${AppName}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "DisplayVersion" "${MyAppVersion}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "DisplayIcon" "$INSTDIR\${AppExeName}" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "UninstallString" \ + '"$INSTDIR\uninstall.exe"' + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "NoModify" 1 + WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" "NoRepair" 1 + + Call InstallWebView2IfRequested + + !insertmacro DetailPrintLog "Installation completed successfully." +SectionEnd + +; --- Uninstall section --- + +Section "Uninstall" un.SEC_MAIN + SectionIn RO + SetShellVarContext current + StrCpy $DataDir "$INSTDIR\${BootstrapSubdir}" + Delete "$DESKTOP\${AppName}.lnk" + Delete "$SMPROGRAMS\${AppName}\${AppName}.lnk" + Delete "$SMPROGRAMS\${AppName}\Uninstall ${AppName}.lnk" + RMDir "$SMPROGRAMS\${AppName}" + + !insertmacro UninstallBulkRemoveDir "$INSTDIR\app" + Delete "$INSTDIR\install.log" + Delete "$INSTDIR\${AppExeName}" + !insertmacro UninstallBulkRemoveDir "$INSTDIR\runtime" + !insertmacro UninstallBulkRemoveDir "$INSTDIR\webview2" + Delete "$INSTDIR\uninstall.exe" + + !insertmacro DeleteManagedBootstrapData + IntCmp $un.DeleteUserData 1 unDeleteUserData unBootstrapCleanupDone + unDeleteUserData: + !insertmacro DeleteUserBootstrapData + unBootstrapCleanupDone: + RMDir "$INSTDIR" + + DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}" +SectionEnd diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java index 42a913fa5..fb0adc251 100644 --- a/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/Bootstrap.java @@ -7,19 +7,21 @@ package com.skcraft.launcher; import com.skcraft.launcher.bootstrap.*; +import com.skcraft.launcher.bootstrap.platform.PlatformSupport; import lombok.Getter; import lombok.extern.java.Log; import javax.swing.*; -import javax.swing.filechooser.FileSystemView; import java.io.File; import java.io.FileFilter; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.net.MalformedURLException; import java.net.URL; import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.*; import java.util.logging.Level; @@ -31,18 +33,19 @@ public class Bootstrap { private static final int BOOTSTRAP_VERSION = 1; @Getter private final File baseDir; - @Getter private final boolean portable; @Getter private final File binariesDir; @Getter private final Properties properties; private final String[] originalArgs; public static void main(String[] args) throws Throwable { + // Must run before any UI so the process matches Start Menu shortcut identity. + applyAppIdentity(); + SimpleLogFormatter.configureGlobalLogger(); + BootstrapFileLogging.init(); SharedLocale.loadBundle("com.skcraft.launcher.lang.Bootstrap", Locale.getDefault()); - boolean portable = isPortableMode(); - - Bootstrap bootstrap = new Bootstrap(portable, args); + Bootstrap bootstrap = new Bootstrap(args); try { bootstrap.cleanup(); bootstrap.launch(); @@ -53,13 +56,24 @@ public static void main(String[] args) throws Throwable { } } - public Bootstrap(boolean portable, String[] args) throws IOException { - this.properties = BootstrapUtils.loadProperties(Bootstrap.class, "bootstrap.properties"); + private static void applyAppIdentity() { + try { + PlatformSupport.INSTANCE.applyAppIdentity(Bootstrap.class); + } catch (Throwable t) { + log.log(Level.FINE, "Unable to apply platform app identity", t); + } + } + + public Bootstrap(String[] args) throws IOException { + this.properties = BootstrapUtils.loadProperties( + Bootstrap.class, + "bootstrap.properties", + "com.skcraft.launcher.bootstrap.propertiesFile"); - File baseDir = portable ? new File(".") : getUserLauncherDir(); + File baseDir = BootstrapUtils.resolveDataDir(properties, Bootstrap.class); this.baseDir = baseDir; - this.portable = portable; + BootstrapFileLogging.attachFile(this.baseDir); this.binariesDir = new File(baseDir, "launcher"); this.originalArgs = args; @@ -92,23 +106,87 @@ public void launch() throws Throwable { } } - if (!binaries.isEmpty()) { + refreshLauncherVersionFile(binaries); + + if (binaries.isEmpty()) { + downloadAndLaunch(Collections.emptyList()); + return; + } + + Collections.sort(binaries); + + LauncherBinary currentBinary = findNewestExecutableBinary(binaries); + if (currentBinary == null) { launchExisting(binaries, true); - } else { - launchInitial(); + return; + } + + try { + String currentVersion = JarVersionReader.readVersion(currentBinary.getPath()); + UpdateChecker.UpdateInfo updateInfo = new UpdateChecker(this).checkForUpdate(currentVersion); + if (updateInfo != null) { + log.info("Found launcher update " + updateInfo.getVersion() + "; downloading before launch."); + downloadAndLaunch(new Downloader(this, updateInfo.getUrl(), binaries)); + return; + } + } catch (Throwable t) { + log.log(Level.WARNING, "Unable to perform bootstrap update check; launching local JAR.", t); } + + launchExisting(binaries, true); } - public void launchInitial() throws Exception { + private void downloadAndLaunch(List existingBinaries) throws Exception { Bootstrap.log.info("Downloading the launcher..."); - Thread thread = new Thread(new Downloader(this)); + downloadAndLaunch(new Downloader(this, existingBinaries)); + } + + private void downloadAndLaunch(Downloader downloader) throws Exception { + Thread thread = new Thread(downloader); thread.start(); + thread.join(); + + List binaries = downloader.getBinaries(); + if (binaries != null && !binaries.isEmpty()) { + launchExisting(binaries, false); + } + } + + private void refreshLauncherVersionFile(List binaries) { + LauncherBinary newest = findNewestExecutableBinary(binaries); + if (newest == null) { + return; + } + + try { + String version = JarVersionReader.readVersion(newest.getPath()); + Files.writeString( + new File(binariesDir, "launcher.version").toPath(), + version.trim(), + StandardCharsets.UTF_8); + } catch (Throwable t) { + log.log(Level.WARNING, "Unable to write launcher.version sidecar.", t); + } + } + + private LauncherBinary findNewestExecutableBinary(List binaries) { + for (LauncherBinary binary : binaries) { + try { + binary.getExecutableJar(); + return binary; + } catch (LauncherBinary.PackedJarException e) { + log.log(Level.WARNING, "Skipping packed launcher binary " + binary.getPath(), e); + } + } + + return null; } public void launchExisting(List binaries, boolean redownload) throws Exception { Collections.sort(binaries); LauncherBinary working = null; Class clazz = null; + Throwable lastFailure = null; for (LauncherBinary binary : binaries) { File testFile = binary.getPath(); @@ -120,6 +198,7 @@ public void launchExisting(List binaries, boolean redownload) th working = binary; break; } catch (Throwable t) { + lastFailure = t; Bootstrap.log.log(Level.WARNING, "Failed to load " + testFile.getAbsoluteFile(), t); } } @@ -135,31 +214,27 @@ public void launchExisting(List binaries, boolean redownload) th execute(clazz); } else { if (redownload) { - launchInitial(); + downloadAndLaunch(binaries); + } else if (lastFailure != null) { + String message = lastFailure.getMessage(); + if (message == null || message.trim().isEmpty()) { + message = "Failed to find launchable .jar"; + } + throw new IOException(message, lastFailure); } else { throw new IOException("Failed to find launchable .jar"); } } } - public void execute(Class clazz) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { + public void execute(Class clazz) + throws InvocationTargetException, IllegalAccessException, NoSuchMethodException { Method method = clazz.getDeclaredMethod("main", String[].class); - String[] launcherArgs; - - if (portable) { - launcherArgs = new String[] { - "--portable", - "--dir", - baseDir.getAbsolutePath(), - "--bootstrap-version", - String.valueOf(BOOTSTRAP_VERSION) }; - } else { - launcherArgs = new String[] { - "--dir", - baseDir.getAbsolutePath(), - "--bootstrap-version", - String.valueOf(BOOTSTRAP_VERSION) }; - } + String[] launcherArgs = new String[] { + "--dir", + baseDir.getAbsolutePath(), + "--bootstrap-version", + String.valueOf(BOOTSTRAP_VERSION) }; String[] args = new String[originalArgs.length + launcherArgs.length]; System.arraycopy(launcherArgs, 0, args, 0, launcherArgs.length); @@ -167,53 +242,65 @@ public void execute(Class clazz) throws InvocationTargetException, IllegalAcc log.info("Launching with arguments " + Arrays.toString(args)); - method.invoke(null, new Object[] { args }); - } - - public Class load(File jarFile) throws MalformedURLException, ClassNotFoundException { - URL[] urls = new URL[] { jarFile.toURI().toURL() }; - URLClassLoader child = new URLClassLoader(urls, this.getClass().getClassLoader()); - Class clazz = Class.forName(getProperties().getProperty("launcherClass"), true, child); - return clazz; - } - - public static void setSwingLookAndFeel() { + Thread thread = Thread.currentThread(); + ClassLoader previous = thread.getContextClassLoader(); + thread.setContextClassLoader(clazz.getClassLoader()); try { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } catch (Throwable e) { + method.invoke(null, new Object[] { args }); + } finally { + thread.setContextClassLoader(previous); } } - private static File getFileChooseDefaultDir() { - JFileChooser chooser = new JFileChooser(); - FileSystemView fsv = chooser.getFileSystemView(); - return fsv.getDefaultDirectory(); - } + public String resolveSelfUpdateUrl() throws IOException { + File[] files = binariesDir.listFiles(new LauncherBinary.Filter()); + if (files != null && files.length > 0) { + List binaries = new ArrayList(); + for (File file : files) { + binaries.add(new LauncherBinary(file)); + } + Collections.sort(binaries); - private File getUserLauncherDir() { - String osName = System.getProperty("os.name").toLowerCase(); - if (osName.contains("win")) { - return new File(getFileChooseDefaultDir(), getProperties().getProperty("homeFolderWindows")); + for (int i = binaries.size() - 1; i >= 0; i--) { + try { + return JarVersionReader.readSelfUpdateUrl(binaries.get(i).getPath()); + } catch (IOException e) { + log.log(Level.WARNING, "Unable to read self-update URL from " + binaries.get(i).getPath(), e); + } + } } - File dotFolder = new File(System.getProperty("user.home"), getProperties().getProperty("homeFolder")); - String xdgFolderName = getProperties().getProperty("homeFolderLinux"); + return JarVersionReader.readSelfUpdateUrlFromClasspath(Bootstrap.class); + } - if (osName.contains("linux") && !dotFolder.exists() && xdgFolderName != null && !xdgFolderName.isEmpty()) { - String xdgDataHome = System.getenv("XDG_DATA_HOME"); - if (xdgDataHome.isEmpty()) { - xdgDataHome = System.getProperty("user.home") + "/.local/share"; - } + public Class load(File jarFile) throws Exception { + URL launcherUrl = jarFile.toURI().toURL(); + URL[] urls = resolveLauncherClasspath(launcherUrl); + URLClassLoader child = new URLClassLoader(urls, ClassLoader.getPlatformClassLoader()); + Class clazz = Class.forName(getProperties().getProperty("launcherClass"), true, child); - return new File(xdgDataHome, xdgFolderName); + String expectedUrl = resolveSelfUpdateUrl(); + String actualUrl = JarVersionReader.readSelfUpdateUrl(jarFile); + if (!Objects.equals(expectedUrl, actualUrl)) { + throw new Exception("Self Update URL is not equal to Latest URL"); } - return dotFolder; + return clazz; } - private static boolean isPortableMode() { - return new File("portable.txt").exists(); + private URL[] resolveLauncherClasspath(URL launcherUrl) throws Exception { + try (URLClassLoader resolver = new URLClassLoader( + new URL[] { launcherUrl }, ClassLoader.getPlatformClassLoader())) { + Class runtime = Class.forName("com.skcraft.launcher.browser.BrowserRuntime", true, resolver); + Method method = runtime.getMethod("resolveLauncherClasspath", URL.class, Path.class); + return (URL[]) method.invoke(null, launcherUrl, baseDir.toPath()); + } } - + public static void setSwingLookAndFeel() { + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Throwable e) { + } + } } diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapFileLogging.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapFileLogging.java new file mode 100644 index 000000000..eed40bc58 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapFileLogging.java @@ -0,0 +1,118 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.FileHandler; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +public final class BootstrapFileLogging { + + private static final Logger ROOT_LOGGER = Logger.getLogger(""); + private static final Logger log = Logger.getLogger(BootstrapFileLogging.class.getName()); + + private static BufferHandler bufferHandler; + private static boolean initialized; + private static boolean attached; + + private BootstrapFileLogging() { + } + + public static synchronized void init() { + if (initialized) { + return; + } + + bufferHandler = new BufferHandler(); + ROOT_LOGGER.addHandler(bufferHandler); + initialized = true; + } + + public static synchronized void attachFile(File baseDir) { + if (attached || baseDir == null) { + return; + } + + File logDir = new File(baseDir, "logs"); + File logFile = new File(logDir, "bootstrap.log"); + try { + if (!logDir.exists() && !logDir.mkdirs()) { + log.warning("Failed to create bootstrap log directory " + logDir.getAbsolutePath() + + "; continuing with console-only logging."); + return; + } + + FileHandler fileHandler = new FileHandler(logFile.getAbsolutePath(), false); + fileHandler.setFormatter(new SimpleLogFormatter()); + ROOT_LOGGER.addHandler(fileHandler); + + if (bufferHandler != null) { + bufferHandler.replayTo(fileHandler); + ROOT_LOGGER.removeHandler(bufferHandler); + bufferHandler.close(); + bufferHandler = null; + } + + fileHandler.flush(); + attached = true; + } catch (IOException e) { + log.log(Level.WARNING, "Failed to open bootstrap log file " + logFile.getAbsolutePath() + + "; continuing with console-only logging.", e); + } + } + + private static final class BufferHandler extends Handler { + + private final List buffered = new ArrayList(); + + @Override + public synchronized void publish(LogRecord record) { + if (record == null || !isLoggable(record)) { + return; + } + + buffered.add(copyRecord(record)); + } + + synchronized void replayTo(Handler target) { + for (LogRecord record : buffered) { + target.publish(record); + } + target.flush(); + buffered.clear(); + } + + @Override + public synchronized void flush() { + } + + @Override + public synchronized void close() { + buffered.clear(); + } + + private static LogRecord copyRecord(LogRecord source) { + LogRecord copy = new LogRecord(source.getLevel(), source.getMessage()); + copy.setLoggerName(source.getLoggerName()); + copy.setResourceBundle(source.getResourceBundle()); + copy.setResourceBundleName(source.getResourceBundleName()); + copy.setSequenceNumber(source.getSequenceNumber()); + copy.setSourceClassName(source.getSourceClassName()); + copy.setSourceMethodName(source.getSourceMethodName()); + copy.setThrown(source.getThrown()); + Object[] parameters = source.getParameters(); + copy.setParameters(parameters != null ? parameters.clone() : null); + return copy; + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java index c103e968e..bdab41dbd 100644 --- a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/BootstrapUtils.java @@ -6,14 +6,22 @@ package com.skcraft.launcher.bootstrap; +import com.skcraft.launcher.bootstrap.platform.PlatformSupport; + import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URL; +import java.util.Locale; import java.util.Properties; import java.util.regex.Pattern; public final class BootstrapUtils { + public static final String DATA_SUBDIR = "bootstrap"; + private static final Pattern absoluteUrlPattern = Pattern.compile("^[A-Za-z0-9\\-]+://.*$"); private BootstrapUtils() { @@ -35,15 +43,100 @@ public static void closeQuietly(Closeable closeable) { } public static Properties loadProperties(Class clazz, String name) throws IOException { + return loadProperties(clazz, name, null); + } + + public static Properties loadProperties(Class clazz, String name, String extraProperty) throws IOException { Properties prop = new Properties(); InputStream in = null; try { in = clazz.getResourceAsStream(name); + if (in == null) { + throw new IOException("Missing bundled properties file: " + name); + } prop.load(in); } finally { closeQuietly(in); } + + String extraPath = extraProperty != null ? System.getProperty(extraProperty) : null; + if (extraPath != null && !extraPath.trim().isEmpty()) { + loadPropertiesFile(prop, new File(extraPath)); + } else { + File sidecar = resolveSidecarPropertiesFile(clazz, name); + if (sidecar != null && sidecar.isFile()) { + loadPropertiesFile(prop, sidecar); + } + } return prop; } + private static void loadPropertiesFile(Properties prop, File file) throws IOException { + InputStream in = null; + try { + in = new FileInputStream(file); + prop.load(in); + } finally { + closeQuietly(in); + } + } + + /** Filesystem-safe install id: strip non-alphanumeric (case preserved). */ + public static String sanitizeInstallDirName(String name) { + if (name == null) { + return "launcher"; + } + String sanitized = name.replaceAll("[^A-Za-z0-9_-]", ""); + if (sanitized.isEmpty()) { + return "launcher"; + } + return sanitized; + } + + /** + * Lowercase alphanumeric data dir id for XDG paths (matches Linux install dir + * sanitization). + */ + public static String sanitizeLinuxDataDirName(String name) { + return sanitizeInstallDirName(name).toLowerCase(Locale.ROOT); + } + + public static File resolveDataDir(Properties properties, Class clazz) throws IOException { + return PlatformSupport.INSTANCE.resolveDataDir(properties, clazz); + } + + private static File resolveSidecarPropertiesFile(Class clazz, String name) { + File baseDir = resolveCodeSourceBaseDir(clazz); + if (baseDir == null) { + return null; + } + return new File(baseDir, name); + } + + private static File resolveCodeSourceBaseDir(Class clazz) { + File path = resolveCodeSourcePath(clazz); + if (path == null) { + return null; + } + + File baseDir = path.isFile() ? path.getParentFile() : path; + if (baseDir == null) { + return null; + } + + return baseDir; + } + + private static File resolveCodeSourcePath(Class clazz) { + try { + URL location = clazz.getProtectionDomain().getCodeSource().getLocation(); + if (location == null) { + return null; + } + return new File(location.toURI()); + } catch (Exception ignored) { + return null; + } + } + } diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/ComparableVersion.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/ComparableVersion.java new file mode 100644 index 000000000..55d5c9ba5 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/ComparableVersion.java @@ -0,0 +1,350 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this file for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package com.skcraft.launcher.bootstrap; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.Properties; +import java.util.Stack; + +/** + * Generic implementation of version comparison. + *

+ * NOTE: This class is a copy of r658725 of + * http://svn.apache.org/repos/asf/maven/artifact/trunk/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java. + * + * @author Kenney Westerhof + * @author Herve Boutemy + * @version $Id$ + */ +@SuppressWarnings("unchecked") +public class ComparableVersion implements Comparable { + private String value; + private String canonical; + private ListItem items; + + private interface Item { + int INTEGER_ITEM = 0; + int STRING_ITEM = 1; + int LIST_ITEM = 2; + + int compareTo(Item item); + + int getType(); + + boolean isNull(); + } + + /** + * Represents a numeric item in the version item list. + */ + private static class IntegerItem implements Item { + private final Integer value; + + IntegerItem(Integer i) { + this.value = i; + } + + public int getType() { + return INTEGER_ITEM; + } + + public boolean isNull() { + return value == 0; + } + + public int compareTo(Item item) { + if (item == null) { + return value == 0 ? 0 : 1; // 1.0 == 1, 1.1 > 1 + } + + switch (item.getType()) { + case INTEGER_ITEM: + return value.compareTo(((IntegerItem) item).value); + case STRING_ITEM: + return 1; // 1.1 > 1-sp + case LIST_ITEM: + return 1; // 1.1 > 1-1 + default: + throw new RuntimeException("invalid item: " + item.getClass()); + } + } + + public String toString() { + return value.toString(); + } + } + + /** + * Represents a string in the version item list, usually a qualifier. + */ + private static class StringItem implements Item { + private static final String[] QUALIFIERS = { + "snapshot", "alpha", "beta", "milestone", "rc", "", "sp" + }; + private static final List _QUALIFIERS = Arrays.asList(QUALIFIERS); + private static final Properties ALIASES = new Properties(); + + static { + ALIASES.put("ga", ""); + ALIASES.put("final", ""); + ALIASES.put("cr", "rc"); + } + + /** + * A comparable for the empty-string qualifier. This one is used to determine + * if a given qualifier makes the version older than one without a qualifier, + * or more recent. + */ + private static final Comparable RELEASE_VERSION_INDEX = + String.valueOf(_QUALIFIERS.indexOf("")); + + private final String value; + + StringItem(String value, boolean followedByDigit) { + if (followedByDigit && value.length() == 1) { + // a1 = alpha-1, b1 = beta-1, m1 = milestone-1 + switch (value.charAt(0)) { + case 'a': + value = "alpha"; + break; + case 'b': + value = "beta"; + break; + case 'm': + value = "milestone"; + break; + default: + break; + } + } + this.value = ALIASES.getProperty(value, value); + } + + public int getType() { + return STRING_ITEM; + } + + public boolean isNull() { + return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX) == 0; + } + + /** + * Returns a comparable for a qualifier. + *

+ * This method both takes into account the ordering of known qualifiers as + * well as lexical ordering for unknown qualifiers. + */ + public static Comparable comparableQualifier(String qualifier) { + int i = _QUALIFIERS.indexOf(qualifier); + return i == -1 ? _QUALIFIERS.size() + "-" + qualifier : String.valueOf(i); + } + + public int compareTo(Item item) { + if (item == null) { + // 1-rc < 1, 1-ga > 1 + return comparableQualifier(value).compareTo(RELEASE_VERSION_INDEX); + } + switch (item.getType()) { + case INTEGER_ITEM: + return -1; // 1.any < 1.1 ? + case STRING_ITEM: + return comparableQualifier(value) + .compareTo(comparableQualifier(((StringItem) item).value)); + case LIST_ITEM: + return -1; // 1.any < 1-1 + default: + throw new RuntimeException("invalid item: " + item.getClass()); + } + } + + public String toString() { + return value; + } + } + + /** + * Represents a version list item. This class is used both for the global item list + * and for sub-lists (which start with '-(number)' in the version specification). + */ + private static class ListItem extends ArrayList implements Item { + public int getType() { + return LIST_ITEM; + } + + public boolean isNull() { + return size() == 0; + } + + void normalize() { + for (ListIterator iterator = listIterator(size()); iterator.hasPrevious(); ) { + Item item = (Item) iterator.previous(); + if (item.isNull()) { + iterator.remove(); // remove null trailing items: 0, "", empty list + } else { + break; + } + } + } + + public int compareTo(Item item) { + if (item == null) { + if (size() == 0) { + return 0; // 1-0 = 1- (normalize) = 1 + } + Item first = (Item) get(0); + return first.compareTo(null); + } + switch (item.getType()) { + case INTEGER_ITEM: + return -1; // 1-1 < 1.0.x + case STRING_ITEM: + return 1; // 1-1 > 1-sp + case LIST_ITEM: + Iterator left = iterator(); + Iterator right = ((ListItem) item).iterator(); + + while (left.hasNext() || right.hasNext()) { + Item l = left.hasNext() ? (Item) left.next() : null; + Item r = right.hasNext() ? (Item) right.next() : null; + + // if this is shorter, then invert the compare and mul with -1 + int result = l == null ? -1 * r.compareTo(l) : l.compareTo(r); + + if (result != 0) { + return result; + } + } + return 0; + default: + throw new RuntimeException("invalid item: " + item.getClass()); + } + } + + public String toString() { + StringBuffer buffer = new StringBuffer("("); + for (Iterator iter = iterator(); iter.hasNext(); ) { + buffer.append(iter.next()); + if (iter.hasNext()) { + buffer.append(','); + } + } + buffer.append(')'); + return buffer.toString(); + } + } + + public ComparableVersion(String version) { + parseVersion(version); + } + + public final void parseVersion(String version) { + this.value = version; + items = new ListItem(); + version = version.toLowerCase(Locale.ENGLISH); + + ListItem list = items; + Stack stack = new Stack(); + stack.push(list); + + boolean isDigit = false; + int startIndex = 0; + + for (int i = 0; i < version.length(); i++) { + char c = version.charAt(i); + + if (c == '.') { + if (i == startIndex) { + list.add(new IntegerItem(0)); + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))); + } + startIndex = i + 1; + } else if (c == '-') { + if (i == startIndex) { + list.add(new IntegerItem(0)); + } else { + list.add(parseItem(isDigit, version.substring(startIndex, i))); + } + startIndex = i + 1; + + if (isDigit) { + list.normalize(); // 1.0-* = 1-* + + if ((i + 1 < version.length()) && Character.isDigit(version.charAt(i + 1))) { + // new ListItem only if previous were digits and new char is a digit, + // i.e. need to differentiate only 1.1 from 1-1 + list.add(list = new ListItem()); + stack.push(list); + } + } + } else if (Character.isDigit(c)) { + if (!isDigit && i > startIndex) { + list.add(new StringItem(version.substring(startIndex, i), true)); + startIndex = i; + } + + isDigit = true; + } else { + if (isDigit && i > startIndex) { + list.add(parseItem(true, version.substring(startIndex, i))); + startIndex = i; + } + + isDigit = false; + } + } + + if (version.length() > startIndex) { + list.add(parseItem(isDigit, version.substring(startIndex))); + } + + while (!stack.isEmpty()) { + list = (ListItem) stack.pop(); + list.normalize(); + } + + canonical = items.toString(); + } + + private static Item parseItem(boolean isDigit, String buf) { + return isDigit ? new IntegerItem(Integer.valueOf(buf)) : new StringItem(buf, false); + } + + public int compareTo(Object o) { + return items.compareTo(((ComparableVersion) o).items); + } + + public String toString() { + return value; + } + + public boolean equals(Object o) { + return (o instanceof ComparableVersion) + && canonical.equals(((ComparableVersion) o).canonical); + } + + public int hashCode() { + return canonical.hashCode(); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java index 3247f4d04..703b606e8 100644 --- a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/Downloader.java @@ -15,7 +15,10 @@ import java.io.File; import java.io.IOException; import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.logging.Level; @@ -25,13 +28,37 @@ @Log public class Downloader implements Runnable, ProgressObservable { + public enum Mode { + INITIAL, + UPDATE + } + private final Bootstrap bootstrap; + private final Mode mode; + private final URL updateUrl; + private final List existingBinaries; private DownloadFrame dialog; private HttpRequest httpRequest; private Thread thread; + private List binaries; public Downloader(Bootstrap bootstrap) { + this(bootstrap, Collections.emptyList()); + } + + public Downloader(Bootstrap bootstrap, List existingBinaries) { + this(bootstrap, Mode.INITIAL, null, existingBinaries); + } + + public Downloader(Bootstrap bootstrap, URL updateUrl, List existingBinaries) { + this(bootstrap, Mode.UPDATE, updateUrl, existingBinaries); + } + + private Downloader(Bootstrap bootstrap, Mode mode, URL updateUrl, List existingBinaries) { this.bootstrap = bootstrap; + this.mode = mode; + this.updateUrl = updateUrl; + this.existingBinaries = new ArrayList(existingBinaries); } @Override @@ -41,63 +68,40 @@ public void run() { try { execute(); } catch (InterruptedException e) { - log.log(Level.WARNING, "Interrupted"); - System.exit(0); + if (hasFallbackBinaries()) { + log.info("Download interrupted; launching bundled launcher."); + } else { + log.log(Level.WARNING, "Interrupted"); + } + launchFallbackOrExit(); } catch (Throwable t) { - log.log(Level.WARNING, "Failed to download launcher", t); - SwingHelper.showErrorDialog(null, tr("errors.failedDownloadError"), tr("errorTitle"), t); - System.exit(0); + if (hasFallbackBinaries()) { + log.log(Level.INFO, "Download failed; launching bundled launcher.", t); + } else { + log.log(Level.WARNING, "Failed to download launcher", t); + if (mode == Mode.INITIAL) { + SwingHelper.showErrorDialog(null, tr("errors.failedDownloadError"), tr("errorTitle"), t); + } + } + launchFallbackOrExit(); } } private void execute() throws Exception { - SwingUtilities.invokeAndWait(new Runnable() { - @Override - public void run() { - Bootstrap.setSwingLookAndFeel(); - dialog = new DownloadFrame(Downloader.this); - dialog.setVisible(true); - dialog.setDownloader(Downloader.this); - } - }); + setupProgressUi(); - URL updateUrl = HttpRequest.url(bootstrap.getProperties().getProperty("latestUrl")); - - log.info("Reading update URL " + updateUrl + "..."); - List binaries = new ArrayList(); + List binaries = new ArrayList(existingBinaries); + URL resolvedUrl = resolveDownloadUrl(); try { - String data = HttpRequest - .get(updateUrl) - .execute() - .expectResponseCode(200) - .returnContent() - .asString("UTF-8"); - - Object object = JSONValue.parse(data); - URL url; - - if (object instanceof JSONObject) { - String rawUrl = String.valueOf(((JSONObject) object).get("url")); - if (rawUrl != null) { - url = HttpRequest.url(rawUrl.trim()); - } else { - log.warning("Did not get valid update document - got:\n\n" + data); - throw new IOException("Update URL did not return a valid result"); - } - } else { - log.warning("Did not get valid update document - got:\n\n" + data); - throw new IOException("Update URL did not return a valid result"); - } - checkInterrupted(); File finalFile = new File(bootstrap.getBinariesDir(), System.currentTimeMillis() + ".jar"); File tempFile = new File(finalFile.getParentFile(), finalFile.getName() + ".tmp"); - log.info("Downloading " + url + " to " + tempFile.getAbsolutePath()); + log.info("Downloading " + resolvedUrl + " to " + tempFile.getAbsolutePath()); - httpRequest = HttpRequest.get(url); + httpRequest = HttpRequest.get(resolvedUrl); httpRequest .execute() .expectResponseCode(200) @@ -106,19 +110,118 @@ public void run() { finalFile.delete(); tempFile.renameTo(finalFile); + writeLauncherVersionFile(finalFile); + LauncherBinary binary = new LauncherBinary(finalFile); binaries.add(binary); } finally { + teardownProgressUi(); + } + + finish(binaries); + } + + public List getBinaries() { + return binaries; + } + + private void finish(List binaries) { + this.binaries = binaries; + } + + private void setupProgressUi() throws Exception { + if (mode != Mode.UPDATE && hasFallbackBinaries()) { + return; + } + + SwingUtilities.invokeAndWait(new Runnable() { + @Override + public void run() { + Bootstrap.setSwingLookAndFeel(); + dialog = new DownloadFrame(Downloader.this); + dialog.setVisible(true); + dialog.setDownloader(Downloader.this); + } + }); + } + + private void teardownProgressUi() { + if (dialog != null) { + final DownloadFrame frame = dialog; + dialog = null; SwingUtilities.invokeLater(new Runnable() { @Override public void run() { - dialog.setDownloader(null); - dialog.dispose(); + frame.setDownloader(null); + frame.dispose(); } }); } + } + + private URL resolveDownloadUrl() throws Exception { + if (mode == Mode.UPDATE) { + if (updateUrl == null) { + throw new IOException("Update URL was not provided"); + } + return updateUrl; + } + + URL latestUrl = HttpRequest.url(bootstrap.resolveSelfUpdateUrl()); + log.info("Reading update URL " + latestUrl + "..."); - bootstrap.launchExisting(binaries, false); + String data = HttpRequest + .get(latestUrl) + .execute() + .expectResponseCode(200) + .returnContent() + .asString("UTF-8"); + + Object object = JSONValue.parse(data); + if (!(object instanceof JSONObject)) { + log.warning("Did not get valid update document - got:\n\n" + data); + throw new IOException("Update URL did not return a valid result"); + } + + Object rawUrlValue = ((JSONObject) object).get("url"); + if (rawUrlValue == null) { + log.warning("Did not get valid update document - got:\n\n" + data); + throw new IOException("Update URL did not return a valid result"); + } + + return HttpRequest.url(String.valueOf(rawUrlValue).trim()); + } + + private void launchFallbackOrExit() { + teardownProgressUi(); + + List binaries = existingBinaries.isEmpty() + ? discoverLocalBinaries() + : new ArrayList(existingBinaries); + + if (!binaries.isEmpty()) { + finish(binaries); + return; + } + + System.exit(1); + } + + private boolean hasFallbackBinaries() { + return !existingBinaries.isEmpty() || !discoverLocalBinaries().isEmpty(); + } + + private List discoverLocalBinaries() { + File[] files = bootstrap.getBinariesDir().listFiles(new LauncherBinary.Filter()); + if (files == null || files.length == 0) { + return Collections.emptyList(); + } + + List binaries = new ArrayList(); + for (File file : files) { + binaries.add(new LauncherBinary(file)); + } + return binaries; } public void cancel() { @@ -142,4 +245,10 @@ public double getProgress() { HttpRequest httpRequest = this.httpRequest; return httpRequest != null ? httpRequest.getProgress() : -1; } + + private static void writeLauncherVersionFile(File launcherJar) throws IOException { + String version = JarVersionReader.readVersion(launcherJar); + File versionFile = new File(launcherJar.getParentFile(), "launcher.version"); + Files.writeString(versionFile.toPath(), version.trim(), StandardCharsets.UTF_8); + } } diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java index 6b3f8016d..222bb6d17 100644 --- a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/HttpRequest.java @@ -44,6 +44,8 @@ public class HttpRequest implements Closeable, ProgressObservable { private HttpURLConnection conn; private InputStream inputStream; private int redirectCount; + private int connectTimeout = READ_TIMEOUT; + private int readTimeout = READ_TIMEOUT; private long contentLength = -1; private long readBytes = 0; @@ -83,6 +85,29 @@ public HttpRequest header(String key, String value) { return this; } + /** + * Set both connect and read timeout. + * + * @param timeout timeout in milliseconds + * @return this object + */ + public HttpRequest timeout(int timeout) { + return timeout(timeout, timeout); + } + + /** + * Set connect and read timeout independently. + * + * @param connectTimeout connect timeout in milliseconds + * @param readTimeout read timeout in milliseconds + * @return this object + */ + public HttpRequest timeout(int connectTimeout, int readTimeout) { + this.connectTimeout = connectTimeout; + this.readTimeout = readTimeout; + return this; + } + /** * Execute the request. *

@@ -136,7 +161,8 @@ private HttpURLConnection runRequest(URL url) throws IOException { conn.setRequestMethod(method); conn.setUseCaches(false); conn.setDoOutput(true); - conn.setReadTimeout(READ_TIMEOUT); + conn.setConnectTimeout(connectTimeout); + conn.setReadTimeout(readTimeout); conn.connect(); diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/JarVersionReader.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/JarVersionReader.java new file mode 100644 index 000000000..aee441aac --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/JarVersionReader.java @@ -0,0 +1,71 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +public final class JarVersionReader { + + private static final String PROPERTIES_PATH = "com/skcraft/launcher/launcher.properties"; + private static final String VERSION_KEY = "version"; + private static final String SELF_UPDATE_URL_KEY = "selfUpdateUrl"; + private static final String VERSION_PLACEHOLDER = "${project.version}"; + private static final String SNAPSHOT_FALLBACK = "1.0.0-SNAPSHOT"; + + private JarVersionReader() { + } + + public static String readVersion(File jarFile) throws IOException { + return readProperty(jarFile, VERSION_KEY, true); + } + + public static String readSelfUpdateUrl(File jarFile) throws IOException { + return readProperty(jarFile, SELF_UPDATE_URL_KEY, true); + } + + public static String readSelfUpdateUrlFromClasspath(Class clazz) throws IOException { + Properties properties = BootstrapUtils.loadProperties(clazz, "launcher.properties"); + String value = properties.getProperty(SELF_UPDATE_URL_KEY); + if (value == null || value.trim().isEmpty() || value.contains("${")) { + throw new IOException("Missing selfUpdateUrl in launcher.properties"); + } + return value.trim(); + } + + private static String readProperty(File jarFile, String key, boolean required) throws IOException { + try (JarFile jar = new JarFile(jarFile)) { + JarEntry entry = jar.getJarEntry(PROPERTIES_PATH); + if (entry == null) { + throw new IOException("Missing launcher properties in " + jarFile.getAbsolutePath()); + } + + Properties properties = new Properties(); + try (InputStream in = jar.getInputStream(entry)) { + properties.load(in); + } + + String value = properties.getProperty(key); + if (value == null || value.trim().isEmpty()) { + if (required) { + throw new IOException("Missing launcher property '" + key + "' in " + jarFile.getAbsolutePath()); + } + return null; + } + + if (VERSION_KEY.equals(key) && VERSION_PLACEHOLDER.equals(value)) { + return SNAPSHOT_FALLBACK; + } + + return value.trim(); + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java index c2e2e7377..3a084b27f 100644 --- a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/SwingHelper.java @@ -33,6 +33,18 @@ public final class SwingHelper { private SwingHelper() { } + private static Font getDetailsTextFont() { + Font baseFont = UIManager.getFont("TextArea.font"); + if (baseFont == null) { + baseFont = UIManager.getFont("Label.font"); + } + if (baseFont == null) { + return new Font(Font.MONOSPACED, Font.PLAIN, 12); + } + return new Font(Font.MONOSPACED, Font.PLAIN, baseFont.getSize()) + .deriveFont(Math.max(11f, baseFont.getSize2D() - 1f)); + } + public static String htmlEscape(String str) { return str.replace(">", ">") .replace("<", "<") @@ -116,10 +128,10 @@ public static void showMessageDialog(final Component parentComponent, if (detailsText != null) { JTextArea textArea = new JTextArea(tr("errorDialog.reportError") + "\n\n" + detailsText); JLabel tempLabel = new JLabel(); - textArea.setFont(tempLabel.getFont()); + textArea.setFont(getDetailsTextFont()); textArea.setBackground(tempLabel.getBackground()); textArea.setTabSize(2); - textArea.setEditable(false);; + textArea.setEditable(false); JScrollPane scrollPane = new JScrollPane(textArea); scrollPane.setPreferredSize(new Dimension(350, 120)); diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/UpdateChecker.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/UpdateChecker.java new file mode 100644 index 000000000..5523e4820 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/UpdateChecker.java @@ -0,0 +1,101 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap; + +import com.skcraft.launcher.Bootstrap; +import lombok.extern.java.Log; +import org.json.simple.JSONObject; +import org.json.simple.JSONValue; + +import java.net.URL; +import java.net.URLEncoder; +import java.util.Locale; + +@Log +public class UpdateChecker { + + private static final int CHECK_TIMEOUT_MS = 5000; + + private final Bootstrap bootstrap; + + public UpdateChecker(Bootstrap bootstrap) { + this.bootstrap = bootstrap; + } + + public UpdateInfo checkForUpdate(String currentVersion) { + try { + String encodedVersion = URLEncoder.encode(currentVersion, "UTF-8"); + String latestUrl = bootstrap.resolveSelfUpdateUrl(); + URL url = HttpRequest.url(latestUrl + "?version=" + encodedVersion); + + String data = HttpRequest.get(url) + .timeout(CHECK_TIMEOUT_MS) + .execute() + .expectResponseCode(200) + .returnContent() + .asString("UTF-8"); + + Object object = JSONValue.parse(data); + if (!(object instanceof JSONObject)) { + log.warning("Invalid latest.json payload, expected object."); + return null; + } + + JSONObject json = (JSONObject) object; + String latestVersion = readString(json, "version"); + String rawUrl = readString(json, "url"); + + if (latestVersion == null || rawUrl == null) { + log.warning("latest.json is missing required 'version' or 'url' fields."); + return null; + } + + ComparableVersion current = new ComparableVersion(currentVersion); + ComparableVersion latest = new ComparableVersion(latestVersion); + + if (latest.compareTo(current) > 0) { + return new UpdateInfo(latestVersion, HttpRequest.url(rawUrl.trim())); + } + + return null; + } catch (Exception e) { + log.info(String.format( + Locale.ROOT, + "Skipping bootstrap update check and launching local JAR (reason: %s)", + e.getMessage())); + return null; + } + } + + private static String readString(JSONObject object, String key) { + Object value = object.get(key); + if (value == null) { + return null; + } + + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + public static final class UpdateInfo { + private final String version; + private final URL url; + + public UpdateInfo(String version, URL url) { + this.version = version; + this.url = url; + } + + public String getVersion() { + return version; + } + + public URL getUrl() { + return url; + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/LinuxPlatformSupport.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/LinuxPlatformSupport.java new file mode 100644 index 000000000..699fc3c60 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/LinuxPlatformSupport.java @@ -0,0 +1,27 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap.platform; + +import com.skcraft.launcher.bootstrap.BootstrapUtils; + +import java.io.File; +import java.util.Properties; + +final class LinuxPlatformSupport extends PlatformSupport.Adapter { + + @Override + public File resolveDataDir(Properties properties, Class anchor) { + String appName = resolveProperty(properties, "packageAppNameLinux", "packageAppName"); + String folderName = BootstrapUtils.sanitizeLinuxDataDirName(appName); + + String xdgDataHome = System.getenv("XDG_DATA_HOME"); + if (xdgDataHome == null || xdgDataHome.isEmpty()) { + xdgDataHome = System.getProperty("user.home") + "/.local/share"; + } + return new File(xdgDataHome, folderName); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/MacPlatformSupport.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/MacPlatformSupport.java new file mode 100644 index 000000000..7b6010301 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/MacPlatformSupport.java @@ -0,0 +1,19 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap.platform; + +import java.io.File; +import java.util.Properties; + +final class MacPlatformSupport extends PlatformSupport.Adapter { + + @Override + public File resolveDataDir(Properties properties, Class anchor) { + String folderName = resolveProperty(properties, "packageAppNameMac", "packageAppName"); + return new File(System.getProperty("user.home"), "Library/Application Support/" + folderName); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/PlatformSupport.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/PlatformSupport.java new file mode 100644 index 000000000..8deaf0cff --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/PlatformSupport.java @@ -0,0 +1,56 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap.platform; + +import lombok.experimental.Delegate; + +import java.io.File; +import java.io.IOException; +import java.util.Locale; +import java.util.Properties; + +public enum PlatformSupport { + INSTANCE; + + @Delegate + private final Adapter adapter = current(); + + private static Adapter current() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + return new WindowsPlatformSupport(); + } + if (os.contains("mac") || os.contains("darwin")) { + return new MacPlatformSupport(); + } + if (os.contains("linux")) { + return new LinuxPlatformSupport(); + } + return new Adapter() { + @Override + public File resolveDataDir(Properties properties, Class anchor) throws IOException { + throw new IOException("Unsupported operating system: " + System.getProperty("os.name")); + } + }; + } + + abstract static class Adapter { + + public abstract File resolveDataDir(Properties properties, Class anchor) throws IOException; + + public void applyAppIdentity(Class anchor) { + } + + static String resolveProperty(Properties properties, String platformKey, String fallbackKey) { + String value = properties.getProperty(platformKey); + if (value != null && !value.isEmpty()) { + return value; + } + return properties.getProperty(fallbackKey, ""); + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/WindowsPlatformSupport.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/WindowsPlatformSupport.java new file mode 100644 index 000000000..5a287c6a6 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/bootstrap/platform/WindowsPlatformSupport.java @@ -0,0 +1,149 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.bootstrap.platform; + +import com.skcraft.launcher.bootstrap.BootstrapUtils; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.Map; +import java.util.Properties; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +final class WindowsPlatformSupport extends PlatformSupport.Adapter { + + private static final String APP_USER_MODEL_ID_PREFIX = "SKCraft."; + private static final String APP_USER_MODEL_ID_PROPERTY = "com.skcraft.launcher.appUserModelId"; + private static final String INSTALL_BASE_DIR_PROPERTY = "com.skcraft.launcher.installBaseDir"; + private static final Pattern ENVIRONMENT_VARIABLE_PATTERN = Pattern.compile("%([^%]+)%"); + + @Override + public File resolveDataDir(Properties properties, Class anchor) throws IOException { + File installDir = resolveInstallDir(anchor); + if (installDir == null) { + installDir = resolveDefaultInstallDir(properties); + } + if (!ensureWritableDirectory(installDir)) { + throw new IOException("Install directory is not writable: " + installDir); + } + + File dataDir = resolveDataDir(installDir); + if (!ensureWritableDirectory(dataDir)) { + throw new IOException("Data directory is not writable: " + dataDir); + } + return dataDir; + } + + @Override + public void applyAppIdentity(Class anchor) { + // Publish only — JNA must initialize in the launcher classloader. + File installDir = resolveInstallDir(anchor); + if (installDir != null) { + System.setProperty(APP_USER_MODEL_ID_PROPERTY, APP_USER_MODEL_ID_PREFIX + installDir.getName()); + } + } + + private static File resolveDefaultInstallDir(Properties properties) throws IOException { + String configuredBaseDir = System.getProperty(INSTALL_BASE_DIR_PROPERTY); + if (configuredBaseDir == null || configuredBaseDir.trim().isEmpty()) { + configuredBaseDir = properties.getProperty("installBaseDirWindows"); + } + if (configuredBaseDir == null || configuredBaseDir.trim().isEmpty()) { + throw new IOException("Missing required bootstrap property: installBaseDirWindows"); + } + + File baseDir = new File(expandEnvironmentVariables(configuredBaseDir.trim())).getAbsoluteFile(); + String installDirName = resolveProperty( + properties, "packageAppNameWindows", "packageAppName"); + if (installDirName.isEmpty()) { + installDirName = "launcher"; + } + return new File(baseDir, BootstrapUtils.sanitizeInstallDirName(installDirName)); + } + + private static File resolveInstallDir(Class anchor) { + File codeSourcePath = resolveCodeSourcePath(anchor); + if (codeSourcePath == null || !codeSourcePath.isFile() || !codeSourcePath.getName().endsWith(".jar")) { + return null; + } + + File jarDir = codeSourcePath.getParentFile(); + if (jarDir == null) { + return null; + } + + if ("app".equalsIgnoreCase(jarDir.getName())) { + File parent = jarDir.getParentFile(); + if (parent != null && new File(parent, "runtime").isDirectory()) { + return parent; + } + } + return jarDir; + } + + private static File resolveDataDir(File installDir) { + File nestedDataDir = new File(installDir, BootstrapUtils.DATA_SUBDIR); + if (nestedDataDir.isDirectory()) { + return nestedDataDir; + } + return hasFlatLayoutData(installDir) ? installDir : nestedDataDir; + } + + private static boolean hasFlatLayoutData(File dir) { + return new File(dir, "launcher").isDirectory() + || new File(dir, "config.json").isFile() + || new File(dir, "instances").isDirectory() + || new File(dir, "runtimes").isDirectory(); + } + + private static boolean ensureWritableDirectory(File dir) { + if (!dir.exists() && !dir.mkdirs()) { + return false; + } + return dir.isDirectory() && dir.canWrite(); + } + + private static String expandEnvironmentVariables(String value) throws IOException { + Matcher matcher = ENVIRONMENT_VARIABLE_PATTERN.matcher(value); + StringBuffer expanded = new StringBuffer(); + while (matcher.find()) { + String variableName = matcher.group(1); + String environmentValue = getEnvironmentVariable(variableName); + if (environmentValue == null || environmentValue.isEmpty()) { + throw new IOException("Undefined Windows environment variable: %" + variableName + "%"); + } + matcher.appendReplacement(expanded, Matcher.quoteReplacement(environmentValue)); + } + matcher.appendTail(expanded); + return expanded.toString(); + } + + private static String getEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value != null) { + return value; + } + + for (Map.Entry entry : System.getenv().entrySet()) { + if (entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } + + private static File resolveCodeSourcePath(Class anchor) { + try { + URL location = anchor.getProtectionDomain().getCodeSource().getLocation(); + return location == null ? null : new File(location.toURI()); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/InstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/InstallerPackager.java new file mode 100644 index 000000000..bd72fcf56 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/InstallerPackager.java @@ -0,0 +1,345 @@ +package com.skcraft.launcher.installer; + +import com.skcraft.launcher.installer.platform.DockerLinuxInstallerPackager; +import com.skcraft.launcher.installer.platform.LinuxInstallerPackager; +import com.skcraft.launcher.installer.platform.MacInstallerPackager; +import com.skcraft.launcher.installer.platform.WindowsInstallerPackager; +import com.skcraft.launcher.installer.platform.WslLinuxInstallerPackager; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; + +public final class InstallerPackager { + + private InstallerPackager() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 1) { + throw new IllegalArgumentException("Usage: InstallerPackager ..."); + } + + String mode = args[0].toLowerCase(Locale.ROOT); + Platform packager = switch (mode) { + case "windows" -> new WindowsInstallerPackager(); + case "linux" -> new LinuxInstallerPackager(); + case "mac" -> new MacInstallerPackager(); + case "wsl-linux" -> new WslLinuxInstallerPackager(); + case "docker-linux" -> new DockerLinuxInstallerPackager(); + default -> throw new IllegalArgumentException("Unsupported mode: " + mode); + }; + packager.run(Arrays.copyOfRange(args, 1, args.length)); + } + + public abstract static class Platform { + protected static final String APP_DIR = "app"; + protected static final String RUNTIME_DIR = "runtime"; + protected static final String LAUNCHER_DIR = "launcher"; + protected static final String DATA_SUBDIR = "bootstrap"; + + protected Platform() { + } + + public abstract void run(String[] args) throws Exception; + + protected Path createBundledAppInput(Path appImageDir, String prefix) throws IOException { + Path inputDir = Files.createTempDirectory(prefix); + copyDirectory(appImageDir.resolve(APP_DIR), inputDir); + copyDirectory(appImageDir.resolve(DATA_SUBDIR), inputDir.resolve(DATA_SUBDIR)); + return inputDir; + } + + protected void ensureAppImageReady(Path appImageDir) { + ensureExists(appImageDir.resolve(RUNTIME_DIR), "Missing app image runtime"); + ensureExists(appImageDir.resolve(APP_DIR).resolve("launcher-bootstrap.jar"), "Missing app image jar"); + ensureExists(appImageDir.resolve(DATA_SUBDIR).resolve(LAUNCHER_DIR), "Missing bundled launcher directory"); + ensureExists(appImageDir.resolve("launcher-bootstrap"), "Missing launcher shell wrapper"); + } + + protected Path prepareJpackageResourceDir(Path projectDir, String platform, Map replacements) + throws IOException { + Path sourceDir = projectDir.resolve("installer").resolve(platform).resolve("jpackage-resources"); + Path targetDir = projectDir.resolve("build/tmp/jpackage-resources").resolve(platform); + + ensureExists(sourceDir, "Missing jpackage resource directory"); + deleteDirectory(targetDir); + Files.walkFileTree(sourceDir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Path rel = sourceDir.relativize(dir); + Files.createDirectories(targetDir.resolve(rel)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path rel = sourceDir.relativize(file); + Path target = targetDir.resolve(rel); + String text = Files.readString(file, StandardCharsets.UTF_8); + for (Map.Entry entry : replacements.entrySet()) { + text = text.replace(entry.getKey(), entry.getValue()); + } + Files.writeString(target, text, StandardCharsets.UTF_8); + if (target.getFileName().toString().startsWith("post")) { + setExecutable(target); + } + return FileVisitResult.CONTINUE; + } + }); + + return targetDir; + } + + protected Properties loadInstallerProperties(Path projectDir) throws IOException { + Path installerProperties = projectDir.resolve("installer/installer.properties"); + Properties properties = new Properties(); + try (InputStream in = Files.newInputStream(installerProperties)) { + properties.load(in); + } + return properties; + } + + protected String getBootstrapPropertyWithFallback(Path projectDir, String key, String fallbackKey) + throws IOException { + Path propertiesFile = projectDir.resolve("src/main/resources/com/skcraft/launcher/bootstrap.properties"); + Properties properties = new Properties(); + try (InputStream input = Files.newInputStream(propertiesFile)) { + properties.load(input); + } + String value = properties.getProperty(key); + if (value != null && !value.isBlank()) { + return value.trim(); + } + value = properties.getProperty(fallbackKey); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Missing bootstrap property '" + fallbackKey + "' in " + propertiesFile); + } + return value.trim(); + } + + protected List append(List base, String... extra) { + List merged = new ArrayList<>(base); + for (String s : extra) { + merged.add(s); + } + return merged; + } + + /** + * Nested Linux Gradle should reuse host-built jars/classes and only rebuild + * Linux-specific packaging (jlink runtime, AppImage, deb). + */ + protected String remoteLinuxPackageGradleArgs(String version, boolean buildDeb, String displayAppName, + String linuxInstallDirName) { + return ":launcher-bootstrap:packageLinux" + + " -Pversion=" + shellSingleQuote(version) + + " -PbuildDeb=" + shellSingleQuote(buildDeb ? "true" : "false") + + " -PappName=" + shellSingleQuote(displayAppName) + + " -PinstallDirName=" + shellSingleQuote(linuxInstallDirName) + + " -x :launcher:generateSwtChecksums" + + " -x :launcher:generateEffectiveLombokConfig" + + " -x :launcher:generateTestEffectiveLombokConfig" + + " -x :launcher:compileJava" + + " -x :launcher:processResources" + + " -x :launcher:classes" + + " -x :launcher:shadowJar" + + " -x :launcher-bootstrap:generateEffectiveLombokConfig" + + " -x :launcher-bootstrap:compileJava" + + " -x :launcher-bootstrap:processResources" + + " -x :launcher-bootstrap:classes" + + " -x :launcher-bootstrap:shadowJar"; + } + + protected String shellSingleQuote(String value) { + return "'" + value.replace("'", "'\"'\"'") + "'"; + } + + /** + * Installer artifact file name. Platform is implied by the output folder + * ({@code installer/windows|linux|macos}), so names are just + * {@code } (e.g. {@code .AppImage}, {@code .dmg}, + * {@code Setup.exe}). + */ + protected String installerFileName(String displayAppName, String extension) { + return normalizePackageAppName(displayAppName) + extension; + } + + protected void renamePackagedFile(Path outputDir, String fromName, String toName) throws IOException { + if (fromName.equals(toName)) { + return; + } + Path from = outputDir.resolve(fromName); + Path to = outputDir.resolve(toName); + ensureExists(from, "Expected packaged file missing"); + Files.deleteIfExists(to); + Files.move(from, to); + } + + protected boolean parseBooleanFlag(String raw) { + if (raw == null) { + return false; + } + String value = raw.trim().toLowerCase(Locale.ROOT); + return value.equals("1") + || value.equals("true") + || value.equals("yes") + || value.equals("on"); + } + + protected String normalizePackageAppName(String appName) { + if (appName == null || appName.isBlank()) { + throw new IllegalArgumentException("Package app name must not be blank."); + } + return appName.trim(); + } + + protected String normalizeInstallDirName(String installDirName) { + if (installDirName == null) { + throw new IllegalArgumentException("Install dir name must not be blank."); + } + String trimmed = installDirName.trim(); + if (trimmed.isBlank()) { + throw new IllegalArgumentException("Install dir name must not be blank."); + } + return trimmed; + } + + protected String findOnPath(String executable) { + String path = System.getenv("PATH"); + if (path == null || path.isBlank()) { + return null; + } + for (String entry : path.split(java.io.File.pathSeparator)) { + if (entry == null || entry.isBlank()) { + continue; + } + Path candidate = Paths.get(entry).resolve(executable); + if (Files.isExecutable(candidate)) { + return candidate.toString(); + } + } + return null; + } + + protected void ensureExists(Path path, String message) { + if (!Files.exists(path)) { + throw new IllegalStateException(message + ": " + path); + } + } + + protected void runCommand(List command, Path workingDirectory, Map extraEnv) + throws Exception { + ProcessBuilder builder = new ProcessBuilder(command); + if (workingDirectory != null) { + builder.directory(workingDirectory.toFile()); + } + builder.inheritIO(); + builder.environment().putAll(extraEnv); + Process process = builder.start(); + int exitCode = process.waitFor(); + if (exitCode != 0) { + throw new IllegalStateException("Command failed (" + exitCode + "): " + String.join(" ", command)); + } + } + + protected String runAndCapture(List command, Path workingDirectory, Map extraEnv) + throws Exception { + ProcessBuilder builder = new ProcessBuilder(command); + if (workingDirectory != null) { + builder.directory(workingDirectory.toFile()); + } + builder.environment().putAll(extraEnv); + Process process = builder.start(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + copy(process.getInputStream(), out); + copy(process.getErrorStream(), err); + int exitCode = process.waitFor(); + if (exitCode != 0) { + String errorText = err.toString(StandardCharsets.UTF_8); + if (!errorText.isBlank()) { + System.err.print(errorText); + } + throw new IllegalStateException("Command failed (" + exitCode + "): " + String.join(" ", command)); + } + return out.toString(StandardCharsets.UTF_8); + } + + protected void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) >= 0) { + out.write(buffer, 0, read); + } + } + + protected void copyDirectory(Path source, Path target) throws IOException { + Files.walkFileTree(source, new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + Path rel = source.relativize(dir); + Files.createDirectories(target.resolve(rel)); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Path rel = source.relativize(file); + Files.copy(file, target.resolve(rel), StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + } + + protected void deleteDirectory(Path dir) throws IOException { + if (!Files.exists(dir)) { + return; + } + try (var paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + } + } + + protected void setExecutable(Path file) throws IOException { + try { + Set perms = EnumSet.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE, + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_EXECUTE); + Files.setPosixFilePermissions(file, perms); + } catch (UnsupportedOperationException ignored) { + file.toFile().setExecutable(true, false); + } + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/DockerLinuxInstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/DockerLinuxInstallerPackager.java new file mode 100644 index 000000000..f0a54088f --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/DockerLinuxInstallerPackager.java @@ -0,0 +1,107 @@ +package com.skcraft.launcher.installer.platform; + +import com.skcraft.launcher.installer.InstallerPackager; + +import java.nio.charset.StandardCharsets; +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.Locale; +import java.util.Map; + +public final class DockerLinuxInstallerPackager extends InstallerPackager.Platform { + /** Matches gradle/wrapper/gradle-wrapper.properties (JDK 17). */ + private static final String DEFAULT_IMAGE = "gradle:8.14.0-jdk17"; + private static final String CONTAINER_WORKSPACE = "/workspace"; + private static final String CONTAINER_SCRIPT = "/tmp/skcraft-package-linux.sh"; + + @Override + public void run(String[] args) throws Exception { + if (args.length < 5) { + throw new IllegalArgumentException( + "Usage: InstallerPackager docker-linux [image]"); + } + packageLinuxFromDocker(Paths.get(args[0]), args[1], parseBooleanFlag(args[2]), + normalizePackageAppName(args[3]), normalizeInstallDirName(args[4]), + args.length >= 6 ? args[5] : ""); + } + + private void packageLinuxFromDocker(Path repoDir, String version, boolean buildDeb, String displayAppName, + String linuxInstallDirName, String image) throws Exception { + String docker = findDocker(); + if (docker == null) { + throw new IllegalStateException("docker was not found on PATH. Install Docker Desktop or Docker Engine first."); + } + + String dockerImage = (image == null || image.isBlank()) ? DEFAULT_IMAGE : image.trim(); + String hostRepo = repoDir.toAbsolutePath().normalize().toString(); + String mountSource = hostRepo.replace('\\', '/'); + + Path outputDir = repoDir.resolve("launcher-bootstrap/build/installer/linux"); + Path scriptFile = Files.createTempFile("skcraft-package-linux-", ".sh"); + try { + String script = "" + + "#!/usr/bin/env bash\n" + + "set -euo pipefail\n" + + "export DEBIAN_FRONTEND=noninteractive\n" + + "export APPIMAGE_EXTRACT_AND_RUN=1\n" + + "missing=\n" + + "command -v file >/dev/null 2>&1 || missing=\"$missing file\"\n" + + (buildDeb ? "command -v fakeroot >/dev/null 2>&1 || missing=\"$missing fakeroot\"\n" : "") + + "if [ -n \"$missing\" ]; then\n" + + " apt-get update -qq\n" + + " apt-get install -y -qq $missing\n" + + "fi\n" + + "cd \"" + CONTAINER_WORKSPACE + "\"\n" + + "command -v gradle >/dev/null 2>&1 || { echo 'Missing gradle in Docker image.' >&2; exit 1; }\n" + + "command -v java >/dev/null 2>&1 || { echo 'Missing Java in Docker image.' >&2; exit 1; }\n" + + "gradle --no-daemon --project-cache-dir /tmp/skcraft-project-cache " + + remoteLinuxPackageGradleArgs(version, buildDeb, displayAppName, linuxInstallDirName) + "\n"; + Files.writeString(scriptFile, script, StandardCharsets.UTF_8); + + String scriptMount = scriptFile.toAbsolutePath().normalize().toString().replace('\\', '/'); + + List command = new ArrayList<>(); + command.add(docker); + command.add("run"); + command.add("--rm"); + command.add("--user"); + command.add("root"); + command.add("--entrypoint"); + command.add("bash"); + command.add("-v"); + command.add(mountSource + ":" + CONTAINER_WORKSPACE); + command.add("-v"); + command.add(scriptMount + ":" + CONTAINER_SCRIPT + ":ro"); + command.add("-w"); + command.add(CONTAINER_WORKSPACE); + command.add(dockerImage); + command.add(CONTAINER_SCRIPT); + + System.out.println("Using Docker image: " + dockerImage); + System.out.println("Reusing host-built jars; Linux nested Gradle skips compile/shadow tasks."); + runCommand(command, repoDir, Map.of()); + + Path appImage = outputDir.resolve(installerFileName(displayAppName, ".AppImage")); + Path tarball = outputDir.resolve(installerFileName(displayAppName, ".tar.gz")); + ensureExists(appImage, "Docker Linux packaging finished without AppImage"); + ensureExists(tarball, "Docker Linux packaging finished without tarball"); + System.out.println("Linux artifacts written to launcher-bootstrap/build/installer/linux"); + } finally { + Files.deleteIfExists(scriptFile); + } + } + + private String findDocker() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + if (os.contains("win")) { + String exe = findOnPath("docker.exe"); + if (exe != null) { + return exe; + } + } + return findOnPath("docker"); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/LinuxInstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/LinuxInstallerPackager.java new file mode 100644 index 000000000..0e370dbc4 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/LinuxInstallerPackager.java @@ -0,0 +1,170 @@ +package com.skcraft.launcher.installer.platform; + +import com.skcraft.launcher.bootstrap.BootstrapUtils; +import com.skcraft.launcher.installer.InstallerPackager; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public final class LinuxInstallerPackager extends InstallerPackager.Platform { + private static final String LINUX_BROWSER_DEPS = "libgtk-3-0, libwebkit2gtk-4.1-0 | libwebkit2gtk-4.0-37"; + private static final String LINUX_APP_DIR = "usr/lib/launcher-bootstrap"; + + @Override + public void run(String[] args) throws Exception { + if (args.length < 5) { + throw new IllegalArgumentException( + "Usage: InstallerPackager linux "); + } + packageLinux(Paths.get(args[0]), args[1], parseBooleanFlag(args[2]), + normalizePackageAppName(args[3]), normalizeInstallDirName(args[4])); + } + + private void packageLinux(Path projectDir, String version, boolean buildDeb, String displayAppName, + String linuxInstallDirName) throws Exception { + Path appImageDir = projectDir.resolve("build/app-image"); + Path outputDir = projectDir.resolve("build/installer/linux"); + Path iconPng = projectDir.resolve("src/main/resources/com/skcraft/launcher/bootstrapper_icon.png"); + + ensureAppImageReady(appImageDir); + ensureExists(iconPng, "Missing launcher icon PNG"); + Files.createDirectories(outputDir); + + String appImageTool = System.getenv("APPIMAGE_TOOL"); + if (appImageTool == null || appImageTool.isBlank()) { + appImageTool = "/tmp/appimagetool-x86_64.AppImage"; + if (!Files.isExecutable(Paths.get(appImageTool))) { + runCommand(List.of( + "curl", "--fail", "--location", + "--output", appImageTool, + "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"), + projectDir, Map.of()); + setExecutable(Paths.get(appImageTool)); + } + } + + String runtimeFile = System.getenv("APPIMAGE_RUNTIME"); + if (runtimeFile == null || runtimeFile.isBlank()) { + runtimeFile = "/tmp/appimage-runtime-x86_64"; + if (!Files.exists(Paths.get(runtimeFile))) { + runCommand(List.of( + "curl", "--fail", "--location", + "--output", runtimeFile, + "https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-x86_64"), + projectDir, Map.of()); + setExecutable(Paths.get(runtimeFile)); + } + } + + String appImageName = installerFileName(displayAppName, ".AppImage"); + Path appImagePath = outputDir.resolve(appImageName); + Path workDir = Files.createTempDirectory("skcraft-appimage-"); + try { + Path appDir = workDir.resolve("AppDir"); + Path appLibDir = appDir.resolve(LINUX_APP_DIR); + Files.createDirectories(appLibDir); + copyLinuxAppPayload(appImageDir, appLibDir); + + Files.writeString(appDir.resolve("AppRun"), + "#!/bin/sh\n" + + "HERE=\"$(dirname \"$(readlink -f \"$0\")\")\"\n" + + "exec \"$HERE/usr/lib/launcher-bootstrap/launcher-bootstrap\" \"$@\"\n", + StandardCharsets.UTF_8); + setExecutable(appDir.resolve("AppRun")); + + Files.writeString(appDir.resolve("launcher-bootstrap.desktop"), + "[Desktop Entry]\n" + + "Type=Application\n" + + "Name=" + displayAppName + "\n" + + "Exec=AppRun %F\n" + + "Icon=launcher-bootstrap\n" + + "StartupWMClass=com.skcraft.launcher.Bootstrap\n" + + "Categories=Game;\n", + StandardCharsets.UTF_8); + + Files.copy(iconPng, + appDir.resolve("launcher-bootstrap.png"), StandardCopyOption.REPLACE_EXISTING); + + Map env = new HashMap<>(); + env.put("ARCH", "x86_64"); + env.put("VERSION", version); + env.put("APPIMAGETOOL_APP_NAME", displayAppName); + if (appImageTool.endsWith(".AppImage")) { + env.put("APPIMAGE_EXTRACT_AND_RUN", "1"); + } + + runCommand(List.of( + appImageTool, + "--runtime-file", runtimeFile, + appDir.toString(), + appImagePath.toString()), projectDir, env); + setExecutable(appImagePath); + + if (findOnPath("zsyncmake") != null) { + runCommand(List.of( + "zsyncmake", + "-o", outputDir.resolve(appImageName + ".zsync").toString(), + appImagePath.toString()), projectDir, Map.of()); + } + } finally { + deleteDirectory(workDir); + } + + Path linuxTarball = outputDir.resolve(installerFileName(displayAppName, ".tar.gz")); + runCommand(List.of( + "tar", + "-C", appImageDir.toString(), + "-czf", linuxTarball.toString(), + "."), projectDir, Map.of()); + + if (buildDeb) { + Path debInputDir = createBundledAppInput(appImageDir, "skcraft-deb-input-"); + try { + Path resourceDir = prepareJpackageResourceDir(projectDir, "linux", Map.of( + "@LINUX_DATA_DIR@", BootstrapUtils.sanitizeLinuxDataDirName( + getBootstrapPropertyWithFallback(projectDir, + "packageAppNameLinux", + "packageAppName")), + "@LINUX_INSTALL_DIR@", linuxInstallDirName)); + + runCommand(List.of( + "jpackage", + "--type", "deb", + "--dest", outputDir.toString(), + "--name", linuxInstallDirName, + "--app-version", version, + "--vendor", "SKCraft", + "--input", debInputDir.toString(), + "--main-jar", "launcher-bootstrap.jar", + "--runtime-image", appImageDir.resolve(RUNTIME_DIR).toString(), + "--icon", iconPng.toString(), + "--resource-dir", resourceDir.toString(), + "--linux-package-deps", LINUX_BROWSER_DEPS, + "--linux-shortcut", + "--linux-app-category", "Game"), projectDir, Map.of()); + System.out.println("Built DEB package in " + outputDir); + } finally { + deleteDirectory(debInputDir); + } + } + + System.out.println("Built AppImage: " + appImagePath); + System.out.println("Built Linux tarball: " + linuxTarball); + } + + private void copyLinuxAppPayload(Path appImageDir, Path targetDir) throws IOException { + copyDirectory(appImageDir.resolve(RUNTIME_DIR), targetDir.resolve(RUNTIME_DIR)); + copyDirectory(appImageDir.resolve(APP_DIR), targetDir.resolve(APP_DIR)); + copyDirectory(appImageDir.resolve(DATA_SUBDIR), targetDir.resolve(DATA_SUBDIR)); + Files.copy(appImageDir.resolve("launcher-bootstrap"), targetDir.resolve("launcher-bootstrap"), + StandardCopyOption.REPLACE_EXISTING); + setExecutable(targetDir.resolve("launcher-bootstrap")); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/MacInstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/MacInstallerPackager.java new file mode 100644 index 000000000..85393d2a9 --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/MacInstallerPackager.java @@ -0,0 +1,204 @@ +package com.skcraft.launcher.installer.platform; + +import com.skcraft.launcher.installer.InstallerPackager; + +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class MacInstallerPackager extends InstallerPackager.Platform { + /** + * macOS Dock/Finder icons sit in ~80% of the canvas; edge-to-edge art reads + * oversized. + */ + private static final double MAC_ICON_CONTENT_SCALE = 0.80; + private static final int MAC_ICON_MASTER_SIZE = 1024; + + @Override + public void run(String[] args) throws Exception { + if (args.length < 3) { + throw new IllegalArgumentException( + "Usage: InstallerPackager mac "); + } + packageMac(Paths.get(args[0]), args[1], normalizePackageAppName(args[2])); + } + + private void packageMac(Path projectDir, String version, String appName) throws Exception { + Path appImageDir = projectDir.resolve("build/app-image"); + Path outputDir = projectDir.resolve("build/installer/macos"); + Path iconPng = projectDir.resolve("src/main/resources/com/skcraft/launcher/bootstrapper_icon.png"); + Path iconIcns = projectDir.resolve("build/tmp/macos/icon.icns"); + Files.createDirectories(outputDir); + ensureAppImageReady(appImageDir); + ensureExists(iconPng, "Missing launcher icon PNG"); + + createMacIcnsFromPng(iconPng, iconIcns, projectDir); + + String macPackageIdentifier = toMacPackageIdentifier(appName); + String macDataDir = "Library/Application Support/" + + getBootstrapPropertyWithFallback(projectDir, "packageAppNameMac", "packageAppName"); + + List commonJpackageArgs = List.of( + "--name", appName, + "--app-version", version, + "--vendor", "SKCraft", + "--main-jar", "launcher-bootstrap.jar", + "--runtime-image", appImageDir.resolve(RUNTIME_DIR).toString(), + "--java-options", "-splash:$APPDIR/splash.png", + "--icon", iconIcns.toString(), + "--mac-package-identifier", macPackageIdentifier, + "--mac-app-category", "games"); + + // DMG: bootstrap-only (no bundled launcher/natives — bootstrap downloads on + // first run) + Path dmgInputDir = createDmgInput(appImageDir, "skcraft-mac-dmg-"); + try { + List dmgCommand = new ArrayList<>(); + dmgCommand.add("jpackage"); + dmgCommand.add("--type"); + dmgCommand.add("dmg"); + dmgCommand.add("--dest"); + dmgCommand.add(outputDir.toString()); + dmgCommand.add("--input"); + dmgCommand.add(dmgInputDir.toString()); + dmgCommand.addAll(commonJpackageArgs); + runCommand(dmgCommand, projectDir, Map.of()); + // jpackage names DMG as "{name}-{version}.dmg"; match Windows/Linux (no version). + renamePackagedFile(outputDir, appName + "-" + version + ".dmg", installerFileName(appName, ".dmg")); + } finally { + deleteDirectory(dmgInputDir); + } + + // PKG: includes bundled launcher jar + natives, seeded by postinstall + Path pkgInputDir = createBundledAppInput(appImageDir, "skcraft-mac-pkg-"); + try { + Path resourceDir = prepareJpackageResourceDir(projectDir, "macos", Map.of( + "@MAC_DATA_DIR@", macDataDir, + "@MAC_APP_NAME@", appName)); + + List pkgCommand = new ArrayList<>(); + pkgCommand.add("jpackage"); + pkgCommand.add("--type"); + pkgCommand.add("pkg"); + pkgCommand.add("--dest"); + pkgCommand.add(outputDir.toString()); + pkgCommand.add("--input"); + pkgCommand.add(pkgInputDir.toString()); + pkgCommand.addAll(commonJpackageArgs); + pkgCommand.add("--resource-dir"); + pkgCommand.add(resourceDir.toString()); + runCommand(pkgCommand, projectDir, Map.of()); + renamePackagedFile(outputDir, appName + "-" + version + ".pkg", installerFileName(appName, ".pkg")); + } finally { + deleteDirectory(pkgInputDir); + } + + Path macTarball = outputDir.resolve(installerFileName(appName, ".tar.gz")); + runCommand(List.of( + "tar", + "-C", appImageDir.toString(), + "-czf", macTarball.toString(), + "."), projectDir, Map.of()); + + System.out.println("Built macOS DMG: " + outputDir.resolve(installerFileName(appName, ".dmg"))); + System.out.println("Built macOS PKG: " + outputDir.resolve(installerFileName(appName, ".pkg"))); + System.out.println("Built macOS tarball: " + macTarball); + } + + private String toMacPackageIdentifier(String appName) { + String sanitized = appName == null ? "" : appName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", ""); + if (sanitized.isBlank()) { + sanitized = "launcher"; + } + return "com.skcraft." + sanitized; + } + + private Path createDmgInput(Path appImageDir, String prefix) throws IOException { + Path inputDir = Files.createTempDirectory(prefix); + copyDirectory(appImageDir.resolve(APP_DIR), inputDir); + return inputDir; + } + + private void createMacIcnsFromPng(Path iconPng, Path iconIcns, Path workingDir) throws Exception { + Files.createDirectories(iconIcns.getParent()); + // iconutil requires the directory name to end with ".iconset" + Path iconsetParent = Files.createTempDirectory("skcraft-iconset-"); + Path iconsetDir = iconsetParent.resolve("AppIcon.iconset"); + Files.createDirectories(iconsetDir); + try { + // Pad before sips so Dock optical size matches system apps; high-quality + // 1024 master keeps 512@2x sharp (no upsample from a smaller asset). + Path masterPng = iconsetParent.resolve("mac-icon-master.png"); + writeMacPaddedIconPng(iconPng, masterPng); + + int[] baseSizes = new int[] { 16, 32, 128, 256, 512 }; + for (int size : baseSizes) { + Path normal = iconsetDir.resolve("icon_" + size + "x" + size + ".png"); + Path retina = iconsetDir.resolve("icon_" + size + "x" + size + "@2x.png"); + + // Capture (don't inherit) so sips path spam stays out of Gradle logs. + runAndCapture(List.of( + "sips", "-z", String.valueOf(size), String.valueOf(size), + masterPng.toString(), + "--out", normal.toString()), workingDir, Map.of()); + if (size * 2 == MAC_ICON_MASTER_SIZE) { + Files.copy(masterPng, retina, StandardCopyOption.REPLACE_EXISTING); + } else { + runAndCapture(List.of( + "sips", "-z", String.valueOf(size * 2), String.valueOf(size * 2), + masterPng.toString(), + "--out", retina.toString()), workingDir, Map.of()); + } + } + + runAndCapture(List.of( + "iconutil", + "-c", "icns", + iconsetDir.toString(), + "-o", iconIcns.toString()), workingDir, Map.of()); + } finally { + deleteDirectory(iconsetParent); + } + } + + /** + * Centers the source art on a transparent 1024 canvas at + * {@link #MAC_ICON_CONTENT_SCALE} + * so macOS squircle + shadow optical size matches typical Dock/Finder icons. + */ + private void writeMacPaddedIconPng(Path sourcePng, Path destPng) throws IOException { + BufferedImage source = ImageIO.read(sourcePng.toFile()); + if (source == null) { + throw new IOException("Unable to read icon PNG: " + sourcePng); + } + + int canvas = MAC_ICON_MASTER_SIZE; + int content = (int) Math.round(canvas * MAC_ICON_CONTENT_SCALE); + BufferedImage out = new BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int x = (canvas - content) / 2; + int y = (canvas - content) / 2; + g.drawImage(source, x, y, content, content, null); + } finally { + g.dispose(); + } + + if (!ImageIO.write(out, "png", destPng.toFile())) { + throw new IOException("Unable to write padded macOS icon PNG: " + destPng); + } + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WindowsInstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WindowsInstallerPackager.java new file mode 100644 index 000000000..2f8f21cbb --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WindowsInstallerPackager.java @@ -0,0 +1,186 @@ +package com.skcraft.launcher.installer.platform; + +import com.skcraft.launcher.installer.InstallerPackager; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +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.Map; + +public final class WindowsInstallerPackager extends InstallerPackager.Platform { + private static final String WEBVIEW2_BOOTSTRAPPER_FILE = "MicrosoftEdgeWebview2Setup.exe"; + + @Override + public void run(String[] args) throws Exception { + if (args.length < 5) { + throw new IllegalArgumentException( + "Usage: InstallerPackager windows "); + } + packageWindows(Paths.get(args[0]), args[1], args[2], args[3], args[4]); + } + + private void packageWindows(Path projectDir, String version, String displayAppName, String installDirName, + String installBaseDir) throws Exception { + String appName = normalizeWindowsAppName(displayAppName); + String installDir = normalizeInstallDirName(installDirName); + String defaultInstallBaseDir = normalizeWindowsInstallBaseDir(installBaseDir); + String setupFileName = installerFileName(appName, " Setup.exe"); + Path appImageDir = projectDir.resolve("build/windows-app-image").resolve(appName); + Path outputDir = projectDir.resolve("build/installer/windows"); + Path iconPng = projectDir.resolve("src/main/resources/com/skcraft/launcher/bootstrapper_icon.png"); + Path iconIco = projectDir.resolve("build/tmp/windows/icon.ico"); + Path setupScript = projectDir.resolve("installer/windows/setup.nsi"); + Path webView2Bootstrapper = projectDir.resolve("build/webview2-runtime").resolve(WEBVIEW2_BOOTSTRAPPER_FILE); + + ensureExists(appImageDir, "Windows app image not found"); + ensureExists(appImageDir.resolve(DATA_SUBDIR).resolve(LAUNCHER_DIR), "Missing bundled launcher directory"); + ensureExists(iconPng, "Launcher icon PNG not found"); + ensureExists(setupScript, "NSIS setup script not found"); + ensureExists(webView2Bootstrapper, "Missing WebView2 bootstrapper"); + ensureExists(projectDir.resolve("installer/installer.properties"), + "Installer properties not found"); + Files.createDirectories(outputDir); + Path setupOutput = outputDir.resolve(setupFileName); + try { + Files.deleteIfExists(setupOutput); + } catch (IOException e) { + throw new IOException("Unable to replace existing Windows installer. Close any running installer or file " + + "viewer using " + setupOutput + " and try again.", e); + } + + writeIcoFromPng(iconPng, iconIco); + + String legacyHomeFolder = readLegacyHomeFolder(projectDir, "legacyHomeFolderWindows"); + Path installerDefines = projectDir.resolve("build/tmp/windows/installer-defines.nsh"); + writeInstallerDefines(installerDefines, legacyHomeFolder, appName, installDir, defaultInstallBaseDir, + setupFileName, appImageDir.toAbsolutePath().toString(), + webView2Bootstrapper.toAbsolutePath().toString()); + + String makensis = findWindowsTool("makensis.exe", + List.of( + Paths.get(System.getenv("ProgramFiles(x86)"), "NSIS", "makensis.exe"), + Paths.get(System.getenv("ProgramFiles"), "NSIS", "makensis.exe"))); + + List command = new ArrayList<>(); + command.add(makensis); + command.add("/DMyAppVersion=" + version); + command.add("/DOutputDir=" + outputDir.toAbsolutePath()); + command.add("/DIconIco=" + iconIco.toAbsolutePath()); + command.add("/DINSTALLER_DEFINES=" + installerDefines.toAbsolutePath()); + command.add(setupScript.toAbsolutePath().toString()); + runCommand(command, projectDir, Map.of()); + System.out.println("Windows installer written to " + setupOutput); + } + + private String normalizeWindowsAppName(String appName) { + if (appName == null || appName.isBlank()) { + throw new IllegalArgumentException("Windows app name must not be blank."); + } + return appName.trim(); + } + + private String readLegacyHomeFolder(Path projectDir, String propertyKey) throws IOException { + return loadInstallerProperties(projectDir).getProperty(propertyKey, "").trim(); + } + + private void writeInstallerDefines(Path output, String legacyHomeFolder, + String displayAppName, String installDirName, String installBaseDir, String setupFileName, + String appImageDir, + String webView2Bootstrapper) + throws IOException { + Files.createDirectories(output.getParent()); + String content = "!define LegacyHomeFolder \"" + escapeNsisDefineValue(legacyHomeFolder) + "\"\r\n" + + "!define AppName \"" + escapeNsisDefineValue(displayAppName) + "\"\r\n" + + "!define InstallDirName \"" + escapeNsisDefineValue(installDirName) + "\"\r\n" + + "!define InstallBaseDir \"" + escapeNsisDefineValue(installBaseDir) + "\"\r\n" + + "!define SetupFileName \"" + escapeNsisDefineValue(setupFileName) + "\"\r\n" + + "!define AppImageDir \"" + toNsisPath(appImageDir) + "\"\r\n" + + "!define WebView2Bootstrapper \"" + escapeNsisDefinePath(webView2Bootstrapper) + "\"\r\n"; + Files.writeString(output, content, StandardCharsets.UTF_8); + } + + private String toNsisPath(String path) { + return escapeNsisDefineValue(path.replace('\\', '/')); + } + + private String escapeNsisDefinePath(String path) { + return path.replace("\"", "$\""); + } + + private String escapeNsisDefineValue(String value) { + return value.replace("\\", "$\\").replace("\"", "$\""); + } + + private String normalizeWindowsInstallBaseDir(String installBaseDir) { + if (installBaseDir == null || installBaseDir.isBlank()) { + throw new IllegalArgumentException("Windows install base dir must not be blank."); + } + return installBaseDir.trim(); + } + + private String findWindowsTool(String executable, List fallbackCandidates) { + String fromPath = findOnPath(executable); + if (fromPath != null) { + return fromPath; + } + for (Path candidate : fallbackCandidates) { + if (candidate != null && Files.isRegularFile(candidate)) { + return candidate.toString(); + } + } + if ("makensis.exe".equalsIgnoreCase(executable)) { + throw new IllegalStateException( + "NSIS (makensis.exe) not found.\n" + + "Install NSIS 3 and ensure makensis.exe is on PATH, or set NSIS_HOME.\n" + + " winget install NSIS.NSIS\n" + + " https://nsis.sourceforge.io/Download\n" + + "Or build without the installer: build.bat --no-installer"); + } + throw new IllegalStateException(executable + " not found."); + } + + private void writeIcoFromPng(Path sourcePng, Path targetIco) throws IOException { + BufferedImage image = ImageIO.read(sourcePng.toFile()); + if (image == null) { + throw new IllegalStateException("Failed to read PNG icon: " + sourcePng); + } + byte[] pngBytes = Files.readAllBytes(sourcePng); + int width = Math.min(image.getWidth(), 256); + int height = Math.min(image.getHeight(), 256); + + Files.createDirectories(targetIco.getParent()); + try (OutputStream out = Files.newOutputStream(targetIco)) { + writeLeShort(out, 0); + writeLeShort(out, 1); + writeLeShort(out, 1); + out.write(width >= 256 ? 0 : width); + out.write(height >= 256 ? 0 : height); + out.write(0); + out.write(0); + writeLeShort(out, 1); + writeLeShort(out, 32); + writeLeInt(out, pngBytes.length); + writeLeInt(out, 22); + out.write(pngBytes); + } + } + + private void writeLeShort(OutputStream out, int value) throws IOException { + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + } + + private void writeLeInt(OutputStream out, int value) throws IOException { + out.write(value & 0xFF); + out.write((value >> 8) & 0xFF); + out.write((value >> 16) & 0xFF); + out.write((value >> 24) & 0xFF); + } +} diff --git a/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WslLinuxInstallerPackager.java b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WslLinuxInstallerPackager.java new file mode 100644 index 000000000..c42126b4f --- /dev/null +++ b/launcher-bootstrap/src/main/java/com/skcraft/launcher/installer/platform/WslLinuxInstallerPackager.java @@ -0,0 +1,60 @@ +package com.skcraft.launcher.installer.platform; + +import com.skcraft.launcher.installer.InstallerPackager; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class WslLinuxInstallerPackager extends InstallerPackager.Platform { + + @Override + public void run(String[] args) throws Exception { + if (args.length < 5) { + throw new IllegalArgumentException( + "Usage: InstallerPackager wsl-linux [distro]"); + } + packageLinuxFromWsl(Paths.get(args[0]), args[1], parseBooleanFlag(args[2]), + normalizePackageAppName(args[3]), normalizeInstallDirName(args[4]), + args.length >= 6 ? args[5] : ""); + } + + private void packageLinuxFromWsl(Path repoDir, String version, boolean buildDeb, String displayAppName, + String linuxInstallDirName, String distro) throws Exception { + String wsl = findOnPath("wsl.exe"); + if (wsl == null) { + throw new IllegalStateException("wsl.exe was not found on PATH. Install WSL first."); + } + + List wslArgs = new ArrayList<>(); + wslArgs.add(wsl); + if (distro != null && !distro.isBlank()) { + wslArgs.add("-d"); + wslArgs.add(distro); + runCommand(append(wslArgs, "--", "echo", "WSL_OK"), repoDir, Map.of()); + } + + String repoForWsl = repoDir.toAbsolutePath().toString().replace('\\', '/'); + String wslRepo = runAndCapture(append(wslArgs, "wslpath", "-a", repoForWsl), repoDir, Map.of()).trim(); + if (wslRepo.isBlank() || !wslRepo.startsWith("/")) { + throw new IllegalStateException("Failed to resolve valid WSL path for repo directory: " + repoDir); + } + + String gradleArgs = remoteLinuxPackageGradleArgs(version, buildDeb, displayAppName, linuxInstallDirName); + String bashCommand = "set -euo pipefail; " + + "cd \"" + wslRepo + "\"; " + + "if ! command -v java >/dev/null 2>&1; then echo 'Missing Java in WSL distro. Install OpenJDK 17.' >&2; exit 1; fi; " + + + "trap 'rm -f ./gradlew-wsl' EXIT; " + + "tr -d '\\r' < ./gradlew > ./gradlew-wsl; " + + "chmod +x ./gradlew-wsl; " + + "GRADLE_USER_HOME=/tmp/skcraft-gradle ./gradlew-wsl --no-daemon --project-cache-dir /tmp/skcraft-project-cache " + + gradleArgs; + + System.out.println("Reusing host-built jars; Linux nested Gradle skips compile/shadow tasks."); + runCommand(append(wslArgs, "bash", "-lc", bashCommand), repoDir, Map.of()); + System.out.println("Linux artifacts written to launcher-bootstrap/build/installer/linux"); + } +} diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties index 678f61648..0e191908c 100644 --- a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties +++ b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/bootstrap.properties @@ -4,8 +4,12 @@ # Please see LICENSE.txt for license information. # -homeFolderWindows=Example Launcher -homeFolderLinux=example_launcher -homeFolder=.examplelauncher +packageAppName=SKCraft Launcher +# Optional per-platform overrides (uncomment to customize): +# packageAppNameWindows=Example Launcher +# packageAppNameMac=Example Launcher +# packageAppNameLinux=Example Launcher +# Required Windows install base. Supports %NAME% environment variables. +# Use forward slashes in custom paths. +installBaseDirWindows=%LOCALAPPDATA% launcherClass=com.skcraft.launcher.Launcher -latestUrl=http://update.skcraft.com/quark/launcher/latest.json diff --git a/launcher-bootstrap/src/main/resources/com/skcraft/launcher/splash.png b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/splash.png new file mode 100644 index 000000000..a4af7db05 Binary files /dev/null and b/launcher-bootstrap/src/main/resources/com/skcraft/launcher/splash.png differ diff --git a/launcher-builder/build.gradle b/launcher-builder/build.gradle index 224895771..3335cfdbc 100644 --- a/launcher-builder/build.gradle +++ b/launcher-builder/build.gradle @@ -1,12 +1,12 @@ plugins { id 'application' id 'java-library' - id "com.github.johnrengelman.shadow" + id 'com.gradleup.shadow' id 'io.freefair.lombok' } application { - mainClassName = "com.skcraft.launcher.builder.PackageBuilder" + mainClass = "com.skcraft.launcher.builder.PackageBuilder" } dependencies { @@ -16,13 +16,4 @@ dependencies { shadowJar { dependsOn ':launcher:shadowJar' - archiveClassifier.set("") } - -// Work around gradle shadow bug -// see https://github.com/johnrengelman/shadow/issues/713 -startScripts.dependsOn(':launcher:shadowJar') -distTar.dependsOn(':launcher:shadowJar') -distZip.dependsOn(':launcher:shadowJar') - -build.dependsOn(shadowJar) diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java index 51014174d..abfadbcc1 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/BuilderConfig.java @@ -7,7 +7,9 @@ package com.skcraft.launcher.builder; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonInclude; import com.google.common.collect.Lists; +import com.skcraft.launcher.model.minecraft.JavaVersion; import com.skcraft.launcher.model.modpack.LaunchModifier; import com.skcraft.launcher.model.modpack.Manifest; import lombok.Data; @@ -23,6 +25,8 @@ public class BuilderConfig { private String name; private String title; private String gameVersion; + @JsonInclude(JsonInclude.Include.NON_NULL) + private JavaVersion javaVersion; @JsonProperty("launch") private LaunchModifier launchModifier = new LaunchModifier(); private List features = Lists.newArrayList(); diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java index 13a2e7143..26ade9184 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FeaturePattern.java @@ -18,7 +18,11 @@ public class FeaturePattern { @JsonProperty("files") private FnPatternList filePatterns = new FnPatternList(); - public boolean matches(String path) { - return filePatterns != null && filePatterns.matches(path); + public boolean matchesInclude(String path) { + return filePatterns != null && filePatterns.matchesInclude(path); + } + + public boolean excludes(String path) { + return filePatterns != null && filePatterns.matchesExclude(path); } } diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java index b800f8fca..d2ed71ba3 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/FnPatternList.java @@ -39,6 +39,14 @@ public boolean matches(String path) { return include != null && matches(path, include) && (exclude == null || !matches(path, exclude)); } + public boolean matchesInclude(String path) { + return include != null && matches(path, include); + } + + public boolean matchesExclude(String path) { + return exclude != null && matches(path, exclude); + } + public boolean matches(String path, Collection patterns) { for (String pattern : patterns) { if (FnMatch.fnmatch(pattern, path, flags)) { diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java index 9b693ce69..7ef4155e6 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PackageBuilder.java @@ -21,6 +21,7 @@ import com.skcraft.launcher.LauncherUtils; import com.skcraft.launcher.builder.loaders.*; import com.skcraft.launcher.model.loader.BasicInstallProfile; +import com.skcraft.launcher.model.minecraft.JavaVersion; import com.skcraft.launcher.model.minecraft.Library; import com.skcraft.launcher.model.minecraft.ReleaseList; import com.skcraft.launcher.model.minecraft.Version; @@ -57,6 +58,7 @@ public class PackageBuilder { private ObjectWriter writer; private final Manifest manifest; private final PropertiesApplicator applicator; + private BuilderConfig config; @Getter private boolean prettyPrint = false; @@ -320,6 +322,7 @@ public void validateManifest() { public void readConfig(File path) throws IOException { if (path != null) { BuilderConfig config = read(path, BuilderConfig.class); + this.config = config; config.update(manifest); config.registerProperties(applicator); } @@ -353,6 +356,21 @@ public void readVersionManifest(File path) throws IOException, InterruptedExcept manifest.setVersionManifest(versionManifest); } + + applyJavaVersionOverride(); + } + + private void applyJavaVersionOverride() { + if (config == null || config.getJavaVersion() == null || manifest.getVersionManifest() == null) { + return; + } + + JavaVersion javaVersion = config.getJavaVersion(); + if (emptyToNull(javaVersion.getComponent()) == null || javaVersion.getMajorVersion() <= 0) { + return; + } + + manifest.getVersionManifest().setJavaVersion(javaVersion); } public void writeManifest(@NonNull File path) throws IOException { @@ -380,13 +398,11 @@ private static BuilderOptions parseArgs(String[] args) { private V read(File path, Class clazz) throws IOException { try { if (path == null) { - return clazz.newInstance(); + return clazz.getDeclaredConstructor().newInstance(); } else { return mapper.readValue(path, clazz); } - } catch (InstantiationException e) { - throw new IOException("Failed to create " + clazz.getCanonicalName(), e); - } catch (IllegalAccessException e) { + } catch (ReflectiveOperationException e) { throw new IOException("Failed to create " + clazz.getCanonicalName(), e); } } diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java index 586a04b60..d4b0693ae 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/PropertiesApplicator.java @@ -49,15 +49,30 @@ public boolean isUserFile(String path) { public Condition fromFeature(String path) { List found = new ArrayList(); + List excluded = new ArrayList(); for (FeaturePattern pattern : features) { - if (pattern.matches(path)) { + boolean includesPath = pattern.matchesInclude(path); + boolean excludesPath = pattern.excludes(path); + + if (includesPath || excludesPath) { used.add(pattern.getFeature()); + } + + if (includesPath && !excludesPath) { found.add(pattern.getFeature()); } + + if (excludesPath) { + excluded.add(pattern.getFeature()); + } } - if (!found.isEmpty()) { + if (!found.isEmpty() && !excluded.isEmpty()) { + return new RequireAnyAndNone(found, excluded); + } else if (!found.isEmpty()) { return new RequireAny(found); + } else if (!excluded.isEmpty()) { + return new RequireNone(excluded); } else { return null; } @@ -68,7 +83,14 @@ public void register(FeaturePattern component) { } public List getFeaturesInUse() { - return new ArrayList(used); + List ordered = new ArrayList(); + for (FeaturePattern pattern : features) { + Feature feature = pattern.getFeature(); + if (used.contains(feature)) { + ordered.add(feature); + } + } + return ordered; } } diff --git a/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java index fa3da44b6..262e62a26 100644 --- a/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java +++ b/launcher-builder/src/main/java/com/skcraft/launcher/builder/ServerCopyExport.java @@ -50,7 +50,7 @@ public static void main(String[] args) throws IOException { SimpleLogFormatter.configureGlobalLogger(); ServerExportOptions options = new ServerExportOptions(); - new JCommander(options, args); + new JCommander(options).parse(args); log.info("From: " + options.getSourceDir().getAbsolutePath()); log.info("To: " + options.getDestDir().getAbsolutePath()); diff --git a/launcher-fancy/build.gradle b/launcher-fancy/build.gradle deleted file mode 100644 index 22af7053d..000000000 --- a/launcher-fancy/build.gradle +++ /dev/null @@ -1,36 +0,0 @@ -plugins { - id 'application' - id "com.github.johnrengelman.shadow" - id 'io.freefair.lombok' -} - -application { - mainClassName = "com.skcraft.launcher.FancyLauncher" -} - -repositories { - maven { - name = 'bbkr.space maven' - url = 'https://server.bbkr.space/artifactory/libs-snapshot' - } -} - -dependencies { - implementation project(path: ':launcher') - implementation 'io.github.cottonmc.insubstantial:substance:7.3.1-SNAPSHOT' -} - -shadowJar { - dependsOn ':launcher:shadowJar' - archiveClassifier.set("") -} - -// Work around gradle shadow bug -// see https://github.com/johnrengelman/shadow/issues/713 -startScripts.dependsOn(':launcher:shadowJar') -distTar.dependsOn(':launcher:shadowJar') -distZip.dependsOn(':launcher:shadowJar') - -build { - dependsOn(shadowJar) -} diff --git a/launcher-fancy/lombok.config b/launcher-fancy/lombok.config deleted file mode 100644 index 6aa51d71e..000000000 --- a/launcher-fancy/lombok.config +++ /dev/null @@ -1,2 +0,0 @@ -# This file is generated by the 'io.freefair.lombok' Gradle plugin -config.stopBubbling = true diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyBackgroundPanel.java b/launcher-fancy/src/main/java/com/skcraft/launcher/FancyBackgroundPanel.java deleted file mode 100644 index 73f747f02..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyBackgroundPanel.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher; - -import javax.imageio.ImageIO; -import javax.swing.*; -import java.awt.*; -import java.io.IOException; - -public class FancyBackgroundPanel extends JPanel { - - private Image background; - - public FancyBackgroundPanel() { - try { - background = ImageIO.read(FancyBackgroundPanel.class.getResourceAsStream("launcher_bg.jpg")); - } catch (IOException e) { - background = null; - } - } - - @Override - protected void paintComponent(Graphics g) { - super.paintComponent(g); - if (background != null) { - double multi; - int w, h; - - // Calculate Aspect Ratio Multiplier depending on window size - if (this.getHeight() <= this.getWidth()) { - multi = this.getWidth() / (float)background.getWidth(null); - } - else { - multi = this.getHeight() / (float)background.getHeight(null); - } - - // Calculate new width and height - w = (int) Math.floor((float)background.getWidth(null) * multi); - h = (int) Math.floor((float)background.getHeight(null) * multi); - - // Check if it needs to be switched (eg. in case of a square window) - if (h < this.getHeight() || w < this.getWidth()) { - if (h < this.getHeight()) { - multi = this.getHeight() / (float)background.getHeight(null); - } - else if (w < this.getWidth()) { - multi = this.getWidth() / (float) background.getWidth(null); - } - - w = (int) Math.floor((float)background.getWidth(null) * multi); - h = (int) Math.floor((float)background.getHeight(null) * multi); - } - - g.drawImage(background, 0, 0, w, h,null); - } - } -} diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncher.java b/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncher.java deleted file mode 100644 index 871a2157a..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncher.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher; - -import com.google.common.base.Supplier; -import com.skcraft.launcher.swing.SwingHelper; -import lombok.extern.java.Log; - -import javax.swing.*; -import java.awt.*; -import java.util.logging.Level; - -@Log -public class FancyLauncher { - - public static void main(final String[] args) { - Launcher.setupLogger(); - - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - try { - Thread.currentThread().setContextClassLoader(FancyLauncher.class.getClassLoader()); - UIManager.getLookAndFeelDefaults().put("ClassLoader", FancyLauncher.class.getClassLoader()); - UIManager.getDefaults().put("SplitPane.border", BorderFactory.createEmptyBorder()); - JFrame.setDefaultLookAndFeelDecorated(true); - JDialog.setDefaultLookAndFeelDecorated(true); - System.setProperty("sun.awt.noerasebackground", "true"); - System.setProperty("substancelaf.windowRoundedCorners", "false"); - - if (!SwingHelper.setLookAndFeel("com.skcraft.launcher.skin.LauncherLookAndFeel")) { - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); - } - - Launcher launcher = Launcher.createFromArguments(args); - launcher.setMainWindowSupplier(new CustomWindowSupplier(launcher)); - launcher.showLauncherWindow(); - } catch (Throwable t) { - log.log(Level.WARNING, "Load failure", t); - SwingHelper.showErrorDialog(null, "Uh oh! The updater couldn't be opened because a " + - "problem was encountered.", "Launcher error", t); - } - } - }); - } - - private static class CustomWindowSupplier implements Supplier { - - private final Launcher launcher; - - private CustomWindowSupplier(Launcher launcher) { - this.launcher = launcher; - } - - @Override - public Window get() { - return new FancyLauncherFrame(launcher); - } - } - -} diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncherFrame.java b/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncherFrame.java deleted file mode 100644 index 0d413dd84..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/FancyLauncherFrame.java +++ /dev/null @@ -1,46 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher; - -import com.skcraft.launcher.dialog.LauncherFrame; -import com.skcraft.launcher.swing.SwingHelper; -import com.skcraft.launcher.swing.WebpagePanel; -import lombok.NonNull; - -import javax.swing.*; - -public class FancyLauncherFrame extends LauncherFrame { - - /** - * Create a new frame. - * - * @param launcher the launcher - */ - public FancyLauncherFrame(@NonNull Launcher launcher) { - super(launcher); - - setSize(800, 500); - setLocationRelativeTo(null); - - SwingHelper.removeOpaqueness(getInstancesTable()); - SwingHelper.removeOpaqueness(getInstanceScroll()); - getInstanceScroll().setBorder(BorderFactory.createEmptyBorder()); - } - - @Override - protected JPanel createContainerPanel() { - return new FancyBackgroundPanel(); - } - - @Override - protected WebpagePanel createNewsPanel() { - WebpagePanel panel = super.createNewsPanel(); - panel.setBrowserBorder(BorderFactory.createEmptyBorder()); - return panel; - } - -} diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherButtonShaper.java b/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherButtonShaper.java deleted file mode 100644 index 2ed037e59..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherButtonShaper.java +++ /dev/null @@ -1,21 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.skin; - -import org.pushingpixels.substance.api.shaper.ClassicButtonShaper; - -import javax.swing.*; -import java.awt.*; - -public class LauncherButtonShaper extends ClassicButtonShaper { - - public Dimension getPreferredSize(AbstractButton button, Dimension uiPreferredSize) { - Dimension size = super.getPreferredSize(button, uiPreferredSize); - return new Dimension(size.width + 5, size.height + 4); - } - -} diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherLookAndFeel.java b/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherLookAndFeel.java deleted file mode 100644 index 80d4725a0..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherLookAndFeel.java +++ /dev/null @@ -1,16 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.skin; - -import org.pushingpixels.substance.api.SubstanceLookAndFeel; - -public class LauncherLookAndFeel extends SubstanceLookAndFeel { - - public LauncherLookAndFeel() { - super(new LauncherSkin()); - } -} diff --git a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherSkin.java b/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherSkin.java deleted file mode 100644 index 15470d3d9..000000000 --- a/launcher-fancy/src/main/java/com/skcraft/launcher/skin/LauncherSkin.java +++ /dev/null @@ -1,94 +0,0 @@ -/* - * SKCraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.skin; - -import org.pushingpixels.substance.api.*; -import org.pushingpixels.substance.api.painter.border.ClassicBorderPainter; -import org.pushingpixels.substance.api.painter.border.CompositeBorderPainter; -import org.pushingpixels.substance.api.painter.border.DelegateBorderPainter; -import org.pushingpixels.substance.api.painter.decoration.FlatDecorationPainter; -import org.pushingpixels.substance.api.painter.fill.FractionBasedFillPainter; -import org.pushingpixels.substance.api.painter.highlight.ClassicHighlightPainter; -import org.pushingpixels.substance.api.skin.GraphiteSkin; - -public class LauncherSkin extends GraphiteSkin { - - public LauncherSkin() { - ColorSchemes schemes = SubstanceSkin.getColorSchemes("com/skcraft/launcher/skin/graphite.colorschemes"); - - SubstanceColorScheme activeScheme = schemes.get("Graphite Active"); - SubstanceColorScheme selectedDisabledScheme = schemes.get("Graphite Selected Disabled"); - SubstanceColorScheme selectedScheme = schemes.get("Graphite Selected"); - SubstanceColorScheme disabledScheme = schemes.get("Graphite Disabled"); - SubstanceColorScheme enabledScheme = schemes.get("Graphite Enabled"); - SubstanceColorScheme backgroundScheme = schemes.get("Graphite Background"); - SubstanceColorScheme highlightScheme = schemes.get("Graphite Highlight"); - SubstanceColorScheme borderScheme = schemes.get("Graphite Border"); - SubstanceColorScheme separatorScheme = schemes.get("Graphite Separator"); - SubstanceColorScheme textHighlightScheme = schemes.get("Graphite Text Highlight"); - SubstanceColorScheme highlightMarkScheme = schemes.get("Graphite Highlight Mark"); - SubstanceColorScheme tabHighlightScheme = schemes.get("Graphite Tab Highlight"); - - SubstanceColorSchemeBundle scheme = new SubstanceColorSchemeBundle(activeScheme, enabledScheme, disabledScheme); - - // highlight fill scheme + custom alpha for rollover unselected state - scheme.registerHighlightColorScheme(highlightScheme, 0.6f, ComponentState.ROLLOVER_UNSELECTED); - scheme.registerHighlightColorScheme(highlightScheme, 0.8f, ComponentState.SELECTED); - scheme.registerHighlightColorScheme(highlightScheme, 1.0f, ComponentState.ROLLOVER_SELECTED); - scheme.registerHighlightColorScheme(highlightScheme, 0.75f, ComponentState.ARMED, ComponentState.ROLLOVER_ARMED); - - // highlight border scheme - scheme.registerColorScheme(highlightScheme, ColorSchemeAssociationKind.HIGHLIGHT_BORDER, ComponentState.getActiveStates()); - scheme.registerColorScheme(borderScheme, ColorSchemeAssociationKind.BORDER); - scheme.registerColorScheme(separatorScheme, ColorSchemeAssociationKind.SEPARATOR); - - // text highlight scheme - scheme.registerColorScheme(textHighlightScheme, ColorSchemeAssociationKind.TEXT_HIGHLIGHT, ComponentState.SELECTED, ComponentState.ROLLOVER_SELECTED); - scheme.registerColorScheme(highlightScheme, ComponentState.ARMED, ComponentState.ROLLOVER_ARMED); - scheme.registerColorScheme(highlightMarkScheme, ColorSchemeAssociationKind.HIGHLIGHT_MARK, ComponentState.getActiveStates()); - scheme.registerColorScheme(highlightMarkScheme, ColorSchemeAssociationKind.MARK, ComponentState.ROLLOVER_SELECTED, ComponentState.ROLLOVER_UNSELECTED); - scheme.registerColorScheme(borderScheme, ColorSchemeAssociationKind.MARK, ComponentState.SELECTED); - scheme.registerColorScheme(disabledScheme, 0.5f, ComponentState.DISABLED_UNSELECTED); - scheme.registerColorScheme(selectedDisabledScheme, 0.65f, ComponentState.DISABLED_SELECTED); - scheme.registerColorScheme(highlightScheme, ComponentState.ROLLOVER_SELECTED); - scheme.registerColorScheme(selectedScheme, ComponentState.SELECTED); - - scheme.registerColorScheme(tabHighlightScheme, ColorSchemeAssociationKind.TAB, ComponentState.ROLLOVER_SELECTED); - - this.registerDecorationAreaSchemeBundle(scheme, backgroundScheme, DecorationAreaType.NONE); - - this.setSelectedTabFadeStart(0.1); - this.setSelectedTabFadeEnd(0.3); - - this.buttonShaper = new LauncherButtonShaper(); - this.watermark = null; - this.fillPainter = new FractionBasedFillPainter("Graphite", - new float[] { 0.0f, 0.5f, 1.0f }, - new ColorSchemeSingleColorQuery[] { - ColorSchemeSingleColorQuery.ULTRALIGHT, - ColorSchemeSingleColorQuery.LIGHT, - ColorSchemeSingleColorQuery.LIGHT }); - this.decorationPainter = new FlatDecorationPainter(); - this.highlightPainter = new ClassicHighlightPainter(); - this.borderPainter = new CompositeBorderPainter("Graphite", - new ClassicBorderPainter(), new DelegateBorderPainter( - "Graphite Inner", new ClassicBorderPainter(), - 0xA0FFFFFF, 0x60FFFFFF, 0x60FFFFFF, - new ColorSchemeTransform() { - @Override - public SubstanceColorScheme transform( - SubstanceColorScheme scheme) { - return scheme.tint(0.25f); - } - })); - - this.highlightBorderPainter = new ClassicBorderPainter(); - - this.watermarkScheme = schemes.get("Graphite Watermark"); - } - -} diff --git a/launcher-fancy/src/main/resources/com/skcraft/launcher/skin/graphite.colorschemes b/launcher-fancy/src/main/resources/com/skcraft/launcher/skin/graphite.colorschemes deleted file mode 100644 index 2c05502c6..000000000 --- a/launcher-fancy/src/main/resources/com/skcraft/launcher/skin/graphite.colorschemes +++ /dev/null @@ -1,153 +0,0 @@ -Graphite Enabled { - kind=Dark - colorUltraLight=#4A4A4A - colorExtraLight=#464646 - colorLight=#424242 - colorMid=#3F3F3F - colorDark=#353535 - colorUltraDark=#313131 - colorForeground=#B4B4B4 -} - -Graphite Border { - kind=Dark - colorUltraLight=#616161 - colorExtraLight=#5A5A5A - colorLight=#4F4F4F - colorMid=#414141 - colorDark=#323232 - colorUltraDark=#2B2B2B - colorForeground=#EDEDED -} - -Graphite Background { - kind=Dark - colorUltraLight=#555555 - colorExtraLight=#444444 - colorLight=#333333 - colorMid=#222222 - colorDark=#111111 - colorUltraDark=#000000 - colorForeground=#B4B4B4 -} - -Graphite Watermark { - kind=Dark - colorUltraLight=#555555 - colorExtraLight=#444444 - colorLight=#333333 - colorMid=#222222 - colorDark=#111111 - colorUltraDark=#000000 - colorForeground=#B4B4B4 -} - -Graphite Active { - kind=Dark - colorUltraLight=#777777 - colorExtraLight=#6F6F6F - colorLight=#636363 - colorMid=#535353 - colorDark=#434343 - colorUltraDark=#3B3B3B - colorForeground=#C8C8C8 -} - -Graphite Selected { - kind=Dark - colorUltraLight=#727272 - colorExtraLight=#696969 - colorLight=#606060 - colorMid=#505050 - colorDark=#404040 - colorUltraDark=#383838 - colorForeground=#C8C8C8 -} - -Graphite Selected Disabled { - kind=Dark - colorUltraLight=#777777 - colorExtraLight=#6F6F6F - colorLight=#636363 - colorMid=#535353 - colorDark=#434343 - colorUltraDark=#3B3B3B - colorForeground=#202020 -} - -Graphite Disabled { - kind=Dark - colorUltraLight=#646464 - colorExtraLight=#5E5E5E - colorLight=#545454 - colorMid=#474747 - colorDark=#3A3A3A - colorUltraDark=#333333 - colorForeground=#CCCCCC -} - -Graphite Highlight { - kind=Dark - colorUltraLight=#373737 - colorExtraLight=#373737 - colorLight=#373737 - colorMid=#373737 - colorDark=#373737 - colorUltraDark=#373737 - colorForeground=#CCCCCC -} - -Graphite Tab Highlight { - kind=Light - colorUltraLight=#FBFCFF - colorExtraLight=#F2F6FB - colorLight=#CED7E0 - colorMid=#BCC0C5 - colorDark=#62666B - colorUltraDark=#363B3F - colorForeground=#EDEDED -} - -Graphite Highlight Mark { - kind=Dark - colorUltraLight=#616161 - colorExtraLight=#5A5A5A - colorLight=#4F4F4F - colorMid=#414141 - colorDark=#323232 - colorUltraDark=#2B2B2B - colorForeground=#2B2B2B -} - -Graphite Text Highlight { - kind=Light - colorUltraLight=#BDBEC0 - colorExtraLight=#B8BBBD - colorLight=#A4A9AE - colorMid=#9A9D9F - colorDark=#686B6D - colorUltraDark=#505355 - colorForeground=#2B2F33 -} - -Graphite Separator { - kind=Dark - colorUltraLight=#565656 - colorExtraLight=#525252 - colorLight=#4F4F4F - colorMid=#383838 - colorDark=#353535 - colorUltraDark=#333333 - colorForeground=#B4B4B4 -} - -Graphite Aqua { - kind=Light - colorUltraLight=#6383FF - colorExtraLight=#577EFF - colorLight=#4A6FFF - colorMid=#355FFB - colorDark=#1A3DE6 - colorUltraDark=#153BE1 - colorForeground=#FFFFFF -} \ No newline at end of file diff --git a/launcher/build.gradle b/launcher/build.gradle index b41115ad2..e6859018f 100644 --- a/launcher/build.gradle +++ b/launcher/build.gradle @@ -1,14 +1,18 @@ plugins { id 'application' id 'java-library' - id "com.github.johnrengelman.shadow" + id 'com.gradleup.shadow' id 'io.freefair.lombok' } application { - mainClassName = "com.skcraft.launcher.Launcher" + mainClass = "com.skcraft.launcher.Launcher" } +apply from: rootProject.file('gradle/browser-runtime.gradle') + +configureLauncherBrowser() + dependencies { api 'javax.xml.bind:jaxb-api:2.3.1' api 'com.fasterxml.jackson.core:jackson-databind:2.13.2.2' @@ -16,25 +20,26 @@ dependencies { api 'commons-io:commons-io:1.2' api 'com.google.guava:guava:15.0' api 'com.beust:jcommander:1.82' - api 'com.miglayout:miglayout:3.7.4' + api 'com.miglayout:miglayout-swing:11.4.3' api 'com.google.code.findbugs:jsr305:3.0.2' implementation 'com.googlecode.plist:dd-plist:1.23' implementation 'net.java.dev.jna:jna-platform:5.11.0' -} - -processResources { - filesMatching('**/*.properties') { - filter { - it.replace('${project.version}', project.version) - } - } + implementation 'com.google.zxing:core:3.5.3' + implementation 'com.formdev:flatlaf:3.7.1' } shadowJar { - archiveClassifier.set("") + minimize { + exclude(dependency('com.formdev:flatlaf:.*')) + exclude(dependency('ca.weblite:webview:.*')) + } + exclude 'module-info.class' } -build { - dependsOn(shadowJar) +processResources { + def projectVersion = project.version.toString() + filesMatching('**/*.properties') { + filter { it.replace('${project.version}', projectVersion) } + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/Configuration.java b/launcher/src/main/java/com/skcraft/launcher/Configuration.java index 8365febc1..0f4ce3830 100644 --- a/launcher/src/main/java/com/skcraft/launcher/Configuration.java +++ b/launcher/src/main/java/com/skcraft/launcher/Configuration.java @@ -7,8 +7,6 @@ package com.skcraft.launcher; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.skcraft.launcher.launch.runtime.JavaRuntime; -import com.skcraft.launcher.launch.runtime.JavaRuntimeFinder; import lombok.Data; /** @@ -23,20 +21,24 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class Configuration { + public static final String THEME_LIGHT = "light"; + public static final String THEME_DARK = "dark"; + public static final String THEME_SYSTEM = "system"; + private boolean offlineEnabled = false; - private JavaRuntime javaRuntime; - private String jvmArgs; - private int minMemory = 1024; - private int maxMemory = 0; // Updated in Launcher - private int permGen = 256; private int windowWidth = 854; private int windowHeight = 480; + private boolean maximizeWindow = true; + private boolean showInstanceConsole = false; + private boolean darkTheme = false; + private String themeMode; private boolean proxyEnabled = false; private String proxyHost = "localhost"; private int proxyPort = 8080; private String proxyUsername; private String proxyPassword; private String gameKey; + private String lastInstance; private boolean serverEnabled = false; private String serverHost; private int serverPort = 25565; @@ -62,8 +64,26 @@ public void setWidowHeight(int height) { * Backwards compatibility for old configs with jvmPaths */ public void setJvmPath(String jvmPath) { - if (jvmPath != null) { - this.javaRuntime = JavaRuntimeFinder.getRuntimeFromPath(jvmPath); + // Global Java runtime settings are no longer used. + } + + public String getThemeMode() { + if (themeMode == null || themeMode.trim().isEmpty()) { + return darkTheme ? THEME_DARK : THEME_LIGHT; + } + + String normalized = themeMode.trim().toLowerCase(); + if (THEME_DARK.equals(normalized)) { + return THEME_DARK; + } + if (THEME_SYSTEM.equals(normalized)) { + return THEME_SYSTEM; } + return THEME_LIGHT; + } + + public void setThemeMode(String themeMode) { + this.themeMode = themeMode; + this.darkTheme = THEME_DARK.equals(getThemeMode()); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/Instance.java b/launcher/src/main/java/com/skcraft/launcher/Instance.java index dd63900fe..cf5235c8c 100644 --- a/launcher/src/main/java/com/skcraft/launcher/Instance.java +++ b/launcher/src/main/java/com/skcraft/launcher/Instance.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Strings; import com.google.common.io.Files; import com.skcraft.launcher.launch.JavaProcessBuilder; import com.skcraft.launcher.model.modpack.LaunchModifier; @@ -28,6 +29,8 @@ public class Instance implements Comparable { private String title; private String name; private String version; + private String newsUrl; + private String iconUrl; private boolean updatePending; private boolean installed; private Date lastAccessed; @@ -51,6 +54,26 @@ public String getTitle() { return title != null ? title : name; } + public String getNewsUrl() { + return normalizeOptionalUrl(newsUrl); + } + + public void setNewsUrl(String newsUrl) { + this.newsUrl = normalizeOptionalUrl(newsUrl); + } + + public String getIconUrl() { + return normalizeOptionalUrl(iconUrl); + } + + public void setIconUrl(String iconUrl) { + this.iconUrl = normalizeOptionalUrl(iconUrl); + } + + private static String normalizeOptionalUrl(String url) { + return Strings.emptyToNull(Strings.nullToEmpty(url).trim()); + } + /** * Update the given process builder with launch settings that are * specific to this instance. @@ -58,7 +81,7 @@ public String getTitle() { * @param builder the process builder */ public void modify(JavaProcessBuilder builder) { - if (launchModifier != null) { + if (launchModifier != null && settings.isModpackJvmArgsEnabled()) { launchModifier.modify(builder); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/InstanceList.java b/launcher/src/main/java/com/skcraft/launcher/InstanceList.java index 8134b99ad..c2ac0f995 100644 --- a/launcher/src/main/java/com/skcraft/launcher/InstanceList.java +++ b/launcher/src/main/java/com/skcraft/launcher/InstanceList.java @@ -11,6 +11,7 @@ import com.skcraft.launcher.model.modpack.ManifestInfo; import com.skcraft.launcher.model.modpack.PackageList; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.update.InstanceUpdateCheck; import com.skcraft.launcher.util.HttpRequest; import com.skcraft.launcher.util.SharedLocale; import lombok.Getter; @@ -150,21 +151,7 @@ public InstanceList call() throws Exception { for (Instance instance : local) { if (instance.getName().equalsIgnoreCase(manifest.getName())) { foundLocal = true; - - instance.setTitle(manifest.getTitle()); - instance.setPriority(manifest.getPriority()); - URL url = concat(packagesURL, manifest.getLocation()); - instance.setManifestURL(url); - - log.info("(" + instance.getName() + ").setManifestURL(" + url + ")"); - - // Check if an update is required - if (instance.getVersion() == null || !instance.getVersion().equals(manifest.getVersion())) { - instance.setUpdatePending(true); - instance.setVersion(manifest.getVersion()); - Persistence.commitAndForget(instance); - log.info(instance.getName() + " requires an update to " + manifest.getVersion()); - } + InstanceUpdateCheck.applyPackageInfo(instance, manifest, packagesURL); } } @@ -181,6 +168,8 @@ public InstanceList call() throws Exception { instance.setManifestURL(concat(packagesURL, manifest.getLocation())); instance.setUpdatePending(true); instance.setLocal(false); + instance.setNewsUrl(manifest.getNewsUrl()); + instance.setIconUrl(manifest.getIconUrl()); remote.add(instance); log.info("Available remote instance: '" + instance.getName() + diff --git a/launcher/src/main/java/com/skcraft/launcher/InstanceSettings.java b/launcher/src/main/java/com/skcraft/launcher/InstanceSettings.java index a320361bb..d3c6edd43 100644 --- a/launcher/src/main/java/com/skcraft/launcher/InstanceSettings.java +++ b/launcher/src/main/java/com/skcraft/launcher/InstanceSettings.java @@ -9,6 +9,12 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class InstanceSettings { private JavaRuntime runtime; + private String managedRuntimeComponent; private MemorySettings memorySettings; private String customJvmArgs; + private boolean modpackJvmArgsEnabled = true; + + public boolean usesAutomaticRuntime() { + return runtime == null && managedRuntimeComponent == null; + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/Launcher.java b/launcher/src/main/java/com/skcraft/launcher/Launcher.java index baef3da7e..96a8a1faa 100644 --- a/launcher/src/main/java/com/skcraft/launcher/Launcher.java +++ b/launcher/src/main/java/com/skcraft/launcher/Launcher.java @@ -13,17 +13,21 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.skcraft.launcher.auth.*; +import com.skcraft.launcher.browser.BrowserBootstrap; import com.skcraft.launcher.launch.LaunchSupervisor; import com.skcraft.launcher.model.minecraft.Library; import com.skcraft.launcher.model.minecraft.VersionManifest; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.swing.LauncherLookAndFeel; import com.skcraft.launcher.swing.SwingHelper; import com.skcraft.launcher.update.UpdateManager; +import com.skcraft.launcher.update.runtime.JavaRuntimeManager; import com.skcraft.launcher.util.Environment; import com.skcraft.launcher.util.HttpRequest; +import com.skcraft.launcher.util.LogBuffer; import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SimpleLogFormatter; -import com.sun.management.OperatingSystemMXBean; +import com.skcraft.launcher.util.WindowsAppIdentity; import lombok.Getter; import lombok.NonNull; import lombok.Setter; @@ -36,7 +40,6 @@ import java.io.FileFilter; import java.io.IOException; import java.io.UnsupportedEncodingException; -import java.lang.management.ManagementFactory; import java.net.URL; import java.net.URLEncoder; import java.util.Locale; @@ -63,6 +66,7 @@ public final class Launcher { @Getter private final Configuration config; @Getter private final AccountList accounts; @Getter private final AssetsRoot assets; + @Getter private final JavaRuntimeManager runtimeManager; @Getter private final LaunchSupervisor launchSupervisor = new LaunchSupervisor(this); @Getter private final UpdateManager updateManager = new UpdateManager(this); @Getter private final InstanceTasks instanceTasks = new InstanceTasks(this); @@ -93,10 +97,14 @@ public Launcher(@NonNull File baseDir, @NonNull File configDir) throws IOExcepti this.properties = LauncherUtils.loadProperties(Launcher.class, "launcher.properties", "com.skcraft.launcher.propertiesFile"); this.instances = new InstanceList(this); this.assets = new AssetsRoot(new File(baseDir, "assets")); + this.runtimeManager = new JavaRuntimeManager(new File(baseDir, "runtimes")); this.config = Persistence.load(new File(configDir, "config.json"), Configuration.class); this.accounts = Persistence.load(new File(configDir, "accounts.dat"), AccountList.class); - setDefaultConfig(); + // Ensure an active account is selected for older account lists. + if (this.accounts.migrateActiveAccount()) { + Persistence.commitAndForget(this.accounts); + } executor.submit(new Runnable() { @Override @@ -104,32 +112,6 @@ public void run() { cleanupExtractDir(); } }); - - updateManager.checkForUpdate(null); - } - - /** - * Updates any incorrect / unset configuration settings with defaults. - */ - public void setDefaultConfig() { - double configMax = config.getMaxMemory() / 1024.0; - double suggestedMax = 2; - double available = Double.MAX_VALUE; - - try { - OperatingSystemMXBean bean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); - available = bean.getTotalPhysicalMemorySize() / 1024.0 / 1024.0 / 1024.0; - if (available <= 6) { - suggestedMax = available * 0.48; - } else { - suggestedMax = 4; - } - } catch (Exception ignored) { - } - - if (config.getMaxMemory() <= 0 || configMax >= available - 1) { - config.setMaxMemory((int) (suggestedMax * 1024)); - } } /** @@ -173,7 +155,9 @@ public MicrosoftLoginService getMicrosoftLogin() { } public LoginService getLoginService(UserType type) { - if (type == UserType.MICROSOFT) { + if (type == UserType.OFFLINE) { + return new OfflineLoginService(); + } else if (type == UserType.MICROSOFT) { return getMicrosoftLogin(); } else { return getYggdrasil(); @@ -207,6 +191,21 @@ public File getInstallerDir() { return new File(getTemporaryDir(), "install"); } + /** + * Get the number of simultaneous file downloads. + * + * @return configured thread count + */ + public int getDownloadThreads() { + String value = getProperties().getProperty("downloadThreads", "32"); + try { + return Math.max(1, Math.min(128, Integer.parseInt(value))); + } catch (NumberFormatException e) { + log.log(Level.WARNING, "Invalid downloadThreads value: " + value); + return 32; + } + } + /** * Get the directory to store temporarily extracted files. * @@ -331,10 +330,28 @@ public File getJarPath(VersionManifest versionManifest) { * @return the news URL */ public URL getNewsURL() { + return getNewsURL(null); + } + + /** + * Get the news URL for an instance. + * + * @param instance the instance, or null if no instance is selected + * @return the news URL + */ + public URL getNewsURL(Instance instance) { try { + String version = URLEncoder.encode(getVersion(), "UTF-8"); + String modpackSlug = instance != null ? Strings.nullToEmpty(instance.getName()) : ""; + String newsUrl = getProperties().getProperty("newsUrl"); + if (instance != null && instance.getNewsUrl() != null) { + newsUrl = instance.getNewsUrl(); + } + return HttpRequest.url( - String.format(getProperties().getProperty("newsUrl"), - URLEncoder.encode(getVersion(), "UTF-8"))); + String.format(newsUrl, + version, + URLEncoder.encode(modpackSlug, "UTF-8"))); } catch (UnsupportedEncodingException e) { throw new RuntimeException(e); } @@ -408,6 +425,27 @@ public Window showLauncherWindow() { return window; } + /** + * Resolve the launcher base directory from command line arguments. + * + * @param args the arguments + * @return the base directory + * @throws ParameterException thrown on a bad parameter + */ + public static File resolveBaseDirFromArguments(String[] args) throws ParameterException { + LauncherArguments options = new LauncherArguments(); + new JCommander(options).parse(args); + return resolveBaseDirFromArguments(options); + } + + private static File resolveBaseDirFromArguments(LauncherArguments options) { + File dir = options.getDir(); + if (dir != null) { + return dir.getAbsoluteFile(); + } + return new File("").getAbsoluteFile(); + } + /** * Create a new launcher from arguments. * @@ -423,12 +461,10 @@ public static Launcher createFromArguments(String[] args) throws ParameterExcept Integer bsVersion = options.getBootstrapVersion(); log.info(bsVersion != null ? "Bootstrap version " + bsVersion + " detected" : "Not bootstrapped"); - File dir = options.getDir(); - if (dir != null) { - dir = dir.getAbsoluteFile(); + File dir = resolveBaseDirFromArguments(options); + if (options.getDir() != null) { log.info("Using given base directory " + dir.getAbsolutePath()); } else { - dir = new File("").getAbsoluteFile(); log.info("Using current directory " + dir.getAbsolutePath()); } @@ -440,6 +476,7 @@ public static Launcher createFromArguments(String[] args) throws ParameterExcept */ public static void setupLogger() { SimpleLogFormatter.configureGlobalLogger(); + LogBuffer.init(); } /** @@ -449,6 +486,11 @@ public static void setupLogger() { */ public static void main(final String[] args) { setupLogger(); + if (BrowserBootstrap.prepare(args)) { + return; + } + WindowsAppIdentity.applyIfPresent(); + BrowserBootstrap.configureSwing(); SwingUtilities.invokeLater(new Runnable() { @Override @@ -456,7 +498,7 @@ public void run() { try { Launcher launcher = createFromArguments(args); SwingHelper.setSwingProperties(tr("launcher.appTitle", launcher.getVersion())); - UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + LauncherLookAndFeel.install(launcher.getConfig().getThemeMode()); launcher.showLauncherWindow(); } catch (Throwable t) { log.log(Level.WARNING, "Load failure", t); @@ -465,7 +507,6 @@ public void run() { } } }); - } } diff --git a/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java b/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java index 2b340e0c4..78bbda3b6 100644 --- a/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java +++ b/launcher/src/main/java/com/skcraft/launcher/LauncherArguments.java @@ -22,8 +22,4 @@ public class LauncherArguments { @Parameter(names = "--bootstrap-version") private Integer bootstrapVersion; - - @Parameter(names = "--portable") - private boolean portable; - } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java index 969caebc1..782bd08c8 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/AccountList.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.google.common.base.Strings; import com.google.common.collect.Lists; import com.skcraft.launcher.dialog.component.ListListenerReducer; import com.skcraft.launcher.persistence.Scrambled; @@ -26,11 +27,13 @@ public class AccountList implements ListModel { private List accounts = Lists.newArrayList(); private String clientId = RandomStringUtils.randomAlphanumeric(24); + private String activeUuid; @JsonIgnore private final ListListenerReducer listeners = new ListListenerReducer(); public synchronized void add(SavedSession session) { accounts.add(session); + setActiveAccount(session); int index = accounts.size() - 1; listeners.intervalAdded(new ListDataEvent(this, ListDataEvent.INTERVAL_ADDED, index, index)); @@ -40,8 +43,17 @@ public synchronized void remove(SavedSession session) { int index = accounts.indexOf(session); if (index > -1) { + boolean wasActive = session.getUuid() != null && session.getUuid().equals(activeUuid); accounts.remove(index); listeners.intervalRemoved(new ListDataEvent(this, ListDataEvent.INTERVAL_REMOVED, index, index)); + + if (wasActive) { + if (accounts.isEmpty()) { + activeUuid = null; + } else { + setActiveAccount(accounts.get(0)); + } + } } } @@ -50,12 +62,92 @@ public synchronized void update(SavedSession newSavedSession) { if (index > -1) { accounts.set(index, newSavedSession); + setActiveAccount(newSavedSession); listeners.contentsChanged(new ListDataEvent(this, ListDataEvent.CONTENTS_CHANGED, index, index)); } else { this.add(newSavedSession); } } + @JsonIgnore + public synchronized SavedSession getActiveAccount() { + if (Strings.isNullOrEmpty(activeUuid)) { + return null; + } + + for (SavedSession session : accounts) { + if (activeUuid.equals(session.getUuid())) { + return session; + } + } + + return null; + } + + @JsonIgnore + public synchronized SavedSession getOfflineAccount(String username) { + for (SavedSession session : accounts) { + if (isOfflineAccount(session) && username.equalsIgnoreCase(session.getUsername())) { + return session; + } + } + + return null; + } + + /** + * Insert or replace an offline profile keyed by username (not UUID), so a + * stale/wrong UUID cannot leave a duplicate entry behind. + */ + public synchronized void putOfflineAccount(SavedSession offlineAccount) { + if (!isOfflineAccount(offlineAccount)) { + throw new IllegalArgumentException("Not an offline account"); + } + + String username = offlineAccount.getUsername(); + for (int i = 0; i < accounts.size(); i++) { + SavedSession existing = accounts.get(i); + if (isOfflineAccount(existing) && username.equalsIgnoreCase(existing.getUsername())) { + accounts.set(i, offlineAccount); + setActiveAccount(offlineAccount); + listeners.contentsChanged(new ListDataEvent(this, ListDataEvent.CONTENTS_CHANGED, i, i)); + return; + } + } + + add(offlineAccount); + } + + public static boolean isOfflineAccount(SavedSession session) { + return session != null && session.getType() == UserType.OFFLINE; + } + + public synchronized boolean migrateActiveAccount() { + if (getActiveAccount() != null) { + return false; + } + + if (!accounts.isEmpty()) { + setActiveAccount(accounts.get(0)); + return true; + } + + if (!Strings.isNullOrEmpty(activeUuid)) { + activeUuid = null; + return true; + } + + return false; + } + + public synchronized void setActiveAccount(SavedSession session) { + if (session == null || session.getUuid() == null) { + activeUuid = null; + } else { + activeUuid = session.getUuid(); + } + } + @Override public int getSize() { return accounts.size(); diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java index 0c461772c..5fffbfc4b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/MicrosoftLoginService.java @@ -27,6 +27,9 @@ @RequiredArgsConstructor public class MicrosoftLoginService implements LoginService { private static final URL MS_TOKEN_URL = url("https://login.live.com/oauth20_token.srf"); + private static final URL MS_DEVICE_CODE_URL = url("https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"); + private static final URL MS_DEVICE_TOKEN_URL = url("https://login.microsoftonline.com/consumers/oauth2/v2.0/token"); + private static final String MS_SCOPE = "XboxLive.signin XboxLive.offline_access"; private final String clientId; @@ -61,6 +64,34 @@ public Session login(Receiver oauthDone) throws IOException, InterruptedExceptio return session; } + public DeviceCodeDetails requestDeviceCodeDetails() + throws IOException, InterruptedException, AuthenticationException { + DeviceCodeResponse deviceCode = requestDeviceCode(); + if (deviceCode.getDeviceCode() == null || deviceCode.getDeviceCode().isEmpty()) { + throw new AuthenticationException("Failed to obtain Microsoft device code."); + } + + long expiresAt = System.currentTimeMillis() + (deviceCode.getExpiresIn() * 1000L); + return new DeviceCodeDetails( + deviceCode.getDeviceCode(), + deviceCode.getUserCode(), + deviceCode.getVerificationUri(), + deviceCode.getVerificationUriComplete(), + deviceCode.getMessage(), + expiresAt, + deviceCode.getInterval()); + } + + public Session loginWithDeviceCode(DeviceCodeDetails details, Receiver oauthDone) + throws IOException, InterruptedException, AuthenticationException { + TokenResponse response = pollDeviceCodeToken(details); + oauthDone.tell(); + + Profile session = performLogin(response.getAccessToken(), null); + session.setRefreshToken(response.getRefreshToken()); + return session; + } + @Override public Session restore(SavedSession savedSession) throws IOException, InterruptedException, AuthenticationException { @@ -93,6 +124,82 @@ private TokenResponse exchangeToken(Consumer formConsumer) .asJson(TokenResponse.class); } + private DeviceCodeResponse requestDeviceCode() throws IOException, InterruptedException, AuthenticationException { + HttpRequest.Form form = HttpRequest.Form.form(); + form.add("client_id", clientId); + form.add("scope", MS_SCOPE); + + return HttpRequest.post(MS_DEVICE_CODE_URL) + .bodyForm(form) + .execute() + .expectResponseCodeOr(200, (req) -> { + TokenError error = req.returnContent().asJson(TokenError.class); + return new AuthenticationException(error.errorDescription, true); + }) + .returnContent() + .asJson(DeviceCodeResponse.class); + } + + private TokenResponse pollDeviceCodeToken(DeviceCodeDetails details) + throws IOException, InterruptedException, AuthenticationException { + int intervalSeconds = Math.max(1, details.getInterval()); + long expiryTime = details.getExpiresAt(); + + while (System.currentTimeMillis() < expiryTime) { + DeviceCodeTokenResult result = exchangeDeviceCodeToken(details.getDeviceCode()); + if (result.success != null) { + return result.success; + } + + String errorCode = result.error != null ? result.error.error : null; + if ("authorization_pending".equals(errorCode)) { + Thread.sleep(intervalSeconds * 1000L); + continue; + } + if ("slow_down".equals(errorCode)) { + intervalSeconds++; + Thread.sleep(intervalSeconds * 1000L); + continue; + } + if ("expired_token".equals(errorCode) || "invalid_grant".equals(errorCode)) { + throw new AuthenticationException("Device code expired. Please retry sign in."); + } + if ("authorization_declined".equals(errorCode) || "access_denied".equals(errorCode)) { + throw new AuthenticationException("Microsoft sign in was cancelled."); + } + + String message = result.error != null && result.error.errorDescription != null + ? result.error.errorDescription : "Microsoft device sign in failed."; + throw new AuthenticationException(message, true); + } + + throw new AuthenticationException("Timed out waiting for Microsoft sign in."); + } + + private DeviceCodeTokenResult exchangeDeviceCodeToken(String deviceCode) + throws IOException, InterruptedException { + HttpRequest.Form form = HttpRequest.Form.form(); + form.add("client_id", clientId); + form.add("grant_type", "urn:ietf:params:oauth:grant-type:device_code"); + form.add("device_code", deviceCode); + form.add("scope", MS_SCOPE); + + HttpRequest request = HttpRequest.post(MS_DEVICE_TOKEN_URL) + .bodyForm(form) + .execute(); + + try { + if (request.getResponseCode() == 200) { + return new DeviceCodeTokenResult(request.returnContent().asJson(TokenResponse.class), null); + } + + TokenError error = request.returnContent().asJson(TokenError.class); + return new DeviceCodeTokenResult(null, error); + } finally { + request.close(); + } + } + private Profile performLogin(String microsoftToken, SavedSession previous) throws IOException, InterruptedException, AuthenticationException { XboxAuthorization xboxAuthorization = XboxTokenAuthorizer.authorizeWithXbox(microsoftToken); @@ -170,8 +277,48 @@ private static class TokenError { private String errorDescription; } + @Data + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @JsonIgnoreProperties(ignoreUnknown = true) + private static class DeviceCodeResponse { + private String deviceCode; + private String userCode; + private String verificationUri; + private String verificationUriComplete; + private Integer expiresIn; + private Integer interval; + private String message; + + public int getExpiresIn() { + return expiresIn != null ? expiresIn : 900; + } + + public int getInterval() { + return interval != null ? interval : 5; + } + } + + @RequiredArgsConstructor + private static class DeviceCodeTokenResult { + private final TokenResponse success; + private final TokenError error; + } + + @Data + @RequiredArgsConstructor + public static class DeviceCodeDetails { + private final String deviceCode; + private final String userCode; + private final String verificationUri; + private final String verificationUriComplete; + private final String message; + private final long expiresAt; + private final int interval; + } + @FunctionalInterface public interface Receiver { void tell(); } + } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineLoginService.java b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineLoginService.java new file mode 100644 index 000000000..5ef878841 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineLoginService.java @@ -0,0 +1,15 @@ +package com.skcraft.launcher.auth; + +import java.io.IOException; + +/** + * Restores saved offline accounts without contacting authentication services. + */ +public class OfflineLoginService implements LoginService { + + @Override + public Session restore(SavedSession savedSession) + throws IOException, InterruptedException, AuthenticationException { + return OfflineSession.fromSavedSession(savedSession); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java index cb2520aa8..0f7c8f470 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/OfflineSession.java @@ -9,6 +9,7 @@ import lombok.Getter; import lombok.NonNull; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.Map; import java.util.UUID; @@ -22,6 +23,7 @@ public class OfflineSession implements Session { @Getter private final String name; + private final String uuid; /** * Create a new offline session using the given player name. @@ -30,11 +32,25 @@ public class OfflineSession implements Session { */ public OfflineSession(@NonNull String name) { this.name = name; + this.uuid = getOfflineUuid(name); + } + + /** + * Restore an offline session from disk. + * Always re-derives the offline UUID from the username and never carries + * avatar/texture data, so the game cannot resolve a Mojang/custom skin. + */ + public static OfflineSession fromSavedSession(@NonNull SavedSession session) { + return new OfflineSession(session.getUsername()); + } + + public static String getOfflineUuid(@NonNull String name) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8)).toString(); } @Override public String getUuid() { - return (new UUID(0, 0)).toString(); + return uuid; } @Override @@ -54,7 +70,7 @@ public String getSessionToken() { @Override public UserType getUserType() { - return UserType.LEGACY; + return UserType.OFFLINE; } @Override @@ -67,4 +83,14 @@ public boolean isOnline() { return false; } + @Override + public SavedSession toSavedSession() { + SavedSession savedSession = new SavedSession(); + savedSession.setType(UserType.OFFLINE); + savedSession.setUsername(name); + savedSession.setUuid(uuid); + // No access/refresh tokens or avatar — offline play uses Steve/Alex only. + return savedSession; + } + } diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java b/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java index 9e12f1b35..f627d7b11 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/SavedSession.java @@ -4,6 +4,8 @@ import lombok.Data; import org.apache.commons.lang.builder.HashCodeBuilder; +import java.util.Objects; + /** * Represents a session saved to disk. */ @@ -25,7 +27,7 @@ public boolean equals(Object o) { SavedSession that = (SavedSession) o; - return getUuid().equals(that.getUuid()); + return Objects.equals(getUuid(), that.getUuid()); } @Override diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java index a697886be..2549f000b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/UserType.java @@ -21,7 +21,11 @@ public enum UserType { /** * Microsoft accounts login via OAuth. */ - MICROSOFT("msa"); + MICROSOFT("msa"), + /** + * Offline accounts are local profiles and launch with legacy user type arguments. + */ + OFFLINE("legacy"); private final String id; diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java index a4d79535c..23f8f52c8 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MicrosoftWebAuthorizer.java @@ -6,7 +6,6 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import java.awt.*; import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -20,13 +19,7 @@ public class MicrosoftWebAuthorizer { @Getter private String redirectUri; public OauthResult authorize() throws IOException, AuthenticationException, InterruptedException { - if (Desktop.isDesktopSupported()) { - // Interactive auth - return authorizeInteractive(); - } else { - // TODO Device code auth - return null; - } + return authorizeInteractive(); } private OauthResult authorizeInteractive() throws IOException, AuthenticationException, InterruptedException { diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java index a64a67d4c..434a3e28a 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/MinecraftServicesAuthorizer.java @@ -14,14 +14,18 @@ public class MinecraftServicesAuthorizer { private static final URL MC_SERVICES_LOGIN = url("https://api.minecraftservices.com/authentication/login_with_xbox"); private static final URL MC_SERVICES_PROFILE = url("https://api.minecraftservices.com/minecraft/profile"); - public static McAuthResponse authorizeWithMinecraft(XboxAuthorization auth) throws IOException, InterruptedException { + public static McAuthResponse authorizeWithMinecraft(XboxAuthorization auth) throws IOException, InterruptedException, AuthenticationException { McAuthRequest request = new McAuthRequest("XBL3.0 x=" + auth.getCombinedToken()); return HttpRequest.post(MC_SERVICES_LOGIN) .bodyJson(request) .header("Accept", "application/json") .execute() - .expectResponseCode(200) + .expectResponseCodeOr(200, req -> { + int responseCode = req.getResponseCode(); + return new AuthenticationException("Minecraft services login failed with HTTP " + responseCode, + SharedLocale.tr("login.minecraft.error", "HTTP " + responseCode)); + }) .returnContent() .asJson(McAuthResponse.class); } @@ -37,21 +41,32 @@ public static McProfileResponse getUserProfile(String authorization) .header("Authorization", authorization) .execute() .expectResponseCodeOr(200, req -> { + int responseCode = req.getResponseCode(); HttpRequest.BufferedResponse content = req.returnContent(); if (content.asBytes().length == 0) { + if (responseCode == 404) { + return new AuthenticationException("No Minecraft profile", + SharedLocale.tr("login.minecraftNotOwnedError")); + } return new AuthenticationException("Got empty response from Minecraft services", - SharedLocale.tr("login.minecraft.error", req.getResponseCode())); + SharedLocale.tr("login.minecraft.error", responseCode)); } McServicesError error = content.asJson(McServicesError.class); + String errorCode = error.getErrorCode(); - if (error.getError().equals("NOT_FOUND")) { + if (responseCode == 404 || "NOT_FOUND".equals(errorCode)) { return new AuthenticationException("No Minecraft profile", SharedLocale.tr("login.minecraftNotOwnedError")); } - return new AuthenticationException(error.getErrorMessage(), - SharedLocale.tr("login.minecraft.error", error.getErrorMessage())); + String detail = error.getErrorMessage(); + if (detail == null || detail.isEmpty()) { + detail = errorCode != null ? errorCode : ("HTTP " + responseCode); + } + + return new AuthenticationException(detail, + SharedLocale.tr("login.minecraft.error", detail)); }) .returnContent() .asJson(McProfileResponse.class); diff --git a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java index 034f05d50..9e8d321fa 100644 --- a/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java +++ b/launcher/src/main/java/com/skcraft/launcher/auth/microsoft/model/McServicesError.java @@ -1,5 +1,6 @@ package com.skcraft.launcher.auth.microsoft.model; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; @@ -7,5 +8,17 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class McServicesError { private String error; + private String errorType; private String errorMessage; + + @JsonIgnore + public String getErrorCode() { + if (error != null && !error.isEmpty()) { + return error; + } + if (errorType != null && !errorType.isEmpty()) { + return errorType; + } + return null; + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/BrowserBootstrap.java b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserBootstrap.java new file mode 100644 index 000000000..2deb6340e --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserBootstrap.java @@ -0,0 +1,136 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser; + +import com.beust.jcommander.ParameterException; +import com.skcraft.launcher.Launcher; +import lombok.extern.java.Log; + +import javax.swing.*; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.logging.Level; + +/** + * Prepares the portable launcher classpath and browser-specific Swing settings. + */ +@Log +public final class BrowserBootstrap { + + private static final String BOOTSTRAPPED_PROPERTY = "launcher.swt.bootstrapped"; + + private BrowserBootstrap() { + } + + /** + * Relaunch once with SWT when a standalone Windows/Linux launcher needs it. + * + * @return true when a child launcher was invoked and the caller should return + */ + public static boolean prepare(String[] args) { + if (!BrowserRuntime.requiresSwt() + || Boolean.getBoolean(BOOTSTRAPPED_PROPERTY) + || isSwtAvailable()) { + return false; + } + + try { + Path baseDir = Launcher.resolveBaseDirFromArguments(args).toPath(); + URL launcherLocation = resolveLauncherLocation(); + URL[] classpath = BrowserRuntime.resolveLauncherClasspath(launcherLocation, baseDir); + launchFromChildClassLoader(classpath, args); + return true; + } catch (ParameterException e) { + log.log(Level.WARNING, "Browser runtime bootstrap skipped because launcher arguments are invalid.", e); + return false; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.log(Level.WARNING, "Browser runtime bootstrap was interrupted.", e); + return false; + } catch (IOException e) { + log.log(Level.WARNING, "Browser runtime bootstrap could not prepare SWT.", e); + return false; + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Failed to launch with the browser runtime.", e); + } + } + + /** + * Apply host-specific Swing settings before constructing launcher windows. + */ + public static void configureSwing() { + if (BrowserRuntime.detectBackend() == BrowserRuntime.WKWEBVIEW) { + JPopupMenu.setDefaultLightWeightPopupEnabled(false); + ToolTipManager.sharedInstance().setLightWeightPopupEnabled(false); + } + } + + private static boolean isSwtAvailable() { + try { + Class.forName("org.eclipse.swt.widgets.Display", false, Launcher.class.getClassLoader()); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + private static URL resolveLauncherLocation() throws IOException { + URL location = Launcher.class.getProtectionDomain().getCodeSource().getLocation(); + if (location == null) { + throw new IOException("Unable to determine launcher code source."); + } + return location; + } + + private static void launchFromChildClassLoader(URL[] classpath, String[] args) + throws IOException, ReflectiveOperationException { + URLClassLoader child = new URLClassLoader(classpath, ClassLoader.getPlatformClassLoader()); + Thread thread = Thread.currentThread(); + ClassLoader previousContextLoader = thread.getContextClassLoader(); + String previousBootstrap = System.getProperty(BOOTSTRAPPED_PROPERTY); + boolean launched = false; + + try { + thread.setContextClassLoader(child); + Class launcherClass = Class.forName(Launcher.class.getName(), true, child); + Method mainMethod = launcherClass.getMethod("main", String[].class); + System.setProperty(BOOTSTRAPPED_PROPERTY, "true"); + mainMethod.invoke(null, new Object[] { args }); + launched = true; + } catch (InvocationTargetException e) { + rethrowLaunchFailure(e); + } finally { + if (!launched) { + restoreParentState(thread, previousContextLoader, previousBootstrap); + } + } + } + + private static void rethrowLaunchFailure(InvocationTargetException e) throws InvocationTargetException { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw e; + } + + private static void restoreParentState(Thread thread, ClassLoader previousContextLoader, String previousBootstrap) { + thread.setContextClassLoader(previousContextLoader); + if (previousBootstrap == null) { + System.clearProperty(BOOTSTRAPPED_PROPERTY); + } else { + System.setProperty(BOOTSTRAPPED_PROPERTY, previousBootstrap); + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/BrowserFallbackPanels.java b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserFallbackPanels.java new file mode 100644 index 000000000..cea6826fa --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserFallbackPanels.java @@ -0,0 +1,89 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser; + +import com.skcraft.launcher.swing.ActionListeners; +import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.Platform; +import com.skcraft.launcher.util.SharedLocale; +import net.miginfocom.swing.MigLayout; + +import javax.swing.*; +import java.awt.*; + +/** + * Shared Swing fallback content for unavailable or failed browser backends. + */ +public final class BrowserFallbackPanels { + + private static final String WEBVIEW2_URL = "https://developer.microsoft.com/microsoft-edge/webview2/"; + private static final String WEBKITGTK_URL = "https://webkitgtk.org/"; + + private BrowserFallbackPanels() { + } + + public static JPanel buildUnavailablePanel(Component parent) { + JPanel content = new JPanel(new MigLayout("insets 18, wrap 1, gap 0 8", "[center]", "[]6[]6[]12[]")); + + Icon icon = UIManager.getIcon("OptionPane.warningIcon"); + if (icon != null) { + content.add(new JLabel(icon)); + } + + JLabel title = new JLabel(SharedLocale.tr("news.panel.unavailable.title")); + title.setFont(title.getFont().deriveFont(Font.BOLD, title.getFont().getSize2D() + 1)); + content.add(title); + + Platform platform = Environment.detectPlatform(); + String detailsKey; + String linkLabel = null; + String linkUrl = null; + + switch (platform) { + case WINDOWS: + detailsKey = "news.panel.unavailable.windows.details"; + linkLabel = SharedLocale.tr("news.panel.unavailable.windows.download"); + linkUrl = WEBVIEW2_URL; + break; + case LINUX: + detailsKey = "news.panel.unavailable.linux.details"; + linkLabel = SharedLocale.tr("news.panel.unavailable.linux.download"); + linkUrl = WEBKITGTK_URL; + break; + case MAC_OS_X: + detailsKey = "news.panel.unavailable.mac.details"; + break; + default: + detailsKey = "news.panel.unavailable.generic.details"; + break; + } + + JLabel details = new JLabel("

" + + SwingHelper.htmlEscape(SharedLocale.tr(detailsKey)) + + "
"); + content.add(details); + + if (linkLabel != null && linkUrl != null) { + JButton downloadButton = new JButton(linkLabel); + downloadButton.addActionListener(ActionListeners.openURL(parent, linkUrl)); + content.add(downloadButton); + } + + SwingHelper.applyTableBackground(content); + return content; + } + + public static JPanel buildErrorPanel(String message) { + JPanel content = new JPanel(new MigLayout("insets 12, wrap 1", "[center]", "[]")); + content.add(new JLabel("
" + + SwingHelper.htmlEscape(message) + + "
")); + SwingHelper.applyTableBackground(content); + return content; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/BrowserRuntime.java b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserRuntime.java new file mode 100644 index 000000000..e2b863a3f --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserRuntime.java @@ -0,0 +1,50 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser; + +import com.skcraft.launcher.browser.swt.SwtRuntimeResolver; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.Path; +import java.util.Locale; + +/** + * Launcher policy for selecting and preparing its embedded browser backend. + */ +public enum BrowserRuntime { + WKWEBVIEW, + SWT; + + private static final BrowserRuntime BACKEND = detectHostBackend(); + + static BrowserRuntime detectBackend() { + return BACKEND; + } + + private static BrowserRuntime detectHostBackend() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (os.contains("mac") || os.contains("darwin")) { + return WKWEBVIEW; + } + return SWT; + } + + public static boolean requiresSwt() { + return detectBackend() == SWT; + } + + public static URL[] resolveLauncherClasspath(URL launcherLocation, Path baseDir) + throws IOException, InterruptedException { + if (!requiresSwt()) { + return new URL[] { launcherLocation }; + } + + Path swtJar = SwtRuntimeResolver.resolveSwtJar(baseDir); + return new URL[] { launcherLocation, swtJar.toUri().toURL() }; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/BrowserView.java b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserView.java new file mode 100644 index 000000000..16e57b4ef --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/BrowserView.java @@ -0,0 +1,29 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser; + +import javax.swing.border.Border; +import java.awt.Component; +import java.net.URL; + +/** + * Swing-facing contract implemented by each embedded browser backend. + */ +public interface BrowserView { + + Component getComponent(); + + void setBrowserBorder(Border border); + + void setDarkTheme(boolean darkTheme); + + void load(URL url); + + void loadHtml(String html); + + void disposeBrowser(); +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/WebpagePanel.java b/launcher/src/main/java/com/skcraft/launcher/browser/WebpagePanel.java new file mode 100644 index 000000000..5a2a776c1 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/WebpagePanel.java @@ -0,0 +1,234 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser; + +import com.formdev.flatlaf.FlatLaf; +import com.skcraft.launcher.browser.mac.MacWkWebpageView; +import com.skcraft.launcher.browser.swt.SwtWebpageView; +import com.skcraft.launcher.swing.SwingHelper; +import lombok.extern.java.Log; + +import javax.swing.*; +import javax.swing.border.Border; +import java.awt.*; +import java.net.URL; +import java.util.logging.Level; + +/** + * Swing facade for the platform-selected embedded browser. + */ +@Log +public final class WebpagePanel extends JPanel { + + private URL url; + private String html; + private boolean activated; + private boolean darkTheme; + private Border browserBorder = createDefaultBrowserBorder(); + private final boolean forceMissingBrowser; + private BrowserView browserView; + + public static WebpagePanel forURL(URL url) { + return new WebpagePanel(url); + } + + public static WebpagePanel forHTML(String html) { + return new WebpagePanel(html); + } + + public static WebpagePanel missingBrowser() { + return new WebpagePanel(true); + } + + private WebpagePanel(URL url) { + this.forceMissingBrowser = false; + this.url = url; + initialize(); + } + + private WebpagePanel(String html) { + this.forceMissingBrowser = false; + this.html = html; + initialize(); + } + + public WebpagePanel() { + this.forceMissingBrowser = false; + initialize(); + } + + private WebpagePanel(boolean forceMissingBrowser) { + this.forceMissingBrowser = forceMissingBrowser; + initialize(); + } + + private void initialize() { + setLayout(new BorderLayout()); + activateBrowser(); + } + + @Override + public void updateUI() { + super.updateUI(); + setDarkTheme(FlatLaf.isLafDark()); + } + + public Border getBrowserBorder() { + return browserBorder; + } + + public void setBrowserBorder(Border browserBorder) { + this.browserBorder = browserBorder; + if (browserView != null) { + browserView.setBrowserBorder(browserBorder); + } + } + + public void setDarkTheme(boolean darkTheme) { + this.darkTheme = darkTheme; + if (browserView != null) { + browserView.setDarkTheme(darkTheme); + } + } + + public void disposeBrowser() { + if (browserView != null) { + browserView.disposeBrowser(); + } + } + + private static Border createDefaultBrowserBorder() { + Border border = UIManager.getBorder("ScrollPane.border"); + return border != null ? border : BorderFactory.createEtchedBorder(); + } + + /** + * Browse to a URL. + * + * @param url the URL + * @param onlyChanged true to only browse if the last URL was different + * @return true if the URL changed + */ + public boolean browse(URL url, boolean onlyChanged) { + if (onlyChanged && this.url != null && this.url.equals(url)) { + return false; + } + + this.url = url; + this.html = null; + + if (!activated) { + activateBrowser(); + } else if (browserView != null) { + browserView.load(url); + } + + return true; + } + + private void activateBrowser() { + if (activated) { + return; + } + + activated = true; + removeAll(); + browserView = BrowserViewFactory.create(this, forceMissingBrowser); + add(browserView.getComponent(), BorderLayout.CENTER); + SwingHelper.removeOpaqueness(this); + browserView.setBrowserBorder(browserBorder); + browserView.setDarkTheme(darkTheme); + + revalidate(); + repaint(); + + if (html != null) { + browserView.loadHtml(html); + } else if (url != null) { + browserView.load(url); + } + } + + private static final class BrowserViewFactory { + + private BrowserViewFactory() { + } + + private static BrowserView create(Component parentComponent, boolean forceMissingBrowser) { + if (forceMissingBrowser) { + return new MissingBrowserView(parentComponent); + } + + try { + switch (BrowserRuntime.detectBackend()) { + case WKWEBVIEW: + return new MacWkWebpageView(parentComponent); + case SWT: + return new SwtWebpageView(parentComponent); + default: + throw new IllegalStateException( + "Unsupported browser backend: " + BrowserRuntime.detectBackend()); + } + } catch (LinkageError e) { + log.log(Level.WARNING, "Embedded browser is unavailable; news panel disabled", e); + return new MissingBrowserView(parentComponent); + } catch (RuntimeException e) { + log.log(Level.WARNING, "Embedded browser failed to initialize; news panel disabled", e); + return new MissingBrowserView(parentComponent); + } + } + } + + private static final class MissingBrowserView implements BrowserView { + + private final Component parentComponent; + private final JPanel panel = new JPanel(new GridBagLayout()); + + private MissingBrowserView(Component parentComponent) { + this.parentComponent = parentComponent; + } + + @Override + public Component getComponent() { + return panel; + } + + @Override + public void setBrowserBorder(Border border) { + panel.setBorder(border); + } + + @Override + public void setDarkTheme(boolean darkTheme) { + SwingHelper.applyTableBackground(panel); + } + + @Override + public void load(URL url) { + showFallback(); + } + + @Override + public void loadHtml(String html) { + showFallback(); + } + + @Override + public void disposeBrowser() { + } + + private void showFallback() { + panel.removeAll(); + JPanel content = BrowserFallbackPanels.buildUnavailablePanel(parentComponent); + SwingHelper.applyTableBackground(content); + panel.add(content); + SwingHelper.applyTableBackground(panel); + panel.revalidate(); + panel.repaint(); + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/mac/MacWkWebpageView.java b/launcher/src/main/java/com/skcraft/launcher/browser/mac/MacWkWebpageView.java new file mode 100644 index 000000000..57d002914 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/mac/MacWkWebpageView.java @@ -0,0 +1,262 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser.mac; + +import ca.weblite.webview.WebView; +import ca.weblite.webview.swing.WebViewComponent; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.skcraft.launcher.browser.BrowserFallbackPanels; +import com.skcraft.launcher.browser.BrowserView; +import com.skcraft.launcher.swing.SwingHelper; +import lombok.extern.java.Log; + +import javax.swing.*; +import javax.swing.border.Border; +import java.awt.*; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.logging.Level; + +/** + * macOS embedded browser backed by the system WKWebView. + */ +@Log +public final class MacWkWebpageView extends JPanel implements BrowserView { + + private static final ObjectMapper JSON = new ObjectMapper(); + private static final String OPEN_EXTERNAL_CALLBACK = "launcherOpenExternal"; + private static final int ATTACH_CHECK_ATTEMPTS = 20; + private static final int ATTACH_CHECK_DELAY_MS = 500; + private static final String EXTERNAL_LINK_SCRIPT = + "(function() {" + + "document.addEventListener('click', function(event) {" + + "var link = event.target;" + + "while (link && link.tagName !== 'A') { link = link.parentElement; }" + + "if (!link || !link.href) { return; }" + + "var target = new URL(link.href, document.baseURI);" + + "if (target.protocol !== 'http:' && target.protocol !== 'https:') { return; }" + + "var current = window.location.href.split('#')[0];" + + "if (target.href.split('#')[0] === current) { return; }" + + "event.preventDefault();" + + "window." + OPEN_EXTERNAL_CALLBACK + "(target.href);" + + "}, true);" + + "})();"; + + private final Component parentComponent; + + private WebViewComponent webView; + private Timer attachCheckTimer; + private URL url; + private String html; + private boolean darkTheme; + private boolean browserUnavailable; + private boolean browserAttached; + + public MacWkWebpageView(Component parentComponent) { + this.parentComponent = parentComponent; + setLayout(new BorderLayout()); + applyBackground(); + } + + @Override + public Component getComponent() { + return this; + } + + @Override + public void setBrowserBorder(Border border) { + setBorder(border); + } + + @Override + public void setDarkTheme(boolean darkTheme) { + this.darkTheme = darkTheme; + applyBackground(); + + WebViewComponent current = webView; + if (current != null) { + current.eval(colorSchemeScript(darkTheme)); + } + } + + @Override + public void load(URL url) { + this.url = url; + this.html = null; + + WebViewComponent current = webView; + if (current != null && browserAttached && url != null) { + current.setUrl(url.toExternalForm()); + } + } + + @Override + public void loadHtml(String html) { + this.url = null; + this.html = html; + + WebViewComponent current = webView; + if (current != null && browserAttached && html != null) { + current.setUrl(htmlDataUrl(html)); + } + } + + @Override + public void addNotify() { + super.addNotify(); + if (webView == null && !browserUnavailable) { + createBrowser(); + } + } + + @Override + public void removeNotify() { + disposeBrowser(); + super.removeNotify(); + } + + @Override + public void disposeBrowser() { + if (attachCheckTimer != null) { + attachCheckTimer.stop(); + attachCheckTimer = null; + } + + WebViewComponent current = webView; + webView = null; + browserAttached = false; + if (current != null) { + current.dispose(); + remove(current); + } + } + + private void createBrowser() { + WebViewComponent component = null; + try { + component = WebViewComponent.create(WebViewComponent.Mode.HEAVYWEIGHT); + component.addOnBeforeLoad(EXTERNAL_LINK_SCRIPT); + component.addOnBeforeLoad(colorSchemeScript(darkTheme)); + component.addJavascriptCallback(OPEN_EXTERNAL_CALLBACK, this::openExternalUrl); + + component.setUrl("about:blank"); + + webView = component; + add(component, BorderLayout.CENTER); + scheduleAttachCheck(component, ATTACH_CHECK_ATTEMPTS); + revalidate(); + repaint(); + } catch (LinkageError | RuntimeException e) { + webView = null; + if (component != null) { + try { + component.dispose(); + } catch (LinkageError | RuntimeException ignored) { + } + remove(component); + } + log.log(Level.WARNING, "WKWebView failed to initialize", e); + showBrowserUnavailable(); + } + } + + private void scheduleAttachCheck(final WebViewComponent component, final int attemptsRemaining) { + Timer timer = new Timer(ATTACH_CHECK_DELAY_MS, e -> { + attachCheckTimer = null; + if (component != webView) { + return; + } + + try { + component.evalAsync("return true;").whenComplete((result, failure) -> { + if (component != webView) { + return; + } + if (failure == null) { + browserAttached = true; + loadPendingContent(component); + return; + } + if (attemptsRemaining > 1) { + scheduleAttachCheck(component, attemptsRemaining - 1); + } else { + log.log(Level.WARNING, "WKWebView did not attach within 10 seconds", failure); + disposeBrowser(); + showBrowserUnavailable(); + } + }); + } catch (RuntimeException e1) { + if (attemptsRemaining > 1) { + scheduleAttachCheck(component, attemptsRemaining - 1); + } else { + log.log(Level.WARNING, "WKWebView failed to attach", e1); + disposeBrowser(); + showBrowserUnavailable(); + } + } + }); + timer.setRepeats(false); + attachCheckTimer = timer; + timer.start(); + } + + private void loadPendingContent(WebViewComponent component) { + if (component != webView) { + return; + } + if (html != null) { + component.setUrl(htmlDataUrl(html)); + } else if (url != null) { + component.setUrl(url.toExternalForm()); + } + } + + private void openExternalUrl(String argumentsJson) { + try { + String[] arguments = JSON.readValue(argumentsJson, String[].class); + if (arguments.length == 0 || arguments[0] == null || arguments[0].trim().isEmpty()) { + return; + } + + URL externalUrl = new URL(arguments[0]); + SwingUtilities.invokeLater(() -> SwingHelper.openURL(externalUrl, parentComponent)); + } catch (IOException e) { + log.log(Level.FINE, "Ignoring malformed WKWebView navigation callback", e); + } + } + + private void showBrowserUnavailable() { + browserUnavailable = true; + removeAll(); + add(BrowserFallbackPanels.buildUnavailablePanel(parentComponent), BorderLayout.CENTER); + applyBackground(); + revalidate(); + repaint(); + } + + private void applyBackground() { + SwingHelper.applyTableBackground(this); + } + + private static String htmlDataUrl(String html) { + String encoded = Base64.getEncoder().encodeToString(html.getBytes(StandardCharsets.UTF_8)); + return "data:text/html;charset=utf-8;base64," + encoded; + } + + private static String colorSchemeScript(boolean darkTheme) { + String scheme = darkTheme ? "dark" : "light"; + return "(function() {" + + "var apply = function() {" + + "if (document.documentElement) { document.documentElement.style.colorScheme = '" + scheme + "'; }" + + "};" + + "apply();" + + "document.addEventListener('DOMContentLoaded', apply, {once:true});" + + "})();"; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtRuntimeResolver.java b/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtRuntimeResolver.java new file mode 100644 index 000000000..0269a84b2 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtRuntimeResolver.java @@ -0,0 +1,220 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser.swt; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Locale; +import java.util.Properties; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Resolves and verifies the Windows/Linux SWT runtime beside launcher data. + */ +public final class SwtRuntimeResolver { + + private static final Logger log = Logger.getLogger(SwtRuntimeResolver.class.getName()); + + private static final String MAVEN_BASE_URL = "https://repo.maven.apache.org/maven2"; + private static final String SWT_GROUP_PATH = "org/eclipse/platform"; + private static final String VERSION_PROPERTY = "version"; + private static final String PATH_PROPERTY = "path"; + private static final String SWT_CHECKSUMS_RESOURCE = "META-INF/skcraft/swt-checksums.properties"; + private static final int CONNECT_TIMEOUT_MS = 30_000; + private static final int READ_TIMEOUT_MS = 10 * 60 * 1000; + + private static Properties swtChecksums; + + private SwtRuntimeResolver() { + } + + public static Path resolveSwtJar(Path baseDir) throws IOException, InterruptedException { + Properties checksums = loadSwtChecksums(); + String swtVersion = checksums.getProperty(VERSION_PROPERTY); + String swtRuntimeRoot = checksums.getProperty(PATH_PROPERTY); + String swtArtifact = resolveHostSwtArtifact(); + Path swtJar = swtPath(baseDir, swtArtifact, swtVersion, swtRuntimeRoot); + String expectedFileName = swtFileName(swtArtifact, swtVersion); + String expectedSha1 = expectedSwtSha1(checksums, swtArtifact); + + if (isValidSwtJar(swtJar, expectedFileName, expectedSha1)) { + log.info("Using local SWT runtime " + swtVersion); + return swtJar; + } + + log.info("SWT runtime " + swtVersion + " is missing or invalid."); + return downloadHostSwt(swtJar, swtArtifact, swtVersion, expectedSha1); + } + + public static String resolveHostSwtArtifact() throws IOException { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + String arch = System.getProperty("os.arch", "").toLowerCase(Locale.ROOT); + String swtArch = arch.contains("aarch64") || arch.contains("arm64") ? "aarch64" : "x86_64"; + + if (os.contains("win")) { + return "org.eclipse.swt.win32.win32." + swtArch; + } + if (os.contains("linux")) { + return "org.eclipse.swt.gtk.linux." + swtArch; + } + throw new IOException("Unsupported platform for SWT loader: os=" + os + ", arch=" + arch); + } + + private static Path swtPath(Path baseDir, String swtArtifact, String swtVersion, String swtRuntimeRoot) { + return baseDir.resolve(swtRuntimeRoot) + .resolve(swtVersion) + .resolve(swtArtifact) + .resolve(swtFileName(swtArtifact, swtVersion)); + } + + private static boolean isValidSwtJar(Path path, String expectedFileName, String expectedSha1) { + try { + return Files.isRegularFile(path) + && expectedFileName.equals(path.getFileName().toString()) + && expectedSha1.equalsIgnoreCase(sha1Hex(path)); + } catch (IOException e) { + return false; + } + } + + private static Path downloadHostSwt(Path targetPath, String swtArtifact, String swtVersion, String expectedSha1) + throws IOException, InterruptedException { + Files.createDirectories(targetPath.getParent()); + URL jarUrl = new URL(mavenArtifactUrl(swtArtifact, swtVersion)); + log.info("Downloading SWT runtime " + swtVersion + " from Maven Central"); + + Path tempPath = Files.createTempFile(targetPath.getParent(), swtArtifact + "-", ".tmp"); + try { + downloadTo(jarUrl, tempPath); + if (!expectedSha1.equalsIgnoreCase(sha1Hex(tempPath))) { + throw new IOException("SWT checksum mismatch for " + jarUrl); + } + moveAtomically(tempPath, targetPath); + log.info("Installed SWT runtime " + swtVersion); + return targetPath; + } finally { + Files.deleteIfExists(tempPath); + } + } + + private static void downloadTo(URL url, Path target) throws IOException, InterruptedException { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(true); + + try { + int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + throw new IOException("Unexpected HTTP " + code + " for " + url); + } + + try (InputStream in = conn.getInputStream(); + OutputStream out = Files.newOutputStream(target)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) != -1) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + out.write(buffer, 0, read); + } + } + } finally { + conn.disconnect(); + } + } + + private static synchronized Properties loadSwtChecksums() throws IOException { + if (swtChecksums == null) { + Properties properties = new Properties(); + try (InputStream input = SwtRuntimeResolver.class.getClassLoader() + .getResourceAsStream(SWT_CHECKSUMS_RESOURCE)) { + if (input == null) { + throw new IOException("Missing SWT checksum resource: " + SWT_CHECKSUMS_RESOURCE); + } + properties.load(input); + } + + String version = properties.getProperty(VERSION_PROPERTY); + if (version == null || version.isBlank()) { + throw new IOException("Missing SWT version in " + SWT_CHECKSUMS_RESOURCE); + } + + String runtimeRoot = properties.getProperty(PATH_PROPERTY); + if (runtimeRoot == null || runtimeRoot.isBlank()) { + throw new IOException("Missing SWT runtime path in " + SWT_CHECKSUMS_RESOURCE); + } + + Path path = Path.of(runtimeRoot); + if (path.isAbsolute() || path.normalize().startsWith("..")) { + throw new IOException("Invalid SWT runtime path in " + SWT_CHECKSUMS_RESOURCE + ": " + runtimeRoot); + } + + properties.setProperty(PATH_PROPERTY, path.normalize().toString()); + swtChecksums = properties; + } + return swtChecksums; + } + + private static String expectedSwtSha1(Properties checksums, String swtArtifact) throws IOException { + String checksum = checksums.getProperty(swtArtifact); + if (checksum == null || checksum.isBlank()) { + throw new IOException("Missing SWT checksum for " + swtArtifact); + } + return checksum; + } + + private static String sha1Hex(Path path) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-1"); + try (InputStream in = Files.newInputStream(path); + DigestInputStream digestIn = new DigestInputStream(in, digest)) { + byte[] buffer = new byte[8192]; + while (digestIn.read(buffer) != -1) { + } + } + byte[] hash = digest.digest(); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-1 not available", e); + } + } + + private static void moveAtomically(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ignored) { + log.log(Level.FINE, "Atomic move failed; falling back to replace", ignored); + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static String mavenArtifactUrl(String swtArtifact, String swtVersion) { + return MAVEN_BASE_URL + "/" + SWT_GROUP_PATH + "/" + swtArtifact + "/" + swtVersion + "/" + + swtFileName(swtArtifact, swtVersion); + } + + private static String swtFileName(String swtArtifact, String swtVersion) { + return swtArtifact + "-" + swtVersion + ".jar"; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtWebpageView.java b/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtWebpageView.java new file mode 100644 index 000000000..0bb416958 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/browser/swt/SwtWebpageView.java @@ -0,0 +1,741 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.browser.swt; + +import com.skcraft.launcher.browser.BrowserFallbackPanels; +import com.skcraft.launcher.browser.BrowserView; +import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.Platform; +import com.skcraft.launcher.util.SharedLocale; +import lombok.extern.java.Log; +import net.miginfocom.swing.MigLayout; +import org.eclipse.swt.SWT; +import org.eclipse.swt.SWTError; +import org.eclipse.swt.awt.SWT_AWT; +import org.eclipse.swt.browser.*; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; + +import javax.swing.*; +import javax.swing.border.Border; +import java.awt.*; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.awt.geom.AffineTransform; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; + +/** + * Windows/Linux embedded browser backed by SWT Browser. + */ +@Log +public final class SwtWebpageView extends JPanel implements BrowserView { + + private static final String LOADING_CARD = "loading"; + private static final String BROWSER_CARD = "browser"; + private static final String ERROR_CARD = "error"; + + private final Component parentComponent; + private final CardLayout cardLayout = new CardLayout(); + private final JPanel cardPanel = new JPanel(cardLayout); + private final JPanel loadingPanel = new JPanel(); + private final JPanel browserPanel = new JPanel(new BorderLayout()); + private final Canvas browserCanvas = new Canvas() { + @Override + public Dimension getMinimumSize() { + return new Dimension(0, 0); + } + + @Override + public Dimension getPreferredSize() { + return new Dimension(0, 0); + } + }; + private final JPanel errorPanel = new JPanel(new GridBagLayout()); + + private volatile SwtBrowserSession session; + private volatile boolean loadingProgrammatically; + private volatile boolean browserUnavailable; + + private URL url; + private String html; + private boolean darkTheme; + private Border browserBorder; + + public SwtWebpageView(Component parentComponent) { + this.parentComponent = parentComponent; + setLayout(new BorderLayout()); + + loadingPanel.setLayout(new MigLayout("fill, align center center", "[100!]", "[]")); + JProgressBar progressBar = new JProgressBar(); + progressBar.setIndeterminate(true); + loadingPanel.add(progressBar, "w 100!, h 16!"); + + browserPanel.add(browserCanvas, BorderLayout.CENTER); + browserCanvas.addComponentListener(new ComponentAdapter() { + @Override + public void componentResized(ComponentEvent e) { + layoutSwtShell(); + } + + @Override + public void componentShown(ComponentEvent e) { + layoutSwtShell(); + } + }); + + cardPanel.add(loadingPanel, LOADING_CARD); + cardPanel.add(browserPanel, BROWSER_CARD); + cardPanel.add(errorPanel, ERROR_CARD); + cardLayout.show(cardPanel, LOADING_CARD); + + add(cardPanel, BorderLayout.CENTER); + applyPanelBackgrounds(); + } + + private void applyPanelBackgrounds() { + SwingHelper.applyTableBackground(this, cardPanel, loadingPanel, errorPanel); + } + + @Override + public Component getComponent() { + return this; + } + + @Override + public void setBrowserBorder(Border border) { + browserBorder = border; + loadingPanel.setBorder(border); + browserPanel.setBorder(border); + errorPanel.setBorder(border); + } + + @Override + public void setDarkTheme(boolean darkTheme) { + this.darkTheme = darkTheme; + applyPanelBackgrounds(); + runOnSwtThread(this::applyPreferredColorScheme); + } + + @Override + public void load(URL url) { + if (browserUnavailable) { + return; + } + + this.url = url; + this.html = null; + showLoading(); + runOnSwtThread(this::loadPendingContent); + } + + @Override + public void loadHtml(String html) { + if (browserUnavailable) { + return; + } + + this.url = null; + this.html = html; + showLoading(); + runOnSwtThread(this::loadPendingContent); + } + + @Override + public void addNotify() { + super.addNotify(); + if (session == null) { + showLoading(); + SwtBrowserSession newSession = new SwtBrowserSession( + browserCanvas, SWT.NONE, darkTheme, (browser, shell) -> { + log.info("SWT browser backend: " + browser.getBrowserType()); + installBrowserListeners(browser, shell); + loadPendingContent(); + }); + session = newSession; + try { + newSession.start(); + layoutSwtShell(); + } catch (LinkageError | SWTError e) { + session = null; + log.log(Level.WARNING, "Embedded browser is unavailable", e); + showBrowserUnavailable(); + } catch (RuntimeException e) { + session = null; + log.log(Level.WARNING, "Embedded browser failed to initialize", e); + showBrowserUnavailable(); + } + } + } + + @Override + public void removeNotify() { + disposeBrowser(); + super.removeNotify(); + } + + @Override + public void disposeBrowser() { + SwtBrowserSession currentSession = session; + session = null; + loadingProgrammatically = false; + if (currentSession != null) { + currentSession.stop(); + } + } + + @Override + public Dimension getMinimumSize() { + return new Dimension(0, 0); + } + + @Override + public void doLayout() { + super.doLayout(); + layoutSwtShell(); + } + + private void layoutSwtShell() { + SwtBrowserSession currentSession = session; + if (currentSession == null) { + return; + } + + Dimension size = getScaledCanvasSize(); + currentSession.layout(size.width, size.height); + } + + private Dimension getScaledCanvasSize() { + Dimension size = browserCanvas.getSize(); + GraphicsConfiguration configuration = browserCanvas.getGraphicsConfiguration(); + if (configuration == null) { + return size; + } + + AffineTransform transform = configuration.getDefaultTransform(); + int width = (int) Math.ceil(size.width * transform.getScaleX()); + int height = (int) Math.ceil(size.height * transform.getScaleY()); + return new Dimension(width, height); + } + + private void installBrowserListeners(final Browser browser, final Shell shell) { + browser.addProgressListener(new ProgressAdapter() { + @Override + public void changed(ProgressEvent event) { + if (loadingProgrammatically && event.current > 0) { + revealBrowserIfLoading(); + } + } + + @Override + public void completed(ProgressEvent event) { + finishLoading(); + } + }); + + browser.addLocationListener(new LocationAdapter() { + @Override + public void changing(LocationEvent event) { + if (event.top && shouldOpenExternally(event.location)) { + event.doit = false; + openExternalUrl(event.location); + } + } + + @Override + public void changed(LocationEvent event) { + if (loadingProgrammatically && event.top) { + revealBrowserIfLoading(); + } + } + }); + + browser.addOpenWindowListener(event -> { + Shell popupShell = new Shell(shell, SWT.NO_TRIM); + Browser popupBrowser = new Browser(popupShell, SWT.NONE); + event.browser = popupBrowser; + popupBrowser.addLocationListener(new LocationAdapter() { + @Override + public void changing(LocationEvent locationEvent) { + locationEvent.doit = false; + openExternalUrl(locationEvent.location); + if (!popupShell.isDisposed()) { + popupShell.dispose(); + } + } + }); + popupBrowser.addCloseWindowListener(closeEvent -> { + if (!popupShell.isDisposed()) { + popupShell.dispose(); + } + }); + }); + } + + private void loadPendingContent() { + Browser browser = currentBrowser(); + if (browser == null) { + return; + } + + if (html != null) { + loadingProgrammatically = true; + showLoading(); + if (browser.setText(html)) { + finishLoading(); + } else { + loadingProgrammatically = false; + showError(SharedLocale.tr("news.panel.loadFailed")); + } + return; + } + + if (url != null) { + loadingProgrammatically = true; + showLoading(); + if (!browser.setUrl(url.toExternalForm())) { + loadingProgrammatically = false; + showError(SharedLocale.tr("news.panel.loadFailed")); + } + return; + } + + showBrowser(); + } + + private Browser currentBrowser() { + SwtBrowserSession currentSession = session; + if (currentSession == null) { + return null; + } + + Browser browser = currentSession.browser(); + return browser == null || browser.isDisposed() ? null : browser; + } + + private void revealBrowserIfLoading() { + if (loadingProgrammatically) { + showBrowser(); + } + } + + private void finishLoading() { + if (!loadingProgrammatically && url == null && html == null) { + return; + } + + loadingProgrammatically = false; + showBrowser(); + applyPreferredColorScheme(); + } + + private boolean shouldOpenExternally(String value) { + if (loadingProgrammatically || url == null || value == null || value.trim().isEmpty()) { + return false; + } + + try { + URI current = stripFragment(url.toURI()); + URI target = stripFragment(new URI(value)); + return target.isAbsolute() && !current.equals(target); + } catch (URISyntaxException e) { + return false; + } + } + + private static URI stripFragment(URI uri) throws URISyntaxException { + return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(), uri.getQuery(), null); + } + + private void applyPreferredColorScheme() { + SwtBrowserColorScheme.apply(currentBrowser(), darkTheme); + } + + private void runOnSwtThread(Runnable action) { + SwtBrowserSession currentSession = session; + if (currentSession != null) { + currentSession.async(action); + } + } + + private void showLoading() { + showCard(LOADING_CARD); + } + + private void showBrowser() { + showCard(BROWSER_CARD); + } + + private void showBrowserUnavailable() { + browserUnavailable = true; + loadingProgrammatically = false; + SwingUtilities.invokeLater(() -> { + errorPanel.removeAll(); + errorPanel.add(BrowserFallbackPanels.buildUnavailablePanel(parentComponent)); + applyPanelBackgrounds(); + cardLayout.show(cardPanel, ERROR_CARD); + errorPanel.revalidate(); + errorPanel.repaint(); + cardPanel.revalidate(); + cardPanel.repaint(); + }); + } + + private void showError(String message) { + SwingUtilities.invokeLater(() -> { + errorPanel.removeAll(); + errorPanel.add(BrowserFallbackPanels.buildErrorPanel(message)); + applyPanelBackgrounds(); + errorPanel.setVisible(true); + cardLayout.show(cardPanel, ERROR_CARD); + errorPanel.revalidate(); + errorPanel.repaint(); + }); + } + + private void showCard(String cardName) { + if (browserUnavailable && !ERROR_CARD.equals(cardName)) { + return; + } + + SwingUtilities.invokeLater(() -> { + if (browserUnavailable && !ERROR_CARD.equals(cardName)) { + return; + } + cardLayout.show(cardPanel, cardName); + cardPanel.revalidate(); + cardPanel.repaint(); + }); + } + + private void openExternalUrl(String value) { + if (value == null || value.trim().isEmpty()) { + return; + } + + try { + URL popupUrl = new URL(value); + SwingUtilities.invokeLater(() -> SwingHelper.openURL(popupUrl, parentComponent)); + } catch (MalformedURLException ignored) { + } + } + + private static final class SwtBrowserSession { + + private enum State { + IDLE, RUNNING, STOPPING, STOPPED + } + + @FunctionalInterface + private interface InitCallback { + void onInitialized(Browser browser, Shell shell); + } + + private final Canvas canvas; + private final int browserStyle; + private final boolean initialDarkTheme; + private final InitCallback initCallback; + private final AtomicBoolean widgetsDisposed = new AtomicBoolean(); + + private volatile State state = State.IDLE; + private volatile Display display; + private volatile Shell shell; + private volatile Browser browser; + private volatile Thread swtThread; + + private SwtBrowserSession( + Canvas canvas, + int browserStyle, + boolean initialDarkTheme, + InitCallback initCallback) { + this.canvas = canvas; + this.browserStyle = browserStyle; + this.initialDarkTheme = initialDarkTheme; + this.initCallback = initCallback; + } + + private Browser browser() { + return state == State.RUNNING ? browser : null; + } + + private synchronized void start() { + if (state != State.IDLE) { + throw new IllegalStateException("Session already started (state: " + state + ")"); + } + + final CountDownLatch ready = new CountDownLatch(1); + final Throwable[] initFailure = new Throwable[1]; + + Thread thread = new Thread(() -> { + try { + Display.setAppName(SharedLocale.tr("launcher.appTitle")); + display = new Display(); + swtThread = Thread.currentThread(); + SwtBrowserColorScheme.applyBeforeBrowserCreate(display, initialDarkTheme); + shell = SWT_AWT.new_Shell(display, canvas); + shell.setLayout(new FillLayout()); + browser = new Browser(shell, browserStyle); + shell.open(); + state = State.RUNNING; + initCallback.onInitialized(browser, shell); + ready.countDown(); + + while (shell != null && !shell.isDisposed()) { + if (!display.readAndDispatch()) { + display.sleep(); + } + } + } catch (Throwable t) { + initFailure[0] = t; + ready.countDown(); + } finally { + disposeOnce(); + } + }, "SWT Browser"); + swtThread = thread; + thread.setDaemon(true); + thread.start(); + + try { + if (!ready.await(30, TimeUnit.SECONDS)) { + state = State.STOPPED; + disposeOnce(); + throw new RuntimeException("Timed out waiting for SWT browser to initialize"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while initializing SWT browser", e); + } + + if (initFailure[0] != null) { + state = State.STOPPED; + if (initFailure[0] instanceof LinkageError) { + throw (LinkageError) initFailure[0]; + } + if (initFailure[0] instanceof SWTError) { + throw (SWTError) initFailure[0]; + } + if (initFailure[0] instanceof RuntimeException) { + throw (RuntimeException) initFailure[0]; + } + throw new RuntimeException("SWT browser failed to initialize", initFailure[0]); + } + } + + private synchronized void stop() { + if (state == State.STOPPING || state == State.STOPPED) { + return; + } + state = State.STOPPING; + + Display currentDisplay = display; + Thread currentThread = swtThread; + if (currentDisplay != null && !currentDisplay.isDisposed()) { + try { + currentDisplay.wake(); + if (Thread.currentThread() == currentThread) { + disposeOnce(); + } else { + currentDisplay.syncExec(this::disposeOnce); + } + } catch (RuntimeException ignored) { + } + } + + joinSwtThread(currentThread); + state = State.STOPPED; + } + + private void async(Runnable task) { + Display currentDisplay = display; + if (currentDisplay == null || currentDisplay.isDisposed()) { + return; + } + + currentDisplay.asyncExec(() -> { + if (state == State.RUNNING) { + task.run(); + } + }); + } + + private void layout(int width, int height) { + if (width <= 0 || height <= 0) { + return; + } + + async(() -> { + Shell currentShell = shell; + if (currentShell == null || currentShell.isDisposed()) { + return; + } + + currentShell.setBounds(0, 0, width, height); + Browser currentBrowser = browser; + if (currentBrowser != null && !currentBrowser.isDisposed()) { + Rectangle clientArea = currentShell.getClientArea(); + currentBrowser.setBounds(clientArea); + } + currentShell.layout(true, true); + }); + } + + private void joinSwtThread(Thread thread) { + if (thread == null || thread == Thread.currentThread()) { + return; + } + + try { + thread.join(TimeUnit.SECONDS.toMillis(5)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + if (thread.isAlive()) { + log.log(Level.WARNING, "SWT browser thread did not shut down within 5 seconds; " + + "the native browser engine may still be running"); + } + } + + private void disposeOnce() { + if (!widgetsDisposed.compareAndSet(false, true)) { + return; + } + + if (browser != null && !browser.isDisposed()) { + try { + browser.setUrl("about:blank"); + } catch (RuntimeException ignored) { + } + browser.dispose(); + } + if (shell != null && !shell.isDisposed()) { + shell.dispose(); + } + if (display != null && !display.isDisposed()) { + display.dispose(); + } + + browser = null; + shell = null; + display = null; + swtThread = null; + } + } + + private static final class SwtBrowserColorScheme { + + // SWT Edge.java Display key; spelling matches upstream ("Prefered"). + private static final String EDGE_DARK_SCHEME_KEY = + "org.eclipse.swt.internal.win32.Edge.useDarkPreferedColorScheme"; + + private static final boolean IS_WINDOWS = Environment.detectPlatform() == Platform.WINDOWS; + + private SwtBrowserColorScheme() { + } + + private static void applyBeforeBrowserCreate(Display display, boolean darkTheme) { + if (IS_WINDOWS) { + display.setData(EDGE_DARK_SCHEME_KEY, darkTheme); + } + } + + private static void apply(Browser browser, boolean darkTheme) { + if (browser == null || browser.isDisposed()) { + return; + } + if (!IS_WINDOWS || !applyNativeEdgeProfile(browser, darkTheme)) { + applyJsFallback(browser, darkTheme); + } + } + + private static boolean applyNativeEdgeProfile(Browser browser, boolean darkTheme) { + if (!"edge".equals(browser.getBrowserType())) { + return false; + } + + try { + Field webBrowserField = Browser.class.getDeclaredField("webBrowser"); + webBrowserField.setAccessible(true); + Object edge = webBrowserField.get(browser); + if (edge == null) { + return false; + } + + Field profileField = edge.getClass().getDeclaredField("profile"); + profileField.setAccessible(true); + Object profile = profileField.get(edge); + if (profile == null) { + return false; + } + + Method putScheme = profile.getClass().getMethod("put_PreferredColorScheme", int.class); + putScheme.invoke(profile, darkTheme ? 2 : 1); + return true; + } catch (ReflectiveOperationException ignored) { + return false; + } + } + + private static void applyJsFallback(Browser browser, boolean darkTheme) { + String preferred = darkTheme ? "dark" : "light"; + String script = """ + (() => { + const preferred = '%s'; + const noop = () => {}; + const createMql = (media, matches) => ({ + media, + matches, + onchange: null, + addListener: noop, + removeListener: noop, + addEventListener: noop, + removeEventListener: noop, + dispatchEvent: () => false + }); + + const originalMatchMedia = window.__launcherOriginalMatchMedia + || (typeof window.matchMedia === 'function' ? window.matchMedia.bind(window) : null); + window.__launcherOriginalMatchMedia = originalMatchMedia; + + window.matchMedia = (query) => { + const media = String(query == null ? '' : query); + if (media.includes('prefers-color-scheme')) { + const matchesDark = media.includes('dark') && preferred === 'dark'; + const matchesLight = media.includes('light') && preferred === 'light'; + return createMql(media, matchesDark || matchesLight); + } + + return originalMatchMedia ? originalMatchMedia(media) : createMql(media, false); + }; + + const root = document && document.documentElement; + if (root) { + root.setAttribute('data-prefers-color-scheme', preferred); + root.style.colorScheme = preferred; + } + })(); + """.formatted(preferred); + + try { + browser.evaluate(script); + } catch (RuntimeException ignored) { + } + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java index 89d0785f2..9930a88f3 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/AccountSelectDialog.java @@ -9,39 +9,64 @@ import com.skcraft.launcher.Launcher; import com.skcraft.launcher.auth.*; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.swing.InstanceRowStyle; import com.skcraft.launcher.swing.LinedBoxPanel; import com.skcraft.launcher.swing.SwingHelper; import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; import lombok.RequiredArgsConstructor; +import net.miginfocom.swing.MigLayout; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; import javax.swing.*; +import javax.swing.border.Border; +import javax.swing.border.EmptyBorder; import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; public class AccountSelectDialog extends JDialog { + private static final int ACTION_ICON_SIZE = 14; + private static final int TITLE_SUBTITLE_GAP = 0; + private static final String OFFLINE_USERNAME_PATTERN = "[A-Za-z0-9_]{3,16}"; + private final JList accountList; - private final JButton loginButton = new JButton(SharedLocale.tr("accounts.play")); + private final JButton loginButton; private final JButton cancelButton = new JButton(SharedLocale.tr("button.cancel")); - private final JButton addMojangButton = new JButton(SharedLocale.tr("accounts.addMojang")); - private final JButton addMicrosoftButton = new JButton(SharedLocale.tr("accounts.addMicrosoft")); - private final JButton removeSelected = new JButton(SharedLocale.tr("accounts.removeSelected")); - private final JButton offlineButton = new JButton(SharedLocale.tr("login.playOffline")); + private final JButton addAccountButton = createAddAccountButton(); + private final JButton removeSelected = createForgetAccountButton(); + private final JButton offlineButton = createOfflineAccountButton(); private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true); private final Launcher launcher; - private Session selected; + private final boolean manageOnly; public AccountSelectDialog(Window owner, Launcher launcher) { + this(owner, launcher, false); + } + + public AccountSelectDialog(Window owner, Launcher launcher, boolean manageOnly) { super(owner, ModalityType.DOCUMENT_MODAL); this.launcher = launcher; + this.manageOnly = manageOnly; this.accountList = new JList<>(launcher.getAccounts()); + this.loginButton = new JButton(SharedLocale.tr(manageOnly ? "accounts.useAccount" : "accounts.play")); + boolean offlineEnabled = launcher.getConfig().isOfflineEnabled(); + offlineButton.setVisible(offlineEnabled); + offlineButton.setEnabled(offlineEnabled); - setTitle(SharedLocale.tr("accounts.title")); + setTitle(SharedLocale.tr(manageOnly ? "accounts.manageTitle" : "accounts.title")); initComponents(); setDefaultCloseOperation(DISPOSE_ON_CLOSE); - setMinimumSize(new Dimension(350, 250)); + setMinimumSize(new Dimension(480, 280)); setResizable(false); pack(); setLocationRelativeTo(owner); @@ -49,63 +74,85 @@ public AccountSelectDialog(Window owner, Launcher launcher) { private void initComponents() { setLayout(new BorderLayout()); + Border panelBorder = BorderFactory.createLineBorder(SwingHelper.uiColor("Component.borderColor", Color.GRAY)); accountList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); accountList.setLayoutOrientation(JList.VERTICAL); accountList.setVisibleRowCount(0); accountList.setCellRenderer(new AccountRenderer()); + accountList.setFixedCellHeight(46); + accountList.setBorder(BorderFactory.createEmptyBorder(4, 4, 4, 4)); + accountList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent event) { + if (event.getClickCount() == 2 && SwingUtilities.isLeftMouseButton(event)) { + confirmSelection(accountList.getSelectedValue()); + } + } + }); + accountList.addListSelectionListener(ev -> updateActionState()); JScrollPane accountPane = new JScrollPane(accountList); accountPane.setPreferredSize(new Dimension(280, 150)); - accountPane.setAlignmentX(CENTER_ALIGNMENT); + accountPane.setAlignmentX(Component.LEFT_ALIGNMENT); + accountPane.setBorder(panelBorder); loginButton.setFont(loginButton.getFont().deriveFont(Font.BOLD)); - loginButton.setMargin(new Insets(0, 10, 0, 10)); + SwingHelper.styleDialogButton(cancelButton); + SwingHelper.styleDialogButton(loginButton); + SwingHelper.styleDialogButton(offlineButton); + SwingHelper.updateDialogButtonCursor(offlineButton); + SwingHelper.alignButtonSizes(cancelButton, loginButton); - //Start Buttons buttonsPanel.setBorder(BorderFactory.createEmptyBorder(26, 13, 13, 13)); - if (launcher.getConfig().isOfflineEnabled()) { - buttonsPanel.addElement(offlineButton); - } + buttonsPanel.addElement(offlineButton); buttonsPanel.addGlue(); buttonsPanel.addElement(cancelButton); buttonsPanel.addElement(loginButton); - //Login Buttons - JPanel loginButtonsRow = new JPanel(new BorderLayout(0, 5)); - addMojangButton.setAlignmentX(CENTER_ALIGNMENT); - addMicrosoftButton.setAlignmentX(CENTER_ALIGNMENT); - removeSelected.setAlignmentX(CENTER_ALIGNMENT); - loginButtonsRow.add(addMojangButton, BorderLayout.NORTH); - loginButtonsRow.add(addMicrosoftButton, BorderLayout.CENTER); - loginButtonsRow.add(removeSelected, BorderLayout.SOUTH); - loginButtonsRow.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0)); - - JPanel listAndLoginContainer = new JPanel(); - listAndLoginContainer.add(accountPane, BorderLayout.WEST); - listAndLoginContainer.add(loginButtonsRow, BorderLayout.EAST); - listAndLoginContainer.add(Box.createVerticalStrut(5)); - listAndLoginContainer.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); - - add(listAndLoginContainer, BorderLayout.CENTER); + JPanel actionsPanel = new JPanel(new MigLayout("insets 10, wrap 1, fillx", "[grow, fill]")); + actionsPanel.setBorder(BorderFactory.createCompoundBorder( + panelBorder, + new EmptyBorder(4, 4, 4, 4))); + + JLabel actionsLabel = new JLabel(SharedLocale.tr("accounts.actionsTitle")); + actionsLabel.setFont(actionsLabel.getFont().deriveFont(Font.BOLD)); + + Insets actionInsets = new Insets(10, 12, 10, 12); + addAccountButton.setMargin(actionInsets); + removeSelected.setMargin(actionInsets); + SwingHelper.styleDialogButton(addAccountButton); + SwingHelper.styleDialogButton(removeSelected); + SwingHelper.alignButtonSizes(addAccountButton, removeSelected); + Dimension actionSize = addAccountButton.getPreferredSize(); + actionSize.height = Math.max(actionSize.height, 38); + addAccountButton.setPreferredSize(actionSize); + addAccountButton.setMinimumSize(actionSize); + removeSelected.setPreferredSize(actionSize); + removeSelected.setMinimumSize(actionSize); + + actionsPanel.add(actionsLabel, "gapbottom 8"); + actionsPanel.add(addAccountButton, "growx"); + actionsPanel.add(removeSelected, "growx, gaptop 6"); + + JPanel contentPanel = new JPanel(new BorderLayout(12, 0)); + contentPanel.add(accountPane, BorderLayout.CENTER); + contentPanel.add(actionsPanel, BorderLayout.EAST); + + JPanel bodyPanel = new JPanel(new BorderLayout(0, 10)); + bodyPanel.setBorder(BorderFactory.createEmptyBorder(0, 12, 12, 12)); + bodyPanel.add(createHeaderPanel(), BorderLayout.NORTH); + bodyPanel.add(contentPanel, BorderLayout.CENTER); + + add(bodyPanel, BorderLayout.CENTER); add(buttonsPanel, BorderLayout.SOUTH); - loginButton.addActionListener(ev -> attemptExistingLogin(accountList.getSelectedValue())); + loginButton.addActionListener(ev -> confirmSelection(accountList.getSelectedValue())); cancelButton.addActionListener(ev -> dispose()); - addMojangButton.addActionListener(ev -> { - Session newSession = LoginDialog.showLoginRequest(this, launcher); - - if (newSession != null) { - launcher.getAccounts().update(newSession.toSavedSession()); - setResult(newSession); - } - }); - - addMicrosoftButton.addActionListener(ev -> attemptMicrosoftLogin(SharedLocale.tr("login.microsoft.seeBrowser"))); + addAccountButton.addActionListener(ev -> beginMicrosoftLogin()); - offlineButton.addActionListener(ev -> - setResult(new OfflineSession(launcher.getProperties().getProperty("offlinePlayerName")))); + offlineButton.addActionListener(ev -> beginOfflineAccount()); removeSelected.addActionListener(ev -> { if (accountList.getSelectedValue() != null) { @@ -114,11 +161,175 @@ private void initComponents() { if (confirmed) { launcher.getAccounts().remove(accountList.getSelectedValue()); + Persistence.commitAndForget(launcher.getAccounts()); + selectActiveAccount(); } } }); - accountList.setSelectedIndex(0); + selectActiveAccount(); + getRootPane().setDefaultButton(loginButton); + updateActionState(); + } + + private void selectActiveAccount() { + SavedSession active = launcher.getAccounts().getActiveAccount(); + if (active != null) { + accountList.setSelectedValue(active, true); + } else if (accountList.getModel().getSize() > 0) { + accountList.setSelectedIndex(0); + } else { + accountList.clearSelection(); + } + updateActionState(); + } + + private JPanel createHeaderPanel() { + JPanel headerPanel = new JPanel(new GridBagLayout()); + headerPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(SwingHelper.uiColor("Component.borderColor", Color.GRAY)), + new EmptyBorder(12, 12, 12, 12))); + + JLabel titleLabel = new JLabel(SharedLocale.tr(manageOnly ? "accounts.manageTitle" : "accounts.title")); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, titleLabel.getFont().getSize2D() + 1f)); + + JLabel subtitleLabel = new JLabel(SharedLocale.tr(manageOnly ? "accounts.manageSubtitle" : "accounts.subtitle")); + subtitleLabel.setForeground(SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY)); + + GridBagConstraints constraints = new GridBagConstraints(); + constraints.gridx = 0; + constraints.gridy = 0; + constraints.weightx = 1.0; + constraints.anchor = GridBagConstraints.WEST; + constraints.fill = GridBagConstraints.HORIZONTAL; + headerPanel.add(titleLabel, constraints); + constraints = (GridBagConstraints) constraints.clone(); + constraints.gridy = 1; + constraints.insets = new Insets(TITLE_SUBTITLE_GAP, 0, 0, 0); + headerPanel.add(subtitleLabel, constraints); + return headerPanel; + } + + private void updateActionState() { + boolean hasSelection = accountList.getSelectedValue() != null; + loginButton.setEnabled(hasSelection); + SwingHelper.updateDialogButtonCursor(loginButton); + removeSelected.setEnabled(hasSelection); + removeSelected.setIcon(createForgetIcon(ACTION_ICON_SIZE, hasSelection)); + updateForgetButtonStyle(removeSelected, hasSelection); + } + + private static JButton createAddAccountButton() { + JButton button = new JButton(SharedLocale.tr("accounts.addAccount"), createPlusIcon(ACTION_ICON_SIZE)); + button.setToolTipText(SharedLocale.tr("accounts.addAccountTooltip")); + button.setIconTextGap(8); + button.setFont(button.getFont().deriveFont(Font.BOLD)); + return button; + } + + private static JButton createOfflineAccountButton() { + JButton button = new JButton(SharedLocale.tr("accounts.offlineAccount")); + button.setToolTipText(SharedLocale.tr("accounts.offlineAccountTooltip")); + return button; + } + + private static JButton createForgetAccountButton() { + JButton button = new JButton(SharedLocale.tr("accounts.removeSelected"), createForgetIcon(ACTION_ICON_SIZE, false)); + button.setToolTipText(SharedLocale.tr("accounts.forgetTooltip")); + button.setIconTextGap(8); + return button; + } + + private static void updateForgetButtonStyle(JButton button, boolean enabled) { + SwingHelper.updateDialogButtonCursor(button); + Color foreground = getForgetButtonForeground(enabled); + button.setForeground(foreground); + button.setIcon(createForgetIcon(ACTION_ICON_SIZE, foreground)); + button.setDisabledIcon(createForgetIcon(ACTION_ICON_SIZE, getForgetButtonForeground(false))); + } + + private static Icon createPlusIcon(int size) { + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(SwingHelper.uiColor("Button.foreground", new Color(55, 55, 55))); + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + int c = size / 2; + int pad = size / 5; + g.drawLine(pad, c, size - pad, c); + g.drawLine(c, pad, c, size - pad); + } finally { + g.dispose(); + } + return new ImageIcon(image); + } + + private static Icon createForgetIcon(int size, boolean enabled) { + return createForgetIcon(size, getForgetButtonForeground(enabled)); + } + + private static Icon createForgetIcon(int size, Color color) { + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.setColor(color); + g.setStroke(new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + int pad = size / 5; + g.drawLine(pad, pad, size - pad, size - pad); + g.drawLine(size - pad, pad, pad, size - pad); + } finally { + g.dispose(); + } + return new ImageIcon(image); + } + + private static Color getForgetButtonForeground(boolean enabled) { + if (!enabled) { + return SwingHelper.uiColor("Button.disabledText", + SwingHelper.uiColor("Label.disabledForeground", Color.GRAY)); + } + + Color color = UIManager.getColor("Actions.Red"); + if (color == null) { + color = UIManager.getColor("Component.error.focusedBorderColor"); + } + if (color == null) { + color = UIManager.getColor("Component.error.borderColor"); + } + if (color == null) { + color = new Color(160, 40, 40); + } + + Color background = SwingHelper.uiColor("Button.background", + SwingHelper.uiColor("Panel.background", Color.WHITE)); + if (contrastRatio(color, background) >= 4.5d) { + return color; + } + + return relativeLuminance(background) < 0.5d + ? new Color(255, 130, 130) + : new Color(145, 35, 35); + } + + private static double contrastRatio(Color a, Color b) { + double lighter = Math.max(relativeLuminance(a), relativeLuminance(b)); + double darker = Math.min(relativeLuminance(a), relativeLuminance(b)); + return (lighter + 0.05d) / (darker + 0.05d); + } + + private static double relativeLuminance(Color color) { + return 0.2126d * linearChannel(color.getRed()) + + 0.7152d * linearChannel(color.getGreen()) + + 0.0722d * linearChannel(color.getBlue()); + } + + private static double linearChannel(int value) { + double channel = value / 255.0d; + return channel <= 0.03928d + ? channel / 12.92d + : Math.pow((channel + 0.055d) / 1.055d, 2.4d); } @Override @@ -127,85 +338,288 @@ public void dispose() { super.dispose(); } + /** + * Resolve a session for launching: restore the active account, or Microsoft sign-in if none. + */ public static Session showAccountRequest(Window owner, Launcher launcher) { - AccountSelectDialog dialog = new AccountSelectDialog(owner, launcher); - dialog.setVisible(true); + SavedSession active = launcher.getAccounts().getActiveAccount(); + if (active != null) { + return restoreAccount(owner, launcher, active); + } - if (dialog.selected != null && dialog.selected.isOnline()) { - launcher.getAccounts().update(dialog.selected.toSavedSession()); + Session session = requestMicrosoftLogin(owner, launcher); + if (session != null && session.isOnline()) { + launcher.getAccounts().update(session.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); } + return session; + } + + /** + * Open the account switcher without launching. + * The management dialog remains available when there are no saved accounts. + */ + public static void showManageAccounts(Window owner, Launcher launcher) { + AccountSelectDialog dialog = new AccountSelectDialog(owner, launcher, true); + dialog.setVisible(true); Persistence.commitAndForget(launcher.getAccounts()); + } - return dialog.selected; + private void confirmSelection(SavedSession session) { + if (session == null) { + return; + } + + if (manageOnly) { + launcher.getAccounts().setActiveAccount(session); + Persistence.commitAndForget(launcher.getAccounts()); + dispose(); + return; + } + + attemptExistingLogin(session); } private void setResult(Session result) { - this.selected = result; dispose(); } - private void attemptMicrosoftLogin(String status) { - SettableProgress progress = new SettableProgress(status, -1); - - ListenableFuture future = launcher.getExecutor().submit(() -> { - Session newSession = launcher.getMicrosoftLogin().login(() -> - progress.set(SharedLocale.tr("login.loggingInStatus"), -1)); - - if (newSession != null) { - launcher.getAccounts().update(newSession.toSavedSession()); + private void beginMicrosoftLogin() { + Session newSession = requestMicrosoftLogin(this, launcher); + if (newSession != null) { + launcher.getAccounts().update(newSession.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + if (manageOnly) { + selectActiveAccount(); + } else { setResult(newSession); } + } + } + + private void beginOfflineAccount() { + SavedSession offlineAccount = createOfflineAccount(this, launcher); + if (offlineAccount == null) { + return; + } + if (manageOnly) { + selectActiveAccount(); + } else { + setResult(sanitizeOfflineAccount(launcher, offlineAccount)); + } + } + + private static SavedSession createOfflineAccount(Window owner, Launcher launcher) { + String username = promptOfflineUsername(owner, launcher.getProperties().getProperty("offlinePlayerName")); + if (username == null) { return null; + } + + SavedSession offlineAccount = launcher.getAccounts().getOfflineAccount(username); + + if (offlineAccount == null) { + offlineAccount = new OfflineSession(username).toSavedSession(); + } + + return persistOfflineAccount(launcher, offlineAccount); + } + + /** + * Force offline UUID from username and strip avatar/tokens so Mojang skins + * cannot ride along into the game process. + */ + private static OfflineSession sanitizeOfflineAccount(Launcher launcher, SavedSession session) { + OfflineSession offlineSession = OfflineSession.fromSavedSession(session); + persistOfflineAccount(launcher, offlineSession.toSavedSession()); + return offlineSession; + } + + private static SavedSession persistOfflineAccount(Launcher launcher, SavedSession offlineAccount) { + SavedSession cleaned = new OfflineSession(offlineAccount.getUsername()).toSavedSession(); + launcher.getAccounts().putOfflineAccount(cleaned); + Persistence.commitAndForget(launcher.getAccounts()); + return cleaned; + } + + private static String promptOfflineUsername(Window owner, String defaultUsername) { + String initialValue = defaultUsername != null ? defaultUsername : ""; + + while (true) { + String username = (String) JOptionPane.showInputDialog(owner, + SharedLocale.tr("accounts.offlineAccountNamePrompt"), + SharedLocale.tr("accounts.offlineAccountTitle"), + JOptionPane.QUESTION_MESSAGE, null, null, initialValue); + + if (username == null) { + return null; + } + + username = username.trim(); + if (isValidOfflineUsername(username)) { + return username; + } + + initialValue = username; + SwingHelper.showMessageDialog(owner, + SharedLocale.tr("accounts.offlineAccountNameInvalid"), + SharedLocale.tr("accounts.offlineAccountTitle"), null, JOptionPane.WARNING_MESSAGE); + } + } + + private static boolean isValidOfflineUsername(String username) { + return username != null && username.matches(OFFLINE_USERNAME_PATTERN); + } + + private static Session requestMicrosoftLogin(Window owner, Launcher launcher) { + MicrosoftLoginDialog.Outcome outcome = MicrosoftLoginDialog.showLogin(owner, launcher); + switch (outcome.getResult()) { + case SUCCESS: + return outcome.getSession(); + case FALLBACK_REQUESTED: + return attemptMicrosoftBrowserLogin(owner, launcher); + case CANCELLED: + default: + return null; + } + } + + private static Session attemptMicrosoftBrowserLogin(Window owner, Launcher launcher) { + String status = SharedLocale.tr("login.microsoft.seeBrowser"); + SettableProgress progress = new SettableProgress(status, -1); + + ListenableFuture future = launcher.getExecutor().submit(() -> { + return launcher.getMicrosoftLogin() + .login(() -> progress.set(SharedLocale.tr("login.loggingInStatus"), -1)); }); - ProgressDialog.showProgress(this, future, progress, + ProgressDialog.showProgress(owner, future, progress, SharedLocale.tr("login.loggingInTitle"), status); - SwingHelper.addErrorDialogCallback(this, future); + SwingHelper.addErrorDialogCallback(owner, future); + + try { + return future.get(); + } catch (CancellationException e) { + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException e) { + return null; + } } + private static Session restoreAccount(Window owner, Launcher launcher, SavedSession session) { + if (AccountList.isOfflineAccount(session)) { + return sanitizeOfflineAccount(launcher, session); + } + + LoginService loginService = launcher.getLoginService(session.getType()); + RestoreSessionCallable callable = new RestoreSessionCallable(loginService, session); + + ObservableFuture future = new ObservableFuture<>(launcher.getExecutor().submit(callable), callable); + + ProgressDialog.showProgress(owner, future, SharedLocale.tr("login.loggingInTitle"), + SharedLocale.tr("login.loggingInStatus")); + + try { + Session result = future.get(); + if (result != null && result.isOnline()) { + launcher.getAccounts().update(result.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + } + return result; + } catch (CancellationException e) { + return null; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof AuthenticationException + && ((AuthenticationException) cause).isInvalidatedSession()) { + return reloginExpired(owner, launcher, session, cause.getLocalizedMessage()); + } + SwingHelper.showErrorDialog(owner, cause.getLocalizedMessage(), SharedLocale.tr("errorTitle"), cause); + return null; + } + } + + private static Session reloginExpired(Window owner, Launcher launcher, SavedSession session, String message) { + if (session.getType() == UserType.MICROSOFT) { + Session newSession = requestMicrosoftLogin(owner, launcher); + if (newSession != null && newSession.isOnline()) { + launcher.getAccounts().update(newSession.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + } + return newSession; + } + + LoginDialog.ReloginDetails details = new LoginDialog.ReloginDetails(session.getUsername(), + SharedLocale.tr("login.relogin", message)); + Session newSession = LoginDialog.showLoginRequest(owner, launcher, details); + if (newSession != null) { + launcher.getAccounts().update(newSession.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + } + return newSession; + } + + @SuppressWarnings("null") private void attemptExistingLogin(SavedSession session) { - if (session == null) return; + if (session == null) { + return; + } + + if (AccountList.isOfflineAccount(session)) { + setResult(sanitizeOfflineAccount(launcher, session)); + return; + } LoginService loginService = launcher.getLoginService(session.getType()); RestoreSessionCallable callable = new RestoreSessionCallable(loginService, session); ObservableFuture future = new ObservableFuture<>(launcher.getExecutor().submit(callable), callable); + @Nonnull Executor callbackExecutor = SwingExecutor.INSTANCE; Futures.addCallback(future, new FutureCallback() { @Override - public void onSuccess(Session result) { + public void onSuccess(@Nullable Session result) { + if (result != null && result.isOnline()) { + launcher.getAccounts().update(result.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + } setResult(result); } @Override - public void onFailure(Throwable t) { + public void onFailure(@Nonnull Throwable t) { if (t instanceof AuthenticationException && ((AuthenticationException) t).isInvalidatedSession()) { - // Just need to log in again relogin(session, t.getLocalizedMessage()); } else { - SwingHelper.showErrorDialog(AccountSelectDialog.this, t.getLocalizedMessage(), SharedLocale.tr("errorTitle"), t); + SwingHelper.showErrorDialog(AccountSelectDialog.this, t.getLocalizedMessage(), + SharedLocale.tr("errorTitle"), t); } } - }, SwingExecutor.INSTANCE); + }, callbackExecutor); ProgressDialog.showProgress(this, future, SharedLocale.tr("login.loggingInTitle"), SharedLocale.tr("login.loggingInStatus")); } - /** - * Re-login to an expired session - */ private void relogin(SavedSession session, String message) { if (session.getType() == UserType.MICROSOFT) { - this.attemptMicrosoftLogin(message); + beginMicrosoftLogin(); } else { LoginDialog.ReloginDetails details = new LoginDialog.ReloginDetails(session.getUsername(), SharedLocale.tr("login.relogin", message)); Session newSession = LoginDialog.showLoginRequest(AccountSelectDialog.this, launcher, details); - launcher.getAccounts().update(newSession.toSavedSession()); - setResult(newSession); + if (newSession != null) { + launcher.getAccounts().update(newSession.toSavedSession()); + Persistence.commitAndForget(launcher.getAccounts()); + setResult(newSession); + } } } @@ -230,30 +644,111 @@ public double getProgress() { } } - private static class AccountRenderer extends JLabel implements ListCellRenderer { + private static class AccountRenderer extends JPanel implements ListCellRenderer { + private static final int AVATAR_SIZE = 32; + private static final int ICON_TEXT_GAP = 10; + private static final float SUBTITLE_FONT_SIZE = 11.0f; + + private final JPanel accountPanel = new JPanel(new BorderLayout(ICON_TEXT_GAP, 0)); + private final JLabel avatarLabel = new JLabel(); + private final JLabel usernameLabel = new JLabel(); + private final JLabel typeLabel = new JLabel(); + private final Icon defaultAvatar = SwingHelper.createIcon(Launcher.class, "default_skin.png", 32, 32); + public AccountRenderer() { - setHorizontalAlignment(LEFT); + super(new BorderLayout()); + setOpaque(true); + setBorder(new EmptyBorder(3, 2, 3, 2)); + + accountPanel.setOpaque(false); + accountPanel.setBorder(new EmptyBorder(4, 6, 4, 6)); + + avatarLabel.setHorizontalAlignment(SwingConstants.CENTER); + avatarLabel.setVerticalAlignment(SwingConstants.CENTER); + avatarLabel.setOpaque(false); + Dimension avatarSize = new Dimension(AVATAR_SIZE, AVATAR_SIZE); + avatarLabel.setPreferredSize(avatarSize); + avatarLabel.setMinimumSize(avatarSize); + avatarLabel.setMaximumSize(new Dimension(AVATAR_SIZE, Integer.MAX_VALUE)); + + usernameLabel.setFont(InstanceRowStyle.titleFont()); + usernameLabel.setOpaque(false); + + typeLabel.setFont(typeLabel.getFont().deriveFont(Font.PLAIN, SUBTITLE_FONT_SIZE)); + typeLabel.setOpaque(false); + + accountPanel.add(avatarLabel, BorderLayout.WEST); + accountPanel.add(createTextPanel(), BorderLayout.CENTER); + add(accountPanel, BorderLayout.CENTER); } @Override - public Component getListCellRendererComponent(JList list, SavedSession value, int index, boolean isSelected, boolean cellHasFocus) { - setText(value.getUsername()); - if (value.getAvatarImage() != null) { - setIcon(new ImageIcon(value.getAvatarImage())); - } else { - setIcon(SwingHelper.createIcon(Launcher.class, "default_skin.png", 32, 32)); - } + public Component getListCellRendererComponent(JList list, SavedSession value, int index, + boolean isSelected, boolean cellHasFocus) { + usernameLabel.setText(value != null ? value.getUsername() : ""); + typeLabel.setText(getAccountTypeLabel(value)); + if (value != null && !AccountList.isOfflineAccount(value) && value.getAvatarImage() != null) { + avatarLabel.setIcon(new ImageIcon(value.getAvatarImage())); + } else { + avatarLabel.setIcon(defaultAvatar); + } + Color background; + Color primaryForeground; + Color secondaryForeground; if (isSelected) { - setOpaque(true); - setBackground(list.getSelectionBackground()); - setForeground(list.getSelectionForeground()); + background = list.getSelectionBackground(); + primaryForeground = list.getSelectionForeground(); + secondaryForeground = primaryForeground; } else { - setOpaque(false); - setForeground(list.getForeground()); + background = list.getBackground(); + primaryForeground = list.getForeground(); + secondaryForeground = SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY); } + setBackground(background); + setForeground(primaryForeground); + usernameLabel.setForeground(primaryForeground); + typeLabel.setForeground(secondaryForeground); + return this; } + + private JPanel createTextPanel() { + JPanel textPanel = new JPanel(new GridBagLayout()); + textPanel.setOpaque(false); + + GridBagConstraints constraints = new GridBagConstraints(); + constraints.gridx = 0; + constraints.gridy = 0; + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + textPanel.add(usernameLabel, constraints); + + constraints = (GridBagConstraints) constraints.clone(); + constraints.gridy = 1; + constraints.insets = new Insets(TITLE_SUBTITLE_GAP, 0, 0, 0); + textPanel.add(typeLabel, constraints); + + return textPanel; + } + + private static String getAccountTypeLabel(SavedSession session) { + if (session == null) { + return ""; + } + + UserType type = session.getType(); + if (type == UserType.MICROSOFT) { + return SharedLocale.tr("accounts.type.microsoft"); + } else if (type == UserType.OFFLINE) { + return SharedLocale.tr("accounts.type.offline"); + } else if (type == UserType.MOJANG || type == UserType.LEGACY) { + return SharedLocale.tr("accounts.type.mojang"); + } + + return ""; + } } } diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java index e0025bc61..5a730c9aa 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ConfigurationDialog.java @@ -7,23 +7,19 @@ package com.skcraft.launcher.dialog; import com.skcraft.launcher.Configuration; +import com.skcraft.launcher.Instance; import com.skcraft.launcher.Launcher; -import com.skcraft.launcher.dialog.component.BetterComboBox; -import com.skcraft.launcher.launch.runtime.AddJavaRuntime; -import com.skcraft.launcher.launch.runtime.JavaRuntime; -import com.skcraft.launcher.launch.runtime.JavaRuntimeFinder; import com.skcraft.launcher.persistence.Persistence; import com.skcraft.launcher.swing.*; import com.skcraft.launcher.util.SharedLocale; +import com.google.common.base.Strings; import lombok.NonNull; +import net.miginfocom.swing.MigLayout; import javax.swing.*; -import javax.swing.filechooser.FileFilter; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; -import java.io.File; -import java.util.Arrays; /** * A dialog to modify configuration options. @@ -31,19 +27,22 @@ public class ConfigurationDialog extends JDialog { private final Configuration config; + private final Launcher launcher; private final ObjectSwingMapper mapper; + private final String originalGameKey; + private boolean gameKeyChanged; private final JPanel tabContainer = new JPanel(new BorderLayout()); private final JTabbedPane tabbedPane = new JTabbedPane(); - private final FormPanel javaSettingsPanel = new FormPanel(); - private final JComboBox jvmRuntime = new BetterComboBox<>(); - private final JTextField jvmArgsText = new JTextField(); - private final JSpinner minMemorySpinner = new JSpinner(); - private final JSpinner maxMemorySpinner = new JSpinner(); - private final JSpinner permGenSpinner = new JSpinner(); + private static final int SETTINGS_BUTTON_FOCUS_INSET = 8; + + private final JPanel instanceSettingsPanel = new JPanel(new MigLayout( + "fillx, wrap 1, ins 12 12 12 " + (12 + SETTINGS_BUTTON_FOCUS_INSET), "[grow]", "")); + private final JScrollPane instanceSettingsScroll = new JScrollPane(instanceSettingsPanel); private final FormPanel gameSettingsPanel = new FormPanel(); private final JSpinner widthSpinner = new JSpinner(); private final JSpinner heightSpinner = new JSpinner(); + private final JCheckBox maximizeWindowCheck = new JCheckBox(SharedLocale.tr("options.maximizeWindow")); private final FormPanel proxySettingsPanel = new FormPanel(); private final JCheckBox useProxyCheck = new JCheckBox(SharedLocale.tr("options.useProxyCheck")); private final JTextField proxyHostText = new JTextField(); @@ -51,6 +50,12 @@ public class ConfigurationDialog extends JDialog { private final JTextField proxyUsernameText = new JTextField(); private final JPasswordField proxyPasswordText = new JPasswordField(); private final FormPanel advancedPanel = new FormPanel(); + private final JCheckBox showInstanceConsoleCheck = new JCheckBox(SharedLocale.tr("options.showInstanceConsole")); + private final JComboBox themeSelect = new JComboBox<>(new String[] { + SharedLocale.tr("options.themeLight"), + SharedLocale.tr("options.themeDark"), + SharedLocale.tr("options.themeSystem") + }); private final JTextField gameKeyText = new JTextField(); private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true); private final JButton okButton = new JButton(SharedLocale.tr("button.ok")); @@ -61,78 +66,69 @@ public class ConfigurationDialog extends JDialog { /** * Create a new configuration dialog. * - * @param owner the window owner + * @param owner the window owner * @param launcher the launcher */ public ConfigurationDialog(Window owner, @NonNull Launcher launcher) { super(owner, ModalityType.DOCUMENT_MODAL); this.config = launcher.getConfig(); + this.launcher = launcher; + this.originalGameKey = Strings.nullToEmpty(config.getGameKey()); mapper = new ObjectSwingMapper(config); - JavaRuntime[] javaRuntimes = JavaRuntimeFinder.getAvailableRuntimes().toArray(new JavaRuntime[0]); - DefaultComboBoxModel model = new DefaultComboBoxModel<>(javaRuntimes); - - // Put the runtime from the config in the model if it isn't - boolean configRuntimeFound = Arrays.stream(javaRuntimes).anyMatch(r -> r.equals(config.getJavaRuntime())); - if (!configRuntimeFound && config.getJavaRuntime() != null) { - model.insertElementAt(config.getJavaRuntime(), 0); - } - - jvmRuntime.setModel(model); - jvmRuntime.addItem(AddJavaRuntime.ADD_RUNTIME_SENTINEL); - - jvmRuntime.setSelectedItem(config.getJavaRuntime()); - setTitle(SharedLocale.tr("options.title")); - initComponents(); // Must be called after jvmRuntime model setup + initComponents(); setDefaultCloseOperation(DISPOSE_ON_CLOSE); setSize(new Dimension(400, 500)); setResizable(false); setLocationRelativeTo(owner); - mapper.map(jvmArgsText, "jvmArgs"); - mapper.map(minMemorySpinner, "minMemory"); - mapper.map(maxMemorySpinner, "maxMemory"); - mapper.map(permGenSpinner, "permGen"); mapper.map(widthSpinner, "windowWidth"); mapper.map(heightSpinner, "windowHeight"); + mapper.map(maximizeWindowCheck, "maximizeWindow"); mapper.map(useProxyCheck, "proxyEnabled"); mapper.map(proxyHostText, "proxyHost"); mapper.map(proxyPortText, "proxyPort"); mapper.map(proxyUsernameText, "proxyUsername"); mapper.map(proxyPasswordText, "proxyPassword"); + mapper.map(showInstanceConsoleCheck, "showInstanceConsole"); mapper.map(gameKeyText, "gameKey"); mapper.copyFromObject(); + updateWindowSizeInputState(); + maximizeWindowCheck.addActionListener(e -> updateWindowSizeInputState()); + themeSelect.setSelectedIndex(indexForThemeMode(config.getThemeMode())); } private void initComponents() { - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.jvmPath")), jvmRuntime); - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.jvmArguments")), jvmArgsText); - javaSettingsPanel.addRow(Box.createVerticalStrut(15)); - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.64BitJavaWarning"))); - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.minMemory")), minMemorySpinner); - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.maxMemory")), maxMemorySpinner); - javaSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.permGen")), permGenSpinner); - SwingHelper.removeOpaqueness(javaSettingsPanel); - tabbedPane.addTab(SharedLocale.tr("options.javaTab"), SwingHelper.alignTabbedPane(javaSettingsPanel)); - + buildInstanceSettingsPanel(); + instanceSettingsScroll.setBorder(BorderFactory.createEmptyBorder()); + instanceSettingsScroll.setViewportBorder(BorderFactory.createEmptyBorder()); + instanceSettingsScroll.setOpaque(false); + instanceSettingsScroll.getViewport().setOpaque(false); + tabbedPane.addTab(SharedLocale.tr("options.instancesTab"), instanceSettingsScroll); + + gameSettingsPanel.addRow(maximizeWindowCheck); gameSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.windowWidth")), widthSpinner); gameSettingsPanel.addRow(new JLabel(SharedLocale.tr("options.windowHeight")), heightSpinner); - SwingHelper.removeOpaqueness(gameSettingsPanel); tabbedPane.addTab(SharedLocale.tr("options.minecraftTab"), SwingHelper.alignTabbedPane(gameSettingsPanel)); + SwingHelper.enableSpinnerMouseWheel(widthSpinner, heightSpinner, proxyPortText); + JSpinner.NumberEditor proxyPortEditor = new JSpinner.NumberEditor(proxyPortText, "#"); + proxyPortEditor.getTextField().setHorizontalAlignment(JTextField.LEFT); + proxyPortText.setEditor(proxyPortEditor); + proxySettingsPanel.addRow(useProxyCheck); proxySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.proxyHost")), proxyHostText); proxySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.proxyPort")), proxyPortText); proxySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.proxyUsername")), proxyUsernameText); proxySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.proxyPassword")), proxyPasswordText); - SwingHelper.removeOpaqueness(proxySettingsPanel); tabbedPane.addTab(SharedLocale.tr("options.proxyTab"), SwingHelper.alignTabbedPane(proxySettingsPanel)); + advancedPanel.addRow(showInstanceConsoleCheck); + advancedPanel.addRow(new JLabel(SharedLocale.tr("options.theme")), themeSelect); advancedPanel.addRow(new JLabel(SharedLocale.tr("options.gameKey")), gameKeyText); - SwingHelper.removeOpaqueness(advancedPanel); tabbedPane.addTab(SharedLocale.tr("options.advancedTab"), SwingHelper.alignTabbedPane(advancedPanel)); buttonsPanel.addElement(logButton); @@ -147,6 +143,10 @@ private void initComponents() { add(buttonsPanel, BorderLayout.SOUTH); SwingHelper.equalWidth(okButton, cancelButton); + SwingHelper.styleDialogButton(logButton); + SwingHelper.styleDialogButton(aboutButton); + SwingHelper.styleDialogButton(okButton); + SwingHelper.styleDialogButton(cancelButton); cancelButton.addActionListener(ActionListeners.dispose(this)); @@ -171,49 +171,124 @@ public void actionPerformed(ActionEvent e) { } }); - jvmRuntime.addActionListener(e -> { - // A little fun hack... - if (jvmRuntime.getSelectedItem() == AddJavaRuntime.ADD_RUNTIME_SENTINEL) { - jvmRuntime.setSelectedItem(null); - jvmRuntime.setPopupVisible(false); - - JFileChooser chooser = new JFileChooser(); - chooser.setFileSelectionMode(JFileChooser.FILES_ONLY); - chooser.setFileFilter(new JavaRuntimeFileFilter()); - chooser.setDialogTitle("Choose a Java executable"); - - int result = chooser.showOpenDialog(this); - if (result == JFileChooser.APPROVE_OPTION) { - JavaRuntime runtime = JavaRuntimeFinder.getRuntimeFromPath(chooser.getSelectedFile().getAbsolutePath()); - - MutableComboBoxModel model = (MutableComboBoxModel) jvmRuntime.getModel(); - model.insertElementAt(runtime, 0); - jvmRuntime.setSelectedItem(runtime); - } + } + + private void updateWindowSizeInputState() { + boolean enabled = !maximizeWindowCheck.isSelected(); + widthSpinner.setEnabled(enabled); + heightSpinner.setEnabled(enabled); + } + + private void buildInstanceSettingsPanel() { + JLabel titleLabel = new JLabel(SharedLocale.tr("options.instancesTitle")); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD)); + instanceSettingsPanel.add(titleLabel, "growx"); + + JTextArea explanation = new JTextArea(SharedLocale.tr("options.instancesDescription")); + explanation.setEditable(false); + explanation.setFocusable(false); + explanation.setLineWrap(true); + explanation.setWrapStyleWord(true); + explanation.setOpaque(false); + explanation.setFont(UIManager.getFont("Label.font")); + explanation.setForeground(UIManager.getColor("Label.foreground")); + explanation.setBorder(BorderFactory.createEmptyBorder(0, 0, 10, 0)); + instanceSettingsPanel.add(explanation, "growx"); + + if (launcher.getInstances().size() == 0) { + instanceSettingsPanel.add(new JLabel(SharedLocale.tr("options.noInstances")), "growx"); + return; + } + + for (int i = 0; i < launcher.getInstances().size(); i++) { + Instance instance = launcher.getInstances().get(i); + JButton settingsButton = null; + if (instance.isLocal()) { + settingsButton = new JButton(SharedLocale.tr("options.instanceJavaSettings")); + settingsButton.addActionListener(e -> { + dispose(); + InstanceSettingsDialog.open(getOwner(), launcher, instance); + }); } - }); + + instanceSettingsPanel.add(createInstanceSettingsRow(instance, settingsButton), "growx"); + } + } + + private JPanel createInstanceSettingsRow(Instance instance, JButton settingsButton) { + JPanel row = new JPanel(new MigLayout("fillx, ins 8 0 8 0", "[grow]push[]", "[][]")); + row.setOpaque(false); + Color separatorColor = UIManager.getColor("Separator.foreground"); + if (separatorColor == null) { + separatorColor = UIManager.getColor("Panel.background"); + } + if (separatorColor == null) { + separatorColor = Color.LIGHT_GRAY; + } else { + separatorColor = separatorColor.darker(); + } + row.setBorder(BorderFactory.createMatteBorder(1, 0, 0, 0, separatorColor)); + + JLabel titleLabel = new JLabel(instance.getTitle()); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD)); + row.add(titleLabel, settingsButton != null ? "growx" : "growx, wrap"); + if (settingsButton != null) { + row.add(settingsButton, "spany 2, aligny center, gapright " + + SETTINGS_BUTTON_FOCUS_INSET + ", wrap"); + } + + JLabel descriptionLabel = new JLabel(InstanceStatusText.forInstance(instance)); + descriptionLabel.setForeground(UIManager.getColor("Label.disabledForeground")); + row.add(descriptionLabel, "growx"); + + return row; } /** * Save the configuration and close the dialog. */ + private void updateWindowSizeInputState() { + boolean enabled = !maximizeWindowCheck.isSelected(); + widthSpinner.setEnabled(enabled); + heightSpinner.setEnabled(enabled); + } + public void save() { mapper.copyFromSwing(); - config.setJavaRuntime((JavaRuntime) jvmRuntime.getSelectedItem()); + config.setThemeMode(themeModeForIndex(themeSelect.getSelectedIndex())); + gameKeyChanged = !originalGameKey.equals(Strings.nullToEmpty(config.getGameKey())); Persistence.commitAndForget(config); + LauncherLookAndFeel.applyTheme(config.getThemeMode()); dispose(); } - static class JavaRuntimeFileFilter extends FileFilter { - @Override - public boolean accept(File f) { - return f.isDirectory() || f.getName().startsWith("java") && f.canExecute(); + private static int indexForThemeMode(String themeMode) { + if (Configuration.THEME_DARK.equals(themeMode)) { + return 1; } + if (Configuration.THEME_SYSTEM.equals(themeMode)) { + return 2; + } + return 0; + } - @Override - public String getDescription() { - return "Java runtime executables"; + private static String themeModeForIndex(int index) { + if (index == 1) { + return Configuration.THEME_DARK; } + if (index == 2) { + return Configuration.THEME_SYSTEM; + } + return Configuration.THEME_LIGHT; + } + + /** + * Whether the game key was changed when the dialog was last saved. + * + * @return true if the game key changed on save + */ + public boolean isGameKeyChanged() { + return gameKeyChanged; } } diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java index bdd5dd172..452b2457e 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ConsoleFrame.java @@ -10,6 +10,7 @@ import com.skcraft.launcher.swing.LinedBoxPanel; import com.skcraft.launcher.swing.MessageLog; import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.util.LogBuffer; import com.skcraft.launcher.util.PastebinPoster; import com.skcraft.launcher.util.SharedLocale; import lombok.Getter; @@ -46,7 +47,11 @@ public class ConsoleFrame extends JFrame { * @param colorEnabled true to enable a colored console */ public ConsoleFrame(int numLines, boolean colorEnabled) { - this(SharedLocale.tr("console.title"), numLines, colorEnabled); + this(SharedLocale.tr("console.title"), numLines, colorEnabled, true); + } + + public ConsoleFrame(int numLines, boolean colorEnabled, boolean lineWrap) { + this(SharedLocale.tr("console.title"), numLines, colorEnabled, lineWrap); } /** @@ -57,7 +62,15 @@ public ConsoleFrame(int numLines, boolean colorEnabled) { * @param colorEnabled true to enable a colored console */ public ConsoleFrame(@NonNull String title, int numLines, boolean colorEnabled) { - messageLog = new MessageLog(numLines, colorEnabled); + this(title, numLines, colorEnabled, true); + } + + public ConsoleFrame(@NonNull String title, int numLines, boolean colorEnabled, boolean lineWrap) { + this(title, new MessageLog(numLines, colorEnabled, lineWrap)); + } + + protected ConsoleFrame(@NonNull String title, @NonNull MessageLog messageLog) { + this.messageLog = messageLog; trayRunningIcon = SwingHelper.createImage(Launcher.class, "tray_ok.png"); trayClosedIcon = SwingHelper.createImage(Launcher.class, "tray_closed.png"); @@ -87,6 +100,7 @@ private void initComponents() { buttonsPanel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); buttonsPanel.addElement(pastebinButton); buttonsPanel.addElement(clearLogButton); + SwingHelper.alignButtonSizes(pastebinButton, clearLogButton); add(buttonsPanel, BorderLayout.NORTH); add(messageLog, BorderLayout.CENTER); @@ -94,6 +108,9 @@ private void initComponents() { @Override public void actionPerformed(ActionEvent e) { messageLog.clear(); + if (globalFrame == ConsoleFrame.this) { + LogBuffer.clearBuffer(); + } } }); @@ -122,6 +139,9 @@ protected void performClose() { messageLog.detachGlobalHandler(); messageLog.clear(); registeredGlobalLog = false; + if (globalFrame == this) { + globalFrame = null; + } dispose(); } @@ -149,7 +169,7 @@ public void handleError(String err) { public static void showMessages() { ConsoleFrame frame = globalFrame; - if (frame == null) { + if (frame == null || !frame.isDisplayable()) { frame = new ConsoleFrame(10000, false); globalFrame = frame; frame.setTitle(SharedLocale.tr("console.launcherConsoleTitle")); diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/InstanceSettingsDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/InstanceSettingsDialog.java index 9518024ca..9e4c3417d 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/InstanceSettingsDialog.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/InstanceSettingsDialog.java @@ -2,33 +2,63 @@ import com.skcraft.launcher.Instance; import com.skcraft.launcher.InstanceSettings; -import com.skcraft.launcher.dialog.component.BetterComboBox; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.launch.JavaProcessBuilder; +import com.skcraft.launcher.launch.MemoryRequirements; import com.skcraft.launcher.launch.MemorySettings; +import com.skcraft.launcher.launch.runtime.AddJavaRuntime; import com.skcraft.launcher.launch.runtime.JavaRuntime; import com.skcraft.launcher.launch.runtime.JavaRuntimeFinder; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.model.minecraft.VersionManifest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.skcraft.launcher.model.modpack.LaunchModifier; import com.skcraft.launcher.persistence.Persistence; import com.skcraft.launcher.swing.FormPanel; +import com.skcraft.launcher.swing.GroupedComboBox; import com.skcraft.launcher.swing.LinedBoxPanel; +import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.update.runtime.JavaVersionResolver; +import com.skcraft.launcher.update.runtime.ManagedRuntimeOption; +import com.skcraft.launcher.model.minecraft.runtime.RuntimePlatform; +import com.skcraft.launcher.util.Environment; import com.skcraft.launcher.util.SharedLocale; -import lombok.extern.java.Log; import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.filechooser.FileFilter; import java.awt.*; +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; -@Log public class InstanceSettingsDialog extends JDialog { + private static final Logger log = Logger.getLogger(InstanceSettingsDialog.class.getName()); + private static final ObjectMapper VERSION_MAPPER = new ObjectMapper(); + private static final int MAX_CONFIGURABLE_MEMORY = 65536; + + private final Launcher launcher; + private final Instance instance; private final InstanceSettings settings; private final LinedBoxPanel formsPanel = new LinedBoxPanel(false); private final FormPanel memorySettingsPanel = new FormPanel(); - private final JCheckBox enableMemorySettings = new JCheckBox(SharedLocale.tr("instance.options.customMemory")); private final JSpinner minMemorySpinner = new JSpinner(); private final JSpinner maxMemorySpinner = new JSpinner(); - private final JCheckBox enableCustomRuntime = new JCheckBox(SharedLocale.tr("instance.options.customJava")); private final FormPanel runtimePanel = new FormPanel(); - private final JComboBox javaRuntimeBox = new BetterComboBox<>(); - private final JTextField javaArgsBox = new JTextField(); + private JComboBox javaRuntimeBox; + private final JTextArea userJavaArgsText = new JTextArea(4, 30); + private final JScrollPane userJavaArgsScroll = new JScrollPane(userJavaArgsText); + private final JCheckBox modpackJvmArgsCheck = new JCheckBox( + SharedLocale.tr("instance.options.useModpackJvmArguments")); + private final JTextArea combinedJavaArgsText = new JTextArea(3, 30); private final LinedBoxPanel buttonsPanel = new LinedBoxPanel(true); private final JButton okButton = new JButton(SharedLocale.tr("button.save")); @@ -36,52 +66,252 @@ public class InstanceSettingsDialog extends JDialog { private boolean saved = false; - public InstanceSettingsDialog(Window owner, InstanceSettings settings) { + public InstanceSettingsDialog(Window owner, Launcher launcher, Instance instance) { super(owner); - this.settings = settings; + this.launcher = launcher; + this.instance = instance; + this.settings = getOrCreateSettings(instance); - setTitle(SharedLocale.tr("instance.options.title")); + setTitle(instance.getTitle() + " - " + SharedLocale.tr("instance.options.title")); setModalityType(DEFAULT_MODALITY_TYPE); initComponents(); - setSize(new Dimension(400, 500)); + setSize(new Dimension(480, 600)); setLocationRelativeTo(owner); } + private static InstanceSettings getOrCreateSettings(Instance instance) { + if (instance.getSettings() == null) { + instance.setSettings(new InstanceSettings()); + } + return instance.getSettings(); + } + private void initComponents() { - memorySettingsPanel.addRow(enableMemorySettings); + initJavaRuntimeBox(); + initMemorySpinners(); + initLayout(); + initActions(); + + updateComponents(); + } + + private void initMemorySpinners() { + int minMemoryCap = getMinimumConfigurableMemory(); + minMemorySpinner + .setModel(new SpinnerNumberModel(minMemoryCap, + minMemoryCap, MAX_CONFIGURABLE_MEMORY, 128)); + maxMemorySpinner + .setModel(new SpinnerNumberModel(Math.max(MemorySettings.DEFAULT_MAX_MEMORY, minMemoryCap), + minMemoryCap, MAX_CONFIGURABLE_MEMORY, 128)); + SwingHelper.enableSpinnerMouseWheel(minMemorySpinner, maxMemorySpinner); + SwingHelper.linkMinMaxSpinners(minMemorySpinner, maxMemorySpinner); + } + + private int getMinimumConfigurableMemory() { + LaunchModifier modifier = instance.getLaunchModifier(); + int requiredMinMemory = modifier != null ? modifier.getMinMemory() : 0; + return Math.max(MemorySettings.DEFAULT_MIN_MEMORY, requiredMinMemory); + } + + private void initJavaRuntimeBox() { + javaRuntimeBox = GroupedComboBox.create(createRuntimeModel(Collections.emptyList()), + this::formatRuntimeOptionLabel); + loadManagedRuntimesAsync(); + } + + private GroupedComboBox.Model createRuntimeModel(List managedOptions) { + GroupedComboBox.Model model = new GroupedComboBox.Model(); + model.addElement(null); + + Set managedDirs = new HashSet<>(); + for (ManagedRuntimeOption option : managedOptions) { + if (option.getRuntime() != null) { + managedDirs.add(option.getRuntime()); + } + } + + model.addGroup(SharedLocale.tr("instance.options.runtimeGroupManaged")); + for (ManagedRuntimeOption option : managedOptions) { + model.addElement(option); + } + if (settings.getManagedRuntimeComponent() != null + && !containsManagedComponent(model, settings.getManagedRuntimeComponent())) { + model.addElement(createSavedManagedRuntimeOption(settings.getManagedRuntimeComponent())); + } + + Set systemRuntimes = new HashSet<>(JavaRuntimeFinder.getAvailableRuntimes()); + systemRuntimes.removeAll(managedDirs); + model.addGroup(SharedLocale.tr("instance.options.runtimeGroupSystem")); + for (JavaRuntime javaRuntime : systemRuntimes.stream().sorted().toArray(JavaRuntime[]::new)) { + model.addElement(javaRuntime); + } + if (settings.getRuntime() != null && !containsRuntime(model, settings.getRuntime())) { + model.addElement(settings.getRuntime()); + } + model.addElement(AddJavaRuntime.ADD_RUNTIME_SENTINEL); + + return model; + } + + private void loadManagedRuntimesAsync() { + new SwingWorker, Void>() { + @Override + protected List doInBackground() throws Exception { + return launcher.getRuntimeManager().fetchManagedRuntimes(launcher.propUrl("runtimeManifestUrl")); + } + + @Override + protected void done() { + try { + Object selected = javaRuntimeBox.getSelectedItem(); + GroupedComboBox.Model model = createRuntimeModel(get()); + javaRuntimeBox.setModel(model); + javaRuntimeBox.setSelectedItem(findMatchingRuntimeSelection(model, selected)); + } catch (Exception e) { + SwingHelper.showErrorDialog(InstanceSettingsDialog.this, + SharedLocale.tr("instance.options.managedRuntimeLoadFailed"), + SharedLocale.tr("instance.options.managedRuntimeLoadFailedTitle"), e); + } + } + }.execute(); + } + + private Object findMatchingRuntimeSelection(ComboBoxModel model, Object selection) { + if (selection == null) { + return null; + } + + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (selection instanceof ManagedRuntimeOption && element instanceof ManagedRuntimeOption) { + String component = ((ManagedRuntimeOption) selection).getComponent(); + if (component.equals(((ManagedRuntimeOption) element).getComponent())) { + return element; + } + } else if (selection instanceof JavaRuntime && selection.equals(element)) { + return element; + } + } + + return selection; + } + + private String formatRuntimeOptionLabel(Object value) { + if (value instanceof GroupedComboBox.Group) { + return ((GroupedComboBox.Group) value).getLabel(); + } + if (value == null) { + return getAutomaticRuntimeLabel(); + } + if (value instanceof ManagedRuntimeOption) { + return ((ManagedRuntimeOption) value).getDisplayLabel(); + } + if (value instanceof AddJavaRuntime) { + return value.toString(); + } + if (value instanceof JavaRuntime) { + return formatSystemRuntimeLabel((JavaRuntime) value); + } + return String.valueOf(value); + } + + private String getAutomaticRuntimeLabel() { + JavaVersion requiredVersion = readRequiredJavaVersion(); + if (requiredVersion != null && requiredVersion.getMajorVersion() > 0) { + return SharedLocale.tr("instance.options.automaticRuntimeWithVersion", + ManagedRuntimeOption.formatJavaMajorVersion(requiredVersion.getMajorVersion())); + } + return SharedLocale.tr("instance.options.automaticRuntime"); + } + + private JavaVersion readRequiredJavaVersion() { + if (!instance.getVersionPath().isFile()) { + return null; + } + + try { + VersionManifest version = VERSION_MAPPER.readValue(instance.getVersionPath(), VersionManifest.class); + return JavaVersionResolver.resolve(launcher, instance, version); + } catch (Exception ignored) { + return null; + } + } + + private static String formatSystemRuntimeLabel(JavaRuntime runtime) { + String version = runtime.getVersion() != null ? runtime.getVersion() : "unknown"; + return ManagedRuntimeOption.formatLabel(version, runtime.is64Bit(), runtime.getDir().getAbsolutePath()); + } + + private static boolean containsRuntime(ComboBoxModel model, JavaRuntime runtime) { + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (!GroupedComboBox.isOption(element)) { + continue; + } + if (element instanceof JavaRuntime && runtime.equals(element)) { + return true; + } + } + return false; + } + + private ManagedRuntimeOption createSavedManagedRuntimeOption(String component) { + JavaVersion javaVersion = new JavaVersion(); + javaVersion.setComponent(component); + return launcher.getRuntimeManager().getRuntime(javaVersion) + .map(runtime -> new ManagedRuntimeOption( + component, runtime.getMajorVersion(), runtime.getVersion(), runtime.is64Bit(), true, runtime)) + .orElseGet(() -> { + RuntimePlatform platform = RuntimePlatform.from(Environment.getInstance()); + boolean is64Bit = platform != null && platform.is64Bit(); + return new ManagedRuntimeOption(component, 0, null, is64Bit, false, null); + }); + } + + private static boolean containsManagedComponent(ComboBoxModel model, String component) { + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (!GroupedComboBox.isOption(element)) { + continue; + } + if (element instanceof ManagedRuntimeOption + && component.equals(((ManagedRuntimeOption) element).getComponent())) { + return true; + } + } + return false; + } + + private static int indexOfAddRuntimeSentinel(ComboBoxModel model) { + for (int i = 0; i < model.getSize(); i++) { + if (model.getElementAt(i) == AddJavaRuntime.ADD_RUNTIME_SENTINEL) { + return i; + } + } + return model.getSize(); + } + + private void initLayout() { + memorySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.jvmRuntime")), javaRuntimeBox); memorySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.minMemory")), minMemorySpinner); memorySettingsPanel.addRow(new JLabel(SharedLocale.tr("options.maxMemory")), maxMemorySpinner); - // TODO: Do we keep this list centrally somewhere? Or is actively refreshing good? - JavaRuntime[] javaRuntimes = JavaRuntimeFinder.getAvailableRuntimes().toArray(new JavaRuntime[0]); - javaRuntimeBox.setModel(new DefaultComboBoxModel<>(javaRuntimes)); - - runtimePanel.addRow(enableCustomRuntime); - runtimePanel.addRow(new JLabel(SharedLocale.tr("options.jvmRuntime")), javaRuntimeBox); - runtimePanel.addRow(new JLabel(SharedLocale.tr("options.jvmArguments")), javaArgsBox); + runtimePanel.addRow(new JLabel(SharedLocale.tr("instance.options.userJvmArguments"))); + runtimePanel.addRow(userJavaArgsScroll); + runtimePanel.addRow(modpackJvmArgsCheck); + runtimePanel.addRow(new JLabel(SharedLocale.tr("instance.options.combinedJvmArguments"))); + runtimePanel.addRow(combinedJavaArgsText); okButton.setMargin(new Insets(0, 10, 0, 10)); buttonsPanel.addGlue(); buttonsPanel.addElement(okButton); buttonsPanel.addElement(cancelButton); - - enableMemorySettings.addActionListener(e -> { - if (enableMemorySettings.isSelected()) { - settings.setMemorySettings(new MemorySettings()); - } else { - settings.setMemorySettings(null); - } - - updateComponents(); - }); - - enableCustomRuntime.addActionListener(e -> { - runtimePanel.setEnabled(enableCustomRuntime.isSelected()); - }); + SwingHelper.alignButtonSizes(okButton, cancelButton); okButton.addActionListener(e -> { - save(); - dispose(); + if (save()) { + dispose(); + } }); cancelButton.addActionListener(e -> dispose()); @@ -92,57 +322,197 @@ private void initComponents() { add(formsPanel, BorderLayout.NORTH); add(buttonsPanel, BorderLayout.SOUTH); - updateComponents(); + initJvmArgsInputs(); + } + + private void initJvmArgsInputs() { + userJavaArgsText.setLineWrap(true); + userJavaArgsText.setWrapStyleWord(true); + + initEffectiveArgsPreview(); + } + + private void initEffectiveArgsPreview() { + combinedJavaArgsText.setEditable(false); + combinedJavaArgsText.setFocusable(false); + combinedJavaArgsText.setLineWrap(true); + combinedJavaArgsText.setWrapStyleWord(true); + combinedJavaArgsText.setFont(UIManager.getFont("Label.font")); + combinedJavaArgsText.setOpaque(false); + combinedJavaArgsText.setBorder(BorderFactory.createEmptyBorder()); + } + + private void initActions() { + userJavaArgsText.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + updateCombinedJvmArgs(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + updateCombinedJvmArgs(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + updateCombinedJvmArgs(); + } + }); + minMemorySpinner.addChangeListener(e -> updateCombinedJvmArgs()); + maxMemorySpinner.addChangeListener(e -> updateCombinedJvmArgs()); + modpackJvmArgsCheck.addActionListener(e -> updateCombinedJvmArgs()); + + javaRuntimeBox.addActionListener(e -> { + if (javaRuntimeBox.getSelectedItem() == AddJavaRuntime.ADD_RUNTIME_SENTINEL) { + javaRuntimeBox.setSelectedItem(getCurrentRuntimeSelection()); + javaRuntimeBox.setPopupVisible(false); + + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.FILES_ONLY); + chooser.setFileFilter(new JavaRuntimeFileFilter()); + chooser.setDialogTitle("Choose a Java executable"); + + int result = chooser.showOpenDialog(this); + if (result == JFileChooser.APPROVE_OPTION) { + JavaRuntime runtime = JavaRuntimeFinder + .getRuntimeFromPath(chooser.getSelectedFile().getAbsolutePath()); + + MutableComboBoxModel comboModel = (MutableComboBoxModel) javaRuntimeBox.getModel(); + comboModel.insertElementAt(runtime, indexOfAddRuntimeSentinel(comboModel)); + javaRuntimeBox.setSelectedItem(runtime); + } + } + }); } private void updateComponents() { - if (settings.getMemorySettings() != null) { - memorySettingsPanel.setEnabled(true); - enableMemorySettings.setSelected(true); + MemorySettings.Resolved memory = settings.getMemorySettings() == null + ? MemorySettings.resolve(instance) + : MemorySettings.normalize( + settings.getMemorySettings().getMinMemory(), + settings.getMemorySettings().getMaxMemory()); + int minMemory = Math.max(getMinimumConfigurableMemory(), memory.getMinMemory()); + minMemorySpinner.setValue(minMemory); + maxMemorySpinner.setValue(Math.max(minMemory, memory.getMaxMemory())); + javaRuntimeBox.setSelectedItem(getCurrentRuntimeSelection()); + userJavaArgsText.setText(settings.getCustomJvmArgs() != null ? settings.getCustomJvmArgs() : ""); + modpackJvmArgsCheck.setSelected(settings.isModpackJvmArgsEnabled()); + userJavaArgsText.setCaretPosition(0); + updateCombinedJvmArgs(); + } + + private void updateCombinedJvmArgs() { + combinedJavaArgsText.setText(getCombinedJvmArgsText()); + combinedJavaArgsText.setCaretPosition(0); + } - minMemorySpinner.setValue(settings.getMemorySettings().getMinMemory()); - maxMemorySpinner.setValue(settings.getMemorySettings().getMaxMemory()); - } else { - memorySettingsPanel.setEnabled(false); - enableMemorySettings.setSelected(false); + private String getCombinedJvmArgsText() { + List args = new ArrayList<>(); + int minMemory = getEffectiveMinMemory(); + int maxMemory = getEffectiveMaxMemory(minMemory); + + String userArgs = userJavaArgsText.getText(); + if (userArgs != null && !userArgs.trim().isEmpty()) { + args.addAll(JavaProcessBuilder.splitArgs(userArgs)); } - if (settings.getRuntime() != null) { - runtimePanel.setEnabled(true); - enableCustomRuntime.setSelected(true); - } else { - runtimePanel.setEnabled(false); - enableCustomRuntime.setSelected(false); + List flags = instance.getLaunchModifier() != null + ? instance.getLaunchModifier().getFlags() + : null; + if (modpackJvmArgsCheck.isSelected() && flags != null) { + args.addAll(flags); } - javaRuntimeBox.setSelectedItem(settings.getRuntime()); - javaArgsBox.setText(settings.getCustomJvmArgs()); + args.add("-Xms" + minMemory + "M"); + args.add("-Xmx" + maxMemory + "M"); + + return String.join(" ", args); } - private void save() { - if (enableMemorySettings.isSelected()) { - MemorySettings memorySettings = settings.getMemorySettings(); + private int getEffectiveMinMemory() { + int minMemory = (int) minMemorySpinner.getValue(); + return Math.max(getMinimumConfigurableMemory(), minMemory); + } - memorySettings.setMinMemory((int) minMemorySpinner.getValue()); - memorySettings.setMaxMemory((int) maxMemorySpinner.getValue()); - } else { - settings.setMemorySettings(null); + private int getEffectiveMaxMemory(int minMemory) { + int maxMemory = (int) maxMemorySpinner.getValue(); + return Math.max(minMemory, maxMemory); + } + + private Object getCurrentRuntimeSelection() { + if (settings.getManagedRuntimeComponent() != null) { + ComboBoxModel model = javaRuntimeBox.getModel(); + for (int i = 0; i < model.getSize(); i++) { + Object element = model.getElementAt(i); + if (element instanceof ManagedRuntimeOption + && settings.getManagedRuntimeComponent() + .equals(((ManagedRuntimeOption) element).getComponent())) { + return element; + } + } } - if (enableCustomRuntime.isSelected()) { - settings.setRuntime((JavaRuntime) javaRuntimeBox.getSelectedItem()); - settings.setCustomJvmArgs(javaArgsBox.getText()); - } else { + return settings.getRuntime(); + } + + private boolean save() { + int maxMemory = getEffectiveMaxMemory(getEffectiveMinMemory()); + int systemCap = MemoryRequirements.getPhysicalMemoryCapMb(); + if (maxMemory > systemCap) { + SwingHelper.showErrorDialog(this, + SharedLocale.tr("runner.instanceMemoryExceedsSystem", + instance.getTitle(), + MemorySettings.formatMemoryGb(maxMemory), + MemorySettings.formatMemoryGb(systemCap)), + SharedLocale.tr("launcher.insufficientSystemMemoryTitle")); + return false; + } + + MemorySettings memorySettings = settings.getMemorySettings(); + if (memorySettings == null) { + memorySettings = new MemorySettings(); + settings.setMemorySettings(memorySettings); + } + + int minMemory = getEffectiveMinMemory(); + memorySettings.setMinMemory(minMemory); + memorySettings.setMaxMemory(getEffectiveMaxMemory(minMemory)); + Object selectedRuntime = javaRuntimeBox.getSelectedItem(); + if (selectedRuntime == null) { + settings.setRuntime(null); + settings.setManagedRuntimeComponent(null); + } else if (selectedRuntime instanceof ManagedRuntimeOption) { settings.setRuntime(null); - settings.setCustomJvmArgs(null); + settings.setManagedRuntimeComponent(((ManagedRuntimeOption) selectedRuntime).getComponent()); + } else if (selectedRuntime instanceof JavaRuntime) { + settings.setRuntime((JavaRuntime) selectedRuntime); + settings.setManagedRuntimeComponent(null); } + String customJvmArgs = userJavaArgsText.getText().trim(); + settings.setCustomJvmArgs(customJvmArgs.isEmpty() ? null : customJvmArgs); + settings.setModpackJvmArgsEnabled(modpackJvmArgsCheck.isSelected()); saved = true; + return true; } - public static boolean open(Window parent, Instance instance) { - InstanceSettingsDialog dialog = new InstanceSettingsDialog(parent, instance.getSettings()); - dialog.setVisible(true); + public static boolean open(Window parent, Launcher launcher, Instance instance) { + if (!instance.isLocal()) { + return false; + } + + InstanceSettingsDialog dialog; + try { + dialog = new InstanceSettingsDialog(parent, launcher, instance); + dialog.setVisible(true); + } catch (Throwable t) { + log.log(Level.WARNING, "Failed to open instance settings for " + instance.getName(), t); + SwingHelper.showErrorDialog(parent, + SharedLocale.tr("errors.genericError"), + SharedLocale.tr("instance.options.title"), t); + return false; + } if (dialog.saved) { Persistence.commitAndForget(instance); @@ -150,4 +520,16 @@ public static boolean open(Window parent, Instance instance) { return dialog.saved; } + + static class JavaRuntimeFileFilter extends FileFilter { + @Override + public boolean accept(File f) { + return f.isDirectory() || f.getName().startsWith("java") && f.canExecute(); + } + + @Override + public String getDescription() { + return "Game Runtime executables"; + } + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java index 497d1aadf..2877cff38 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/LauncherFrame.java @@ -10,9 +10,12 @@ import com.skcraft.launcher.Instance; import com.skcraft.launcher.InstanceList; import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.browser.WebpagePanel; import com.skcraft.launcher.launch.LaunchListener; import com.skcraft.launcher.launch.LaunchOptions; import com.skcraft.launcher.launch.LaunchOptions.UpdatePolicy; +import com.skcraft.launcher.model.modpack.Manifest; +import com.skcraft.launcher.persistence.Persistence; import com.skcraft.launcher.swing.*; import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; @@ -22,16 +25,18 @@ import net.miginfocom.swing.MigLayout; import javax.swing.*; -import javax.swing.event.TableModelEvent; -import javax.swing.event.TableModelListener; +import javax.swing.event.ListSelectionEvent; +import javax.swing.event.ListSelectionListener; import java.awt.*; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; +import java.awt.event.KeyEvent; import java.awt.event.MouseEvent; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.io.File; import java.lang.ref.WeakReference; +import java.net.URL; import static com.skcraft.launcher.util.SharedLocale.tr; @@ -43,18 +48,25 @@ public class LauncherFrame extends JFrame { private final Launcher launcher; + private static final String INSTANCES_LIST_CARD = "list"; + private static final String INSTANCES_EMPTY_CARD = "empty"; + @Getter private final InstanceTable instancesTable = new InstanceTable(); private final InstanceTableModel instancesModel; @Getter private final JScrollPane instanceScroll = new JScrollPane(instancesTable); + private final JPanel instancesPanel = new JPanel(new CardLayout()); private WebpagePanel webView; - private JSplitPane splitPane; + private URL lastLoggedNewsUrl; private final JButton launchButton = new JButton(SharedLocale.tr("launcher.launch")); private final JButton refreshButton = new JButton(SharedLocale.tr("launcher.checkForUpdates")); + private final AccountSwitcher accountSwitcher; private final JButton optionsButton = new JButton(SharedLocale.tr("launcher.options")); private final JButton selfUpdateButton = new JButton(SharedLocale.tr("launcher.updateLauncher")); private final JCheckBox updateCheck = new JCheckBox(SharedLocale.tr("launcher.downloadUpdates")); + private boolean initialInstanceLoad = true; + private boolean instancesLoaded = false; /** * Create a new frame. @@ -65,10 +77,11 @@ public LauncherFrame(@NonNull Launcher launcher) { super(tr("launcher.title", launcher.getVersion())); this.launcher = launcher; - instancesModel = new InstanceTableModel(launcher.getInstances()); + this.accountSwitcher = new AccountSwitcher(launcher.getAccounts()); + instancesModel = new InstanceTableModel(launcher); setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); - setMinimumSize(new Dimension(400, 300)); + setMinimumSize(new Dimension(520, 300)); initComponents(); pack(); setLocationRelativeTo(null); @@ -85,10 +98,31 @@ public void run() { private void initComponents() { JPanel container = createContainerPanel(); - container.setLayout(new MigLayout("fill, insets dialog", "[][]push[][]", "[grow][]")); + container.setLayout(new MigLayout("fill, insets 0 11 11 11", "[grow]", "[grow][]")); webView = createNewsPanel(); - splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, instanceScroll, webView); + instanceScroll.setBorder(BorderFactory.createEmptyBorder()); + instanceScroll.setViewportBorder(null); + instancesPanel.setBorder(null); + instancesPanel.setOpaque(false); + instancesPanel.add(instanceScroll, INSTANCES_LIST_CARD); + instancesPanel.add(createNoInstancesPanel(), INSTANCES_EMPTY_CARD); + // Border lives on the outer panel; keep the SWT view borderless. + webView.setBrowserBorder(BorderFactory.createEmptyBorder()); + + JPanel leftColumn = new TableChromePanel(new MigLayout("ins 0, fill, gap 0", "[grow, fill]", "[grow, fill][]")); + leftColumn.setBorder(SwingHelper.uiLineBorder()); + leftColumn.add(instancesPanel, "grow, wrap"); + leftColumn.add(accountSwitcher, "growx"); + + JPanel newsPanel = new TableChromePanel(new BorderLayout()); + newsPanel.setBorder(SwingHelper.uiLineBorder()); + newsPanel.add(webView, BorderLayout.CENTER); + + JPanel contentPanel = new JPanel(new MigLayout("ins 0, fill", "[200!][grow, fill]", "[grow, fill]")); + contentPanel.setPreferredSize(new Dimension(680, 350)); + contentPanel.add(leftColumn, "grow"); + contentPanel.add(newsPanel, "grow"); selfUpdateButton.setVisible(launcher.getUpdateManager().getPendingUpdate()); launcher.getUpdateManager().addPropertyChangeListener(new PropertyChangeListener() { @@ -103,25 +137,38 @@ public void propertyChange(PropertyChangeEvent evt) { updateCheck.setSelected(true); instancesTable.setModel(instancesModel); + instancesModel.addTableModelListener(e -> updateInstancesPlaceholder()); launchButton.setFont(launchButton.getFont().deriveFont(Font.BOLD)); - splitPane.setDividerLocation(200); - splitPane.setDividerSize(4); - splitPane.setOpaque(false); - container.add(splitPane, "grow, wrap, span 5, gapbottom unrel, w null:680, h null:350"); - SwingHelper.flattenJSplitPane(splitPane); - container.add(refreshButton); - container.add(updateCheck); - container.add(selfUpdateButton); - container.add(optionsButton); - container.add(launchButton); + launchButton.putClientProperty("FlatLaf.styleClass", "primary"); + bindEnterToLaunch(); + container.add(contentPanel, "grow, wrap, gapbottom unrel"); + + JPanel bottomBar = new JPanel(new BorderLayout()); + bottomBar.setOpaque(false); + + JPanel bottomLeft = new JPanel(new MigLayout("ins 0", "[][]", "[]")); + bottomLeft.setOpaque(false); + bottomLeft.add(refreshButton); + bottomLeft.add(updateCheck, "grow 0"); + + JPanel bottomRight = new JPanel(new MigLayout("ins 0, hidemode 3", "[][][]", "[]")); + bottomRight.setOpaque(false); + bottomRight.add(selfUpdateButton); + bottomRight.add(optionsButton); + bottomRight.add(launchButton); + + bottomBar.add(bottomLeft, BorderLayout.WEST); + bottomBar.add(bottomRight, BorderLayout.EAST); + container.add(bottomBar, "growx"); + getRootPane().setDefaultButton(launchButton); add(container, BorderLayout.CENTER); - instancesModel.addTableModelListener(new TableModelListener() { + instancesTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() { @Override - public void tableChanged(TableModelEvent e) { - if (instancesTable.getRowCount() > 0) { - instancesTable.setRowSelectionInterval(0, 0); + public void valueChanged(ListSelectionEvent e) { + if (!e.getValueIsAdjusting() && instancesTable.getSelectedRow() >= 0) { + updateNewsPanel(); } } }); @@ -133,7 +180,6 @@ public void tableChanged(TableModelEvent e) { public void actionPerformed(ActionEvent e) { loadInstances(); launcher.getUpdateManager().checkForUpdate(LauncherFrame.this); - webView.browse(launcher.getNewsURL(), false); } }); @@ -144,6 +190,14 @@ public void actionPerformed(ActionEvent e) { } }); + accountSwitcher.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + AccountSelectDialog.showManageAccounts(LauncherFrame.this, launcher); + accountSwitcher.refresh(); + } + }); + optionsButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { @@ -182,117 +236,221 @@ protected JPanel createContainerPanel() { * @return the news panel */ protected WebpagePanel createNewsPanel() { - return WebpagePanel.forURL(launcher.getNewsURL(), false); + try { + return new WebpagePanel(); + } catch (LinkageError e) { + log.warning("SWT Browser is unavailable; embedded news panel disabled"); + return WebpagePanel.missingBrowser(); + } catch (RuntimeException e) { + log.warning("SWT Browser failed to initialize; embedded news panel disabled"); + return WebpagePanel.missingBrowser(); + } + } + + private JPanel createNoInstancesPanel() { + final JLabel label = new JLabel(SharedLocale.tr("launcher.noInstances")); + label.setHorizontalAlignment(SwingConstants.CENTER); + + JPanel panel = new TableChromePanel(new GridBagLayout()) { + @Override + public void updateUI() { + super.updateUI(); + if (label.getParent() == this) { + Color muted = UIManager.getColor("Label.disabledForeground"); + if (muted != null) { + label.setForeground(muted); + } + } + } + }; + panel.add(label); + Color muted = UIManager.getColor("Label.disabledForeground"); + if (muted != null) { + label.setForeground(muted); + } + return panel; + } + + private void updateInstancesPlaceholder() { + CardLayout layout = (CardLayout) instancesPanel.getLayout(); + boolean showEmpty = instancesLoaded && instancesModel.getRowCount() == 0; + layout.show(instancesPanel, showEmpty ? INSTANCES_EMPTY_CARD : INSTANCES_LIST_CARD); + } + + private void bindEnterToLaunch() { + final String launchActionKey = "launchSelectedInstance"; + Action launchAction = new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + if (launchButton.isEnabled()) { + launchButton.doClick(); + } + } + }; + + instancesTable.getActionMap().put(launchActionKey, launchAction); + KeyStroke enter = KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0); + instancesTable.getInputMap(JComponent.WHEN_FOCUSED).put(enter, launchActionKey); + instancesTable.getInputMap(JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT).put(enter, launchActionKey); + } + + private void updateNewsPanel() { + updateNewsPanel(false); + } + + private void updateNewsPanel(boolean forceReload) { + if (webView == null) { + return; + } + + Instance instance = getSelectedInstance(); + URL newsUrl = launcher.getNewsURL(instance); + if (!newsUrl.equals(lastLoggedNewsUrl)) { + log.info("Loading news from " + newsUrl); + lastLoggedNewsUrl = newsUrl; + } + webView.browse(newsUrl, !forceReload); + } + + private Instance getSelectedInstance() { + int selectedRow = instancesTable.getSelectedRow(); + if (selectedRow < 0) { + return null; + } + + int modelRow = instancesTable.convertRowIndexToModel(selectedRow); + if (modelRow < 0 || modelRow >= launcher.getInstances().size()) { + return null; + } + + return launcher.getInstances().get(modelRow); + } + + private void restoreInstanceSelection(String selectedName) { + if (selectedName != null) { + for (int i = 0; i < launcher.getInstances().size(); i++) { + if (launcher.getInstances().get(i).getName().equalsIgnoreCase(selectedName)) { + int viewRow = instancesTable.convertRowIndexToView(i); + if (viewRow >= 0) { + instancesTable.setRowSelectionInterval(viewRow, viewRow); + return; + } + } + } + } } /** * Popup the menu for the instances. * * @param component the component - * @param x mouse X - * @param y mouse Y - * @param selected the selected instance, possibly null + * @param x mouse X + * @param y mouse Y + * @param selected the selected instance, possibly null */ private void popupInstanceMenu(Component component, int x, int y, final Instance selected) { + if (selected == null) { + return; + } + JPopupMenu popup = new JPopupMenu(); JMenuItem menuItem; - if (selected != null) { - menuItem = new JMenuItem(!selected.isLocal() ? tr("instance.install") : tr("instance.launch")); + menuItem = new JMenuItem(!selected.isLocal() ? tr("instance.install") : tr("instance.launch")); + menuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + launch(); + } + }); + popup.add(menuItem); + + if (selected.isLocal()) { + popup.addSeparator(); + + menuItem = new JMenuItem(SharedLocale.tr("instance.openFolder")); + menuItem.addActionListener(ActionListeners.browseDir( + LauncherFrame.this, selected.getContentDir(), true)); + popup.add(menuItem); + + menuItem = new JMenuItem(SharedLocale.tr("instance.openSaves")); + menuItem.addActionListener(ActionListeners.browseDir( + LauncherFrame.this, new File(selected.getContentDir(), "saves"), true)); + popup.add(menuItem); + + menuItem = new JMenuItem(SharedLocale.tr("instance.openResourcePacks")); + menuItem.addActionListener(ActionListeners.browseDir( + LauncherFrame.this, new File(selected.getContentDir(), "resourcepacks"), true)); + popup.add(menuItem); + + menuItem = new JMenuItem(SharedLocale.tr("instance.openScreenshots")); + menuItem.addActionListener(ActionListeners.browseDir( + LauncherFrame.this, new File(selected.getContentDir(), "screenshots"), true)); + popup.add(menuItem); + + menuItem = new JMenuItem(SharedLocale.tr("instance.copyAsPath")); menuItem.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { - launch(); + File dir = selected.getContentDir(); + dir.mkdirs(); + SwingHelper.setClipboard(dir.getAbsolutePath()); } }); popup.add(menuItem); - if (selected.isLocal()) { - popup.addSeparator(); - - menuItem = new JMenuItem(SharedLocale.tr("instance.openFolder")); - menuItem.addActionListener(ActionListeners.browseDir( - LauncherFrame.this, selected.getContentDir(), true)); - popup.add(menuItem); - - menuItem = new JMenuItem(SharedLocale.tr("instance.openSaves")); - menuItem.addActionListener(ActionListeners.browseDir( - LauncherFrame.this, new File(selected.getContentDir(), "saves"), true)); - popup.add(menuItem); - - menuItem = new JMenuItem(SharedLocale.tr("instance.openResourcePacks")); - menuItem.addActionListener(ActionListeners.browseDir( - LauncherFrame.this, new File(selected.getContentDir(), "resourcepacks"), true)); - popup.add(menuItem); + menuItem = new JMenuItem(SharedLocale.tr("instance.openSettings")); + menuItem.addActionListener(e -> { + InstanceSettingsDialog.open(this, launcher, selected); + }); + popup.add(menuItem); - menuItem = new JMenuItem(SharedLocale.tr("instance.openScreenshots")); - menuItem.addActionListener(ActionListeners.browseDir( - LauncherFrame.this, new File(selected.getContentDir(), "screenshots"), true)); - popup.add(menuItem); + popup.addSeparator(); - menuItem = new JMenuItem(SharedLocale.tr("instance.copyAsPath")); + if (instanceHasFeatures(selected)) { + menuItem = new JMenuItem(SharedLocale.tr("instance.selectFeatures")); menuItem.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { - File dir = selected.getContentDir(); - dir.mkdirs(); - SwingHelper.setClipboard(dir.getAbsolutePath()); + selected.setUpdatePending(true); + launch(true, true); + instancesModel.update(); } }); popup.add(menuItem); + } - menuItem = new JMenuItem(SharedLocale.tr("instance.openSettings")); - menuItem.addActionListener(e -> { - InstanceSettingsDialog.open(this, selected); - }); - popup.add(menuItem); - - popup.addSeparator(); - - if (!selected.isUpdatePending()) { - menuItem = new JMenuItem(SharedLocale.tr("instance.forceUpdate")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - selected.setUpdatePending(true); - launch(); - instancesModel.update(); - } - }); - popup.add(menuItem); + menuItem = new JMenuItem(SharedLocale.tr("instance.verifyFiles")); + menuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + new File(selected.getDir(), "update_cache.json").delete(); + selected.setUpdatePending(true); + launch(false, true); + instancesModel.update(); } + }); + popup.add(menuItem); - menuItem = new JMenuItem(SharedLocale.tr("instance.hardForceUpdate")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - confirmHardUpdate(selected); - } - }); - popup.add(menuItem); - - menuItem = new JMenuItem(SharedLocale.tr("instance.deleteFiles")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - confirmDelete(selected); - } - }); - popup.add(menuItem); - } + menuItem = new JMenuItem(SharedLocale.tr("instance.reinstallMods")); + menuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + confirmHardUpdate(selected); + } + }); + popup.add(menuItem); - popup.addSeparator(); + menuItem = new JMenuItem(SharedLocale.tr("instance.deleteFiles")); + menuItem.addActionListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent e) { + confirmDelete(selected); + } + }); + popup.add(menuItem); } - menuItem = new JMenuItem(SharedLocale.tr("launcher.refreshList")); - menuItem.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - loadInstances(); - } - }); - popup.add(menuItem); - popup.show(component, x, y); } @@ -315,7 +473,8 @@ public void run() { } private void confirmHardUpdate(Instance instance) { - if (!SwingHelper.confirmDialog(this, SharedLocale.tr("instance.confirmHardUpdate"), SharedLocale.tr("confirmTitle"))) { + if (!SwingHelper.confirmDialog(this, SharedLocale.tr("instance.confirmReinstallMods"), + SharedLocale.tr("confirmTitle"))) { return; } @@ -325,43 +484,86 @@ private void confirmHardUpdate(Instance instance) { future.addListener(new Runnable() { @Override public void run() { - launch(); + launch(false, true); instancesModel.update(); } }, SwingExecutor.INSTANCE); } + private boolean instanceHasFeatures(Instance instance) { + File manifestPath = instance.getManifestPath(); + if (!manifestPath.isFile()) { + return false; + } + + Manifest manifest = Persistence.read(manifestPath, Manifest.class); + return manifest.getFeatures() != null && !manifest.getFeatures().isEmpty(); + } + private void loadInstances() { + String selectedNameToRestore = null; + Instance selected = getSelectedInstance(); + if (selected != null) { + selectedNameToRestore = selected.getName(); + } else if (initialInstanceLoad) { + selectedNameToRestore = launcher.getConfig().getLastInstance(); + } + final String selectedName = selectedNameToRestore; + ObservableFuture future = launcher.getInstanceTasks().reloadInstances(this); future.addListener(new Runnable() { @Override public void run() { + instancesLoaded = true; instancesModel.update(); - if (instancesTable.getRowCount() > 0) { - instancesTable.setRowSelectionInterval(0, 0); - } + instancesTable.clearSelection(); + restoreInstanceSelection(selectedName); + initialInstanceLoad = false; + updateNewsPanel(true); requestFocus(); } }, SwingExecutor.INSTANCE); - - ProgressDialog.showProgress(this, future, SharedLocale.tr("launcher.checkingTitle"), SharedLocale.tr("launcher.checkingStatus")); - SwingHelper.addErrorDialogCallback(this, future); } private void showOptions() { ConfigurationDialog configDialog = new ConfigurationDialog(this, launcher); configDialog.setVisible(true); + if (configDialog.isGameKeyChanged()) { + loadInstances(); + } + } + + /** + * Dispose the frame, tearing down the embedded browser first so the native + * engine is released no matter how the window is closed. + */ + @Override + public void dispose() { + if (webView != null) { + webView.disposeBrowser(); + webView = null; + } + super.dispose(); } private void launch() { - boolean permitUpdate = updateCheck.isSelected(); - Instance instance = launcher.getInstances().get(instancesTable.getSelectedRow()); + launch(false, updateCheck.isSelected()); + } + + private void launch(boolean reselectFeatures, boolean permitUpdate) { + Instance instance = getSelectedInstance(); + if (instance == null) { + SwingHelper.showErrorDialog(this, SharedLocale.tr("launcher.noInstanceError"), + SharedLocale.tr("launcher.noInstanceTitle")); + return; + } LaunchOptions options = new LaunchOptions.Builder() .setInstance(instance) .setListener(new LaunchListenerImpl(this)) - .setUpdatePolicy(permitUpdate ? UpdatePolicy.UPDATE_IF_SESSION_ONLINE : UpdatePolicy.NO_UPDATE) + .setUpdatePolicy(permitUpdate ? UpdatePolicy.ALWAYS_UPDATE : UpdatePolicy.NO_UPDATE) + .setReselectFeatures(reselectFeatures) .setWindow(this) .build(); launcher.getLaunchSupervisor().launch(options); diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java index e99e050dd..a4c12c7c5 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/LoginDialog.java @@ -94,6 +94,7 @@ private void initComponents() { buttonsPanel.addGlue(); buttonsPanel.addElement(loginButton); buttonsPanel.addElement(cancelButton); + SwingHelper.alignButtonSizes(loginButton, cancelButton); add(formPanel, BorderLayout.CENTER); add(buttonsPanel, BorderLayout.SOUTH); diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/MicrosoftLoginDialog.java b/launcher/src/main/java/com/skcraft/launcher/dialog/MicrosoftLoginDialog.java new file mode 100644 index 000000000..a98bb7e23 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/MicrosoftLoginDialog.java @@ -0,0 +1,576 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.dialog; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.skcraft.concurrency.SettableProgress; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.auth.MicrosoftLoginService; +import com.skcraft.launcher.auth.Session; +import com.skcraft.launcher.swing.LinkButton; +import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.util.QrCodes; +import com.skcraft.launcher.util.SharedLocale; +import com.skcraft.launcher.util.SwingExecutor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.miginfocom.swing.MigLayout; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.event.KeyEvent; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.awt.geom.Path2D; +import java.awt.image.BufferedImage; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import static com.skcraft.launcher.util.SharedLocale.tr; + +/** + * Modal dialog for Microsoft sign-in. + * + *

+ * Offers browser-redirect sign-in as the primary action, plus a device-code + * path (QR + code) for mobile or alternate devices. Polls until the device-code + * flow completes, or returns a fallback request for redirect auth. + *

+ */ +public class MicrosoftLoginDialog extends JDialog { + + private static final int QR_SIZE = 144; + private static final int QR_PADDING = 2; + private static final int CODE_FIELD_FONT_SIZE = 32; + private static final int COPY_ICON_SIZE = 18; + private static final Icon COPY_ICON = createCopyIcon(COPY_ICON_SIZE); + private static final Icon COPIED_ICON = createCopiedIcon(COPY_ICON_SIZE); + + private final Launcher launcher; + private final MicrosoftLoginService.DeviceCodeDetails details; + private final String authUrl; + private final String verificationUri; + + private final JButton copyCodeButton = createCopyIconButton(); + private final JButton fallbackButton = new JButton(tr("login.microsoft.device.useBrowserFallback")); + private final JButton cancelButton = new JButton(tr("button.cancel")); + private final JTextField codeField; + private final JLabel statusLabel = new JLabel(tr("login.microsoft.device.awaiting")); + private final JLabel countdownLabel = new JLabel(" "); + private final JLabel qrLabel = new JLabel(); + private final QrSpinnerPanel qrSpinnerPanel = new QrSpinnerPanel(QR_SIZE); + private final JPanel qrContentPanel = new JPanel(new CardLayout()); + private JPanel qrColumn; + private final JProgressBar pollIndicator = new JProgressBar(); + + private final Timer countdownTimer; + private Timer copyResetTimer; + private ListenableFuture pollFuture; + + @Getter + private Outcome outcome = Outcome.cancelled(); + private boolean codeExpired; + + private MicrosoftLoginDialog(Window owner, Launcher launcher, MicrosoftLoginService.DeviceCodeDetails details) { + super(owner, tr("login.microsoft.device.dialogTitle"), ModalityType.DOCUMENT_MODAL); + this.launcher = launcher; + this.details = details; + this.authUrl = details.getVerificationUriComplete() != null + ? details.getVerificationUriComplete() + : details.getVerificationUri(); + this.verificationUri = details.getVerificationUri() != null + ? details.getVerificationUri() + : authUrl; + this.codeField = new JTextField(details.getUserCode()); + this.countdownTimer = new Timer(1000, ev -> updateCountdown()); + this.countdownTimer.setInitialDelay(0); + + initComponents(); + updateCountdown(); + + setDefaultCloseOperation(WindowConstants.DO_NOTHING_ON_CLOSE); + addWindowListener(new WindowAdapter() { + @Override + public void windowOpened(WindowEvent event) { + countdownTimer.start(); + if (authUrl != null && !authUrl.isEmpty()) { + qrSpinnerPanel.start(); + } + startPolling(); + loadQrCode(); + } + + @Override + public void windowClosing(WindowEvent event) { + handleCancel(); + } + }); + + pack(); + setMinimumSize(getSize()); + setResizable(false); + setLocationRelativeTo(owner); + } + + private void initComponents() { + JPanel content = new JPanel(new MigLayout( + "insets 18 22 16 22, wrap 1, fillx, hidemode 3", + "[grow, fill]")); + + JLabel titleLabel = new JLabel(tr("login.microsoft.device.title")); + titleLabel.setFont(titleLabel.getFont().deriveFont(Font.BOLD, titleLabel.getFont().getSize2D() + 4f)); + + JLabel subtitleLabel = new JLabel(tr("login.microsoft.device.subtitle")); + subtitleLabel.setForeground(SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY)); + + content.add(titleLabel, "gapbottom 2"); + content.add(subtitleLabel, "gapbottom 16"); + + fallbackButton.setFont(fallbackButton.getFont().deriveFont(Font.BOLD, fallbackButton.getFont().getSize2D() + 1f)); + fallbackButton.setMargin(new Insets(12, 18, 12, 18)); + fallbackButton.putClientProperty("FlatLaf.styleClass", "primary"); + content.add(fallbackButton, "growx, hmin 44, gapbottom 16"); + + content.add(buildOrDivider(tr("login.microsoft.device.deviceHint")), "growx, gapbottom 16"); + content.add(buildDeviceCodePanel(), "alignx center, gapbottom 14"); + + pollIndicator.setIndeterminate(true); + pollIndicator.setPreferredSize(new Dimension(18, 18)); + statusLabel.setFont(statusLabel.getFont().deriveFont(Font.BOLD)); + countdownLabel.setForeground(SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY)); + + JPanel statusRow = new JPanel(new MigLayout("insets 0, gap 10", "[][grow, fill][]")); + statusRow.add(pollIndicator, "w 18!, h 18!"); + statusRow.add(statusLabel, "growx"); + statusRow.add(countdownLabel, ""); + content.add(statusRow, "growx"); + + JPanel buttonBar = new JPanel(new MigLayout("insets 12 22 16 22, fillx", "[push][]")); + buttonBar.add(cancelButton, "tag cancel"); + + setLayout(new BorderLayout()); + add(content, BorderLayout.CENTER); + add(buttonBar, BorderLayout.SOUTH); + + SwingHelper.styleDialogButton(copyCodeButton); + SwingHelper.styleDialogButton(fallbackButton); + SwingHelper.styleDialogButton(cancelButton); + + copyCodeButton.addActionListener(ev -> handleCopy()); + fallbackButton.addActionListener(ev -> handleFallback()); + cancelButton.addActionListener(ev -> handleCancel()); + + getRootPane().setDefaultButton(fallbackButton); + getRootPane().registerKeyboardAction(ev -> handleCancel(), + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), + JComponent.WHEN_IN_FOCUSED_WINDOW); + } + + private JPanel buildOrDivider(String text) { + Color mutedColor = SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY); + JLabel label = new JLabel(text); + label.setForeground(mutedColor); + + JPanel panel = new JPanel(new MigLayout("insets 0, fillx, gap 10", "[grow][][grow]")); + panel.add(new JSeparator(), "growx"); + panel.add(label, ""); + panel.add(new JSeparator(), "growx"); + return panel; + } + + private JPanel buildDeviceCodePanel() { + Color mutedColor = SwingHelper.uiColor("Label.disabledForeground", Color.DARK_GRAY); + + codeField.setEditable(false); + codeField.setHorizontalAlignment(SwingConstants.CENTER); + codeField.setFont(new Font(Font.MONOSPACED, Font.BOLD, CODE_FIELD_FONT_SIZE)); + codeField.setColumns(Math.max(8, details.getUserCode().length() + 1)); + codeField.setBorder(new EmptyBorder(10, 16, 10, 8)); + Color codeBorderColor = SwingHelper.uiColor("Component.borderColor", new Color(160, 160, 160)); + Color codeBackground = SwingHelper.uiColor("TextField.background", Color.WHITE); + codeField.setBackground(codeBackground); + JPanel codeBox = new JPanel(new BorderLayout(0, 0)); + codeBox.setBackground(codeBackground); + codeBox.setBorder(BorderFactory.createLineBorder(codeBorderColor)); + copyCodeButton.setBackground(codeBackground); + codeBox.add(codeField, BorderLayout.CENTER); + codeBox.add(copyCodeButton, BorderLayout.EAST); + + JLabel prefix = new JLabel(tr("login.microsoft.device.browserHintPrefix")); + prefix.setForeground(mutedColor); + + String linkText = verificationUri != null ? verificationUri : ""; + LinkButton link = new LinkButton(linkText); + link.setFont(prefix.getFont()); + link.addActionListener(ev -> { + if (verificationUri != null && !verificationUri.isEmpty()) { + SwingHelper.openURL(verificationUri, this); + } + }); + + JPanel openRow = new JPanel(new FlowLayout(FlowLayout.LEADING, 0, 0)); + openRow.setOpaque(false); + openRow.add(prefix); + openRow.add(link); + + int rightWidth = Math.max(openRow.getPreferredSize().width, codeBox.getPreferredSize().width); + + JTextArea suffix = new JTextArea(tr("login.microsoft.device.browserHintSuffix")); + suffix.setEditable(false); + suffix.setFocusable(false); + suffix.setOpaque(false); + suffix.setBorder(null); + suffix.setLineWrap(true); + suffix.setWrapStyleWord(true); + suffix.setFont(prefix.getFont()); + suffix.setForeground(mutedColor); + + JPanel instructions = new JPanel(new MigLayout("insets 0, wrap 1, gapy 2", "[" + rightWidth + "!]")); + instructions.add(openRow, "growx"); + instructions.add(suffix, "growx, w " + rightWidth + "!"); + + JPanel right = new JPanel(new MigLayout("insets 0, wrap 1, gapy 10", "[" + rightWidth + "!]")); + right.add(instructions, "growx"); + right.add(codeBox, "alignx left"); + + qrLabel.setHorizontalAlignment(SwingConstants.CENTER); + qrLabel.setVerticalAlignment(SwingConstants.CENTER); + qrLabel.setOpaque(true); + qrLabel.setBackground(Color.WHITE); + qrLabel.setBorder(BorderFactory.createEmptyBorder(QR_PADDING, QR_PADDING, QR_PADDING, QR_PADDING)); + + qrContentPanel.add(qrSpinnerPanel, "spinner"); + qrContentPanel.add(qrLabel, "qr"); + + qrColumn = new JPanel(new MigLayout("insets 0", "[" + QR_SIZE + "!]", "[" + QR_SIZE + "!]")); + qrColumn.add(qrContentPanel, "w " + QR_SIZE + "!, h " + QR_SIZE + "!"); + qrColumn.setVisible(authUrl != null && !authUrl.isEmpty()); + + JPanel panel = new JPanel(new MigLayout("insets 0, hidemode 3", "[]16[]", "[center]")); + panel.add(qrColumn, "center"); + panel.add(right, "center"); + return panel; + } + + private void startPolling() { + pollFuture = launcher.getExecutor().submit(() -> launcher.getMicrosoftLogin() + .loginWithDeviceCode(details, () -> SwingUtilities.invokeLater(() -> { + statusLabel.setText(tr("login.loggingInStatus")); + countdownLabel.setText(" "); + countdownTimer.stop(); + }))); + + Futures.addCallback(pollFuture, new FutureCallback() { + @Override + public void onSuccess(Session session) { + if (session == null) { + handleCancel(); + return; + } + + outcome = Outcome.success(session); + closeDialog(); + } + + @Override + public void onFailure(Throwable t) { + if (codeExpired || pollFuture.isCancelled() || t instanceof CancellationException) { + return; + } + + closeDialog(); + SwingHelper.showErrorDialog(getOwner(), t.getLocalizedMessage(), tr("errorTitle"), t); + } + }, SwingExecutor.INSTANCE); + } + + private void loadQrCode() { + if (authUrl == null || authUrl.isEmpty()) { + return; + } + + SwingWorker worker = new SwingWorker() { + @Override + protected BufferedImage doInBackground() { + return QrCodes.generate(authUrl, QR_SIZE - 2 * QR_PADDING); + } + + @Override + protected void done() { + try { + BufferedImage image = get(); + if (image != null) { + qrLabel.setIcon(new ImageIcon(image)); + qrSpinnerPanel.stop(); + ((CardLayout) qrContentPanel.getLayout()).show(qrContentPanel, "qr"); + pack(); + } else { + hideQrOption(); + } + } catch (InterruptedException | ExecutionException ignored) { + hideQrOption(); + } + } + }; + worker.execute(); + } + + private void updateCountdown() { + long remaining = details.getExpiresAt() - System.currentTimeMillis(); + if (remaining <= 0) { + if (!codeExpired) { + handleCodeExpired(); + } + return; + } + + long totalSeconds = remaining / 1000L; + long minutes = totalSeconds / 60L; + long seconds = totalSeconds % 60L; + String formatted = String.format("%d:%02d", minutes, seconds); + countdownLabel.setText(tr("login.microsoft.device.timeRemaining", formatted)); + } + + private void handleCodeExpired() { + codeExpired = true; + countdownTimer.stop(); + countdownLabel.setText(tr("login.microsoft.device.timeExpired")); + countdownLabel.setForeground(SwingHelper.uiColor("Component.error.focusedBorderColor", Color.RED.darker())); + statusLabel.setText(tr("login.microsoft.device.expiredStatus")); + pollIndicator.setIndeterminate(false); + pollIndicator.setVisible(false); + if (pollFuture != null && !pollFuture.isDone()) { + pollFuture.cancel(true); + } + } + + private void handleCopy() { + SwingHelper.setClipboard(details.getUserCode()); + + copyCodeButton.setIcon(COPIED_ICON); + copyCodeButton.setToolTipText(tr("login.microsoft.device.copied")); + if (copyResetTimer != null && copyResetTimer.isRunning()) { + copyResetTimer.stop(); + } + copyResetTimer = new Timer(1600, ev -> { + copyCodeButton.setIcon(COPY_ICON); + copyCodeButton.setToolTipText(tr("login.microsoft.device.copy")); + }); + copyResetTimer.setRepeats(false); + copyResetTimer.start(); + } + + private void handleFallback() { + outcome = Outcome.fallback(); + closeDialog(); + } + + private void handleCancel() { + outcome = Outcome.cancelled(); + closeDialog(); + } + + private void hideQrOption() { + qrSpinnerPanel.stop(); + if (qrColumn != null) { + qrColumn.setVisible(false); + } + pack(); + } + + private void closeDialog() { + countdownTimer.stop(); + qrSpinnerPanel.stop(); + if (copyResetTimer != null) { + copyResetTimer.stop(); + } + if (pollFuture != null && !pollFuture.isDone()) { + pollFuture.cancel(true); + } + dispose(); + } + + private static JButton createCopyIconButton() { + JButton button = new JButton(COPY_ICON); + button.setToolTipText(tr("login.microsoft.device.copy")); + button.setFocusable(false); + button.setFocusPainted(false); + button.setContentAreaFilled(false); + button.setBorderPainted(false); + button.setOpaque(false); + button.setMargin(new Insets(0, 0, 0, 0)); + button.setBorder(new EmptyBorder(0, 4, 0, 12)); + return button; + } + + private static Icon createCopyIcon(int size) { + return paintIcon(size, g -> { + Color stroke = new Color(55, 55, 55); + g.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + int pad = Math.max(2, size / 9); + int w = size - pad * 2 - 3; + int h = size - pad * 2 - 3; + int arc = 3; + g.setColor(stroke); + g.drawRoundRect(pad + 3, pad, w, h, arc, arc); + g.setColor(new Color(240, 240, 240)); + g.fillRoundRect(pad, pad + 3, w, h, arc, arc); + g.setColor(stroke); + g.drawRoundRect(pad, pad + 3, w, h, arc, arc); + }); + } + + private static Icon createCopiedIcon(int size) { + return paintIcon(size, g -> { + g.setColor(new Color(34, 139, 34)); + g.setStroke(new BasicStroke(1.8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + Path2D check = new Path2D.Float(); + check.moveTo(size * 0.2, size * 0.52); + check.lineTo(size * 0.42, size * 0.72); + check.lineTo(size * 0.8, size * 0.32); + g.draw(check); + }); + } + + private static Icon paintIcon(int size, Consumer painter) { + BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = image.createGraphics(); + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + painter.accept(g); + } finally { + g.dispose(); + } + return new ImageIcon(image); + } + + /** + * Run the full Microsoft device-code sign in flow as a modal dialog. + * + *

+ * Fetches the device code (showing progress), presents the sign-in dialog, + * and waits for the user to finish or cancel. Always returns synchronously. + *

+ * + * @param owner parent window for modality and positioning + * @param launcher launcher providing the Microsoft login service + * @return outcome of the flow; never {@code null} + */ + public static Outcome showLogin(Window owner, Launcher launcher) { + ListenableFuture fetchFuture = launcher.getExecutor() + .submit(() -> launcher.getMicrosoftLogin().requestDeviceCodeDetails()); + + SettableProgress progress = new SettableProgress(tr("login.microsoft.device.starting"), -1); + ProgressDialog.showProgress(owner, fetchFuture, progress, tr("login.microsoft.device.fetchingTitle"), tr("login.microsoft.device.starting")); + + MicrosoftLoginService.DeviceCodeDetails details; + try { + details = fetchFuture.get(); + } catch (CancellationException e) { + return Outcome.cancelled(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return Outcome.cancelled(); + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + SwingHelper.showErrorDialog(owner, cause.getLocalizedMessage(), tr("errorTitle"), cause); + return Outcome.cancelled(); + } + + if (details == null) { + return Outcome.cancelled(); + } + + MicrosoftLoginDialog dialog = new MicrosoftLoginDialog(owner, launcher, details); + dialog.setVisible(true); + return dialog.outcome; + } + + /** + * Spinner shown while the QR code is generated. + */ + private static final class QrSpinnerPanel extends JPanel { + + private static final Color TRACK = new Color(220, 220, 220); + private static final Color ARC = new Color(90, 90, 90); + + private final Timer spinTimer; + private int arcStart; + + private QrSpinnerPanel(int size) { + setPreferredSize(new Dimension(size, size)); + setMinimumSize(new Dimension(size, size)); + setMaximumSize(new Dimension(size, size)); + setOpaque(true); + setBackground(UIManager.getColor("Panel.background")); + setBorder(BorderFactory.createLineBorder( + SwingHelper.uiColor("Component.borderColor", new Color(200, 200, 200)))); + spinTimer = new Timer(30, ev -> { + arcStart = (arcStart - 12 + 360) % 360; + repaint(); + }); + } + + private void start() { + if (!spinTimer.isRunning()) { + spinTimer.start(); + } + } + + private void stop() { + spinTimer.stop(); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int diameter = Math.min(getWidth(), getHeight()) - 28; + int x = (getWidth() - diameter) / 2; + int y = (getHeight() - diameter) / 2; + g2.setStroke(new BasicStroke(3f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g2.setColor(TRACK); + g2.drawOval(x, y, diameter, diameter); + g2.setColor(ARC); + g2.drawArc(x, y, diameter, diameter, arcStart, 270); + } finally { + g2.dispose(); + } + } + } + + public enum Result { + SUCCESS, + CANCELLED, + FALLBACK_REQUESTED + } + + @RequiredArgsConstructor + @Getter + public static final class Outcome { + private final Result result; + private final Session session; + + public static Outcome success(Session session) { + return new Outcome(Result.SUCCESS, session); + } + + public static Outcome cancelled() { + return new Outcome(Result.CANCELLED, null); + } + + public static Outcome fallback() { + return new Outcome(Result.FALLBACK_REQUESTED, null); + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java b/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java index 73bcd88fc..ecef2a123 100644 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java +++ b/launcher/src/main/java/com/skcraft/launcher/dialog/ProcessConsoleFrame.java @@ -7,6 +7,7 @@ package com.skcraft.launcher.dialog; import com.skcraft.launcher.swing.LinedBoxPanel; +import com.skcraft.launcher.swing.MessageLog; import com.skcraft.launcher.swing.SwingHelper; import com.skcraft.launcher.util.SharedLocale; import lombok.Getter; @@ -17,6 +18,8 @@ import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.PrintWriter; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import static com.skcraft.launcher.util.SharedLocale.tr; @@ -24,7 +27,10 @@ * A version of the console window that can manage a process. */ public class ProcessConsoleFrame extends ConsoleFrame { - + + private static final ConcurrentHashMap BY_INSTANCE = + new ConcurrentHashMap(); + private JButton killButton; private JButton minimizeButton; private TrayIcon trayIcon; @@ -33,6 +39,8 @@ public class ProcessConsoleFrame extends ConsoleFrame { @Getter @Setter private boolean killOnClose; private PrintWriter processOut; + private String instanceKey; + private final AtomicBoolean killedByUser = new AtomicBoolean(); /** * Create a new instance of the frame. @@ -41,13 +49,63 @@ public class ProcessConsoleFrame extends ConsoleFrame { * @param colorEnabled whether color is enabled in the log */ public ProcessConsoleFrame(int numLines, boolean colorEnabled) { - super(SharedLocale.tr("console.title"), numLines, colorEnabled); + this(numLines, colorEnabled, true); + } + + /** + * Create a new instance of the frame. + * + * @param numLines the number of log lines + * @param colorEnabled whether color is enabled in the log + * @param showUi whether to attach a tray icon and allow showing the window + */ + public ProcessConsoleFrame(int numLines, boolean colorEnabled, boolean showUi) { + this(new MessageLog(numLines, colorEnabled, true), showUi); + } + + private ProcessConsoleFrame(MessageLog messageLog, boolean showUi) { + super(SharedLocale.tr("console.title"), messageLog); processOut = new PrintWriter( getMessageLog().getOutputStream(new Color(0, 0, 255)), true); - initComponents(); + initComponents(showUi); updateComponents(); } + public static ProcessConsoleFrame createGameConsole(int numLines) { + return createGameConsole(numLines, true); + } + + public static ProcessConsoleFrame createGameConsole(int numLines, boolean showUi) { + return new ProcessConsoleFrame(MessageLog.createGameLog(numLines), showUi); + } + + /** + * Close any existing process console for the given instance key. + */ + public static void closeExisting(String instanceKey) { + if (instanceKey == null) { + return; + } + + ProcessConsoleFrame existing = BY_INSTANCE.get(instanceKey); + if (existing != null && existing.hasLiveProcess()) { + return; + } + if (existing != null && BY_INSTANCE.remove(instanceKey, existing)) { + existing.performClose(); + } + } + + /** + * Associate this console with an instance so a later launch can replace it. + */ + public void registerForInstance(String instanceKey) { + this.instanceKey = instanceKey; + if (instanceKey != null) { + BY_INSTANCE.put(instanceKey, this); + } + } + /** * Track the given process. * @@ -79,8 +137,32 @@ private synchronized boolean hasProcess() { return process != null; } + private synchronized boolean hasLiveProcess() { + return process != null && process.isAlive(); + } + + public void processEnded(boolean keepOpen) { + setProcess(null); + if (keepOpen) { + requestFocus(); + } else { + performClose(); + } + } + + /** + * Whether Force Close (or tray equivalent) terminated the process. + */ + public boolean wasKilledByUser() { + return killedByUser.get(); + } + @Override protected void performClose() { + if (instanceKey != null) { + BY_INSTANCE.remove(instanceKey, this); + } + if (hasProcess()) { if (killOnClose) { performKill(); @@ -101,6 +183,7 @@ private void performKill() { synchronized (this) { if (hasProcess()) { + killedByUser.set(true); process.destroy(); setProcess(null); } @@ -109,15 +192,16 @@ private void performKill() { updateComponents(); } - protected void initComponents() { + protected void initComponents(boolean showUi) { killButton = new JButton(SharedLocale.tr("console.forceClose")); - minimizeButton = new JButton(); // Text set later + minimizeButton = new JButton(SharedLocale.tr("console.closeWindow")); LinedBoxPanel buttonsPanel = getButtonsPanel(); buttonsPanel.addGlue(); buttonsPanel.addElement(killButton); buttonsPanel.addElement(minimizeButton); - + alignActionButtons(); + killButton.addActionListener(new ActionListener() { @Override public void actionPerformed(ActionEvent e) { @@ -131,8 +215,12 @@ public void actionPerformed(ActionEvent e) { contextualClose(); } }); - - if (!setupTrayIcon()) { + + if (showUi) { + if (!setupTrayIcon()) { + minimizeButton.setEnabled(true); + } + } else { minimizeButton.setEnabled(true); } } @@ -152,7 +240,7 @@ public void actionPerformed(ActionEvent e) { reshow(); } }); - + PopupMenu popup = new PopupMenu(); MenuItem item; @@ -174,19 +262,23 @@ public void actionPerformed(ActionEvent e) { performKill(); } }); - + trayIcon.setPopupMenu(popup); - + try { SystemTray tray = SystemTray.getSystemTray(); tray.add(trayIcon); return true; } catch (AWTException e) { } - + return false; } + private void alignActionButtons() { + SwingHelper.alignButtonSizes(killButton, minimizeButton); + } + private synchronized void updateComponents() { Image icon = hasProcess() ? getTrayRunningIcon() : getTrayClosedIcon(); @@ -197,6 +289,7 @@ private synchronized void updateComponents() { } else { minimizeButton.setText(SharedLocale.tr("console.hideWindow")); } + alignActionButtons(); if (trayIcon != null) { trayIcon.setImage(icon); diff --git a/launcher/src/main/java/com/skcraft/launcher/dialog/component/BetterComboBox.java b/launcher/src/main/java/com/skcraft/launcher/dialog/component/BetterComboBox.java deleted file mode 100644 index b9bb22d24..000000000 --- a/launcher/src/main/java/com/skcraft/launcher/dialog/component/BetterComboBox.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.skcraft.launcher.dialog.component; - -import javax.swing.*; -import javax.swing.plaf.basic.BasicComboBoxUI; -import javax.swing.plaf.basic.BasicComboPopup; -import javax.swing.plaf.basic.ComboPopup; -import java.awt.*; - -public class BetterComboBox extends JComboBox { - public BetterComboBox() { - setUI(new BetterComboBoxUI()); - } - - private static class BetterComboBoxUI extends BasicComboBoxUI { - @Override - protected ComboPopup createPopup() { - BasicComboPopup popup = new BasicComboPopup(comboBox) { - @Override - protected Rectangle computePopupBounds(int px, int py, int pw, int ph) { - return super.computePopupBounds(px, py, Math.max(comboBox.getPreferredSize().width, pw), ph); - } - }; - - popup.getAccessibleContext().setAccessibleParent(comboBox); - return popup; - } - } -} diff --git a/launcher/src/main/java/com/skcraft/launcher/install/CreateFolder.java b/launcher/src/main/java/com/skcraft/launcher/install/CreateFolder.java new file mode 100644 index 000000000..1031f1c4d --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/install/CreateFolder.java @@ -0,0 +1,28 @@ +package com.skcraft.launcher.install; + +import com.skcraft.launcher.Launcher; +import lombok.RequiredArgsConstructor; + +import java.io.File; + +import static com.skcraft.launcher.util.SharedLocale.tr; + +@RequiredArgsConstructor +public class CreateFolder implements InstallTask { + private final File target; + + @Override + public void execute(Launcher launcher) { + target.mkdirs(); + } + + @Override + public double getProgress() { + return -1; + } + + @Override + public String getStatus() { + return tr("installer.creatingDirectory", target); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/install/CreateLink.java b/launcher/src/main/java/com/skcraft/launcher/install/CreateLink.java new file mode 100644 index 000000000..dc1caff5d --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/install/CreateLink.java @@ -0,0 +1,33 @@ +package com.skcraft.launcher.install; + +import com.skcraft.launcher.Launcher; +import lombok.RequiredArgsConstructor; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; + +import static com.skcraft.launcher.util.SharedLocale.tr; + +@RequiredArgsConstructor +public class CreateLink implements InstallTask { + private final File target; + private final Path linkTarget; + + @Override + public void execute(Launcher launcher) throws Exception { + target.getParentFile().mkdirs(); + Files.deleteIfExists(target.toPath()); + Files.createSymbolicLink(target.toPath(), linkTarget); + } + + @Override + public double getProgress() { + return -1; + } + + @Override + public String getStatus() { + return tr("installer.creatingLink", target, linkTarget); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/install/Downloader.java b/launcher/src/main/java/com/skcraft/launcher/install/Downloader.java index 137daf155..097ca48d4 100644 --- a/launcher/src/main/java/com/skcraft/launcher/install/Downloader.java +++ b/launcher/src/main/java/com/skcraft/launcher/install/Downloader.java @@ -18,4 +18,6 @@ public interface Downloader extends ProgressObservable { File download(List urls, String key, long size, String name); File download(URL url, String key, long size, String name); + + void download(List urls, File destination, long size, String name); } diff --git a/launcher/src/main/java/com/skcraft/launcher/install/FileSetExecutable.java b/launcher/src/main/java/com/skcraft/launcher/install/FileSetExecutable.java new file mode 100644 index 000000000..f4501aec3 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/install/FileSetExecutable.java @@ -0,0 +1,28 @@ +package com.skcraft.launcher.install; + +import com.skcraft.launcher.Launcher; +import lombok.RequiredArgsConstructor; + +import java.io.File; + +import static com.skcraft.launcher.util.SharedLocale.tr; + +@RequiredArgsConstructor +public class FileSetExecutable implements InstallTask { + private final File target; + + @Override + public void execute(Launcher launcher) { + target.setExecutable(true, false); + } + + @Override + public double getProgress() { + return -1; + } + + @Override + public String getStatus() { + return tr("installer.settingExecutable", target); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/install/FileVerify.java b/launcher/src/main/java/com/skcraft/launcher/install/FileVerify.java index d8a313ad4..056df26f4 100644 --- a/launcher/src/main/java/com/skcraft/launcher/install/FileVerify.java +++ b/launcher/src/main/java/com/skcraft/launcher/install/FileVerify.java @@ -8,6 +8,8 @@ import java.io.File; +import static com.skcraft.launcher.util.SharedLocale.tr; + @RequiredArgsConstructor @Log public class FileVerify implements InstallTask { @@ -37,6 +39,6 @@ public double getProgress() { @Override public String getStatus() { - return "Verifying " + name; + return tr("installer.verifyingFile", name); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/install/HttpDownloader.java b/launcher/src/main/java/com/skcraft/launcher/install/HttpDownloader.java index ccc3f478f..de8685a67 100644 --- a/launcher/src/main/java/com/skcraft/launcher/install/HttpDownloader.java +++ b/launcher/src/main/java/com/skcraft/launcher/install/HttpDownloader.java @@ -15,7 +15,7 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import com.skcraft.concurrency.ProgressObservable; -import com.skcraft.launcher.util.HttpRequest; +import com.skcraft.launcher.util.SharedHttpClient; import com.skcraft.launcher.util.SharedLocale; import lombok.Getter; import lombok.NonNull; @@ -23,8 +23,19 @@ import lombok.extern.java.Log; import java.io.File; +import java.io.InputStream; import java.io.IOException; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; import java.net.URL; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.time.Duration; import java.util.*; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; @@ -39,7 +50,7 @@ public class HttpDownloader implements Downloader { private final HashFunction hf = Hashing.sha1(); private final File tempDir; - @Getter @Setter private int threadCount = 6; + @Getter @Setter private int threadCount = 32; @Getter @Setter private int retryDelay = 2000; @Getter @Setter private int tryCount = 3; @@ -48,6 +59,7 @@ public class HttpDownloader implements Downloader { private final List running = new ArrayList(); private final List failed = new ArrayList(); + private boolean interrupted; private long downloaded = 0; private long total = 0; private int left = 0; @@ -105,6 +117,21 @@ public File download(URL url, String key, long size, String name) { return download(urls, key, size, name); } + @Override + public synchronized void download(@NonNull List urls, @NonNull File destination, + long size, String name) { + if (urls.isEmpty()) { + throw new IllegalArgumentException("Can't download empty list of URLs"); + } + + if (!destination.exists()) { + total += size; + left++; + queue.add(new HttpDownloadJob(destination, urls, size, + name != null ? name : destination.getName())); + } + } + /** * Prevent further downloads from being queued and download queued files. * @@ -135,6 +162,9 @@ public void execute() throws InterruptedException, IOException { } synchronized (this) { + if (interrupted) { + throw new InterruptedException("One or more downloads were interrupted"); + } if (failed.size() > 0) { throw new IOException(failed.size() + " file(s) could not be downloaded"); } @@ -183,7 +213,7 @@ public class HttpDownloadJob implements Runnable, ProgressObservable { private final List urls; private final long size; @Getter private String name; - private HttpRequest request; + private volatile long transferred; private HttpDownloadJob(File destFile, List urls, long size, String name) { this.destFile = destFile; @@ -204,12 +234,19 @@ public void run() { synchronized (HttpDownloader.this) { downloaded += size; } - } catch (IOException e) { + } catch (IOException | RuntimeException e) { + deleteTemporaryFile(); + log.log(Level.WARNING, "Failed to download " + destFile + " from " + urls, e); synchronized (HttpDownloader.this) { failed.add(this); } } catch (InterruptedException e) { + deleteTemporaryFile(); + Thread.currentThread().interrupt(); log.info("Download of " + destFile + " was interrupted"); + synchronized (HttpDownloader.this) { + interrupted = true; + } } finally { synchronized (HttpDownloader.this) { left--; @@ -219,18 +256,33 @@ public void run() { } private void download() throws IOException, InterruptedException { - log.log(Level.INFO, "Downloading " + destFile + " from " + urls); + log.log(Level.FINE, "Downloading " + destFile + " from " + urls); File destDir = destFile.getParentFile(); - File tempFile = new File(destDir, destFile.getName() + ".tmp"); + File tempFile = getTemporaryFile(); destDir.mkdirs(); // Try to download download(tempFile); - destFile.delete(); - if (!tempFile.renameTo(destFile)) { - throw new IOException(String.format("Failed to rename %s to %s", tempFile, destFile)); + try { + Files.move(tempFile.toPath(), destFile.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tempFile.toPath(), destFile.toPath(), + StandardCopyOption.REPLACE_EXISTING); + } + } + + private File getTemporaryFile() { + return new File(destFile.getParentFile(), destFile.getName() + ".tmp"); + } + + private void deleteTemporaryFile() { + try { + Files.deleteIfExists(getTemporaryFile().toPath()); + } catch (IOException e) { + log.log(Level.FINE, "Failed to remove partial download for " + destFile, e); } } @@ -248,7 +300,7 @@ private void download(File file) throws IOException, InterruptedException { first = false; try { - tryDownloadFrom(url, file, null, 0); + tryDownloadFrom(url, file); return; } catch (IOException e) { lastException = e; @@ -259,31 +311,85 @@ private void download(File file) throws IOException, InterruptedException { throw new IOException("Failed to download from " + urls, lastException); } - private void tryDownloadFrom(URL url, File file, HttpRequest.PartialDownloadInfo retryDetails, int tries) - throws InterruptedException, IOException { + private void tryDownloadFrom(URL url, File file) throws InterruptedException, IOException { + long existingLength = file.isFile() ? file.length() : 0; + if (size > 0 && existingLength == size) { + transferred = existingLength; + return; + } + + HttpRequest.Builder builder = HttpRequest.newBuilder(toUri(url)) + .GET() + .timeout(Duration.ofMinutes(10)) + .header("User-Agent", "Mozilla/5.0 (Java) SKMCLauncher"); + if (existingLength > 0) { + builder.header("Range", "bytes=" + existingLength + "-"); + } + + HttpResponse response; try { - request = HttpRequest.get(url); - request.setResumeInfo(retryDetails).execute().expectResponseCode(200).saveContent(file); - } catch (IOException e) { - log.log(Level.WARNING, "Failed to download " + url, e); + response = SharedHttpClient.get().send(builder.build(), + HttpResponse.BodyHandlers.ofInputStream()); + } catch (IllegalArgumentException e) { + throw new IOException("Invalid download URL " + url, e); + } + + int status = response.statusCode(); + boolean append = existingLength > 0 && status == 206; + if (status != 200 && !append) { + response.body().close(); + throw new IOException("Did not get expected response code, got " + status + " for " + url); + } - // We only want to try to resume a partial download if the request succeeded before - // throwing an exception halfway through. If it didn't succeed, just throw the error. - if (tries >= tryCount || !request.isConnected() || !request.isSuccessCode()) { - throw e; + long baseLength = append ? existingLength : 0; + long responseLength = response.headers().firstValueAsLong("Content-Length").orElse(-1); + transferred = baseLength; + + StandardOpenOption[] options = append + ? new StandardOpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.WRITE, + StandardOpenOption.APPEND} + : new StandardOpenOption[]{StandardOpenOption.CREATE, StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING}; + + long responseBytes = 0; + try (InputStream in = response.body(); + OutputStream out = Files.newOutputStream(file.toPath(), options)) { + byte[] buffer = new byte[32 * 1024]; + int length; + while ((length = in.read(buffer)) >= 0) { + out.write(buffer, 0, length); + responseBytes += length; + transferred = baseLength + responseBytes; + if (Thread.currentThread().isInterrupted()) { + throw new InterruptedException(); + } } + } - Optional byteRangeSupport = request.canRetryPartial(); - if (byteRangeSupport.isPresent()) { - tryDownloadFrom(url, file, byteRangeSupport.get(), tries + 1); + if (responseLength >= 0 && responseBytes != responseLength) { + throw new IOException(String.format( + "Connection closed with %d bytes transferred, expected %d", + responseBytes, responseLength)); + } + } + + private URI toUri(URL url) throws IOException { + try { + return url.toURI(); + } catch (URISyntaxException e) { + try { + // URL accepts unescaped spaces while URI does not. Preserve + // existing percent escapes and quote the common legacy form. + return new URI(url.toExternalForm().replace(" ", "%20")); + } catch (URISyntaxException nested) { + throw new IOException("Invalid download URL " + url, nested); } } } @Override public double getProgress() { - HttpRequest request = this.request; - return request != null ? request.getProgress() : -1; + return size > 0 ? Math.min(1, transferred / (double) size) : -1; } @Override diff --git a/launcher/src/main/java/com/skcraft/launcher/install/Installer.java b/launcher/src/main/java/com/skcraft/launcher/install/Installer.java index 22e224a40..6f9ae9906 100644 --- a/launcher/src/main/java/com/skcraft/launcher/install/Installer.java +++ b/launcher/src/main/java/com/skcraft/launcher/install/Installer.java @@ -34,8 +34,13 @@ public class Installer implements ProgressObservable { private transient TaskQueue activeQueue; public Installer(@NonNull File tempDir) { + this(tempDir, 32); + } + + public Installer(@NonNull File tempDir, int downloadThreads) { this.tempDir = tempDir; this.downloader = new HttpDownloader(tempDir); + this.downloader.setThreadCount(downloadThreads); } public synchronized void queue(@NonNull InstallTask runnable) { diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameLogBuffer.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogBuffer.java new file mode 100644 index 000000000..94f6609bd --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogBuffer.java @@ -0,0 +1,125 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.util.ArrayList; +import java.util.List; + +/** + * Thread-safe fixed-size circular buffer for game log lines. + */ +public final class GameLogBuffer { + + private final String[] lines; + private final GameLogLevel[] levels; + private int first; + private int count; + private long totalAppended; + private long publishedStart; + private long publishedEnd; + + public GameLogBuffer(int maxLines) { + if (maxLines <= 0) { + throw new IllegalArgumentException("maxLines must be > 0"); + } + this.lines = new String[maxLines]; + this.levels = new GameLogLevel[maxLines]; + } + + public synchronized void append(String line) { + append(line, GameLogLevel.INFO); + } + + public synchronized void append(GameLogLine line) { + if (line == null) { + return; + } + append(line.getText(), line.getLevel()); + } + + private void append(String line, GameLogLevel level) { + if (line == null) { + return; + } + int index = (first + count) % lines.length; + lines[index] = line; + levels[index] = level != null ? level : GameLogLevel.INFO; + totalAppended++; + + if (count == lines.length) { + first = (first + 1) % lines.length; + } else { + count++; + } + } + + public synchronized void appendAll(List batch) { + if (batch == null || batch.isEmpty()) { + return; + } + for (GameLogLine line : batch) { + append(line); + } + } + + public synchronized void clear() { + for (int i = 0; i < count; i++) { + int index = (first + i) % lines.length; + lines[index] = null; + levels[index] = null; + } + first = 0; + count = 0; + publishedStart = totalAppended; + publishedEnd = totalAppended; + } + + /** + * Return the changes since the previous drain, normalized to the current + * bounded contents. This prevents an idle EDT from accumulating an + * unbounded pending batch when the producer wraps the ring repeatedly. + */ + public synchronized SyncDelta drainDelta() { + long currentEnd = totalAppended; + long currentStart = currentEnd - count; + long previousCount = publishedEnd - publishedStart; + + int dropped = (int) Math.min(previousCount, + Math.max(0, currentStart - publishedStart)); + long appendStart = Math.max(publishedEnd, currentStart); + int appendCount = (int) (currentEnd - appendStart); + List appended = new ArrayList<>(appendCount); + + for (long sequence = appendStart; sequence < currentEnd; sequence++) { + int logicalIndex = (int) (sequence - currentStart); + int index = (first + logicalIndex) % lines.length; + appended.add(new GameLogLine(lines[index], levels[index])); + } + + publishedStart = currentStart; + publishedEnd = currentEnd; + return new SyncDelta(dropped, appended); + } + + public static final class SyncDelta { + private final int dropped; + private final List appended; + + private SyncDelta(int dropped, List appended) { + this.dropped = dropped; + this.appended = appended; + } + + public int getDropped() { + return dropped; + } + + public List getAppended() { + return appended; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLevel.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLevel.java new file mode 100644 index 000000000..aaa167e32 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLevel.java @@ -0,0 +1,38 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.util.Locale; + +/** + * Severity of a parsed game log line. + */ +public enum GameLogLevel { + FATAL, + ERROR, + WARN, + INFO, + DEBUG, + TRACE; + + public static GameLogLevel fromName(String name) { + if (name == null || name.isEmpty()) { + return INFO; + } + + String normalized = name.toUpperCase(Locale.ROOT); + if ("WARNING".equals(normalized)) { + return WARN; + } + + try { + return valueOf(normalized); + } catch (IllegalArgumentException ignored) { + return INFO; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLine.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLine.java new file mode 100644 index 000000000..788c5a6c2 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogLine.java @@ -0,0 +1,31 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.util.Objects; + +/** + * A game log line and its parsed severity. + */ +public final class GameLogLine { + + private final String text; + private final GameLogLevel level; + + public GameLogLine(String text, GameLogLevel level) { + this.text = Objects.requireNonNull(text, "text"); + this.level = level != null ? level : GameLogLevel.INFO; + } + + public String getText() { + return text; + } + + public GameLogLevel getLevel() { + return level; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameLogParser.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogParser.java new file mode 100644 index 000000000..5dc6683e1 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogParser.java @@ -0,0 +1,140 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses game output lines from both legacy plain-text streams and modern + * log4j XML events. + */ +public final class GameLogParser { + + private static final String LOG4J_EVENT_START = "]*)?>(.*?)"); + private static final Pattern PLAIN_LEVEL_PATTERN = Pattern.compile( + "\\[[^\\]\\r\\n]*/(FATAL|ERROR|WARN(?:ING)?|INFO|DEBUG|TRACE)\\]", + Pattern.CASE_INSENSITIVE); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("HH:mm:ss", Locale.US) + .withZone(ZoneId.systemDefault()); + + private final StringBuilder pendingXml = new StringBuilder(); + + public List parseLine(String line) { + List out = new ArrayList<>(); + if (line == null) { + return out; + } + + if (pendingXml.length() > 0) { + pendingXml.append(line).append('\n'); + if (line.contains(LOG4J_EVENT_END)) { + out.add(parseLog4jEvent(pendingXml.toString())); + pendingXml.setLength(0); + } + return out; + } + + String trimmed = line.trim(); + if (trimmed.startsWith(LOG4J_EVENT_START)) { + if (trimmed.contains(LOG4J_EVENT_END)) { + out.add(parseLog4jEvent(trimmed)); + } else { + pendingXml.append(line).append('\n'); + } + return out; + } + + out.add(new GameLogLine(line, guessLevel(line))); + return out; + } + + public List flushPending() { + List out = new ArrayList<>(); + if (pendingXml.length() > 0) { + String pending = pendingXml.toString().trim(); + out.add(new GameLogLine(pending, guessLevel(pending))); + pendingXml.setLength(0); + } + return out; + } + + private static GameLogLine parseLog4jEvent(String xmlEvent) { + String logger = ""; + String level = "INFO"; + String thread = "main"; + String timeText = ""; + + Matcher attrMatcher = ATTR_PATTERN.matcher(xmlEvent); + while (attrMatcher.find()) { + String key = attrMatcher.group(1); + String value = attrMatcher.group(2); + if ("logger".equalsIgnoreCase(key)) { + logger = decodeXml(value); + } else if ("level".equalsIgnoreCase(key)) { + level = decodeXml(value); + } else if ("thread".equalsIgnoreCase(key)) { + thread = decodeXml(value); + } else if ("timestamp".equalsIgnoreCase(key)) { + try { + long ms = Long.parseLong(value); + timeText = TIME_FORMATTER.format(Instant.ofEpochMilli(ms)); + } catch (NumberFormatException ignored) { + } + } + } + + Matcher msgMatcher = MESSAGE_PATTERN.matcher(xmlEvent); + String message; + if (msgMatcher.find()) { + message = decodeXml(msgMatcher.group(1)); + } else { + message = decodeXml(xmlEvent); + } + message = message + .replace("", "") + .replace("\r", "") + .replace('\n', ' ') + .trim(); + + if (timeText.isEmpty()) { + timeText = TIME_FORMATTER.format(Instant.now()); + } + if (logger.isEmpty()) { + logger = "Minecraft"; + } + String text = "[" + timeText + "] [" + thread + "/" + level + "] [" + logger + "]: " + message; + return new GameLogLine(text, GameLogLevel.fromName(level)); + } + + private static GameLogLevel guessLevel(String line) { + Matcher matcher = PLAIN_LEVEL_PATTERN.matcher(line); + return matcher.find() ? GameLogLevel.fromName(matcher.group(1)) : GameLogLevel.INFO; + } + + private static String decodeXml(String value) { + return value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&"); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameLogReadySignal.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogReadySignal.java new file mode 100644 index 000000000..28edc2ea6 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameLogReadySignal.java @@ -0,0 +1,100 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Locale; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +/** + * Scans game stdout/stderr for lines that imply the client window is up. + * Used as the readiness signal on non-Windows platforms. + */ +public final class GameLogReadySignal { + + private final CountDownLatch latch = new CountDownLatch(1); + + public boolean isSignaled() { + return latch.getCount() == 0; + } + + public InputStream tee(InputStream source) { + return new LineScanningInputStream(source, this::offerLine); + } + + private void offerLine(String line) { + if (matchesReadyHeuristic(line)) { + latch.countDown(); + } + } + + static boolean matchesReadyHeuristic(String line) { + if (line == null || line.isEmpty()) { + return false; + } + String lower = line.toLowerCase(Locale.ROOT); + return lower.contains("sound engine started") + || (lower.contains("created:") && lower.contains("display")) + || (lower.contains("lwjgl") + && (lower.contains("framebuffer") || lower.contains("opengl"))); + } + + private static final class LineScanningInputStream extends FilterInputStream { + private final Consumer lineConsumer; + private final StringBuilder lineBuf = new StringBuilder(256); + + LineScanningInputStream(InputStream in, Consumer lineConsumer) { + super(in); + this.lineConsumer = lineConsumer; + } + + @Override + public int read() throws IOException { + int b = super.read(); + if (b >= 0) { + acceptByte((byte) b); + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int n = super.read(b, off, len); + if (n > 0) { + for (int i = 0; i < n; i++) { + acceptByte(b[off + i]); + } + } + return n; + } + + private void acceptByte(byte value) { + char c = (char) (value & 0xFF); + if (c == '\n') { + flushLine(); + } else if (c != '\r') { + lineBuf.append(c); + } + } + + private void flushLine() { + if (lineBuf.length() > 0) { + lineConsumer.accept(lineBuf.toString()); + lineBuf.setLength(0); + } + } + + @Override + public void close() throws IOException { + flushLine(); + super.close(); + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/GameWindowWatcher.java b/launcher/src/main/java/com/skcraft/launcher/launch/GameWindowWatcher.java new file mode 100644 index 000000000..25e99c303 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/GameWindowWatcher.java @@ -0,0 +1,189 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.Platform; +import com.sun.jna.Pointer; +import com.sun.jna.platform.win32.Kernel32; +import com.sun.jna.platform.win32.Tlhelp32; +import com.sun.jna.platform.win32.User32; +import com.sun.jna.platform.win32.WinBase; +import com.sun.jna.platform.win32.WinDef.DWORD; +import com.sun.jna.platform.win32.WinDef.HWND; +import com.sun.jna.platform.win32.WinDef.RECT; +import com.sun.jna.platform.win32.WinNT.HANDLE; +import com.sun.jna.platform.win32.WinUser; +import com.sun.jna.ptr.IntByReference; +import lombok.extern.java.Log; + +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Level; + +/** + * Blocks until the game looks ready to show: a real window on Windows, or a + * log heuristic elsewhere. Timeout still returns so launch can proceed. + */ +@Log +public final class GameWindowWatcher { + + private static final long TIMEOUT_MS = 90_000L; + private static final long POLL_MS = 50L; + private static final int WS_EX_TOOLWINDOW = 0x00000080; + /** Skip tiny helper HWNDs; NeoForge earlydisplay is typically 854x480. */ + private static final int MIN_WINDOW_SIZE = 64; + + public enum Result { + READY, + PROCESS_DIED, + TIMEOUT + } + + private GameWindowWatcher() { + } + + public static Result waitUntilReady(Process process, GameLogReadySignal logSignal) + throws InterruptedException { + long startedAt = System.currentTimeMillis(); + long deadline = startedAt + TIMEOUT_MS; + boolean windows = Environment.detectPlatform() == Platform.WINDOWS; + boolean win32Failed = false; + + while (System.currentTimeMillis() < deadline) { + if (!process.isAlive()) { + return Result.PROCESS_DIED; + } + + if (windows && !win32Failed) { + try { + if (findGameWindow(process) != null) { + log.info("Game window detected after " + + (System.currentTimeMillis() - startedAt) + " ms"); + return Result.READY; + } + } catch (Throwable t) { + log.log(Level.WARNING, "Win32 window poll failed; using log signal too", t); + win32Failed = true; + } + } + + // Non-Windows primary signal; also backup on Windows if HWND poll fails. + if ((!windows || win32Failed) && logSignal != null && logSignal.isSignaled()) { + log.info("Game log readiness after " + + (System.currentTimeMillis() - startedAt) + " ms"); + return Result.READY; + } + + Thread.sleep(POLL_MS); + } + + return process.isAlive() ? Result.TIMEOUT : Result.PROCESS_DIED; + } + + /** + * Maximize the visible game window belonging to the process tree. + * + * @param process the launched game process + */ + public static void maximizeGameWindow(Process process) { + if (Environment.detectPlatform() != Platform.WINDOWS) { + return; + } + + try { + HWND gameWindow = findGameWindow(process); + if (gameWindow != null) { + User32.INSTANCE.ShowWindow(gameWindow, WinUser.SW_MAXIMIZE); + } + } catch (Throwable t) { + log.log(Level.WARNING, "Failed to maximize game window", t); + } + } + + private static HWND findGameWindow(Process process) { + Set pids = collectProcessTree((int) process.pid()); + AtomicReference found = new AtomicReference(); + + User32.INSTANCE.EnumWindows((hWnd, data) -> { + IntByReference processId = new IntByReference(); + User32.INSTANCE.GetWindowThreadProcessId(hWnd, processId); + if (!pids.contains(processId.getValue())) { + return true; + } + // NeoForge earlydisplay creates the GLFW window with GLFW_VISIBLE=0 and + // only calls glfwShowWindow after GL/font init — often several seconds later. + // Require a real-sized top-level window instead of visibility. + if (hasOwner(hWnd)) { + return true; + } + if ((User32.INSTANCE.GetWindowLong(hWnd, WinUser.GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) { + return true; + } + + RECT rect = new RECT(); + if (!User32.INSTANCE.GetWindowRect(hWnd, rect)) { + return true; + } + int width = Math.abs(rect.right - rect.left); + int height = Math.abs(rect.bottom - rect.top); + if (width < MIN_WINDOW_SIZE || height < MIN_WINDOW_SIZE) { + return true; + } + + found.set(hWnd); + return false; + }, null); + + return found.get(); + } + + private static Set collectProcessTree(int rootPid) { + Set pids = new LinkedHashSet(); + pids.add(rootPid); + + Map parentByPid = new HashMap(); + HANDLE snapshot = Kernel32.INSTANCE.CreateToolhelp32Snapshot( + Tlhelp32.TH32CS_SNAPPROCESS, new DWORD(0)); + if (snapshot == null || WinBase.INVALID_HANDLE_VALUE.equals(snapshot)) { + return pids; + } + + try { + Tlhelp32.PROCESSENTRY32 entry = new Tlhelp32.PROCESSENTRY32(); + if (Kernel32.INSTANCE.Process32First(snapshot, entry)) { + do { + parentByPid.put(entry.th32ProcessID.intValue(), entry.th32ParentProcessID.intValue()); + } while (Kernel32.INSTANCE.Process32Next(snapshot, entry)); + } + } finally { + Kernel32.INSTANCE.CloseHandle(snapshot); + } + + boolean grew; + do { + grew = false; + for (Map.Entry entry : parentByPid.entrySet()) { + if (pids.contains(entry.getValue()) && pids.add(entry.getKey())) { + grew = true; + } + } + } while (grew); + + return pids; + } + + private static boolean hasOwner(HWND hWnd) { + HWND owner = User32.INSTANCE.GetWindow(hWnd, new DWORD(WinUser.GW_OWNER)); + return owner != null + && owner.getPointer() != null + && Pointer.nativeValue(owner.getPointer()) != 0; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/JavaProcessBuilder.java b/launcher/src/main/java/com/skcraft/launcher/launch/JavaProcessBuilder.java index 7b7e40e1e..56bd2bbd9 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/JavaProcessBuilder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/JavaProcessBuilder.java @@ -31,7 +31,6 @@ public class JavaProcessBuilder { @Getter @Setter private JavaRuntime runtime; @Getter @Setter private int minMemory; @Getter @Setter private int maxMemory; - @Getter @Setter private int permGen; @Getter private final List classPath = new ArrayList(); @Getter private final List flags = new ArrayList(); @@ -44,7 +43,7 @@ private File getJavaBinPath() throws IOException { // Try the parent directory if (!path.exists()) { throw new IOException( - "The configured Java runtime path '" + path + "' doesn't exist."); + "The configured Game Runtime path '" + path + "' doesn't exist."); } else if (path.isFile()) { path = path.getParentFile(); } @@ -104,13 +103,6 @@ public List buildCommand() throws IOException { command.add("-Xmx" + maxMemory + "M"); } - if (permGen > 0) { - // If we know the Java version, only add permsize for 7 or older - if (getRuntime() == null || getRuntime().getMajorVersion() < 8) { - command.add("-XX:MaxPermSize=" + permGen + "M"); - } - } - command.add(mainClass); command.addAll(args); @@ -129,7 +121,7 @@ public static List splitArgs(String str) { Matcher matcher = argsPattern.matcher(str); List parts = new ArrayList(); while (matcher.find()) { - parts.add(matcher.group(1)); + parts.add(matcher.group(1) != null ? matcher.group(1) : matcher.group(2)); } return parts; } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchOptions.java b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchOptions.java index 91aa4be22..a615d67ce 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchOptions.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchOptions.java @@ -23,6 +23,7 @@ public class LaunchOptions { private final UpdatePolicy updatePolicy; private final LaunchListener listener; private final Session session; + private final boolean reselectFeatures; @Data public static class Builder { @@ -32,6 +33,7 @@ public static class Builder { private UpdatePolicy updatePolicy = UpdatePolicy.UPDATE_IF_SESSION_ONLINE; private LaunchListener listener = new DummyLaunchListener(); private Session session; + private boolean reselectFeatures; public Builder setWindow(Window window) { this.window = window; @@ -60,9 +62,14 @@ public Builder setSession(Session session) { return this; } + public Builder setReselectFeatures(boolean reselectFeatures) { + this.reselectFeatures = reselectFeatures; + return this; + } + public LaunchOptions build() { checkNotNull(instance, "instance"); - return new LaunchOptions(window, instance, updatePolicy, listener, session); + return new LaunchOptions(window, instance, updatePolicy, listener, session, reselectFeatures); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchProcessHandler.java b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchProcessHandler.java index cfa04831b..9e56dc717 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchProcessHandler.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchProcessHandler.java @@ -7,71 +7,255 @@ package com.skcraft.launcher.launch; import com.google.common.base.Function; +import com.google.common.util.concurrent.SettableFuture; +import com.skcraft.launcher.Instance; import com.skcraft.launcher.Launcher; -import com.skcraft.launcher.dialog.LauncherFrame; +import com.skcraft.launcher.LauncherException; import com.skcraft.launcher.dialog.ProcessConsoleFrame; import com.skcraft.launcher.swing.MessageLog; +import com.skcraft.launcher.util.SharedLocale; import lombok.NonNull; import lombok.extern.java.Log; +import javax.annotation.Nullable; import javax.swing.*; import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; /** - * Handles post-process creation during launch. + * After {@link Runner} starts the game process: attach logs, wait until the + * game window is up, dismiss the launching dialog / launcher, then wait for + * exit. */ @Log public class LaunchProcessHandler implements Function { - private static final int CONSOLE_NUM_LINES = 10000; + private static final int CONSOLE_NUM_LINES = 10_000; + // EDT frame budget for batching document updates (~60 FPS); process I/O remains + // continuous. + private static final int CONSOLE_UI_REFRESH_DELAY_MS = 16; private final Launcher launcher; + private final Instance instance; + private final LaunchListener listener; + private final AtomicBoolean gameStarted; + private final SettableFuture readyFuture; private ProcessConsoleFrame consoleFrame; + private boolean showConsole; - public LaunchProcessHandler(@NonNull Launcher launcher) { + public LaunchProcessHandler(@NonNull Launcher launcher, @NonNull Instance instance, + @NonNull LaunchListener listener, @NonNull AtomicBoolean gameStarted, + @NonNull SettableFuture readyFuture) { this.launcher = launcher; + this.instance = instance; + this.listener = listener; + this.gameStarted = gameStarted; + this.readyFuture = readyFuture; } @Override - public ProcessConsoleFrame apply(final Process process) { + public ProcessConsoleFrame apply(@Nullable Process process) { + if (process == null) { + throw new IllegalArgumentException("process must not be null"); + } log.info("Watching process " + process); + GameLogReadySignal logSignal = new GameLogReadySignal(); + readyFuture.addListener(() -> { + if (readyFuture.isCancelled() && process.isAlive()) { + process.destroy(); + } + }, r -> new Thread(r, "launch-ready-cancel").start()); + + final GameLogBuffer buffer = new GameLogBuffer(CONSOLE_NUM_LINES); + final CoalescedLogUpdater updater = new CoalescedLogUpdater(buffer); + final ProcessLogReader reader = new ProcessLogReader(process, buffer, updater::requestSync, logSignal); + try { - SwingUtilities.invokeAndWait(new Runnable() { - @Override - public void run() { - consoleFrame = new ProcessConsoleFrame(CONSOLE_NUM_LINES, true); - consoleFrame.setProcess(process); - consoleFrame.setVisible(true); - MessageLog messageLog = consoleFrame.getMessageLog(); - messageLog.consume(process.getInputStream()); - messageLog.consume(process.getErrorStream()); + attachConsole(process, buffer, updater); + reader.start(); + + GameWindowWatcher.Result result = GameWindowWatcher.waitUntilReady(process, logSignal); + log.info("Game readiness: " + result); + + if (result == GameWindowWatcher.Result.PROCESS_DIED) { + reader.stop(); + SwingUtilities.invokeAndWait(updater::flushNow); + + // Force Close / cancel during launch dies with a non-zero code — not a crash. + if (wasUserAborted()) { + if (!readyFuture.isDone()) { + readyFuture.cancel(false); + } + log.info("Game process terminated by user before becoming ready"); + } else { + showConsoleForFailure(); + int exitCode = process.exitValue(); + LauncherException failure = new LauncherException( + "Game process exited before becoming ready with code " + exitCode, + SharedLocale.tr("console.processExitedEarly")); + if (!readyFuture.isDone()) { + readyFuture.setException(failure); + } + throw new RuntimeException(failure.getLocalizedMessage(), failure); + } + } else { + if (result == GameWindowWatcher.Result.READY && launcher.getConfig().isMaximizeWindow()) { + GameWindowWatcher.maximizeGameWindow(process); } - }); - // Wait for the process to end - process.waitFor(); + // Dismiss launching dialog before disposing its owner (the launcher frame). + markReady(process); + disposeLauncherIfRunning(process, result); + + process.waitFor(); + } } catch (InterruptedException e) { - // Orphan process + if (!readyFuture.isDone()) { + readyFuture.setException(e); + } + Thread.currentThread().interrupt(); } catch (InvocationTargetException e) { - log.log(Level.WARNING, "Unexpected failure", e); + log.log(Level.WARNING, "Launch UI failure", e); + if (!readyFuture.isDone()) { + readyFuture.setException(e); + } + if (process.isAlive()) { + process.destroy(); + } + } finally { + reader.stop(); + try { + SwingUtilities.invokeAndWait(updater::flushNow); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (InvocationTargetException e) { + log.log(Level.FINE, "Unable to finalize game console refresh", e); + } } - log.info("Process ended, re-showing launcher..."); + log.info("Process ended"); + SwingUtilities.invokeLater(() -> { + if (consoleFrame == null) { + return; + } + consoleFrame.processEnded(showConsole); + }); + return consoleFrame; + } + + private void attachConsole(Process process, GameLogBuffer buffer, CoalescedLogUpdater updater) + throws InterruptedException, InvocationTargetException { + SwingUtilities.invokeAndWait(() -> { + ProcessConsoleFrame.closeExisting(instance.getName()); - // Restore the launcher - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - if (consoleFrame != null) { - consoleFrame.setProcess(null); - consoleFrame.requestFocus(); - } + showConsole = launcher.getConfig().isShowInstanceConsole(); + consoleFrame = ProcessConsoleFrame.createGameConsole(CONSOLE_NUM_LINES, showConsole); + consoleFrame.setTitle(SharedLocale.tr("console.title") + " - " + instance.getTitle()); + consoleFrame.registerForInstance(instance.getName()); + + MessageLog messageLog = consoleFrame.getMessageLog(); + updater.bind(messageLog); + messageLog.bindGameBuffer(buffer, updater::requestSync); + + consoleFrame.setProcess(process); + consoleFrame.setVisible(showConsole); + }); + } + + private void disposeLauncherIfRunning(Process process, GameWindowWatcher.Result result) + throws InterruptedException, InvocationTargetException { + SwingUtilities.invokeAndWait(() -> { + if (!process.isAlive()) { + return; + } + if (result != GameWindowWatcher.Result.READY && result != GameWindowWatcher.Result.TIMEOUT) { + return; + } + if (gameStarted.compareAndSet(false, true)) { + listener.gameStarted(); } }); + } - return consoleFrame; + private void showConsoleForFailure() throws InterruptedException, InvocationTargetException { + SwingUtilities.invokeAndWait(() -> { + if (consoleFrame != null) { + showConsole = true; + consoleFrame.setVisible(true); + consoleFrame.requestFocus(); + } + }); + } + + private void markReady(Process process) { + if (!readyFuture.isDone()) { + readyFuture.set(process); + } + } + + private boolean wasUserAborted() { + return readyFuture.isCancelled() || (consoleFrame != null && consoleFrame.wasKilledByUser()); + } + + private static final class CoalescedLogUpdater { + private final GameLogBuffer buffer; + private final AtomicBoolean dirty = new AtomicBoolean(); + private final AtomicBoolean syncScheduled = new AtomicBoolean(); + private volatile MessageLog messageLog; + private Timer syncTimer; + + private CoalescedLogUpdater(GameLogBuffer buffer) { + this.buffer = buffer; + } + + private void bind(MessageLog messageLog) { + this.messageLog = messageLog; + } + + private void requestSync() { + dirty.set(true); + scheduleSync(); + } + + private void scheduleSync() { + if (syncScheduled.compareAndSet(false, true)) { + SwingUtilities.invokeLater(this::startTimer); + } + } + + private void startTimer() { + if (syncTimer == null) { + syncTimer = new Timer(CONSOLE_UI_REFRESH_DELAY_MS, event -> flushToUi()); + syncTimer.setRepeats(false); + } + syncTimer.restart(); + } + + private void flushToUi() { + MessageLog log = messageLog; + if (log != null && dirty.compareAndSet(true, false)) { + log.refreshFromBuffer(buffer); + } + + syncScheduled.set(false); + if (dirty.get()) { + scheduleSync(); + } + } + + private void flushNow() { + if (syncTimer != null) { + syncTimer.stop(); + } + syncScheduled.set(false); + dirty.set(false); + MessageLog log = messageLog; + if (log != null) { + log.refreshFromBuffer(buffer); + } + } } } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java index 09f2f23c4..680c58139 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/LaunchSupervisor.java @@ -6,10 +6,13 @@ package com.skcraft.launcher.launch; +import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; import com.skcraft.concurrency.ObservableFuture; +import com.skcraft.concurrency.ProgressObservable; import com.skcraft.launcher.Instance; import com.skcraft.launcher.Launcher; import com.skcraft.launcher.auth.Session; @@ -19,12 +22,15 @@ import com.skcraft.launcher.launch.LaunchOptions.UpdatePolicy; import com.skcraft.launcher.launch.runtime.JavaRuntime; import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.model.minecraft.VersionManifest; import com.skcraft.launcher.persistence.Persistence; import com.skcraft.launcher.swing.SwingHelper; +import com.skcraft.launcher.update.InstanceUpdateCheck; import com.skcraft.launcher.update.Updater; +import com.skcraft.launcher.update.runtime.JavaVersionResolver; +import com.skcraft.launcher.update.runtime.RuntimeInstallTask; import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; -import lombok.RequiredArgsConstructor; import lombok.extern.java.Log; import org.apache.commons.io.FileUtils; @@ -36,7 +42,8 @@ import java.util.Date; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; -import java.util.function.BiPredicate; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; import java.util.logging.Level; import static com.google.common.util.concurrent.MoreExecutors.sameThreadExecutor; @@ -46,6 +53,7 @@ public class LaunchSupervisor { private final Launcher launcher; + private final ObjectMapper mapper = new ObjectMapper(); public LaunchSupervisor(Launcher launcher) { this.launcher = launcher; @@ -57,12 +65,12 @@ public void launch(LaunchOptions options) { final LaunchListener listener = options.getListener(); try { - boolean update = options.getUpdatePolicy().isUpdateEnabled() && instance.isUpdatePending(); - // Store last access date Date now = new Date(); instance.setLastAccessed(now); Persistence.commitAndForget(instance); + launcher.getConfig().setLastInstance(instance.getName()); + Persistence.commitAndForget(launcher.getConfig()); // Perform login final Session session; @@ -75,85 +83,218 @@ public void launch(LaunchOptions options) { } } - // If we have to update, we have to update - if (!instance.isInstalled()) { - update = true; - } - - if (update) { - // Execute the updater - Updater updater = new Updater(launcher, instance); - updater.setOnline(options.getUpdatePolicy() == UpdatePolicy.ALWAYS_UPDATE || session.isOnline()); - ObservableFuture future = new ObservableFuture( - launcher.getExecutor().submit(updater), updater); + if (instance.getManifestURL() != null + && shouldCheckForUpdates(options.getUpdatePolicy(), session)) { + InstanceUpdateCheck check = new InstanceUpdateCheck(launcher, instance); + ObservableFuture checkFuture = new ObservableFuture( + launcher.getExecutor().submit(check), check); - // Show progress - ProgressDialog.showProgress(window, future, SharedLocale.tr("launcher.updatingTitle"), tr("launcher.updatingStatus", instance.getTitle())); - SwingHelper.addErrorDialogCallback(window, future); + ProgressDialog.showProgress(window, checkFuture, + SharedLocale.tr("launcher.updateCheckTitle"), + SharedLocale.tr("launcher.updateCheckStatus")); + SwingHelper.addErrorDialogCallback(window, checkFuture); - // Update the list of instances after updating - future.addListener(new Runnable() { - @Override - public void run() { - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - listener.instancesUpdated(); - } - }); - } - }, SwingExecutor.INSTANCE); - - // On success, launch also - Futures.addCallback(future, new FutureCallback() { + Futures.addCallback(checkFuture, new FutureCallback() { @Override public void onSuccess(Instance result) { - launch(window, instance, session, listener); + continueAfterUpdateCheck(options, session); } @Override public void onFailure(Throwable t) { + log.log(Level.WARNING, + "Unable to check for an update for " + instance.getTitle(), t); } }, SwingExecutor.INSTANCE); } else { - launch(window, instance, session, listener); + continueAfterUpdateCheck(options, session); } } catch (ArrayIndexOutOfBoundsException e) { SwingHelper.showErrorDialog(window, SharedLocale.tr("launcher.noInstanceError"), SharedLocale.tr("launcher.noInstanceTitle")); } } + private void continueAfterUpdateCheck(LaunchOptions options, Session session) { + final Window window = options.getWindow(); + final Instance instance = options.getInstance(); + final LaunchListener listener = options.getListener(); + + boolean update = shouldCheckForUpdates(options.getUpdatePolicy(), session) + && instance.isUpdatePending(); + if (!instance.isInstalled()) { + update = true; + } + + if (update) { + Updater updater = new Updater(launcher, instance); + updater.setOnline(options.getUpdatePolicy() == UpdatePolicy.ALWAYS_UPDATE || session.isOnline()); + updater.setPromptForFeatures(options.isReselectFeatures()); + ObservableFuture future = new ObservableFuture( + launcher.getExecutor().submit(updater), updater); + + ProgressDialog.showProgress(window, future, SharedLocale.tr("launcher.updatingTitle"), tr("launcher.updatingStatus", instance.getTitle())); + SwingHelper.addErrorDialogCallback(window, future); + + future.addListener(new Runnable() { + @Override + public void run() { + SwingUtilities.invokeLater(new Runnable() { + @Override + public void run() { + listener.instancesUpdated(); + } + }); + } + }, SwingExecutor.INSTANCE); + + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(Instance result) { + launchInstance(window, instance, session, listener, true); + } + + @Override + public void onFailure(Throwable t) { + } + }, SwingExecutor.INSTANCE); + } else { + boolean runtimeDownloadAllowed = options.getUpdatePolicy() == UpdatePolicy.ALWAYS_UPDATE + || (options.getUpdatePolicy().isUpdateEnabled() && session.isOnline()); + launchInstance(window, instance, session, listener, runtimeDownloadAllowed); + } + } + + static boolean shouldCheckForUpdates(UpdatePolicy policy, Session session) { + return policy == UpdatePolicy.ALWAYS_UPDATE + || policy == UpdatePolicy.UPDATE_IF_SESSION_ONLINE && session.isOnline(); + } + + private void launchInstance(Window window, Instance instance, Session session, + LaunchListener listener, boolean runtimeDownloadAllowed) { + if (instance.getSettings().getRuntime() != null) { + launch(window, instance, session, listener); + return; + } + + JavaVersion javaVersion = null; + if (instance.getSettings().getManagedRuntimeComponent() != null) { + javaVersion = new JavaVersion(); + javaVersion.setComponent(instance.getSettings().getManagedRuntimeComponent()); + } + + if (javaVersion != null || instance.getSettings().usesAutomaticRuntime()) { + prepareRuntimeAndLaunch(window, instance, session, listener, runtimeDownloadAllowed, javaVersion); + } else { + launch(window, instance, session, listener); + } + } + + private void prepareRuntimeAndLaunch(Window window, Instance instance, Session session, + LaunchListener listener, boolean runtimeDownloadAllowed, + @Nullable JavaVersion javaVersion) { + JavaVersion resolvedJavaVersion = javaVersion != null + ? javaVersion + : readRequiredJavaVersion(instance); + + if (!runtimeDownloadAllowed) { + if (launcher.getRuntimeManager().getRuntime(resolvedJavaVersion).isPresent()) { + launch(window, instance, session, listener); + } else { + SwingHelper.showErrorDialog(window, + tr("runtime.missingDisabled", instance.getTitle(), resolvedJavaVersion.getComponent()), + tr("runtime.missingTitle")); + } + return; + } + + RuntimeInstallTask task = new RuntimeInstallTask(launcher, resolvedJavaVersion); + ObservableFuture future = new ObservableFuture( + launcher.getExecutor().submit(task), task); + + ProgressDialog.showProgress( + window, future, tr("runtime.installingTitle"), tr("runtime.installingStatus", instance.getTitle())); + SwingHelper.addErrorDialogCallback(window, future); + + Futures.addCallback(future, new FutureCallback() { + @Override + public void onSuccess(JavaRuntime result) { + launch(window, instance, session, listener); + } + + @Override + public void onFailure(Throwable t) { + } + }, SwingExecutor.INSTANCE); + } + + private JavaVersion readRequiredJavaVersion(Instance instance) { + if (!instance.getVersionPath().isFile()) { + return JavaVersionResolver.legacyDefault(); + } + + try { + VersionManifest version = mapper.readValue(instance.getVersionPath(), VersionManifest.class); + JavaVersion javaVersion = JavaVersionResolver.resolve(launcher, instance, version); + if (version.getJavaVersion() == null) { + version.setJavaVersion(javaVersion); + mapper.writeValue(instance.getVersionPath(), version); + } + return javaVersion; + } catch (IOException e) { + log.log(Level.WARNING, "Failed to read Java version from " + instance.getVersionPath(), e); + return JavaVersionResolver.legacyDefault(); + } + } + private void launch(Window window, Instance instance, Session session, final LaunchListener listener) { final File extractDir = launcher.createExtractDir(); - // Get the process - Runner task = new Runner(launcher, instance, session, extractDir, new RuntimeVerifier(instance)); + Runner task = new Runner(launcher, instance, session, extractDir, new MemoryVerifier(instance)); ObservableFuture processFuture = new ObservableFuture( launcher.getExecutor().submit(task), task); - // Show process for the process retrieval - ProgressDialog.showProgress( - window, processFuture, SharedLocale.tr("launcher.launchingTItle"), tr("launcher.launchingStatus", instance.getTitle())); + // Completes when the game window is up (or launch failed) — keeps one ProgressDialog open. + final SettableFuture readyFuture = SettableFuture.create(); + final AtomicBoolean gameStarted = new AtomicBoolean(false); - // If the process is started, get rid of this window Futures.addCallback(processFuture, new FutureCallback() { @Override public void onSuccess(Process result) { - SwingUtilities.invokeLater(listener::gameStarted); } @Override public void onFailure(Throwable t) { + readyFuture.setException(t); } }); + readyFuture.addListener(() -> { + if (readyFuture.isCancelled()) { + processFuture.cancel(true); + } + }, sameThreadExecutor()); - // Watch the created process - ListenableFuture future = Futures.transform( - processFuture, new LaunchProcessHandler(launcher), launcher.getExecutor()); - SwingHelper.addErrorDialogCallback(null, future); + ProgressObservable launchProgress = new ProgressObservable() { + @Override + public double getProgress() { + return processFuture.isDone() ? -1 : processFuture.getProgress(); + } - // Clean up at the very end - future.addListener(() -> { + @Override + public String getStatus() { + return processFuture.isDone() + ? tr("launcher.launchingStatus", instance.getTitle()) + : processFuture.getStatus(); + } + }; + + // Register before showProgress — that call blocks the EDT until readyFuture completes. + ListenableFuture lifeFuture = Futures.transform( + processFuture, + new LaunchProcessHandler(launcher, instance, listener, gameStarted, readyFuture), + launcher.getExecutor()); + SwingHelper.addErrorDialogCallback(null, lifeFuture); + + lifeFuture.addListener(() -> { try { log.info("Process ended; cleaning up " + extractDir.getAbsolutePath()); FileUtils.deleteDirectory(extractDir); @@ -162,48 +303,91 @@ public void onFailure(Throwable t) { } }, sameThreadExecutor()); - // Hook up launch listener - Futures.addCallback(future, new FutureCallback() { + Futures.addCallback(lifeFuture, new FutureCallback() { @Override public void onSuccess(@Nullable ProcessConsoleFrame result) { - // gameStarted was only invoked on success above, so only call gameClosed on success - listener.gameClosed(); + if (gameStarted.get()) { + listener.gameClosed(); + } } @Override public void onFailure(Throwable t) { - // likely user cancellation if (!(t instanceof CancellationException)) { log.info("Process failure: " + t.getLocalizedMessage()); } } }, SwingExecutor.INSTANCE); + + ProgressDialog.showProgress( + window, readyFuture, launchProgress, + SharedLocale.tr("launcher.launchingTItle"), + tr("launcher.launchingStatus", instance.getTitle())); } - @RequiredArgsConstructor - static class RuntimeVerifier implements BiPredicate { + public static class MemoryVerifier implements BiFunction { private final Instance instance; + public MemoryVerifier(Instance instance) { + this.instance = instance; + } + + static void showInsufficientSystemMemory(Instance instance, int requiredMb, int systemCapMb) { + runOnEdt(() -> SwingHelper.showErrorDialog(null, + tr("runner.insufficientSystemMemory", + instance.getTitle(), + MemorySettings.formatMemoryGb(requiredMb), + MemorySettings.formatMemoryGb(systemCapMb)), + tr("launcher.insufficientSystemMemoryTitle"))); + } + + static void showInstanceMemoryExceedsSystem(Instance instance, int configuredMb, int systemCapMb) { + runOnEdt(() -> SwingHelper.showErrorDialog(null, + tr("runner.instanceMemoryExceedsSystem", + instance.getTitle(), + MemorySettings.formatMemoryGb(configuredMb), + MemorySettings.formatMemoryGb(systemCapMb)), + tr("launcher.insufficientSystemMemoryTitle"))); + } + + private static void runOnEdt(Runnable action) { + ListenableFuture fut = SwingExecutor.INSTANCE.submit(action); + try { + fut.get(); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + @Override - public boolean test(JavaRuntime javaRuntime, JavaVersion javaVersion) { - ListenableFuture fut = SwingExecutor.INSTANCE.submit(() -> { - Object[] options = new Object[]{ + public Runner.MemoryVerificationResult apply(Integer currentMaxMemory, Integer requiredMaxMemory) { + ListenableFuture fut = SwingExecutor.INSTANCE.submit(() -> { + Object[] options = new Object[] { tr("button.cancel"), + tr("button.increaseMemory"), tr("button.launchAnyway"), }; - String message = tr("runner.wrongJavaVersion", - instance.getTitle(), javaVersion.getMajorVersion(), javaRuntime.getVersion()); + String message = tr("runner.insufficientMemory", + instance.getTitle(), + MemorySettings.formatMemoryGb(requiredMaxMemory), + MemorySettings.formatMemoryGb(currentMaxMemory)); int picked = JOptionPane.showOptionDialog(null, SwingHelper.htmlWrap(message), - tr("launcher.javaMismatchTitle"), + tr("launcher.insufficientMemoryTitle"), JOptionPane.DEFAULT_OPTION, JOptionPane.WARNING_MESSAGE, null, options, null); - return picked == 1; + if (picked == 1) { + return Runner.MemoryVerificationResult.UPDATE_INSTANCE_SETTINGS; + } + if (picked == 2) { + return Runner.MemoryVerificationResult.LAUNCH_ANYWAY; + } + return Runner.MemoryVerificationResult.CANCEL; }); try { diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/MemoryRequirements.java b/launcher/src/main/java/com/skcraft/launcher/launch/MemoryRequirements.java new file mode 100644 index 000000000..1ad9ebf70 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/MemoryRequirements.java @@ -0,0 +1,81 @@ +package com.skcraft.launcher.launch; + +import com.skcraft.launcher.Instance; +import com.skcraft.launcher.InstanceSettings; +import com.skcraft.launcher.model.modpack.LaunchModifier; +import com.skcraft.launcher.persistence.Persistence; +import com.sun.management.OperatingSystemMXBean; + +import java.lang.management.ManagementFactory; +import java.util.function.BiFunction; + +public final class MemoryRequirements { + + private static final int OS_RESERVE_MB = 2048; + + private MemoryRequirements() { + } + + public static int getPhysicalMemoryCapMb() { + try { + java.lang.management.OperatingSystemMXBean bean = ManagementFactory.getOperatingSystemMXBean(); + if (bean instanceof OperatingSystemMXBean sunBean) { + long totalMb = sunBean.getTotalMemorySize() / (1024L * 1024L); + if (totalMb > 0) { + return (int) Math.max(MemorySettings.DEFAULT_MIN_MEMORY, totalMb - OS_RESERVE_MB); + } + } + } catch (Throwable ignored) { + } + return Integer.MAX_VALUE; + } + + public static boolean verifyInstanceMemory(Instance instance, + BiFunction memoryRequirementMismatch) { + int systemCap = getPhysicalMemoryCapMb(); + MemorySettings.Resolved configured = MemorySettings.resolve(instance); + int configuredMb = Math.max(configured.getMinMemory(), configured.getMaxMemory()); + + LaunchModifier launchModifier = instance.getLaunchModifier(); + int requiredMb = launchModifier == null ? 0 : launchModifier.getMinMemory(); + if (requiredMb > systemCap) { + LaunchSupervisor.MemoryVerifier.showInsufficientSystemMemory(instance, requiredMb, systemCap); + return false; + } + if (configuredMb > systemCap) { + LaunchSupervisor.MemoryVerifier.showInstanceMemoryExceedsSystem(instance, configuredMb, systemCap); + return false; + } + if (launchModifier == null || memoryRequirementMismatch == null) { + return true; + } + + int requiredMin = launchModifier.getMinMemory(); + int currentMax = configured.getMaxMemory(); + if (requiredMin <= 0 || currentMax >= requiredMin) { + return true; + } + + Runner.MemoryVerificationResult result = memoryRequirementMismatch.apply(currentMax, requiredMin); + if (result == Runner.MemoryVerificationResult.CANCEL) { + return false; + } + if (result == Runner.MemoryVerificationResult.UPDATE_INSTANCE_SETTINGS) { + MemorySettings.Resolved updated = MemorySettings.normalize( + requiredMin, Math.max(currentMax, requiredMin)); + MemorySettings settings = getOrCreateMemorySettings(instance); + settings.setMinMemory(updated.getMinMemory()); + settings.setMaxMemory(updated.getMaxMemory()); + Persistence.commitAndForget(instance); + } + return true; + } + + private static MemorySettings getOrCreateMemorySettings(Instance instance) { + InstanceSettings settings = instance.getSettings(); + if (settings.getMemorySettings() == null) { + settings.setMemorySettings(new MemorySettings()); + } + return settings.getMemorySettings(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/MemorySettings.java b/launcher/src/main/java/com/skcraft/launcher/launch/MemorySettings.java index eed8b64cf..3ba8511d1 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/MemorySettings.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/MemorySettings.java @@ -1,19 +1,150 @@ package com.skcraft.launcher.launch; +import com.skcraft.launcher.Instance; +import com.skcraft.launcher.model.modpack.Feature; +import com.skcraft.launcher.model.modpack.LaunchModifier; import lombok.Data; +import lombok.Value; + +import java.util.List; +import java.util.Locale; /** * Settings for launched process memory allocation. */ @Data public class MemorySettings { + + public static final int DEFAULT_MIN_MEMORY = 1024; + public static final int DEFAULT_MAX_MEMORY = 4096; + /** * Minimum memory in megabytes. */ - private int minMemory; + private int minMemory = DEFAULT_MIN_MEMORY; /** * Maximum memory in megabytes. */ - private int maxMemory; + private int maxMemory = DEFAULT_MAX_MEMORY; + + @Value + public static class Resolved { + int minMemory; + int maxMemory; + } + + /** + * Resolve effective memory for launch: instance settings, then manifest launch + * block, then defaults. + */ + public static Resolved resolve(Instance instance) { + int minMemory = DEFAULT_MIN_MEMORY; + int maxMemory = DEFAULT_MAX_MEMORY; + + LaunchModifier launch = instance.getLaunchModifier(); + if (launch != null) { + if (launch.getMinMemory() > 0) { + minMemory = launch.getMinMemory(); + } + if (launch.getMaxMemory() > 0) { + maxMemory = launch.getMaxMemory(); + } + } + + MemorySettings settings = instance.getSettings().getMemorySettings(); + if (settings != null) { + if (settings.getMinMemory() > 0) { + minMemory = settings.getMinMemory(); + } + if (settings.getMaxMemory() > 0) { + maxMemory = settings.getMaxMemory(); + } + } + + return normalize(minMemory, maxMemory); + } + + public static String formatMemoryGb(int memoryMb) { + return String.format(Locale.US, memoryMb % 1024 == 0 ? "%.0f" : "%.1f", memoryMb / 1024.0); + } + + public static Resolved normalize(int minMemory, int maxMemory) { + if (minMemory <= 0) { + minMemory = DEFAULT_MIN_MEMORY; + } + + if (maxMemory <= 0) { + maxMemory = DEFAULT_MAX_MEMORY; + } + + if (minMemory > maxMemory) { + maxMemory = minMemory; + } + + return new Resolved(minMemory, maxMemory); + } + + public static LaunchModifier computeEffectiveLaunchModifier(LaunchModifier base, List features) { + LaunchModifier modifier = new LaunchModifier(base); + Resolved memory = normalize(modifier); + + if (features != null) { + for (Feature feature : features) { + if (feature != null && feature.isSelected() && feature.getMinMemoryDelta() > 0) { + memory = normalize(memory.getMinMemory() + feature.getMinMemoryDelta(), memory.getMaxMemory()); + } + } + } + + modifier.setMinMemory(memory.getMinMemory()); + modifier.setMaxMemory(memory.getMaxMemory()); + return modifier; + } + + public static void syncDefaultsFromManifest( + Instance instance, LaunchModifier previousEffective, LaunchModifier newEffective) { + boolean hadSettings = instance.getSettings().getMemorySettings() != null; + MemorySettings settings = getOrCreateSettings(instance); + Resolved nextDefaults = normalize(newEffective); + if (!hadSettings) { + settings.setMinMemory(nextDefaults.getMinMemory()); + settings.setMaxMemory(nextDefaults.getMaxMemory()); + return; + } + + Resolved current = normalize(settings.getMinMemory(), settings.getMaxMemory()); + Resolved previousDefaults = previousEffective == null + ? normalize(DEFAULT_MIN_MEMORY, DEFAULT_MAX_MEMORY) + : normalize(previousEffective); + int nextMinMemory = current.getMinMemory(); + int nextMaxMemory = current.getMaxMemory(); + + if (nextMinMemory == previousDefaults.getMinMemory()) { + nextMinMemory = nextDefaults.getMinMemory(); + } + if (nextMaxMemory == previousDefaults.getMaxMemory() + && nextDefaults.getMaxMemory() > previousDefaults.getMaxMemory()) { + nextMaxMemory = nextDefaults.getMaxMemory(); + } + Resolved updated = normalize(nextMinMemory, nextMaxMemory); + settings.setMinMemory(updated.getMinMemory()); + settings.setMaxMemory(updated.getMaxMemory()); + } + + private static Resolved normalize(LaunchModifier modifier) { + if (modifier == null) { + return normalize(DEFAULT_MIN_MEMORY, DEFAULT_MAX_MEMORY); + } + return normalize(modifier.getMinMemory(), modifier.getMaxMemory()); + } + + private static MemorySettings getOrCreateSettings(Instance instance) { + MemorySettings settings = instance.getSettings().getMemorySettings(); + if (settings == null) { + settings = new MemorySettings(); + instance.getSettings().setMemorySettings(settings); + } + return settings; + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/ProcessLogReader.java b/launcher/src/main/java/com/skcraft/launcher/launch/ProcessLogReader.java new file mode 100644 index 000000000..357d9987a --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/launch/ProcessLogReader.java @@ -0,0 +1,159 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.launch; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads both process streams continuously and appends parsed lines into a + * bounded {@link GameLogBuffer}. + */ +public final class ProcessLogReader { + + private static final Logger log = Logger.getLogger(ProcessLogReader.class.getName()); + + private final Process process; + private final GameLogBuffer buffer; + private final Runnable onDirty; + private final GameLogReadySignal readySignal; + private final AtomicBoolean running = new AtomicBoolean(); + + private Thread stdoutThread; + private Thread stderrThread; + private InputStream stdout; + private InputStream stderr; + + public ProcessLogReader(Process process, GameLogBuffer buffer, Runnable onDirty) { + this(process, buffer, onDirty, null); + } + + public ProcessLogReader(Process process, GameLogBuffer buffer, Runnable onDirty, + GameLogReadySignal readySignal) { + this.process = process; + this.buffer = buffer; + this.onDirty = onDirty; + this.readySignal = readySignal; + } + + public void start() { + if (!running.compareAndSet(false, true)) { + return; + } + + stdout = process.getInputStream(); + stderr = process.getErrorStream(); + if (readySignal != null) { + stdout = readySignal.tee(stdout); + stderr = readySignal.tee(stderr); + } + + stdoutThread = new Thread(() -> consume(stdout, new GameLogParser()), "Game log stdout"); + stderrThread = new Thread(() -> consume(stderr, new GameLogParser()), "Game log stderr"); + stdoutThread.setDaemon(true); + stderrThread.setDaemon(true); + stdoutThread.start(); + stderrThread.start(); + } + + public void stop() { + // The process normally reached EOF before this call. Give both readers + // a chance to publish their final partial line before closing streams. + joinQuietly(stdoutThread, 250); + joinQuietly(stderrThread, 250); + running.set(false); + closeQuietly(stdout); + closeQuietly(stderr); + joinQuietly(stdoutThread); + joinQuietly(stderrThread); + } + + private void consume(InputStream stream, GameLogParser parser) { + final byte[] chunk = new byte[4096]; + final StringBuilder pending = new StringBuilder(); + + try { + int read; + while (running.get() && (read = stream.read(chunk)) != -1) { + pending.append(new String(chunk, 0, read, StandardCharsets.UTF_8)); + drainLines(pending, parser); + } + + if (pending.length() > 0) { + emitParsed(parser.parseLine(stripCarriageReturn(pending.toString()))); + pending.setLength(0); + } + emitParsed(parser.flushPending()); + } catch (IOException e) { + if (running.get()) { + log.log(Level.FINE, "Stopped reading process stream", e); + } + } finally { + closeQuietly(stream); + } + } + + private void drainLines(StringBuilder pending, GameLogParser parser) { + int lineStart = 0; + for (int i = 0; i < pending.length(); i++) { + if (pending.charAt(i) == '\n') { + String line = pending.substring(lineStart, i); + emitParsed(parser.parseLine(stripCarriageReturn(line))); + lineStart = i + 1; + } + } + if (lineStart > 0) { + pending.delete(0, lineStart); + } + } + + private static String stripCarriageReturn(String line) { + if (line.endsWith("\r")) { + return line.substring(0, line.length() - 1); + } + return line; + } + + private void emitParsed(List lines) { + if (lines == null || lines.isEmpty()) { + return; + } + buffer.appendAll(lines); + onDirty.run(); + } + + private static void joinQuietly(Thread thread) { + joinQuietly(thread, 2000); + } + + private static void joinQuietly(Thread thread, long timeoutMillis) { + if (thread == null || thread == Thread.currentThread()) { + return; + } + try { + thread.join(timeoutMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static void closeQuietly(Closeable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (IOException ignored) { + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/Runner.java b/launcher/src/main/java/com/skcraft/launcher/launch/Runner.java index 7846861f8..a1dfee91d 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/Runner.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/Runner.java @@ -20,6 +20,7 @@ import com.skcraft.launcher.launch.runtime.JavaRuntimeFinder; import com.skcraft.launcher.model.minecraft.*; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.update.runtime.JavaVersionResolver; import com.skcraft.launcher.util.Environment; import com.skcraft.launcher.util.Platform; import com.skcraft.launcher.util.SharedLocale; @@ -38,7 +39,7 @@ import java.util.Optional; import java.util.concurrent.Callable; import java.util.concurrent.CancellationException; -import java.util.function.BiPredicate; +import java.util.function.BiFunction; import static com.skcraft.launcher.LauncherUtils.checkInterrupted; import static com.skcraft.launcher.util.SharedLocale.tr; @@ -56,7 +57,7 @@ public class Runner implements Callable, ProgressObservable { private final Instance instance; private final Session session; private final File extractDir; - private final BiPredicate javaRuntimeMismatch; + private final BiFunction memoryRequirementMismatch; @Getter @Setter private Environment environment = Environment.getInstance(); private VersionManifest versionManifest; @@ -66,23 +67,26 @@ public class Runner implements Callable, ProgressObservable { private JavaProcessBuilder builder; private AssetsRoot assetsRoot; private FeatureList.Mutable featureList; + private int resolvedWindowWidth; + private int resolvedWindowHeight; /** * Create a new instance launcher. - * @param launcher the launcher - * @param instance the instance - * @param session the session - * @param extractDir the directory to extract to - * @param javaRuntimeMismatch + * + * @param launcher the launcher + * @param instance the instance + * @param session the session + * @param extractDir the directory to extract to + * @param memoryRequirementMismatch */ public Runner(@NonNull Launcher launcher, @NonNull Instance instance, - @NonNull Session session, @NonNull File extractDir, - BiPredicate javaRuntimeMismatch) { + @NonNull Session session, @NonNull File extractDir, + BiFunction memoryRequirementMismatch) { this.launcher = launcher; this.instance = instance; this.session = session; this.extractDir = extractDir; - this.javaRuntimeMismatch = javaRuntimeMismatch; + this.memoryRequirementMismatch = memoryRequirementMismatch; this.featureList = new FeatureList.Mutable(); } @@ -152,8 +156,7 @@ public Process call() throws Exception { addLegacyArgs(); callLaunchModifier(); - - verifyJavaRuntime(); + verifyMemory(); ProcessBuilder processBuilder = new ProcessBuilder(builder.buildCommand()); processBuilder.directory(instance.getContentDir()); @@ -172,21 +175,20 @@ private void callLaunchModifier() { instance.modify(builder); } - private void verifyJavaRuntime() { - JavaRuntime pickedRuntime = builder.getRuntime(); - JavaVersion targetVersion = versionManifest.getJavaVersion(); - - if (pickedRuntime == null || targetVersion == null) { - return; + private void verifyMemory() { + if (!MemoryRequirements.verifyInstanceMemory(instance, memoryRequirementMismatch)) { + throw new CancellationException("Launch cancelled: memory requirements were not met."); } - if (pickedRuntime.getMajorVersion() != targetVersion.getMajorVersion()) { - boolean launchAnyway = javaRuntimeMismatch.test(pickedRuntime, targetVersion); + MemorySettings.Resolved memory = MemorySettings.resolve(instance); + builder.setMinMemory(memory.getMinMemory()); + builder.setMaxMemory(memory.getMaxMemory()); + } - if (!launchAnyway) { - throw new CancellationException("Launch cancelled by user."); - } - } + public enum MemoryVerificationResult { + CANCEL, + UPDATE_INSTANCE_SETTINGS, + LAUNCH_ANYWAY } /** @@ -242,61 +244,31 @@ private void addLibraries() throws LauncherException { * @throws IOException on I/O error */ private void addJvmArgs() throws IOException, LauncherException { - Optional memorySettings = Optional.ofNullable(instance.getSettings().getMemorySettings()); - - int minMemory = memorySettings - .map(MemorySettings::getMinMemory) - .orElse(config.getMinMemory()); - - int maxMemory = memorySettings - .map(MemorySettings::getMaxMemory) - .orElse(config.getMaxMemory()); - - int permGen = config.getPermGen(); - - if (minMemory <= 0) { - minMemory = 1024; - } - - if (maxMemory <= 0) { - maxMemory = 1024; - } - - if (permGen <= 0) { - permGen = 128; - } - - if (permGen <= 64) { - permGen = 64; - } - - if (minMemory > maxMemory) { - maxMemory = minMemory; - } - - builder.setMinMemory(minMemory); - builder.setMaxMemory(maxMemory); - builder.setPermGen(permGen); + MemorySettings.Resolved memory = MemorySettings.resolve(instance); + builder.setMinMemory(memory.getMinMemory()); + builder.setMaxMemory(memory.getMaxMemory()); JavaRuntime selectedRuntime = Optional.ofNullable(instance.getSettings().getRuntime()) - .orElseGet(() -> Optional.ofNullable(versionManifest.getJavaVersion()) - .flatMap(JavaRuntimeFinder::findBestJavaRuntime) - .orElse(config.getJavaRuntime()) - ); + .orElseGet(() -> { + String managedComponent = instance.getSettings().getManagedRuntimeComponent(); + if (managedComponent != null) { + JavaVersion managedVersion = new JavaVersion(); + managedVersion.setComponent(managedComponent); + return launcher.getRuntimeManager().getRuntime(managedVersion).orElse(null); + } + + JavaVersion requiredVersion = JavaVersionResolver.resolve(launcher, instance, versionManifest); + return launcher.getRuntimeManager().getRuntime(requiredVersion) + .orElseGet(() -> JavaRuntimeFinder.findBestJavaRuntime(requiredVersion).orElse(null)); + }); // Builder defaults to the PATH `java` if the runtime is null builder.setRuntime(selectedRuntime); List flags = builder.getFlags(); - String[] rawJvmArgsList = new String[] { - config.getJvmArgs(), - instance.getSettings().getCustomJvmArgs() - }; - - for (String rawJvmArgs : rawJvmArgsList) { - if (!Strings.isNullOrEmpty(rawJvmArgs)) { - flags.addAll(JavaProcessBuilder.splitArgs(rawJvmArgs)); - } + String rawJvmArgs = instance.getSettings().getCustomJvmArgs(); + if (!Strings.isNullOrEmpty(rawJvmArgs)) { + flags.addAll(JavaProcessBuilder.splitArgs(rawJvmArgs)); } List javaArguments = versionManifest.getArguments().getJvmArguments(); @@ -387,12 +359,13 @@ private void addServerArgs() { } /** - * Add window arguments. + * Add window size args from configuration. */ private void addWindowArgs() { - int width = config.getWindowWidth(); + resolvedWindowWidth = config.getWindowWidth(); + resolvedWindowHeight = config.getWindowHeight(); - if (width >= 10) { + if (resolvedWindowWidth >= 10) { featureList.addFeature("has_custom_resolution", true); } } @@ -412,9 +385,9 @@ private void addLegacyArgs() { if (featureList.hasFeature("has_custom_resolution")) { List args = builder.getArgs(); args.add("--width"); - args.add(String.valueOf(config.getWindowWidth())); + args.add(String.valueOf(resolvedWindowWidth)); args.add("--height"); - args.add(String.valueOf(config.getWindowHeight())); + args.add(String.valueOf(resolvedWindowHeight)); } // Add old platform hacks that the new manifests already specify @@ -455,8 +428,8 @@ private Map getCommandSubstitutions() throws JsonProcessingExcep map.put("assets_root", launcher.getAssets().getDir().getAbsolutePath()); map.put("assets_index_name", versionManifest.getAssetId()); - map.put("resolution_width", String.valueOf(config.getWindowWidth())); - map.put("resolution_height", String.valueOf(config.getWindowHeight())); + map.put("resolution_width", String.valueOf(resolvedWindowWidth)); + map.put("resolution_height", String.valueOf(resolvedWindowHeight)); map.put("launcher_name", launcher.getTitle()); map.put("launcher_version", launcher.getVersion()); diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/AddJavaRuntime.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/AddJavaRuntime.java index 06265ed16..bbe2ae349 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/AddJavaRuntime.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/AddJavaRuntime.java @@ -1,5 +1,7 @@ package com.skcraft.launcher.launch.runtime; +import com.skcraft.launcher.util.SharedLocale; + import java.io.File; /** @@ -12,7 +14,7 @@ public AddJavaRuntime() { @Override public String toString() { - return "Add undetected Java runtime..."; + return SharedLocale.tr("instance.options.addCustomRuntime"); } public static final AddJavaRuntime ADD_RUNTIME_SENTINEL = new AddJavaRuntime(); diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/JavaRuntimeFinder.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/JavaRuntimeFinder.java index 01ecf2efe..a32a1825e 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/JavaRuntimeFinder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/JavaRuntimeFinder.java @@ -8,65 +8,70 @@ import com.skcraft.launcher.model.minecraft.JavaVersion; import com.skcraft.launcher.util.Environment; -import lombok.extern.java.Log; import java.io.File; -import java.util.*; +import java.util.Collections; +import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; /** * Finds the best Java runtime to use. */ -@Log public final class JavaRuntimeFinder { + private static volatile List cachedRuntimes; + private JavaRuntimeFinder() { } /** * Get all available Java runtimes on the system + * * @return List of available Java runtimes sorted by newest first */ public static List getAvailableRuntimes() { - Environment env = Environment.getInstance(); - PlatformRuntimeFinder runtimeFinder = getRuntimeFinder(env); - - if (runtimeFinder == null) { - return Collections.emptyList(); + List cached = cachedRuntimes; + if (cached != null) { + return cached; } - // Add Minecraft javas - List mcRuntimes = MinecraftJavaFinder.scanLauncherDirectories(env, - runtimeFinder.getLauncherDirectories(env)); - Set entries = new HashSet<>(mcRuntimes); - - // Add system Javas - runtimeFinder.getCandidateJavaLocations().stream() - .map(JavaRuntimeFinder::getRuntimeFromPath) - .filter(Objects::nonNull) - .forEach(entries::add); - - // Add extra runtimes - entries.addAll(runtimeFinder.getExtraRuntimes()); - - return entries.stream().sorted().collect(Collectors.toList()); + synchronized (JavaRuntimeFinder.class) { + if (cachedRuntimes != null) { + return cachedRuntimes; + } + + Environment env = Environment.getInstance(); + PlatformRuntimeFinder runtimeFinder = getRuntimeFinder(env); + + if (runtimeFinder == null) { + cachedRuntimes = Collections.emptyList(); + return cachedRuntimes; + } + + cachedRuntimes = Collections.unmodifiableList( + runtimeFinder.getSystemRuntimes(env).stream() + .distinct() // JavaRuntime.equals is by dir + .sorted() + .collect(Collectors.toList())); + return cachedRuntimes; + } } /** * Find the best runtime for a given Java version + * * @param targetVersion Version to match * @return Java runtime if available, empty Optional otherwise */ public static Optional findBestJavaRuntime(JavaVersion targetVersion) { - List entries = getAvailableRuntimes(); - - return entries.stream().sorted() + return getAvailableRuntimes().stream() .filter(runtime -> runtime.getMajorVersion() == targetVersion.getMajorVersion()) .findFirst(); } public static Optional findAnyJavaRuntime() { - return getAvailableRuntimes().stream().sorted().findFirst(); + return getAvailableRuntimes().stream().findFirst(); } public static JavaRuntime getRuntimeFromPath(String path) { diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/LinuxRuntimeFinder.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/LinuxRuntimeFinder.java index 1ab0df6c0..5b0812bc5 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/LinuxRuntimeFinder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/LinuxRuntimeFinder.java @@ -6,16 +6,29 @@ import java.io.File; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; public class LinuxRuntimeFinder implements PlatformRuntimeFinder { @Override - public Set getLauncherDirectories(Environment env) { - return ImmutableSet.of(new File(System.getenv("HOME"), ".minecraft")); + public List getSystemRuntimes(Environment env) { + ArrayList entries = Lists.newArrayList(); + + entries.addAll(MinecraftJavaFinder.scanLauncherDirectories(env, + ImmutableSet.of(new File(System.getenv("HOME"), ".minecraft")))); + + for (File candidate : getCandidateJavaLocations()) { + JavaRuntime runtime = JavaRuntimeFinder.getRuntimeFromPath(candidate); + if (runtime != null) { + entries.add(runtime); + } + } + + return entries; } - @Override - public List getCandidateJavaLocations() { + private static List getCandidateJavaLocations() { ArrayList entries = Lists.newArrayList(); String javaHome = System.getenv("JAVA_HOME"); @@ -36,9 +49,4 @@ public List getCandidateJavaLocations() { return entries; } - - @Override - public List getExtraRuntimes() { - return Collections.emptyList(); - } } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/MacRuntimeFinder.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/MacRuntimeFinder.java index 22a21dfbc..ccc660665 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/MacRuntimeFinder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/MacRuntimeFinder.java @@ -11,27 +11,18 @@ import java.io.File; import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Set; import java.util.logging.Level; @Log public class MacRuntimeFinder implements PlatformRuntimeFinder { @Override - public Set getLauncherDirectories(Environment env) { - return ImmutableSet.of(new File(System.getenv("HOME"), "Library/Application Support/minecraft")); - } - - @Override - public List getCandidateJavaLocations() { - return Collections.emptyList(); - } - - @Override - public List getExtraRuntimes() { + public List getSystemRuntimes(Environment env) { ArrayList entries = Lists.newArrayList(); + entries.addAll(MinecraftJavaFinder.scanLauncherDirectories(env, + ImmutableSet.of(new File(System.getenv("HOME"), "Library/Application Support/minecraft")))); + try { Process p = Runtime.getRuntime().exec("/usr/libexec/java_home -X"); NSArray root = (NSArray) PropertyListParser.parse(p.getInputStream()); @@ -41,8 +32,7 @@ public List getExtraRuntimes() { entries.add(new JavaRuntime( new File(dict.objectForKey("JVMHomePath").toString()).getAbsoluteFile(), dict.objectForKey("JVMVersion").toString(), - isArch64Bit(dict.objectForKey("JVMArch").toString()) - )); + isArch64Bit(dict.objectForKey("JVMArch").toString()))); } } catch (Throwable err) { log.log(Level.WARNING, "Failed to parse java_home command", err); diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/PlatformRuntimeFinder.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/PlatformRuntimeFinder.java index 1d66636c8..9995c0183 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/PlatformRuntimeFinder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/PlatformRuntimeFinder.java @@ -2,30 +2,15 @@ import com.skcraft.launcher.util.Environment; -import java.io.File; import java.util.List; -import java.util.Set; public interface PlatformRuntimeFinder { /** - * Get the list of possible launcher locations for this platform - * @return List of possible launcher locations - */ - Set getLauncherDirectories(Environment env); - - /** - * Get a list of candidate folders to check for Java runtimes. - * The returned folders will be checked for "release" files which describe the version and architecture. - * - * @return List of folders that may contain Java runtimes - */ - List getCandidateJavaLocations(); - - /** - * Get a list of extra runtimes obtained using platform-specific logic. - * e.g. on Windows, registry entries are returned + * Discover Java runtimes available on this platform (vendor installs, + * Mojang launcher bundles, registry / java_home / etc.). * - * @return List of extra Java runtimes + * @param env current environment + * @return system Java runtimes (may contain duplicates by path) */ - List getExtraRuntimes(); + List getSystemRuntimes(Environment env); } diff --git a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/WindowsRuntimeFinder.java b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/WindowsRuntimeFinder.java index 13089658c..c9e8ae9f8 100644 --- a/launcher/src/main/java/com/skcraft/launcher/launch/runtime/WindowsRuntimeFinder.java +++ b/launcher/src/main/java/com/skcraft/launcher/launch/runtime/WindowsRuntimeFinder.java @@ -7,85 +7,166 @@ import lombok.extern.java.Log; import java.io.File; -import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; import java.util.logging.Level; @Log public class WindowsRuntimeFinder implements PlatformRuntimeFinder { + private static final WinReg.HKEY[] HIVES = { + WinReg.HKEY_LOCAL_MACHINE, + WinReg.HKEY_CURRENT_USER, + }; + + /** Registry locations that store installed Java homes. */ + private static final RegistrySpec[] REGISTRY_SPECS = { + // Oracle / JavaSoft (also covers Corretto when its JavaSoft MSI feature is + // enabled) + new RegistrySpec("SOFTWARE\\JavaSoft\\Java Runtime Environment", "JavaHome", ""), + new RegistrySpec("SOFTWARE\\JavaSoft\\Java Development Kit", "JavaHome", ""), + new RegistrySpec("SOFTWARE\\JavaSoft\\JRE", "JavaHome", ""), + new RegistrySpec("SOFTWARE\\JavaSoft\\JDK", "JavaHome", ""), + + // Eclipse Adoptium (Temurin) and legacy AdoptOpenJDK / Eclipse Foundation keys + new RegistrySpec("SOFTWARE\\Eclipse Adoptium\\JRE", "Path", "\\hotspot\\MSI"), + new RegistrySpec("SOFTWARE\\Eclipse Adoptium\\JDK", "Path", "\\hotspot\\MSI"), + new RegistrySpec("SOFTWARE\\Eclipse Foundation\\JDK", "Path", "\\hotspot\\MSI"), + new RegistrySpec("SOFTWARE\\AdoptOpenJDK\\JRE", "Path", "\\hotspot\\MSI"), + new RegistrySpec("SOFTWARE\\AdoptOpenJDK\\JDK", "Path", "\\hotspot\\MSI"), + + // IBM Semeru + new RegistrySpec("SOFTWARE\\Semeru\\JRE", "Path", "\\openj9\\MSI"), + new RegistrySpec("SOFTWARE\\Semeru\\JDK", "Path", "\\openj9\\MSI"), + + // Azul Zulu + new RegistrySpec("SOFTWARE\\Azul Systems\\Zulu", "InstallationPath", ""), + + // Microsoft Build of OpenJDK + new RegistrySpec("SOFTWARE\\Microsoft\\JDK", "Path", "\\hotspot\\MSI"), + }; + + /** Default 64-bit install roots under %ProgramFiles%. */ + private static final String[] VENDOR_DIR_ROOTS = { + "Eclipse Adoptium", + "Eclipse Foundation", + "Zulu", + "Amazon Corretto", + "Semeru", + "Microsoft", + }; + @Override - public Set getLauncherDirectories(Environment env) { - HashSet launcherDirs = new HashSet<>(); + public List getSystemRuntimes(Environment env) { + ArrayList entries = Lists.newArrayList(); - try { - String launcherPath = WinRegistry.readString(WinReg.HKEY_CURRENT_USER, - "SOFTWARE\\Mojang\\InstalledProducts\\Minecraft Launcher", "InstallLocation"); + entries.addAll(MinecraftJavaFinder.scanLauncherDirectories( + env, getMinecraftLauncherDirectories(env))); - launcherDirs.add(new File(launcherPath)); - } catch (Throwable err) { - log.log(Level.WARNING, "Failed to read launcher location from registry", err); + for (File candidate : getCandidateJavaLocations()) { + JavaRuntime runtime = JavaRuntimeFinder.getRuntimeFromPath(candidate); + if (runtime != null) { + entries.add(runtime); + } } + for (RegistrySpec spec : REGISTRY_SPECS) { + for (WinReg.HKEY hive : HIVES) { + getEntriesFromRegistry(entries, hive, spec.basePath, spec.valueName, spec.subkeySuffix); + } + } + + return entries; + } + + private static List getMinecraftLauncherDirectories(Environment env) { + List launcherDirs = new ArrayList<>(); + + WinRegistry.readStringOptional(WinReg.HKEY_CURRENT_USER, + "SOFTWARE\\Mojang\\InstalledProducts\\Minecraft Launcher", "InstallLocation") + .map(File::new) + .ifPresent(launcherDirs::add); + String programFiles = Objects.equals(env.getArchBits(), "64") ? System.getenv("ProgramFiles(x86)") : System.getenv("ProgramFiles"); + if (programFiles != null) { + launcherDirs.add(new File(programFiles, "Minecraft")); + launcherDirs.add(new File(programFiles, "Minecraft Launcher")); + } - // Mojang likes to move the java runtime directory - launcherDirs.add(new File(programFiles, "Minecraft")); - launcherDirs.add(new File(programFiles, "Minecraft Launcher")); - launcherDirs.add(new File(System.getenv("LOCALAPPDATA"), "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local")); + String localAppData = System.getenv("LOCALAPPDATA"); + if (localAppData != null) { + launcherDirs.add(new File(localAppData, + "Packages\\Microsoft.4297127D64EC6_8wekyb3d8bbwe\\LocalCache\\Local")); + } return launcherDirs; } - @Override - public List getCandidateJavaLocations() { - return Collections.emptyList(); - } - - @Override - public List getExtraRuntimes() { - ArrayList entries = Lists.newArrayList(); + private static List getCandidateJavaLocations() { + List candidates = new ArrayList<>(); + String programFiles = System.getenv("ProgramFiles"); + if (programFiles == null) { + return candidates; + } - getEntriesFromRegistry(entries, "SOFTWARE\\JavaSoft\\Java Runtime Environment"); - getEntriesFromRegistry(entries, "SOFTWARE\\JavaSoft\\Java Development Kit"); - getEntriesFromRegistry(entries, "SOFTWARE\\JavaSoft\\JDK"); + for (String root : VENDOR_DIR_ROOTS) { + File[] children = new File(programFiles, root).listFiles(File::isDirectory); + if (children != null) { + Collections.addAll(candidates, children); + } + } - return entries; + return candidates; } - private static void getEntriesFromRegistry(Collection entries, String basePath) - throws IllegalArgumentException { + private static void getEntriesFromRegistry(Collection entries, WinReg.HKEY hive, + String basePath, String valueName, String subkeySuffix) { + if (!WinRegistry.keyExists(hive, basePath)) { + // Key isn't present (no such Java installed) - nothing to read, don't log. + return; + } + try { - List subKeys = WinRegistry.readStringSubKeys(WinReg.HKEY_LOCAL_MACHINE, basePath); + List subKeys = WinRegistry.readStringSubKeys(hive, basePath); for (String subKey : subKeys) { - JavaRuntime entry = getEntryFromRegistry(basePath, subKey); + JavaRuntime entry = getEntryFromRegistry(hive, basePath, subKey, valueName, subkeySuffix); if (entry != null) { entries.add(entry); } } } catch (Throwable err) { - log.log(Level.INFO, "Failed to read Java locations from registry in " + basePath); + log.log(Level.INFO, "Failed to read Java locations from registry in " + basePath, err); } } - private static JavaRuntime getEntryFromRegistry(String basePath, String version) { - String regPath = basePath + "\\" + version; - String path = WinRegistry.readString(WinReg.HKEY_LOCAL_MACHINE, regPath, "JavaHome"); - File dir = new File(path); - if (dir.exists() && new File(dir, "bin/java.exe").exists()) { - return new JavaRuntime(dir, version, guessIf64BitWindows(dir)); - } else { + private static JavaRuntime getEntryFromRegistry(WinReg.HKEY hive, String basePath, String version, + String valueName, String subkeySuffix) { + String regPath = basePath + "\\" + version + subkeySuffix; + try { + String path = WinRegistry.readString(hive, regPath, valueName); + if (path == null || path.isEmpty()) { + return null; + } + return JavaRuntimeFinder.getRuntimeFromPath(new File(path)); + } catch (Throwable err) { + // Subkey may not have the expected value (e.g. CurrentVersion sibling keys). return null; } } - private static boolean guessIf64BitWindows(File path) { - try { - String programFilesX86 = System.getenv("ProgramFiles(x86)"); - return programFilesX86 == null || !path.getCanonicalPath().startsWith(new File(programFilesX86).getCanonicalPath()); - } catch (IOException ignored) { - return false; + private static final class RegistrySpec { + final String basePath; + final String valueName; + final String subkeySuffix; + + RegistrySpec(String basePath, String valueName, String subkeySuffix) { + this.basePath = basePath; + this.valueName = valueName; + this.subkeySuffix = subkeySuffix; } } } diff --git a/launcher/src/main/java/com/skcraft/launcher/model/loader/ProcessorEntry.java b/launcher/src/main/java/com/skcraft/launcher/model/loader/ProcessorEntry.java index 33e7319c2..6569f226b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/loader/ProcessorEntry.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/loader/ProcessorEntry.java @@ -8,6 +8,8 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; +import java.util.List; + @Data @AllArgsConstructor @NoArgsConstructor @@ -20,8 +22,19 @@ public class ProcessorEntry extends ManifestEntry { public void install(Installer installer, InstallLog log, UpdateCache cache, InstallExtras extras) throws Exception { LocalLoader loader = extras.getLoader(loaderName); - if (processor.shouldRunOn(Side.CLIENT)) { - installer.queueLate(new ProcessorTask(processor, loader.getManifest(), getManifest(), loader.getLocalFiles())); + if (!processor.shouldRunOn(Side.CLIENT)) { + return; + } + + String cacheKey = "processor:" + loaderName + ":" + processor.getJar(); + List args = processor.getArgs(); + if (args != null) { + cacheKey += ":" + String.join(":", args); } + if (!cache.mark(cacheKey, loaderName)) { + return; + } + + installer.queueLate(new ProcessorTask(processor, loader.getManifest(), getManifest(), loader.getLocalFiles())); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/Version.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/Version.java index a580df2f3..9bc718981 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/Version.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/Version.java @@ -25,6 +25,27 @@ public class Version { @NonNull private String url; + /** + * Mojang manifest version type (e.g. release, snapshot), when present. + */ + @Getter + @Setter + private String type; + + /** + * Mojang manifest timestamp (ISO-8601), when present. + */ + @Getter + @Setter + private String time; + + /** + * Mojang manifest release timestamp (ISO-8601), when present. + */ + @Getter + @Setter + private String releaseTime; + public Version() { } diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/DownloadInfo.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/DownloadInfo.java new file mode 100644 index 000000000..7704b8186 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/DownloadInfo.java @@ -0,0 +1,12 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class DownloadInfo { + private String url; + private String sha1; + private long size; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/Format.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/Format.java new file mode 100644 index 000000000..1d4222b50 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/Format.java @@ -0,0 +1,10 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum Format { + @JsonProperty("raw") + RAW, + @JsonProperty("lzma") + LZMA +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeInfo.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeInfo.java new file mode 100644 index 000000000..abf278872 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeInfo.java @@ -0,0 +1,18 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class RuntimeInfo { + private DownloadInfo manifest; + private Version version; + + @Data + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Version { + private String name; + private String released; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeList.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeList.java new file mode 100644 index 000000000..7acd8cc05 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeList.java @@ -0,0 +1,38 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import lombok.Data; + +import java.util.List; +import java.util.Map; + +@Data +public class RuntimeList { + @JsonValue + private final Map>> runtimesByPlatform; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public RuntimeList(Map>> runtimesByPlatform) { + this.runtimesByPlatform = runtimesByPlatform; + } + + public RuntimeInfo getRuntime(RuntimePlatform platform, JavaVersion target) { + if (platform == null || target == null || target.getComponent() == null) { + return null; + } + + Map> platformRuntimes = runtimesByPlatform.get(platform.getId()); + if (platformRuntimes == null) { + return null; + } + + List versions = platformRuntimes.get(target.getComponent()); + if (versions == null || versions.isEmpty()) { + return null; + } + + return versions.get(0); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifest.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifest.java new file mode 100644 index 000000000..e9b94d5a2 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifest.java @@ -0,0 +1,12 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.Map; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class RuntimeManifest { + private Map files; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifestEntry.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifestEntry.java new file mode 100644 index 000000000..e93b0317d --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimeManifestEntry.java @@ -0,0 +1,37 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeName; +import lombok.Data; + +import java.util.Map; + +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = RuntimeManifestEntry.File.class, name = "file"), + @JsonSubTypes.Type(value = RuntimeManifestEntry.Directory.class, name = "directory"), + @JsonSubTypes.Type(value = RuntimeManifestEntry.Link.class, name = "link"), +}) +public interface RuntimeManifestEntry { + @Data + @JsonTypeName("file") + @JsonIgnoreProperties(ignoreUnknown = true) + class File implements RuntimeManifestEntry { + private Map downloads; + private boolean executable; + } + + @JsonTypeName("directory") + @JsonIgnoreProperties(ignoreUnknown = true) + class Directory implements RuntimeManifestEntry { + } + + @Data + @JsonTypeName("link") + @JsonIgnoreProperties(ignoreUnknown = true) + class Link implements RuntimeManifestEntry { + private String target; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimePlatform.java b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimePlatform.java new file mode 100644 index 000000000..33d1d5c72 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/minecraft/runtime/RuntimePlatform.java @@ -0,0 +1,53 @@ +package com.skcraft.launcher.model.minecraft.runtime; + +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.Platform; +import lombok.Getter; + +public enum RuntimePlatform { + LINUX("linux", true), + LINUX_386("linux-i386", false), + MAC_OS("mac-os", true), + MAC_OS_ARM64("mac-os-arm64", true), + WINDOWS_ARM64("windows-arm64", true), + WINDOWS_X64("windows-x64", true), + WINDOWS_X86("windows-x86", false); + + @Getter + private final String id; + @Getter + private final boolean is64Bit; + + RuntimePlatform(String id, boolean is64Bit) { + this.id = id; + this.is64Bit = is64Bit; + } + + public static RuntimePlatform from(Environment environment) { + Platform platform = environment.getPlatform(); + String arch = environment.getArch() != null ? environment.getArch().toLowerCase() : ""; + + switch (platform) { + case WINDOWS: + if (isArm64(arch)) return WINDOWS_ARM64; + if (is32BitX86(arch)) return WINDOWS_X86; + return WINDOWS_X64; + case MAC_OS_X: + if (isArm64(arch)) return MAC_OS_ARM64; + return MAC_OS; + case LINUX: + if (is32BitX86(arch)) return LINUX_386; + return LINUX; + default: + return null; + } + } + + private static boolean isArm64(String arch) { + return arch.equals("aarch64") || arch.equals("arm64"); + } + + private static boolean is32BitX86(String arch) { + return arch.equals("x86") || arch.equals("i386") || arch.equals("i686"); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/BaseManifest.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/BaseManifest.java index 3aa84ea18..7d04ec8d7 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/BaseManifest.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/BaseManifest.java @@ -6,9 +6,11 @@ package com.skcraft.launcher.model.modpack; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class BaseManifest { private String title; diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Condition.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Condition.java index 8f0332c9c..0b3226737 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Condition.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Condition.java @@ -12,7 +12,9 @@ @JsonTypeInfo(use=JsonTypeInfo.Id.NAME, include=JsonTypeInfo.As.PROPERTY, property="if") @JsonSubTypes({ @JsonSubTypes.Type(value = RequireAny.class, name = "requireAny"), - @JsonSubTypes.Type(value = RequireAll.class, name = "requireAll") + @JsonSubTypes.Type(value = RequireAll.class, name = "requireAll"), + @JsonSubTypes.Type(value = RequireNone.class, name = "requireNone"), + @JsonSubTypes.Type(value = RequireAnyAndNone.class, name = "requireAnyAndNone") }) public interface Condition { diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/DownloadableFile.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/DownloadableFile.java index 1eddaf054..476e82626 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/DownloadableFile.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/DownloadableFile.java @@ -1,5 +1,6 @@ package com.skcraft.launcher.model.modpack; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.skcraft.launcher.install.Installer; import com.skcraft.launcher.model.minecraft.Side; import lombok.Data; @@ -12,6 +13,7 @@ import static com.skcraft.launcher.LauncherUtils.concat; @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class DownloadableFile { private String name; private String hash; diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Feature.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Feature.java index 820e70169..eef28813b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Feature.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Feature.java @@ -8,12 +8,14 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIdentityInfo; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonValue; import com.fasterxml.jackson.annotation.ObjectIdGenerators; import com.google.common.base.Strings; import lombok.Data; @JsonIdentityInfo(generator=ObjectIdGenerators.PropertyGenerator.class, property="name") +@JsonIgnoreProperties(ignoreUnknown = true) @Data public class Feature implements Comparable { @@ -36,6 +38,7 @@ public String toJson() { private String description; private Recommendation recommendation; private boolean selected; + private int minMemoryDelta; public Feature() { } @@ -50,6 +53,7 @@ public Feature(Feature feature) { setName(feature.getName()); setDescription(feature.getDescription()); setSelected(feature.isSelected()); + setMinMemoryDelta(feature.getMinMemoryDelta()); } @Override diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/LaunchModifier.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/LaunchModifier.java index 5ad0226ef..a6660422d 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/LaunchModifier.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/LaunchModifier.java @@ -6,6 +6,7 @@ package com.skcraft.launcher.model.modpack; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.google.common.collect.Lists; import com.skcraft.launcher.launch.JavaProcessBuilder; import lombok.Data; @@ -13,10 +14,24 @@ import java.util.List; @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class LaunchModifier { + private int minMemory; + private int maxMemory; private List flags = Lists.newArrayList(); + public LaunchModifier() { + } + + public LaunchModifier(LaunchModifier other) { + if (other != null) { + this.minMemory = other.minMemory; + this.maxMemory = other.maxMemory; + setFlags(other.flags != null ? Lists.newArrayList(other.flags) : null); + } + } + public void setFlags(List flags) { this.flags = flags != null ? flags : Lists.newArrayList(); } diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Manifest.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Manifest.java index 49ec3134a..56e99bc1c 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/Manifest.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/Manifest.java @@ -7,12 +7,14 @@ package com.skcraft.launcher.model.modpack; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonManagedReference; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.base.Strings; import com.skcraft.launcher.Instance; import com.skcraft.launcher.LauncherUtils; import com.skcraft.launcher.install.Installer; +import com.skcraft.launcher.launch.MemorySettings; import com.skcraft.launcher.model.loader.LoaderManifest; import com.skcraft.launcher.model.minecraft.VersionManifest; import lombok.Data; @@ -29,6 +31,7 @@ @Data @EqualsAndHashCode(callSuper = true) +@JsonIgnoreProperties(ignoreUnknown = true) public class Manifest extends BaseManifest { public static final int MIN_PROTOCOL_VERSION = 3; @@ -93,6 +96,10 @@ public void updateGameVersion(String gameVersion) { } public void update(Instance instance) { - instance.setLaunchModifier(getLaunchModifier()); + LaunchModifier previousEffective = instance.getLaunchModifier(); + LaunchModifier newEffective = MemorySettings.computeEffectiveLaunchModifier(getLaunchModifier(), features); + + MemorySettings.syncDefaultsFromManifest(instance, previousEffective, newEffective); + instance.setLaunchModifier(newEffective); } } diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestEntry.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestEntry.java index 776e45026..872bede18 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestEntry.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestEntry.java @@ -7,6 +7,7 @@ package com.skcraft.launcher.model.modpack; import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.skcraft.launcher.install.InstallExtras; @@ -28,6 +29,7 @@ }) @Data @ToString(exclude = "manifest") +@JsonIgnoreProperties(ignoreUnknown = true) public abstract class ManifestEntry { @JsonBackReference("manifest") diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestInfo.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestInfo.java index 6d63f483b..a945e0d37 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestInfo.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/ManifestInfo.java @@ -15,6 +15,8 @@ public class ManifestInfo extends BaseManifest implements Comparable features = new ArrayList(); diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAny.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAny.java index 299595a51..e71dab0d5 100644 --- a/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAny.java +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAny.java @@ -6,6 +6,7 @@ package com.skcraft.launcher.model.modpack; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; import java.util.ArrayList; @@ -13,6 +14,7 @@ import java.util.List; @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class RequireAny implements Condition { private List features = new ArrayList(); diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAnyAndNone.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAnyAndNone.java new file mode 100644 index 000000000..4b256dfa4 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireAnyAndNone.java @@ -0,0 +1,36 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.model.modpack; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class RequireAnyAndNone implements Condition { + + private List requireAny = new ArrayList(); + private List requireNone = new ArrayList(); + + public RequireAnyAndNone() { + } + + public RequireAnyAndNone(List requireAny, List requireNone) { + this.requireAny = requireAny; + this.requireNone = requireNone; + } + + @Override + public boolean matches() { + return new RequireAny(requireAny).matches() + && new RequireNone(requireNone).matches(); + } + +} diff --git a/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireNone.java b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireNone.java new file mode 100644 index 000000000..66323f3fa --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/model/modpack/RequireNone.java @@ -0,0 +1,48 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.model.modpack; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class RequireNone implements Condition { + + private List features = new ArrayList(); + + public RequireNone() { + } + + public RequireNone(List features) { + this.features = features; + } + + public RequireNone(Feature... feature) { + features.addAll(Arrays.asList(feature)); + } + + @Override + public boolean matches() { + if (features == null) { + return true; + } + + for (Feature feature : features) { + if (feature.isSelected()) { + return false; + } + } + + return true; + } + +} diff --git a/launcher/src/main/java/com/skcraft/launcher/persistence/Persistence.java b/launcher/src/main/java/com/skcraft/launcher/persistence/Persistence.java index 529a2b5a0..4daf99199 100644 --- a/launcher/src/main/java/com/skcraft/launcher/persistence/Persistence.java +++ b/launcher/src/main/java/com/skcraft/launcher/persistence/Persistence.java @@ -79,7 +79,7 @@ public static void commit(@NonNull Object object) throws IOException { Closer closer = Closer.create(); try { OutputStream os = closer.register(sink.openBufferedStream()); - mapper.writeValue(os, object); + mapper.writerWithDefaultPrettyPrinter().writeValue(os, object); } finally { closer.close(); } @@ -123,11 +123,8 @@ public static V read(ByteSource source, Class cls, boolean returnNull) { } try { - object = cls.newInstance(); - } catch (InstantiationException e1) { - throw new RuntimeException( - "Failed to construct object with no-arg constructor", e1); - } catch (IllegalAccessException e1) { + object = cls.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException e1) { throw new RuntimeException( "Failed to construct object with no-arg constructor", e1); } diff --git a/launcher/src/main/java/com/skcraft/launcher/selfupdate/ComparableVersion.java b/launcher/src/main/java/com/skcraft/launcher/selfupdate/ComparableVersion.java index 1adcf30bf..6da85fcf0 100644 --- a/launcher/src/main/java/com/skcraft/launcher/selfupdate/ComparableVersion.java +++ b/launcher/src/main/java/com/skcraft/launcher/selfupdate/ComparableVersion.java @@ -345,7 +345,7 @@ public final void parseVersion(String version) { } private static Item parseItem(boolean isDigit, String buf) { - return isDigit ? new IntegerItem(new Integer(buf)) : new StringItem(buf, false); + return isDigit ? new IntegerItem(Integer.parseInt(buf)) : new StringItem(buf, false); } public int compareTo(Object o) { diff --git a/launcher/src/main/java/com/skcraft/launcher/selfupdate/SelfUpdater.java b/launcher/src/main/java/com/skcraft/launcher/selfupdate/SelfUpdater.java index c23b1a35d..98ca6a205 100644 --- a/launcher/src/main/java/com/skcraft/launcher/selfupdate/SelfUpdater.java +++ b/launcher/src/main/java/com/skcraft/launcher/selfupdate/SelfUpdater.java @@ -30,7 +30,7 @@ public class SelfUpdater implements Callable, ProgressObservable { public SelfUpdater(@NonNull Launcher launcher, @NonNull URL url) { this.launcher = launcher; this.url = url; - this.installer = new Installer(launcher.getInstallerDir()); + this.installer = new Installer(launcher.getInstallerDir(), launcher.getDownloadThreads()); } @Override diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/AccountSwitcher.java b/launcher/src/main/java/com/skcraft/launcher/swing/AccountSwitcher.java new file mode 100644 index 000000000..626494771 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/AccountSwitcher.java @@ -0,0 +1,351 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.auth.AccountList; +import com.skcraft.launcher.auth.SavedSession; +import com.skcraft.launcher.util.SharedLocale; +import lombok.NonNull; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; + +/** + * Footer control for viewing and changing the active launch account. + * + *

Layout mirrors {@link InstanceTableCellRenderer} for the content row. + * The divider is a separate NORTH strip — it does not share insets with the + * content row, so it cannot steal from the {@link InstanceRowStyle#ROW_HEIGHT} + * chrome. + * + *

+ *   separator strip:  SEPARATOR_TOP_MARGIN + SEPARATOR_THICKNESS  (extra)
+ *   content row:      InstanceRowStyle.ROW_HEIGHT (= 48) — same as InstanceTable
+ * 
+ */ +public class AccountSwitcher extends JPanel { + + /** Air above the divider line only (none below — content row owns its own top inset). */ + private static final int SEPARATOR_TOP_MARGIN = 6; + private static final int SEPARATOR_THICKNESS = 1; + + private final AccountList accounts; + /** + * Exact twin of {@link InstanceTableCellRenderer} root: EmptyBorder(VERTICAL, + * SIDE) around the icon+text panel. Height locked to {@link InstanceRowStyle#ROW_HEIGHT}. + */ + private final JPanel contentRow = new JPanel(new BorderLayout()) { + @Override + public Dimension getPreferredSize() { + Dimension preferred = super.getPreferredSize(); + preferred.height = InstanceRowStyle.ROW_HEIGHT; + return preferred; + } + + @Override + public Dimension getMinimumSize() { + Dimension minimum = super.getMinimumSize(); + minimum.height = InstanceRowStyle.ROW_HEIGHT; + return minimum; + } + + @Override + public Dimension getMaximumSize() { + Dimension maximum = super.getMaximumSize(); + maximum.height = InstanceRowStyle.ROW_HEIGHT; + return maximum; + } + }; + private final JPanel separatorStrip = new JPanel(new BorderLayout()); + private final JPanel accountPanel = new JPanel(new BorderLayout(InstanceRowStyle.ICON_TEXT_GAP, 0)); + private final JPanel textPanel = new JPanel(new GridBagLayout()); + private final JLabel avatarLabel = new JLabel(); + private final JLabel usernameLabel = new JLabel(); + private final JLabel statusLabel = new JLabel(); + private final List actionListeners = new ArrayList(); + private final Icon defaultHead; + private boolean hovered; + + public AccountSwitcher(@NonNull AccountList accounts) { + super(new BorderLayout()); + this.accounts = accounts; + this.defaultHead = SwingHelper.createIcon(Launcher.class, "default_skin.png", + InstanceRowStyle.ICON_SIZE, InstanceRowStyle.ICON_SIZE); + + // Never opaque: translucent hover must be painted manually (setOpaque(true) + + // alpha Color leaves uncleared ghosts — double text / stale subtitles). + setOpaque(false); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setToolTipText(SharedLocale.tr("accounts.manageTitle")); + setBorder(null); + + buildSeparatorStrip(); + buildContentRow(); + + add(separatorStrip, BorderLayout.NORTH); + add(contentRow, BorderLayout.CENTER); + + MouseAdapter interaction = new MouseAdapter() { + @Override + public void mouseEntered(MouseEvent e) { + setHovered(true); + } + + @Override + public void mouseExited(MouseEvent e) { + Point p = SwingUtilities.convertPoint((Component) e.getSource(), e.getPoint(), AccountSwitcher.this); + if (!contains(p)) { + setHovered(false); + } + } + + @Override + public void mouseClicked(MouseEvent e) { + if (SwingUtilities.isLeftMouseButton(e) && isEnabled()) { + fireActionPerformed(); + } + } + }; + addMouseListener(interaction); + addMouseListenerToAccountControls(interaction); + + refresh(); + } + + public void addActionListener(ActionListener listener) { + actionListeners.add(listener); + } + + public void removeActionListener(ActionListener listener) { + actionListeners.remove(listener); + } + + @Override + public void updateUI() { + super.updateUI(); + // Child fields are still null when JPanel's constructor invokes updateUI(). + if (separatorStrip != null) { + applySeparatorColor(); + } + if (usernameLabel != null) { + usernameLabel.setFont(InstanceRowStyle.titleFont()); + } + if (statusLabel != null) { + statusLabel.setFont(statusLabel.getFont().deriveFont( + Font.PLAIN, InstanceRowStyle.SUBTITLE_FONT_SIZE)); + statusLabel.setForeground(SwingHelper.uiColor("Label.disabledForeground", Color.GRAY)); + } + } + + @Override + public Dimension getPreferredSize() { + Dimension preferred = super.getPreferredSize(); + preferred.height = separatorStripHeight() + InstanceRowStyle.ROW_HEIGHT; + return preferred; + } + + @Override + public Dimension getMinimumSize() { + Dimension minimum = super.getMinimumSize(); + minimum.height = separatorStripHeight() + InstanceRowStyle.ROW_HEIGHT; + return minimum; + } + + @Override + public Dimension getMaximumSize() { + Dimension maximum = super.getMaximumSize(); + maximum.height = separatorStripHeight() + InstanceRowStyle.ROW_HEIGHT; + return maximum; + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + // Paint hover only over the content row (same 48px chrome as an instance cell). + if (!hovered) { + return; + } + Graphics2D g2 = (Graphics2D) g.create(); + try { + Rectangle bounds = contentRow.getBounds(); + g2.setColor(getHoverBackground()); + g2.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + } finally { + g2.dispose(); + } + } + + public void refresh() { + SavedSession active = accounts.getActiveAccount(); + + if (active != null) { + usernameLabel.setText(active.getUsername()); + statusLabel.setText(SharedLocale.tr(AccountList.isOfflineAccount(active) + ? "accounts.offlineMode" + : "accounts.switchAccount")); + if (!AccountList.isOfflineAccount(active) && active.getAvatarImage() != null) { + // Same construction as AccountSelectDialog.AccountRenderer. + avatarLabel.setIcon(new ImageIcon(active.getAvatarImage())); + } else { + avatarLabel.setIcon(defaultHead); + } + } else { + usernameLabel.setText(SharedLocale.tr("accounts.noAccountSelected")); + statusLabel.setText(SharedLocale.tr("launcher.accountSignIn")); + avatarLabel.setIcon(defaultHead); + } + + revalidate(); + repaint(); + } + + private void buildSeparatorStrip() { + separatorStrip.setOpaque(false); + separatorStrip.setBorder(BorderFactory.createEmptyBorder(SEPARATOR_TOP_MARGIN, 0, 0, 0)); + JComponent line = new JComponent() { + @Override + public Dimension getPreferredSize() { + return new Dimension(1, SEPARATOR_THICKNESS); + } + + @Override + public Dimension getMinimumSize() { + return getPreferredSize(); + } + + @Override + public Dimension getMaximumSize() { + return new Dimension(Integer.MAX_VALUE, SEPARATOR_THICKNESS); + } + + @Override + protected void paintComponent(Graphics g) { + g.setColor(getSeparatorColor()); + g.fillRect(0, 0, getWidth(), getHeight()); + } + }; + separatorStrip.add(line, BorderLayout.CENTER); + // Lock strip height so BorderLayout cannot fold it into the content row. + Dimension stripSize = new Dimension(1, separatorStripHeight()); + separatorStrip.setPreferredSize(stripSize); + separatorStrip.setMinimumSize(stripSize); + separatorStrip.setMaximumSize(new Dimension(Integer.MAX_VALUE, separatorStripHeight())); + } + + private void buildContentRow() { + // Same outer chrome as InstanceTableCellRenderer root. + contentRow.setOpaque(false); + contentRow.setBorder(BorderFactory.createEmptyBorder( + InstanceRowStyle.VERTICAL_INSET, InstanceRowStyle.SIDE_INSET, + InstanceRowStyle.VERTICAL_INSET, InstanceRowStyle.SIDE_INSET)); + + // Mirrors InstanceTableCellRenderer contentPanel. + accountPanel.setOpaque(false); + accountPanel.setBorder(BorderFactory.createEmptyBorder( + 0, InstanceRowStyle.HORIZONTAL_INSET, 0, InstanceRowStyle.HORIZONTAL_INSET)); + + avatarLabel.setHorizontalAlignment(SwingConstants.CENTER); + avatarLabel.setVerticalAlignment(SwingConstants.CENTER); + avatarLabel.setOpaque(false); + Dimension headSize = new Dimension(InstanceRowStyle.ICON_SIZE, InstanceRowStyle.ICON_SIZE); + avatarLabel.setPreferredSize(headSize); + avatarLabel.setMinimumSize(headSize); + avatarLabel.setMaximumSize(new Dimension(InstanceRowStyle.ICON_SIZE, Integer.MAX_VALUE)); + + usernameLabel.setFont(InstanceRowStyle.titleFont()); + usernameLabel.setOpaque(false); + + statusLabel.setFont(statusLabel.getFont().deriveFont( + Font.PLAIN, InstanceRowStyle.SUBTITLE_FONT_SIZE)); + statusLabel.setForeground(SwingHelper.uiColor("Label.disabledForeground", Color.GRAY)); + statusLabel.setOpaque(false); + + textPanel.setOpaque(false); + addTextRows(); + + accountPanel.add(avatarLabel, BorderLayout.WEST); + accountPanel.add(textPanel, BorderLayout.CENTER); + contentRow.add(accountPanel, BorderLayout.CENTER); + } + + private void addTextRows() { + GridBagConstraints constraints = new GridBagConstraints(); + constraints.gridx = 0; + constraints.gridy = 0; + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + textPanel.add(usernameLabel, constraints); + + constraints = (GridBagConstraints) constraints.clone(); + constraints.gridy = 1; + constraints.insets = new Insets(InstanceRowStyle.TITLE_SUBTITLE_GAP, 0, 0, 0); + textPanel.add(statusLabel, constraints); + } + + private void addMouseListenerToAccountControls(MouseAdapter listener) { + contentRow.addMouseListener(listener); + accountPanel.addMouseListener(listener); + avatarLabel.addMouseListener(listener); + usernameLabel.addMouseListener(listener); + statusLabel.addMouseListener(listener); + textPanel.addMouseListener(listener); + } + + private void setHovered(boolean hovered) { + if (this.hovered == hovered) { + return; + } + this.hovered = hovered; + repaint(); + } + + private void fireActionPerformed() { + ActionEvent event = new ActionEvent(this, ActionEvent.ACTION_PERFORMED, "accountSwitcher"); + for (ActionListener listener : actionListeners) { + listener.actionPerformed(event); + } + } + + private void applySeparatorColor() { + separatorStrip.repaint(); + } + + private static int separatorStripHeight() { + return SEPARATOR_TOP_MARGIN + SEPARATOR_THICKNESS; + } + + private static Color getSeparatorColor() { + Color separator = UIManager.getColor("Component.borderColor"); + if (separator == null) { + separator = UIManager.getColor("Separator.foreground"); + } + return separator != null ? separator : Color.GRAY; + } + + private static Color getHoverBackground() { + // Same source as InstanceTableCellRenderer.getHoverBackground. + Color hover = UIManager.getColor("Table.selectionBackground"); + if (hover == null) { + hover = UIManager.getColor("List.selectionBackground"); + } + if (hover == null) { + hover = UIManager.getColor("Component.accentColor"); + } + if (hover == null) { + hover = Color.GRAY; + } + return new Color(hover.getRed(), hover.getGreen(), hover.getBlue(), InstanceRowStyle.HOVER_ALPHA); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/FormPanel.java b/launcher/src/main/java/com/skcraft/launcher/swing/FormPanel.java index 5808227a2..7b4faa632 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/FormPanel.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/FormPanel.java @@ -30,7 +30,7 @@ public class FormPanel extends JPanel { labelConstraints.insets = new Insets(4, 5, 1, 10); wideFieldConstraints = (GridBagConstraints) fieldConstraints.clone(); - wideFieldConstraints.insets = new Insets(7, 2, 1, 2); + wideFieldConstraints.insets = new Insets(7, 2, 3, 2); } public FormPanel() { diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/GroupedComboBox.java b/launcher/src/main/java/com/skcraft/launcher/swing/GroupedComboBox.java new file mode 100644 index 000000000..4a9bf92f2 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/GroupedComboBox.java @@ -0,0 +1,97 @@ +package com.skcraft.launcher.swing; + +import javax.swing.BorderFactory; +import javax.swing.DefaultComboBoxModel; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComboBox; +import javax.swing.JList; +import javax.swing.UIManager; +import javax.swing.border.Border; +import java.awt.Color; +import java.awt.Component; +import java.awt.Font; +import java.util.function.Function; + +public final class GroupedComboBox { + + private GroupedComboBox() { + } + + public static final class Group { + private final String label; + + public Group(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + public static class Model extends DefaultComboBoxModel { + public void addGroup(String label) { + addElement(new Group(label)); + } + + @Override + public void setSelectedItem(Object item) { + if (item instanceof Group) { + return; + } + super.setSelectedItem(item); + } + } + + public static boolean isOption(Object item) { + return !(item instanceof Group); + } + + public static JComboBox create(Model model, Function labelFormatter) { + JComboBox combo = new JComboBox<>(model); + combo.setRenderer(new Renderer(labelFormatter)); + return combo; + } + + private static final class Renderer extends DefaultListCellRenderer { + private final Function labelFormatter; + + private Renderer(Function labelFormatter) { + this.labelFormatter = labelFormatter; + } + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, + boolean cellHasFocus) { + boolean inPopup = index >= 0; + boolean isGroup = value instanceof Group; + super.getListCellRendererComponent(list, value, index, isGroup ? false : isSelected, + isGroup ? false : cellHasFocus); + + if (isGroup) { + setText(((Group) value).getLabel()); + setFont(list.getFont().deriveFont(Font.BOLD)); + if (inPopup && index > 0) { + Color separator = UIManager.getColor("Separator.foreground"); + if (separator == null) { + separator = UIManager.getColor("Component.borderColor"); + } + Border line = separator != null + ? BorderFactory.createMatteBorder(1, 0, 0, 0, separator) + : BorderFactory.createEmptyBorder(); + setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(6, 0, 0, 0), + BorderFactory.createCompoundBorder(line, BorderFactory.createEmptyBorder(2, 8, 0, 8)))); + } else { + setBorder(BorderFactory.createEmptyBorder(0, 8, 0, 8)); + } + return this; + } + + setFont(list.getFont()); + setText(labelFormatter.apply(value)); + setBorder(inPopup ? BorderFactory.createEmptyBorder(0, 16, 0, 8) : BorderFactory.createEmptyBorder()); + return this; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceIconCache.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceIconCache.java new file mode 100644 index 000000000..4ee20d46e --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceIconCache.java @@ -0,0 +1,185 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.google.common.base.Charsets; +import com.google.common.hash.Hashing; +import com.skcraft.launcher.util.HttpRequest; +import lombok.NonNull; +import lombok.extern.java.Log; + +import javax.imageio.ImageIO; +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.nio.file.Files; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.logging.Level; + +/** + * Downloads and caches remote instance icons under {@code baseDir/cache/icons}. + */ +@Log +public class InstanceIconCache { + + private static final int MAX_BYTES = 2 * 1024 * 1024; + + private final File cacheDir; + private final Executor executor; + private final Runnable onUpdated; + private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); + private final Set inFlight = ConcurrentHashMap.newKeySet(); + private final Set failed = ConcurrentHashMap.newKeySet(); + + public InstanceIconCache(@NonNull File cacheDir, @NonNull Executor executor, @NonNull Runnable onUpdated) { + this.cacheDir = cacheDir; + this.executor = executor; + this.onUpdated = onUpdated; + } + + /** + * Return a scaled icon for the URL if available; otherwise kick off a fetch and return null. + */ + public Icon get(String iconUrl) { + if (iconUrl == null || iconUrl.isEmpty()) { + return null; + } + + Icon cached = memory.get(iconUrl); + if (cached != null) { + return cached; + } + if (failed.contains(iconUrl)) { + return null; + } + + File file = cacheFileFor(iconUrl); + if (file.isFile()) { + Icon fromDisk = loadScaledIcon(file); + if (fromDisk != null) { + memory.put(iconUrl, fromDisk); + return fromDisk; + } + } + + requestFetch(iconUrl, file); + return null; + } + + private void requestFetch(String iconUrl, File destFile) { + if (!inFlight.add(iconUrl)) { + return; + } + + executor.execute(() -> { + try { + fetchAndStore(iconUrl, destFile); + } finally { + inFlight.remove(iconUrl); + } + }); + } + + private void fetchAndStore(String iconUrl, File destFile) { + URL url; + try { + url = parseHttpUrl(iconUrl); + } catch (MalformedURLException | IllegalArgumentException e) { + failed.add(iconUrl); + log.log(Level.WARNING, "Invalid instance icon URL: " + iconUrl, e); + return; + } + + try { + byte[] bytes = HttpRequest + .get(url) + .execute() + .expectResponseCode(200) + .returnContent() + .asBytes(); + + if (bytes.length == 0 || bytes.length > MAX_BYTES) { + throw new IOException("Icon payload size out of range: " + bytes.length); + } + + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image == null) { + throw new IOException("Unrecognized image data from " + iconUrl); + } + + File parent = destFile.getParentFile(); + if (parent != null) { + parent.mkdirs(); + } + + File tempFile = new File(destFile.getPath() + ".tmp"); + Files.write(tempFile.toPath(), bytes); + destFile.delete(); + if (!tempFile.renameTo(destFile)) { + Files.deleteIfExists(tempFile.toPath()); + throw new IOException("Failed to rename " + tempFile + " to " + destFile); + } + + Icon icon = toScaledIcon(image); + memory.put(iconUrl, icon); + failed.remove(iconUrl); + onUpdated.run(); + } catch (IOException | InterruptedException e) { + failed.add(iconUrl); + log.log(Level.WARNING, "Failed to download instance icon from " + iconUrl, e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } + } + + private File cacheFileFor(String iconUrl) { + String hash = Hashing.sha1().hashString(iconUrl, Charsets.UTF_8).toString(); + return new File(cacheDir, hash.substring(0, 2) + "/" + hash); + } + + private static URL parseHttpUrl(String iconUrl) throws MalformedURLException { + URL url = new URL(iconUrl); + String protocol = url.getProtocol(); + if (protocol == null) { + throw new IllegalArgumentException("Missing URL protocol"); + } + String normalized = protocol.toLowerCase(Locale.ROOT); + if (!"http".equals(normalized) && !"https".equals(normalized)) { + throw new IllegalArgumentException("Only http/https icon URLs are allowed: " + protocol); + } + return url; + } + + private static Icon loadScaledIcon(File file) { + try { + BufferedImage image = ImageIO.read(file); + if (image == null) { + return null; + } + return toScaledIcon(image); + } catch (IOException e) { + log.log(Level.WARNING, "Failed to read cached instance icon " + file, e); + return null; + } + } + + private static Icon toScaledIcon(BufferedImage image) { + int size = InstanceRowStyle.ICON_SIZE; + Image scaled = image.getScaledInstance(size, size, Image.SCALE_SMOOTH); + return new ImageIcon(scaled); + } + +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceRowStyle.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceRowStyle.java new file mode 100644 index 000000000..882e84a71 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceRowStyle.java @@ -0,0 +1,61 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import javax.swing.UIManager; +import java.awt.Font; + +/** + * Shared chrome for instance table rows and the account switcher content row. + * Keep these in sync so the two UIs cannot drift. + */ +public final class InstanceRowStyle { + + /** {@link InstanceTable} / content-row chrome height. */ + public static final int ROW_HEIGHT = 48; + /** Instance / avatar icon edge length. */ + public static final int ICON_SIZE = 32; + /** Title font size (instance rows and account switcher). */ + public static final float TITLE_FONT_SIZE = 14.0f; + /** Subtitle font size. */ + public static final float SUBTITLE_FONT_SIZE = 11.0f; + /** + * Outer EmptyBorder vertical inset on the row root. + * Kept at 0 so icon+text use the full {@link #ROW_HEIGHT}; a 32px icon is still + * optically centered with the same 8px air as the old 3+5 inset split. + */ + public static final int VERTICAL_INSET = 0; + /** Outer EmptyBorder horizontal inset on the row root. */ + public static final int SIDE_INSET = 2; + /** Inner content panel horizontal EmptyBorder. */ + public static final int HORIZONTAL_INSET = 6; + /** Gap between icon and text columns. */ + public static final int ICON_TEXT_GAP = 8; + /** Gap between title and subtitle (top inset on subtitle). */ + public static final int TITLE_SUBTITLE_GAP = 0; + /** Hover fill alpha on selection background. */ + public static final int HOVER_ALPHA = 42; + + private InstanceRowStyle() { + } + + /** + * Title font at {@link #TITLE_FONT_SIZE}: FlatLaf {@code semibold.font} when present + * (Segoe UI Semibold / platform equivalent), else Label.font at that size. + */ + public static Font titleFont() { + Font semibold = UIManager.getFont("semibold.font"); + if (semibold != null) { + return semibold.deriveFont(TITLE_FONT_SIZE); + } + Font label = UIManager.getFont("Label.font"); + if (label != null) { + return label.deriveFont(TITLE_FONT_SIZE); + } + return new Font(Font.DIALOG, Font.PLAIN, Math.round(TITLE_FONT_SIZE)); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceStatusText.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceStatusText.java new file mode 100644 index 000000000..9c52c3b43 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceStatusText.java @@ -0,0 +1,32 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.skcraft.launcher.Instance; +import com.skcraft.launcher.util.SharedLocale; + +public final class InstanceStatusText { + + private InstanceStatusText() { + } + + public static String forInstance(Instance instance) { + if (!instance.isLocal()) { + return SharedLocale.tr("options.instanceNotInstalled"); + } + + if (instance.isUpdatePending()) { + return SharedLocale.tr("options.instanceUpdatePending"); + } + + if (instance.getManifestURL() == null) { + return SharedLocale.tr("options.instanceInstalled"); + } + + return SharedLocale.tr("options.instanceUpToDate"); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTable.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTable.java index 63643ed35..90c845ea2 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTable.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTable.java @@ -6,21 +6,62 @@ package com.skcraft.launcher.swing; +import com.skcraft.launcher.Instance; + import javax.swing.table.TableModel; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.event.MouseMotionAdapter; public class InstanceTable extends DefaultTable { + private static final String ROLLOVER_ROW_PROPERTY = "launcher.rolloverRow"; + + private int rolloverRow = -1; + public InstanceTable() { super(); setTableHeader(null); + setFont(InstanceRowStyle.titleFont()); + setRowHeight(InstanceRowStyle.ROW_HEIGHT); + putClientProperty(ROLLOVER_ROW_PROPERTY, rolloverRow); + addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + setRolloverRow(rowAtPoint(e.getPoint())); + } + }); + addMouseListener(new MouseAdapter() { + @Override + public void mouseExited(MouseEvent e) { + setRolloverRow(-1); + } + }); } @Override public void setModel(TableModel dataModel) { super.setModel(dataModel); - try { - getColumnModel().getColumn(0).setMaxWidth(24); - } catch (ArrayIndexOutOfBoundsException e) { + if (dataModel instanceof InstanceTableModel) { + setDefaultRenderer(Instance.class, new InstanceTableCellRenderer((InstanceTableModel) dataModel)); + } + } + + private void setRolloverRow(int row) { + if (row == rolloverRow) { + return; + } + + int oldRolloverRow = rolloverRow; + rolloverRow = row; + putClientProperty(ROLLOVER_ROW_PROPERTY, rolloverRow); + repaintRow(oldRolloverRow); + repaintRow(rolloverRow); + } + + private void repaintRow(int row) { + if (row >= 0 && row < getRowCount()) { + repaint(getCellRect(row, 0, true)); } } } diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableCellRenderer.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableCellRenderer.java new file mode 100644 index 000000000..9976b7e31 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableCellRenderer.java @@ -0,0 +1,123 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.skcraft.launcher.Instance; + +import javax.swing.*; +import javax.swing.table.TableCellRenderer; +import java.awt.*; + +public class InstanceTableCellRenderer extends JPanel implements TableCellRenderer { + + private final InstanceTableModel model; + private final JLabel iconLabel = new JLabel(); + private final JLabel titleLabel = new JLabel(); + private final JLabel subtitleLabel = new JLabel(); + + public InstanceTableCellRenderer(InstanceTableModel model) { + super(new BorderLayout()); + this.model = model; + setOpaque(true); + setBorder(BorderFactory.createEmptyBorder( + InstanceRowStyle.VERTICAL_INSET, InstanceRowStyle.SIDE_INSET, + InstanceRowStyle.VERTICAL_INSET, InstanceRowStyle.SIDE_INSET)); + + int iconColumnWidth = model.getIconColumnWidth(); + JPanel contentPanel = new JPanel(new BorderLayout(InstanceRowStyle.ICON_TEXT_GAP, 0)); + contentPanel.setOpaque(false); + contentPanel.setBorder(BorderFactory.createEmptyBorder( + 0, InstanceRowStyle.HORIZONTAL_INSET, 0, InstanceRowStyle.HORIZONTAL_INSET)); + + iconLabel.setHorizontalAlignment(SwingConstants.CENTER); + iconLabel.setVerticalAlignment(SwingConstants.CENTER); + Dimension iconSize = new Dimension(iconColumnWidth, iconColumnWidth); + iconLabel.setPreferredSize(iconSize); + iconLabel.setMinimumSize(iconSize); + iconLabel.setMaximumSize(new Dimension(iconColumnWidth, Integer.MAX_VALUE)); + iconLabel.setOpaque(false); + + titleLabel.setFont(InstanceRowStyle.titleFont()); + titleLabel.setOpaque(false); + + subtitleLabel.setFont(subtitleLabel.getFont().deriveFont( + Font.PLAIN, InstanceRowStyle.SUBTITLE_FONT_SIZE)); + subtitleLabel.setOpaque(false); + + contentPanel.add(iconLabel, BorderLayout.WEST); + contentPanel.add(createTextPanel(), BorderLayout.CENTER); + add(contentPanel, BorderLayout.CENTER); + } + + @Override + public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, + boolean hasFocus, int row, int column) { + Instance instance = (Instance) value; + iconLabel.setIcon(model.getIcon(instance)); + titleLabel.setText(instance.getTitle()); + subtitleLabel.setText(InstanceStatusText.forInstance(instance)); + + Color mutedForeground = UIManager.getColor("Label.disabledForeground"); + if (mutedForeground == null) { + mutedForeground = table.getForeground(); + } + + if (isSelected) { + Color activeSelectionBackground = UIManager.getColor("Table.selectionBackground"); + Color activeSelectionForeground = UIManager.getColor("Table.selectionForeground"); + Color selectionForeground = activeSelectionForeground != null + ? activeSelectionForeground : table.getSelectionForeground(); + setBackground(activeSelectionBackground != null ? activeSelectionBackground : table.getSelectionBackground()); + titleLabel.setForeground(selectionForeground); + subtitleLabel.setForeground(selectionForeground); + } else if (isRolloverRow(table, row)) { + setBackground(getHoverBackground(table)); + titleLabel.setForeground(table.getForeground()); + subtitleLabel.setForeground(mutedForeground); + } else { + setBackground(table.getBackground()); + titleLabel.setForeground(table.getForeground()); + subtitleLabel.setForeground(mutedForeground); + } + + return this; + } + + private JPanel createTextPanel() { + JPanel textPanel = new JPanel(new GridBagLayout()); + textPanel.setOpaque(false); + + GridBagConstraints constraints = new GridBagConstraints(); + constraints.gridx = 0; + constraints.gridy = 0; + constraints.anchor = GridBagConstraints.WEST; + constraints.weightx = 1.0; + constraints.fill = GridBagConstraints.HORIZONTAL; + textPanel.add(titleLabel, constraints); + + constraints = (GridBagConstraints) constraints.clone(); + constraints.gridy = 1; + constraints.insets = new Insets(InstanceRowStyle.TITLE_SUBTITLE_GAP, 0, 0, 0); + textPanel.add(subtitleLabel, constraints); + + return textPanel; + } + + private static boolean isRolloverRow(JTable table, int row) { + Object rolloverRow = table.getClientProperty("launcher.rolloverRow"); + return rolloverRow instanceof Integer && ((Integer) rolloverRow).intValue() == row; + } + + private static Color getHoverBackground(JTable table) { + Color hover = UIManager.getColor("Table.selectionBackground"); + if (hover == null) { + hover = table.getSelectionBackground(); + } + return new Color(hover.getRed(), hover.getGreen(), hover.getBlue(), InstanceRowStyle.HOVER_ALPHA); + } + +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableModel.java b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableModel.java index 583011d25..1a391c93f 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableModel.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/InstanceTableModel.java @@ -13,19 +13,29 @@ import javax.swing.*; import javax.swing.table.AbstractTableModel; +import java.io.File; public class InstanceTableModel extends AbstractTableModel { + private static final int DOWNLOAD_ICON_SIZE = 24; + private final InstanceList instances; + private final InstanceIconCache iconCache; private final Icon instanceIcon; private final Icon customInstanceIcon; private final Icon downloadIcon; - public InstanceTableModel(InstanceList instances) { - this.instances = instances; - instanceIcon = SwingHelper.createIcon(Launcher.class, "instance_icon.png", 16, 16); - customInstanceIcon = SwingHelper.createIcon(Launcher.class, "custom_instance_icon.png", 16, 16); - downloadIcon = SwingHelper.createIcon(Launcher.class, "download_icon.png", 14, 14); + public InstanceTableModel(Launcher launcher) { + this.instances = launcher.getInstances(); + this.iconCache = new InstanceIconCache( + new File(launcher.getBaseDir(), "cache/icons"), + launcher.getExecutor(), + () -> SwingUtilities.invokeLater(this::fireTableDataChanged)); + instanceIcon = SwingHelper.createIcon(Launcher.class, "instance_icon.png", + InstanceRowStyle.ICON_SIZE, InstanceRowStyle.ICON_SIZE); + customInstanceIcon = SwingHelper.createIcon(Launcher.class, "custom_instance_icon.png", + InstanceRowStyle.ICON_SIZE, InstanceRowStyle.ICON_SIZE); + downloadIcon = SwingHelper.createIcon(Launcher.class, "download_icon.png", DOWNLOAD_ICON_SIZE, DOWNLOAD_ICON_SIZE); } public void update() { @@ -35,50 +45,30 @@ public void update() { @Override public String getColumnName(int columnIndex) { - switch (columnIndex) { - case 0: - return ""; - case 1: - return SharedLocale.tr("launcher.modpackColumn"); - default: - return null; + if (columnIndex == 0) { + return SharedLocale.tr("launcher.modpackColumn"); } + return null; } @Override public Class getColumnClass(int columnIndex) { - switch (columnIndex) { - case 0: - return ImageIcon.class; - case 1: - return String.class; - default: - return null; + if (columnIndex == 0) { + return Instance.class; } + return null; } @Override public void setValueAt(Object value, int rowIndex, int columnIndex) { - switch (columnIndex) { - case 0: - instances.get(rowIndex).setSelected((boolean) (Boolean) value); - break; - case 1: - default: - break; + if (columnIndex == 0) { + instances.get(rowIndex).setSelected((boolean) (Boolean) value); } } @Override public boolean isCellEditable(int rowIndex, int columnIndex) { - switch (columnIndex) { - case 0: - return false; - case 1: - return false; - default: - return false; - } + return false; } @Override @@ -88,28 +78,30 @@ public int getRowCount() { @Override public int getColumnCount() { - return 2; + return 1; } @Override public Object getValueAt(int rowIndex, int columnIndex) { - Instance instance; - switch (columnIndex) { - case 0: - instance = instances.get(rowIndex); - if (!instance.isLocal()) { - return downloadIcon; - } else if (instance.getManifestURL() != null) { - return instanceIcon; - } else { - return customInstanceIcon; - } - case 1: - instance = instances.get(rowIndex); - return instance.getTitle(); - default: - return null; + if (columnIndex == 0) { + return instances.get(rowIndex); } + return null; + } + + public Icon getIcon(Instance instance) { + if (!instance.isLocal()) { + return downloadIcon; + } else if (instance.getManifestURL() != null) { + Icon remote = iconCache.get(instance.getIconUrl()); + return remote != null ? remote : instanceIcon; + } else { + return customInstanceIcon; + } + } + + public int getIconColumnWidth() { + return InstanceRowStyle.ICON_SIZE; } } diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/LauncherComboBoxUI.java b/launcher/src/main/java/com/skcraft/launcher/swing/LauncherComboBoxUI.java new file mode 100644 index 000000000..5daa21373 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/LauncherComboBoxUI.java @@ -0,0 +1,45 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.formdev.flatlaf.ui.FlatComboBoxUI; +import com.formdev.flatlaf.ui.FlatUIUtils; + +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.plaf.ComponentUI; +import java.awt.Graphics2D; + +/** + * FlatMac paints up+down chevrons on non-editable combos and overlays the popup + * on the field. Keep mac button chrome, but use a single down arrow and pop down + * below the combo (same as editable FlatMac combos / Aqua isPopDown). + */ +public class LauncherComboBoxUI extends FlatComboBoxUI { + + public static ComponentUI createUI(JComponent c) { + return new LauncherComboBoxUI(); + } + + @Override + public void installUI(JComponent c) { + super.installUI(c); + // FlatMac overlays the popup over the combo; force standard drop-down placement. + c.putClientProperty("JComboBox.isPopDown", Boolean.TRUE); + } + + @Override + protected JButton createArrowButton() { + return new FlatComboBoxButton() { + @Override + protected void paintArrow(Graphics2D g) { + FlatUIUtils.paintArrow(g, 0, 0, getWidth(), getHeight(), getDirection(), chevron, + getArrowWidth(), getArrowThickness(), getXOffset(), getYOffset()); + } + }; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/LauncherLookAndFeel.java b/launcher/src/main/java/com/skcraft/launcher/swing/LauncherLookAndFeel.java new file mode 100644 index 000000000..b1f99dbe3 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/LauncherLookAndFeel.java @@ -0,0 +1,208 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import com.skcraft.launcher.Configuration; +import com.formdev.flatlaf.FlatLaf; +import com.formdev.flatlaf.themes.FlatMacDarkLaf; +import com.formdev.flatlaf.themes.FlatMacLightLaf; +import lombok.extern.java.Log; + +import javax.swing.*; +import javax.swing.text.JTextComponent; +import java.awt.AWTEvent; +import java.awt.Component; +import java.awt.KeyboardFocusManager; +import java.awt.Toolkit; +import java.awt.Window; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; +import java.awt.event.WindowEvent; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.logging.Level; + +@Log +public final class LauncherLookAndFeel { + + private static final String CUSTOM_DEFAULTS_SOURCE = "com.skcraft.launcher.theme"; + private static boolean clickToClearFocusInstalled; + private static boolean escapeToCloseDialogInstalled; + + private LauncherLookAndFeel() { + } + + public static void install(String themeMode) { + try { + FlatLaf.registerCustomDefaultsSource(CUSTOM_DEFAULTS_SOURCE); + FlatLaf.setUseNativeWindowDecorations(true); + if (isDarkTheme(themeMode)) { + FlatMacDarkLaf.setup(); + } else { + FlatMacLightLaf.setup(); + } + } catch (Exception e) { + log.log(Level.WARNING, "Failed to set FlatLaf, falling back to system look and feel", e); + try { + UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); + } catch (Exception fallbackEx) { + log.log(Level.WARNING, "Failed to set fallback look and feel", fallbackEx); + } + } + installClickToClearFocus(); + installEscapeToCloseDialog(); + } + + public static void applyTheme(String themeMode) { + install(themeMode); + FlatLaf.updateUI(); + } + + private static synchronized void installClickToClearFocus() { + if (clickToClearFocusInstalled) { + return; + } + + Toolkit.getDefaultToolkit().addAWTEventListener(event -> { + if (!(event instanceof MouseEvent) || event.getID() != MouseEvent.MOUSE_RELEASED) { + return; + } + + Component clicked = ((MouseEvent) event).getComponent(); + if (requiresMouseFocus(clicked)) { + return; + } + + KeyboardFocusManager.getCurrentKeyboardFocusManager().clearGlobalFocusOwner(); + }, AWTEvent.MOUSE_EVENT_MASK); + clickToClearFocusInstalled = true; + } + + private static synchronized void installEscapeToCloseDialog() { + if (escapeToCloseDialogInstalled) { + return; + } + + Toolkit.getDefaultToolkit().addAWTEventListener(event -> { + if (!(event instanceof KeyEvent) || event.getID() != KeyEvent.KEY_PRESSED) { + return; + } + + KeyEvent keyEvent = (KeyEvent) event; + if (keyEvent.getKeyCode() != KeyEvent.VK_ESCAPE || keyEvent.isConsumed()) { + return; + } + + MenuElement[] path = MenuSelectionManager.defaultManager().getSelectedPath(); + if (path != null && path.length > 0) { + return; + } + + Window active = KeyboardFocusManager.getCurrentKeyboardFocusManager().getActiveWindow(); + if (!(active instanceof JDialog) || !active.isDisplayable() || !active.isShowing()) { + return; + } + + active.dispatchEvent(new WindowEvent(active, WindowEvent.WINDOW_CLOSING)); + keyEvent.consume(); + }, AWTEvent.KEY_EVENT_MASK); + escapeToCloseDialogInstalled = true; + } + + private static boolean requiresMouseFocus(Component component) { + for (Component current = component; current != null; current = current.getParent()) { + if (current instanceof JTextComponent + || current instanceof JComboBox + || current instanceof JSpinner + || current instanceof JTable + || current instanceof JList + || current instanceof JTree + || current instanceof JSlider) { + return true; + } + if (current instanceof Window) { + break; + } + } + return false; + } + + public static boolean isDarkTheme(String themeMode) { + String normalized = normalizeThemeMode(themeMode); + if (Configuration.THEME_DARK.equals(normalized)) { + return true; + } + if (Configuration.THEME_SYSTEM.equals(normalized)) { + return isSystemDarkTheme(); + } + return false; + } + + private static String normalizeThemeMode(String themeMode) { + if (themeMode == null || themeMode.trim().isEmpty()) { + return Configuration.THEME_LIGHT; + } + + String normalized = themeMode.trim().toLowerCase(Locale.ROOT); + if (Configuration.THEME_DARK.equals(normalized)) { + return Configuration.THEME_DARK; + } + if (Configuration.THEME_SYSTEM.equals(normalized)) { + return Configuration.THEME_SYSTEM; + } + return Configuration.THEME_LIGHT; + } + + private static boolean isSystemDarkTheme() { + String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + try { + if (os.contains("win")) { + return isWindowsSystemDarkTheme(); + } + if (os.contains("mac")) { + return isMacSystemDarkTheme(); + } + } catch (InterruptedException e) { + log.log(Level.FINE, "Unable to read OS theme mode", e); + Thread.currentThread().interrupt(); + } catch (IOException e) { + log.log(Level.FINE, "Unable to read OS theme mode", e); + } + return false; + } + + private static boolean isWindowsSystemDarkTheme() throws IOException, InterruptedException { + String output = runCommand("reg", "query", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", "AppsUseLightTheme"); + return output.contains("0x0"); + } + + private static boolean isMacSystemDarkTheme() throws IOException, InterruptedException { + String output = runCommand("defaults", "read", "-g", "AppleInterfaceStyle"); + return output.toLowerCase(Locale.ROOT).contains("dark"); + } + + private static String runCommand(String... command) throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).start(); + StringBuilder output = new StringBuilder(); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + } + + process.waitFor(); + return output.toString(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/LinkButton.java b/launcher/src/main/java/com/skcraft/launcher/swing/LinkButton.java index 30c45fba2..5b17eec2b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/LinkButton.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/LinkButton.java @@ -14,9 +14,8 @@ public class LinkButton extends JButton { - private static final Color LINK_COLOR = Color.blue; - private static final Border LINK_BORDER = BorderFactory.createEmptyBorder(0, 0, 1, 0); - private static final Border HOVER_BORDER = BorderFactory.createMatteBorder(0, 0, 1, 0, LINK_COLOR); + private static final Color FALLBACK_LINK_COLOR = Color.blue; + private static final Border LINK_BORDER = BorderFactory.createEmptyBorder(0, 0, 1, 0); public LinkButton() { super(); @@ -44,16 +43,16 @@ public LinkButton(String text) { } public void setupLink() { - setBorder(LINK_BORDER); - setForeground(LINK_COLOR); - setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); - setFocusPainted(false); - setRequestFocusEnabled(false); + setBorder(LINK_BORDER); + setForeground(getLinkColor()); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + setFocusPainted(false); + setRequestFocusEnabled(false); setContentAreaFilled(false); addMouseListener(new MouseAdapter() { @Override public void mouseEntered(MouseEvent e) { - ((JComponent) e.getComponent()).setBorder(HOVER_BORDER); + ((JComponent) e.getComponent()).setBorder(createHoverBorder()); } @Override @@ -68,4 +67,13 @@ public void mouseExited(MouseEvent e) { }); } + private static Border createHoverBorder() { + return BorderFactory.createMatteBorder(0, 0, 1, 0, getLinkColor()); + } + + private static Color getLinkColor() { + Color linkColor = UIManager.getColor("Component.linkColor"); + return linkColor != null ? linkColor : FALLBACK_LINK_COLOR; + } + } diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/MessageLog.java b/launcher/src/main/java/com/skcraft/launcher/swing/MessageLog.java index 128154010..33ac265ce 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/MessageLog.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/MessageLog.java @@ -6,8 +6,11 @@ package com.skcraft.launcher.swing; -import com.skcraft.launcher.LauncherUtils; +import com.skcraft.launcher.launch.GameLogBuffer; +import com.skcraft.launcher.launch.GameLogLevel; +import com.skcraft.launcher.launch.GameLogLine; import com.skcraft.launcher.util.LimitLinesDocumentListener; +import com.skcraft.launcher.util.LogBuffer; import com.skcraft.launcher.util.SimpleLogFormatter; import javax.swing.*; @@ -17,6 +20,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.util.ArrayList; +import java.util.List; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -30,51 +35,88 @@ public class MessageLog extends JPanel { private static final Logger rootLogger = Logger.getLogger(""); - + + // In game mode this mirrors the external ring capacity; the document listener + // is disabled. private final int numLines; private final boolean colorEnabled; - + private final boolean lineWrap; + private final boolean limitLinesInDocument; + private final boolean gameMode; + protected JTextComponent textComponent; protected Document document; + private JScrollPane scrollText; + private GameLogBuffer gameLogBuffer; + private Runnable gameLogDirtyCallback; private Handler loggerHandler; protected final SimpleAttributeSet defaultAttributes = new SimpleAttributeSet(); protected final SimpleAttributeSet highlightedAttributes; + protected final SimpleAttributeSet warnAttributes; protected final SimpleAttributeSet errorAttributes; + protected final SimpleAttributeSet fatalAttributes; protected final SimpleAttributeSet infoAttributes; protected final SimpleAttributeSet debugAttributes; + protected final SimpleAttributeSet traceAttributes; public MessageLog(int numLines, boolean colorEnabled) { + this(numLines, colorEnabled, true, true); + } + + public MessageLog(int numLines, boolean colorEnabled, boolean lineWrap) { + this(numLines, colorEnabled, lineWrap, true); + } + + public MessageLog(int numLines, boolean colorEnabled, boolean lineWrap, boolean limitLinesInDocument) { + this(numLines, colorEnabled, lineWrap, limitLinesInDocument, false); + } + + private MessageLog(int numLines, boolean colorEnabled, boolean lineWrap, + boolean limitLinesInDocument, boolean gameMode) { this.numLines = numLines; this.colorEnabled = colorEnabled; - + this.lineWrap = lineWrap; + this.limitLinesInDocument = limitLinesInDocument; + this.gameMode = gameMode; + this.highlightedAttributes = new SimpleAttributeSet(); StyleConstants.setForeground(highlightedAttributes, new Color(0xFF7F00)); - + this.warnAttributes = new SimpleAttributeSet(); + StyleConstants.setForeground(warnAttributes, new Color(0xFF7F00)); this.errorAttributes = new SimpleAttributeSet(); - StyleConstants.setForeground(errorAttributes, new Color(0xFF0000)); + StyleConstants.setForeground(errorAttributes, new Color(0xFF3300)); + this.fatalAttributes = new SimpleAttributeSet(); + StyleConstants.setForeground(fatalAttributes, new Color(0xC00000)); this.infoAttributes = new SimpleAttributeSet(); this.debugAttributes = new SimpleAttributeSet(); + StyleConstants.setForeground(debugAttributes, new Color(0x5B9BD5)); + this.traceAttributes = new SimpleAttributeSet(); + StyleConstants.setForeground(traceAttributes, new Color(0xDDEBF7)); setLayout(new BorderLayout()); - + initComponents(); } + public static MessageLog createGameLog(int numLines) { + return new MessageLog(numLines, true, false, false, true); + } + private void initComponents() { if (colorEnabled) { JTextPane text = new JTextPane() { @Override public boolean getScrollableTracksViewportWidth() { - return true; + return lineWrap; } }; this.textComponent = text; } else { JTextArea text = new JTextArea(); this.textComponent = text; - text.setLineWrap(true); - text.setWrapStyleWord(true); + text.setLineWrap(lineWrap); + text.setWrapStyleWord(lineWrap); } textComponent.setFont(new JLabel().getFont()); @@ -83,18 +125,21 @@ public boolean getScrollableTracksViewportWidth() { DefaultCaret caret = (DefaultCaret) textComponent.getCaret(); caret.setUpdatePolicy(DefaultCaret.NEVER_UPDATE); document = textComponent.getDocument(); - document.addDocumentListener(new LimitLinesDocumentListener(numLines, true)); - - JScrollPane scrollText = new JScrollPane(textComponent); + if (limitLinesInDocument) { + document.addDocumentListener(new LimitLinesDocumentListener(numLines, true)); + } + + scrollText = new JScrollPane(textComponent); scrollText.setBorder(null); scrollText.setVerticalScrollBarPolicy( ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); scrollText.setHorizontalScrollBarPolicy( - ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); - + lineWrap ? ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + : ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED); + add(scrollText, BorderLayout.CENTER); } - + public String getPastableText() { String text = textComponent.getText().replaceAll("[\r\n]+", "\n"); text = text.replaceAll("Session ID is [A-Fa-f0-9]+", "Session ID is [redacted]"); @@ -102,40 +147,264 @@ public String getPastableText() { } public void clear() { + if (gameMode) { + runOnEventDispatchThread(() -> { + if (gameLogBuffer != null) { + gameLogBuffer.clear(); + } + textComponent.setText(""); + }); + } else { + textComponent.setText(""); + } + } + + public void bindGameBuffer(GameLogBuffer buffer, Runnable onDirty) { + if (!gameMode) { + throw new IllegalStateException("A game log buffer can only be bound in game mode"); + } + if (!SwingUtilities.isEventDispatchThread()) { + throw new IllegalStateException("A game log buffer must be bound on the EDT"); + } + + gameLogBuffer = buffer; + gameLogDirtyCallback = onDirty; textComponent.setText(""); + applyBufferDelta(buffer); + } + + public void refreshFromBuffer(GameLogBuffer buffer) { + if (!gameMode) { + throw new IllegalStateException("A game log buffer can only be refreshed in game mode"); + } + + runOnEventDispatchThread(() -> { + if (gameLogBuffer != buffer) { + throw new IllegalStateException("The game log buffer has not been bound"); + } else { + applyBufferDelta(buffer); + } + }); } - + + public void setTailContent(final String text) { + if (gameMode) { + runOnEventDispatchThread(() -> { + gameLogBuffer.clear(); + appendPlainLines(splitLines(text)); + applyBufferDelta(gameLogBuffer); + }); + return; + } + + if (SwingUtilities.isEventDispatchThread()) { + textComponent.setText(text); + scrollToLatest(); + return; + } + + SwingUtilities.invokeLater(() -> { + textComponent.setText(text); + scrollToLatest(); + }); + } + /** * Log a message given the {@link javax.swing.text.AttributeSet}. * - * @param line line + * @param line line * @param attributes attribute set, or null for none */ public void log(final String line, AttributeSet attributes) { + if (gameMode) { + appendPlainLines(splitLines(line)); + gameLogDirtyCallback.run(); + return; + } + final Document d = document; - final JTextComponent t = textComponent; if (colorEnabled) { if (line.startsWith("(!!)")) { attributes = highlightedAttributes; } } final AttributeSet a = attributes; - - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { - try { - int offset = d.getLength(); - d.insertString(offset, line, - (a != null && colorEnabled) ? a : defaultAttributes); - t.setCaretPosition(d.getLength()); - } catch (BadLocationException ble) { - - } + + SwingUtilities.invokeLater(() -> { + try { + int offset = d.getLength(); + d.insertString(offset, line, + (a != null && colorEnabled) ? a : defaultAttributes); + scrollToLatest(); + } catch (BadLocationException ble) { + } }); } - + + private void scrollToLatest() { + if (lineWrap) { + textComponent.setCaretPosition(textComponent.getDocument().getLength()); + return; + } + + if (scrollText == null) { + return; + } + + JScrollBar vertical = scrollText.getVerticalScrollBar(); + if (vertical != null) { + vertical.setValue(vertical.getMaximum()); + } + } + + private void applyBufferDelta(GameLogBuffer buffer) { + boolean autoFollow = isPinnedToBottom(); + JScrollBar horizontal = scrollText != null ? scrollText.getHorizontalScrollBar() : null; + int horizontalValue = horizontal != null ? horizontal.getValue() : 0; + int selectionStart = textComponent.getSelectionStart(); + int selectionEnd = textComponent.getSelectionEnd(); + GameLogBuffer.SyncDelta delta = buffer.drainDelta(); + if (delta.getDropped() == 0 && delta.getAppended().isEmpty()) { + return; + } + + try { + int removedLength = removeLeadingLines(delta.getDropped()); + appendLines(delta.getAppended()); + + if (!autoFollow) { + int documentLength = document.getLength(); + int adjustedStart = Math.min(documentLength, Math.max(0, selectionStart - removedLength)); + int adjustedEnd = Math.min(documentLength, Math.max(0, selectionEnd - removedLength)); + textComponent.select(adjustedStart, adjustedEnd); + } + schedulePostLayoutScroll(autoFollow, horizontalValue); + } catch (BadLocationException e) { + throw new IllegalStateException("Failed to update game log document", e); + } + } + + private void schedulePostLayoutScroll(boolean autoFollow, int horizontalValue) { + JScrollBar vertical = scrollText != null ? scrollText.getVerticalScrollBar() : null; + int scheduledValue = vertical != null ? vertical.getValue() : 0; + SwingUtilities.invokeLater(() -> { + JScrollBar currentVertical = scrollText != null ? scrollText.getVerticalScrollBar() : null; + boolean userHasNotMovedUp = currentVertical == null || currentVertical.getValue() >= scheduledValue; + if (autoFollow + && userHasNotMovedUp + && textComponent.getSelectionStart() == textComponent.getSelectionEnd()) { + scrollToLatest(); + } + JScrollBar currentHorizontal = scrollText != null ? scrollText.getHorizontalScrollBar() : null; + if (currentHorizontal != null) { + currentHorizontal.setValue(horizontalValue); + } + }); + } + + private int removeLeadingLines(int lineCount) throws BadLocationException { + if (lineCount <= 0 || document.getLength() == 0) { + return 0; + } + + Element root = document.getDefaultRootElement(); + int removeEnd = lineCount >= root.getElementCount() + ? document.getLength() + : root.getElement(lineCount).getStartOffset(); + document.remove(0, removeEnd); + return removeEnd; + } + + private void appendLines(List lines) throws BadLocationException { + if (lines.isEmpty()) { + return; + } + + boolean needsSeparator = document.getLength() > 0; + StringBuilder run = new StringBuilder(lines.size() * 80); + GameLogLevel runLevel = null; + + for (GameLogLine line : lines) { + GameLogLevel level = line.getLevel(); + if (runLevel != null && level != runLevel) { + document.insertString(document.getLength(), run.toString(), attributesFor(runLevel)); + run.setLength(0); + } + runLevel = level; + + if (needsSeparator) { + run.append('\n'); + } + run.append(line.getText()); + needsSeparator = true; + } + + if (runLevel != null) { + document.insertString(document.getLength(), run.toString(), attributesFor(runLevel)); + } + } + + private AttributeSet attributesFor(GameLogLevel level) { + switch (level) { + case FATAL: + return fatalAttributes; + case ERROR: + return errorAttributes; + case WARN: + return warnAttributes; + case DEBUG: + return debugAttributes; + case TRACE: + return traceAttributes; + case INFO: + default: + return defaultAttributes; + } + } + + private void appendPlainLines(List lines) { + for (String line : lines) { + gameLogBuffer.append(line); + } + } + + private boolean isPinnedToBottom() { + if (textComponent.getSelectionStart() != textComponent.getSelectionEnd()) { + return false; + } + JScrollBar vertical = scrollText != null ? scrollText.getVerticalScrollBar() : null; + int bottomTolerance = textComponent.getFontMetrics(textComponent.getFont()).getHeight(); + return document.getLength() == 0 + || vertical == null + || vertical.getMaximum() - vertical.getValue() - vertical.getVisibleAmount() <= bottomTolerance; + } + + private static List splitLines(String text) { + List lines = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return lines; + } + + String[] split = text.replace("\r", "").split("\n", -1); + int length = split.length; + if (length > 0 && split[length - 1].isEmpty()) { + length--; + } + for (int i = 0; i < length; i++) { + lines.add(split[i]); + } + return lines; + } + + private static void runOnEventDispatchThread(Runnable task) { + if (SwingUtilities.isEventDispatchThread()) { + task.run(); + } else { + SwingUtilities.invokeLater(task); + } + } + /** * Get an output stream that can be written to. * @@ -144,7 +413,7 @@ public void run() { public ConsoleOutputStream getOutputStream() { return getOutputStream((AttributeSet) null); } - + /** * Get an output stream with the given attribute set. * @@ -166,7 +435,7 @@ public ConsoleOutputStream getOutputStream(Color color) { StyleConstants.setForeground(attributes, color); return getOutputStream(attributes); } - + /** * Consume an input stream and print it to the dialog. The consumer * will be in a separate daemon thread. @@ -181,7 +450,7 @@ public void consume(InputStream from) { * Consume an input stream and print it to the dialog. The consumer * will be in a separate daemon thread. * - * @param from stream to read + * @param from stream to read * @param color color to use */ public void consume(InputStream from, Color color) { @@ -192,39 +461,35 @@ public void consume(InputStream from, Color color) { * Consume an input stream and print it to the dialog. The consumer * will be in a separate daemon thread. * - * @param from stream to read + * @param from stream to read * @param attributes attributes */ public void consume(InputStream from, AttributeSet attributes) { consume(from, getOutputStream(attributes)); } - + /** * Internal method to consume a stream. * - * @param from stream to consume + * @param from stream to consume * @param outputStream console stream to write to */ private void consume(InputStream from, ConsoleOutputStream outputStream) { final InputStream in = from; final PrintWriter out = new PrintWriter(outputStream, true); - Thread thread = new Thread(new Runnable() { - @Override - public void run() { - byte[] buffer = new byte[1024]; - try { - int len; - while ((len = in.read(buffer)) != -1) { - String s = new String(buffer, 0, len); - System.out.print(s); - out.append(s); - out.flush(); - } - } catch (IOException e) { - } finally { - closeQuietly(in); - closeQuietly(out); + Thread thread = new Thread(() -> { + byte[] buffer = new byte[1024]; + try { + int len; + while ((len = in.read(buffer)) != -1) { + String s = new String(buffer, 0, len); + out.append(s); + out.flush(); } + } catch (IOException e) { + } finally { + closeQuietly(in); + closeQuietly(out); } }); thread.setDaemon(true); @@ -232,13 +497,25 @@ public void run() { } /** - * Register a global logger listener. + * Register a global logger listener, replaying buffered history first. */ public void registerLoggerHandler() { + if (loggerHandler != null) { + return; + } + loggerHandler = new ConsoleLoggerHandler(); + + LogBuffer buffer = LogBuffer.get(); + if (buffer != null) { + for (LogRecord record : buffer.snapshot()) { + loggerHandler.publish(record); + } + } + rootLogger.addHandler(loggerHandler); } - + /** * Detach the handler on the global logger. */ @@ -270,15 +547,20 @@ public SimpleAttributeSet asDebug() { } /** - * Used to send logger messages to the console. + * Used to send logger messages to the launcher console. + * Ignores records that are not from the launcher (or its libraries under + * com.skcraft). */ private class ConsoleLoggerHandler extends Handler { private final SimpleLogFormatter formatter = new SimpleLogFormatter(); @Override public void publish(LogRecord record) { + if (record == null || !isLoggable(record) || !isLauncherRecord(record)) { + return; + } + Level level = record.getLevel(); - Throwable t = record.getThrown(); AttributeSet attributes = defaultAttributes; if (level.intValue() >= Level.WARNING.intValue()) { @@ -290,6 +572,11 @@ public void publish(LogRecord record) { log(formatter.format(record), attributes); } + private boolean isLauncherRecord(LogRecord record) { + String name = record.getLoggerName(); + return name == null || name.startsWith("com.skcraft"); + } + @Override public void flush() { } @@ -298,21 +585,22 @@ public void flush() { public void close() throws SecurityException { } } - + /** * Used to send console messages to the console. */ private class ConsoleOutputStream extends ByteArrayOutputStream { private AttributeSet attributes; - + private ConsoleOutputStream(AttributeSet attributes) { this.attributes = attributes; } - + @Override public void flush() { String data = toString(); - if (data.length() == 0) return; + if (data.length() == 0) + return; log(data, attributes); reset(); } diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/SwingHelper.java b/launcher/src/main/java/com/skcraft/launcher/swing/SwingHelper.java index 1ee7a1c50..a2bf86176 100644 --- a/launcher/src/main/java/com/skcraft/launcher/swing/SwingHelper.java +++ b/launcher/src/main/java/com/skcraft/launcher/swing/SwingHelper.java @@ -21,9 +21,8 @@ import javax.imageio.ImageIO; import javax.swing.*; +import javax.swing.border.AbstractBorder; import javax.swing.border.Border; -import javax.swing.plaf.basic.BasicSplitPaneDivider; -import javax.swing.plaf.basic.BasicSplitPaneUI; import javax.swing.text.JTextComponent; import java.awt.*; import java.awt.datatransfer.Clipboard; @@ -64,6 +63,18 @@ public void lostOwnership(Clipboard clipboard, Transferable contents) { private SwingHelper() { } + private static Font getDetailsTextFont() { + Font baseFont = UIManager.getFont("TextArea.font"); + if (baseFont == null) { + baseFont = UIManager.getFont("Label.font"); + } + if (baseFont == null) { + return new Font(Font.MONOSPACED, Font.PLAIN, 12); + } + return new Font(Font.MONOSPACED, Font.PLAIN, baseFont.getSize()) + .deriveFont(Math.max(11f, baseFont.getSize2D() - 1f)); + } + public static String htmlEscape(String str) { return str.replace(">", ">") .replace("<", "<") @@ -92,7 +103,7 @@ public static void browseDir(File file, Component component) { /** * Opens a system web browser for the given URL. * - * @param url the URL + * @param url the URL * @param parentComponent the component from which to show any errors */ public static void openURL(@NonNull String url, @NonNull Component parentComponent) { @@ -105,7 +116,7 @@ public static void openURL(@NonNull String url, @NonNull Component parentCompone /** * Opens a system web browser for the given URL. * - * @param url the URL + * @param url the URL * @param parentComponent the component from which to show any errors */ public static void openURL(URL url, Component parentComponent) { @@ -124,39 +135,46 @@ public static void openURL(URI url) throws IOException { } catch (UnsupportedOperationException e) { if (Environment.detectPlatform() == Platform.LINUX) { // Try xdg-open instead - Runtime.getRuntime().exec(new String[]{"xdg-open", url.toString()}); + Runtime.getRuntime().exec(new String[] { "xdg-open", url.toString() }); } } } /** - * Shows an popup error dialog, with potential extra details shown either immediately + * Shows an popup error dialog, with potential extra details shown either + * immediately * or available on the dialog. * - * @param parentComponent the frame from which the dialog is displayed, otherwise + * @param parentComponent the frame from which the dialog is displayed, + * otherwise * null to use the default frame - * @param message the message to display - * @param title the title string for the dialog - * @see #showMessageDialog(java.awt.Component, String, String, String, int) for details + * @param message the message to display + * @param title the title string for the dialog + * @see #showMessageDialog(java.awt.Component, String, String, String, int) for + * details */ public static void showErrorDialog(Component parentComponent, @NonNull String message, - @NonNull String title) { + @NonNull String title) { showErrorDialog(parentComponent, message, title, null); } /** - * Shows an popup error dialog, with potential extra details shown either immediately + * Shows an popup error dialog, with potential extra details shown either + * immediately * or available on the dialog. * - * @param parentComponent the frame from which the dialog is displayed, otherwise + * @param parentComponent the frame from which the dialog is displayed, + * otherwise * null to use the default frame - * @param message the message to display - * @param title the title string for the dialog - * @param throwable the exception, or null if there is no exception to show - * @see #showMessageDialog(java.awt.Component, String, String, String, int) for details + * @param message the message to display + * @param title the title string for the dialog + * @param throwable the exception, or null if there is no exception to + * show + * @see #showMessageDialog(java.awt.Component, String, String, String, int) for + * details */ public static void showErrorDialog(Component parentComponent, @NonNull String message, - @NonNull String title, Throwable throwable) { + @NonNull String title, Throwable throwable) { String detailsText = null; // Get a string version of the exception and use that for @@ -176,22 +194,26 @@ public static void showErrorDialog(Component parentComponent, @NonNull String me * Show a message dialog using * {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)}. * - *

The dialog will be shown from the Event Dispatch Thread, regardless of the + *

+ * The dialog will be shown from the Event Dispatch Thread, regardless of the * thread it is called from. In either case, the method will block until the - * user has closed the dialog (or dialog creation fails for whatever reason).

+ * user has closed the dialog (or dialog creation fails for whatever reason). + *

* - * @param parentComponent the frame from which the dialog is displayed, otherwise + * @param parentComponent the frame from which the dialog is displayed, + * otherwise * null to use the default frame - * @param message the message to display - * @param title the title string for the dialog - * @param messageType see {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)} - * for available message types + * @param message the message to display + * @param title the title string for the dialog + * @param messageType see + * {@link javax.swing.JOptionPane#showMessageDialog(java.awt.Component, Object, String, int)} + * for available message types */ public static void showMessageDialog(final Component parentComponent, - @NonNull final String message, - @NonNull final String title, - final String detailsText, - final int messageType) { + @NonNull final String message, + @NonNull final String title, + final String detailsText, + final int messageType) { if (SwingUtilities.isEventDispatchThread()) { String htmlMessage = htmlWrap(message); @@ -205,7 +227,7 @@ public static void showMessageDialog(final Component parentComponent, if (detailsText != null) { JTextArea textArea = new JTextArea(SharedLocale.tr("errors.reportErrorPreface") + detailsText); JLabel tempLabel = new JLabel(); - textArea.setFont(tempLabel.getFont()); + textArea.setFont(getDetailsTextFont()); textArea.setBackground(tempLabel.getBackground()); textArea.setTabSize(2); textArea.setEditable(false); @@ -241,17 +263,16 @@ public void run() { * Asks the user a binary yes or no question. * * @param parentComponent the component - * @param message the message to display - * @param title the title string for the dialog + * @param message the message to display + * @param title the title string for the dialog * @return whether 'yes' was selected */ public static boolean confirmDialog(final Component parentComponent, - @NonNull final String message, - @NonNull final String title) { + @NonNull final String message, + @NonNull final String title) { if (SwingUtilities.isEventDispatchThread()) { return JOptionPane.showConfirmDialog( - parentComponent, message, title, JOptionPane.YES_NO_OPTION) == - JOptionPane.YES_OPTION; + parentComponent, message, title, JOptionPane.YES_NO_OPTION) == JOptionPane.YES_OPTION; } else { // Use an AtomicBoolean to pass the result back from the // Event Dispatcher Thread @@ -279,7 +300,7 @@ public void run() { * * @param component component */ - public static void equalWidth(Component ... component) { + public static void equalWidth(Component... component) { double widest = 0; for (Component comp : component) { Dimension dim = comp.getPreferredSize(); @@ -299,7 +320,7 @@ public static void equalWidth(Component ... component) { * * @param components list of components */ - public static void removeOpaqueness(@NonNull Component ... components) { + public static void removeOpaqueness(@NonNull Component... components) { for (Component component : components) { if (component instanceof JComponent) { JComponent jComponent = (JComponent) component; @@ -361,8 +382,11 @@ public static void setFrameIcon(JFrame frame, Class clazz, String path) { /** * Focus a component. * - *

The focus call happens in {@link javax.swing.SwingUtilities#invokeLater(Runnable)}.

- * + *

+ * The focus call happens in + * {@link javax.swing.SwingUtilities#invokeLater(Runnable)}. + *

+ * * @param component the component */ public static void focusLater(@NonNull final Component component) { @@ -378,18 +402,7 @@ public void run() { } public static void flattenJSplitPane(JSplitPane splitPane) { - splitPane.setBorder(BorderFactory.createEmptyBorder(1, 1, 1, 1)); - BasicSplitPaneUI flatDividerSplitPaneUI = new BasicSplitPaneUI() { - @Override - public BasicSplitPaneDivider createDefaultDivider() { - return new BasicSplitPaneDivider(this) { - @Override - public void setBorder(Border b) { - } - }; - } - }; - splitPane.setUI(flatDividerSplitPaneUI); + splitPane.setContinuousLayout(true); splitPane.setBorder(null); } @@ -405,28 +418,43 @@ public void onFailure(Throwable t) { return; } + // Prefer the human-readable launcher error; skip wrapper stacks (Futures, etc.). + LauncherException launcherException = findLauncherException(t); String message; - if (t instanceof LauncherException) { - message = t.getLocalizedMessage(); - t = t.getCause(); + Throwable details; + if (launcherException != null) { + message = launcherException.getLocalizedMessage(); + details = launcherException.getCause(); } else { message = t.getLocalizedMessage(); if (message == null) { message = SharedLocale.tr("errors.genericError"); } + details = t; } log.log(Level.WARNING, "Task failed", t); - SwingHelper.showErrorDialog(owner, message, SharedLocale.tr("errorTitle"), t); + SwingHelper.showErrorDialog(owner, message, SharedLocale.tr("errorTitle"), details); } }, SwingExecutor.INSTANCE); } + private static LauncherException findLauncherException(Throwable t) { + for (Throwable cur = t; cur != null; cur = cur.getCause()) { + if (cur instanceof LauncherException) { + return (LauncherException) cur; + } + if (cur.getCause() == cur) { + break; + } + } + return null; + } + public static Component alignTabbedPane(Component component) { JPanel container = new JPanel(); container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS)); container.add(component); container.add(new Box.Filler(new Dimension(0, 0), new Dimension(0, 10000), new Dimension(0, 10000))); - SwingHelper.removeOpaqueness(container); return container; } @@ -463,19 +491,138 @@ public static void addActionListeners(AbstractButton button, ActionListener[] li } } - public static boolean setLookAndFeel(String lookAndFeel) { - try { - UIManager.setLookAndFeel(lookAndFeel); - return true; - } catch (Exception e) { - log.log(Level.WARNING, "Failed to set look and feel to " + lookAndFeel, e); - return false; - } - } - public static void setSwingProperties(String appName) { UIManager.getDefaults().put("SplitPane.border", BorderFactory.createEmptyBorder()); System.setProperty("com.apple.mrj.application.apple.menu.about.name", appName); System.setProperty("apple.laf.useScreenMenuBar", "true"); } + + public static Color uiColor(String key, Color fallback) { + Color color = UIManager.getColor(key); + return color != null ? color : fallback; + } + + /** + * 1px line border that reads {@code Component.borderColor} on each paint + * so theme switches do not need {@code updateUI} border re-apply. + */ + public static Border uiLineBorder() { + return new AbstractBorder() { + @Override + public void paintBorder(Component c, Graphics g, int x, int y, int width, int height) { + g.setColor(uiColor("Component.borderColor", Color.GRAY)); + g.drawRect(x, y, width - 1, height - 1); + } + + @Override + public Insets getBorderInsets(Component c, Insets insets) { + insets.set(1, 1, 1, 1); + return insets; + } + + @Override + public boolean isBorderOpaque() { + return false; + } + }; + } + + /** + * Background color used by list/table panes such as the instances panel. + */ + public static Color tableBackground() { + return uiColor("Table.background", Color.WHITE); + } + + public static void applyTableBackground(JComponent... components) { + Color background = tableBackground(); + for (JComponent component : components) { + component.setBackground(background); + component.setOpaque(true); + } + } + + public static void styleDialogButton(JButton button) { + button.setFocusPainted(false); + updateDialogButtonCursor(button); + } + + /** + * Keep a min/max spinner pair consistent in real time: raising the minimum + * above + * the maximum pushes the maximum up to match, and lowering the maximum below + * the + * minimum pulls the minimum down to match. + */ + public static void linkMinMaxSpinners(final JSpinner minSpinner, final JSpinner maxSpinner) { + final AtomicBoolean adjusting = new AtomicBoolean(false); + minSpinner.addChangeListener(e -> clampSpinner(adjusting, minSpinner, maxSpinner, true)); + maxSpinner.addChangeListener(e -> clampSpinner(adjusting, minSpinner, maxSpinner, false)); + } + + private static void clampSpinner(AtomicBoolean adjusting, JSpinner minSpinner, JSpinner maxSpinner, + boolean minChanged) { + if (adjusting.get()) { + return; + } + Object minValue = minSpinner.getValue(); + Object maxValue = maxSpinner.getValue(); + if (!(minValue instanceof Number) || !(maxValue instanceof Number)) { + return; + } + if (((Number) minValue).doubleValue() <= ((Number) maxValue).doubleValue()) { + return; + } + adjusting.set(true); + try { + if (minChanged) { + maxSpinner.setValue(minValue); + } else { + minSpinner.setValue(maxValue); + } + } finally { + adjusting.set(false); + } + } + + /** + * Let the given spinners respond to mouse wheel scrolling: wheel up increments, + * wheel down decrements, respecting the model's bounds. + */ + public static void enableSpinnerMouseWheel(JSpinner... spinners) { + for (final JSpinner spinner : spinners) { + spinner.addMouseWheelListener(e -> { + if (!spinner.isEnabled() || e.getWheelRotation() == 0) { + return; + } + SpinnerModel model = spinner.getModel(); + Object next = e.getWheelRotation() < 0 ? model.getNextValue() : model.getPreviousValue(); + if (next != null) { + model.setValue(next); + } + }); + } + } + + public static void updateDialogButtonCursor(JButton button) { + button.setCursor(button.isEnabled() + ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + : Cursor.getDefaultCursor()); + } + + public static void alignButtonSizes(JButton... buttons) { + int maxWidth = 0; + int maxHeight = 0; + for (JButton button : buttons) { + Dimension preferred = button.getPreferredSize(); + maxWidth = Math.max(maxWidth, preferred.width); + maxHeight = Math.max(maxHeight, preferred.height); + } + Dimension size = new Dimension(maxWidth, maxHeight); + for (JButton button : buttons) { + button.setPreferredSize(size); + button.setMinimumSize(size); + button.setMaximumSize(size); + } + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/TableChromePanel.java b/launcher/src/main/java/com/skcraft/launcher/swing/TableChromePanel.java new file mode 100644 index 000000000..f4fa266b0 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/swing/TableChromePanel.java @@ -0,0 +1,27 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.swing; + +import javax.swing.*; +import java.awt.*; + +/** + * Panel that keeps {@code Table.background} after FlatLaf theme switches. + */ +public class TableChromePanel extends JPanel { + + public TableChromePanel(LayoutManager layout) { + super(layout); + } + + @Override + public void updateUI() { + super.updateUI(); + setOpaque(true); + setBackground(SwingHelper.tableBackground()); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/WebpageLayoutManager.java b/launcher/src/main/java/com/skcraft/launcher/swing/WebpageLayoutManager.java deleted file mode 100644 index 74ee2dec3..000000000 --- a/launcher/src/main/java/com/skcraft/launcher/swing/WebpageLayoutManager.java +++ /dev/null @@ -1,58 +0,0 @@ -/* - * SK's Minecraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.swing; - -import javax.swing.*; -import java.awt.*; - -public class WebpageLayoutManager implements LayoutManager { - - private static final int PROGRESS_WIDTH = 100; - - @Override - public void addLayoutComponent(String name, Component comp) { - } - - @Override - public void removeLayoutComponent(Component comp) { - throw new UnsupportedOperationException("Can't remove things!"); - } - - @Override - public Dimension preferredLayoutSize(Container parent) { - return new Dimension(0, 0); - } - - @Override - public Dimension minimumLayoutSize(Container parent) { - return new Dimension(0, 0); - } - - @Override - public void layoutContainer(Container parent) { - Insets insets = parent.getInsets(); - int maxWidth = parent.getWidth() - (insets.left + insets.right); - int maxHeight = parent.getHeight() - (insets.top + insets.bottom); - - int numComps = parent.getComponentCount(); - for (int i = 0 ; i < numComps ; i++) { - Component comp = parent.getComponent(i); - - if (comp instanceof JProgressBar) { - Dimension size = comp.getPreferredSize(); - comp.setLocation((parent.getWidth() - PROGRESS_WIDTH) / 2, - (int) (parent.getHeight() / 2.0 - size.height / 2.0)); - comp.setSize(PROGRESS_WIDTH, - (int) comp.getPreferredSize().height); - } else { - comp.setLocation(insets.left, insets.top); - comp.setSize(maxWidth, maxHeight); - } - } - } - -} diff --git a/launcher/src/main/java/com/skcraft/launcher/swing/WebpagePanel.java b/launcher/src/main/java/com/skcraft/launcher/swing/WebpagePanel.java deleted file mode 100644 index a8c554a11..000000000 --- a/launcher/src/main/java/com/skcraft/launcher/swing/WebpagePanel.java +++ /dev/null @@ -1,299 +0,0 @@ -/* - * SK's Minecraft Launcher - * Copyright (C) 2010-2014 Albert Pham and contributors - * Please see LICENSE.txt for license information. - */ - -package com.skcraft.launcher.swing; - -import com.skcraft.launcher.LauncherUtils; -import lombok.extern.java.Log; - -import javax.swing.*; -import javax.swing.border.Border; -import javax.swing.border.CompoundBorder; -import javax.swing.event.HyperlinkEvent; -import javax.swing.event.HyperlinkListener; -import javax.swing.text.html.HTMLDocument; -import java.awt.*; -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.Enumeration; -import java.util.logging.Level; - -import static com.skcraft.launcher.LauncherUtils.checkInterrupted; - -@Log -public final class WebpagePanel extends JPanel { - - private final WebpagePanel self = this; - - private URL url; - private boolean activated; - private JEditorPane documentView; - private JScrollPane documentScroll; - private JProgressBar progressBar; - private Thread thread; - private Border browserBorder; - - public static WebpagePanel forURL(URL url, boolean lazy) { - return new WebpagePanel(url, lazy); - } - - public static WebpagePanel forHTML(String html) { - return new WebpagePanel(html); - } - - private WebpagePanel(URL url, boolean lazy) { - this.url = url; - - setLayout(new BorderLayout()); - - if (lazy) { - setPlaceholder(); - } else { - setDocument(); - fetchAndDisplay(url); - } - } - - private WebpagePanel(String text) { - this.url = null; - - setLayout(new BorderLayout()); - - setDocument(); - setDisplay(text, null); - } - - public WebpagePanel(boolean lazy) { - this.url = null; - - setLayout(new BorderLayout()); - - if (lazy) { - setPlaceholder(); - } else { - setDocument(); - } - } - - public Border getBrowserBorder() { - return browserBorder; - } - - public void setBrowserBorder(Border browserBorder) { - synchronized (this) { - this.browserBorder = browserBorder; - if (documentScroll != null) { - documentScroll.setBorder(browserBorder); - } - } - } - - private void setDocument() { - activated = true; - - JLayeredPane panel = new JLayeredPane(); - panel.setLayout(new WebpageLayoutManager()); - - documentView = new JEditorPane(); - documentView.setOpaque(false); - documentView.setBorder(null); - documentView.setEditable(false); - documentView.addHyperlinkListener(new HyperlinkListener() { - @Override - public void hyperlinkUpdate(HyperlinkEvent e) { - if (e.getEventType() == HyperlinkEvent.EventType.ACTIVATED) { - if (e.getURL() != null) { - SwingHelper.openURL(e.getURL(), self); - } - } - } - }); - - documentScroll = new JScrollPane(documentView); - documentScroll.setOpaque(false); - panel.add(documentScroll, new Integer(1)); - documentScroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS); - synchronized (this) { - if (browserBorder != null) { - documentScroll.setBorder(browserBorder); - } - } - - progressBar = new JProgressBar(); - progressBar.setIndeterminate(true); - panel.add(progressBar, new Integer(2)); - - SwingHelper.removeOpaqueness(this); - SwingHelper.removeOpaqueness(documentView); - SwingHelper.removeOpaqueness(documentScroll); - - add(panel, BorderLayout.CENTER); - } - - private void setPlaceholder() { - activated = false; - - JLayeredPane panel = new JLayeredPane(); - panel.setBorder(new CompoundBorder( - BorderFactory.createEtchedBorder(), BorderFactory - .createEmptyBorder(4, 4, 4, 4))); - panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); - - final JButton showButton = new JButton("Load page"); - showButton.setAlignmentX(Component.CENTER_ALIGNMENT); - showButton.addActionListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent e) { - showButton.setVisible(false); - setDocument(); - fetchAndDisplay(url); - } - }); - - // Center the button vertically. - panel.add(new Box.Filler( - new Dimension(0, 0), - new Dimension(0, 0), - new Dimension(1000, 1000))); - panel.add(showButton); - panel.add(new Box.Filler( - new Dimension(0, 0), - new Dimension(0, 0), - new Dimension(1000, 1000))); - - add(panel, BorderLayout.CENTER); - } - - /** - * Browse to a URL. - * - * @param url the URL - * @param onlyChanged true to only browse if the last URL was different - * @return true if only the URL was changed - */ - public boolean browse(URL url, boolean onlyChanged) { - if (onlyChanged && this.url != null && this.url.equals(url)) { - return false; - } - - this.url = url; - - if (activated) { - fetchAndDisplay(url); - } - - return true; - } - - /** - * Update the page. This has to be run in the Swing event thread. - * - * @param url the URL - */ - private synchronized void fetchAndDisplay(URL url) { - if (thread != null) { - thread.interrupt(); - } - - progressBar.setVisible(true); - - thread = new Thread(new FetchWebpage(url)); - thread.setDaemon(true); - thread.start(); - } - - private void setDisplay(String text, URL baseUrl) { - progressBar.setVisible(false); - documentView.setContentType("text/html"); - HTMLDocument document = (HTMLDocument) documentView.getDocument(); - - // Clear existing styles - Enumeration e = document.getStyleNames(); - while (e.hasMoreElements()) { - Object o = e.nextElement(); - document.removeStyle((String) o); - } - - document.setBase(baseUrl); - documentView.setText(text); - - documentView.setCaretPosition(0); - } - - private void setError(String text) { - progressBar.setVisible(false); - documentView.setContentType("text/plain"); - documentView.setText(text); - documentView.setCaretPosition(0); - } - - private class FetchWebpage implements Runnable { - private URL url; - - public FetchWebpage(URL url) { - this.url = url; - } - - @Override - public void run() { - HttpURLConnection conn = null; - - try { - conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setUseCaches(false); - conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Java) SKMCLauncher"); - conn.setDoInput(true); - conn.setDoOutput(false); - conn.setReadTimeout(5000); - - conn.connect(); - - checkInterrupted(); - - if (conn.getResponseCode() != 200) { - throw new IOException( - "Did not get expected 200 code, got " - + conn.getResponseCode()); - } - - BufferedReader reader = new BufferedReader( - new InputStreamReader(conn.getInputStream(), - "UTF-8")); - - StringBuilder s = new StringBuilder(); - char[] buf = new char[1024]; - int len = 0; - while ((len = reader.read(buf)) != -1) { - s.append(buf, 0, len); - } - String result = s.toString(); - - checkInterrupted(); - - setDisplay(result, LauncherUtils.concat(url, "")); - } catch (IOException e) { - if (Thread.interrupted()) { - return; - } - - log.log(Level.WARNING, "Failed to fetch page", e); - setError("Failed to fetch page: " + e.getMessage()); - } catch (InterruptedException e) { - } finally { - if (conn != null) - conn.disconnect(); - conn = null; - } - } - } - -} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/BaseUpdater.java b/launcher/src/main/java/com/skcraft/launcher/update/BaseUpdater.java index 4bb4a76f2..5fc71ffda 100644 --- a/launcher/src/main/java/com/skcraft/launcher/update/BaseUpdater.java +++ b/launcher/src/main/java/com/skcraft/launcher/update/BaseUpdater.java @@ -28,7 +28,9 @@ import com.skcraft.launcher.util.FileUtils; import com.skcraft.launcher.util.HttpRequest; import com.skcraft.launcher.util.SharedLocale; +import lombok.Getter; import lombok.NonNull; +import lombok.Setter; import lombok.extern.java.Log; import javax.swing.*; @@ -67,6 +69,10 @@ public abstract class BaseUpdater { private final Environment environment = Environment.getInstance(); private final List executeOnCompletion = new ArrayList(); + @Getter + @Setter + private boolean promptForFeatures; + protected BaseUpdater(@NonNull Launcher launcher) { this.launcher = launcher; } @@ -117,32 +123,45 @@ protected Manifest installPackage(@NonNull Installer installer, @NonNull Instanc } } - Collections.sort(features); + Set manifestFeatureNames = new HashSet(); + for (Feature feature : features) { + manifestFeatureNames.add(Strings.nullToEmpty(feature.getName())); + } + boolean featuresChanged = !manifestFeatureNames.equals(featuresCache.getSelected().keySet()); - SwingUtilities.invokeLater(new Runnable() { - @Override - public void run() { + boolean shouldPrompt = promptForFeatures || featuresChanged; + if (shouldPrompt) { + SwingUtilities.invokeLater(() -> { new FeatureSelectionDialog(ProgressDialog.getLastDialog(), features, BaseUpdater.this) .setVisible(true); - } - }); + }); - synchronized (this) { - this.wait(); + synchronized (this) { + this.wait(); + } } + Map selected = new HashMap(); for (Feature feature : features) { - featuresCache.getSelected().put(Strings.nullToEmpty(feature.getName()), feature.isSelected()); + selected.put(Strings.nullToEmpty(feature.getName()), feature.isSelected()); } + featuresCache.setSelected(selected); + + // Persist immediately so "chose none" / first choice sticks even if install + // fails later + writeDataFile(featuresPath, featuresCache); } + onManifestPrepared(manifest); + // Download any extra processing files for each loader HashMap loaders = Maps.newHashMap(); for (Map.Entry entry : manifest.getLoaders().entrySet()) { HashMap localFilesMap = Maps.newHashMap(); for (DownloadableFile file : entry.getValue().getDownloadableFiles()) { - if (file.getSide() != Side.CLIENT) continue; + if (file.getSide() != Side.CLIENT) + continue; DownloadableFile.LocalFile localFile = file.download(installer, manifest); localFilesMap.put(localFile.getName(), localFile); @@ -152,6 +171,7 @@ public void run() { } InstallExtras extras = new InstallExtras(contentDir, loaders); + onVerifyingFiles(); for (ManifestEntry entry : manifest.getTasks()) { entry.install(installer, currentLog, updateCache, extras); } @@ -176,14 +196,25 @@ public void run() { return manifest; } + /** + * Called after feature selection has finalized the manifest, but before any + * package files are enumerated or queued for download. + */ + protected void onManifestPrepared(Manifest manifest) throws Exception { + } + + protected void onVerifyingFiles() { + } + protected void installJar(@NonNull Installer installer, - @NonNull VersionManifest.Artifact artifact, - @NonNull File jarFile, - @NonNull URL url) throws InterruptedException { + @NonNull VersionManifest.Artifact artifact, + @NonNull File jarFile, + @NonNull URL url) throws InterruptedException { // If the JAR does not exist, install it if (!jarFile.exists()) { long size = artifact.getSize(); - if (size <= 0) size = JAR_SIZE_ESTIMATE; + if (size <= 0) + size = JAR_SIZE_ESTIMATE; File tempFile = installer.getDownloader().download(url, "", size, jarFile.getName()); installer.queue(new FileMover(tempFile, jarFile)); @@ -195,9 +226,9 @@ protected void installJar(@NonNull Installer installer, } protected void installAssets(@NonNull Installer installer, - @NonNull VersionManifest versionManifest, - @NonNull URL indexUrl, - @NonNull List sources) throws IOException, InterruptedException { + @NonNull VersionManifest versionManifest, + @NonNull URL indexUrl, + @NonNull List sources) throws IOException, InterruptedException { AssetsRoot assetsRoot = launcher.getAssets(); AssetsIndex index = HttpRequest @@ -228,19 +259,18 @@ protected void installAssets(@NonNull Installer installer, } } - File tempFile = installer.getDownloader().download( - urls, "", entry.getValue().getSize(), entry.getKey()); - installer.queue(new FileMover(tempFile, targetFile)); - log.info("Fetching " + path + " from " + urls); + installer.getDownloader().download( + urls, targetFile, entry.getValue().getSize(), entry.getKey()); + log.fine("Fetching " + path + " from " + urls); downloading.add(path); } } } protected void installLibraries(@NonNull Installer installer, - @NonNull Manifest manifest, - @NonNull File librariesDir, - @NonNull List sources) throws InterruptedException, IOException { + @NonNull Manifest manifest, + @NonNull File librariesDir, + @NonNull List sources) throws InterruptedException, IOException { VersionManifest versionManifest = manifest.getVersionManifest(); Iterable allLibraries = versionManifest.getLibraries(); @@ -249,7 +279,8 @@ protected void installLibraries(@NonNull Installer installer, } for (Library library : allLibraries) { - if (library.isGenerated()) continue; // Skip generated libraries. + if (library.isGenerated()) + continue; // Skip generated libraries. if (library.matches(environment)) { checkInterrupted(); @@ -258,7 +289,8 @@ protected void installLibraries(@NonNull Installer installer, String path = artifact.getPath(); long size = artifact.getSize(); - if (size <= 0) size = LIBRARY_SIZE_ESTIMATE; + if (size <= 0) + size = LIBRARY_SIZE_ESTIMATE; File targetFile = new File(librariesDir, path); @@ -293,7 +325,8 @@ protected void installLibraries(@NonNull Installer installer, if (embeddedConfig == null) { // No embedded config, just use whatever the server gives us - File tempFile = installer.getDownloader().download(url(file.getUrl()), file.getHash(), file.getSize(), file.getId()); + File tempFile = installer.getDownloader().download(url(file.getUrl()), file.getHash(), file.getSize(), + file.getId()); log.info("Downloading logging config " + file.getId() + " from " + file.getUrl()); installer.queue(new FileMover(tempFile, targetFile)); @@ -309,6 +342,10 @@ protected void installLibraries(@NonNull Installer installer, } } + protected void installRuntime(@NonNull Installer installer, @NonNull JavaVersion version) throws Exception { + launcher.getRuntimeManager().install(installer, launcher.propUrl("runtimeManifestUrl"), version); + } + private static void writeDataFile(File path, Object object) { try { Persistence.write(path, object); diff --git a/launcher/src/main/java/com/skcraft/launcher/update/InstanceUpdateCheck.java b/launcher/src/main/java/com/skcraft/launcher/update/InstanceUpdateCheck.java new file mode 100644 index 000000000..15c7f1a17 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/InstanceUpdateCheck.java @@ -0,0 +1,121 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.update; + +import com.skcraft.concurrency.DefaultProgress; +import com.skcraft.concurrency.ProgressObservable; +import com.skcraft.launcher.Instance; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.LauncherException; +import com.skcraft.launcher.model.modpack.ManifestInfo; +import com.skcraft.launcher.model.modpack.PackageList; +import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.util.HttpRequest; +import com.skcraft.launcher.util.SharedLocale; +import lombok.NonNull; +import lombok.extern.java.Log; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.concurrent.Callable; + +import static com.skcraft.launcher.LauncherUtils.concat; + +/** + * Fetches packages.json and refreshes an instance's pending-update state. + */ +@Log +public class InstanceUpdateCheck implements Callable, ProgressObservable { + + private final Launcher launcher; + private final Instance instance; + private ProgressObservable progress = new DefaultProgress(-1, + SharedLocale.tr("launcher.updateCheckStatus")); + + public InstanceUpdateCheck(@NonNull Launcher launcher, @NonNull Instance instance) { + this.launcher = launcher; + this.instance = instance; + } + + /** + * Apply remote package listing metadata to a local instance. + * Sets {@code updatePending} when the remote version differs; never clears it. + */ + public static void applyPackageInfo(@NonNull Instance instance, @NonNull ManifestInfo manifest, + @NonNull URL packagesURL) throws MalformedURLException { + instance.setName(manifest.getName()); + instance.setTitle(manifest.getTitle()); + instance.setPriority(manifest.getPriority()); + URL url = concat(packagesURL, manifest.getLocation()); + instance.setManifestURL(url); + instance.setNewsUrl(manifest.getNewsUrl()); + instance.setIconUrl(manifest.getIconUrl()); + + log.info("(" + instance.getName() + ").setManifestURL(" + url + ")"); + + if (instance.getVersion() == null || !instance.getVersion().equals(manifest.getVersion())) { + instance.setUpdatePending(true); + instance.setVersion(manifest.getVersion()); + Persistence.commitAndForget(instance); + log.info(instance.getName() + " requires an update to " + manifest.getVersion()); + } + } + + @Override + public Instance call() throws Exception { + log.info("Checking for updates for '" + instance.getName() + "'..."); + progress = new DefaultProgress(-1, SharedLocale.tr("launcher.updateCheckStatus")); + + try { + URL packagesURL = launcher.getPackagesURL(); + + PackageList packages = HttpRequest + .get(packagesURL) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(PackageList.class); + + if (packages.getMinimumVersion() > Launcher.PROTOCOL_VERSION) { + throw new LauncherException("Update required", SharedLocale.tr("errors.updateRequiredError")); + } + + ManifestInfo found = null; + if (packages.getPackages() != null) { + for (ManifestInfo manifest : packages.getPackages()) { + if (instance.getName().equalsIgnoreCase(manifest.getName())) { + found = manifest; + break; + } + } + } + + if (found == null) { + throw new LauncherException("Pack missing from package list", + SharedLocale.tr("launcher.updateCheckMissing", instance.getTitle())); + } + + applyPackageInfo(instance, found, packagesURL); + return instance; + } catch (LauncherException e) { + throw e; + } catch (IOException e) { + throw new LauncherException(e, SharedLocale.tr("launcher.updateCheckFailed", instance.getTitle())); + } + } + + @Override + public double getProgress() { + return progress.getProgress(); + } + + @Override + public String getStatus() { + return progress.getStatus(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/UpdateManager.java b/launcher/src/main/java/com/skcraft/launcher/update/UpdateManager.java index e24e8f370..74532273b 100644 --- a/launcher/src/main/java/com/skcraft/launcher/update/UpdateManager.java +++ b/launcher/src/main/java/com/skcraft/launcher/update/UpdateManager.java @@ -11,6 +11,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.skcraft.concurrency.ObservableFuture; import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.LauncherException; import com.skcraft.launcher.dialog.ProgressDialog; import com.skcraft.launcher.selfupdate.LatestVersionInfo; import com.skcraft.launcher.selfupdate.SelfUpdater; @@ -19,6 +20,7 @@ import com.skcraft.launcher.util.SharedLocale; import com.skcraft.launcher.util.SwingExecutor; import lombok.Getter; +import lombok.extern.java.Log; import javax.swing.*; import javax.swing.event.SwingPropertyChangeSupport; @@ -26,7 +28,10 @@ import java.beans.PropertyChangeListener; import java.io.File; import java.net.URL; +import java.util.concurrent.CancellationException; +import java.util.logging.Level; +@Log public class UpdateManager { @Getter @@ -63,11 +68,9 @@ public void onSuccess(LatestVersionInfo result) { @Override public void onFailure(Throwable t) { - // Error handler attached below. + logSelfUpdateCheckFailure(t); } }, SwingExecutor.INSTANCE); - - SwingHelper.addErrorDialogCallback(window, future); } public void performUpdate(final Window window) { @@ -109,5 +112,16 @@ private void requestUpdate(LatestVersionInfo url) { this.pendingUpdate = url; } + private static void logSelfUpdateCheckFailure(Throwable t) { + if (t instanceof InterruptedException || t instanceof CancellationException) { + return; + } + + Throwable cause = t instanceof LauncherException ? t.getCause() : t; + if (cause == null) { + cause = t; + } + log.log(Level.WARNING, "Self-update check failed", cause); + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/update/Updater.java b/launcher/src/main/java/com/skcraft/launcher/update/Updater.java index 745c20aab..cba51a07a 100644 --- a/launcher/src/main/java/com/skcraft/launcher/update/Updater.java +++ b/launcher/src/main/java/com/skcraft/launcher/update/Updater.java @@ -14,11 +14,14 @@ import com.skcraft.launcher.Launcher; import com.skcraft.launcher.LauncherException; import com.skcraft.launcher.install.Installer; +import com.skcraft.launcher.launch.LaunchSupervisor; +import com.skcraft.launcher.launch.MemoryRequirements; import com.skcraft.launcher.model.minecraft.ReleaseList; import com.skcraft.launcher.model.minecraft.Version; import com.skcraft.launcher.model.minecraft.VersionManifest; import com.skcraft.launcher.model.modpack.Manifest; import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.update.runtime.JavaVersionResolver; import com.skcraft.launcher.util.HttpRequest; import com.skcraft.launcher.util.SharedLocale; import lombok.Getter; @@ -45,7 +48,8 @@ public class Updater extends BaseUpdater implements Callable, Progress private final Launcher launcher; private final Instance instance; - @Getter @Setter + @Getter + @Setter private boolean online; private List librarySources = new ArrayList(); @@ -56,7 +60,7 @@ public class Updater extends BaseUpdater implements Callable, Progress public Updater(@NonNull Launcher launcher, @NonNull Instance instance) { super(launcher); - this.installer = new Installer(launcher.getInstallerDir()); + this.installer = new Installer(launcher.getInstallerDir(), launcher.getDownloadThreads()); this.launcher = launcher; this.instance = instance; @@ -107,9 +111,12 @@ public Instance call() throws Exception { * otherwise we'll have to download the one for the given Minecraft version. * * BACKWARDS COMPATIBILITY: - * Old manifests have an embedded version manifest without the minecraft JARs list present. - * If we find a manifest without that jar list, fetch the newer copy from launchermeta and use the list from that. - * We can't just replace the manifest outright because library versions might differ and that screws up Runner. + * Old manifests have an embedded version manifest without the minecraft JARs + * list present. + * If we find a manifest without that jar list, fetch the newer copy from + * launchermeta and use the list from that. + * We can't just replace the manifest outright because library versions might + * differ and that screws up Runner. */ private VersionManifest readVersionManifest(Manifest manifest) throws IOException, InterruptedException { VersionManifest version = manifest.getVersionManifest(); @@ -125,13 +132,21 @@ private VersionManifest readVersionManifest(Manifest manifest) throws IOExceptio version.setDownloads(otherManifest.getDownloads()); version.setAssetIndex(otherManifest.getAssetIndex()); + if (version.getJavaVersion() == null) { + version.setJavaVersion(otherManifest.getJavaVersion()); + } + } + + if (version.getJavaVersion() == null) { + version.setJavaVersion(JavaVersionResolver.resolve(launcher, instance, version)); } mapper.writeValue(instance.getVersionPath(), version); return version; } - private static VersionManifest fetchVersionManifest(URL url, Manifest manifest) throws IOException, InterruptedException { + private static VersionManifest fetchVersionManifest(URL url, Manifest manifest) + throws IOException, InterruptedException { ReleaseList releases = HttpRequest.get(url) .execute() .expectResponseCode(200) @@ -150,9 +165,9 @@ private static VersionManifest fetchVersionManifest(URL url, Manifest manifest) * Update the given instance. * * @param instance the instance - * @throws IOException thrown on I/O error + * @throws IOException thrown on I/O error * @throws InterruptedException thrown on interruption - * @throws ExecutionException thrown on execution error + * @throws ExecutionException thrown on execution error */ protected void update(Instance instance) throws Exception { // Mark this instance as local @@ -164,9 +179,6 @@ protected void update(Instance instance) throws Exception { progress = new DefaultProgress(-1, SharedLocale.tr("instanceUpdater.readingManifest")); Manifest manifest = installPackage(installer, instance); - // Update instance from manifest - manifest.update(instance); - // Read version manifest log.info("Reading version manifest..."); progress = new DefaultProgress(-1, SharedLocale.tr("instanceUpdater.readingVersion")); @@ -174,6 +186,12 @@ protected void update(Instance instance) throws Exception { progress = new DefaultProgress(-1, SharedLocale.tr("instanceUpdater.buildingDownloadList")); + // Install the managed Game Runtime only for Automatic runtime selection + if (version.getJavaVersion() != null && instance.getSettings().usesAutomaticRuntime()) { + log.info("Enumerating Game Runtime..."); + installRuntime(installer, version.getJavaVersion()); + } + // Install the .jar File jarPath = launcher.getJarPath(version); VersionManifest.Artifact clientJar = version.getDownloads().get("client"); @@ -223,6 +241,21 @@ protected void update(Instance instance) throws Exception { " has been updated to version " + manifest.getVersion() + "."); } + @Override + protected void onManifestPrepared(Manifest manifest) throws Exception { + manifest.update(instance); + if (!MemoryRequirements.verifyInstanceMemory(instance, new LaunchSupervisor.MemoryVerifier(instance))) { + throw new LauncherException("Update cancelled: insufficient memory", + SharedLocale.tr("updater.updateCancelledInsufficientMemory")); + } + } + + @Override + protected void onVerifyingFiles() { + log.info("Verifying package files..."); + progress = new DefaultProgress(-1, SharedLocale.tr("instanceUpdater.verifyingFiles")); + } + @Override public double getProgress() { return progress.getProgress(); @@ -233,5 +266,4 @@ public String getStatus() { return progress.getStatus(); } - } diff --git a/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaRuntimeManager.java b/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaRuntimeManager.java new file mode 100644 index 000000000..a24c58d23 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaRuntimeManager.java @@ -0,0 +1,318 @@ +package com.skcraft.launcher.update.runtime; + +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.LauncherException; +import com.skcraft.launcher.install.*; +import com.skcraft.launcher.launch.runtime.JavaRuntime; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.model.minecraft.runtime.*; +import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.util.Environment; +import com.skcraft.launcher.util.HttpRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.java.Log; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import static com.skcraft.launcher.util.HttpRequest.url; +import static com.skcraft.launcher.util.SharedLocale.tr; + +@Log +@RequiredArgsConstructor +public class JavaRuntimeManager { + private final File runtimesDir; + private final Environment environment = Environment.getInstance(); + + public List fetchManagedRuntimes(URL manifestUrl) throws IOException, InterruptedException { + RuntimePlatform platform = RuntimePlatform.from(environment); + if (platform == null) { + return Collections.emptyList(); + } + + RuntimeList availableRuntimes = HttpRequest.get(manifestUrl) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(RuntimeList.class); + + if (availableRuntimes.getRuntimesByPlatform() == null) { + return Collections.emptyList(); + } + + Map> platformRuntimes = availableRuntimes.getRuntimesByPlatform().get(platform.getId()); + if (platformRuntimes == null) { + return Collections.emptyList(); + } + + Map optionsByMajorVersion = new TreeMap<>(Comparator.reverseOrder()); + for (Map.Entry> entry : platformRuntimes.entrySet()) { + String component = entry.getKey(); + if (!isRuntimeComponent(component) || entry.getValue() == null || entry.getValue().isEmpty()) { + continue; + } + + RuntimeInfo info = entry.getValue().get(0); + String version = info.getVersion() != null ? info.getVersion().getName() : null; + int majorVersion = detectMajorVersion(version); + if (majorVersion <= 0) { + continue; + } + + ManagedRuntimeOption candidate = createManagedRuntimeOption(component, majorVersion, version); + ManagedRuntimeOption current = optionsByMajorVersion.get(majorVersion); + if (current == null || isPreferredRuntimeChoice(candidate, current)) { + optionsByMajorVersion.put(majorVersion, candidate); + } + } + + return new ArrayList<>(optionsByMajorVersion.values()); + } + + private ManagedRuntimeOption createManagedRuntimeOption(String component, int majorVersion, String version) { + JavaVersion javaVersion = new JavaVersion(); + javaVersion.setComponent(component); + Optional installed = getRuntime(javaVersion); + if (installed.isPresent()) { + JavaRuntime runtime = installed.get(); + runtime.setMinecraftBundled(true); + String displayVersion = runtime.getVersion() != null ? runtime.getVersion() : version; + return new ManagedRuntimeOption(component, majorVersion, displayVersion, runtime.is64Bit(), true, runtime); + } + + RuntimePlatform platform = RuntimePlatform.from(environment); + boolean is64Bit = platform != null && platform.is64Bit(); + return new ManagedRuntimeOption(component, majorVersion, version, is64Bit, false, null); + } + + private static boolean isPreferredRuntimeChoice(ManagedRuntimeOption candidate, ManagedRuntimeOption current) { + int candidatePriority = getRuntimeComponentPriority(candidate.getComponent()); + int currentPriority = getRuntimeComponentPriority(current.getComponent()); + if (candidatePriority != currentPriority) { + return candidatePriority < currentPriority; + } + + return candidate.getComponent().compareTo(current.getComponent()) > 0; + } + + private static int getRuntimeComponentPriority(String component) { + if ("jre-legacy".equals(component)) { + return 0; + } + + if (component.contains("snapshot")) { + return 2; + } + + return 1; + } + + private static boolean isRuntimeComponent(String component) { + return "jre-legacy".equals(component) || component.startsWith("java-runtime-"); + } + + private static int detectMajorVersion(String version) { + if (version == null || version.isEmpty()) { + return 0; + } + + if (version.startsWith("1.") && version.length() > 2) { + return parseLeadingInt(version.substring(2)); + } + + return parseLeadingInt(version); + } + + private static int parseLeadingInt(String text) { + StringBuilder value = new StringBuilder(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (!Character.isDigit(c)) { + break; + } + value.append(c); + } + + if (value.length() == 0) { + return 0; + } + + return Integer.parseInt(value.toString()); + } + + public Optional getRuntime(JavaVersion version) { + RuntimePlatform platform = RuntimePlatform.from(environment); + RuntimeData data = readRuntimeData(platform, version); + + if (platform == null || data == null || !platform.getId().equals(data.getPlatform())) { + return Optional.empty(); + } + + File javaHome = getJavaHome(getRuntimeDir(platform, version), data); + if (!hasJavaExecutable(javaHome)) { + return Optional.empty(); + } + + return Optional.of(new JavaRuntime(javaHome.getAbsoluteFile(), data.getVersion(), data.is64Bit())); + } + + public void install(Installer installer, URL manifestUrl, JavaVersion version) throws Exception { + RuntimePlatform platform = RuntimePlatform.from(environment); + if (platform == null) { + throw new LauncherException("Unsupported Game Runtime platform", + tr("runtime.unsupportedPlatform", environment.getPlatform(), environment.getArch())); + } + + RuntimeList availableRuntimes = HttpRequest.get(manifestUrl) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(RuntimeList.class); + + RuntimeInfo info = availableRuntimes.getRuntime(platform, version); + if (info == null || info.getManifest() == null) { + throw new LauncherException("No Game Runtime available", + tr("runtime.unavailable", version.getComponent(), platform.getId())); + } + + File destination = getRuntimeDir(platform, version); + RuntimeData currentData = readRuntimeData(platform, version); + boolean manifestChanged = currentData == null + || currentData.getManifestSha1() == null + || !currentData.getManifestSha1().equals(info.getManifest().getSha1()); + + RuntimeManifest manifest = HttpRequest.get(url(info.getManifest().getUrl())) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(RuntimeManifest.class); + + queueRuntimeFiles(installer, destination, manifest, manifestChanged); + queueRuntimeData(installer, destination, platform, version, info); + } + + private void queueRuntimeFiles(Installer installer, File destination, RuntimeManifest manifest, + boolean manifestChanged) throws Exception { + if (manifest.getFiles() == null) { + return; + } + + for (Map.Entry entry : manifest.getFiles().entrySet()) { + String filename = entry.getKey(); + RuntimeManifestEntry runtimeEntry = entry.getValue(); + File target = resolveRuntimeFile(destination, filename); + + if (runtimeEntry instanceof RuntimeManifestEntry.File) { + RuntimeManifestEntry.File file = (RuntimeManifestEntry.File) runtimeEntry; + DownloadInfo download = file.getDownloads() != null ? file.getDownloads().get(Format.RAW) : null; + if (download == null || download.getUrl() == null) { + continue; + } + + if (manifestChanged || !target.isFile()) { + File tempFile = installer.getDownloader().download( + url(download.getUrl()), "", download.getSize(), filename); + installer.queue(new FileMover(tempFile, target)); + + if (download.getSha1() != null) { + installer.queue(new FileVerify(target, filename, download.getSha1())); + } + + if (file.isExecutable()) { + installer.queue(new FileSetExecutable(target)); + } + } + } else if (runtimeEntry instanceof RuntimeManifestEntry.Directory) { + if (!target.isDirectory()) { + installer.queue(new CreateFolder(target)); + } + } else if (runtimeEntry instanceof RuntimeManifestEntry.Link) { + if (manifestChanged || !target.exists()) { + RuntimeManifestEntry.Link link = (RuntimeManifestEntry.Link) runtimeEntry; + installer.queue(new CreateLink(target, Paths.get(link.getTarget()))); + } + } + } + } + + private void queueRuntimeData(Installer installer, File destination, RuntimePlatform platform, + JavaVersion version, RuntimeInfo info) { + installer.queueLate(new InstallTask() { + @Override + public void execute(Launcher launcher) throws Exception { + RuntimeData data = new RuntimeData(); + data.setComponent(version.getComponent()); + data.setPlatform(platform.getId()); + data.setVersion(info.getVersion().getName()); + data.setManifestSha1(info.getManifest().getSha1()); + data.setJavaHomePath(getJavaHomePath(platform)); + data.set64Bit(platform.is64Bit()); + Persistence.write(new File(destination, "runtime.json"), data); + } + + @Override + public double getProgress() { + return -1; + } + + @Override + public String getStatus() { + return tr("installer.adhoc.writingRuntimeData"); + } + }); + } + + private RuntimeData readRuntimeData(RuntimePlatform platform, JavaVersion version) { + if (platform == null || version == null || version.getComponent() == null) { + return null; + } + + return Persistence.read(new File(getRuntimeDir(platform, version), "runtime.json"), RuntimeData.class, true); + } + + private File getRuntimeDir(RuntimePlatform platform, JavaVersion version) { + return new File(new File(runtimesDir, platform.getId()), version.getComponent()); + } + + private static File getJavaHome(File runtimeDir, RuntimeData data) { + String javaHomePath = data.getJavaHomePath() != null ? data.getJavaHomePath() : "."; + return new File(runtimeDir, javaHomePath); + } + + private static String getJavaHomePath(RuntimePlatform platform) { + switch (platform) { + case MAC_OS: + case MAC_OS_ARM64: + return "jre.bundle/Contents/Home"; + default: + return "."; + } + } + + private static boolean hasJavaExecutable(File javaHome) { + File bin = new File(javaHome, "bin"); + return new File(bin, "java").isFile() || new File(bin, "java.exe").isFile(); + } + + private static File resolveRuntimeFile(File root, String filename) throws IOException, LauncherException { + File target = new File(root, filename); + String rootPath = root.getCanonicalPath(); + String targetPath = target.getCanonicalPath(); + + if (!targetPath.equals(rootPath) && !targetPath.startsWith(rootPath + File.separator)) { + throw new LauncherException("Invalid Game Runtime path", + tr("runtime.invalidPath", filename)); + } + + return target; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaVersionResolver.java b/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaVersionResolver.java new file mode 100644 index 000000000..03b9ef6a7 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/runtime/JavaVersionResolver.java @@ -0,0 +1,105 @@ +package com.skcraft.launcher.update.runtime; + +import com.google.common.base.Strings; +import com.skcraft.launcher.Instance; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.model.minecraft.ReleaseList; +import com.skcraft.launcher.model.minecraft.Version; +import com.skcraft.launcher.model.minecraft.VersionManifest; +import com.skcraft.launcher.model.modpack.Manifest; +import com.skcraft.launcher.persistence.Persistence; +import com.skcraft.launcher.util.HttpRequest; +import lombok.extern.java.Log; + +import java.io.File; +import java.net.URL; +import java.util.logging.Level; + +import static com.skcraft.launcher.util.HttpRequest.url; + +@Log +public final class JavaVersionResolver { + private static final String LEGACY_COMPONENT = "jre-legacy"; + private static final int LEGACY_MAJOR_VERSION = 8; + + private JavaVersionResolver() { + } + + public static JavaVersion resolve(Launcher launcher, Instance instance, VersionManifest version) { + if (version != null && version.getJavaVersion() != null) { + return version.getJavaVersion(); + } + + String gameVersion = resolveGameVersion(instance, version, readModpackManifest(instance)); + JavaVersion mojangJava = fetchFromMojang(launcher, gameVersion); + if (mojangJava != null) { + return mojangJava; + } + + return legacyDefault(); + } + + public static JavaVersion legacyDefault() { + JavaVersion javaVersion = new JavaVersion(); + javaVersion.setComponent(LEGACY_COMPONENT); + javaVersion.setMajorVersion(LEGACY_MAJOR_VERSION); + return javaVersion; + } + + private static JavaVersion fetchFromMojang(Launcher launcher, String gameVersion) { + if (launcher == null || Strings.isNullOrEmpty(gameVersion)) { + return null; + } + + try { + URL versionManifestUrl = launcher.propUrl("versionManifestUrl"); + ReleaseList releases = HttpRequest.get(versionManifestUrl) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(ReleaseList.class); + + Version release = releases != null ? releases.find(gameVersion) : null; + if (release == null || Strings.isNullOrEmpty(release.getUrl())) { + return null; + } + + VersionManifest versionManifest = HttpRequest.get(url(release.getUrl())) + .execute() + .expectResponseCode(200) + .returnContent() + .asJson(VersionManifest.class); + + return versionManifest != null ? versionManifest.getJavaVersion() : null; + } catch (Exception e) { + log.log(Level.FINE, "Failed to resolve Java version from Mojang for " + gameVersion, e); + return null; + } + } + + private static String resolveGameVersion(Instance instance, VersionManifest version, Manifest modpackManifest) { + if (version != null && !Strings.isNullOrEmpty(version.getId())) { + return version.getId(); + } + + if (modpackManifest != null && !Strings.isNullOrEmpty(modpackManifest.getGameVersion())) { + return modpackManifest.getGameVersion(); + } + + return null; + } + + private static Manifest readModpackManifest(Instance instance) { + if (instance == null) { + return null; + } + + File manifestPath = instance.getManifestPath(); + if (!manifestPath.isFile()) { + return null; + } + + return Persistence.read(manifestPath, Manifest.class, true); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/runtime/ManagedRuntimeOption.java b/launcher/src/main/java/com/skcraft/launcher/update/runtime/ManagedRuntimeOption.java new file mode 100644 index 000000000..3b339d70c --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/runtime/ManagedRuntimeOption.java @@ -0,0 +1,36 @@ +package com.skcraft.launcher.update.runtime; + +import com.skcraft.launcher.launch.runtime.JavaRuntime; +import com.skcraft.launcher.util.SharedLocale; +import lombok.Data; + +@Data +public class ManagedRuntimeOption { + private final String component; + private final int majorVersion; + private final String version; + private final boolean is64Bit; + private final boolean installed; + private final JavaRuntime runtime; + + public String getDisplayLabel() { + String versionLabel = version != null && !version.isEmpty() + ? version + : majorVersion > 0 ? formatJavaMajorVersion(majorVersion) : component; + return formatLabel(versionLabel, is64Bit, "Mojang"); + } + + public static String formatLabel(String version, boolean is64Bit, String detail) { + String bitness = is64Bit ? "64-bit" : "32-bit"; + return SharedLocale.tr("instance.options.javaRuntime", version, bitness, detail); + } + + @Override + public String toString() { + return getDisplayLabel(); + } + + public static String formatJavaMajorVersion(int majorVersion) { + return majorVersion == 8 ? "1.8" : String.valueOf(majorVersion); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeData.java b/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeData.java new file mode 100644 index 000000000..f5a49b524 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeData.java @@ -0,0 +1,18 @@ +package com.skcraft.launcher.update.runtime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +@JsonIgnoreProperties(ignoreUnknown = true) +public class RuntimeData { + private String component; + private String platform; + private String version; + private String manifestSha1; + private String javaHomePath; + + @JsonProperty("64Bit") + private boolean is64Bit; +} diff --git a/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeInstallTask.java b/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeInstallTask.java new file mode 100644 index 000000000..7eed1bff0 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/update/runtime/RuntimeInstallTask.java @@ -0,0 +1,59 @@ +package com.skcraft.launcher.update.runtime; + +import com.skcraft.concurrency.DefaultProgress; +import com.skcraft.concurrency.ProgressFilter; +import com.skcraft.concurrency.ProgressObservable; +import com.skcraft.launcher.Launcher; +import com.skcraft.launcher.LauncherException; +import com.skcraft.launcher.install.Installer; +import com.skcraft.launcher.launch.runtime.JavaRuntime; +import com.skcraft.launcher.model.minecraft.JavaVersion; +import com.skcraft.launcher.util.SharedLocale; + +import java.util.Optional; +import java.util.concurrent.Callable; + +public class RuntimeInstallTask implements Callable, ProgressObservable { + private final Launcher launcher; + private final JavaVersion javaVersion; + private final Installer installer; + + private ProgressObservable progress = new DefaultProgress(-1, SharedLocale.tr("runtime.preparing")); + + public RuntimeInstallTask(Launcher launcher, JavaVersion javaVersion) { + this.launcher = launcher; + this.javaVersion = javaVersion; + this.installer = new Installer(launcher.getInstallerDir(), launcher.getDownloadThreads()); + } + + @Override + public JavaRuntime call() throws Exception { + progress = new DefaultProgress(-1, SharedLocale.tr("runtime.collecting")); + launcher.getRuntimeManager().install(installer, launcher.propUrl("runtimeManifestUrl"), javaVersion); + + progress = ProgressFilter.between(installer.getDownloader(), 0, 0.98); + installer.download(); + + progress = ProgressFilter.between(installer, 0.98, 1); + installer.execute(launcher); + installer.executeLate(launcher); + + Optional runtime = launcher.getRuntimeManager().getRuntime(javaVersion); + if (!runtime.isPresent()) { + throw new LauncherException("Game Runtime install failed", + SharedLocale.tr("runtime.installFailed", javaVersion.getComponent())); + } + + return runtime.get(); + } + + @Override + public double getProgress() { + return progress.getProgress(); + } + + @Override + public String getStatus() { + return progress.getStatus(); + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java b/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java index e1b94d42d..0f1285cdc 100644 --- a/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java +++ b/launcher/src/main/java/com/skcraft/launcher/util/HttpRequest.java @@ -429,7 +429,16 @@ public String getStatus() { @Override public void close() throws IOException { - if (conn != null) conn.disconnect(); + if (inputStream != null) { + inputStream.close(); + inputStream = null; + conn = null; + } else if (conn != null) { + // No response stream was acquired, so this connection cannot be + // returned to HttpURLConnection's keep-alive pool. + conn.disconnect(); + conn = null; + } } /** @@ -442,6 +451,50 @@ public static HttpRequest get(URL url) { return request("GET", url); } + public static long fetchContentLength(URL url) throws IOException { + long length = fetchContentLength(url, "HEAD"); + if (length >= 0) { + return length; + } + return fetchContentLength(url, "GET"); + } + + private static long fetchContentLength(URL url, String method) throws IOException { + HttpURLConnection conn = null; + try { + conn = (HttpURLConnection) url.openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Java) SKMCLauncher"); + conn.setInstanceFollowRedirects(true); + conn.setUseCaches(false); + conn.setReadTimeout(READ_TIMEOUT); + conn.setRequestMethod(method); + conn.connect(); + + int responseCode = conn.getResponseCode(); + if (responseCode >= 400) { + return -1; + } + + long length = conn.getContentLengthLong(); + if (length >= 0) { + return length; + } + + String header = conn.getHeaderField("Content-Length"); + if (header != null) { + try { + return Long.parseLong(header); + } catch (NumberFormatException ignored) { + } + } + return -1; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + /** * Perform a POST request. * diff --git a/launcher/src/main/java/com/skcraft/launcher/util/LogBuffer.java b/launcher/src/main/java/com/skcraft/launcher/util/LogBuffer.java new file mode 100644 index 000000000..7bf59d9fd --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/util/LogBuffer.java @@ -0,0 +1,118 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.util; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +/** + * Keeps a ring buffer of recent root-logger records so the launcher console + * can show history when opened after startup. + */ +public final class LogBuffer extends Handler { + + private static final int DEFAULT_CAPACITY = 10000; + + private static LogBuffer instance; + + private final int capacity; + private final ArrayDeque records; + + private LogBuffer(int capacity) { + this.capacity = capacity; + this.records = new ArrayDeque(Math.min(capacity, 256)); + setFormatter(new SimpleLogFormatter()); + } + + /** + * Attach the buffer to the root logger once. + */ + public static synchronized void init() { + if (instance != null) { + return; + } + + instance = new LogBuffer(DEFAULT_CAPACITY); + Logger.getLogger("").addHandler(instance); + } + + /** + * @return the shared buffer, or null if {@link #init()} has not run + */ + public static synchronized LogBuffer get() { + return instance; + } + + /** + * Clear buffered history. + */ + public static synchronized void clearBuffer() { + if (instance != null) { + instance.clear(); + } + } + + /** + * Snapshot of buffered records in chronological order. + * + * @return copied list of records + */ + public synchronized List snapshot() { + return new ArrayList(records); + } + + /** + * Drop all buffered records. + */ + public synchronized void clear() { + records.clear(); + } + + @Override + public synchronized void publish(LogRecord record) { + if (record == null || !isLoggable(record) || !isLauncherRecord(record)) { + return; + } + + while (records.size() >= capacity) { + records.removeFirst(); + } + records.addLast(copyRecord(record)); + } + + private static boolean isLauncherRecord(LogRecord record) { + String name = record.getLoggerName(); + return name == null || name.startsWith("com.skcraft"); + } + + @Override + public void flush() { + } + + @Override + public synchronized void close() { + records.clear(); + } + + private static LogRecord copyRecord(LogRecord source) { + LogRecord copy = new LogRecord(source.getLevel(), source.getMessage()); + copy.setLoggerName(source.getLoggerName()); + copy.setResourceBundle(source.getResourceBundle()); + copy.setResourceBundleName(source.getResourceBundleName()); + copy.setSequenceNumber(source.getSequenceNumber()); + copy.setSourceClassName(source.getSourceClassName()); + copy.setSourceMethodName(source.getSourceMethodName()); + copy.setThrown(source.getThrown()); + Object[] parameters = source.getParameters(); + copy.setParameters(parameters != null ? parameters.clone() : null); + return copy; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/util/QrCodes.java b/launcher/src/main/java/com/skcraft/launcher/util/QrCodes.java new file mode 100644 index 000000000..cc4c25aa4 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/util/QrCodes.java @@ -0,0 +1,46 @@ +package com.skcraft.launcher.util; + +import com.google.zxing.BarcodeFormat; +import com.google.zxing.EncodeHintType; +import com.google.zxing.WriterException; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; + +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.util.EnumMap; +import java.util.Map; + +/** + * Local QR code rendering for the launcher UI. + */ +public final class QrCodes { + + private QrCodes() { + } + + public static BufferedImage generate(String contents, int size) { + try { + Map hints = new EnumMap<>(EncodeHintType.class); + hints.put(EncodeHintType.CHARACTER_SET, "UTF-8"); + hints.put(EncodeHintType.MARGIN, 0); + + BitMatrix matrix = new QRCodeWriter().encode(contents, BarcodeFormat.QR_CODE, size, size, hints); + return toImage(matrix); + } catch (WriterException e) { + return null; + } + } + + private static BufferedImage toImage(BitMatrix matrix) { + int width = matrix.getWidth(); + int height = matrix.getHeight(); + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + image.setRGB(x, y, matrix.get(x, y) ? Color.BLACK.getRGB() : Color.WHITE.getRGB()); + } + } + return image; + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/util/SharedHttpClient.java b/launcher/src/main/java/com/skcraft/launcher/util/SharedHttpClient.java new file mode 100644 index 000000000..e7ea2b80a --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/util/SharedHttpClient.java @@ -0,0 +1,49 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.util; + +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Process-wide HTTP client used for bulk file downloads. + */ +public final class SharedHttpClient { + + private static final int EXECUTOR_THREADS = 32; + private static final ExecutorService EXECUTOR = Executors.newFixedThreadPool( + EXECUTOR_THREADS, new DownloadThreadFactory()); + private static final HttpClient CLIENT = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(30)) + .executor(EXECUTOR) + .build(); + + private SharedHttpClient() { + } + + public static HttpClient get() { + return CLIENT; + } + + private static final class DownloadThreadFactory implements ThreadFactory { + private final AtomicInteger nextId = new AtomicInteger(); + + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, + "launcher-http-" + nextId.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + } +} diff --git a/launcher/src/main/java/com/skcraft/launcher/util/WinRegistry.java b/launcher/src/main/java/com/skcraft/launcher/util/WinRegistry.java index d7ca5425c..a01a91f1d 100644 --- a/launcher/src/main/java/com/skcraft/launcher/util/WinRegistry.java +++ b/launcher/src/main/java/com/skcraft/launcher/util/WinRegistry.java @@ -8,9 +8,12 @@ import com.google.common.collect.ImmutableList; import com.sun.jna.platform.win32.Advapi32Util; +import com.sun.jna.platform.win32.Win32Exception; +import com.sun.jna.platform.win32.WinError; import com.sun.jna.platform.win32.WinReg; import java.util.List; +import java.util.Optional; /** * Windows registry helper via JNA platform @@ -33,6 +36,21 @@ public static String readString(WinReg.HKEY hkey, String key, String valueName) return Advapi32Util.registryGetStringValue(hkey, key, valueName); } + /** + * Read a value from a path and value name, returning empty if the key or value does not exist. + */ + public static Optional readStringOptional(WinReg.HKEY hkey, String key, String valueName) { + try { + return Optional.of(readString(hkey, key, valueName)); + } catch (Win32Exception e) { + if (e.getErrorCode() == WinError.ERROR_FILE_NOT_FOUND + || e.getErrorCode() == WinError.ERROR_PATH_NOT_FOUND) { + return Optional.empty(); + } + throw e; + } + } + /** * Get the subkeys of a given path * @@ -46,4 +64,15 @@ public static String readString(WinReg.HKEY hkey, String key, String valueName) public static List readStringSubKeys(WinReg.HKEY hkey, String key) { return ImmutableList.copyOf(Advapi32Util.registryGetKeys(hkey, key)); } + + /** + * Check whether a registry key exists. + * + * @param hkey Hive key + * @param key Registry path + * @return true if the key exists + */ + public static boolean keyExists(WinReg.HKEY hkey, String key) { + return Advapi32Util.registryKeyExists(hkey, key); + } } diff --git a/launcher/src/main/java/com/skcraft/launcher/util/WindowsAppIdentity.java b/launcher/src/main/java/com/skcraft/launcher/util/WindowsAppIdentity.java new file mode 100644 index 000000000..506015881 --- /dev/null +++ b/launcher/src/main/java/com/skcraft/launcher/util/WindowsAppIdentity.java @@ -0,0 +1,40 @@ +/* + * SK's Minecraft Launcher + * Copyright (C) 2010-2014 Albert Pham and contributors + * Please see LICENSE.txt for license information. + */ + +package com.skcraft.launcher.util; + +import com.sun.jna.WString; +import com.sun.jna.platform.win32.Shell32; +import lombok.extern.java.Log; + +import java.util.logging.Level; + +/** Applies the Windows AppUserModelID set by bootstrap for taskbar grouping. */ +@Log +public final class WindowsAppIdentity { + + public static final String APP_USER_MODEL_ID_PROPERTY = "com.skcraft.launcher.appUserModelId"; + + private WindowsAppIdentity() { + } + + public static void applyIfPresent() { + if (Environment.detectPlatform() != Platform.WINDOWS) { + return; + } + + String appUserModelId = System.getProperty(APP_USER_MODEL_ID_PROPERTY); + if (appUserModelId == null || appUserModelId.isEmpty()) { + return; + } + + try { + Shell32.INSTANCE.SetCurrentProcessExplicitAppUserModelID(new WString(appUserModelId)); + } catch (Throwable t) { + log.log(Level.FINE, "Unable to apply Windows AppUserModelID", t); + } + } +} diff --git a/launcher/src/main/resources/META-INF/licenses/ca.weblite-webview.txt b/launcher/src/main/resources/META-INF/licenses/ca.weblite-webview.txt new file mode 100644 index 000000000..240e39f28 --- /dev/null +++ b/launcher/src/main/resources/META-INF/licenses/ca.weblite-webview.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Steve Hannah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties b/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties index ef48f3c4b..44924e772 100644 --- a/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties +++ b/launcher/src/main/resources/com/skcraft/launcher/lang/Launcher.properties @@ -23,26 +23,37 @@ errors.selfUpdateCheckError=Checking for an update to the launcher has failed. button.cancel=Cancel button.ok=OK button.save=Save +button.increaseMemory=Increase Memory button.launchAnyway=Launch Anyway options.title = Options options.useProxyCheck = Use following proxy in Minecraft -options.jvmPath=Fallback Java Runtime\: -options.jvmRuntime=Java Runtime\: -options.jvmArguments=JVM arguments\: -options.64BitJavaWarning=Make sure to have 64-bit Java installed if you are planning to set the memory limits higher. +options.jvmRuntime=Game Runtime\: options.minMemory=Minimum memory (MB)\: options.maxMemory=Maximum memory (MB)\: -options.permGen=PermGen (MB)\: -options.javaTab=Java +options.instancesTab=Java +options.instancesTitle=Instanced Java Settings +options.instancesDescription=Game Runtime, JVM arguments, and memory limits are now saved per instance instead of globally. Open an instance's settings to tune Java for that modpack. +options.instanceJavaSettings=Settings +options.instanceInstalled=Installed +options.instanceUpToDate=Up to date +options.instanceNotInstalled=Not installed +options.instanceUpdatePending=Update pending +options.noInstances=No instances are currently loaded. options.windowWidth=Window width\: options.windowHeight=Window height\: +options.maximizeWindow=Start maximized options.minecraftTab=Minecraft options.proxyHost=Proxy host\: options.proxyPort=Proxy port\: options.proxyUsername=Proxy username\: options.proxyPassword=Proxy password\: options.proxyTab=Proxy +options.theme=Theme\: +options.themeLight=Light +options.themeDark=Dark +options.themeSystem=System +options.showInstanceConsole=Enable game log window options.gameKey=Game key\: options.advancedTab=Advanced options.about=About @@ -55,31 +66,45 @@ instance.openSaves=View saves instance.openResourcePacks=View resource packs instance.openScreenshots=View screenshots instance.copyAsPath=Copy as path -instance.openSettings=Settings... -instance.forceUpdate=Force update -instance.hardForceUpdate=Hard force update... +instance.openSettings=Instance Settings +instance.selectFeatures=Optional features... +instance.verifyFiles=Verify files +instance.reinstallMods=Reinstall mods & configs... instance.deleteFiles=Delete files... instance.confirmDelete=Are you sure that you wish to delete ALL THE FILES (screenshots, worlds, configs) for ''{0}''? instance.deletingTitle=Deleting instance... instance.deletingStatus=Deleting ''{0}'' and files... -instance.confirmHardUpdate=A hard force update will delete the contents of config/ and mods/ and then require an update. Are you sure that you want to continue? -instance.resettingTitle=Resetting instance... -instance.resettingStatus=Resetting ''{0}''... +instance.confirmReinstallMods=This will delete the mods and config folders for this modpack, then redownload them. Worlds, screenshots, and other files outside those folders are kept. Continue? +instance.resettingTitle=Reinstalling instance... +instance.resettingStatus=Reinstalling ''{0}''... instance.options.title=Instance Settings -instance.options.customJava=Use a custom Java runtime -instance.options.customMemory=Use custom memory settings +instance.options.automaticRuntime=Use modpack default +instance.options.automaticRuntimeWithVersion=Use modpack default (Java {0}) +instance.options.javaRuntime=Java {0} ({1}, {2}) +instance.options.addCustomRuntime=Browse for custom Java... +instance.options.managedRuntimeLoadFailed=Could not load available Game Runtimes from Mojang. Installed and system runtimes are still listed. +instance.options.managedRuntimeLoadFailedTitle=Game Runtimes +instance.options.runtimeGroupManaged=Mojang Runtimes +instance.options.runtimeGroupSystem=System Runtimes +instance.options.userJvmArguments=Custom JVM arguments\: +instance.options.useModpackJvmArguments=Use modpack JVM arguments +instance.options.combinedJvmArguments=Effective JVM arguments\: launcher.launch=Launch... launcher.checkForUpdates=Check for updates launcher.options=Options... launcher.updateLauncher=Update launcher... launcher.downloadUpdates=Download modpack updates -launcher.title=SKCraft Launcher (v{0}) +launcher.title=SKCraft Launcher (v#{0}) launcher.appTitle=SKCraft Launcher launcher.refreshList=Refresh list launcher.checkingTitle=Getting available modpacks... launcher.checkingStatus=Getting available modpacks... Please wait. +launcher.updateCheckTitle=Checking for updates... +launcher.updateCheckStatus=Checking for modpack updates... Please wait. +launcher.updateCheckFailed=Could not check for updates for ''{0}''. Launch aborted. +launcher.updateCheckMissing=Modpack ''{0}'' is no longer listed and cannot be verified. Launch aborted. launcher.selfUpdatingTitle=Updating launcher... launcher.selfUpdatingStatus=Downloading launcher update... launcher.selfUpdateComplete=Restart the launcher to use the new version. @@ -88,22 +113,53 @@ launcher.updatingTitle=Updating... launcher.updatingStatus=Updating ''{0}''... Please wait. launcher.noInstanceError=Please select a modpack to launch. launcher.noInstanceTitle=No Modpack Selected +launcher.noInstances=No modpacks available. launcher.launchingTItle=Launching the game... launcher.launchingStatus=Launching ''{0}''. Please wait. -launcher.javaMismatchTitle=Java version warning +launcher.insufficientMemoryTitle=Insufficient memory warning +launcher.insufficientSystemMemoryTitle=Not enough system memory launcher.modpackColumn=Modpack launcher.notInstalledHint=(not installed) launcher.requiresUpdateHint=(requires update) launcher.updatePendingHint=(pending update) +news.panel.unavailable.title=News panel unavailable +news.panel.unavailable.windows.details=Microsoft Edge WebView2 is required for the embedded news panel. Download and install WebView2, then restart the launcher. +news.panel.unavailable.windows.download=Download WebView2 +news.panel.unavailable.linux.details=GTK and WebKitGTK are required for the embedded news panel. Install libgtk-3-0 and libwebkit2gtk-4.1-0 (or libwebkit2gtk-4.0-37) using your package manager, then restart the launcher. +news.panel.unavailable.linux.download=WebKitGTK +news.panel.unavailable.mac.details=The embedded browser could not be initialized. Update macOS or reinstall the launcher, then try again. +news.panel.unavailable.generic.details=The embedded browser is not available in this Java runtime. +news.panel.loadFailed=Failed to load page + accounts.title=Select the account to play with +accounts.manageTitle=Accounts +accounts.manageSubtitle=Choose which account to use when launching, or add a new Microsoft account. +accounts.useAccount=Use account accounts.play=Play! accounts.refreshingStatus=Refreshing login session... -accounts.addMojang=Add Mojang account -accounts.addMicrosoft=Add Microsoft account -accounts.removeSelected=Forget selected account +accounts.addAccount=Add account +accounts.addAccountTooltip=Sign in with a Microsoft account +accounts.offlineAccount=Add Offline +accounts.offlineAccountTooltip=Create or use an offline account +accounts.offlineAccountTitle=Offline mode +accounts.offlineAccountNamePrompt=Enter an offline player name. +accounts.offlineAccountNameInvalid=Offline player names must be 3-16 characters and use only letters, numbers, or underscores. +accounts.offlineMode=Offline mode +accounts.actionsTitle=Account actions +accounts.subtitle=Choose a saved account or add a new Microsoft account. +accounts.removeSelected=Forget account +accounts.forgetTooltip=Remove the selected account from this launcher accounts.confirmForgetTitle=Forget account accounts.confirmForget=Are you sure that you want to forget that account? +accounts.switchAccount=Switch account +accounts.noAccountSelected=No account selected +accounts.type.microsoft=Microsoft Account +accounts.type.offline=Offline Account +accounts.type.mojang=Mojang Account + +launcher.accountSignIn=Sign in with Microsoft +launcher.accountSwitch=Account: {0} login.login=Login... login.recoverAccount=Forgot your login? @@ -122,6 +178,24 @@ login.minecraftNotOwnedError=Sorry, Minecraft is not owned on that account. login.relogin=Please log in again. ({0}) login.microsoft.seeBrowser=Check your browser to login with Microsoft. +login.microsoft.device.starting=Preparing Microsoft sign in... +login.microsoft.device.fetchingTitle=Preparing sign in... +login.microsoft.device.awaiting=Waiting for you to finish signing in... +login.microsoft.device.dialogTitle=Sign in with Microsoft +login.microsoft.device.title=Sign in +login.microsoft.device.subtitle=Choose a sign-in method below. +login.microsoft.device.stepLabel=Step {0} +login.microsoft.device.step1=Open the Microsoft sign-in page using one of the options below. +login.microsoft.device.step2=Enter this code when the page asks for it. +login.microsoft.device.browserHintPrefix=Open +login.microsoft.device.browserHintSuffix=or scan the QR and enter the code below. +login.microsoft.device.expiredStatus=Sign-in code expired. Close this window and try again. +login.microsoft.device.copy=Copy code +login.microsoft.device.copied=Copied! +login.microsoft.device.timeRemaining=Code expires in {0} +login.microsoft.device.timeExpired=Code expired. Please try again. +login.microsoft.device.useBrowserFallback=Sign in with Microsoft Account +login.microsoft.device.deviceHint=Or sign in on mobile device login.xbox.generic=Failed to authenticate with Xbox Live. login.xbox.noXboxAccount=That account does not have an Xbox account associated! login.xbox.isChild=The account is a child (under 18) and cannot proceed unless it is part of a Family. @@ -136,6 +210,7 @@ console.pasteUploading=Uploading {0} bytes...\n console.pasteUploaded=Paste uploaded\: {0}\n console.pasteFailed=Upload failed\: {0}\n console.processEndCode=Process ended with code\: {0} +console.processExitedEarly=The game closed before it finished starting. Check the log for details. console.attachedToProcess=The game is running. Please wait. console.forceClose=Force Close console.trayTooltip=SKCraft Launcher @@ -168,33 +243,56 @@ installer.executing=Executing tasks... ({0} remaining) installer.copyingFile=Copying from {0} to {1} installer.movingFile=Moving {0} to {1} installer.runningProcessor=Running processor {0}: {1} +installer.creatingDirectory=Creating directory {0} +installer.verifyingFile=Verifying {0} +installer.settingExecutable=Setting permissions on {0} +installer.creatingLink=Linking {0} to {1} +installer.adhoc.writingRuntimeData=Writing Game Runtime metadata updater.updating=Updating launcher... updater.updateRequiredButOffline=An update is required but you need to be in online mode. updater.updateRequiredButNoManifest=An update is required but update information for this instance is no longer available. +updater.updateCancelledInsufficientMemory=The update was cancelled because this instance's memory requirements were not met. instanceUpdater.preparingUpdate=Preparing to update... instanceUpdater.readingManifest=Reading package manifest... +instanceUpdater.verifyingFiles=Verifying package files... instanceUpdater.readingVersion=Reading version manifest... instanceUpdater.buildingDownloadList=Collecting files to download... instanceUpdater.collectingLibraries=Collecting libraries to download... instanceUpdater.collectingAssets=Collecting assets to download... +runtime.preparing=Preparing Game Runtime... +runtime.collecting=Collecting Game Runtime files... +runtime.installingTitle=Installing Game Runtime... +runtime.installingStatus=Installing Game Runtime for ''{0}''... +runtime.missingTitle=Game Runtime unavailable +runtime.missingVersion=''{0}'' does not declare a required Game Runtime. Update the instance before launching. +runtime.missingDisabled=''{0}'' requires Game Runtime ''{1}'', but it is not installed and downloads are disabled. +runtime.unsupportedPlatform=No managed Game Runtime platform matches {0} ({1}). +runtime.unavailable=No managed Game Runtime ''{0}'' is available for {1}. +runtime.invalidPath=The Game Runtime manifest contains an invalid path\: {0} +runtime.installFailed=Game Runtime ''{0}'' could not be installed. + instanceDeleter.deleting=Deleting {0}... instanceDeleter.failures={0} file(s) could not be deleted. -instanceResetter.resetting=Resetting {0}... +instanceResetter.resetting=Reinstalling {0}... instanceLoader.loadingLocal=Loading local instances from disk... instanceLoader.checkingRemote=Checking for new modpacks... runner.preparing=Preparing for launch... runner.collectingArgs=Collecting process arguments... -runner.startingJava=Starting java... +runner.startingJava=Starting game runtime... runner.updateRequired=This instance must be updated before it can be run. runner.missingLibrary={0} needs to be relaunched and updated because the library ''{1}'' is missing. runner.missingAssetsIndex={0} needs to be relaunched and updated because its asset index is missing. runner.corruptAssetsIndex={0} needs to be relaunched and updated because its asset index is corrupt. -runner.wrongJavaVersion=Instance ''{0}'' requires Java version {1}, but could only find {2}. +runner.missingJavaVersion={0} does not declare a required Game Runtime. Update the instance before launching. +runner.missingJavaRuntime={0} needs to be relaunched and updated because Game Runtime ''{1}'' is missing. +runner.insufficientMemory=Instance ''{0}'' requires at least {1} GB of maximum memory, but the current setting is {2} GB. +runner.insufficientSystemMemory=Instance ''{0}'' requires at least {1} GB of memory, but this computer can only allocate about {2} GB. Close other applications or use a machine with more RAM. +runner.instanceMemoryExceedsSystem=Instance ''{0}'' is configured to use {1} GB, but this computer can only allocate about {2} GB. Lower the maximum memory in instance settings. assets.expanding1=Expanding {0} asset... ({1} remaining) assets.expandingN=Expanding {0} assets... ({1} remaining) diff --git a/launcher/src/main/resources/com/skcraft/launcher/launcher.properties b/launcher/src/main/resources/com/skcraft/launcher/launcher.properties index 1ff0c13cd..07db08ca3 100644 --- a/launcher/src/main/resources/com/skcraft/launcher/launcher.properties +++ b/launcher/src/main/resources/com/skcraft/launcher/launcher.properties @@ -11,9 +11,11 @@ version=${project.version} # These can be left as-is or changed to your liking launcherShortname=SKCLauncher offlinePlayerName=Player +downloadThreads=32 # Only change these if you know what you're doing. versionManifestUrl=https://launchermeta.mojang.com/mc/game/version_manifest.json +runtimeManifestUrl=https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json librariesSource=https://libraries.minecraft.net/ assetsSource=https://resources.download.minecraft.net/ yggdrasilAuthUrl=https://authserver.mojang.com/authenticate diff --git a/launcher/src/main/resources/com/skcraft/launcher/login.html b/launcher/src/main/resources/com/skcraft/launcher/login.html index df0fcace1..c916b6f0c 100644 --- a/launcher/src/main/resources/com/skcraft/launcher/login.html +++ b/launcher/src/main/resources/com/skcraft/launcher/login.html @@ -4,6 +4,10 @@ Login success