sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 01/02: Provide an installation wizard for configuring the path to JavaFX.
Date Thu, 28 Jan 2021 00:38:03 GMT
This is an automated email from the ASF dual-hosted git repository.

desruisseaux pushed a commit to branch geoapi-4.0
in repository https://gitbox.apache.org/repos/asf/sis.git

commit 54f5a9cb52ef58ca395a36711ef63c233767d93a
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Thu Jan 28 00:50:11 2021 +0100

    Provide an installation wizard for configuring the path to JavaFX.
---
 application/sis-javafx/src/main/artifact/bin/sisfx |   9 +-
 .../sis-javafx/src/main/artifact/bin/sisfx.bat     |   2 +-
 .../org/apache/sis/internal/setup/FXFinder.java    | 369 +++++++---
 .../org/apache/sis/internal/setup/Inflater.java    | 175 +++++
 .../sis/internal/setup/LoggingConfiguration.java   |   7 +
 .../java/org/apache/sis/internal/setup/Wizard.java | 759 +++++++++++++++++++++
 .../org/apache/sis/internal/setup/WizardPage.java  | 114 ++++
 7 files changed, 1334 insertions(+), 101 deletions(-)

diff --git a/application/sis-javafx/src/main/artifact/bin/sisfx b/application/sis-javafx/src/main/artifact/bin/sisfx
index 4f13a6b..fe4dfbe 100755
--- a/application/sis-javafx/src/main/artifact/bin/sisfx
+++ b/application/sis-javafx/src/main/artifact/bin/sisfx
@@ -18,10 +18,12 @@
 
 set -o errexit
 
-BASE_DIR="`readlink --canonicalize-existing $0`"
-BASE_DIR="`dirname $BASE_DIR`/.."
+BASE_DIR="`dirname $0`/.."
 source "$BASE_DIR/conf/setenv.sh"
 
+SIS_DATA="${SIS_DATA:-$BASE_DIR/data}"
+export SIS_DATA
+
 if [ -z "$PATH_TO_FX" ]
 then
     java --class-path "$BASE_DIR/lib/*" org.apache.sis.internal.setup.FXFinder $BASE_DIR/conf/setenv.sh
@@ -32,9 +34,6 @@ then
     source "$BASE_DIR/conf/setenv.sh"
 fi
 
-SIS_DATA="${SIS_DATA:-$BASE_DIR/data}"
-export SIS_DATA
-
 # Execute SIS with any optional JAR that the user may put in the `lib` directory.
 java -splash:"$BASE_DIR/lib/logo.jpg" \
      --add-modules javafx.graphics,javafx.controls \
diff --git a/application/sis-javafx/src/main/artifact/bin/sisfx.bat b/application/sis-javafx/src/main/artifact/bin/sisfx.bat
index 92a5f0f..eb1126e 100644
--- a/application/sis-javafx/src/main/artifact/bin/sisfx.bat
+++ b/application/sis-javafx/src/main/artifact/bin/sisfx.bat
@@ -22,7 +22,7 @@ SET SIS_DATA=%BASE_DIR%\data
 
 IF "%PATH_TO_FX%"=="" (
     java --class-path "%BASE_DIR%\lib\*" org.apache.sis.internal.setup.FXFinder "%BASE_DIR%\conf\setenv.bat"
-    if %ERRORLEVEL% GEQ 1 EXIT /B 1
+    IF %ERRORLEVEL% GEQ 1 EXIT /B 1
     CALL "%BASE_DIR%\conf\setenv.bat"
 )
 
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/FXFinder.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/FXFinder.java
index 5cc2736..9184d56 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/FXFinder.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/FXFinder.java
@@ -16,21 +16,17 @@
  */
 package org.apache.sis.internal.setup;
 
-import java.awt.Desktop;
-import java.awt.Font;
 import java.io.File;
+import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.net.URI;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.StandardOpenOption;
 import java.util.ArrayList;
-import javax.swing.JFileChooser;
-import javax.swing.JLabel;
-import javax.swing.JOptionPane;
-import javax.swing.UIManager;
-import javax.swing.UnsupportedLookAndFeelException;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
 
 
 /**
@@ -44,25 +40,51 @@ import javax.swing.UnsupportedLookAndFeelException;
  */
 public final class FXFinder {
     /**
+     * Minimal version of JavaFX required by Apache SIS.
+     */
+    static final int JAVAFX_VERSION = 13;
+
+    /**
      * The URL where to download JavaFX.
      */
-    private static final String DOWNLOAD_URL = "https://openjfx.io/";
+    static final String JAVAFX_HOME = "https://openjfx.io/";
+
+    /**
+     * Prefix of JavaFX directory. This is checked only in ZIP files.
+     * We do not check that name in decompressed directory because the
+     * user is free to rename.
+     */
+    static final String JAVAFX_DIRECTORY_PREFIX = "javafx-sdk-";
 
     /**
      * The {@value} directory in JavaFX installation directory.
      * This is the directory where JAR files are expected to be found.
      */
-    private static final String LIB_DIRECTORY = "lib";
+    private static final String JAVAFX_LIB_DIRECTORY = "lib";
+
+    /**
+     * A file to search in the {@value #JAVAFX_LIB_DIRECTORY} directory for determining if JavaFX is present.
+     */
+    private static final String JAVAFX_SENTINEL_FILE = "javafx.controls.jar";
+
+    /**
+     * The environment variable containing the path to JavaFX {@value #JAVAFX_LIB_DIRECTORY} directory.
+     */
+    static final String PATH_VARIABLE = "PATH_TO_FX";
 
     /**
-     * A file to search in the {@value #LIB_DIRECTORY} directory for determining if JavaFX is present.
+     * The {@value} directory in Apache SIS installation where the {@code setenv.sh} file
+     * is expected to be located.
      */
-    private static final String SENTINEL_FILE = "javafx.controls.jar";
+    private static final String SIS_CONF_DIRECTORY = "conf";
 
     /**
-     * The environment variable containing the path to JavaFX {@value #LIB_DIRECTORY} directory.
+     * The {@value} directory in Apache SIS installation where to unzip JavaFX.
+     * This is relative to {@code $BASE_DIR} environment variable.
+     *
+     * @see #decompress(Wizard)
      */
-    private static final String PATH_VARIABLE = "PATH_TO_FX";
+    private static final String SIS_UNZIP_DIRECTORY = "opt";
 
     /**
      * File extension of Windows batch file. If the script file to edit does not have this extension,
@@ -71,9 +93,47 @@ public final class FXFinder {
     private static final String WINDOWS_BATCH_EXTENSION = ".bat";
 
     /**
-     * Do not allow instantiation of this class.
+     * Exit code to return when user cancelled the configuration process.
+     */
+    private static final int CANCEL_EXIT_CODE = 1;
+
+    /**
+     * Exit code to return if the wizard can not start.
+     */
+    private static final int ERROR_EXIT_CODE = 2;
+
+    /**
+     * The JavaFX directory as specified by the user, or {@code null} if none.
+     */
+    private File specified;
+
+    /**
+     * The JavaFX directory validated by {@code FXFinder}, or {@code null} if the directory is invalid.
+     * May be the same file than {@link #specified}, but not necessarily; it may be a subdirectory.
      */
-    private FXFinder() {
+    private File validated;
+
+    /**
+     * Path of the {@code setenv.sh} file to edit.
+     */
+    private final Path setenv;
+
+    /**
+     * The background task created if there is a JavaFX ZIP file to decompress.
+     */
+    private Inflater inflater;
+
+    /**
+     * {@code true} if this operation systems is Windows, or {@code false} if assumed Unix (Linux or MacOS).
+     */
+    private final boolean isWindows;
+
+    /**
+     * Creates a new finder.
+     */
+    private FXFinder(final String setenv) {
+        this.setenv = Paths.get(setenv).normalize();
+        isWindows = setenv.endsWith(WINDOWS_BATCH_EXTENSION);
     }
 
     /**
@@ -82,110 +142,229 @@ public final class FXFinder {
      * @param  args  command line arguments. Should have a length of 1,
      *               with {@code args[0]} containing the path of the file to edit.
      */
+    @SuppressWarnings("UseOfSystemOutOrSystemErr")
     public static void main(String[] args) {
-        boolean success = false;
-        try {
-            success = askDirectory(Paths.get(args[0]).normalize());
-        } catch (Exception e) {
-            JOptionPane.showMessageDialog(null, e.toString(), "Error", JOptionPane.ERROR_MESSAGE);
+        if (args.length == 1) {
+            if (Wizard.show(new FXFinder(args[0]))) {
+                // Call to `System.exit(int)` will be done by `Wizard`.
+                return;
+            }
+        } else {
+            System.out.println("Required: path to setenv.sh");
         }
-        System.exit(success ? 0 : 1);
+        System.exit(ERROR_EXIT_CODE);
     }
 
     /**
-     * Popups a modal dialog box asking user to choose a directory.
+     * Returns {@code null} if the configuration file has been found and can be edited.
+     * If this method returns a non-null, then the setup wizard should be cancelled.
+     * The returned value can be used as an error message.
+     */
+    final String diagnostic() {
+        if (Files.isReadable(setenv) && Files.isWritable(setenv)) {
+            return null;
+        }
+        return "Can not edit " + setenv;
+    }
+
+    /**
+     * Returns the values of environment variables relevant to Apache SIS.
+     * This is used for showing a summary after configuration finished.
+     */
+    final String[][] getEnvironmentVariables() {
+        return new String[][] {
+            getEnvironmentVariable("JAVA_HOME"),
+            getEnvironmentVariable(PATH_VARIABLE),
+            getEnvironmentVariable("SIS_DATA"),
+            getEnvironmentVariable("SIS_OPTS"),
+        };
+    }
+
+    /**
+     * Returns the value of the environment variable of given name.
+     * The returned array contains the following elements:
      *
-     * @param  setenv  path of the {@code setenv.sh} file to edit.
-     * @return {@code true} if we can continue with application launch,
-     *         or {@code false} on error or cancellation.
+     * <ul>
+     *   <li>Variable name</li>
+     *   <li>Value to show (never null)</li>
+     * </ul>
+     */
+    private String[] getEnvironmentVariable(final String name) {
+        String value;
+        try {
+            value = System.getenv(name);
+            if (value == null) {
+                value = "(undefined)";
+            } else if (value.isEmpty()) {
+                value = "(blank)";
+            } else if (name.equals("SIS_DATA") && value.equals("bin/../data")) {
+                value = Paths.get(value).toAbsolutePath().toString();
+            }
+        } catch (SecurityException e) {
+            value  = "(unreadable)";
+        }
+        return new String[] {name, value};
+    }
+
+    /**
+     * Returns the name of JavaFX bundle to download, including the operating system name.
+     * Example: "JavaFX Linux SDK". This is for helping the user to choose which file to
+     * download on the {@value Constants#JAVAFX_HOME} web page.
      */
-    private static boolean askDirectory(final Path setenv) throws Exception {
+    static String getJavafxBundleName() {
+        String name;
         try {
-            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
-        } catch (ReflectiveOperationException | UnsupportedLookAndFeelException e) {
-            // Ignore.
+            name = System.getProperty("os.name");
+        } catch (SecurityException e) {
+            name = null;
         }
-        /*
-         * Checks now that we can edit `setenv.sh` content in order to not show the next
-         * dialog box if we czn not read that file (e.g. because the file was not found).
-         */
-        if (!Files.isReadable(setenv) || !Files.isWritable(setenv)) {
-            JOptionPane.showMessageDialog(null, "Can not edit " + setenv,
-                    "Configuration error", JOptionPane.WARNING_MESSAGE);
-            return false;
+        if (name == null) {
+            name = "<operating system>";
         }
-        /*
-         * Ask the user what he wants to do.
-         */
-        final JLabel description = new JLabel(
-                "<html><body><p style=\"width:400px; text-align:justify;\">" +
-                "This application requires <b>JavaFX</b> version 13 or later. " +
-                "Click on “Download” for opening the free download page. " +
-                "If JavaFX is already installed on this computer, " +
-                "click on “Set directory” for specifying the installation directory." +
-                "</p></body></html>");
-
-        description.setFont(description.getFont().deriveFont(Font.PLAIN));
-        final Object[] options = {"Download", "Set directory", "Cancel"};
-        final int choice = JOptionPane.showOptionDialog(null, description,
-                "JavaFX installation directory",
-                JOptionPane.YES_NO_CANCEL_OPTION,
-                JOptionPane.QUESTION_MESSAGE,
-                null,
-                options,
-                options[2]);
-
-        if (choice == 0) {
-            if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
-                Desktop.getDesktop().browse(URI.create(DOWNLOAD_URL));
-            } else {
-                JOptionPane.showMessageDialog(null, "See " + DOWNLOAD_URL,
-                        "JavaFX download", JOptionPane.INFORMATION_MESSAGE);
-            }
-        } else if (choice == 1) {
-            final JFileChooser fd = new JFileChooser();
-            fd.setDialogTitle("JavaFX installation directory");
-            fd.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
-            while (fd.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
-                final File dir = findSubDirectory(fd.getSelectedFile());
-                if (dir == null) {
-                    JOptionPane.showMessageDialog(null, "Not a JavaFX directory.",
-                            "JavaFX installation directory", JOptionPane.WARNING_MESSAGE);
-                } else {
-                    setDirectory(setenv, dir);
-                    return true;
+        return "JavaFX " + name + " SDK";
+    }
+
+    /**
+     * Returns the directory as validated by {@code FXFinder}.
+     * May be slightly different than the user-specified directory.
+     */
+    final String getValidatedDirectory() {
+        return (validated != null) ? validated.getPath() : null;
+    }
+
+    /**
+     * Returns the directory specified by the user, or {@code null} if none.
+     */
+    final File getDirectory() {
+        return specified;
+    }
+
+    /**
+     * Sets the JavaFX directory to the given value and checks its validity.
+     * This method tries to locate the {@code lib} sub-folder that we expect
+     * in a JavaFX installation directory.
+     *
+     * @param  dir  the directory from where to start the search.
+     * @return whether the given directory seems valid.
+     */
+    final boolean setDirectory(final File dir) {
+        specified = dir;
+        validated = null;
+        if (new File(dir, JAVAFX_SENTINEL_FILE).exists()) {
+            validated = dir;
+            return true;
+        }
+        final File lib = new File(dir, JAVAFX_LIB_DIRECTORY);
+        if (new File(lib, JAVAFX_SENTINEL_FILE).exists()) {
+            validated = lib;
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Verifies whether the given file seems to be a valid ZIP file.
+     * This method checks for a sentinel value in the ZIP entries.
+     * The entry may be:
+     *
+     * <pre>javafx-sdk-&lt;version&gt;/lib/javafx.controls.jar</pre>
+     *
+     * If the file seems valid, {@code null} is returned.
+     * Otherwise an error message is HTML is returned.
+     */
+    static String checkZip(final File file) throws IOException {
+        try (ZipFile zip = new ZipFile(file)) {
+            final Enumeration<? extends ZipEntry> entries = zip.entries();
+            while (entries.hasMoreElements()) {
+                final ZipEntry entry = entries.nextElement();
+                if (entry.isDirectory()) {
+                    final String basedir = entry.getName();
+                    if (basedir.startsWith(JAVAFX_DIRECTORY_PREFIX)) {
+                        final int start = JAVAFX_DIRECTORY_PREFIX.length();
+                        int end = basedir.indexOf('.', start);
+                        if (end < start) end = basedir.length();
+                        final int version = Integer.parseInt(basedir.substring(start, end));
+                        if (version < JAVAFX_VERSION) {
+                            return "<html>Apache SIS requires JavaFX version " + JAVAFX_VERSION + " or later. "
+                                    + "The given file contains JavaFX version " + version + ".</html>";
+                        }
+                        if (zip.getEntry(basedir + JAVAFX_LIB_DIRECTORY + '/' + JAVAFX_SENTINEL_FILE) != null) {
+                            return null;        // Valid file.
+                        }
+                    }
+                    break;
                 }
             }
         }
-        return false;
+        return "<html>Not a recognized ZIP file for JavaFX SDK.</html>";
     }
 
     /**
-     * Tries to locate the {@code lib} sub-folder in a JavaFX installation directory.
+     * Returns the destination directory where to decompress ZIP files.
+     * This method assumes the following directory structure:
      *
-     * @param  dir  the directory from where to start the search.
-     * @return      the {@code lib} directory, or {@code null} if not found.
+     * {@preformat text
+     *     apache-sis       (can be any name)
+     *     ├─ conf
+     *     │  └─ setenv.sh
+     *     └─ opt
+     * }
      */
-    private static File findSubDirectory(final File dir) {
-        if (new File(dir, SENTINEL_FILE).exists()) {
-            return dir;
+    final File getDestinationDirectory() throws IOException {
+        File basedir = setenv.toAbsolutePath().toFile().getParentFile();
+        if (basedir != null && SIS_CONF_DIRECTORY.equals(basedir.getName())) {
+            basedir = basedir.getParentFile();
+            if (basedir != null) {
+                final File destination = new File(basedir, SIS_UNZIP_DIRECTORY);
+                if (destination.isDirectory() || destination.mkdir()) {
+                    return destination;
+                }
+                throw new IOException("Can not create directory: " + destination);
+            }
         }
-        final File lib = new File(dir, LIB_DIRECTORY);
-        if (new File(lib, SENTINEL_FILE).exists()) {
-            return lib;
+        throw new FileNotFoundException("No parent directory to " + setenv + '.');
+    }
+
+    /**
+     * If the user-specified file is a ZIP file, starts decompression in a background thread.
+     *
+     * @return whether decompression started.
+     */
+    final boolean decompress(final Wizard wizard) {
+        if (validated == null) {
+            inflater = new Inflater(wizard, specified);
+            final Thread t = new Thread(inflater, "Inflater");
+            t.start();
+            return true;
         }
-        return null;
+        return false;
     }
 
     /**
-     * Sets the JavaFX directory.
+     * Cancels configuration, deletes decompressed files if any and exits.
+     * This method is invoked by {@link Wizard} in the following situations:
+     *
+     * <ul>
+     *   <li>User clicked on the "Cancel" button.</li>
+     *   <li>User clicked on the "Close window" button in window title bar.</li>
+     * </ul>
      *
-     * @param  setenv  path to the {@code setenv.sh} file to edit.
-     * @param  dir     directory selected by user.
+     * If a decompression is in progress, it is stopped and all files are deleted.
+     */
+    final void cancel() {
+        if (inflater != null) {
+            inflater.cancel();
+        }
+        System.exit(CANCEL_EXIT_CODE);
+    }
+
+    /**
+     * Commits the configuration by writing the JavaFX directory in the {@code setenv.sh} file.
      */
-    private static void setDirectory(final Path setenv, final File dir) throws IOException {
+    final void commit() throws IOException {
+        inflater = null;
         String command = PATH_VARIABLE;
-        if (setenv.getFileName().toString().endsWith(WINDOWS_BATCH_EXTENSION)) {
+        if (isWindows) {
             command = "SET " + command;                             // Microsoft Windows syntax.
         }
         final ArrayList<String> content = new ArrayList<>();
@@ -201,7 +380,7 @@ public final class FXFinder {
         if (insertAt < 0) {
             insertAt = content.size();
         }
-        content.add(insertAt, command + '=' + dir);
+        content.add(insertAt, command + '=' + validated);
         Files.write(setenv, content, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
     }
 }
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Inflater.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Inflater.java
new file mode 100644
index 0000000..5e680f8
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Inflater.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work 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 org.apache.sis.internal.setup;
+
+import java.awt.EventQueue;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.Enumeration;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import javax.swing.JProgressBar;
+
+
+/**
+ * Decompress the ZIP file for JavaFX in a background thread.
+ *
+ * <p><b>Design note:</b> we do not use {@link javax.swing.SwingWorker} because that classes
+ * is more expansive than what we need. For example it creates a pool of 10 threads while we
+ * need only one.</p>
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class Inflater implements Runnable {
+    /**
+     * The wizard to notify about completion of failure.
+     */
+    private final Wizard wizard;
+
+    /**
+     * The zip file to decompress.
+     */
+    private final File source;
+
+    /**
+     * The directory where the ZIP file is decompressed.
+     */
+    private File destination;
+
+    /**
+     * The {@linkplain #destination} directory, plus the first subdirectory in
+     * the ZIP file that starts with {@value FXFinder#JAVAFX_DIRECTORY_PREFIX}.
+     */
+    private File subdir;
+
+    /**
+     * If decompression failed, the cause. Otherwise {@code null}.
+     */
+    private Exception failure;
+
+    /**
+     * Whether this task is cancelled.
+     */
+    private volatile boolean cancelled;
+
+    /**
+     * Creates a new inflater for the specified ZIP file.
+     */
+    Inflater(final Wizard wizard, final File source) {
+        this.wizard = wizard;
+        this.source = source;
+    }
+
+    /**
+     * The task to be executed in a background {@link Thread}.
+     * This method dispatches the work to {@link #doInBackground()} and {@link #done()} methods.
+     */
+    @Override
+    public synchronized void run() {
+        try {
+            doInBackground();
+        } catch (Exception e) {
+            failure = e;
+            delete(destination);
+        }
+        EventQueue.invokeLater(this::done);
+    }
+
+    /**
+     * Decompresses the JavaFX ZIP file.
+     */
+    private void doInBackground() throws Exception {
+        destination = wizard.javafxFinder.getDestinationDirectory();
+        final JProgressBar progressBar = wizard.inflateProgress;
+        final byte[] buffer = new byte[65536];
+        try (ZipFile zip = new ZipFile(source)) {
+            final int size = zip.size();
+            EventQueue.invokeAndWait(() -> progressBar.setMaximum(size));
+            final Enumeration<? extends ZipEntry> entries = zip.entries();
+            int progressValue = 0;
+            while (entries.hasMoreElements()) {
+                final ZipEntry entry = entries.nextElement();
+                final File file = new File(destination, entry.getName());
+                if (entry.isDirectory()) {
+                    if (!file.isDirectory() && !file.mkdir()) {
+                        throw new IOException("Directory can not be created: " + file);
+                    }
+                    if (subdir == null && entry.getName().startsWith(FXFinder.JAVAFX_DIRECTORY_PREFIX)) {
+                        subdir = file;
+                    }
+                } else {
+                    try (InputStream  in  = zip.getInputStream(entry);
+                         OutputStream out = new FileOutputStream(file))     // No need for buffered streams here.
+                    {
+                        int n;
+                        while ((n = in.read(buffer)) >= 0) {
+                            if (cancelled) return;
+                            out.write(buffer, 0, n);
+                        }
+                    }
+                }
+                final int p = progressValue++;
+                EventQueue.invokeLater(() -> progressBar.setValue(p));
+            }
+        }
+    }
+
+    /**
+     * Invoked in Swing thread after the decompression is done, either successfully or on failure.
+     * Note that {@link #cancelled} may be {@code true} only if {@link #cancel()} has been invoked,
+     * in which case this method does nothing because the files will be deleted by {@code cancel()}
+     * and the system will exit.
+     */
+    private void done() {
+        if (!cancelled) {
+            if (subdir == null) subdir = destination;
+            wizard.decompressionFinished(subdir, failure);
+        }
+    }
+
+    /**
+     * Stops the thread if it is running, then delete the files.
+     * This method is invoked by {@link FXFinder} just before {@link System#exit(int)}.
+     */
+    final void cancel() {
+        cancelled = true;
+        synchronized (this) {               // Wait for background thread to finish.
+            delete(destination);
+        }
+    }
+
+    /**
+     * Deletes a directory and all its content recursively.
+     */
+    private static void delete(final File directory) {
+        if (directory != null) {
+            final File[] content = directory.listFiles();
+            if (content != null) {
+                for (final File file : content) {
+                    delete(file);
+                }
+            }
+            directory.delete();
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/LoggingConfiguration.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/LoggingConfiguration.java
index 0af0f66..b03a302 100644
--- a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/LoggingConfiguration.java
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/LoggingConfiguration.java
@@ -33,6 +33,13 @@ import java.nio.file.Paths;
  * <p>This class should not use any SIS classes because it may be invoked early
  * while the application is still initializing.</p>
  *
+ * <p>This class is not referenced directly by other Java code. Instead, it is
+ * specified at JVM startup time like below:</p>
+ *
+ * {@preformat shell
+ *     java -Djava.util.logging.config.class="org.apache.sis.internal.setup.LoggingConfiguration"
+ * }
+ *
  * @author  Martin Desruisseaux (Geomatys)
  * @version 1.1
  * @since   1.1
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Wizard.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Wizard.java
new file mode 100644
index 0000000..6ef160e
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Wizard.java
@@ -0,0 +1,759 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work 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 org.apache.sis.internal.setup;
+
+import java.awt.BorderLayout;
+import java.awt.CardLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Desktop;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.datatransfer.UnsupportedFlavorException;
+import java.awt.dnd.DnDConstants;
+import java.awt.dnd.DropTarget;
+import java.awt.dnd.DropTargetDragEvent;
+import java.awt.dnd.DropTargetDropEvent;
+import java.awt.dnd.DropTargetEvent;
+import java.awt.dnd.DropTargetListener;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.io.File;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import javax.swing.Box;
+import javax.swing.JButton;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JOptionPane;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.JSeparator;
+import javax.swing.UIManager;
+import javax.swing.UnsupportedLookAndFeelException;
+import javax.swing.border.Border;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.LineBorder;
+import javax.swing.filechooser.FileFilter;
+
+
+/**
+ * Configuration wizard for Apache SIS.
+ * The wizard contains the following step:
+ *
+ * <ul>
+ *   <li>Introduction</li>
+ *   <li>Internet page where to download JavaFX.</li>
+ *   <li>Path to JavaFX installation directory.</li>
+ *   <li>Configuration summary</li>
+ * </ul>
+ *
+ * This class provides all the Graphical User Interface (GUI) using Swing widgets.
+ * The class doing actual work for managing SIS configuration is {@link FXFinder}.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+final class Wizard extends FileFilter implements ActionListener, PropertyChangeListener, DropTargetListener {
+    /**
+     * Initializes Look and Feel before to construct any Swing component.
+     */
+    static {
+        try {
+            UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+        } catch (ReflectiveOperationException | UnsupportedLookAndFeelException e) {
+            // Ignore.
+        }
+        UIManager.put("FileChooser.readOnly", Boolean.TRUE);
+    }
+
+    /**
+     * The window width, in pixels.
+     */
+    private static final int WIDTH = 700;
+
+    /**
+     * Label of button to shown in the wizard, also used as action identifier.
+     */
+    private static final String BACK = "Back", NEXT = "Next", CANCEL = "Cancel",
+            JAVAFX_HOME = "Open JavaFX home page", BROWSE = "Browse", SELECT = "Select";
+
+    /**
+     * Color of {@linkplain #titles} for pages other than the current page.
+     *
+     * Conceptually a {@code static final} constant, but declared non-static for initializing
+     * it only at {@link Wizard} creation time and because that creation will happen only once.
+     */
+    private final Color TITLE_COLOR = new Color(36, 113, 163);
+
+    /**
+     * Color of title for the {@linkplain #currentPage current page}.
+     *
+     * Conceptually a {@code static final} constant, but declared non-static for initializing
+     * it only at {@link Wizard} creation time and because that creation will happen only once.
+     */
+    private final Color SELECTED_TITLE_COLOR = new Color(21, 67, 96);
+
+    /**
+     * Bullet in front of (selected) titles. Bullets should have the same width
+     * for avoiding change in {@link #titles} text position when user move from
+     * one page to the other.
+     */
+    private static final String TITLE_BULLET = "• ", SELECTED_TITLE_BULLET = "‣ ";
+
+    /**
+     * The normal {@link #javafxPath} border.
+     *
+     * Conceptually a {@code static final} constant, but declared non-static for initializing
+     * it only at {@link Wizard} creation time and because that creation will happen only once.
+     */
+    private final Border JAVAFX_PATH_BORDER = new LineBorder​(Color.GRAY);
+
+    /**
+     * The {@link #javafxPath} border during drag and drop action. We use a green border.
+     *
+     * Conceptually a {@code static final} constant, but declared non-static for initializing
+     * it only at {@link Wizard} creation time and because that creation will happen only once.
+     */
+    private final Border JAVAFX_PATH_BORDER_DND = new LineBorder​(new Color(40, 180, 99), 3);
+
+    /**
+     * The top-level window where wizard will be shown.
+     */
+    private final JFrame wizard;
+
+    /**
+     * The panel where each wizard step is shown. This panel uses a {@link CardLayout}.
+     */
+    private final JPanel cardPanel;
+
+    /**
+     * The button for moving to next page.
+     * Its label will be changed from "Next" to "Finish" when on the last page.
+     */
+    private final JButton nextButton;
+
+    /**
+     * The button for moving to previous page.
+     * Disabled when on the first page.
+     */
+    private final JButton backButton;
+
+    /**
+     * The button for cancelling setup.
+     * Disabled when on the last page.
+     */
+    private final JButton cancelButton;
+
+    /**
+     * The button for selecting a directory or a ZIP file. This button may be
+     * non-null only during the time that a {@link JFileChooser} is visible.
+     *
+     * @see #findSelectButton(Container)
+     */
+    private JButton selectButton;
+
+    /**
+     * Titles of each page. This is highlighted during navigation.
+     */
+    private final JLabel[] titles;
+
+    /**
+     * The page currently shown.
+     */
+    private WizardPage currentPage;
+
+    /**
+     * Whether this wizard accepts the JavaFX location specified in the {@link WizardPage#JAVAFX_LOCATION} page.
+     */
+    private boolean acceptLocation;
+
+    /**
+     * JavaFX directory or ZIP file.
+     *
+     * @see #setJavafxPath(File)
+     */
+    final FXFinder javafxFinder;
+
+    /**
+     * View of the path to JavaFX installation directory. This is the value of {@link FXFinder#getDirectory()},
+     * potentially shown in red if the location is not valid.
+     */
+    private final JLabel javafxPath;
+
+    /**
+     * If the {@link #javafxPath} is not valid, a message for the user. Otherwise this label is empty.
+     */
+    private final JLabel javafxPathError;
+
+    /**
+     * The message shown on the last page. This is <cite>"Apache SIS setup is completed"</cite>,
+     * but may be changed if the setup failed.
+     */
+    private JLabel finalMessage;
+
+    /**
+     * Final value of JavaFX path shown in the last page. May be slightly different than {@link #javafxPath}.
+     */
+    private JLabel finalJavafxPath;
+
+    /**
+     * Information about progress of decompression process.
+     */
+    final JProgressBar inflateProgress;
+
+    /**
+     * Creates a new wizard.
+     *
+     * @see #show(FXFinder)
+     */
+    private Wizard(final FXFinder javafxFinder) {
+        this.javafxFinder = javafxFinder;
+        wizard = new JFrame("Apache SIS setup");
+        final Container content = wizard.getContentPane();
+        content.setLayout(new BorderLayout());
+        /*
+         * Back, Next, Cancel button.
+         */
+        {   // For keeping variables in a local scope.
+            final Box buttons = Box.createHorizontalBox();
+            buttons.setBorder(new EmptyBorder(9, 12, 9, 15));       // Top, left, bottom, right.
+            backButton   = createButton(buttons, BACK); buttons.add(Box.createHorizontalStrut(10));
+            nextButton   = createButton(buttons, NEXT); buttons.add(Box.createHorizontalStrut(30));
+            cancelButton = createButton(buttons, CANCEL);
+            backButton.setEnabled(false);
+
+            final JPanel bottom = new JPanel(new BorderLayout());
+            bottom.add(new JSeparator(), BorderLayout.NORTH);
+            bottom.add(buttons, java.awt.BorderLayout.EAST);
+            content.add(bottom, BorderLayout.SOUTH);
+        }
+        /*
+         * Navigation panel on the left side with the following titles
+         * (currently shown page is highlighted):
+         *
+         *    - Introduction
+         *    - Download
+         *    - Set directory
+         *    - Summary
+         */
+        final WizardPage[] pages = WizardPage.values();
+        {
+            titles = new JLabel[pages.length];
+            final EmptyBorder padding = new EmptyBorder(3, 0, 3, 0);
+            final Box summary = Box.createVerticalBox();
+            for (int i=0; i<pages.length; i++) {
+                final String title = (i == 0 ? SELECTED_TITLE_BULLET : TITLE_BULLET) + pages[i].title;
+                final JLabel label = new JLabel(title, JLabel.LEFT);
+                label.setForeground(i == 0 ? SELECTED_TITLE_COLOR : TITLE_COLOR);
+                label.setBorder(padding);
+                summary.add(titles[i] = label);
+            }
+            final JPanel pane = new JPanel();
+            pane.setBackground(new Color(169, 204, 227));
+            pane.setBorder(new EmptyBorder(40, 15, 9, 24));         // Top, left, bottom, right.
+            pane.add(summary);
+            content.add(pane,  BorderLayout.WEST);
+        }
+        /*
+         * The main content where text is shown, together with download button, directory chooser, etc.
+         * The content of each page is created by `createPage(…)`. They all have in common to start with
+         * a description text formatted in HTML.
+         */
+        {
+            final Font font = new Font(Font.SERIF, Font.PLAIN, 14);
+            javafxPath = new JLabel();
+            javafxPath.setBorder(JAVAFX_PATH_BORDER);
+            javafxPathError = new JLabel();
+            javafxPathError.setForeground(Color.RED);
+            javafxPathError.setFont(font);
+            inflateProgress = new JProgressBar();
+            cardPanel = new JPanel(new CardLayout());
+            cardPanel.setBorder(new EmptyBorder(30, 30, 9, 30));    // Top, left, bottom, right.
+            cardPanel.setBackground(Color.WHITE);
+            for (final WizardPage page : pages) {
+                cardPanel.add(createPage(page, font), page.name());
+                // The initially visible component is the first added.
+            }
+            currentPage = pages[0];
+            content.add(cardPanel, BorderLayout.CENTER);
+        }
+        wizard.setSize(WIDTH, 500);                 // Must be before `setLocationRelativeTo(…)`.
+        wizard.setResizable(false);
+        wizard.setLocationRelativeTo(null);
+        wizard.addWindowListener(new WindowAdapter() {
+            @Override public void windowClosing(WindowEvent event) {
+                javafxFinder.cancel();
+            }
+        });
+    }
+
+    /**
+     * Invoked by the constructor for preparing in advance each page in a {@link CardLayout}.
+     * Each page starts with a text formatted in HTML using a Serif font (such as Times),
+     * followed by control specific to each page.
+     *
+     * @param  page  identifies the page to create.
+     * @param  font  Serif font to use for the text.
+     */
+    private Box createPage(final WizardPage page, final Font font) {
+        final Box content = Box.createVerticalBox();
+        final JLabel text = new JLabel(page.text, JLabel.LEFT);
+        text.setFont(font);
+        content.add(text);
+        content.add(Box.createVerticalStrut(30));
+        switch (page) {
+            case DOWNLOAD_JAVAFX: {
+                createButton(content, JAVAFX_HOME).setToolTipText(FXFinder.JAVAFX_HOME);
+                final JLabel instruction = new JLabel(WizardPage.downloadSteps());
+                instruction.setFont(font.deriveFont(12f));
+                content.add(instruction);
+                break;
+            }
+            case JAVAFX_LOCATION: {
+                javafxPath.setMinimumSize(new Dimension(  100, 30));
+                javafxPath.setMaximumSize(new Dimension(WIDTH, 30));
+                content.add(javafxPath);
+                content.add(Box.createVerticalStrut(12));
+                createButton(content, BROWSE);
+                content.add(Box.createVerticalStrut(24));
+                content.add(javafxPathError);
+                content.setDropTarget(new DropTarget(content, this));
+                break;
+            }
+            case DECOMPRESS: {
+                inflateProgress.setMinimumSize(new Dimension(  100, 21));
+                inflateProgress.setMaximumSize(new Dimension(WIDTH, 21));
+                content.add(inflateProgress);
+                break;
+            }
+            case COMPLETED: {
+                finalMessage = text;
+                final Border vb = new EmptyBorder(0, 15, 9, 0);
+                final Font   fn = new Font(Font.MONOSPACED, Font.BOLD,  13);
+                final Font   fv = new Font(Font.SANS_SERIF, Font.PLAIN, 13);
+                for (final String[] variable : javafxFinder.getEnvironmentVariables()) {
+                    final JLabel name  = new JLabel(variable[0] + ':');
+                    final JLabel value = new JLabel(variable[1]);
+                    name .setForeground(Color.DARK_GRAY);
+                    value.setForeground(Color.DARK_GRAY);
+                    name .setFont(fn);
+                    value.setFont(fv);
+                    value.setBorder(vb);
+                    name.setLabelFor(value);
+                    content.add(name);
+                    content.add(value);
+                    if (FXFinder.PATH_VARIABLE.equals(variable[0])) {
+                        finalJavafxPath = value;
+                    }
+                }
+                break;
+            }
+        }
+        return content;
+    }
+
+    /**
+     * Creates a button and adds it to the given box. A listener is registered
+     * for an action having the same name than the button label.
+     *
+     * @param  addTo  the horizontal box where to add the button.
+     * @param  label  button labels, also used as action identifier.
+     * @return the added button.
+     */
+    private JButton createButton(final Box addTo, final String label) {
+        JButton button = new JButton(label);
+        button.setActionCommand(label);
+        button.addActionListener(this);
+        addTo.add(button);
+        return button;
+    }
+
+    /**
+     * Invoked when user clicks on a button.
+     * The action name is the label given to {@link #createButton(Box, String)}.
+     */
+    @Override
+    public void actionPerformed(final ActionEvent event) {
+        switch (event.getActionCommand()) {
+            case CANCEL:      javafxFinder.cancel();  break;
+            case BACK:        nextOrPreviousPage(-1); break;
+            case NEXT:        nextOrPreviousPage(+1); break;
+            case JAVAFX_HOME: openJavafxHomePage();   break;
+            case BROWSE:      showDirectoryChooser(); break;
+        }
+    }
+
+    /**
+     * Invoked when the user clicks on the {@value #BACK} or {@value #NEXT} button for moving to the
+     * previous page or to the next page. This method changes the highlighted title on the left side,
+     * updates the buttons enabled status and shows the new page.
+     *
+     * <p>Moving to the next page may cause the following actions:</p>
+     * <ul>
+     *   <li>Moving to the last page cause a call to {@link FXFinder#commit()}.</li>
+     *   <li>Moving after the last page cause a system exit (wizard finished).</li>
+     * </ul>
+     *
+     * @param  n  -1 for previous page, or +1 for next page.
+     */
+    private void nextOrPreviousPage(final int n) {
+        /*
+         * Restore title (on the left side) of current page to default color.
+         * In other words, remove highlighting.
+         */
+        int index = currentPage.ordinal();
+        JLabel title = titles[index];
+        title.setForeground(TITLE_COLOR);
+        title.setText(TITLE_BULLET + currentPage.title);
+        final WizardPage[] pages = WizardPage.values();
+        if ((index += n) >= pages.length) {
+            /*
+             * User clicked on "Finish" in the last page:
+             * wizard finished successfully.
+             */
+            wizard.dispose();
+            System.exit(0);
+            return;
+        }
+        /*
+         * Highlight title (on the left side) of new current page.
+         * Next, there is some specific actions depending on the new page.
+         */
+        currentPage = pages[index];
+        title = titles[index];
+        title.setForeground(SELECTED_TITLE_COLOR);
+        title.setText(SELECTED_TITLE_BULLET + currentPage.title);
+        backButton.setEnabled(index > 0);
+        nextButton.setEnabled(true);
+        switch (currentPage) {
+            case JAVAFX_LOCATION: {
+                nextButton.setEnabled(acceptLocation);
+                break;
+            }
+            case DECOMPRESS: {
+                backButton.setEnabled(false);
+                nextButton.setEnabled(false);
+                if (!javafxFinder.decompress(this)) {
+                    nextOrPreviousPage(n);              // Nothing to decompress, skip this page.
+                    return;
+                }
+                break;
+            }
+            case COMPLETED: {
+                backButton.setEnabled(false);
+                nextButton.setText("Finish");
+                try {
+                    javafxFinder.commit();
+                    cancelButton.setEnabled(false);
+                    finalJavafxPath.setText(javafxFinder.getValidatedDirectory());
+                } catch (IOException e) {
+                    nextButton.setEnabled(false);
+                    finalMessage.setForeground(Color.RED);
+                    finalMessage.setText(getHtmlMessage("Apache SIS setup can not be completed.", e));
+                }
+               break;
+            }
+        }
+        ((CardLayout) cardPanel.getLayout()).show(cardPanel, currentPage.name());
+    }
+
+    /**
+     * Invoked in Swing thread after decompression finished either successfully or on failure.
+     * Note that there is no method for cancelled operation because in such case,
+     * {@link FXFinder#cancel()} will be invoked directly.
+     *
+     * @param  destination  the directory where ZIP files have been decompressed.
+     * @param  failure      if decompression failed, the error. Otherwise {@code null}.
+     */
+    final void decompressionFinished(final File destination, final Exception failure) {
+        final boolean isValid;
+        if (failure != null) {
+            isValid = false;
+            javafxPathError.setText(getHtmlMessage("Can not decompress the file.", failure));
+        } else {
+            isValid = setJavafxPath(destination);
+        }
+        nextOrPreviousPage(isValid ? +1 : -1);
+    }
+
+    /**
+     * Returns a non-null message for given exception with HTML characters escaped.
+     */
+    private static String getHtmlMessage(final String header, final Exception e) {
+        final StringBuilder buffer = new StringBuilder(100).append("<html>").append(header)
+                    .append("<br><b>").append(e.getClass().getSimpleName()).append("</b>");
+        String message = e.getLocalizedMessage();
+        if (message != null) {
+            message = message.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
+            buffer.append(": ").append(message);
+        }
+        return buffer.append("</html>").toString();
+    }
+
+    /**
+     * Invoked when the user clicks on the {@value #JAVAFX_HOME} button
+     * for opening the {@value Constants#JAVAFX_HOME} URL in a browser.
+     */
+    private void openJavafxHomePage() {
+        if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) try {
+            Desktop.getDesktop().browse(new URI(FXFinder.JAVAFX_HOME));
+            nextOrPreviousPage(1);
+        } catch (URISyntaxException | IOException e) {
+            JOptionPane.showMessageDialog(wizard, e.toString(), "Error", JOptionPane.ERROR_MESSAGE);
+        } else {
+            JOptionPane.showMessageDialog(wizard, "Can not find internet browser on this computer.\n"
+                    + "See " + FXFinder.JAVAFX_HOME + " for download information.",
+                    "JavaFX download", JOptionPane.INFORMATION_MESSAGE);
+        }
+    }
+
+    /**
+     * Invoked when user clicks on the {@value #BROWSE} button for choosing a JavaFX installation directory.
+     * If user selects an invalid file or directory, the chooser popups again until the user selects a valid
+     * file or cancels.
+     */
+    private void showDirectoryChooser() {
+        final JFileChooser fd = new JFileChooser(javafxFinder.getDirectory());
+        fd.addChoosableFileFilter(this);
+        fd.setFileFilter(this);
+        fd.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);
+        fd.setDialogTitle("JavaFX installation directory");
+        fd.setApproveButtonText(SELECT);
+        selectButton = findSelectButton(fd);
+        fd.setApproveButtonText(null);
+        if (selectButton != null) {
+            selectButton.setEnabled(false);
+            fd.addPropertyChangeListener(this);
+        }
+        if (fd.showOpenDialog(wizard) == JFileChooser.APPROVE_OPTION) {
+            setJavafxPath(fd.getSelectedFile());
+        }
+        selectButton = null;
+    }
+
+    /**
+     * Searches recursively for the {@value #SELECT} button in the given container. This is used for
+     * locating the "Open" button in {@link JFileChooser}. Caller needs to temporarily change button
+     * text to {@value #SELECT} before to invoke this method. We can not search directly for "Open"
+     * text because that text may be localized.
+     *
+     * @param  c  the container where to search for the {@value #SELECT} button.
+     */
+    private static JButton findSelectButton(final Container c) {
+        final int n = c.getComponentCount();
+        for (int i=0; i<n; i++) {
+            final Component child = c.getComponent(i);
+            if (child instanceof JButton) {
+                final JButton button = (JButton) child;
+                if (SELECT.equals(button.getText())) {
+                    return button;
+                }
+            } else if (child instanceof Container) {
+                final JButton button = findSelectButton((Container) child);
+                if (button != null) return button;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the description to show in {@link JFileChooser} for possible JavaFX installation files.
+     * The list of accepted file formats includes ZIP files.
+     *
+     * @return description of this filter to show in file chooser.
+     */
+    @Override
+    public String getDescription() {
+        return "ZIP files";
+    }
+
+    /**
+     * Returns whether the given file is shown in {@link JFileChooser} as a possible JavaFX installation file.
+     * This method performs a cheap test based on the extension.
+     *
+     * @param  file  the file to test.
+     * @return whether the given file should be shown in the file chooser.
+     */
+    @Override
+    public boolean accept(final File file) {
+        if (file.isDirectory()) {
+            return true;
+        }
+        final String name = file.getName();
+        final int s = name.lastIndexOf('.');
+        return (s >= 0) && name.regionMatches(true, s+1, "zip", 0, 3);
+    }
+
+    /**
+     * Invoked when a {@link JFileChooser} property changed. If the property change tells us that
+     * file selection changed (including the case where user changed directory), then this method
+     * checks if the new selection is valid. This determines whether {@value #NEXT} button should
+     * be enabled.
+     *
+     * @param  event  a description of the change.
+     */
+    @Override
+    @SuppressWarnings("CallToPrintStackTrace")
+    public void propertyChange(final PropertyChangeEvent event) {
+        final File file;
+        switch (event.getPropertyName()) {
+            default: {
+                return;
+            }
+            case JFileChooser.SELECTED_FILE_CHANGED_PROPERTY: {
+                file = (File) event.getNewValue();
+                break;
+            }
+            case JFileChooser.DIRECTORY_CHANGED_PROPERTY: {
+                file = ((JFileChooser) event.getSource()).getSelectedFile();
+                break;
+            }
+        }
+        /*
+         * Perform a cheap validity check (without opening the file) because this method may
+         * be invoked often while user navigates through files. A more extensive check will
+         * be done later by `setJavafxPath(File)`.
+         */
+        boolean enabled = false;
+        if (file != null) {
+            if (file.isFile()) {
+                enabled = true;
+            } else if (file.isDirectory()) {
+                enabled = javafxFinder.setDirectory(file);
+            }
+        }
+        selectButton.setEnabled(enabled);
+    }
+
+    /**
+     * Sets the JavaFX directory and enables or disables the {@value #NEXT} button depending
+     * on whether that file or directory is valid. If the file is not valid, a message will
+     * be set in {@link #javafxPathError} (below the path).
+     *
+     * @param  dir  the JavaFX directory or ZIP file, or {@code null} if none.
+     * @return whether the given file or directory is valid.
+     */
+    private boolean setJavafxPath(final File dir) {
+        String error = null;
+        boolean isValid = javafxFinder.setDirectory(dir);
+        if (!isValid) {
+            if (dir.isFile()) try {
+                error = FXFinder.checkZip(dir);
+                isValid = (error == null);
+            } catch (IOException e) {
+                error = getHtmlMessage("Can not open the file.", e);
+            } else {
+                error = "<html>Not a recognized JavaFX directory or ZIP file.</html>";
+            }
+        }
+        javafxPath.setText(dir != null ? dir.getPath() : null);
+        javafxPath.setForeground(isValid ? Color.DARK_GRAY : Color.RED);
+        if (currentPage == WizardPage.JAVAFX_LOCATION) {
+            nextButton.setEnabled(isValid);
+        }
+        javafxPathError.setText(error);
+        acceptLocation = isValid;
+        return isValid;
+    }
+
+    /**
+     * Invoked when user drops files in the wizard. This is an alternative way to set the JavaFX directory.
+     *
+     * @param  event  the drop event.
+     */
+    @Override
+    @SuppressWarnings("unchecked")
+    public void drop(final DropTargetDropEvent event) {
+        for (final DataFlavor flavor : event.getCurrentDataFlavors()) {
+            if (flavor.isFlavorJavaFileListType()) {
+                javafxPath.setBorder(JAVAFX_PATH_BORDER);
+                event.acceptDrop(DnDConstants.ACTION_LINK);
+                try {
+                    for (final File file : (Iterable<File>) event.getTransferable().getTransferData(DataFlavor.javaFileListFlavor)) {
+                        if (setJavafxPath(file)) break;
+                    }
+                } catch (UnsupportedFlavorException | IOException e) {
+                    javafxPathError.setText(getHtmlMessage("Can not open the file.", e));
+                }
+                event.dropComplete(true);
+                return;
+            }
+        }
+        event.rejectDrop();
+    }
+
+    /**
+     * Invoked when the user is doing a drag and drop action and is entering in the target area.
+     * This method sets a visual hint for telling to the user that the wizard is ready to receives the files.
+     */
+    @Override
+    public void dragEnter(final DropTargetDragEvent event) {
+        for (final DataFlavor flavor : event.getCurrentDataFlavors()) {
+            if (flavor.isFlavorJavaFileListType()) {
+                javafxPath.setBorder(JAVAFX_PATH_BORDER_DND);
+                break;
+            }
+        }
+    }
+
+    /**
+     * Invoked when the user is doing a drag and drop action and is exiting in the target area.
+     * This method cancels the visual hint created by {@link #dragEnter(DropTargetDragEvent)}.
+     */
+    @Override
+    public void dragExit(final DropTargetEvent event) {
+        javafxPath.setBorder(JAVAFX_PATH_BORDER);
+    }
+
+    /** Ignored. */
+    @Override public void dragOver(final DropTargetDragEvent event) {}
+
+    /** Ignored. */
+    @Override public void dropActionChanged(final DropTargetDragEvent event) {}
+
+    /**
+     * Shows the installation wizard.
+     *
+     * @return {@code true} if the wizard has been started, or {@code false} on configuration error.
+     */
+    public static boolean show(final FXFinder javafxFinder) {
+        /*
+         * Checks now that we can edit `setenv.sh` content in order to not show the wizard
+         * if we can not read that file (e.g. because the file was not found).
+         */
+        final String diagnostic = javafxFinder.diagnostic();
+        if (diagnostic != null) {
+            JOptionPane.showMessageDialog(null, diagnostic, "Configuration error", JOptionPane.ERROR);
+            return false;
+        } else {
+            final Wizard wizard = new Wizard(javafxFinder);
+            wizard.wizard.setVisible(true);
+            return true;
+        }
+    }
+}
diff --git a/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/WizardPage.java b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/WizardPage.java
new file mode 100644
index 0000000..29cf5ef
--- /dev/null
+++ b/application/sis-javafx/src/main/java/org/apache/sis/internal/setup/WizardPage.java
@@ -0,0 +1,114 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work 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 org.apache.sis.internal.setup;
+
+
+/**
+ * An identifier of the page to shown in {@link Wizard}.
+ * Pages are shown in enumeration order.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.1
+ * @since   1.1
+ * @module
+ */
+enum WizardPage {
+    /**
+     * Plain text saying what this wizard will do.
+     */
+    INTRODUCTION("Introduction",
+            "<html><h1>Welcome to Apache SIS™</h1>"
+            + "<p>"
+            + "This wizard will configure Apache Spatial Information System (SIS) "
+            + "JavaFX application on your computer. "
+            + "This configuration needs to be done only once. "
+            + "Click <u>Next</u> to continue, or <u>Cancel</u> to exit setup."
+            + "</p><p style=\"padding-top:20px; font-size:10px; color:#909090;\">"
+            + "Apache SIS is licensed under the Apache License, version 2.0."
+            + "</p></html>"),
+
+    /**
+     * Page proposing to download JavaFX, with a "Download" button.
+     * Those instructions a completed by {@link #downloadSteps()}.
+     */
+    DOWNLOAD_JAVAFX("Download",
+            "<html><p style=\"padding-top:10px;\">"
+            + "This application requires <i>JavaFX</i> (or <i>OpenJFX</i>) version " + FXFinder.JAVAFX_VERSION + " or later. "
+            + "OpenJFX is free software, licensed under the GPL with the class path exception. "
+            + "Click on <u>Open JavaFX home page</u> for opening the JavaFX home page. "
+            + "If JavaFX or OpenJFX has already been downloaded on this computer, "
+            + "skip download and click on <u>Next</u> for specifying its installation directory."
+            + "</p></html>"),
+
+    /**
+     * Page asking to specify the installation directory.
+     */
+    JAVAFX_LOCATION("JavaFX location",
+            "<html><p style=\"padding-top:10px;\">"
+            + "Specify the downloaded ZIP file, or the directory where JavaFX or OpenJFX has been installed. "
+            + "You can drag and drop the file or directory below or click on <u>Browse</u>."
+            + "</p></html>"),
+
+    /**
+     * Page notifying user that a decompression is in progress.
+     * This page is skipped if the user specified an existing directory instead than a ZIP file.
+     */
+    DECOMPRESS("Decompress",
+            "<html><p style=\"padding-top:10px;\">"
+            + "Decompressing ZIP file."
+            + "</p></html>"),
+
+    /**
+     * Final page saying that the configuration is completed.
+     */
+    COMPLETED("Summary",
+            "<html><p style=\"padding-top:10px;\">"
+            + "Apache SIS setup is completed. "
+            + "Environment variables relevant to SIS are listed below."
+            + "</p></html>");
+
+    /**
+     * Complement to {@link #DOWNLOAD_JAVAFX}.
+     */
+    static String downloadSteps() {
+        return "<html><ul>"
+            + "<li>Click on <b>Download</b>.</li>"
+            + "<li>Scroll down to <b>Latest releases</b>.</li>"
+            + "<li>Download <b>" + FXFinder.getJavafxBundleName() + "</b>.</li>"
+            + "<li><em>(Optional)</em> decompress the ZIP file in any directory.</li>"
+            + "<li>Click <u>Next</u> to continue.</li>"
+            + "</ul></html>";
+    }
+
+    /**
+     * Title for this page.
+     */
+    final String title;
+
+    /**
+     * The text to shown on the page.
+     */
+    final String text;
+
+    /**
+     * Creates a new enumeration for a page showing the specified text.
+     */
+    private WizardPage(final String title, final String text) {
+        this.title = title;
+        this.text  = text;
+    }
+}


Mime
View raw message