Search code examples
javacssjavafxjavafx-8

JavaFX styles lost when loading an external Jar(plugin architecture)


I'm trying to create a JavaFX application that can work with plugins, those plugins are other jars loaded on runtime and openened to look for the implementation of an specific interface I created, I'm able to load the jar and to find the specific class but some styles that the loaded jar can't be loaded, let me explain what I did:

I created three maven projects, these projects are the following:

Core: Has an interface the the plugin should implement(TestPlugin.java), and a interface that the main program should implement(TestSceneHandler.java)

TestPlugin.java

public interface TestPlugin {

    void init(TestSceneHandler sceneHandler);

}

TestSceneHandler.java

import javafx.scene.Parent;

public interface TestSceneHandler {

    void setView(Parent node);

}

Plugin: Has the Core as a dependency and a class that implements TestPlugin.java, I left the Main.java so it can work on both modes, sigle app and plugin but it's not really necsessary

MyTestViewController.java

import javafx.fxml.FXMLLoader;
import javafx.scene.layout.GridPane;
import java.io.IOException;

public class MyTestViewController extends GridPane {

    public MyTestViewController() {
        FXMLLoader fxmlLoader = new FXMLLoader(this.getClass().getResource("/pluginView.fxml"));
        fxmlLoader.setClassLoader(getClass().getClassLoader());
        fxmlLoader.setRoot(this);
        fxmlLoader.setController(this);

        try {
            fxmlLoader.load();
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }
    }

}

TestPluginImpl.java

package sample;

public class TestPluginImpl implements TestPlugin {
    @Override
    public void init(TestSceneHandler testSceneHandler) {
        testSceneHandler.setView(new MyTestViewController());
    }
}

Main.java

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception{
        primaryStage.setTitle("Hello World");
        primaryStage.setScene(new Scene(new MyTestViewController(), 300, 275));
        primaryStage.show();
    }


    public static void main(String[] args) {
        launch(args);
    }
}

pluginView.fxml

<?import javafx.scene.layout.GridPane?>

<?import javafx.scene.control.Label?>
<fx:root  xmlns:fx="http://javafx.com/fxml/1"
          type="javafx.scene.layout.GridPane"
          alignment="center" hgap="10" vgap="10" stylesheets="style.css">
    <Label>
        Hello world
    </Label>
</fx:root>

style.css

.root {
    -fx-background-color: red;
}

App: Has the Core as a dependency and a class that implements TestSceneHandler.java

Main.java

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class Main extends Application {

    @Override
    public void start(Stage primaryStage) throws Exception{
        primaryStage.setTitle("Hello World");
        primaryStage.setScene(new Scene(new TestScene(), 300, 275));
        primaryStage.show();
    }


    public static void main(String[] args) {
        launch(args);
    }
}

sample.fxml

<?import javafx.scene.layout.HBox?>
<?import javafx.scene.control.Label?>
<fx:root xmlns:fx="http://javafx.com/fxml/1"
     type="javafx.scene.layout.BorderPane">

    <top>
        <HBox style="-fx-background-color: orange;">
            <children>
                <Label>
                    This is the header
                </Label>
            </children>
        </HBox>
    </top>

</fx:root>

TestScene.java

import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.layout.BorderPane;

import java.io.*;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.zip.ZipFile;

public class TestScene extends BorderPane implements TestSceneHandler {

    private static final String ROOT_FOLDER = "Zamba";
    private static final String PLUGIN_FOLDER = "plugins";
    private static final String USER_HOME = System.getProperty("user.home");

    public TestScene() {
        FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/sample.fxml"));
        fxmlLoader.setRoot(this);
        fxmlLoader.setController(this);

        try {
            fxmlLoader.load();
        } catch (IOException exception) {
            throw new RuntimeException(exception);
        }

        File pluginFolder = initFolder();
        readPlugins(pluginFolder);
    }

    private File initFolder() {
        final String ROOT_FOLDER_PATH = USER_HOME + "/" + ROOT_FOLDER;
        final String PLUGIN_FOLDER_PATH = ROOT_FOLDER_PATH + "/" + PLUGIN_FOLDER;

        File appFolder = new File(ROOT_FOLDER_PATH);

        if(!appFolder.exists()) {
            appFolder.mkdir();
        }

        File pluginFolder = new File(PLUGIN_FOLDER_PATH);

        if(!pluginFolder.exists()) {
            pluginFolder.mkdir();
        }

        return pluginFolder;
    }

    /**
     * Determine whether a file is a JAR File.
     */
    public static boolean isJarFile(File file) throws IOException {
        if (!isZipFile(file)) {
            return false;
        }
        ZipFile zip = new ZipFile(file);
        boolean manifest = zip.getEntry("META-INF/MANIFEST.MF") != null;
        zip.close();
        return manifest;
    }
    /**
     * Determine whether a file is a ZIP File.
     */
    public static boolean isZipFile(File file) throws IOException {
        if(file.isDirectory()) {
            return false;
        }
        if(!file.canRead()) {
            throw new IOException("Cannot read file "+file.getAbsolutePath());
        }
        if(file.length() < 4) {
            return false;
        }
        DataInputStream in = new DataInputStream(new BufferedInputStream(new FileInputStream(file)));
        int test = in.readInt();
        in.close();
        return test == 0x504b0304;
    }

    private void readPlugins(File pluginFolder) {
        File[] pluginFolderFiles = pluginFolder.listFiles();
        Arrays.asList(pluginFolderFiles).forEach(file -> {
            System.out.println("Filee: " + file.getAbsolutePath());

            try {
                if(isJarFile(file)) {
                    JarFile jarFile = new JarFile(file);

                    Enumeration<JarEntry> e = jarFile.entries();

                    URL[] urls = { new URL("jar:file:" + file.getAbsolutePath()+"!/") };
                    URLClassLoader cl = URLClassLoader.newInstance(urls);

                    while (e.hasMoreElements()) {
                        JarEntry je = e.nextElement();

                        if(je.isDirectory() || !je.getName().endsWith(".class")){
                            continue;
                        }

                        // -6 because of .class
                        String className = je.getName().substring(0,je.getName().length()-6);
                        className = className.replace('/', '.');

                        Class c = cl.loadClass(className);

                        if(TestPlugin.class.isAssignableFrom(c) && c != TestPlugin.class) {
                            System.out.println("Plugin found!!!");

                            TestPlugin plugin = (TestPlugin)c.newInstance();
                            plugin.init(this);
                        }

                    }

                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
    }

    @Override
    public void setView(Parent parent) {
        setCenter(parent);
    }
}

When executing the project Plugin as an standalone app this is the result:

Correct styles

But when it is executed through the App project the result is the following:

Invalid styles

As you can see the styles are gone and I have an error on the console:

Plugin found!!!
dic 30, 2018 5:41:30 PM com.sun.javafx.css.StyleManager loadStylesheetUnPrivileged
WARNING: Resource "style.css" not found.

Solution

  • The problem is caused by a classpath issue since you are creating your own ClassLoader to load your plugins. The value of the stylesheet attribute in your FXML is style.css. This is the same as doing:

    GridPane pane = new GridPane();
    pane.getStylesheets().add("style.css");
    

    Which will look for a resource named style.css relative to the root of the classpath. This is because there is no scheme; see the documentation for details. The problem is this behavior uses the ClassLoader for the JavaFX classes to load the resource. Unfortunately, your resource is not visible to that ClassLoader but rather the ClassLoader you created to load the plugin.

    The fix for this is to provide the full URL for the CSS resource file. This is done by using @ (see Introduction to FXML). When using @ the location is relative to the FXML file. For example, if your FXML file is /fxml/PluginView.fxml and your CSS file is /styles/style.css, you'd have:

    <?import javafx.scene.control.Label?>
    <fx:root  xmlns:fx="http://javafx.com/fxml/1"
              type="javafx.scene.layout.GridPane"
              alignment="center" hgap="10" vgap="10" stylesheets="@../styles/style.css">
        <Label>
            Hello world
        </Label>
    </fx:root>
    

    This will call getStylesheets().add(...) with something like:

    jar:file:///path/to/plugin/file.jar!/styles/style.css
    

    Which can be located regardless of the ClassLoader used.


    Another likely issue is in your style.css file. You use .root {} but the root style-class is only automatically added to roots of a Scene. What you probably want to do is set your own style-class and use that in the CSS file.


    Also, you are writing your own plugin-discovery-and-load code. Not saying you can't keep doing what you're doing but I just want to let you know you don't have to reinvent the wheel. Java has java.util.ServiceLoader for exactly this sort of thing.