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
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Collectors;
@@ -78,6 +79,22 @@ public class FileUtils {
}
}
public static List<Path> listFiles(Path dir) {
try (Stream<Path> files = Files.list(dir)) {
return files.collect(Collectors.toList());
} catch (IOException e) {
throw new JadxRuntimeException("Failed to list files in directory: " + dir, e);
}
}
public static List<Path> listFiles(Path dir, Predicate<? super Path> filter) {
try (Stream<Path> files = Files.list(dir)) {
return files.filter(filter).collect(Collectors.toList());
} catch (IOException e) {
throw new JadxRuntimeException("Failed to list files in directory: " + dir, e);
}
}
public static List<Path> expandDirs(List<Path> paths) {
List<Path> files = new ArrayList<>(paths.size());
for (Path path : paths) {
@@ -148,6 +165,10 @@ public class FileUtils {
return true;
}
public static void deleteDir(Path dir) {
deleteDir(dir, false);
}
public static void deleteDirIfExists(Path dir) {
if (Files.exists(dir)) {
try {
@@ -158,10 +179,6 @@ public class FileUtils {
}
}
private static void deleteDir(Path dir) {
deleteDir(dir, false);
}
private static void deleteDir(Path dir, boolean keepRootDir) {
try {
List<Path> files = new ArrayList<>();
@@ -424,6 +441,11 @@ public class FileUtils {
return fileName.substring(0, extEndIndex);
}
public static boolean hasExtension(Path path, String extension) {
String fileName = path.getFileName().toString();
return fileName.toLowerCase().endsWith(extension);
}
public static File toFile(String path) {
if (path == null) {
return null;
@@ -3,7 +3,6 @@ package jadx.gui.settings.ui.plugins;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import javax.swing.BorderFactory;
@@ -61,7 +60,7 @@ public class InstallPluginDialog extends JDialog {
locationPanel.add(locationFld);
JButton fileBtn = new JButton(NLS.str("preferences.plugins.plugin_jar"));
fileBtn.addActionListener(ev -> openPluginJar());
fileBtn.addActionListener(ev -> openPluginFile());
JLabel fileLbl = new JLabel(NLS.str("preferences.plugins.plugin_jar_label"));
fileLbl.setLabelFor(fileBtn);
@@ -105,10 +104,10 @@ public class InstallPluginDialog extends JDialog {
UiUtils.addEscapeShortCutToDispose(this);
}
private void openPluginJar() {
private void openPluginFile() {
FileDialogWrapper fd = new FileDialogWrapper(mainWindow, FileOpenMode.CUSTOM_OPEN);
fd.setTitle(NLS.str("preferences.plugins.plugin_jar"));
fd.setFileExtList(Collections.singletonList("jar"));
fd.setFileExtList(List.of("jar", "zip"));
fd.setSelectionMode(JFileChooser.FILES_ONLY);
List<Path> files = fd.show();
if (files.size() == 1) {
+1
View File
@@ -9,6 +9,7 @@ dependencies {
implementation(project(":jadx-commons:jadx-app-commons"))
implementation("com.google.code.gson:gson:2.13.2")
implementation("commons-io:commons-io:2.21.0")
testImplementation("com.squareup.okhttp3:mockwebserver3:5.3.0")
}
@@ -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) {