From 3de1d75f147b601f439c327937237336ca8df402 Mon Sep 17 00:00:00 2001 From: Rohit Kushwaha Date: Tue, 23 Jun 2026 13:57:34 +0530 Subject: [PATCH] feat: implement native unzip for plugin installation performance improvement --- src/lib/installPlugin.js | 93 ++++++++-------- .../android/com/foxdebug/system/System.java | 100 ++++++++++++++++++ src/plugins/system/system.d.ts | 13 +++ src/plugins/system/www/plugin.js | 3 + 4 files changed, 159 insertions(+), 50 deletions(-) diff --git a/src/lib/installPlugin.js b/src/lib/installPlugin.js index b7de3508d..8dcac6f2d 100644 --- a/src/lib/installPlugin.js +++ b/src/lib/installPlugin.js @@ -38,6 +38,7 @@ export default async function installPlugin( let pluginDir; let pluginUrl; let state; + let tempZipFile; try { if (!(await fsOperation(PLUGIN_DIR).exists())) { @@ -174,65 +175,48 @@ export default async function installPlugin( // Track unsafe absolute entries to skip const ignoredUnsafeEntries = new Set(); - const files = Object.keys(zip.files); - const limit = 2; - - async function processFile(file) { - try { - const entry = zip.files[file]; - - let correctFile = file.replace(/\\/g, "/"); - const isDirEntry = entry.dir || correctFile.endsWith("/"); - - if (isUnsafeAbsolutePath(file)) { - ignoredUnsafeEntries.add(file); - return; - } - - correctFile = sanitizeZipPath(correctFile, isDirEntry); - if (!correctFile) return; - - const fileUrl = Url.join(pluginDir, correctFile); - // Handle directory entries - if (isDirEntry) { - await createFileRecursive(pluginDir, correctFile, true); - return; - } - - // Ensure parent directory exists - const lastSlash = correctFile.lastIndexOf("/"); - if (lastSlash !== -1) { - const parentRel = correctFile.slice(0, lastSlash + 1); - await createFileRecursive(pluginDir, parentRel, true); - } - - if (!state.exists(correctFile)) { - await createFileRecursive(pluginDir, correctFile, false); - } + const tempZipName = `${id.replace(/[^a-zA-Z0-9]/g, "_")}.zip`; + tempZipFile = Url.join(CACHE_STORAGE, tempZipName); + if (await fsOperation(tempZipFile).exists()) { + await fsOperation(tempZipFile).delete(); + } + await fsOperation(CACHE_STORAGE).createFile(tempZipName, plugin); - let data = await entry.async("ArrayBuffer"); + // Natively unzip to plugin directory + await new Promise((resolve, reject) => { + system.unzip(tempZipFile, pluginDir, resolve, reject); + }); - if (file === "plugin.json") { - data = JSON.stringify(pluginJson); - } + // Overwrite the original plugin.json inside pluginDir with the patched pluginJson + const pluginJsonFile = Url.join(pluginDir, "plugin.json"); + if (await fsOperation(pluginJsonFile).exists()) { + await fsOperation(pluginJsonFile).writeFile(JSON.stringify(pluginJson)); + } else { + await fsOperation(pluginDir).createFile( + "plugin.json", + JSON.stringify(pluginJson), + ); + } - if (!(await state.isUpdated(correctFile, data))) return; + // Populate install state (updatedStore) and track skipped unsafe entries + for (const file of files) { + const entry = zip.files[file]; + let correctFile = file.replace(/\\/g, "/"); + const isDirEntry = entry.dir || correctFile.endsWith("/"); - await fsOperation(fileUrl).writeFile(data); - } catch (error) { - console.error(`Error processing file ${file}:`, error); + if (isUnsafeAbsolutePath(file)) { + ignoredUnsafeEntries.add(file); + continue; } - } - // Process in batches - for (let i = 0; i < files.length; i += limit) { - const batch = files.slice(i, i + limit); - await Promise.allSettled(batch.map(processFile)); + correctFile = sanitizeZipPath(correctFile, isDirEntry); + if (!correctFile) continue; - // Allow UI thread to breathe - await new Promise((r) => setTimeout(r, 0)); + if (!isDirEntry) { + state.updatedStore[correctFile.toLowerCase()] = "1"; + } } // Emit a non-blocking warning if any unsafe entries were skipped if (!isDependency && ignoredUnsafeEntries.size) { @@ -276,6 +260,15 @@ export default async function installPlugin( } throw err; } finally { + if (tempZipFile) { + try { + if (await fsOperation(tempZipFile).exists()) { + await fsOperation(tempZipFile).delete(); + } + } catch (e) { + console.error("Failed to delete tempZipFile:", e); + } + } if (!isDependency) { loaderDialog.destroy(); } diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 672a19e4d..d8e1390d7 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -6,6 +6,9 @@ import java.nio.file.Paths; import java.nio.file.StandardOpenOption; import java.io.IOException; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipEntry; +import java.io.BufferedOutputStream; import android.app.Activity; import android.app.PendingIntent; import android.content.ClipData; @@ -214,6 +217,7 @@ public boolean execute( case "compare-file-text": case "compare-texts": case "extractAsset": + case "unzip": case "pin-file-shortcut": break; case "get-configuration": @@ -442,6 +446,16 @@ public void run() { } return; + + case "unzip": + try { + String zipPath = args.getString(0); + String destPath = args.getString(1); + unzip(zipPath, destPath, callbackContext); + } catch (Exception e) { + callbackContext.error("Failed to unzip: " + e.getMessage()); + } + return; case "getInstaller": try { @@ -2184,4 +2198,90 @@ private void extractAsset(String assetName, String destinationPath, CallbackCont callback.error(sw.toString()); } } + + private void unzip(String zipPath, String destPath, CallbackContext callback) { + try { + Uri zipUri = Uri.parse(zipPath); + File destDir = null; + Uri destUri = Uri.parse(destPath); + if ("file".equalsIgnoreCase(destUri.getScheme())) { + destDir = new File(destUri.getPath()); + } else { + destDir = new File(destPath); + } + + if (!destDir.exists()) { + destDir.mkdirs(); + } + + String canonicalDestDirPath = destDir.getCanonicalPath(); + + InputStream is = null; + if ("file".equalsIgnoreCase(zipUri.getScheme())) { + is = new FileInputStream(new File(zipUri.getPath())); + } else if (zipUri.getScheme() != null) { + is = context.getContentResolver().openInputStream(zipUri); + } else { + is = new FileInputStream(new File(zipPath)); + } + + if (is == null) { + callback.error("Could not open input stream for zip file"); + return; + } + + try (ZipInputStream zis = new ZipInputStream(is)) { + ZipEntry entry; + byte[] buffer = new byte[8192]; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + + // Normalize separator + name = name.replace('\\', '/'); + + // Skip unsafe absolute paths + if (name.startsWith("/") || name.contains("://") || name.startsWith("\\\\") || (name.length() > 1 && name.charAt(1) == ':')) { + // Unsafe, skip it + zis.closeEntry(); + continue; + } + + // Resolve relative paths + File file = new File(destDir, name); + String canonicalFilePath = file.getCanonicalPath(); + + // Prevent Zip Slip + if (!canonicalFilePath.startsWith(canonicalDestDirPath + File.separator) && !canonicalFilePath.equals(canonicalDestDirPath)) { + zis.closeEntry(); + continue; + } + + if (entry.isDirectory()) { + file.mkdirs(); + } else { + // Ensure parent directory exists + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) { + parent.mkdirs(); + } + + try (FileOutputStream fos = new FileOutputStream(file); + BufferedOutputStream bos = new BufferedOutputStream(fos, buffer.length)) { + int len; + while ((len = zis.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + bos.flush(); + } + } + zis.closeEntry(); + } + } + callback.success(); + } catch (Exception e) { + StringWriter sw = new StringWriter(); + e.printStackTrace(new PrintWriter(sw)); + callback.error(sw.toString()); + } + } } diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts index aa2ef8bf7..4ca1e1f9e 100644 --- a/src/plugins/system/system.d.ts +++ b/src/plugins/system/system.d.ts @@ -295,6 +295,19 @@ interface System { onSuccess?: () => void, onFail?: OnFail, ): void; + /** + * Unzip a zip archive natively + * @param zipFile Path or URI to the zip file + * @param destDir Target directory path to extract contents + * @param onSuccess Success callback + * @param onFail Error callback + */ + unzip( + zipFile: string, + destDir: string, + onSuccess: () => void, + onFail: OnFail, + ): void; } interface Window{ diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index 8bebd47ef..0ddd5ebcd 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -272,5 +272,8 @@ module.exports = { [text1, text2] ); }); + }, + unzip: function (zipFile, destDir, success, error) { + cordova.exec(success, error, 'System', 'unzip', [zipFile, destDir]); } };