From 7e3a1fe597f059663ef30f351a528d8cfdc8350d Mon Sep 17 00:00:00 2001 From: Curtis Rueden Date: Sat, 19 Oct 2024 16:49:23 -0500 Subject: [PATCH] WIP: Add logic for migrating to Jaunch launcher Closes #93. --- pom.xml | 8 +- .../ui/swing/updater/ImageJUpdater.java | 29 +- .../ui/swing/updater/LauncherMigrator.java | 559 ++++++++++++++++++ 3 files changed, 583 insertions(+), 13 deletions(-) create mode 100644 src/main/java/net/imagej/ui/swing/updater/LauncherMigrator.java diff --git a/pom.xml b/pom.xml index 37f4350..3931c9e 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ net.imagej imagej-ui-swing - 1.0.2-SNAPSHOT + 1.2.0-SNAPSHOT ImageJ UI: Swing ImageJ UI for Java Swing. @@ -160,6 +160,7 @@ sign,deploy-to-scijava + 2.0.0 7.6.0 @@ -189,6 +190,11 @@ + + org.scijava + app-launcher + ${app-launcher.version} + org.scijava scijava-common diff --git a/src/main/java/net/imagej/ui/swing/updater/ImageJUpdater.java b/src/main/java/net/imagej/ui/swing/updater/ImageJUpdater.java index 959c283..2a1cdbd 100644 --- a/src/main/java/net/imagej/ui/swing/updater/ImageJUpdater.java +++ b/src/main/java/net/imagej/ui/swing/updater/ImageJUpdater.java @@ -29,18 +29,16 @@ package net.imagej.ui.swing.updater; +import java.awt.EventQueue; import java.io.DataInputStream; import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.net.Authenticator; import java.net.HttpURLConnection; -import java.net.MalformedURLException; import java.net.URL; -import java.net.URLClassLoader; import java.net.URLConnection; import java.net.UnknownHostException; -import java.util.ArrayList; import java.util.List; import net.imagej.ui.swing.updater.ViewOptions.Option; @@ -48,8 +46,8 @@ import net.imagej.updater.Conflicts.Conflict; import net.imagej.updater.util.*; +import org.scijava.Context; import org.scijava.app.StatusService; -import org.scijava.command.CommandService; import org.scijava.event.ContextDisposingEvent; import org.scijava.event.EventHandler; import org.scijava.log.LogService; @@ -59,8 +57,6 @@ import org.scijava.plugin.Plugin; import org.scijava.util.AppUtils; -import javax.swing.*; - /** * The Updater. As a command. * @@ -69,8 +65,12 @@ @Plugin(type = UpdaterUI.class, menu = { @Menu(label = "Help"), @Menu(label = "Update...") }) public class ImageJUpdater implements UpdaterUI { + private UpdaterFrame main; + @Parameter(required = false) + private Context context; + @Parameter(required = false) private StatusService statusService; @@ -82,6 +82,7 @@ public class ImageJUpdater implements UpdaterUI { @Override public void run() { + new LauncherMigrator(context).checkLaunchStatus(); if (errorIfDebian()) return; @@ -91,14 +92,12 @@ public void run() { if (errorIfNetworkInaccessible(log)) return; - String imagejDirProperty = System.getProperty("imagej.dir"); - final File imagejRoot = imagejDirProperty != null ? new File(imagejDirProperty) : - AppUtils.getBaseDirectory("ij.dir", FilesCollection.class, "updater"); - final FilesCollection files = new FilesCollection(log, imagejRoot); + final File appDir = getAppDirectory(); + final FilesCollection files = new FilesCollection(log, appDir); UpdaterUserInterface.set(new SwingUserInterface(log, statusService)); - if (new File(imagejRoot, "update").exists()) { + if (new File(appDir, "update").exists()) { if (!UpdaterUserInterface.get().promptYesNo("It is suggested that you restart ImageJ, then continue the update.\n" + "Alternately, you can attempt to continue the upgrade without\n" + "restarting, but ImageJ might crash.\n\n" @@ -187,6 +186,12 @@ protected void updateConflictList() { main.updateFilesTable(); } + static File getAppDirectory() { + String imagejDirProperty = System.getProperty("imagej.dir"); + return imagejDirProperty != null ? new File(imagejDirProperty) : + AppUtils.getBaseDirectory("ij.dir", FilesCollection.class, "updater"); + } + private void refreshUpdateSites(FilesCollection files) throws InterruptedException, InvocationTargetException { @@ -194,7 +199,7 @@ private void refreshUpdateSites(FilesCollection files) changes = AvailableSites.initializeAndAddSites(files, (Logger) log); if(ReviewSiteURLsDialog.shouldBeDisplayed(changes)) { ReviewSiteURLsDialog dialog = new ReviewSiteURLsDialog(main, changes); - SwingUtilities.invokeAndWait(() -> dialog.setVisible(true)); + EventQueue.invokeAndWait(() -> dialog.setVisible(true)); if(dialog.isOkPressed()) AvailableSites.applySitesURLUpdates(files, changes); } diff --git a/src/main/java/net/imagej/ui/swing/updater/LauncherMigrator.java b/src/main/java/net/imagej/ui/swing/updater/LauncherMigrator.java new file mode 100644 index 0000000..3dafc5b --- /dev/null +++ b/src/main/java/net/imagej/ui/swing/updater/LauncherMigrator.java @@ -0,0 +1,559 @@ +/* + * #%L + * ImageJ software for multidimensional image processing and analysis. + * %% + * Copyright (C) 2009 - 2024 ImageJ developers. + * %% + * 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 HOLDERS 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. + * #L% + */ + +package net.imagej.ui.swing.updater; + +import net.imagej.updater.util.UpdaterUtil; +import org.scijava.Context; +import org.scijava.app.AppService; +import org.scijava.launcher.Java; +import org.scijava.launcher.Versions; +import org.scijava.log.LogService; +import org.scijava.log.Logger; +import org.scijava.ui.ApplicationFrame; +import org.scijava.ui.UIService; +import org.scijava.ui.UserInterface; +import org.scijava.widget.UIComponent; + +import javax.swing.ImageIcon; +import javax.swing.JOptionPane; +import javax.swing.UIManager; +import java.awt.Window; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.prefs.Preferences; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Absurdly complex logic for helping users transition + * safely from the old ImageJ launcher to the new one. + * + * @author Curtis Rueden + */ +class LauncherMigrator { + + private static final List ARM32 = Arrays.asList("aarch32", "arm32"); + private static final List ARM64 = Arrays.asList("aarch64", "arm64"); + private static final List X32 = + Arrays.asList("i386", "i486", "i586", "i686", "x86-32", "x86_32", "x86"); + private static final List X64 = + Arrays.asList("amd64", "x86-64", "x86_64", "x64"); + private static final boolean OS_WIN, OS_MACOS, OS_LINUX; + private static final String OS, ARCH; + + static { + OS = System.getProperty("os.name"); + OS_WIN = OS.toLowerCase().contains("windows"); + OS_MACOS = OS.toLowerCase().contains("mac"); + OS_LINUX = OS.toLowerCase().contains("linux"); + String osArch = System.getProperty("os.arch").toLowerCase(); + if (ARM32.contains(osArch)) ARCH = "arm32"; + else if (ARM64.contains(osArch)) ARCH = "arm64"; + else if (X32.contains(osArch)) ARCH = "x32"; + else if (X64.contains(osArch)) ARCH = "x64"; + else ARCH = osArch; + } + + private AppService appService; + private UIService uiService; + private Logger log; + + LauncherMigrator(Context ctx) { + if (ctx == null) return; + appService = ctx.getService(AppService.class); + uiService = ctx.getService(UIService.class); + log = ctx.getService(LogService.class); + } + + /** + * Figures out what's going on with the application's launch situation. + *
    + *
  • If launched with the old ImageJ launcher, call + * {@link #switchToNewLauncher()}.
  • + *
  • If launched with the new Jaunch launcher, call + * {@link #migrateShortcuts()} and {@link #migrateUpdateSite()}.
  • + *
  • If launched in some other creative way, do nothing.
  • + *
+ */ + void checkLaunchStatus() { + // Check whether *either* launcher (old or new) launched the app. + // Both launchers set one of these telltale properties. + boolean launcherUsed = + System.getProperty("ij.executable") != null || + System.getProperty("fiji.executable") != null; + if (!launcherUsed) return; // Program was launched in some creative way. + + // Check if the old launcher launched the app. + // The old launcher does not set the scijava.app.name property. + boolean oldLauncherUsed = System.getProperty("scijava.app.name") == null; + if (oldLauncherUsed) switchToNewLauncher(); + else { + migrateShortcuts(); + migrateUpdateSite(); + } + } + + /** + * Updates platform-specific shortcuts to reference the new launcher, + * including {@code .desktop} files on Linux. + *

+ * The macOS-specific {@code Contents/Info.plist} is handled separately + * by the Updater, as part of the switch to the new update site; see + * {@link #migrateUpdateSite()}. + *

+ *

+ * Windows users are advised to update their shortcuts by hand, because: + * A) we don't know where the user put their Fiji shortcuts—they + * could be in the Start menu, on the desktop, or elsewhere and pinned + * to the taskbar; and B) the .lnk file format is an opaque binary + * format and I'm not going to code up something to hack it just for + * this transitional migration code that will be obsolete in a few months. + *

+ *

+ * Finally, old launchers are renamed with {@code .backup} extension in + * case there are any missed shortcuts, so that launch fails fast at the + * OS level rather than potentially exploding at the application level. + *

+ */ + private void migrateShortcuts() { + // If no old launchers are present, assume we already did this. + // FIXME: Which launcher(s) should we look for? All platforms? Or current only? + + // Fix links within Linux .desktop files. + // ~/.local/share/applications + // ~/.local/share/applications/wine/Programs + // /usr/share/applications + // /usr/local/share/applications + // /var/lib/flatpak/exports/share/applications + // START HERE: Scan all .desktop files? Or only ImageJ2.desktop and Fiji.desktop? Or...? + // Completely rewrite them? By calling what code? Can we reuse the code that generated + // them in the first place? Where is that code? The only code I could find that generates + // one of these files is fiji/fiji/scripts/Plugins/Utilities/Create_Desktop_Icon.bsh, + // but it's outdated. So what is making these files these days?? + + if (OS_WIN) { + // FIXME: Warn user to update any shortcuts! + } + + // + } + + /** + * Checks whether the application is running with a + * sufficient Java version, and if so, transitions it to the new core update site. + */ + private void migrateUpdateSite() { + throw new UnsupportedOperationException("migrateUpdateSite unimplemented"); //FIXME + } + + /** + * Checks whether this installation has the new launcher available, and if so, + * clues in the user, informing them about the pros and cons of switching. + * Then, if they should elect to switch, upgrades the managed Java to the + * recommended one and finally relaunches with the new launcher. + */ + private void switchToNewLauncher() { + // Check whether the user has silenced the launcher upgrade prompt. + String prefKey = "skipLauncherUpgradePrompt"; + Preferences prefs = Preferences.userNodeForPackage(getClass()); + boolean skipPrompt = prefs.getBoolean(prefKey, false); + if (skipPrompt) { + // User previously said to "never ask again". + if (log != null) log.debug("Skipping launcher upgrade due to user preference"); + return; + } + + // Define the title of the application. + // Note: I wanted to use the AppService for this. + // But the net.imagej.app.TopLevelImageJApp in net.imagej:imagej + // has higher priority than the sc.fiji.app.FijiApp in sc.fiji:fiji, + // so appService.getApp().getTitle() does not yield the needed string. + // In practice, the only launcher migration path we are supporting + // with this code is ImageJ- -> fiji-, nothing else. + String appTitle = "Fiji"; + + // Discern the application base directory. + File appDir = appService == null ? + ImageJUpdater.getAppDirectory() : + appService.getApp().getBaseDirectory(); + if (appDir == null) { + if (log != null) log.debug("Cannot glean base directory"); + return; + } + appDir = appDir.getAbsoluteFile(); + String appSlug = appTitle.toLowerCase(); + File configDir = appDir.toPath().resolve("config").resolve("jaunch").toFile(); + + // Test whether the new launcher is likely to work on this system. + String nljv; + try { + nljv = probeJavaVersion(appDir, configDir, appSlug); + if (log != null) log.debug("Java from new launcher BEFORE: " + nljv); + } + catch (IOException exc) { + // Something bad happened invoking the new launcher. + // We inform the user and ask for a bug report, then give up. + askForBugReport(log, appTitle, appSlug, exc); + return; + } + catch (UnsupportedOperationException exc) { + // No new executable launcher is available for this platform. + // We give up, because there is nothing to switch over to. + if (log != null) log.debug(exc); + return; + } + + // OK, we've gotten far enough that it's time to ask the + // user whether they want to upgrade to the new launcher. + + String message = "" + + "" + + "
" + + " Heads up: " + appTitle + " is receiving some major updates under the hood! " + + "
" + + "

You are currently running the stable version of " + appTitle + + ", but you now have the option to switch
to the future version. " + + "To help you decide, here is a table summarizing the differences:

" + + "
" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
Feature " + appTitle + " stable " + appTitle + " Future
Stability More Less
Java version OpenJDK 8 OpenJDK 21
Launcher ImageJ Launcher (deprecated) Jaunch
Core update site(s) ImageJ+Fiji+Java-8 sites.imagej.net/Fiji
Apple silicon? Emulated/x86 mode Native!
Receives updates? Security only Latest features
Minimum Windows Windows XP Windows 10
Minimum macOS Mac OS X 10.8 'Mountain Lion' macOS 11 'Big Sur'
Minimum Ubuntu Ubuntu 12.04 'Precise Pangolin' Ubuntu 22.04 'Jammy Jellyfish'
Java 3D version 1.6.0-scijava-2 (buggier) 1.7.x (less buggy)
ImgLib2 version 6.1.0 (released 2023-03-07) 7.1.2 (released 2024-09-03)

" + + "In short: updating to Future will let you continue receiving updates, " + + "but because it is still
new and less well tested, it also " + + "might break your " + appTitle + " installation or favorite plugins.
" + + "
How would you like to proceed?"; + + int optionType = JOptionPane.DEFAULT_OPTION; + int messageType = JOptionPane.QUESTION_MESSAGE; + ImageIcon icon = new ImageIcon(appDir.toPath() + .resolve("images").resolve("icon.png").toString()); + int w = icon.getIconWidth(); + int maxWidth = 120; + if (w > maxWidth) { + icon = new ImageIcon(icon.getImage().getScaledInstance( + maxWidth, icon.getIconHeight() * maxWidth / w, + java.awt.Image.SCALE_SMOOTH)); + } + String yes = "
Update to Future!
※\\(^o^)/※
"; + String no = "
Keep stable for now
⊹╰(⌣ʟ⌣)╯⊹
"; + String never = "
Stable, and never ask again
୧༼ಠ益ಠ༽୨
"; + Object[] options = {yes, no, never}; + Window parent = getApplicationWindow(); + int rval = JOptionPane.showOptionDialog(parent, message, + appTitle, optionType, messageType, icon, options, no); + if (rval != 0) { + // User did not opt in to the launcher upgrade. + if (rval == 2) prefs.putBoolean(prefKey, true); // never ask again! + return; + } + + // Here, the user has agreed to switch to the new launcher. At this point + // we need to be very careful. Users on Apple silicon hardware are likely + // to be running with Rosetta (x86 emulation mode) rather than in native + // ARM64 mode. As such, they probably do not have *any* ARM64 version of + // Java installed, not even Java 8, much less Java 21+. + // + // Therefore, we are going to trigger the new app-launcher's Java upgrade + // logic now, rather than relying on it to trigger conditionally upon + // restart with the new launcher -- because that restart could potentially + // fail due to lack of available Java installations, especially on macOS. + // In order to trigger it successfully, we need to set various properties: + + File appConfigFile = new File(configDir, appSlug + ".toml"); + List lines; + try { + lines = Files.readAllLines(appConfigFile.toPath()); + } + catch (IOException exc) { + log.debug(exc); + // Couldn't read from the config file; define some fallback values. + lines = Arrays.asList( + "-Dscijava.app.java-links='https://downloads.imagej.net/java/jdk-urls.txt',", + "-Dscijava.app.java-version-minimum=8", + "-Dscijava.app.java-version-recommended=21", + "-Dscijava.app.config-file=config/jaunch/" + appSlug + ".cfg" + ); + } + setPropertyIfNull("scijava.app.name", appTitle); + Path splashImage = appDir.toPath().resolve("images").resolve("icon.png"); + setPropertyIfNull("scijava.app.splash-image", splashImage.toString()); + extractAndSetProperty("scijava.app.java-links", lines); + extractAndSetProperty("scijava.app.java-version-minimum", lines); + extractAndSetProperty("scijava.app.java-version-recommended", lines); + extractAndSetProperty("scijava.app.config-file", lines); + String platform = UpdaterUtil.getPlatform(); + // FIXME: I think the macOS platform will be wrong here. It needs -arm64 suffix! + setPropertyIfNull("scijava.app.java-platform", platform); + setPropertyIfNull("scijava.app.java-root", + appDir.toPath().resolve("java").resolve(platform).toString()); + + // Now that the properties are set, we can decide whether to upgrade Java. + if (nljv == null || Versions.compare(nljv, Java.recommendedVersion()) < 0) { + // The new launcher did not find a good-enough Java in our test above, + // so we now ask the app-launcher to download and install such a Java. + Java.upgrade(); + + // And now we test whether the new launcher finds the new Java. + try { + nljv = probeJavaVersion(appDir, configDir, appSlug); + if (log != null) log.debug("Java from new launcher AFTER: " + nljv); + } + catch (IOException | UnsupportedOperationException exc) { + // Something bad happened invoking the new launcher. This is especially + // bad because it worked before running the Java upgrade, but now fails. + // Bummer. We inform the user and ask for a bug report, then give up. + askForBugReport(log, appTitle, appSlug, exc); + return; + } + + if (nljv == null || Versions.compare(nljv, Java.recommendedVersion()) < 0) { + // The new launcher is not using a good-enough Java after upgrading! + if (log != null) { + Path cfgPath = appDir.toPath().resolve( + Paths.get("config", "jaunch", appSlug + ".cfg")); + log.warn("Congratulations on upgrading Java. Unfortunately, " + + "the Java version chosen by the new launcher after the upgrade " + + "is " + nljv + ", which is still less than the recommended Java " + + "version of " + Java.recommendedVersion() + ". This should not " + + "be the case of course; it seems to be a bug. Would you please " + + "visit https://forum.image.sc/ and report this problem? Click " + + "'New Topic', choose 'Usage & Issues' category, and use tag '" + + appSlug + "'. To fix it locally for now, you can try editing the " + + cfgPath + " file by hand to point to a newer Java installation."); + } + return; + } + } + + // All looks good! We can finally relaunch safely with the new launcher. + File exeFile = exeFile(appSlug, appDir); + try { + Process p = new ProcessBuilder(exeFile.getPath()).start(); + boolean terminated = p.waitFor(500, TimeUnit.MILLISECONDS); + if (terminated || !p.isAlive()) { + askForBugReport(log, appTitle, appSlug, + new RuntimeException("New launcher terminated unexpectedly")); + return; + } + // New process seems to be up and running; we are done. Whew! + System.exit(0); + } + catch (IOException | InterruptedException exc) { + askForBugReport(log, appTitle, appSlug, exc); + } + } + + /** Implores the user to report a bug relating to new launcher switch-over. */ + private void askForBugReport( + Logger log, String appTitle, String appSlug, Exception exc) + { + if (log == null) return; + log.error("Argh! " + appTitle + "'s fancy new launcher is not " + + "working on your system! It might be a bug in the new launcher, " + + "or your operating system may be too old to support it. Would you " + + "please visit https://forum.image.sc/ and report this problem? " + + "Click 'New Topic', choose 'Usage & Issues' category, and use tag '" + + appSlug + "'. Please copy+paste the technical information below " + + "into your report. Thank you!\n\n" + + "* os.name=" + System.getProperty("os.name") + "\n" + + "* os.arch=" + System.getProperty("os.arch") + "\n" + + "* os.version=" + System.getProperty("os.version") + "\n", exc); + } + + /** + * Invokes the new native launcher, to make sure all is working as intended. + * + * @return + * The version of Java discovered and used by the native launcher, or else + * {@code null} if either no valid Java installation is discovered or the + * discovered installation emitted no {@code java.version} property value. + * @throws IOException + * If executing the native launcher or reading its output fails. + * @throws UnsupportedOperationException + * If no executable native launcher is available for this system platform. + */ + private static String probeJavaVersion( + File appDir, File configDir, String appPrefix) throws IOException + { + // 1. Find and validate the new launcher and helper files. + + File exeFile = exeFile(appPrefix, appDir); + if (!configDir.isDirectory()) { + throw new UnsupportedOperationException("Launcher config directory is missing: " + configDir); + } + File propsClass = new File(configDir, "Props.class"); + if (!propsClass.isFile()) { + throw new UnsupportedOperationException("Launcher helper program is missing: " + propsClass); + } + + // 2. Run it. + + List output; + int exitCode; + try { + Process p = new ProcessBuilder(exeFile.getPath(), + "-Djava.class.path=" + configDir.getPath(), "--main-class", "Props") + .redirectErrorStream(true).start(); + output = collectProcessOutput(p); + exitCode = p.exitValue(); + } + catch (InterruptedException exc) { + throw new IOException(exc); + } + + // 3. Analyze the output. + + String noJavas = "No matching Java installations found."; + if (!output.isEmpty() && output.get(0).startsWith(noJavas)) return null; + + // Note: We check the exit code below *after* detecting the lack of Java + // installations, because in the above case, that exit code is also non-zero + // (20 as of this writing), and we want to return -1, not throw IOException. + + if (exitCode != 0) { + throw new IOException("Launcher exited with non-zero value: " + exitCode); + } + + String propKey = "java.version="; + return output.stream() + .filter(line -> line.startsWith(propKey)) + .map(line -> line.substring(propKey.length())) + .findFirst().orElse(null); + } + + private static File exeFile(String appPrefix, File appDir) { + // Determine the right executable path for the new launcher. + String exe; + if (OS_WIN) exe = appPrefix + "-windows-" + ARCH + ".exe"; + else if (OS_MACOS) exe = "Contents/MacOS/" + appPrefix + "-macos-" + ARCH; + else if (OS_LINUX) exe = appPrefix + "-linux-" + ARCH; + else throw new UnsupportedOperationException("Unsupported OS: " + OS); + + // Do some sanity checks to make sure we can actually run it. + File exeFile = new File(appDir, exe); + if (!exeFile.isFile()) { + throw new UnsupportedOperationException("Launcher is missing: " + exe); + } + if (!exeFile.canExecute()) { + // Weird -- program is not executable like it should be. Try to fix it. + //noinspection ResultOfMethodCallIgnored + exeFile.setExecutable(true); + } + if (!exeFile.canExecute()) { + throw new UnsupportedOperationException("Launcher is not executable: " + exeFile); + } + + return exeFile; + } + + private static void setPropertyIfNull(String name, String value) { + if (System.getProperty(name) == null) System.setProperty(name, value); + } + + private static void extractAndSetProperty(String name, List lines) { + // No, the following replacement does not escape all problematic regex + // characters. But the properties we're working with here are only + // alphameric with dot separators, so it's OK. Hooray for pragmatism! + String escaped = name.replaceAll("\\.", "\\\\."); + Pattern p = Pattern.compile(".*-D" + escaped + "=['\"]?(.*?)['\"]?,$"); + String value = null; + for (String line : lines) { + Matcher m = p.matcher(line); + if (m.matches()) { + value = m.group(1); + break; + } + } + if (value != null) setPropertyIfNull(name, value); + } + + /** Annoying code to collect stdout lines from a short-running process. */ + private static List collectProcessOutput(Process p) + throws IOException, InterruptedException + { + boolean completed = p.waitFor(5, TimeUnit.SECONDS); + p.exitValue(); + if (!completed) { + p.destroyForcibly(); + throw new IOException("Process took too long to complete."); + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + return reader.lines().collect(Collectors.toList()); + } + } + + /** Annoying code to discern the AWT/Swing main application frame, if any. */ + private Window getApplicationWindow() { + if (uiService == null) return null; + return uiService.getVisibleUIs().stream() + .map(this::getApplicationWindow) + .filter(Objects::nonNull) + .findFirst().orElse(null); + } + + private Window getApplicationWindow(UserInterface ui) { + ApplicationFrame appFrame = ui.getApplicationFrame(); + if (appFrame instanceof Window) return (Window) appFrame; + if (appFrame instanceof UIComponent) { + Object component = ((UIComponent) appFrame).getComponent(); + if (component instanceof Window) return (Window) component; + } + return null; + } +}