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.
+ *
+ * - Maybe the user launched with the old ImageJ launcher.
+ * - Maybe they launched with the new Jaunch launcher.
+ * - Maybe they launched in some other creative way.
+ *
+ *
+ * If they launched with the old launcher, but they have the new one
+ * available, this fancy function clues them in, informing them about
+ * the pros and cons of switching, and even automatically relaunching
+ * with the new launcher if they should elect to do so.
+ *
+ */
+ 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 *new* launcher launched the app.
+ // The old launcher does not set the scijava.app.name property.
+ boolean newLauncherUsed = System.getProperty("scijava.app.name") != null;
+ if (newLauncherUsed) return; // The new launcher was used; all is well.
+
+ // 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.
+ askForLauncherBugReport(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.
+ askForLauncherBugReport(log, appTitle, appSlug, exc);
+ return;
+ }
+
+ if (nljv == null || Versions.compare(nljv, Java.recommendedVersion()) < 0) {
+ // The new launcher is not using the new Java after upgrading!
+ // CTR START HERE - there are still two missing pieces:
+ // - check vs --print-java-home instead of doing version comp
+ // - gotta write fiji.cfg file! grab code from download-java branch
+ }
+ }
+
+ System.out.println(rval);//TEMP
+ System.exit(0);//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()) {
+ askForLauncherBugReport(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) {
+ askForLauncherBugReport(log, appTitle, appSlug, exc);
+ }
+ }
+
+ /** Implores the user to report a bug relating to new launcher switch-over. */
+ private void askForLauncherBugReport(
+ 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 = runProcess(p, 5);
+ 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 running process. */
+ private static List runProcess(Process p, int timeoutInSeconds)
+ throws IOException, InterruptedException
+ {
+ boolean completed = p.waitFor(timeoutInSeconds, 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;
+ }
+
+ /** Annoying code to escape a string for use in HTML. */
+ public static String escapeHtml(String input) {
+ if (input == null || input.isEmpty()) return input;
+
+ StringBuilder escaped = new StringBuilder(input.length());
+ for (int i = 0; i < input.length(); i++) {
+ char c = input.charAt(i);
+ switch (c) {
+ case '&': escaped.append("&"); break;
+ case '<': escaped.append("<"); break;
+ case '>': escaped.append(">"); break;
+ case '"': escaped.append("""); break;
+ case '\'': escaped.append("'"); break;
+ default: escaped.append(c);
+ }
+ }
+ return escaped.toString();
+ }
+
+ public static void main(String[] args) throws Exception {
+ 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();
+ }
+
+}