package org.esa.snap.nbexec;
import javax.swing.JOptionPane;
import javax.swing.SwingUtilities;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.io.StreamTokenizer;
import java.io.StringReader;
import java.lang.management.ManagementFactory;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* A plain Java NetBeans Platform launcher which mimics the core functionality of the NB's native launcher
* {@code nbexec}. Can be used for easy debugging of NB Platform (Maven) applications when using the NB IDE is not
* an option.
* <p>
* <i>IMPORTANT NOTE: This launcher only implements a subset of the functionality the native NetBeans
* launcher {@code nbexec} provides. For example, you cannot update plugins and then let the application
* restart itself.<br>
* The recommended way to run/debug applications build on the NetBeans platform is either using the NetBeans
* Maven plugin (via {@code nbm:run-platform}) or directly using the NetBeans IDE.
* </i>
* <p>
* Usage:
* <pre>
* Launcher [--patches <patches>] [--clusters <clusters>] [--branding <app>]
* [--userdir <userdir>] [--cachedir <cachedir>] <args>
* </pre>
* where the {@code clusters}, {@code branding}, {@code userdir}, and {@code cachedir} options are the same as
* for the native launcher.
* The current working directory must be the target deployment directory, {@code $appmodule/target/$app}.
* <p>
* The Launcher takes care of any changed code in modules indicated by the <i>patches</i> patterns given by the
* {@code patches} option. the <i>patches</i> patterns may contain multiple patterns separated by a semicolon (;)
* on Windows systems and a colon (:) on Unixes. Every patch pattern must contain a single wildcard character ($).
* The default patch pattern is {@code $appmodule/../../../$/target/classes}<br> and is always included.
* <p>
* So, In IntelliJ IDEA we can hit CTRL+F9
* and then run/debug the Launcher.
* <p>
* This is enabled for all modules which are
* <ul>
* <li>(a) found in the applications target cluster (e.g. modules with {@code nbm} packaging) and </li>
* <li>(b) have a valid target/classes output directory.</li>
* </ul>
* We may later want to be able to further configure this default behaviour. See code for how the current
* strategy is implemented.
*
* @author Norman Fomferra
* @version 1.0
*/
public class Launcher {
private static final String CLUSTERS_EXT = ".clusters";
// Command-line arguments
private final String[] args;
// Contains all environment variables and all variables from ${some-dir}/${app-name}/etc/${app-name}.conf
private final Map<String, String> configuration;
public static void main(String[] args) {
new Launcher(args).run();
}
private Launcher(String[] args) {
this.args = args;
this.configuration = new HashMap<>();
}
private void run() {
Path installationDir = Paths.get("").toAbsolutePath();
Path etcDir = installationDir.resolve("etc");
Path platformDir = installationDir.resolve("platform");
if (!Files.isDirectory(etcDir) || !Files.isDirectory(platformDir)) {
throw new IllegalStateException("Not a valid installation directory: " + installationDir);
}
LinkedList<String> argList = new LinkedList<>(Arrays.asList(args));
String clusterDirs = parseArg(argList, "--clusters");
String brandingToken = parseArg(argList, "--branding");
String userDir = parseArg(argList, "--userdir");
String cacheDir = parseArg(argList, "--cachedir");
// Collect project dirs.
// Default is "../../../$/target/classes" which refers to a Maven specific directory layout:
//
// ${parent-1}/
// pom.xml
// ${nb-app-module-dir}/
// pom.xml
// src/
// target/
// ${app} // -> must be current working directory
// ${nb-nbm-module-dir-1}/
// ${nb-nbm-module-dir-2}/
// ${nb-nbm-module-dir-3}/
// ...
// ${parent-2}/
// pom.xml
// ${nb-nbm-module-dir-1}/
// ${nb-nbm-module-dir-2}/
// ...
//
Set<Patch> patches = parseClusterPatches(argList);
Stream<Path> etcFiles;
try {
etcFiles = Files.list(etcDir);
} catch (IOException e) {
throw new IllegalStateException(e);
}
List<Path> clustersFiles = etcFiles
.filter(Files::isRegularFile)
.filter(path -> path.getFileName().toString().endsWith(CLUSTERS_EXT))
.collect(Collectors.toList());
if (clustersFiles.isEmpty()) {
throw new IllegalStateException(String.format("no '*.clusters' file found in '%s'", etcDir));
} else if (clustersFiles.size() > 1) {
throw new IllegalStateException(String.format("multiple '*.clusters' files found in '%s'", etcDir));
}
Path clustersFile = clustersFiles.get(0);
String clustersFileName = clustersFile.getFileName().toString();
String appName = clustersFileName.substring(0, clustersFileName.length() - CLUSTERS_EXT.length());
Path confFile = etcDir.resolve(appName + ".conf");
configuration.putAll(System.getenv());
setConfigurationVariableIfNotSet("APPNAME", appName);
setConfigurationVariableIfNotSet("HOME", System.getProperty("user.home"));
if (Files.isRegularFile(confFile)) {
loadConf(confFile);
}
// Parse "default_options"
List<String> defaultOptionList = new LinkedList<>();
String defaultOptions = getVar("default_options");
String defaultClusterDirs = null;
String defaultBrandingToken = null;
String defaultUserDir = null;
String defaultCacheDir = null;
if (defaultOptions != null) {
defaultOptionList = parseOptions(defaultOptions);
defaultClusterDirs = parseArg(defaultOptionList, "--clusters");
defaultBrandingToken = parseArg(defaultOptionList, "--branding");
defaultUserDir = parseArg(defaultOptionList, "--userdir");
defaultCacheDir = parseArg(defaultOptionList, "--cachedir");
}
if (defaultUserDir == null) {
// From nbexec:
if ("Darwin".equals(System.getProperty("os.name"))) {
defaultUserDir = getVar("default_mac_userdir");
} else {
defaultUserDir = getVar("default_userdir");
}
// .. but not used here because our default is the nbm standard location
if (defaultUserDir == null) {
defaultUserDir = installationDir.resolve("..").resolve("userdir").toString();
}
}
if (clusterDirs == null) {
clusterDirs = defaultClusterDirs;
}
if (userDir == null) {
userDir = defaultUserDir;
}
if (defaultCacheDir == null) {
defaultCacheDir = path(userDir, "var", "cache");
}
if (brandingToken == null) {
brandingToken = defaultBrandingToken;
}
if (cacheDir == null) {
cacheDir = defaultCacheDir;
}
List<String> clusterList = readLines(clustersFile);
clusterList = toAbsolutePaths(clusterList);
String extraClusterPaths = getVar("extra_clusters");
if (extraClusterPaths != null) {
clusterList.add(extraClusterPaths);
}
if (clusterDirs != null) {
clusterList.addAll(toAbsolutePaths(Arrays.asList(clusterDirs.split(File.pathSeparator))));
}
String clusterPaths = toPathsString(clusterList);
List<URL> classPathList = new ArrayList<>();
buildClasspath(userDir, classPathList);
buildClasspath(platformDir.toString(), classPathList);
if ("true".equals(getVar("KDE_FULL_SESSION"))) {
setSystemPropertyIfNotSet("netbeans.running.environment", "kde");
} else if (getVar("GNOME_DESKTOP_SESSION_ID") != null) {
setSystemPropertyIfNotSet("netbeans.running.environment", "gnome");
}
// todo - address following warning:
// WARNING [org.netbeans.modules.autoupdate.ui.actions.AutoupdateSettings]: The property "netbeans.default_userdir_root" was not set!
if (getVar("DEFAULT_USERDIR_ROOT") != null) {
setSystemPropertyIfNotSet("netbeans.default_userdir_root", getVar("DEFAULT_USERDIR_ROOT"));
}
setSystemPropertyIfNotSet("netbeans.home", platformDir.toString());
setSystemPropertyIfNotSet("netbeans.dirs", clusterPaths);
setSystemPropertyIfNotSet("netbeans.logger.console", "true");
setSystemPropertyIfNotSet("com.apple.mrj.application.apple.menu.about.name", brandingToken);
List<String> remainingDefaultOptions = parseJavaOptions(defaultOptionList, false);
List<String> remainingArgs = parseJavaOptions(argList, true);
setPatchModules(clusterList, patches);
List<String> newArgList = new ArrayList<>();
newArgList.add("--branding");
newArgList.add(brandingToken);
newArgList.add("--userdir");
newArgList.add(userDir);
newArgList.add("--cachedir");
newArgList.add(cacheDir);
newArgList.addAll(remainingArgs);
newArgList.addAll(remainingDefaultOptions);
Path restartMarkerFile = Paths.get(userDir, "var", "restart");
try {
Files.deleteIfExists(restartMarkerFile);
} catch (IOException e) {
// So what?
}
final String _userDir = userDir;
Path restartExeFile;
try {
Optional<Path> restartFileResult = Files.list(installationDir.resolve("bin"))
.filter(Files::isExecutable)
.filter(p -> p.getFileName().toString().startsWith("restart."))
.findFirst();
restartExeFile = restartFileResult.get();
} catch (Exception e) {
restartExeFile = null;
}
final Path _restartExeFile = restartExeFile;
if (_restartExeFile != null) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
if (Files.exists(restartMarkerFile)) {
String processName = ManagementFactory.getRuntimeMXBean().getName();
Logger.getLogger("").info("Shut down: " + processName);
String pid = processName.split("@")[0];
try {
new ProcessBuilder().command(_restartExeFile.toString(), pid).start();
} catch (IOException e) {
Logger.getLogger("").log(Level.SEVERE, "Failed to restart: " + _restartExeFile.toString(), e);
//SwingUtilities.invokeLater(() -> JOptionPane.showMessageDialog(null, "Failed to restart:\n" + e.getMessage()));
}
}
}));
}
runMain(classPathList, newArgList);
}
private Set<Patch> parseClusterPatches(LinkedList<String> argList) {
Set<Patch> patches = new LinkedHashSet<>();
// Add Maven-specific output directory for application module
patches.add(Patch.parse("../../../$/target/classes"));
while (true) {
String patchPatterns = parseArg(argList, "--patches");
if (patchPatterns != null) {
String[] patterns = patchPatterns.split(File.pathSeparator);
for (String pattern : patterns) {
patches.add(Patch.parse(pattern));
}
} else {
break;
}
}
return patches;
}
private List<String> parseJavaOptions(List<String> defaultOptionList, boolean fail) {
List<String> remainingDefaultOptions = new ArrayList<>();
for (String option : defaultOptionList) {
if (option.startsWith("-J")) {
if (option.startsWith("-J-D")) {
String kv = option.substring(4);
int i = kv.indexOf("=");
if (i > 0) {
setSystemPropertyIfNotSet(kv.substring(0, i), kv.substring(i + 1));
}
} else {
String msg = String.format("configured option '%s' will be ignored, because the JVM is already running", option);
if (fail) {
throw new IllegalArgumentException(msg);
}
warn(msg);
}
} else {
remainingDefaultOptions.add(option);
}
}
return remainingDefaultOptions;
}
int patchCount = 0;
/*
* scan appDir for modules and set system property netbeans.patches.<module>=<module-classes-dir> for each module
*/
private void setPatchModules(List<String> clusterList, Set<Patch> patches) {
String JAR_EXT = ".jar";
List<String> moduleNames = new ArrayList<>();
for (String clusterDir : clusterList) {
Path clusterModulesDir = Paths.get(clusterDir).resolve("modules");
try {
Files.list(clusterModulesDir).forEach(path -> {
String fileName = path.getFileName().toString();
if (fileName.endsWith(JAR_EXT)) {
String moduleName = fileName.substring(0, fileName.length() - JAR_EXT.length());
//info("candidate patch-providing module in development: " + moduleName);
moduleNames.add(moduleName);
}
});
} catch (IOException e) {
warn("failed to list entries of " + clusterModulesDir);
}
}
for (Patch patch : patches) {
patchCount = 0;
Path parentSourceDir = patch.dir;
if (Files.isDirectory(parentSourceDir)) {
try {
List<Path> moduleSourceDirs = Files.list(parentSourceDir)
.filter(moduleSourceDir -> Files.isDirectory(moduleSourceDir))
.collect(Collectors.toList());
for (Path moduleSourceDir : moduleSourceDirs) {
addPatchForModuleSourceDir(moduleSourceDir, moduleNames, patch);
}
} catch (IOException e) {
warn("failed to list entries of " + parentSourceDir);
}
if (patchCount == 0 && parentSourceDir.getFileName() != null) {
// Maybe patch points to single-module project directory, so let's see
addPatchForModuleSourceDir(parentSourceDir, moduleNames, patch);
}
}
if (patchCount == 0) {
warn("no module patches found for pattern " + patch);
} else {
info(patchCount + " module patch(es) found for pattern " + patch);
}
}
}
private boolean addPatchForModuleSourceDir(Path moduleSourceDir, List<String> moduleNames, Patch patch) {
String moduleSourceName = moduleSourceDir.getFileName().toString();
if (!moduleSourceName.startsWith(".")) {
//info("checking '" + moduleSourceDir + "'");
Path modulePatchDir = moduleSourceDir.resolve(patch.subPath);
if (Files.isDirectory(modulePatchDir)) {
//info("checking if artifact '" + artifactName + "' has output directory " + classesDir);
for (String moduleName : moduleNames) {
if (moduleName.endsWith(moduleSourceName)) {
addPatch(moduleName, modulePatchDir);
return true;
}
}
for (String moduleName : moduleNames) {
if (moduleName.contains(moduleSourceName)) {
addPatch(moduleName, modulePatchDir);
return true;
}
}
}
}
return false;
}
private void addPatch(String moduleName, Path classesDir) {
String propertyName = "netbeans.patches." + moduleName.replace("-", ".");
setSystemPropertyIfNotSet(propertyName, classesDir.toString());
patchCount++;
}
private List<String> parseOptions(String defaultOptions) {
LinkedList<String> defaultOptionList = new LinkedList<>();
StreamTokenizer st = new StreamTokenizer(new StringReader(defaultOptions));
st.resetSyntax();
st.wordChars(' ' + 1, 255);
st.whitespaceChars(0, ' ');
st.quoteChar('"');
st.quoteChar('\'');
boolean firstArgQuoted;
try {
int tt = st.nextToken();
firstArgQuoted = tt == '\'' || tt == '"';
if (tt != StreamTokenizer.TT_EOF) {
do {
if (st.sval != null) {
defaultOptionList.add(st.sval);
}
tt = st.nextToken();
} while (tt != StreamTokenizer.TT_EOF);
}
} catch (IOException e) {
throw new IllegalStateException(e);
}
if (defaultOptionList.size() == 1 && firstArgQuoted) {
return parseOptions(defaultOptionList.get(0));
}
return defaultOptionList;
}
private static void runMain(List<URL> classPathList, List<String> argList) {
URLClassLoader classLoader = new URLClassLoader(classPathList.toArray(new URL[classPathList.size()]));
try {
Class<?> nbMainClass = classLoader.loadClass("org.netbeans.Main");
Method nbMainMethod = nbMainClass.getDeclaredMethod("main", String[].class);
nbMainMethod.invoke(null, (Object) argList.toArray(new String[argList.size()]));
} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
throw new IllegalStateException(e);
}
}
private void buildClasspath(String base, List<URL> classPathList) {
appendToClasspath(path(base, "lib", "patches"), classPathList);
appendToClasspath(path(base, "lib"), classPathList);
appendToClasspath(path(base, "locale", "locale"), classPathList);
}
private void appendToClasspath(String path, List<URL> classPathList) {
try {
Files.list(Paths.get(path)).forEach(file -> {
if (Files.isDirectory(file)) {
appendToClasspath(file.toString(), classPathList);
} else if (Files.isRegularFile(file)) {
String s = file.getFileName().toString().toLowerCase();
if (s.endsWith(".jar") || s.endsWith(".zip")) {
try {
URL url = file.toUri().toURL();
classPathList.add(url);
info("added to application classpath: " + file);
} catch (MalformedURLException e) {
throw new IllegalStateException(e);
}
}
}
});
} catch (IOException e) {
warn("failed to list entries of " + path);
}
}
private void setConfigurationVariableIfNotSet(String varName, String varValue) {
if (!configuration.containsKey(varName)) {
configuration.put(varName, varValue);
}
}
private void setSystemPropertyIfNotSet(String name, String value) {
String oldValue = System.getProperty(name);
if (oldValue == null) {
info("setting system property: " + name + " = " + value);
System.setProperty(name, value);
} else {
warn("not overriding existing system property: " + name + " = " + oldValue + "(new value: " + value + ")");
}
}
private static String toPathsString(List<String> paths) {
StringBuilder sb = new StringBuilder();
for (String path : paths) {
if (sb.length() > 0) {
sb.append(File.pathSeparatorChar);
}
sb.append(path);
}
return sb.toString();
}
private static List<String> toAbsolutePaths(List<String> paths) {
return paths.stream()
.map(path -> Paths.get(path).toAbsolutePath().toString())
.collect(Collectors.toList());
}
private static List<String> readLines(Path path) {
try {
return Files.readAllLines(path);
} catch (IOException e) {
return Collections.emptyList();
}
}
private String parseArg(List<String> argList, String name) {
String value = null;
int i = argList.indexOf(name);
if (i >= 0 && i + 1 < argList.size()) {
value = argList.get(i + 1);
argList.remove(i);
argList.remove(i);
}
return value;
}
private String getVar(String name) {
String value = configuration.get(name);
if (value != null) {
return resolveString(value, configuration);
}
return null;
}
private void loadConf(Path path) {
info("reading configuration from " + path);
try {
Properties properties = new Properties();
try (Reader reader = Files.newBufferedReader(path)) {
properties.load(reader);
}
Set<String> propertyNames = properties.stringPropertyNames();
for (String propertyName : propertyNames) {
String propertyValue = properties.getProperty(propertyName);
if (propertyValue.startsWith("\"") && propertyValue.endsWith("\"")) {
propertyValue = propertyValue.substring(1, propertyValue.length() - 1);
}
configuration.put(propertyName, propertyValue);
}
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
private void info(String msg) {
System.out.printf("INFO: %s: %s%n", getClass(), msg);
}
private void warn(String msg) {
System.err.printf("WARNING: %s: %s%n", getClass(), msg);
}
private static String resolveString(String text, Map<String, String> variables) {
for (Map.Entry<String, String> entry : variables.entrySet()) {
text = text.replace("$" + entry.getKey(), entry.getValue());
text = text.replace("${" + entry.getKey() + "}", entry.getValue());
}
return text;
}
private static String path(String first, String... more) {
return Paths.get(first, more).toString();
}
public static class Patch {
public static final char WILDCARD_CHAR = '$';
private final Path dir;
private final String subPath;
public static Patch parse(String pattern) {
int wcPos = pattern.indexOf(WILDCARD_CHAR);
if (wcPos >= 0) {
String subPath = pattern.substring(wcPos + 1);
if (subPath.startsWith(File.separator) || subPath.startsWith("/")) {
subPath = subPath.substring(1);
}
if (subPath.indexOf(WILDCARD_CHAR) > 0) {
throw new IllegalArgumentException(String.format("patch pattern must contain a single wildcard '%s': %s",
WILDCARD_CHAR, pattern));
}
return new Patch(Paths.get(pattern.substring(0, wcPos)).toAbsolutePath().normalize(), subPath);
} else {
throw new IllegalArgumentException(String.format("patch pattern must contain wildcard '%s': %s",
WILDCARD_CHAR, pattern));
}
}
private Patch(Path dir, String subPath) {
this.dir = dir;
this.subPath = subPath;
}
public Path getDir() {
return dir;
}
public String getSubPath() {
return subPath;
}
@Override
public String toString() {
if (subPath.isEmpty()) {
return dir + File.separator + WILDCARD_CHAR;
}
return dir + File.separator + WILDCARD_CHAR + File.separator + subPath;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Patch patch = (Patch) o;
return dir.equals(patch.dir) && subPath.equals(patch.subPath);
}
@Override
public int hashCode() {
int result = dir.hashCode();
result = 31 * result + subPath.hashCode();
return result;
}
}
}