package org.dstovall;
import org.apache.commons.io.IOUtils;
import org.apache.maven.artifact.Artifact;
import org.apache.maven.model.Dependency;
import org.apache.maven.model.FileSet;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
import org.codehaus.plexus.util.FileUtils;
import java.io.*;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.jar.Attributes;
import java.util.jar.JarInputStream;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipInputStream;
/**
* Creates an executable one-jar version of the project's normal jar, including all dependencies.
*
* @goal one-jar
* @phase package
* @requiresProject
* @requiresDependencyResolution runtime
*/
public class OneJarMojo extends AbstractMojo {
private static final String MF_REQUIRED_IMPL_VERSION = "ImplementationVersion";
private static final String MF_OPTION_MAIN_CLASS = "One-Jar-Main-Class";
private static final String MF_OPTION_SPLASH_SCREEN_IMAGE = "SplashScreen-Image";
/**
* All the dependencies including trancient dependencies.
*
* @parameter default-value="${project.artifacts}"
* @required
* @readonly
*/
private Collection<Artifact> artifacts;
/**
* All declared dependencies in this project, including system scoped dependencies.
*
* @parameter default-value="${project.dependencies}"
* @required
* @readonly
*/
private Collection<Dependency> dependencies;
/**
* FileSet to be included in the "binlib" directory inside the one-jar. This is the place to include native
* libraries such as .dll files and .so files. They will automatically be loaded by the one-jar.
*
* @parameter
*/
private FileSet[] binlibs;
/**
* The directory for the resulting file.
*
* @parameter expression="${project.build.directory}"
* @required
* @readonly
*/
private File outputDirectory;
/**
* Name of the main JAR.
*
* @parameter expression="${project.build.finalName}.jar"
* @readonly
* @required
*/
private String mainJarFilename;
/**
* Name of the generated JAR.
* Will default to the final name if left unspecified.
* Include the classifier if required, it will not be added automatically.
*
* @parameter expression="${project.build.finalName}.one-jar.jar"
* @required
*/
private String filename;
// TODO: do we want to be strict and do a mutual exclusive check for version and local template?
/**
* The version of one-jar to use. Has a default, so typically no need to specify this.
*
* @parameter expression="${onejar-version}" default-value="0.97"
*/
private String onejarVersion;
/**
* A custom one-jar artifact to start from (RC build for example).
* This must be a "one-jar-boot.jar" style artifact.
* If you specify this option, the onejarVersion will have no effect anymore.
* This should be a path relative to the project basedir.
*
* @parameter
*/
private String localOneJarTemplate;
/**
* Whether to attach the generated one-jar to the build. You may also wish to set <code>classifier</code>.
*
* @parameter default-value=false
*/
private boolean attachToBuild;
/**
* Classifier to use, if the one-jar is to be attached to the build.
* Set <code><attachToBuild>true</attachToBuild> if you want that.
*
* @parameter default-value="onejar"
*/
private String classifier;
/**
* This Maven project.
*
* @parameter expression="${project}"
* @required
* @readonly
*/
private MavenProject project;
/**
* For attaching artifacts etc.
*
* @component
* @readonly
*/
private MavenProjectHelper projectHelper;
/**
* The main class that one-jar should activate
*
* @parameter expression="${onejar-mainclass}"
*/
private String mainClass;
/**
* The splash screen image.
* This should be a path relative to the project basedir.
* Adds the option to the manifest and copies and checks the image.
*
* @parameter expression="${onejar-splashScreen}"
*/
private String splashScreen;
/**
* Implementation Version of the jar. Defaults to the build's version.
*
* @parameter expression="${project.version}"
* @required
*/
private String implementationVersion;
/**
* The entries to include as-is in the one-jar manifest.
* This is optional.
* Values used here will are leading and will not be overridden by other options.
*
* @see <a href="http://one-jar.sourceforge.net/index.php?page=details&file=manifest">Documentation one the one-jar manifest options</a>.
* @parameter
*/
private Map<String,String> manifestEntries;
public void execute() throws MojoExecutionException {
// Show some info about the plugin.
displayPluginInfo();
JarOutputStream out = null;
JarInputStream template = null;
File onejarFile = null;
try {
// Create the target file
onejarFile = new File(outputDirectory, filename);
// Prepare the onejar manifest file content
Manifest manifest = prepareManifest();
// Open a stream to write to the target file
out = new JarOutputStream(new FileOutputStream(onejarFile, false), manifest);
// Add files (based on options)
addFilesToArchive(out);
// Finalize the onejar archive
template = openOnejarTemplateArchive();
copyTemplateFilesToArchive(template, out);
} catch (IOException e) {
error(e);
throw new MojoExecutionException("One-jar Mojo failed.", e);
} finally {
IOUtils.closeQuietly(out);
IOUtils.closeQuietly(template);
}
// Attach the created one-jar to the build.
if (attachToBuild) {
projectHelper.attachArtifact(project, "jar", classifier, onejarFile);
}
}
private void copyTemplateFilesToArchive(JarInputStream template,
JarOutputStream out) throws IOException {
// One-jar stuff
debug("Adding one-jar components...");
ZipEntry entry = null;
while ((entry = template.getNextEntry()) != null) {
// Skip the manifest file, no need to clutter...
if (!"boot-manifest.mf".equals(entry.getName())) {
addToZip(out, entry, template);
}
}
}
private void addFilesToArchive(JarOutputStream out) throws IOException, MojoExecutionException {
final List<File> dependencyJars = Collections.unmodifiableList(extractDependencyFiles(artifacts));
final List<File> systemDependencyJars = Collections.unmodifiableList(extractSystemDependencyFiles(dependencies));
// Main jar
debug("Adding main jar main/[%s]", mainJarFilename);
addToZip(new File(outputDirectory, mainJarFilename), "main/", out);
// Add all dependencies, including transient dependencies, but excluding system scope dependencies
debug("Adding [%s] dependency libraries...", dependencyJars.size());
for (File jar : dependencyJars) {
addToZip(jar, "lib/", out);
}
// Add system scope dependencies
debug("Adding [%s] system dependency libraries...", systemDependencyJars.size());
for (File jar : systemDependencyJars) {
addToZip(jar, "lib/", out);
}
// Add native libraries
if (binlibs != null) {
for (FileSet eachFileSet : binlibs) {
List<File> includedFiles = toFileList(eachFileSet);
debug("Adding [%s] native libraries...", includedFiles.size());
for (File eachIncludedFile : includedFiles) {
addToZip(eachIncludedFile, "binlib/", out);
}
}
}
// Add splash screen image
if (splashScreen != null) {
File splashFile = new File(project.getBasedir(), splashScreen);
if (splashFile.exists()) {
debug("Adding splash screen image [%s]", splashScreen);
addToZip(out, new ZipEntry(splashScreen), new FileInputStream(splashFile));
} else {
throw new MojoExecutionException("Could not find splash screen image defined in pom.");
}
}
}
private void displayPluginInfo() {
info("Using One-Jar to create a single-file distribution");
info("Implementation Version: %s", implementationVersion);
if (localOneJarTemplate != null) {
info("Using local One-Jar template: %s", localOneJarTemplate);
} else {
info("Using One-Jar version: %s", onejarVersion);
}
info("More info on One-Jar: http://one-jar.sourceforge.net/");
info("License for One-Jar: http://one-jar.sourceforge.net/one-jar-license.txt");
info("One-Jar file: %s", outputDirectory.getAbsolutePath() + File.separator + filename);
}
// ----- One-Jar Template ------------------------------------------------------------------------------------------
private String getOnejarArchiveName() {
return "one-jar-boot-" + onejarVersion + ".jar";
}
private JarInputStream openOnejarTemplateArchive() throws IOException {
if (localOneJarTemplate != null) {
return new JarInputStream(new FileInputStream(new File(project.getBasedir(), localOneJarTemplate)));
} else {
return new JarInputStream(getClass().getClassLoader().getResourceAsStream(getOnejarArchiveName()));
}
}
private class AttributeEntry extends AbstractMap.SimpleEntry<String, String> {
private static final long serialVersionUID = 843323303047092453L;
public AttributeEntry(String key, String value) {
super(key, value);
}
}
private Manifest prepareManifest() throws IOException {
// Copy the template's boot-manifest.mf file
ZipInputStream zipIS = openOnejarTemplateArchive();
Manifest manifest = new Manifest(getFileBytes(zipIS, "boot-manifest.mf"));
IOUtils.closeQuietly(zipIS);
Attributes mainAttributes = manifest.getMainAttributes();
// first add the custom specified entries
addExplicitManifestEntries(mainAttributes);
// If the client has specified an implementationVersion argument, add it also
// (It's required and defaulted, so this always executes...)
//
// TODO: The format of this manifest entry is not "hard and fast". Some specs call for "implementationVersion",
// some for "implemenation-version", and others use various capitalizations of these two. It's likely that a
// better solution then this "brute-force" bit here is to allow clients to configure these entries from the
// Maven POM.
setRequired(mainAttributes,
new AttributeEntry(MF_REQUIRED_IMPL_VERSION, implementationVersion),
MF_REQUIRED_IMPL_VERSION);
// If the client has specified a splashScreen argument, add the proper entry to the manifest
setOptional(splashScreen, mainAttributes,
new AttributeEntry(MF_OPTION_SPLASH_SCREEN_IMAGE, splashScreen),
MF_OPTION_SPLASH_SCREEN_IMAGE);
// If the client has specified a mainClass argument, add the proper entry to the manifest
// to be backwards compatible, add mainclass as simple option when not already set in manifestEntries
setOptional(mainClass, mainAttributes,
new AttributeEntry(MF_OPTION_MAIN_CLASS, mainClass),
MF_OPTION_MAIN_CLASS);
return manifest;
}
private void setRequired(Attributes mainAttributes,
AttributeEntry keyPairToSet, String... entryNamesForValue) {
// just call as if it were optional, but always applies
setOptional(Boolean.TRUE, mainAttributes, keyPairToSet, entryNamesForValue);
}
/**
* Adds option to manifest entries, if applicable.
* @param optionToCheck to perform null check, if null then nothing added
* @param mainAttributes to add to
* @param keyPairToSet both the key and value to add if applicable
* @param entryNamesForValue keys to look for to prevent duplicating or overwriting keys
*/
private void setOptional(Object optionToCheck, Attributes mainAttributes, AttributeEntry keyPairToSet,
String... entryNamesForValue) {
if (optionToCheck != null) {
for (String keyToCheck : entryNamesForValue) {
if (mainAttributes.containsKey(keyToCheck)) {
// key is already set, don't override
return;
}
}
// if key not found, add it
mainAttributes.putValue(keyPairToSet.getKey(), keyPairToSet.getValue());
}
}
private void addExplicitManifestEntries(Attributes mainAttributes) {
// add explicitly specified manifest entries
if (manifestEntries != null) {
for (Entry<String, String> entry : manifestEntries.entrySet()) {
debug("adding entry [%s:%s] to the one-jar manifest", entry.getKey(), entry.getValue());
mainAttributes.putValue(entry.getKey(), entry.getValue());
}
}
}
// ----- Zip-file manipulations ------------------------------------------------------------------------------------
private void addToZip(File sourceFile, String zipfilePath, JarOutputStream out) throws IOException {
addToZip(out, new ZipEntry(zipfilePath + sourceFile.getName()), new FileInputStream(sourceFile));
}
private final AtomicInteger alternativeEntryCounter = new AtomicInteger(0);
private void addToZip(JarOutputStream out, ZipEntry entry, InputStream in) throws IOException {
try{
out.putNextEntry(entry);
IOUtils.copy(in, out);
out.closeEntry();
}catch(ZipException e){
if (e.getMessage().startsWith("duplicate entry")){
// A Jar with the same name was already added. Let's add this one using a modified name:
final ZipEntry alternativeEntry = new ZipEntry(entry.getName() + "-DUPLICATE-FILENAME-" + alternativeEntryCounter.incrementAndGet() + ".jar");
addToZip(out, alternativeEntry, in);
}else{
throw e;
}
}
}
private InputStream getFileBytes(ZipInputStream is, String name) throws IOException {
ZipEntry entry = null;
while ((entry = is.getNextEntry()) != null) {
if (entry.getName().equals(name)) {
byte[] data = IOUtils.toByteArray(is);
return new ByteArrayInputStream(data);
}
}
return null;
}
/**
* Returns a {@link File} object for each artifact.
*
* @param artifacts Pre-resolved artifacts
* @return <code>File</code> objects for each artifact.
*/
private List<File> extractDependencyFiles(Collection<Artifact> artifacts) {
List<File> files = new ArrayList<File>();
if (artifacts == null){
return files;
}
for (Artifact artifact : artifacts) {
File file = artifact.getFile();
if (file.isFile()) {
files.add(file);
}
}
return files;
}
/**
* Returns a {@link File} object for each system dependency.
* @param systemDependencies a collection of dependencies
* @return <code>File</code> objects for each system dependency in the supplied dependencies.
*/
private List<File> extractSystemDependencyFiles(Collection<Dependency> systemDependencies) {
final ArrayList<File> files = new ArrayList<File>();
if (systemDependencies == null){
return files;
}
for (Dependency systemDependency : systemDependencies) {
if (systemDependency != null && "system".equals(systemDependency.getScope())){
files.add(new File(systemDependency.getSystemPath()));
}
}
return files;
}
@SuppressWarnings("unchecked")
private static List<File> toFileList(FileSet fileSet)
throws IOException {
File directory = new File(fileSet.getDirectory());
String includes = toString(fileSet.getIncludes());
String excludes = toString(fileSet.getExcludes());
return FileUtils.getFiles(directory, includes, excludes);
}
private static String toString(List<String> strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
if (sb.length() > 0) {
sb.append(", ");
}
sb.append(string);
}
return sb.toString();
}
private void error(IOException e) {
getLog().error(e);
}
private void debug(String msgTemplate, Object... values) {
if (getLog().isDebugEnabled()) {
getLog().debug(String.format(msgTemplate, values));
}
}
private void info(String msgTemplate, Object... values) {
getLog().info(String.format(msgTemplate, values));
}
}