Compare commits

...

10 Commits

17 changed files with 857 additions and 66 deletions

22
LICENSE.md Normal file
View File

@@ -0,0 +1,22 @@
Copyright 2023 Walter Oggioni
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

113
README.md Normal file
View File

@@ -0,0 +1,113 @@
## Overview
Envelope is a simple Gradle plugin that allows you to create an executable jar file
that includes all runtime dependencies and can be executed with a simple
```bash
java -jar my-app.jar
```
It supports JPMS, embedded system properties, Java agents, extra folders to be added to classpath.
### Usage
Declare the plugin in your build's `settings.gradle` like this
```groovy
pluginManagement {
repositories {
maven {
url = 'https://woggioni.net/mvn/'
}
}
plugins {
id "net.woggioni.gradle.envelope" version "2023.09.25"
}
}
```
Then add it to a project's `build.gradle`
```groovy
plugins {
id 'net.woggioni.gradle.envelope'
}
envelopeJar {
mainClass = 'your.main.Class'
}
```
The plugin adds 2 tasks to your project:
- `envelopeJar` of type `net.woggioni.gradle.envelope.EnvelopeJarTask` that creates the executable jar in the project's libraries folder
- `envelopeRun` of type `org.gradle.api.tasks.JavaExec` which launches the jar created by the `envelopeJar` task
### Configuration
`EnvelopeJarTask` has several properties useful for configuration purposes:
###### mainClass
This string property sets the class that will be searched for the `main` method to start the application
###### mainModule
When this string property is set, the jar file will be started in JPMS mode (if running on Java 9+) and
this module will be searched for the main class, if the `mainClass` is not set the main class specified
in the module descriptor will be loaded instead
###### systemProperties
This is a map that contains Java system properties that will be set before your application starts
###### extraClasspath
This is a list of strings representing filesystem paths that will be added to the classpath (if running in classpath mode)
or to the module path (if running in JPMS mode) when the application starts.
Relative paths and interpolation with Java System properties and environmental variables are supported:
e.g.
This looks for a `plugin` folder in the user's home directory
```
${env:HOME}/plugins
```
Same using Java system properties instead
```
${sys:user.home}/plugins
```
###### javaAgent
This is a method accepting 2 strings, the first is the Java agent classname and the second one is the java agent arguments.
It can be invoked multiple times to setup multiple java agents for the same JAR file.
All the java agents will be invoked before the application startup.
### Example
```groovy
plugins {
id 'net.woggioni.gradle.envelope'
}
envelopeJar {
mainClass = 'your.main.Class'
mainModule = 'your.main.module'
systemProperties = [
'some.property' : 'Some value'
]
extraClasspath = ["plugins"]
javaAgent('your.java.agent.Class', 'optional agent arguments')
}
```
### Limitations
This plugin requires Gradle >= 6.0 and Java >=0 8 to build the executable jar file.
The assembled envelope jar requires and Java >= 8 to run, if only `mainClass` is specified,
if both `mainModule` and `mainClass` are specified the generated jar file will (try to) run in classpath mode on Java 8
and in JPMS mode on Java > 8.

View File

@@ -1,24 +1,27 @@
package net.woggioni.envelope;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.util.AbstractMap;
import java.util.Map;
import java.util.MissingFormatArgumentException;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Stream;
import java.util.zip.CRC32;
import java.util.zip.ZipEntry;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import java.io.IOException;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Common {
@@ -114,4 +117,152 @@ public class Common {
OutputStream result = new FileOutputStream(file);
return buffered ? new BufferedOutputStream(result) : result;
}
/**
* @param template Template text containing the variables to be replaced by this method. <br>
* Variables follow the format ${variable_name}. <br>
* Example: <br>
* "This template was created by ${author}."
* @param valuesMap A hashmap with the values of the variables to be replaced. <br>
* The key is the variable name and the value is the value to be replaced in the template. <br>
* Example: <br>
* {"author" =&gt; "John Doe"}
* @return The template text (String) with the variable names replaced by the values passed in the map. <br>
* If any of the variable names is not contained in the map it will be replaced by an empty string. <br>
* Example: <br>
* "This template was created by John Doe."
*/
public static String renderTemplate(String template, Map<String, Object> valuesMap) {
return renderTemplate(template, valuesMap, null);
}
public static int indexOfWithEscape(String haystack, char needle, char escape, int begin, int end) {
int result = -1;
int cursor = begin;
if (end == 0) {
end = haystack.length();
}
int escapeCount = 0;
while (cursor < end) {
char c = haystack.charAt(cursor);
if (escapeCount > 0) {
--escapeCount;
if (c == escape) {
result = -1;
}
} else if (escapeCount == 0) {
if (c == escape) {
++escapeCount;
}
if (c == needle) {
result = cursor;
}
}
if (result >= 0 && escapeCount == 0) {
break;
}
++cursor;
}
return result;
}
public static String renderTemplate(
String template,
Map<String, Object> valuesMap,
Map<String, Map<String, Object>> dictMap) {
StringBuilder sb = new StringBuilder();
Object absent = new Object();
int cursor = 0;
TokenScanner tokenScanner = new TokenScanner(template, '$', '$');
while (cursor < template.length()) {
tokenScanner.next();
int nextPlaceHolder;
switch (tokenScanner.getTokenType()) {
case TOKEN: {
nextPlaceHolder = tokenScanner.getTokenIndex();
while (cursor < nextPlaceHolder) {
char ch = template.charAt(cursor++);
sb.append(ch);
}
if (cursor + 1 < template.length() && template.charAt(cursor + 1) == '{') {
String key;
String context = null;
String defaultValue = null;
Object value;
int end = template.indexOf('}', cursor + 1);
int colon;
if (dictMap == null)
colon = -1;
else {
colon = indexOfWithEscape(template, ':', '\\', cursor + 1, template.length());
if (colon >= end) colon = -1;
}
if (colon < 0) {
key = template.substring(cursor + 2, end);
value = valuesMap.getOrDefault(key, absent);
} else {
context = template.substring(cursor + 2, colon);
int secondColon = indexOfWithEscape(template, ':', '\\', colon + 1, end);
if (secondColon < 0) {
key = template.substring(colon + 1, end);
} else {
key = template.substring(colon + 1, secondColon);
defaultValue = template.substring(secondColon + 1, end);
}
value = Optional.ofNullable(dictMap.get(context))
.map(m -> m.get(key))
.orElse(absent);
}
if (value != absent) {
sb.append(value.toString());
} else {
if (defaultValue != null) {
sb.append(defaultValue);
} else {
throw new MissingFormatArgumentException(
String.format("Missing value for placeholder '%s'",
context == null ? key : context + ':' + key
)
);
}
}
cursor = end + 1;
}
break;
}
case ESCAPE:
nextPlaceHolder = tokenScanner.getTokenIndex();
while (cursor < nextPlaceHolder) {
char ch = template.charAt(cursor++);
sb.append(ch);
}
cursor = nextPlaceHolder + 1;
sb.append(template.charAt(cursor++));
break;
case END:
default:
nextPlaceHolder = template.length();
while (cursor < nextPlaceHolder) {
char ch = template.charAt(cursor++);
sb.append(ch);
}
break;
}
}
return sb.toString();
}
public static <T> Stream<T> opt2Stream(Optional<T> opt) {
return opt.map(Stream::of).orElse(Stream.empty());
}
public static <T> Optional<T> or(Supplier<T> ...suppliers) {
Optional<T> result = Optional.empty();
for(Supplier<T> supplier : suppliers) {
T value = supplier.get();
if(value != null) return Optional.of(value);
}
return result;
}
}

View File

@@ -1,9 +1,10 @@
package net.woggioni.envelope;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import java.util.Calendar;
import java.util.GregorianCalendar;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Constants {
@@ -16,13 +17,22 @@ public class Constants {
public static final String SYSTEM_PROPERTIES_FILE = METADATA_FOLDER + "/system.properties";
public static final String LIBRARIES_TOC = METADATA_FOLDER + "/libraries.txt";
public static final char EXTRA_CLASSPATH_ENTRY_SEPARATOR = ';';
public static class ManifestAttributes {
public static final String MAIN_MODULE = "Executable-Jar-Main-Module";
public static final String MAIN_CLASS = "Executable-Jar-Main-Class";
public static final String EXTRA_CLASSPATH = "Executable-Jar-Extra-Classpath";
public static final String ENTRY_HASH = "SHA-256-Digest";
}
public static class JvmProperties {
private static final String PREFIX = "envelope.";
public static final String MAIN_MODULE = PREFIX + "main.module";
public static final String MAIN_CLASS = PREFIX + "main.class";
public static final String EXTRA_CLASSPATH = PREFIX + "extra.classpath";
}
/**
* This value is used as a default file timestamp for all the zip entries when
* <a href="https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/bundling/AbstractArchiveTask.html#isPreserveFileTimestamps--">AbstractArchiveTask.isPreserveFileTimestamps</a>

View File

@@ -0,0 +1,74 @@
package net.woggioni.envelope;
import lombok.Getter;
public class TokenScanner {
public enum TokenType {
ESCAPE, TOKEN, END;
}
private final String haystack;
private final char needle;
private final char escape;
private int begin;
private final int end;
@Getter
private int tokenIndex = -1;
@Getter
private TokenType tokenType = null;
public TokenScanner(String haystack, char needle, char escape, int begin, int end) {
this.haystack = haystack;
this.needle = needle;
this.escape = escape;
this.begin = begin;
this.end = end;
}
public TokenScanner(String haystack, char needle, char escape, int begin) {
this(haystack, needle, escape, begin, haystack.length());
}
public TokenScanner(String haystack, char needle, char escape) {
this(haystack, needle, escape, 0, haystack.length());
}
public void next() {
int result = -1;
int cursor = begin;
int escapeCount = 0;
while(true) {
if(cursor < end) {
char c = haystack.charAt(cursor);
if (escapeCount > 0) {
--escapeCount;
if(c == escape || c == needle) {
tokenIndex = cursor - 1;
tokenType = TokenType.ESCAPE;
break;
}
} else if (escapeCount == 0) {
if (c == escape) {
++escapeCount;
}
if (c == needle) {
result = cursor;
}
}
if (result >= 0 && escapeCount == 0) {
tokenIndex = result;
tokenType = TokenType.TOKEN;
break;
}
++cursor;
} else {
tokenIndex = result;
tokenType = result < 0 ? TokenType.END :TokenType.TOKEN;
break;
}
}
begin = cursor + 1;
}
}

View File

@@ -1,7 +1,7 @@
woggioniMavenRepositoryUrl=https://woggioni.net/mvn/
publishMavenRepositoryUrl=https://mvn.woggioni.net/
lys.version = 0.2-SNAPSHOT
lys.version = 2024.02.24
version.envelope=2023.03
version.envelope=2024.02.28
version.gradle=7.6

Binary file not shown.

View File

@@ -1,5 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

12
gradlew vendored
View File

@@ -55,7 +55,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,10 +80,10 @@ do
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@@ -143,12 +143,16 @@ fi
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac

1
gradlew.bat vendored
View File

@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%

View File

@@ -7,12 +7,15 @@ ext {
setProperty('jpms.module.name', 'net.woggioni.envelope')
}
configurations {
embedded {
visible = false
canBeConsumed = false
}
compileOnly.extendsFrom(embedded)
compileOnly {
extendsFrom(embedded)
}
tar {
visible = true
canBeConsumed = true
@@ -22,7 +25,7 @@ configurations {
dependencies {
embedded project(path: ":common", configuration: 'archives')
embedded project(path: ":loader", configuration: 'archives')
embedded project(path: ":loader", configuration: 'embed')
}
java {

View File

@@ -10,16 +10,27 @@ import java.io.Reader;
import java.lang.reflect.Method;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.AbstractMap;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.TreeMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static net.woggioni.envelope.Common.or;
import static net.woggioni.envelope.Constants.EXTRA_CLASSPATH_ENTRY_SEPARATOR;
public class Launcher {
@@ -53,6 +64,69 @@ public class Launcher {
}
}
private static Map<String, Map<String, Object>> createContextMap() {
Map<String, Map<String, Object>> dictMap = new TreeMap<>();
dictMap.put("env", Collections.unmodifiableMap(System.getenv()
.entrySet()
.stream()
.map(entry -> new AbstractMap.SimpleEntry<>(entry.getKey(), (Object) entry.getValue()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))));
dictMap.put("sys", Collections.unmodifiableMap(System.getProperties().entrySet().stream()
.map((Map.Entry<? super String, Object> entry) -> (Map.Entry<String, Object>) entry)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))));
return Collections.unmodifiableMap(dictMap);
}
private static Stream<JarFile> getExtraClasspath(Attributes mainAttributes) {
return Common.opt2Stream(or(
() -> System.getProperty(Constants.JvmProperties.EXTRA_CLASSPATH),
() -> mainAttributes.getValue(Constants.ManifestAttributes.EXTRA_CLASSPATH)
)
.map(manifestAttribute -> {
Map<String, Map<String, Object>> dictMap = createContextMap();
return Common.renderTemplate(manifestAttribute, Collections.emptyMap(), dictMap);
}).map(extraClasspathString -> {
List<String> paths = new ArrayList<>();
int cursor = 0;
while(true) {
int sep = Common.indexOfWithEscape(
extraClasspathString,
EXTRA_CLASSPATH_ENTRY_SEPARATOR,
EXTRA_CLASSPATH_ENTRY_SEPARATOR,
cursor,
extraClasspathString.length()
);
String classpathEntry = extraClasspathString.substring(cursor, sep < 0 ? extraClasspathString.length() : sep);
paths.add(classpathEntry);
if(sep < 0) break;
cursor = sep + 1;
}
return paths;
}))
.flatMap(List::stream)
.map(Paths::get)
.flatMap(new Function<Path, Stream<Path>>() {
@Override
@SneakyThrows
public Stream<Path> apply(Path path) {
if(Files.isDirectory(path)) {
return Files.list(path).filter(childPath -> !Files.isDirectory(childPath));
} else {
return Stream.of(path);
}
}
})
.filter(path -> path.getFileName().toString().toLowerCase().endsWith(".jar"))
.map(Path::toFile)
.map(new Function<File, JarFile>() {
@Override
@SneakyThrows
public JarFile apply(File file) {
return new JarFile(file);
}
});
}
@SneakyThrows
public static void main(String[] args) {
Enumeration<URL> it = Launcher.class.getClassLoader().getResources(Constants.SYSTEM_PROPERTIES_FILE);
@@ -73,8 +147,14 @@ public class Launcher {
Manifest mf = currentJar.getManifest();
Attributes mainAttributes = mf.getMainAttributes();
String mainClassName = mainAttributes.getValue(Constants.ManifestAttributes.MAIN_CLASS);
String mainModuleName = mainAttributes.getValue(Constants.ManifestAttributes.MAIN_MODULE);
String mainClassName = or(
() -> System.getProperty(Constants.JvmProperties.MAIN_CLASS),
() -> mainAttributes.getValue(Constants.ManifestAttributes.MAIN_CLASS)
).orElse(null);
String mainModuleName = or(
() -> System.getProperty(Constants.JvmProperties.MAIN_MODULE),
() -> mainAttributes.getValue(Constants.ManifestAttributes.MAIN_MODULE)
).orElse(null);
StringBuilder sb = new StringBuilder();
List<JarFile> classpath = new ArrayList<>();
URL libraryTocResource = Launcher.class.getClassLoader().getResource(Constants.LIBRARIES_TOC);
@@ -94,6 +174,8 @@ public class Launcher {
else sb.append((char) c);
}
}
getExtraClasspath(mainAttributes).forEach(classpath::add);
Consumer<Class<?>> runner = new Consumer<Class<?>>() {
@Override
@SneakyThrows

View File

@@ -85,6 +85,7 @@ class MainRunner {
ModuleLayer layer = controller.layer();
Module mainModule = layer.findModule(mainModuleName).orElseThrow(
() -> new IllegalStateException(String.format("Main module '%s' not found", mainModuleName)));
Thread.currentThread().setContextClassLoader(mainModule.getClassLoader());
Optional<String> mainClassOpt = Optional.ofNullable(mainClassName);
runner.accept(Optional.ofNullable(mainClassName)
.or(() -> mainModule.getDescriptor().mainClass())

View File

@@ -7,11 +7,14 @@ ext {
setProperty('jpms.module.name', 'net.woggioni.envelope.loader')
}
compileJava11 {
exclude('module-info.java')
configurations {
embed {
canBeResolved = true
canBeConsumed = true
visible = true
transitive = true
}
}
publishing {
publications {
@@ -24,3 +27,24 @@ publishing {
}
}
tasks.register('embedJar', Jar) { jar ->
Provider<Jar> jarTaskProvider = tasks.named(JavaPlugin.JAR_TASK_NAME)
jar.inputs.files(jarTaskProvider)
archiveClassifier = 'embed'
from(zipTree(jarTaskProvider.map { it.archiveFile} )) {
exclude '**/module-info.class'
}
manifest{
attributes([
'Multi-Release': 'true'
])
}
}
artifacts {
embed(embedJar)
}

View File

@@ -4,9 +4,11 @@ import lombok.SneakyThrows;
import net.woggioni.envelope.loader.JarFile;
import net.woggioni.envelope.loader.jar.Handler;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.module.ModuleDescriptor;
import java.lang.module.ModuleFinder;
import java.lang.module.ModuleReader;
@@ -17,6 +19,8 @@ import java.net.URLStreamHandler;
import java.nio.file.Paths;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
import java.util.HashSet;
import java.util.Map;
@@ -27,16 +31,20 @@ import java.util.TreeMap;
import java.util.jar.Attributes.Name;
import java.util.jar.JarEntry;
import java.util.jar.Manifest;
import java.util.jar.Attributes;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.lang.module.FindException;
import java.nio.charset.StandardCharsets;
import java.lang.module.InvalidModuleDescriptorException;
public class JarFileModuleFinder implements ModuleFinder {
private static final String MODULE_DESCRIPTOR_ENTRY = "module-info.class";
private static final Name AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY = new Name("Automatic-Module-Name");
private static final String SERVICES_PREFIX = "META-INF/services/";
private static class Patterns {
static final Pattern DASH_VERSION = Pattern.compile("-(\\d+(\\.|$))");
static final Pattern NON_ALPHANUM = Pattern.compile("[^A-Za-z0-9]");
@@ -44,6 +52,66 @@ public class JarFileModuleFinder implements ModuleFinder {
static final Pattern LEADING_DOTS = Pattern.compile("^\\.");
static final Pattern TRAILING_DOTS = Pattern.compile("\\.$");
}
// keywords, boolean and null literals, not allowed in identifiers
private static final Set<String> RESERVED = Set.of(
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"strictfp",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"try",
"void",
"volatile",
"while",
"true",
"false",
"null",
"_"
);
private final Map<String, Map.Entry<ModuleReference, Handler>> modules;
@SneakyThrows
@@ -150,20 +218,7 @@ public class JarFileModuleFinder implements ModuleFinder {
moduleDescriptor = ModuleDescriptor.read(is, () -> collectPackageNames(jarFile));
}
} else {
Manifest mf = jarFile.getManifest();
moduleName = mf.getMainAttributes().getValue(AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY);
if(moduleName == null) {
moduleName = moduleNameFromURI(uri);
}
ModuleDescriptor.Builder mdb = ModuleDescriptor.newAutomaticModule(moduleName);
mdb.packages(collectPackageNames(jarFile));
// Main-Class attribute if it exists
String mainClass = mf.getMainAttributes().getValue(Name.MAIN_CLASS);
if (mainClass != null) {
mdb.mainClass(mainClass);
}
moduleDescriptor = mdb.build();
moduleDescriptor = deriveModuleDescriptor(jarFile);
}
modules.put(moduleDescriptor.name(),
@@ -210,4 +265,233 @@ public class JarFileModuleFinder implements ModuleFinder {
.stream().map(Map.Entry::getKey)
.collect(Collectors.toSet()));
}
private ModuleDescriptor deriveModuleDescriptor(JarFile jf)
throws IOException
{
// Read Automatic-Module-Name attribute if present
Manifest man = jf.getManifest();
Attributes attrs = null;
String moduleName = null;
if (man != null) {
attrs = man.getMainAttributes();
if (attrs != null) {
moduleName = attrs.getValue(AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY);
}
}
// Derive the version, and the module name if needed, from JAR file name
String fn = jf.getName();
int i = fn.lastIndexOf(File.separator);
if (i != -1)
fn = fn.substring(i + 1);
// drop ".jar"
String name = fn.substring(0, fn.length() - 4);
String vs = null;
// find first occurrence of -${NUMBER}. or -${NUMBER}$
Matcher matcher = Patterns.DASH_VERSION.matcher(name);
if (matcher.find()) {
int start = matcher.start();
// attempt to parse the tail as a version string
try {
String tail = name.substring(start + 1);
ModuleDescriptor.Version.parse(tail);
vs = tail;
} catch (IllegalArgumentException ignore) { }
name = name.substring(0, start);
}
// Create builder, using the name derived from file name when
// Automatic-Module-Name not present
ModuleDescriptor.Builder builder;
if (moduleName != null) {
try {
builder = ModuleDescriptor.newAutomaticModule(moduleName);
} catch (IllegalArgumentException e) {
throw new FindException(AUTOMATIC_MODULE_NAME_MANIFEST_ENTRY + ": " + e.getMessage());
}
} else {
builder = ModuleDescriptor.newAutomaticModule(cleanModuleName(name));
}
// module version if present
if (vs != null)
builder.version(vs);
// scan the names of the entries in the JAR file
Map<Boolean, Set<String>> map = jf.versionedStream()
.filter(e -> !e.isDirectory())
.map(JarEntry::getName)
.filter(e -> (e.endsWith(".class") ^ e.startsWith(SERVICES_PREFIX)))
.collect(Collectors.partitioningBy(e -> e.startsWith(SERVICES_PREFIX),
Collectors.toSet()));
Set<String> classFiles = map.get(Boolean.FALSE);
Set<String> configFiles = map.get(Boolean.TRUE);
// the packages containing class files
Set<String> packages = classFiles.stream()
.map(this::toPackageName)
.flatMap(Optional::stream)
.distinct()
.collect(Collectors.toSet());
// all packages are exported and open
builder.packages(packages);
// map names of service configuration files to service names
Set<String> serviceNames = configFiles.stream()
.map(this::toServiceName)
.flatMap(Optional::stream)
.collect(Collectors.toSet());
// parse each service configuration file
for (String sn : serviceNames) {
JarEntry entry = jf.getJarEntry(SERVICES_PREFIX + sn);
List<String> providerClasses = new ArrayList<>();
try (InputStream in = jf.getInputStream(entry)) {
BufferedReader reader
= new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
String cn;
while ((cn = nextLine(reader)) != null) {
if (!cn.isEmpty()) {
String pn = packageName(cn);
if (!packages.contains(pn)) {
String msg = "Provider class " + cn + " not in module";
throw new InvalidModuleDescriptorException(msg);
}
providerClasses.add(cn);
}
}
}
if (!providerClasses.isEmpty())
builder.provides(sn, providerClasses);
}
// Main-Class attribute if it exists
if (attrs != null) {
String mainClass = attrs.getValue(Attributes.Name.MAIN_CLASS);
if (mainClass != null) {
mainClass = mainClass.replace('/', '.');
if (isClassName(mainClass)) {
String pn = packageName(mainClass);
if (packages.contains(pn)) {
builder.mainClass(mainClass);
}
}
}
}
return builder.build();
}
private Optional<String> toServiceName(String cf) {
assert cf.startsWith(SERVICES_PREFIX);
int index = cf.lastIndexOf("/") + 1;
if (index < cf.length()) {
String prefix = cf.substring(0, index);
if (prefix.equals(SERVICES_PREFIX)) {
String sn = cf.substring(index);
if (isClassName(sn))
return Optional.of(sn);
}
}
return Optional.empty();
}
private static String packageName(String cn) {
int index = cn.lastIndexOf('.');
return (index == -1) ? "" : cn.substring(0, index);
}
/**
* Maps the name of an entry in a JAR or ZIP file to a package name.
*
* @throws InvalidModuleDescriptorException if the name is a class file in
* the top-level directory of the JAR/ZIP file (and it's not
* module-info.class)
*/
private Optional<String> toPackageName(String name) {
assert !name.endsWith("/");
int index = name.lastIndexOf("/");
if (index == -1) {
if (name.endsWith(".class") && !name.equals(MODULE_DESCRIPTOR_ENTRY)) {
String msg = name + " found in top-level directory"
+ " (unnamed package not allowed in module)";
throw new InvalidModuleDescriptorException(msg);
}
return Optional.empty();
}
String pn = name.substring(0, index).replace('/', '.');
if (isPackageName(pn)) {
return Optional.of(pn);
} else {
// not a valid package name
return Optional.empty();
}
}
/**
* Reads the next line from the given reader and trims it of comments and
* leading/trailing white space.
*
* Returns null if the reader is at EOF.
*/
private String nextLine(BufferedReader reader) throws IOException {
String ln = reader.readLine();
if (ln != null) {
int ci = ln.indexOf('#');
if (ci >= 0)
ln = ln.substring(0, ci);
ln = ln.trim();
}
return ln;
}
private static boolean isClassName(String name) {
return isTypeName(name);
}
/**
* Returns {@code true} if the given name is a legal type name.
*/ private static boolean isPackageName(String name) {
return isTypeName(name);
}
private static boolean isTypeName(String name) {
int next;
int off = 0;
while ((next = name.indexOf('.', off)) != -1) {
String id = name.substring(off, next);
if (!isJavaIdentifier(id))
return false;
off = next+1;
}
String last = name.substring(off);
return isJavaIdentifier(last);
}
private static boolean isJavaIdentifier(String str) {
if (str.isEmpty() || RESERVED.contains(str))
return false;
int first = Character.codePointAt(str, 0);
if (!Character.isJavaIdentifierStart(first))
return false;
int i = Character.charCount(first);
while (i < str.length()) {
int cp = Character.codePointAt(str, i);
if (!Character.isJavaIdentifierPart(cp))
return false;
i += Character.charCount(cp);
}
return true;
}
}

View File

@@ -18,8 +18,11 @@ import org.gradle.api.internal.file.copy.CopyActionProcessingStream;
import org.gradle.api.internal.file.copy.FileCopyDetailsInternal;
import org.gradle.api.java.archives.internal.DefaultManifest;
import org.gradle.api.model.ObjectFactory;
import org.gradle.api.plugins.BasePlugin;
import org.gradle.api.plugins.BasePluginExtension;
import org.gradle.api.plugins.JavaApplication;
import org.gradle.api.provider.ListProperty;
import org.gradle.api.provider.MapProperty;
import org.gradle.api.provider.Property;
import org.gradle.api.provider.Provider;
import org.gradle.api.tasks.Input;
@@ -59,10 +62,12 @@ import java.util.zip.ZipOutputStream;
import static java.util.zip.Deflater.BEST_COMPRESSION;
import static java.util.zip.Deflater.NO_COMPRESSION;
import static net.woggioni.gradle.envelope.EnvelopePlugin.ENVELOPE_GROUP_NAME;
@SuppressWarnings({"unused" })
public class EnvelopeJarTask extends AbstractArchiveTask {
public abstract class EnvelopeJarTask extends AbstractArchiveTask {
private static final String DEFAULT_ARCHIVE_APPENDIX = ENVELOPE_GROUP_NAME;
private static final String MINIMUM_GRADLE_VERSION = "6.0";
private static final String EXTRACT_LAUNCHER_TASK_NAME = "extractEnvelopeLauncher";
@@ -75,16 +80,21 @@ public class EnvelopeJarTask extends AbstractArchiveTask {
private final Provider<ExtractLauncherTask> extractLauncherTaskProvider;
@Getter(onMethod_ = {@Input, @Optional})
private final Property<String> mainClass;
@Input
@Optional
public abstract Property<String> getMainClass();
@Getter(onMethod_ = {@Input, @Optional})
private final Property<String> mainModule;
@Input
@Optional
public abstract Property<String> getMainModule();
private final Properties javaAgents = new Properties();
@Getter(onMethod_ = {@Input})
private final Map<String, String> systemProperties = new TreeMap<>();
@Input
public abstract MapProperty<String, String> getSystemProperties();
@Input
public abstract ListProperty<String> getExtraClasspath();
private final org.gradle.api.java.archives.Manifest manifest;
@@ -124,10 +134,6 @@ public class EnvelopeJarTask extends AbstractArchiveTask {
javaAgents.put(className, args);
}
public void systemProperty(String key, String value) {
systemProperties.put(key, value);
}
public void includeLibraries(Object... files) {
into(Constants.LIBRARIES_FOLDER, (copySpec) -> copySpec.from(files));
}
@@ -135,31 +141,30 @@ public class EnvelopeJarTask extends AbstractArchiveTask {
@Inject
public EnvelopeJarTask(ObjectFactory objects, FileResolver fileResolver) {
Project rootProject = getProject().getRootProject();
TaskContainer rootProjectTasks = rootProject.getTasks();
if(rootProjectTasks.getNames().contains(EXTRACT_LAUNCHER_TASK_NAME)) {
extractLauncherTaskProvider = rootProjectTasks.named(EXTRACT_LAUNCHER_TASK_NAME, ExtractLauncherTask.class);
Project project = getProject();
TaskContainer tasks = project.getTasks();
if(tasks.getNames().contains(EXTRACT_LAUNCHER_TASK_NAME)) {
extractLauncherTaskProvider = tasks.named(EXTRACT_LAUNCHER_TASK_NAME, ExtractLauncherTask.class);
} else {
extractLauncherTaskProvider = rootProject.getTasks().register(EXTRACT_LAUNCHER_TASK_NAME, ExtractLauncherTask.class);
extractLauncherTaskProvider = tasks.register(EXTRACT_LAUNCHER_TASK_NAME, ExtractLauncherTask.class);
}
getInputs().files(extractLauncherTaskProvider);
setGroup("build");
setGroup(BasePlugin.BUILD_GROUP);
setDescription("Creates an executable jar file, embedding all of its runtime dependencies");
BasePluginExtension basePluginExtension = getProject().getExtensions().getByType(BasePluginExtension.class);
getDestinationDirectory().set(basePluginExtension.getDistsDirectory());
getDestinationDirectory().set(basePluginExtension.getLibsDirectory());
getArchiveBaseName().convention(getProject().getName());
getArchiveExtension().convention("jar");
getArchiveVersion().convention(getProject().getVersion().toString());
getArchiveAppendix().convention("envelope");
getArchiveAppendix().convention(DEFAULT_ARCHIVE_APPENDIX);
manifest = new DefaultManifest(fileResolver);
mainClass = objects.property(String.class);
mainModule = objects.property(String.class);
getSystemProperties().convention(new TreeMap<>());
JavaApplication javaApplication = getProject().getExtensions().findByType(JavaApplication.class);
if(!Objects.isNull(javaApplication)) {
mainClass.convention(javaApplication.getMainClass());
mainModule.convention(javaApplication.getMainModule());
getMainClass().convention(javaApplication.getMainClass());
getMainModule().convention(javaApplication.getMainModule());
}
from(getProject().tarTree(extractLauncherTaskProvider.map(ExtractLauncherTask::getLauncherTar)), copySpec -> exclude(JarFile.MANIFEST_NAME));
}
@@ -286,11 +291,23 @@ public class EnvelopeJarTask extends AbstractArchiveTask {
mainAttributes.put(new Attributes.Name("Launcher-Agent-Class"), Constants.AGENT_LAUNCHER);
mainAttributes.put(new Attributes.Name("Can-Redefine-Classes"), "true");
mainAttributes.put(new Attributes.Name("Can-Retransform-Classes"), "true");
if(mainClass.isPresent()) {
mainAttributes.putValue(Constants.ManifestAttributes.MAIN_CLASS, mainClass.get());
String separator = "" + Constants.EXTRA_CLASSPATH_ENTRY_SEPARATOR;
ListProperty<String> extraClasspathProperty = EnvelopeJarTask.this.getExtraClasspath();
java.util.Optional.of(extraClasspathProperty)
.filter(ListProperty::isPresent)
.map(ListProperty::get)
.filter(l -> !l.isEmpty())
.ifPresent(extraClasspath -> {
String extraClasspathString = extraClasspath.stream()
.map(it -> it.replace(separator, separator + separator)
).collect(Collectors.joining(separator));
mainAttributes.put(new Attributes.Name(Constants.ManifestAttributes.EXTRA_CLASSPATH), extraClasspathString);
});
if(getMainClass().isPresent()) {
mainAttributes.putValue(Constants.ManifestAttributes.MAIN_CLASS, getMainClass().get());
}
if(mainModule.isPresent()) {
mainAttributes.putValue(Constants.ManifestAttributes.MAIN_MODULE, mainModule.get());
if(getMainModule().isPresent()) {
mainAttributes.putValue(Constants.ManifestAttributes.MAIN_MODULE, getMainModule().get());
}
MessageDigest md = MessageDigest.getInstance("SHA-256");
@@ -334,7 +351,7 @@ public class EnvelopeJarTask extends AbstractArchiveTask {
zipEntry.setMethod(ZipEntry.DEFLATED);
zipOutputStream.putNextEntry(zipEntry);
Properties props = new Properties();
for(Map.Entry<String, String> entry : systemProperties.entrySet()) {
for(Map.Entry<String, String> entry : getSystemProperties().get().entrySet()) {
props.setProperty(entry.getKey(), entry.getValue());
}
props.store(zipOutputStream, null);

View File

@@ -10,10 +10,14 @@ import org.gradle.api.tasks.JavaExec;
import org.gradle.api.tasks.bundling.Jar;
public class EnvelopePlugin implements Plugin<Project> {
public static final String ENVELOPE_GROUP_NAME = "envelope";
@Override
public void apply(Project project) {
project.getPluginManager().apply(JavaPlugin.class);
Provider<EnvelopeJarTask> envelopeJarTaskProvider = project.getTasks().register("envelopeJar", EnvelopeJarTask.class, t -> {
t.setGroup(BasePlugin.BUILD_GROUP);
t.setGroup(ENVELOPE_GROUP_NAME);
t.setDescription("Package the application in a single executable jar file");
t.includeLibraries(project.getConfigurations().named(JavaPlugin.RUNTIME_CLASSPATH_CONFIGURATION_NAME));
t.includeLibraries(project.getTasks().named(JavaPlugin.JAR_TASK_NAME, Jar.class));
@@ -23,7 +27,7 @@ public class EnvelopePlugin implements Plugin<Project> {
});
Provider<JavaExec> envelopeRunTaskProvider = project.getTasks().register("envelopeRun", JavaExec.class, t -> {
t.getInputs().files(envelopeJarTaskProvider);
t.setGroup("envelope");
t.setGroup(ENVELOPE_GROUP_NAME);
t.setDescription("Run the application in the envelope jar");
t.classpath(envelopeJarTaskProvider);
});