diff --git a/pom.xml b/pom.xml index e756b2a..f8f47ce 100644 --- a/pom.xml +++ b/pom.xml @@ -189,6 +189,11 @@ + + org.scijava + app-launcher + 0 + org.scijava scijava-common @@ -230,6 +235,11 @@ junit test + + com.formdev + flatlaf + true + 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..198bf32 --- /dev/null +++ b/src/main/java/net/imagej/ui/swing/updater/LauncherMigrator.java @@ -0,0 +1,508 @@ +/* + * #%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.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 + */ +public 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 and {@code Info.plist} on macOS. + * Windows users are advised to update their shortcuts by hand. + * 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() { + throw new UnsupportedOperationException("migrateShortcuts unimplemented"); //FIXME + } + + /** + * 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) return; // User previously said to "never ask again". + + // Discern the title of the application. + String appTitle = appService == null ? + "Fiji" : appService.getApp().getTitle(); //TEMP + + // Discern the application base directory. + File appDir = appService == null ? + ImageJUpdater.getAppDirectory() : + appService.getApp().getBaseDirectory(); + if (appDir == null) return; // Cannot glean base directory; give up. + 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," + ); + } + setPropertyIfNull("scijava.app.name", appTitle); + extractAndSetProperty("scijava.app.java-links", lines); + extractAndSetProperty("scijava.app.java-version-minimum", lines); + extractAndSetProperty("scijava.app.java-version-recommended", lines); + String platform = UpdaterUtil.getPlatform(); + setPropertyIfNull("scijava.app.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! + // FIXME - there is still a missing piece: + // gotta write fiji.cfg file! grab code from download-java branch + throw new RuntimeException("The sky is falling"); + } + } + + System.out.println(rval);//TEMP + System.exit(100);//TEMP + + // 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; + } + + public static void main(String[] args) throws Exception { + System.setProperty("scijava.log.level", "debug");//TEMP + UIManager.setLookAndFeel("com.formdev.flatlaf.FlatLightLaf");//TEMP + System.setProperty("ij.dir", "/home/curtis/Apps/Fiji-Future.app");//TEMP + System.setProperty("ij.executable", "ImageJ-linux64");//TEMP + new LauncherMigrator(null).checkLaunchStatus(); + } + +}