diff --git a/common/src/main/java/net/woggioni/envelope/Common.java b/common/src/main/java/net/woggioni/envelope/Common.java index ac95ce0..11dbaac 100644 --- a/common/src/main/java/net/woggioni/envelope/Common.java +++ b/common/src/main/java/net/woggioni/envelope/Common.java @@ -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,144 @@ 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.
+ * Variables follow the format ${variable_name}.
+ * Example:
+ * "This template was created by ${author}." + * @param valuesMap A hashmap with the values of the variables to be replaced.
+ * The key is the variable name and the value is the value to be replaced in the template.
+ * Example:
+ * {"author" => "John Doe"} + * @return The template text (String) with the variable names replaced by the values passed in the map.
+ * If any of the variable names is not contained in the map it will be replaced by an empty string.
+ * Example:
+ * "This template was created by John Doe." + */ + public static String renderTemplate(String template, Map 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 valuesMap, + Map> 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 Stream opt2Stream(Optional opt) { + return opt.map(Stream::of).orElse(Stream.empty()); + } } diff --git a/common/src/main/java/net/woggioni/envelope/Constants.java b/common/src/main/java/net/woggioni/envelope/Constants.java index ff35a1b..345c169 100644 --- a/common/src/main/java/net/woggioni/envelope/Constants.java +++ b/common/src/main/java/net/woggioni/envelope/Constants.java @@ -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,10 +17,12 @@ 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"; } diff --git a/common/src/main/java/net/woggioni/envelope/TokenScanner.java b/common/src/main/java/net/woggioni/envelope/TokenScanner.java new file mode 100644 index 0000000..34ddab3 --- /dev/null +++ b/common/src/main/java/net/woggioni/envelope/TokenScanner.java @@ -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; + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 47ab112..4327f61 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ woggioniMavenRepositoryUrl=https://woggioni.net/mvn/ publishMavenRepositoryUrl=https://mvn.woggioni.net/ -lys.version = 2023.06.13 +lys.version = 2023.06.22 -version.envelope=2023.06.13 +version.envelope=2023.06.24 version.gradle=7.6 diff --git a/launcher/src/main/java/net/woggioni/envelope/Launcher.java b/launcher/src/main/java/net/woggioni/envelope/Launcher.java index 5d1088d..e35f1b6 100644 --- a/launcher/src/main/java/net/woggioni/envelope/Launcher.java +++ b/launcher/src/main/java/net/woggioni/envelope/Launcher.java @@ -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.Optional; 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.Constants.EXTRA_CLASSPATH_ENTRY_SEPARATOR; public class Launcher { @@ -53,6 +64,65 @@ public class Launcher { } } + private static Map> createContextMap() { + Map> 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 entry) -> (Map.Entry) entry) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)))); + return Collections.unmodifiableMap(dictMap); + } + + private static Stream getExtraClasspath(Attributes mainAttributes) { + return Common.opt2Stream(Optional.ofNullable(mainAttributes.getValue(Constants.ManifestAttributes.EXTRA_CLASSPATH)) + .map(manifestAttribute -> { + Map> dictMap = createContextMap(); + return Common.renderTemplate(manifestAttribute, Collections.emptyMap(), dictMap); + }).map(extraClasspathString -> { + List paths = new ArrayList<>(); + int cursor = 0; + while(true) { + int sep = Common.indexOfWithEscape( + extraClasspathString, + EXTRA_CLASSPATH_ENTRY_SEPARATOR, + EXTRA_CLASSPATH_ENTRY_SEPARATOR, + cursor, + extraClasspathString.length() + ); + if(sep < 0) break; + paths.add(extraClasspathString.substring(cursor, sep)); + cursor = sep + 1; + } + return paths; + })) + .flatMap(List::stream) + .map(Paths::get) + .flatMap(new Function>() { + @Override + @SneakyThrows + public Stream 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() { + @Override + @SneakyThrows + public JarFile apply(File file) { + return new JarFile(file); + } + }); + } + @SneakyThrows public static void main(String[] args) { Enumeration it = Launcher.class.getClassLoader().getResources(Constants.SYSTEM_PROPERTIES_FILE); @@ -94,6 +164,8 @@ public class Launcher { else sb.append((char) c); } } + + getExtraClasspath(mainAttributes).forEach(classpath::add); Consumer> runner = new Consumer>() { @Override @SneakyThrows diff --git a/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java b/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java index ebdb856..41c6165 100644 --- a/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java +++ b/src/main/java/net/woggioni/gradle/envelope/EnvelopeJarTask.java @@ -20,6 +20,7 @@ import org.gradle.api.java.archives.internal.DefaultManifest; import org.gradle.api.model.ObjectFactory; import org.gradle.api.plugins.BasePluginExtension; import org.gradle.api.plugins.JavaApplication; +import org.gradle.api.provider.ListProperty; import org.gradle.api.provider.Property; import org.gradle.api.provider.Provider; import org.gradle.api.tasks.Input; @@ -86,6 +87,9 @@ public class EnvelopeJarTask extends AbstractArchiveTask { @Getter(onMethod_ = {@Input}) private final Map systemProperties = new TreeMap<>(); + @Getter(onMethod_ = {@Input}) + private final ListProperty extraClasspath; + private final org.gradle.api.java.archives.Manifest manifest; public org.gradle.api.java.archives.Manifest manifest() { @@ -156,6 +160,7 @@ public class EnvelopeJarTask extends AbstractArchiveTask { manifest = new DefaultManifest(fileResolver); mainClass = objects.property(String.class); mainModule = objects.property(String.class); + extraClasspath = objects.listProperty(String.class); JavaApplication javaApplication = getProject().getExtensions().findByType(JavaApplication.class); if(!Objects.isNull(javaApplication)) { mainClass.convention(javaApplication.getMainClass()); @@ -286,6 +291,11 @@ 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"); + String separator = "" + Constants.EXTRA_CLASSPATH_ENTRY_SEPARATOR; + String extraClasspath = EnvelopeJarTask.this.extraClasspath.get().stream() + .map(it -> it.replace(separator, separator + separator) + ).collect(Collectors.joining(separator)); + mainAttributes.put(new Attributes.Name(Constants.ManifestAttributes.EXTRA_CLASSPATH), extraClasspath); if(mainClass.isPresent()) { mainAttributes.putValue(Constants.ManifestAttributes.MAIN_CLASS, mainClass.get()); }