diff --git a/.gitignore b/.gitignore index 5c7b1784..7e283ae5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ build .idea RuneLiteSetup*.exe +*_jre.tar.gz +*_jre.zip +jdk-*-jre diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 00000000..6c1233c3 --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,3 @@ +build-aarch64 +build-x64 +build-x86 diff --git a/src/main/java/net/runelite/launcher/DockMenu.java b/src/main/java/net/runelite/launcher/DockMenu.java new file mode 100644 index 00000000..d911f4d7 --- /dev/null +++ b/src/main/java/net/runelite/launcher/DockMenu.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, Nick + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.launcher; + +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.Taskbar; +import java.io.IOException; +import java.util.List; +import javax.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class DockMenu +{ + private static final String NEW_INSTANCE = "New Instance"; + + // Adds a "New Instance" item to the dock icon's context menu. + // + // This has to be called from the process which owns the dock icon for the session. In FORK launch + // mode that is the forked child, not the parent launcher, which exits as soon as it has forked. + static void install(@Nullable List newInstanceCommand) + { + if (OS.getOs() != OS.OSType.MacOS) + { + return; + } + + if (newInstanceCommand == null || newInstanceCommand.isEmpty()) + { + log.debug("Not installing dock menu, unable to determine how to launch a new instance"); + return; + } + + try + { + if (!Taskbar.isTaskbarSupported()) + { + log.debug("Not installing dock menu, taskbar is unsupported"); + return; + } + + var taskbar = Taskbar.getTaskbar(); + if (!taskbar.isSupported(Taskbar.Feature.MENU)) + { + log.debug("Not installing dock menu, menu feature is unsupported"); + return; + } + + var item = new MenuItem(NEW_INSTANCE); + item.addActionListener(ev -> newInstance(newInstanceCommand)); + + var menu = new PopupMenu(); + menu.add(item); + taskbar.setMenu(menu); + + log.debug("Installed dock menu, new instance command: {}", newInstanceCommand); + } + catch (Exception ex) + { + // the dock menu is cosmetic, so it must never prevent the client from starting + log.warn("unable to install dock menu", ex); + } + } + + /** + * {@link ProcessBuilder#start()} forks, so it is kept off the Event Dispatch Thread. Unlike + * {@link ForkLauncher#launch}, this process outlives the one it starts, so the child's output is + * discarded. + */ + private static void newInstance(List command) + { + log.info("Launching new instance: {}", command); + + var thread = new Thread(() -> + { + try + { + new ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start(); + } + catch (IOException ex) + { + log.error("unable to launch new instance", ex); + } + }); + thread.setName(NEW_INSTANCE); + thread.setDaemon(true); + thread.start(); + } +} diff --git a/src/main/java/net/runelite/launcher/ForkLauncher.java b/src/main/java/net/runelite/launcher/ForkLauncher.java index dba18969..c1c61310 100644 --- a/src/main/java/net/runelite/launcher/ForkLauncher.java +++ b/src/main/java/net/runelite/launcher/ForkLauncher.java @@ -24,14 +24,17 @@ */ package net.runelite.launcher; +import com.google.common.base.Strings; import java.io.File; import java.io.IOException; +import java.lang.management.ManagementFactory; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; +import javax.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import net.runelite.launcher.beans.Bootstrap; @@ -76,37 +79,12 @@ static void launch( Map jvmProps, List jvmArgs) throws IOException { - ProcessHandle current = ProcessHandle.current(); - Path path; - - switch (OS.getOs()) - { - case Windows: - path = Paths.get(current.info().command().get()); - break; - case MacOS: - path = Paths.get(current.info().command().get()); - // on macOS packr changes the cwd to the resource directory prior to launching the JVM, - // causing current.info().command() to return /Applications/RuneLite.app/Contents/Resources/./RuneLite - // despite the executable really being at /Applications/RuneLite.app/Contents/MacOS/RuneLite - path = path.normalize() - .resolveSibling(Path.of("..", "MacOS", path.getFileName().toString())) - .normalize(); - break; - case Linux: - // the executable is in the fuse-mounted filesystem of the appimage, which goes away after the launcher - // exits. So relaunch the appimage instead. - var appimage = System.getenv("APPIMAGE"); - path = Path.of(appimage); - break; - default: - throw new IllegalStateException("invalid os"); - } + Path path = launcherExecutable(); var commands = new ArrayList<>(); commands.add(path.toAbsolutePath().toString()); commands.add("-c"); - // bootstrap vm args + var clientJvmArgs = JvmLauncher.getJvmArguments(bootstrap); if (clientJvmArgs != null) { @@ -116,29 +94,70 @@ static void launch( commands.add(arg); } } - // launcher vm props + for (var prop : jvmProps.entrySet()) { commands.add("-J"); commands.add("-D" + prop.getKey() + "=" + prop.getValue()); } - // launcher vm args + for (var arg : jvmArgs) { commands.add("-J"); commands.add(arg); } - // program arguments commands.add("--"); if (classpath.isEmpty()) { - // avoid looping throw new RuntimeException("cannot fork launch with an empty classpath"); } commands.add("--classpath"); + commands.add(classpathOption(classpath)); + + commands.addAll(clientArgs); + + log.debug("Running process: {}", commands); + + var builder = new ProcessBuilder(commands.toArray(new String[0])); + builder.start(); + } + static Path launcherExecutable() + { + ProcessHandle current = ProcessHandle.current(); + Path path; + + switch (OS.getOs()) + { + case Windows: + path = Paths.get(current.info().command().get()); + break; + case MacOS: + path = Paths.get(current.info().command().get()); + // on macOS packr changes the cwd to the resource directory prior to launching the JVM, + // causing current.info().command() to return /Applications/RuneLite.app/Contents/Resources/./RuneLite + // despite the executable really being at /Applications/RuneLite.app/Contents/MacOS/RuneLite + path = path.normalize() + .resolveSibling(Path.of("..", "MacOS", path.getFileName().toString())) + .normalize(); + break; + case Linux: + // the executable is in the fuse-mounted filesystem of the appimage, which goes away after the launcher + // exits. So relaunch the appimage instead. + var appimage = System.getenv("APPIMAGE"); + path = Path.of(appimage); + break; + default: + throw new IllegalStateException("invalid os"); + } + + return path; + } + + static String classpathOption(List classpath) + { var sb = new StringBuilder(); for (var f : classpath) { @@ -149,13 +168,60 @@ static void launch( sb.append(f.getName()); } - commands.add(sb.toString()); + return sb.toString(); + } + + static List relaunchCommand(Path executable, List vmArgs, String classpathOpt, + Collection clientArgs) + { + var commands = new ArrayList(); + commands.add(executable.toAbsolutePath().toString()); + commands.add("-c"); + + // the -J args launch() passed us are this JVM's input args + for (var arg : vmArgs) + { + commands.add("-J"); + commands.add(arg); + } + + // program arguments + commands.add("--"); + commands.add("--classpath"); + commands.add(classpathOpt); commands.addAll(clientArgs); - log.debug("Running process: {}", commands); + return commands; + } - var builder = new ProcessBuilder(commands.toArray(new String[0])); - builder.start(); + // The command to launch an additional instance of the client, or null if one can't be determined. + @Nullable + static List relaunchCommand(String classpathOpt, Collection clientArgs) + { + if (canForkLaunch()) + { + return relaunchCommand(launcherExecutable(), + ManagementFactory.getRuntimeMXBean().getInputArguments(), classpathOpt, clientArgs); + } + + var command = ProcessHandle.current().info().command(); + var classpath = System.getProperty("java.class.path"); + if (command.isEmpty() || Strings.isNullOrEmpty(classpath)) + { + log.debug("Unable to build a relaunch command, command={} classpath={}", command, classpath); + return null; + } + + var commands = new ArrayList(); + commands.add(command.get()); + commands.add("-cp"); + commands.add(classpath); + commands.add(Launcher.class.getName()); + commands.add("--launch-mode"); + commands.add(LaunchMode.REFLECT.name()); + commands.addAll(clientArgs); + + return commands; } } diff --git a/src/main/java/net/runelite/launcher/Launcher.java b/src/main/java/net/runelite/launcher/Launcher.java index c0f8a1ac..257e6e53 100644 --- a/src/main/java/net/runelite/launcher/Launcher.java +++ b/src/main/java/net/runelite/launcher/Launcher.java @@ -215,9 +215,12 @@ public static void main(String[] args) .split(classpathOpt)) .map(name -> new File(REPO_DIR, name)) .collect(Collectors.toList()); + var clientArgs = getClientArgs(settings); + DockMenu.install(ForkLauncher.relaunchCommand(classpathOpt, clientArgs)); + try { - ReflectionLauncher.launch(classpath, getClientArgs(settings)); + ReflectionLauncher.launch(classpath, clientArgs); } catch (Exception e) { @@ -434,6 +437,7 @@ public static void main(String[] args) if (settings.launchMode == LaunchMode.REFLECT) { log.debug("Using launch mode: REFLECT"); + DockMenu.install(ForkLauncher.relaunchCommand(ForkLauncher.classpathOption(classpath), clientArgs)); ReflectionLauncher.launch(classpath, clientArgs); } else if (settings.launchMode == LaunchMode.FORK || (settings.launchMode == LaunchMode.AUTO && ForkLauncher.canForkLaunch())) diff --git a/src/test/java/net/runelite/launcher/ForkLauncherTest.java b/src/test/java/net/runelite/launcher/ForkLauncherTest.java new file mode 100644 index 00000000..de8ad938 --- /dev/null +++ b/src/test/java/net/runelite/launcher/ForkLauncherTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026, Nick + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package net.runelite.launcher; + +import java.io.File; +import java.nio.file.Path; +import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class ForkLauncherTest +{ + @Test + public void testRelaunchCommand() + { + Path executable = Path.of("/Applications/RuneLite.app/Contents/MacOS/RuneLite"); + + List command = ForkLauncher.relaunchCommand( + executable, + List.of("-Xmx768m", "-Dapple.awt.application.appearance=system"), + "client-1.11.jar" + File.pathSeparatorChar + "runelite-api-1.11.jar", + List.of("--debug")); + + assertEquals(List.of( + executable.toAbsolutePath().toString(), + "-c", + "-J", "-Xmx768m", + "-J", "-Dapple.awt.application.appearance=system", + "--", + "--classpath", "client-1.11.jar" + File.pathSeparatorChar + "runelite-api-1.11.jar", + "--debug" + ), command); + } + + @Test + public void testRelaunchCommandNoVmArgsOrClientArgs() + { + Path executable = Path.of("/Applications/RuneLite.app/Contents/MacOS/RuneLite"); + + List command = ForkLauncher.relaunchCommand(executable, List.of(), "client.jar", List.of()); + + assertEquals(List.of( + executable.toAbsolutePath().toString(), + "-c", + "--", + "--classpath", "client.jar" + ), command); + } + + @Test + public void testClasspathOption() + { + // launch() sends jar names, not paths, as they are resolved against REPO_DIR by the child + assertEquals("a.jar" + File.pathSeparatorChar + "b.jar", + ForkLauncher.classpathOption(List.of(new File("/repo", "a.jar"), new File("/repo", "b.jar")))); + assertEquals("", ForkLauncher.classpathOption(List.of())); + } + + @Test + public void testDockMenuIsNotInstalledWithoutACommand() + { + // must not throw, regardless of platform or headlessness + DockMenu.install(null); + DockMenu.install(List.of()); + assertTrue(true); + } +}