From 2a2806ebd74cfa9b77030ee0e32ee379eeb1d17e Mon Sep 17 00:00:00 2001 From: Skylot <118523+skylot@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:18:07 +0000 Subject: [PATCH] feat(plugins): allow to use `.zip` as plugin artifact (with jars inside) --- .../java/jadx/core/utils/files/FileUtils.java | 30 ++++- .../ui/plugins/InstallPluginDialog.java | 7 +- jadx-plugins-tools/build.gradle.kts | 1 + .../tools/JadxExternalPluginsLoader.java | 52 ++++++--- .../jadx/plugins/tools/JadxPluginsTools.java | 106 +++++++++++------- .../tools/data/JadxPluginMetadata.java | 29 ++++- .../resolvers/file/LocalFileResolver.java | 15 ++- .../github/GithubReleaseResolver.java | 26 +++-- 8 files changed, 184 insertions(+), 82 deletions(-) diff --git a/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java b/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java index 51a0f1541..85940ff50 100644 --- a/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java +++ b/jadx-core/src/main/java/jadx/core/utils/files/FileUtils.java @@ -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 listFiles(Path dir) { + try (Stream 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 listFiles(Path dir, Predicate filter) { + try (Stream 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 expandDirs(List paths) { List 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 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; diff --git a/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/InstallPluginDialog.java b/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/InstallPluginDialog.java index eed865b11..6895b4d4b 100644 --- a/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/InstallPluginDialog.java +++ b/jadx-gui/src/main/java/jadx/gui/settings/ui/plugins/InstallPluginDialog.java @@ -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 files = fd.show(); if (files.size() == 1) { diff --git a/jadx-plugins-tools/build.gradle.kts b/jadx-plugins-tools/build.gradle.kts index b10779b86..d0fd158d1 100644 --- a/jadx-plugins-tools/build.gradle.kts +++ b/jadx-plugins-tools/build.gradle.kts @@ -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") } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxExternalPluginsLoader.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxExternalPluginsLoader.java index 60658cbeb..9262fa08a 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxExternalPluginsLoader.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxExternalPluginsLoader.java @@ -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 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 map) { - List jars = JadxPluginsTools.getInstance().getEnabledPluginJars(); - for (Path jar : jars) { - loadFromJar(map, jar); + List paths = JadxPluginsTools.getInstance().getEnabledPluginPaths(); + for (Path pluginPath : paths) { + loadFromPath(map, pluginPath); } } - private void loadFromJar(Map map, Path jar) { + private void loadFromPath(Map 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); } } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java index f3d5d7973..0181dedf0 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/JadxPluginsTools.java @@ -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 getAllPluginJars() { - List list = new ArrayList<>(); - for (JadxPluginMetadata pluginMetadata : loadPluginsJson().getInstalled()) { - list.add(INSTALLED_DIR.resolve(pluginMetadata.getJar())); - } - collectJarsFromDir(list, DROPINS_DIR); - return list; - } - - public List getEnabledPluginJars() { + public List getEnabledPluginPaths() { List 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 list, Path dir) { - try (Stream 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); } } } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/data/JadxPluginMetadata.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/data/JadxPluginMetadata.java index 1965a4347..04417d2bb 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/data/JadxPluginMetadata.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/data/JadxPluginMetadata.java @@ -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 { private String pluginId; private String name; @@ -11,8 +13,15 @@ public class JadxPluginMetadata implements Comparable { 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 { 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 { + ", name=" + name + ", version=" + (version != null ? version : "?") + ", locationId=" + locationId - + ", jar=" + jar + + ", path=" + path + '}'; } } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/file/LocalFileResolver.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/file/LocalFileResolver.java index dee2c6127..26cb4767f 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/file/LocalFileResolver.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/file/LocalFileResolver.java @@ -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 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); } diff --git a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubReleaseResolver.java b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubReleaseResolver.java index 558a74270..5ff0e521a 100644 --- a/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubReleaseResolver.java +++ b/jadx-plugins-tools/src/main/java/jadx/plugins/tools/resolvers/github/GithubReleaseResolver.java @@ -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 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 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) {