fix(plugin-install): Move plugin zip extraction to native (Java) code, remove hardcoded 2-file batch limit - #2817
Conversation
installPlugin.js unzipped archives on the JS thread in hardcoded batches of 2 files, with a setTimeout(0) between batches purely to keep the WebView UI thread from freezing. This made installing plugins with many files slow for no real reason. Add a new Cordova plugin (src/plugins/pluginInstaller) that does the unzip + disk I/O in Java on Cordova's background thread pool, so there's no UI thread to protect and therefore no batch-size limit. - Stream extraction from a temp file on disk (CACHE_STORAGE) instead of base64-encoding the archive through the JS bridge, avoiding several redundant in-memory copies of the payload. - Preserve existing zip-slip protections (sanitizeZipPath / isUnsafeAbsolutePath), reimplemented natively, plus a cheap lexical containment check as defense in depth. - Preserve the per-file SHA-256 checksum/update-skip behavior from installState.js so existing install state stays compatible. - Add zip-bomb guards (per-entry and total decompressed size caps). - Report extraction progress back to JS and surface it in the loader dialog. - Add real cancellation support: the loader's cancel button now actually aborts the in-flight download/extraction instead of just hiding the dialog while the install keeps running in the background. - One bad archive entry no longer aborts the whole install; failures are collected and logged, matching (and improving on) the previous per-file try/catch behavior.
Greptile SummaryThe PR moves plugin archive extraction into a native Android Cordova plugin and adds staged directory swaps, cancellation, progress reporting, and extraction limits.
Confidence Score: 3/5The PR is not yet safe to merge because update backups can be discarded before the update is fully committed, and deferred dependency validation can strand unvalidated replacements without recoverable prior versions. Backup lifetime does not cover install-state persistence, and dependency backups are isolated in deferred closures that the parent failure path cannot restore before startup cleanup removes them. Files Needing Attention: src/lib/installPlugin.js and src/lib/loadPlugins.js Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Download plugin archive] --> B[Extract into staging directory]
B --> C{Extraction complete?}
C -->|No| D[Delete staging]
C -->|Yes| E[Park existing plugin as backup]
E --> F[Swap staging into plugin directory]
F --> G[Defer or run plugin validation]
G --> H{Load and state persistence succeed?}
H -->|Yes| I[Delete backup]
H -->|No| J[Restore backup]
Reviews (4): Last reviewed commit: "fix: implement backup and restore mechan..." | Re-trigger Greptile |
|
@greptile-apps review |
…leanup for interrupted installs
|
@greptile-apps review |
|
@greptile-apps review |
| if (backupDir) { | ||
| const finishedBackupDir = backupDir; | ||
| backupDir = null; | ||
| fsOperation(finishedBackupDir) | ||
| .delete() | ||
| .catch(() => {}); |
There was a problem hiding this comment.
Backup discarded before commit
When an updated plugin loads successfully but persisting its new checksum store fails, commitOrRollback has already cleared backupDir and started deleting the previous version. The outer recovery then cannot restore that version, leaving the replacement installed with stale install state and no recoverable backup.
Knowledge Base Used: Plugin lifecycle and extension management
| if (isDependency) { | ||
| depsLoaders.push(async () => { | ||
| await loadPluginWithTimeout(id, true); | ||
| }); | ||
| depsLoaders.push(() => | ||
| commitOrRollback(() => loadPluginWithTimeout(id, true)), | ||
| ); |
There was a problem hiding this comment.
Dependency backups become orphaned
When the parent installation fails or is cancelled after updating dependencies but before all deferred loaders run, those dependencies have already swapped in their unvalidated replacements while their backups remain inside inaccessible per-call closures. The parent cannot restore them, and startup cleanup later deletes the parked backups, permanently removing the previous working versions.
Knowledge Base Used: Plugin lifecycle and extension management
Problem
installPlugin.jsextracted plugin zip archives on the JS thread inhardcoded batches of 2 files, yielding to the UI thread with
await new Promise(r => setTimeout(r, 0))between batches. This waspresumably to stop the WebView from freezing during install, but it
made installing plugins with many files unnecessarily slow, and the
batch size had no principled basis.
Fix
Move the actual unzip + disk I/O to native Java code, running on
Cordova's background thread pool instead of the JS thread. Since
native code never touches the UI thread, there's nothing to protect
by batching — the whole archive is extracted in a single native call.
New plugin:
src/plugins/pluginInstallerPluginInstaller.java— opens the (already-downloaded, on-disk) zipwith
ZipFile, walks entries, sanitizes each path (mirrors theexisting
sanitizeZipPath/isUnsafeAbsolutePathlogic frominstallPlugin.jsso zip-slip protection isn't lost), writes fileswith plain
java.io.FileI/O, and computes SHA-256 checksums in thesame format
installState.jsalready uses, so existing installstate remains valid.
PluginInstaller.js(www bridge) — exposesextractZip()/cancelExtract()overcordova.exec.plugin.xml/package.json— standard Cordova plugin scaffolding,following the same pattern as the existing
pluginContextplugin.installPlugin.jschangescreateFileRecursive/sanitizeZipPath/isUnsafeAbsolutePathhelpers it used, arereplaced by a single call to
PluginInstaller.extractZip().CACHE_STORAGEand native code streams from that path, rather thanbase64-encoding the whole thing through the JS bridge (which would
otherwise inflate the payload ~33% and hold several full copies of
it in memory across JS/bridge/Java).
dialog.
aborts the in-flight download (where the underlying transport
supports it) or the in-flight extraction, instead of just closing
the dialog while the install continues in the background.
of aborting the entire install.
Hardening included along the way
containment check as a second line of defense (no per-file
filesystem/symlink-resolution syscalls).
on the total decompressed size of the archive, since this code path
extracts arbitrary user-supplied/downloaded archives.