You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2021/01/28 00:38:02 UTC

[sis] branch geoapi-4.0 updated (96b4ea0 -> 2ca318c)

This is an automated email from the ASF dual-hosted git repository.

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


    from 96b4ea0  Portrayal : fix incoherency in presentation candidate value type
     new 54f5a9c  Provide an installation wizard for configuring the path to JavaFX.
     new 2ca318c  Fix a `NullPointerException` when `bin/sisfx` is invoked while `apache-sis-<version>` is the current directory.

The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 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   |  17 +-
 .../java/org/apache/sis/internal/setup/Wizard.java | 759 +++++++++++++++++++++
 .../org/apache/sis/internal/setup/WizardPage.java  | 114 ++++
 .../sis/internal/storage/io/IOUtilities.java       |   5 +
 8 files changed, 1345 insertions(+), 105 deletions(-)
 create mode 100644 application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Inflater.java
 create mode 100644 application/sis-javafx/src/main/java/org/apache/sis/internal/setup/Wizard.java
 create mode 100644 application/sis-javafx/src/main/java/org/apache/sis/internal/setup/WizardPage.java


[sis] 01/02: Provide an installation wizard for configuring the path to JavaFX.

Posted by de...@apache.org.
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 <ma...@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;
+    }
+}


[sis] 02/02: Fix a `NullPointerException` when `bin/sisfx` is invoked while `apache-sis-` is the current directory.

Posted by de...@apache.org.
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 2ca318cc4a950f08317305ee3e19d5b8db495473
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Thu Jan 28 01:35:31 2021 +0100

    Fix a `NullPointerException` when `bin/sisfx` is invoked while `apache-sis-<version>` is the current directory.
---
 .../apache/sis/internal/setup/LoggingConfiguration.java    | 14 ++++++++------
 .../org/apache/sis/internal/storage/io/IOUtilities.java    |  5 +++++
 2 files changed, 13 insertions(+), 6 deletions(-)

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 b03a302..6cf9b0d 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
@@ -24,17 +24,19 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 
+import static org.apache.sis.internal.storage.io.IOUtilities.CURRENT_DIRECTORY_SYMBOL;
+
 
 /**
- * Loads {@code conf/logging.properties} file and filter the {@code %b} pattern
+ * Loads {@code conf/logging.properties} file and filter the {@code %p} pattern
  * before to delegate to Java logging system. The filtering replaces {@code "%p"}
  * by the parent directory of configuration file.
  *
- * <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 should not use any SIS classes (except constants inlined at compile-time)
+ * 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>
+ * <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"
@@ -79,7 +81,7 @@ public final class LoggingConfiguration {
                             for (int j=Math.min(parent.getNameCount(), 2); --j >= 0;) {
                                 parent = parent.getParent();
                             }
-                            String replacement = parent.toString();
+                            String replacement = (parent != null) ? parent.toString() : CURRENT_DIRECTORY_SYMBOL;
                             replacement = replacement.replace(File.separatorChar, '/');
                             buffer.replace(i, i + PATTERN.length(), replacement);
                         }
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/IOUtilities.java b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/IOUtilities.java
index ea5f894..2c65096 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/IOUtilities.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/internal/storage/io/IOUtilities.java
@@ -65,6 +65,11 @@ import org.apache.sis.internal.storage.Resources;
  */
 public final class IOUtilities extends Static {
     /**
+     * The symbol for current directory.
+     */
+    public static final String CURRENT_DIRECTORY_SYMBOL = ".";
+
+    /**
      * Do not allow instantiation of this class.
      */
     private IOUtilities() {