Packaging Java as a Native Application with a Self-Contained, Custom Runtime: A Manual Walkthrough
INTENDED AUDIENCE:
Java programmers new to modular Java and deployment.
WHAT:
In this walk-through, we will start with a simple Java program (“Pongish”) and end with an .exe file that, when downloaded and executed, will launch the Windows Setup and install our program. The Java program that is installed will be self-contained with a runtime customized to hold only the modules needed to run the program. The steps taken will involve command line Java using the Windows cmd.exe console shell, and InnoSetup 5.
With this walk-through example, the resulting setup.exe download file is 21 MB, and the installed program occupies 87.5MB. In comparison, the same program packed with a complete JRE has a setup.exe download file of 40.5 MB and a footprint of 180 MB.
Note: Modular Java requires Java 9 or higher, and a 64-bit cpu on Windows systems.
GETTING STARTED
- Create a file folder for our deployment project on the Desktop. Name it dpproject. We will refer to its absolute address in the following way:
c:\...\Desktop\dpproject
where the ellipsis will refer to specifics such as your Windows User file folder.
- For our application, I’m reusing code from another tutorial which might be described as an “in-progress” Pong. This code, with its multiple packages, classes and use of graphics, should be more helpful than a single-class HelloWorld example. The more involved file structure affects the syntax of several important commands.
In your IDE, create a project (I’m going to call it Pongish) and import these two classes, complete with package structure, and verify that they run.
Demo.java
package com.adonax.deploydemo;
import javafx.animation.AnimationTimer;
import javafx.application.Application;
import javafx.event.EventHandler;
import javafx.geometry.Dimension2D;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.paint.Color;
import javafx.scene.shape.Rectangle;
import javafx.stage.Stage;
public class Demo extends Application
implements EventHandler <KeyEvent>
{
final int WIDTH = 600;
final int HEIGHT = 400;
private Ball ball;
public static void main(String[] args) {
launch(args);
}
@Override
public void start(Stage stage) throws Exception {
stage.setTitle("In progress Pong/Breakout");
Group root = new Group();
Scene scene = new Scene(root, WIDTH, HEIGHT);
Rectangle playScreen = new Rectangle(WIDTH, HEIGHT);
playScreen.setFill(Color.YELLOW);
root.getChildren().add(playScreen);
// Bouncing Ball
this.ball = new Ball(new Dimension2D(WIDTH, HEIGHT));
root.getChildren().add(ball);
// Attach KeyEvent listening to a Node that has focus.
root.setFocusTraversable(true);
root.requestFocus();
root.setOnKeyPressed(this);
stage.setScene(scene);
stage.show();
AnimationTimer animator = new AnimationTimer() {
@Override
public void handle(long arg0) {
ball.update();
}
};
animator.start();
}
@Override
public void handle(KeyEvent arg0) {
if (arg0.getCode() == KeyCode.SPACE ) {
ball.paddleHit();
}
}
}
Ball.Java
package com.adonax.deploydemo;
import javafx.geometry.Dimension2D;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
public class Ball extends Circle
{
private double ballRadius = 40;
private double ballX = 100;
private double ballY = 200;
private double xSpeed = 4;
private double ySpeed = 3;
private double height, width;
private volatile boolean swatted;
public Ball(Dimension2D playfieldDims)
{
setCenterX(ballX);
setCenterY(ballY);
setRadius(ballRadius);
setFill(Color.BLUE);
height = playfieldDims.getHeight();
width = playfieldDims.getWidth();
}
// Asynchronous methods:
public void paddleHit() {
swatted = true;
}
// Gameloop methods:
public void update() {
ballX += xSpeed;
ballY += ySpeed;
if (swatted) {
xSpeed *= -1;
swatted = false;
}
if (ballX + ballRadius >= width) {
ballX = width - ballRadius;
xSpeed *= -1;
}
else if (ballX - ballRadius < 0) {
ballX = 0 + ballRadius;
xSpeed *= -1;
}
if (ballY + ballRadius >= height) {
ballY = height - ballRadius;
ySpeed *= -1;
}
else if (ballY - ballRadius < 0) {
ballY = 0 + ballRadius;
ySpeed *= -1;
}
setCenterX(ballX);
setCenterY(ballY);
}
}
SET UP THE SOURCE AS A MODULAR JAVA APPLICATION
3A) Create a file folder named source within our project folder dpproject.
3B) Create file folder moduleDemo within source. The file folder moduleDemo is going to be our module container to the application source.
3C) Every module container must have a module-info file. Create a text file and name it module-info.java. Save it in the file folder moduleDemo. The .java file should contain the following text:
module moduleDemo {
}
Note that the name of our module’s file folder matches the name of the class in this code. The body of the code (to be filled in) will be a series of requires
and exports
statements that make explicit all dependencies between our module and the required Java runtime modules. We will determine them in a future step.
D) Copy our example application’s source code into the moduleDemo folder. Our package statement, for Demo.java, is the following:
package com.adonax.deploydemo;
So, we need to have the following file folder structure within moduleDemo:
moduleDemo
|
--> com
|
--> adonax
|
--> deploydemo : Demo.java
Ball.java
Probably the easiest way to do this is to copy directly from the top package file folder from where your IDE stores the source files for your application.
COMPILE THE MODULE
4A) Because module-info.java is incomplete, attempts to compile the project will fail. We will use information from the error messages to finish writing module-info.java. From within the project folder dpprject, run the following cmd.exe command:
c:\...\dpproject>"%JAVA9_HOME%\bin\javac" -d compiled --module-source-path source -m moduleDemo
Note that I am using a Windows Environment Variable to specify the location of the Java 9 JDK in order to address javac.exe. You will likely have to create your own environment variable.
[A couple notes on cmd.exe:
(1) The cmd.exe program is a standard Windows program that creates a “console” or “shell” within which you can navigate the file system of your PC and issue commands. To invoke it, right-click the Start Menu and select Run. In the Open field, enter cmd and hit OK. This should open a console and place you in a file directory corresponding to your User name. To navigate from there to the project folder, enter the following command: cd Desktop/dpproject
(2) Notice that the fully addressed javac command is enclosed in quotes. This is because my JDK is located in a subfolder of C:\Program Files and cmd.exe interprets the space character between “Program” and “Files” as a delimiter. Enclosed in quotes, the space is interpreted as a character, and this allows cmd.exe to properly locate javac.exe.
(3) Windows now also comes with PowerShell which is similar to cmd. There are some differences between the two. I think you can use either, but I haven’t verified PowerShell works with every step of this tutorial.]
The parameter d () designates the file folder where the compiled code will be put. Our command creates and places the result in a folder named compiled.
The next section of the command designates the file folder which holds the modular Java source code. Our source code is in the folder source.
The last clause gives the name of the module to be compiled: moduleDemo.
4B) Because we haven’t spelled out the module’s dependencies, the result will be a listing consisting of many errors. Inspect the start of the output. You should have a something like the following (I’ve pasted my first three of 22 errors):
C:\...\dpproject>"%JAVA9_HOME%\bin\javac" -d compiled --module-source-path source -m moduleDemo
source\moduleDemo\com\adonax\deploydemo\Ball.java:3: error: package javafx.geometry is not visible
import javafx.geometry.Dimension2D;
^
(package javafx.geometry is declared in module javafx.graphics, but module moduleDemo does not read it)
source\moduleDemo\com\adonax\deploydemo\Ball.java:4: error: package javafx.scene.paint is not visible
import javafx.scene.paint.Color;
^
(package javafx.scene.paint is declared in module javafx.graphics, but module moduleDemo does not read it)
source\moduleDemo\com\adonax\deploydemo\Ball.java:5: error: package javafx.scene.shape is not visible
import javafx.scene.shape.Circle;
^
(package javafx.scene.shape is declared in module javafx.graphics, but module moduleDemo does not read it)
The first error occurs when the compiler is unable to find the package javafx.graphics in line 3 of Ball.java. The compiler gives us the following helpful error message:
package javafx.animation is declared in module javafx.graphics, but module moduleDemo does not read it
This tell us that our module is dependent upon module javafx.graphics.
4C) Add the following requires
line to our module-info code block and save:
module moduleDemo {
requires javafx.graphics;
}
4D) With the module-info.java update saved, run the compile line again.
Rather remarkably, the compilation now completes without error. A more involved project could easily require multiple iterations. However, sometimes dependencies don’t show up at compile time, but come up at run time. Before declaring that our module-info.java is finished, we need to give our compiled code a test run.
4E) Run the following command line to test if our compiled project runs:
C:\...\dpproject>java -p compiled -m moduleDemo/com.adonax.deploydemo.Demo
The option -p is a shortened form of –module-path. The module we want to run was just compiled into the compiled folder. The option -m is a shortening of –module and the required value is /. A nice little gotcha here is the syntax specifying the module and its main class. Note the direction of the “/” and the use of “.” as a separator for the package structure.
4F) When we run our compiled code, we get an error. Inspect the error code. A diagnostic points out a needed exports
dependency.
Exception in Application constructor
Exception in thread "main" java.lang.reflect.InvocationTargetException
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.base/java.lang.reflect.Method.invoke(Unknown Source)
at java.base/sun.launcher.LauncherHelper$FXHelper.main(Unknown Source)
Caused by: java.lang.RuntimeException: Unable to construct Application instance: class com.adonax.deploydemo.Demo
at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplication1(Unknown Source)
at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication$2(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)
Caused by: java.lang.IllegalAccessException: class com.sun.javafx.application.LauncherImpl (in module javafx.graphics)
cannot access class com.adonax.deploydemo.Demo (in module moduleDemo) because module moduleDemo does not
export com.adonax.deploydemo to module javafx.graphics
at java.base/jdk.internal.reflect.Reflection.newIllegalAccessException(Unknown Source)
at java.base/java.lang.reflect.AccessibleObject.checkAccess(Unknown Source)
at java.base/java.lang.reflect.Constructor.newInstance(Unknown Source)
at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$8(Unknown Source)
at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$11(Unknown Source)
at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$9(Unknown Source)
at java.base/java.security.AccessController.doPrivileged(Native Method)
at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(Unknown Source)
at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(Unknown Source)
at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(Unknown Source)
... 1 more
The key phrase above is the following:
module moduleDemo does not export com.adonax.deploydemo to module javafx.graphics
In other words, code in the javafx.graphics module (which makes use of reflection) needs to access code from our module.
4G) To fix this, add an exports
line to module-info.java:
module moduleDemo {
exports com.adonax.deploydemo;
requires javafx.graphics;
}
Save this revision of module-info.java and redo the compilation line from before. (I like to delete the previously generated folders before redoing.)
C:\...\dpproject>"%JAVA9_HOME%\bin\javac" -d compiled --module-source-path source -m moduleDemo
Now, try another test run of our program.
C:\...\dpproject>java -p compiled -m moduleDemo/com.adonax.deploydemo.Demo
Did it work? Congratulations if it did!
If the Pongish program fails to run, examine both the contents of module-info.java and any typed commands very closely for typos. Small things, like a missing “;” or a inconsistency in capitalization or wrong direction on a slash can be quite enough to cause a modular compilation to fail.
ONE MORE STEP FOR COMPILATION
If your program makes use of resources, such as graphic or audio files, these have to be copied over manually into the compiled folder system. The javac command does not do this for us.
CREATE CUSTOMIZED RUNTIME WITH jlink TOOL
In this step, we take the compiled source and create a distribution folder.
5A) Run the following command:
C:\...\dpproject>"%JAVA9_HOME%\bin\jlink" --module-path compiled;"%JAVA9_HOME%\jmods" --add-modules moduleDemo --launcher LaunchDemo=moduleDemo/com.adonax.deploydemo.Demo --output dist
The required –module-path clause points to where the jlink tool will look for modules. We have two arguments:
--module-path compiled;"%JAVA9_HOME%\jmods"
One argument (compiled) is the file folder location of our compiled application. The other argument, “%JAVA9_HOME%[b]jmods[/b]”, is the file location within which the Java 9 JDK that holds the Java language modules.
The required –add-modules clause names the root module for resolution. For our program, moduleDemo is the only module that needs to be named. Its module-info.class, with its list of requirements, will tell jlink which other modules are needed.
The –launcher clause is optional. The syntax we use is the following:
--launcher command=module/main
With the inclusion of this option, two files are created in dist/bin, based on the name “LaunchDemo”. These files are named LaunchDemo and LaunchDemo.bat. The first is a Bash script (for Unix), the second is a Batch file (for DOS). We will use LaunchDemo.bat for testing, to verify that our program runs correctly. But we won’t actually need either of these files when we configure Inno Setup 5.
The –output option gives the name that will be assigned to the root folder of the files created by jlink. In our command line, we name the folder dist.
5B) Verify that the distribution application works. In the file folder dpproject/dist/bin, along with many .dll files and a few .exe files, will be a file we specified with our –launcher clause: LaunchDemo.bat. Run this file.
The program should run correctly at this point. A console window is opened for the .bat file and suspended while our program runs. We will configure Inno Setup 5 so that there will be no cmd.exe window opened.
PACKAGE WITH INNO SETUP 5
6A) Run the Inno Setup Wizard to create an .ISS file. This is done by the Start Menu choice Inno Setup Compiler. (Nothing says “I am a Wizard” like the program name “Compiler”, yes?)
6B) Check the checkbox for the following when given the option:
New File > Create a new script file using the Script Wizard
and hit Next for this, and for the “Welcome Screen” that follows.
6C) The “Application Information” screen that comes up next is self-explanatory. The reader can decide what to fill in, and proceed. I am naming the application Pongish and giving it a version number of 0.1.
6D) For “Application Folder”, leave in the defaults. If you put Pongish as the name in the previous step, it will appear now as the default Application Folder Name. This will be the folder name created for our application in the Program Files directory.
6E) Under “Application Files” we are asked to enter the executable. Browse to the …/dpproject/dist/bin folder. Select javaw.exe. Why javaw.exe and not java.exe? The reason is that java.exe is a console application, whereas javaw.exe runs in a window and won’t cause a console shell to open. Later on, when editing the completed .ISS file, we will provide a Parameters line that will provide the rest of the text needed to run our module.
6F) Leave the checkbox “Allow user to start the application after Setup has finished” checked, and the checkbox for “The application doesn’t have a main executable file” unchecked.
6G) For the “Application Shortcuts” tab, leave the settings so that a Start Menu entry and an optional Desktop Shortcut are made.
6H) For the “Application Documentation” section, I am leaving this blank for now. If you have documents ready to go, this is where they would be identified.
6I) For “Languages” I’m leaving in the default “English”.
6J) For “Compiler Settings” I recommend using the Browse button to set the “Custom compiler output folder” to our project folder: “C:…\Desktop\dpproject”. With the next options you can take the opportunity to specify a name for the resulting setup file that is generated, and to specify an icon for the setup file, and even to set a password. I am leaving the default name setup. After we run our compilation, the dpproject folder will contain setup.exe.
6K) On “Inno Setup Preprocessor” if possible, the default will create #define compiler directives. I recommend leaving the option checked if available.
6L) After all these screens, go ahead and generate the .iss file. The wizard will create the file and leave it open for further editing.
EDIT THE .iss FILE
7A) If Inno Setup is able, the first section of the .iss file will consist of a set of #define statements. These should all be fine, except for one. Change the #define for “MyAppExeName” to the following:
#define MyAppExeName "bin\javaw.exe"
If you don’t have #define directives, then you will have to make the same change to every reference to javaw.exe in the document, as the {app} variable will point to the root folder, dist.
7B) In the [Setup] tag section, add the following two lines:
ArchitecturesAllowed=x64
ArchitecturesInstallIn64BitMode=x64
These are needed because Inno Setup 5 defaults to 32-bit, but Java 9 on Windows is 64-bit.
7C) In the [Files] tag section, edit the existing line to the following (as a single line):
Source: "C:\...\dpproject\dist\*.*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
This line will direct the setup routine to copy the entire contents of dist to the Program Files/Pongish directory. Not every file needs to be brought over during the install. For example, the Bash and Batch files bin\LaunchDemo and bin\LaunchDemo.bat are not needed, nor is bin\java.exe since we only use bin\javaw.exe. There are likely other files that can be omitted, but I haven’t figured out which. If you want to save a few KB, you can delete these files from dist before running the compiler.
7D) In the [Icons] section we inline information pertaining to the icons used in the Start Menu and desktop. The two lines should be edited to the following two single lines by adding a Parameters option:
Name: "{commonprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "-m moduleDemo/com.adonax.deploydemo.Demo"
Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; Parameters: "-m moduleDemo/com.adonax.deploydemo.Demo"
The filename and parameter contents combine to give us the standard command line form for running a modular Java program. If you have no #define satements, then substitute Pongish
for {#MyAppName}
, and bin\javaw.exe
for {#MyAppExeName}
.
When no icon is designated to the wizard, the compiler will default to using the icon stored in the javaw.exe file. If you have an .ico file to use, it should be included as the option IconFilename. As an example, if the icon file is pongish.ico, and you have saved it to the top folder, dist, the resulting line for the Start Menu icon would be the following:
Name: "{commonprograms}\{#MyAppName}"; IconFileName: "{app}\pongish.ico"; Filename: "{app}\{#MyAppExeName}"; Parameters: "-m moduleDemo/com.adonax.deploydemo.Demo"
7E) In the [Run] tag section, edit the existing line to the following:
Filename: "{app}\bin\javaw"; Parameters: "-m moduleDemo/com.adonax.deploydemo.Demo"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: postinstall skipifsilent
7F) Save all changes.
7G) Run the compile.
If all goes well, the program will first create the setup.exe file and place it in our project folder, then offer to proceed with an install. Go ahead and run the install, then verify the program runs correctly and was installed in the correct location. I’d also then uninstall and then reinstall by clicking on setup.exe. The key test is taking setup.exe to another PC and running it.
Congratulations on reaching the end of this walk-through!
I wish you the best of success with your publishing, and look forward to playing your Java games.
END