feat(plugins): allow to use .zip as plugin artifact (with jars inside)

This commit is contained in:
Skylot
2026-02-12 17:18:07 +00:00
parent c7a0f7a092
commit 2a2806ebd7
8 changed files with 184 additions and 82 deletions
@@ -1,8 +1,9 @@
package jadx.plugins.tools;
import java.io.File;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
@@ -19,6 +20,7 @@ import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.loader.JadxPluginLoader;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
public class JadxExternalPluginsLoader implements JadxPluginLoader {
private static final Logger LOG = LoggerFactory.getLogger(JadxExternalPluginsLoader.class);
@@ -44,16 +46,16 @@ public class JadxExternalPluginsLoader implements JadxPluginLoader {
return list;
}
public JadxPlugin loadFromJar(Path jar) {
public JadxPlugin loadFromPath(Path pluginPath) {
Map<String, JadxPlugin> map = new HashMap<>();
loadFromJar(map, jar);
loadFromPath(map, pluginPath);
int loaded = map.size();
if (loaded == 0) {
throw new JadxRuntimeException("No plugin found in jar: " + jar);
throw new JadxRuntimeException("No plugin found in jar: " + pluginPath);
}
if (loaded > 1) {
String plugins = map.values().stream().map(p -> p.getPluginInfo().getPluginId()).collect(Collectors.joining(", "));
throw new JadxRuntimeException("Expect only one plugin per jar: " + jar + ", but found: " + loaded + " - " + plugins);
throw new JadxRuntimeException("Expect only one plugin per jar: " + pluginPath + ", but found: " + loaded + " - " + plugins);
}
return Utils.first(map.values());
@@ -72,22 +74,46 @@ public class JadxExternalPluginsLoader implements JadxPluginLoader {
}
private void loadInstalledPlugins(Map<String, JadxPlugin> map) {
List<Path> jars = JadxPluginsTools.getInstance().getEnabledPluginJars();
for (Path jar : jars) {
loadFromJar(map, jar);
List<Path> paths = JadxPluginsTools.getInstance().getEnabledPluginPaths();
for (Path pluginPath : paths) {
loadFromPath(map, pluginPath);
}
}
private void loadFromJar(Map<String, JadxPlugin> map, Path jar) {
private void loadFromPath(Map<String, JadxPlugin> map, Path pluginPath) {
try {
File jarFile = jar.toFile();
String clsLoaderName = JADX_PLUGIN_CLASSLOADER_PREFIX + jarFile.getName();
URL[] urls = new URL[] { jarFile.toURI().toURL() };
URL[] urls;
if (Files.isDirectory(pluginPath)) {
urls = FileUtils.listFiles(pluginPath, file -> FileUtils.hasExtension(file, ".jar"))
.stream()
.map(JadxExternalPluginsLoader::toURL)
.toArray(URL[]::new);
if (urls.length == 0) {
throw new JadxRuntimeException("No jar files found in plugin directory");
}
} else if (Files.isRegularFile(pluginPath)) {
if (FileUtils.hasExtension(pluginPath, ".jar")) {
urls = new URL[] { toURL(pluginPath) };
} else {
throw new JadxRuntimeException("Unexpected plugin file format");
}
} else {
throw new JadxRuntimeException("Plugin file not found");
}
String clsLoaderName = JADX_PLUGIN_CLASSLOADER_PREFIX + pluginPath.getFileName();
URLClassLoader pluginClsLoader = new URLClassLoader(clsLoaderName, urls, thisClassLoader());
classLoaders.add(pluginClsLoader);
loadFromClsLoader(map, pluginClsLoader);
} catch (Exception e) {
throw new JadxRuntimeException("Failed to load plugins from jar: " + jar, e);
throw new JadxRuntimeException("Failed to load plugins from: " + pluginPath, e);
}
}
private static URL toURL(Path pluginPath) {
try {
return pluginPath.toUri().toURL();
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
@@ -15,7 +15,6 @@ import java.util.Objects;
import java.util.Optional;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.jetbrains.annotations.Nullable;
import org.slf4j.Logger;
@@ -23,17 +22,22 @@ import org.slf4j.LoggerFactory;
import jadx.api.plugins.JadxPlugin;
import jadx.api.plugins.JadxPluginInfo;
import jadx.api.plugins.utils.CommonFileUtils;
import jadx.core.Jadx;
import jadx.core.plugins.versions.VerifyRequiredVersion;
import jadx.core.utils.StringUtils;
import jadx.core.utils.Utils;
import jadx.core.utils.exceptions.JadxRuntimeException;
import jadx.core.utils.files.FileUtils;
import jadx.plugins.tools.data.JadxInstalledPlugins;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.data.JadxPluginUpdate;
import jadx.plugins.tools.resolvers.IJadxPluginResolver;
import jadx.plugins.tools.resolvers.ResolversRegistry;
import jadx.plugins.tools.utils.PluginUtils;
import jadx.zip.IZipEntry;
import jadx.zip.ZipContent;
import jadx.zip.ZipReader;
import static jadx.core.utils.GsonUtils.buildGson;
import static jadx.plugins.tools.utils.PluginFiles.DROPINS_DIR;
@@ -165,7 +169,7 @@ public class JadxPluginsTools {
return false;
}
JadxPluginMetadata plugin = found.get();
deletePluginJar(plugin);
deletePlugin(plugin);
plugins.getInstalled().remove(plugin);
savePluginsJson(plugins);
return true;
@@ -189,24 +193,15 @@ public class JadxPluginsTools {
}
}
public List<Path> getAllPluginJars() {
List<Path> list = new ArrayList<>();
for (JadxPluginMetadata pluginMetadata : loadPluginsJson().getInstalled()) {
list.add(INSTALLED_DIR.resolve(pluginMetadata.getJar()));
}
collectJarsFromDir(list, DROPINS_DIR);
return list;
}
public List<Path> getEnabledPluginJars() {
public List<Path> getEnabledPluginPaths() {
List<Path> list = new ArrayList<>();
for (JadxPluginMetadata pluginMetadata : loadPluginsJson().getInstalled()) {
if (pluginMetadata.isDisabled()) {
continue;
}
list.add(INSTALLED_DIR.resolve(pluginMetadata.getJar()));
list.add(INSTALLED_DIR.resolve(pluginMetadata.getPath()));
}
collectJarsFromDir(list, DROPINS_DIR);
list.addAll(FileUtils.listFiles(DROPINS_DIR));
return list;
}
@@ -254,22 +249,31 @@ public class JadxPluginsTools {
throw new JadxRuntimeException("Can't install plugin, required version: \"" + reqVersionStr + '\"'
+ " is not compatible with current jadx version: " + Jadx.getVersion());
}
// remove previous version
uninstall(metadata.getPluginId());
String version = metadata.getVersion();
String fileName = metadata.getPluginId() + (StringUtils.notBlank(version) ? '-' + version : "") + ".jar";
Path pluginJar = INSTALLED_DIR.resolve(fileName);
copyJar(Paths.get(metadata.getJar()), pluginJar);
metadata.setJar(INSTALLED_DIR.relativize(pluginJar).toString());
JadxInstalledPlugins plugins = loadPluginsJson();
// remove previous version jar
plugins.getInstalled().removeIf(p -> {
if (p.getPluginId().equals(metadata.getPluginId())) {
deletePluginJar(p);
return true;
String pluginBaseName = metadata.getPluginId() + (StringUtils.notBlank(version) ? '-' + version : "");
String pluginPathStr = metadata.getPath();
Path pluginPath = Paths.get(pluginPathStr);
if (pluginPathStr.endsWith(".jar")) {
Path pluginJar = INSTALLED_DIR.resolve(pluginBaseName + ".jar");
copyJar(pluginPath, pluginJar);
metadata.setPath(INSTALLED_DIR.relativize(pluginJar).toString());
} else if (Files.isDirectory(pluginPath)) {
Path pluginDir = INSTALLED_DIR.resolve(pluginBaseName);
try {
FileUtils.deleteDirIfExists(pluginDir);
org.apache.commons.io.FileUtils.moveDirectory(pluginPath.toFile(), pluginDir.toFile());
} catch (IOException e) {
throw new JadxRuntimeException("Failed to install plugin: " + pluginBaseName, e);
}
return false;
});
metadata.setPath(INSTALLED_DIR.relativize(pluginDir).toString());
} else {
throw new JadxRuntimeException("Unexpected plugin path type: " + pluginPathStr);
}
// update plugins json
JadxInstalledPlugins plugins = loadPluginsJson();
plugins.getInstalled().add(metadata);
plugins.setUpdated(System.currentTimeMillis());
savePluginsJson(plugins);
@@ -277,23 +281,30 @@ public class JadxPluginsTools {
private void fillMetadata(JadxPluginMetadata metadata) {
try {
Path tmpJar;
if (needDownload(metadata.getJar())) {
tmpJar = Files.createTempFile(metadata.getName(), "plugin.jar");
PluginUtils.downloadFile(metadata.getJar(), tmpJar);
metadata.setJar(tmpJar.toAbsolutePath().toString());
} else {
tmpJar = Paths.get(metadata.getJar());
String pluginPath = metadata.getPath();
if (needDownload(pluginPath)) {
// download plugin
String ext = CommonFileUtils.getFileExtension(pluginPath);
Path tmpJar = Files.createTempFile(metadata.getName(), "plugin." + ext);
PluginUtils.downloadFile(pluginPath, tmpJar);
pluginPath = tmpJar.toAbsolutePath().toString();
}
fillMetadataFromJar(metadata, tmpJar);
if (pluginPath.endsWith(".zip")) {
// unpack plugin zip
Path tmpDir = Files.createTempDirectory(metadata.getName());
unzip(Paths.get(pluginPath), tmpDir);
pluginPath = tmpDir.toAbsolutePath().toString();
}
metadata.setPath(pluginPath);
fillMetadataFromPath(metadata, Paths.get(pluginPath));
} catch (Exception e) {
throw new RuntimeException("Failed to fill plugin metadata, plugin: " + metadata.getPluginId(), e);
}
}
private void fillMetadataFromJar(JadxPluginMetadata metadata, Path jar) {
private void fillMetadataFromPath(JadxPluginMetadata metadata, Path pluginPath) {
try (JadxExternalPluginsLoader loader = new JadxExternalPluginsLoader()) {
JadxPlugin jadxPlugin = loader.loadFromJar(jar);
JadxPlugin jadxPlugin = loader.loadFromPath(pluginPath);
JadxPluginInfo pluginInfo = jadxPlugin.getPluginInfo();
metadata.setPluginId(pluginInfo.getPluginId());
metadata.setName(pluginInfo.getName());
@@ -317,9 +328,14 @@ public class JadxPluginsTools {
}
}
private void deletePluginJar(JadxPluginMetadata plugin) {
private void deletePlugin(JadxPluginMetadata plugin) {
try {
Files.deleteIfExists(INSTALLED_DIR.resolve(plugin.getJar()));
Path pluginPath = INSTALLED_DIR.resolve(plugin.getPath());
if (Files.isDirectory(pluginPath)) {
FileUtils.deleteDir(pluginPath);
} else {
Files.deleteIfExists(pluginPath);
}
} catch (IOException e) {
// ignore
}
@@ -363,11 +379,15 @@ public class JadxPluginsTools {
}
}
private static void collectJarsFromDir(List<Path> list, Path dir) {
try (Stream<Path> files = Files.list(dir)) {
files.filter(p -> p.getFileName().toString().endsWith(".jar")).forEach(list::add);
private static void unzip(Path zipFile, Path outDir) {
ZipReader zipReader = new ZipReader(); // TODO: pass zip options from jadx args
try (ZipContent content = zipReader.open(zipFile.toFile())) {
for (IZipEntry entry : content.getEntries()) {
Path entryFile = outDir.resolve(entry.getName());
Files.copy(entry.getInputStream(), entryFile, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
throw new RuntimeException(e);
throw new JadxRuntimeException("Failed to unzip file: " + zipFile, e);
}
}
}
@@ -3,6 +3,8 @@ package jadx.plugins.tools.data;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import com.google.gson.annotations.SerializedName;
public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
private String pluginId;
private String name;
@@ -11,8 +13,15 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
private @Nullable String requiredJadxVersion;
private @Nullable String version;
private String locationId;
private String jar;
/**
* Absolute path to '.jar' file or unpacked zip directory
*/
@SerializedName(value = "path", alternate = { "jar" })
private String path;
private boolean disabled;
public String getPluginId() {
@@ -71,12 +80,22 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
this.locationId = locationId;
}
public String getJar() {
return jar;
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
@Deprecated
public String getJar() {
return path;
}
@Deprecated
public void setJar(String jar) {
this.jar = jar;
this.path = jar;
}
public boolean isDisabled() {
@@ -115,7 +134,7 @@ public class JadxPluginMetadata implements Comparable<JadxPluginMetadata> {
+ ", name=" + name
+ ", version=" + (version != null ? version : "?")
+ ", locationId=" + locationId
+ ", jar=" + jar
+ ", path=" + path
+ '}';
}
}
@@ -20,18 +20,23 @@ public class LocalFileResolver implements IJadxPluginResolver {
return false;
}
private static boolean isValidFileLocation(String locationId) {
return locationId.startsWith("file:")
&& (locationId.endsWith(".jar") || locationId.endsWith(".zip"));
}
@Override
public Optional<JadxPluginMetadata> resolve(String locationId) {
if (!locationId.startsWith("file:") || !locationId.endsWith(".jar")) {
if (!isValidFileLocation(locationId)) {
return Optional.empty();
}
File jarFile = new File(removePrefix(locationId, "file:"));
if (!jarFile.isFile()) {
throw new RuntimeException("File not found: " + jarFile.getAbsolutePath());
File pluginFile = new File(removePrefix(locationId, "file:"));
if (!pluginFile.isFile()) {
throw new RuntimeException("File not found: " + pluginFile.getAbsolutePath());
}
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setLocationId(locationId);
metadata.setJar(jarFile.getAbsolutePath());
metadata.setPath(pluginFile.getAbsolutePath());
return Optional.of(metadata);
}
@@ -5,6 +5,8 @@ import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import org.jetbrains.annotations.Nullable;
import jadx.core.utils.ListUtils;
import jadx.plugins.tools.data.JadxPluginMetadata;
import jadx.plugins.tools.resolvers.IJadxPluginResolver;
@@ -58,7 +60,7 @@ public class GithubReleaseResolver implements IJadxPluginResolver {
JadxPluginMetadata metadata = new JadxPluginMetadata();
metadata.setVersion(releaseVersion);
metadata.setLocationId(buildLocationIdWithoutVersion(info)); // exclude version for later updates
metadata.setJar(asset.getDownloadUrl());
metadata.setPath(asset.getDownloadUrl());
return metadata;
}
@@ -89,20 +91,28 @@ public class GithubReleaseResolver implements IJadxPluginResolver {
}
private static Asset searchPluginAsset(List<Asset> assets, String artifactPrefix, String releaseVersion) {
String artifactName = artifactPrefix + '-' + releaseVersion + ".jar";
Asset assetJar = searchAssetWithExt(assets, artifactPrefix, releaseVersion, ".jar");
if (assetJar != null) {
return assetJar;
}
Asset assetZip = searchAssetWithExt(assets, artifactPrefix, releaseVersion, ".zip");
if (assetZip != null) {
return assetZip;
}
throw new RuntimeException("Release artifact with prefix '" + artifactPrefix + "' not found");
}
private static @Nullable Asset searchAssetWithExt(List<Asset> assets, String artifactPrefix, String releaseVersion, String ext) {
String artifactName = artifactPrefix + '-' + releaseVersion + ext;
Asset exactAsset = ListUtils.filterOnlyOne(assets, a -> a.getName().equals(artifactName));
if (exactAsset != null) {
return exactAsset;
}
// search without version filter
Asset foundAsset = ListUtils.filterOnlyOne(assets, a -> {
return ListUtils.filterOnlyOne(assets, a -> {
String assetFileName = a.getName();
return assetFileName.startsWith(artifactPrefix) && assetFileName.endsWith(".jar");
return assetFileName.startsWith(artifactPrefix) && assetFileName.endsWith(ext);
});
if (foundAsset != null) {
return foundAsset;
}
throw new RuntimeException("Release artifact with prefix '" + artifactPrefix + "' not found");
}
private static String buildLocationIdWithoutVersion(LocationInfo info) {