From 3e4affe9c580999001aaf0fbc585b6a7e61a3ef6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:37:30 +0000 Subject: [PATCH 1/4] feat: add listContents and selective unzip APIs (#365) Expose listContents, unzipFiles, and unzipFilesWithPassword on iOS and Android so apps can inspect archives and extract only selected entries without switching to another library. Co-authored-by: Perry --- .maestro/flows/_home-check.yaml | 1 + CHANGELOG.md | 8 + README.md | 54 +++ __mocks__/react-native.js | 13 + __tests__/api.test.js | 73 ++++ __tests__/module-integration.test.js | 3 + .../com/rnziparchive/RNZipArchiveModule.java | 168 +++++++++ .../EntryMatchesSelectionTest.java | 50 +++ index.d.ts | 24 ++ index.js | 32 ++ ios/RNZipArchive.mm | 356 ++++++++++++++++++ package.json | 2 +- playground-expo/app/_layout.tsx | 1 + playground-expo/app/index.tsx | 1 + playground-expo/app/list-contents.tsx | 191 ++++++++++ playground-rn/src/App.tsx | 3 + playground-rn/src/screens/HomeScreen.tsx | 2 + .../src/screens/ListContentsScreen.tsx | 191 ++++++++++ specs/NativeZipArchive.ts | 21 ++ 19 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java create mode 100644 playground-expo/app/list-contents.tsx create mode 100644 playground-rn/src/screens/ListContentsScreen.tsx diff --git a/.maestro/flows/_home-check.yaml b/.maestro/flows/_home-check.yaml index 45f5f5d7..dcd7204d 100644 --- a/.maestro/flows/_home-check.yaml +++ b/.maestro/flows/_home-check.yaml @@ -2,6 +2,7 @@ appId: ${APP_ID} --- - assertVisible: "Zip Operations" - assertVisible: "Unzip Operations" +- assertVisible: "List & Selective Extract" - assertVisible: "Password Protection" - assertVisible: "Progress Events" - scrollUntilVisible: diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e4371c7..2eadca0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [9.1.0] - 2026-07-25 + +### Added +- `listContents(source, charset?)` — inspect archive entries (path, sizes, directory/encrypted flags) without extracting (#365) +- `unzipFiles(source, target, entries, charset?)` — extract only selected entry paths; directory names include nested children (#365) +- `unzipFilesWithPassword(source, target, entries, password)` — selective extract for password-protected archives (#365) +- Android unit tests for selective-extract entry matching + ## [9.0.2] - 2026-07-22 ### Fixed diff --git a/README.md b/README.md index 4ee731b7..bb2f14ea 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ import { zipWithPassword, unzip, unzipWithPassword, + unzipFiles, + unzipFilesWithPassword, + listContents, unzipAssets, subscribe, isPasswordProtected, @@ -129,6 +132,54 @@ unzipWithPassword(sourcePath, targetPath, 'password') .catch((error) => console.error(error)) ``` +### `listContents(source: string, charset?: string): Promise` + +List archive entries without extracting. + +```ts +type ZipEntry = { + path: string + size: number // uncompressed size in bytes + compressedSize: number + isDirectory: boolean + isEncrypted: boolean +} +``` + +> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored. + +```js +listContents(sourcePath) + .then((entries) => { + entries.forEach((entry) => { + console.log(entry.path, entry.size, entry.isDirectory) + }) + }) + .catch((error) => console.error(error)) +``` + +### `unzipFiles(source: string, target: string, entries: string[], charset?: string): Promise` + +Extract only the listed entry paths. Directory names match that entry and all nested children (e.g. `'docs'` extracts `docs/` and `docs/readme.md`). + +> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored. + +```js +unzipFiles(sourcePath, targetPath, ['readme.md', 'docs']) + .then((path) => console.log(`selective unzip completed at ${path}`)) + .catch((error) => console.error(error)) +``` + +### `unzipFilesWithPassword(source: string, target: string, entries: string[], password: string): Promise` + +Selective extract for a password-protected archive. + +```js +unzipFilesWithPassword(sourcePath, targetPath, ['secret.txt'], 'password') + .then((path) => console.log(`selective unzip completed at ${path}`)) + .catch((error) => console.error(error)) +``` + ### `unzipAssets(assetPath: string, target: string): Promise` Unzip a file from the Android `assets` folder. **Android only.** @@ -189,6 +240,9 @@ useEffect(() => { | `zipWithPassword` (files array) | ⚠️ | ✅ | iOS: only `STANDARD` encryption | | `unzip` | ✅ | ✅ | Charset ignored on iOS | | `unzipWithPassword` | ✅ | ✅ | — | +| `listContents` | ✅ | ✅ | Charset ignored on iOS | +| `unzipFiles` | ✅ | ✅ | Charset ignored on iOS | +| `unzipFilesWithPassword` | ✅ | ✅ | — | | `unzipAssets` | ❌ | ✅ | Android only | | `isPasswordProtected` | ✅ | ✅ | — | | `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS | diff --git a/__mocks__/react-native.js b/__mocks__/react-native.js index b4076191..16fe7d98 100644 --- a/__mocks__/react-native.js +++ b/__mocks__/react-native.js @@ -6,6 +6,19 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + listContents: jest.fn(() => + Promise.resolve([ + { + path: 'hello.txt', + size: 12, + compressedSize: 10, + isDirectory: false, + isEncrypted: false, + }, + ]) + ), unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), diff --git a/__tests__/api.test.js b/__tests__/api.test.js index 54559c52..3077fc09 100644 --- a/__tests__/api.test.js +++ b/__tests__/api.test.js @@ -3,6 +3,9 @@ const { zipWithPassword, unzip, unzipWithPassword, + unzipFiles, + unzipFilesWithPassword, + listContents, unzipAssets, isPasswordProtected, getUncompressedSize, @@ -25,6 +28,9 @@ describe('react-native-zip-archive API', () => { expect(typeof zipWithPassword).toBe('function'); expect(typeof unzip).toBe('function'); expect(typeof unzipWithPassword).toBe('function'); + expect(typeof unzipFiles).toBe('function'); + expect(typeof unzipFilesWithPassword).toBe('function'); + expect(typeof listContents).toBe('function'); expect(typeof unzipAssets).toBe('function'); expect(typeof isPasswordProtected).toBe('function'); expect(typeof getUncompressedSize).toBe('function'); @@ -107,6 +113,73 @@ describe('react-native-zip-archive API', () => { }); }); + describe('listContents', () => { + test('listContents with default charset', async () => { + const result = await listContents('/source.zip'); + expect(result).toEqual([ + { + path: 'hello.txt', + size: 12, + compressedSize: 10, + isDirectory: false, + isEncrypted: false, + }, + ]); + expect(mockRNZipArchive.listContents).toHaveBeenCalledWith('/source.zip', 'UTF-8'); + }); + + test('listContents normalizes file:// paths', async () => { + await listContents('file:///source.zip', 'GBK'); + expect(mockRNZipArchive.listContents).toHaveBeenCalledWith('/source.zip', 'GBK'); + }); + }); + + describe('unzipFiles', () => { + test('unzipFiles with default charset', async () => { + await unzipFiles('/source.zip', '/dest', ['a.txt', 'b.txt']); + expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt', 'b.txt'], + 'UTF-8' + ); + }); + + test('unzipFiles rejects empty entries', async () => { + await expect(unzipFiles('/source.zip', '/dest', [])).rejects.toThrow( + 'unzipFiles requires a non-empty entries array' + ); + }); + + test('unzipFiles normalizes file:// paths', async () => { + await unzipFiles('file:///source.zip', 'file:///dest', ['a.txt'], 'GBK'); + expect(mockRNZipArchive.unzipFiles).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt'], + 'GBK' + ); + }); + }); + + describe('unzipFilesWithPassword', () => { + test('unzipFilesWithPassword calls native module', async () => { + await unzipFilesWithPassword('/source.zip', '/dest', ['a.txt'], 'secret'); + expect(mockRNZipArchive.unzipFilesWithPassword).toHaveBeenCalledWith( + '/source.zip', + '/dest', + ['a.txt'], + 'secret' + ); + }); + + test('unzipFilesWithPassword rejects empty entries', async () => { + await expect( + unzipFilesWithPassword('/source.zip', '/dest', [], 'secret') + ).rejects.toThrow('unzipFilesWithPassword requires a non-empty entries array'); + }); + }); + describe('isPasswordProtected', () => { test('isPasswordProtected coerces to boolean', async () => { mockRNZipArchive.isPasswordProtected.mockResolvedValueOnce(1); diff --git a/__tests__/module-integration.test.js b/__tests__/module-integration.test.js index 22cf1b29..dd3a6dca 100644 --- a/__tests__/module-integration.test.js +++ b/__tests__/module-integration.test.js @@ -6,6 +6,9 @@ const mockRNZipArchive = { zipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/path.zip')), unzip: jest.fn(() => Promise.resolve('/mock/dest')), unzipWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFiles: jest.fn(() => Promise.resolve('/mock/dest')), + unzipFilesWithPassword: jest.fn(() => Promise.resolve('/mock/dest')), + listContents: jest.fn(() => Promise.resolve([])), unzipAssets: jest.fn(() => Promise.resolve('/mock/dest')), isPasswordProtected: jest.fn(() => Promise.resolve(true)), getUncompressedSize: jest.fn(() => Promise.resolve(1024)), diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 104ea7ef..05514b5e 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -12,6 +12,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableType; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -164,6 +165,173 @@ public void unzip(final String zipFilePath, final String destDirectory, final St }); } + @Override + public void listContents(final String zipFilePath, final String charset, final Promise promise) { + executor.submit(() -> { + if (zipFilePath == null) { + promise.reject("RNZipArchiveError", "Couldn't open file null. "); + return; + } + File zipFileRef = new File(zipFilePath); + if (!zipFileRef.exists()) { + promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + return; + } + + try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { + WritableArray entries = Arguments.createArray(); + for (FileHeader fileHeader : zipFile.getFileHeaders()) { + WritableMap entry = Arguments.createMap(); + entry.putString("path", fileHeader.getFileName()); + entry.putDouble("size", Math.max(fileHeader.getUncompressedSize(), 0)); + entry.putDouble("compressedSize", Math.max(fileHeader.getCompressedSize(), 0)); + entry.putBoolean("isDirectory", fileHeader.isDirectory()); + entry.putBoolean("isEncrypted", fileHeader.isEncrypted()); + entries.pushMap(entry); + } + promise.resolve(entries); + } catch (Exception ex) { + promise.reject("RNZipArchiveError", "Failed to list contents: " + ex.getLocalizedMessage()); + } + }); + } + + @Override + public void unzipFiles(final String zipFilePath, final String destDirectory, + final ReadableArray entries, final String charset, final Promise promise) { + final List entryList; + try { + entryList = readableArrayToStringList(entries); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + return; + } + extractSelectedEntries(zipFilePath, destDirectory, entryList, charset, null, promise); + } + + @Override + public void unzipFilesWithPassword(final String zipFilePath, final String destDirectory, + final ReadableArray entries, final String password, + final Promise promise) { + final List entryList; + try { + entryList = readableArrayToStringList(entries); + } catch (IllegalArgumentException ex) { + promise.reject("RNZipArchiveError", "Invalid entries array: " + ex.getMessage()); + return; + } + extractSelectedEntries(zipFilePath, destDirectory, entryList, "UTF-8", password, promise); + } + + private void extractSelectedEntries(final String zipFilePath, final String destDirectory, + final List wantedEntries, final String charset, + final String password, final Promise promise) { + executor.submit(() -> { + if (zipFilePath == null) { + promise.reject("RNZipArchiveError", "Couldn't open file null. "); + return; + } + File zipFileRef = new File(zipFilePath); + if (!zipFileRef.exists()) { + promise.reject("RNZipArchiveError", "Couldn't open file " + zipFilePath + ". "); + return; + } + if (wantedEntries == null || wantedEntries.isEmpty()) { + promise.reject("RNZipArchiveError", "entries must be a non-empty array"); + return; + } + + try (net.lingala.zip4j.ZipFile zipFile = openZipFile(zipFilePath, charset)) { + if (password != null) { + if (!zipFile.isEncrypted()) { + promise.reject("RNZipArchiveError", + String.format("Zip file: %s is not password protected", zipFilePath)); + return; + } + zipFile.setPassword(password.toCharArray()); + } + + File destDir = new File(destDirectory); + if (!destDir.exists()) { + //noinspection ResultOfMethodCallIgnored + destDir.mkdirs(); + } + + List selected = new ArrayList<>(); + for (FileHeader fileHeader : zipFile.getFileHeaders()) { + if (entryMatchesSelection(fileHeader.getFileName(), wantedEntries)) { + selected.add(fileHeader); + } + } + + if (selected.isEmpty()) { + promise.reject("RNZipArchiveError", "None of the requested entries were found in the archive"); + return; + } + + long totalBytes = Math.max(totalUncompressedSize(selected), 1); + long extractedBytes = 0; + + updateProgress(0, 1, zipFilePath); + for (FileHeader fileHeader : selected) { + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); + + if (!fileHeader.isDirectory()) { + zipFile.extractFile(fileHeader, destDirectory, ZipSecurity.createExtractParameters()); + extractedBytes += Math.max(fileHeader.getUncompressedSize(), 0); + } else { + File dir = new File(destDirectory, fileHeader.getFileName()); + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + } + updateProgress(extractedBytes, totalBytes, zipFilePath); + } + + updateProgress(1, 1, zipFilePath); + promise.resolve(destDirectory); + } catch (Exception ex) { + updateProgress(0, 1, zipFilePath); + promise.reject("RNZipArchiveError", "Failed to extract selected files: " + ex.getLocalizedMessage()); + } + }); + } + + /** + * Returns true when {@code entryName} should be extracted for the given selection list. + * Matches exact paths, directory prefixes ({@code foo/} or {@code foo}), and strips a + * trailing slash from either side before comparing. + */ + static boolean entryMatchesSelection(String entryName, List wantedEntries) { + if (entryName == null || wantedEntries == null) { + return false; + } + String normalizedEntry = stripTrailingSlash(entryName.replace('\\', '/')); + for (String wanted : wantedEntries) { + if (wanted == null || wanted.isEmpty()) { + continue; + } + String normalizedWanted = stripTrailingSlash(wanted.replace('\\', '/')); + if (normalizedEntry.equals(normalizedWanted)) { + return true; + } + if (normalizedEntry.startsWith(normalizedWanted + "/")) { + return true; + } + } + return false; + } + + private static String stripTrailingSlash(String path) { + if (path == null || path.isEmpty()) { + return path; + } + int end = path.length(); + while (end > 0 && path.charAt(end - 1) == '/') { + end--; + } + return path.substring(0, end); + } + /** * Extract a zip held in the assets directory. *

diff --git a/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java b/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java new file mode 100644 index 00000000..2b0ecf31 --- /dev/null +++ b/android/src/test/java/com/rnziparchive/EntryMatchesSelectionTest.java @@ -0,0 +1,50 @@ +package com.rnziparchive; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.Test; + +public class EntryMatchesSelectionTest { + + @Test + public void matchesExactFile() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "hello.txt", Collections.singletonList("hello.txt"))); + } + + @Test + public void matchesExactFileIgnoringTrailingSlashOnWanted() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "dir", Collections.singletonList("dir/"))); + } + + @Test + public void matchesDirectoryPrefix() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "docs/readme.md", Collections.singletonList("docs"))); + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "docs/readme.md", Collections.singletonList("docs/"))); + } + + @Test + public void doesNotMatchUnrelatedPrefix() { + assertFalse(RNZipArchiveModule.entryMatchesSelection( + "documentation/readme.md", Collections.singletonList("docs"))); + } + + @Test + public void matchesAnyOfMultipleWantedEntries() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "b.txt", Arrays.asList("a.txt", "b.txt"))); + } + + @Test + public void normalizesBackslashes() { + assertTrue(RNZipArchiveModule.entryMatchesSelection( + "folder\\file.txt", Collections.singletonList("folder/file.txt"))); + } +} diff --git a/index.d.ts b/index.d.ts index 73a0b167..e80d4150 100644 --- a/index.d.ts +++ b/index.d.ts @@ -32,6 +32,30 @@ declare module "react-native-zip-archive" { export function unzipWithPassword(source: string, target: string, password: string): Promise; export function unzipAssets(assetPath: string, target: string): Promise; + export type ZipEntry = { + path: string; + size: number; + compressedSize: number; + isDirectory: boolean; + isEncrypted: boolean; + }; + + export function listContents(source: string, charset?: string): Promise; + + export function unzipFiles( + source: string, + target: string, + entries: string[], + charset?: string + ): Promise; + + export function unzipFilesWithPassword( + source: string, + target: string, + entries: string[], + password: string + ): Promise; + export function subscribe( callback: ({ progress, filePath }: { progress: number; filePath: string }) => void ): NativeEventSubscription; diff --git a/index.js b/index.js index e6488e74..57c46fd7 100644 --- a/index.js +++ b/index.js @@ -64,6 +64,38 @@ export const unzipWithPassword = (source, target, password) => { ); }; +export const listContents = (source, charset = "UTF-8") => { + return getRNZipArchive().listContents(normalizeFilePath(source), charset); +}; + +export const unzipFiles = (source, target, entries, charset = "UTF-8") => { + if (!Array.isArray(entries) || entries.length === 0) { + return Promise.reject( + new Error("unzipFiles requires a non-empty entries array") + ); + } + return getRNZipArchive().unzipFiles( + normalizeFilePath(source), + normalizeFilePath(target), + entries, + charset + ); +}; + +export const unzipFilesWithPassword = (source, target, entries, password) => { + if (!Array.isArray(entries) || entries.length === 0) { + return Promise.reject( + new Error("unzipFilesWithPassword requires a non-empty entries array") + ); + } + return getRNZipArchive().unzipFilesWithPassword( + normalizeFilePath(source), + normalizeFilePath(target), + entries, + password + ); +}; + export const zipWithPassword = ( source, target, diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 2ba09151..55adee00 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,6 +7,7 @@ // #import "RNZipArchive.h" +#import "unzip.h" #import #if __has_include() @@ -71,6 +72,361 @@ - (void)unzipWithPassword:(NSString *)from [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; } +- (void)listContents:(NSString *)source + charset:(NSString *)charset + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)charset; // iOS always reads entry names as UTF-8 / Latin-1 fallback + + zipFile zip = unzOpen(source.fileSystemRepresentation); + if (zip == NULL) { + reject(@"list_contents_error", @"failed to open zip file", nil); + return; + } + + NSMutableArray *entries = [NSMutableArray array]; + // Empty archives return a non-UNZ_OK code from unzGoToFirstFile; treat as an empty list. + int ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + unz_file_info fileInfo; + memset(&fileInfo, 0, sizeof(unz_file_info)); + ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + unzClose(zip); + reject(@"list_contents_error", @"failed to retrieve info for zip entry", nil); + return; + } + + char *filename = (char *)malloc(fileInfo.size_filename + 1); + if (filename == NULL) { + unzClose(zip); + reject(@"list_contents_error", @"out of memory while listing zip contents", nil); + return; + } + unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); + filename[fileInfo.size_filename] = '\0'; + + NSString *path = [NSString stringWithUTF8String:filename]; + if (path == nil) { + path = [[NSString alloc] initWithBytes:filename + length:fileInfo.size_filename + encoding:NSISOLatin1StringEncoding]; + } + BOOL isDirectory = NO; + if (fileInfo.size_filename > 0 && + (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { + isDirectory = YES; + } + free(filename); + + if (path == nil) { + path = @""; + } + + BOOL isEncrypted = (fileInfo.flag & 1) != 0; + [entries addObject:@{ + @"path": path, + @"size": @((double)fileInfo.uncompressed_size), + @"compressedSize": @((double)fileInfo.compressed_size), + @"isDirectory": @(isDirectory), + @"isEncrypted": @(isEncrypted), + }]; + + ret = unzGoToNextFile(zip); + } + + unzClose(zip); + resolve(entries); +} + +- (NSString *)normalizedZipPath:(NSString *)path { + NSString *normalized = [path stringByReplacingOccurrencesOfString:@"\\" withString:@"/"]; + while ([normalized hasSuffix:@"/"] && normalized.length > 0) { + normalized = [normalized substringToIndex:normalized.length - 1]; + } + return normalized; +} + +- (BOOL)entry:(NSString *)entryName matchesSelection:(NSArray *)wantedEntries { + if (entryName == nil || wantedEntries.count == 0) { + return NO; + } + NSString *normalizedEntry = [self normalizedZipPath:entryName]; + for (NSString *wanted in wantedEntries) { + if (wanted.length == 0) { + continue; + } + NSString *normalizedWanted = [self normalizedZipPath:wanted]; + if ([normalizedEntry isEqualToString:normalizedWanted]) { + return YES; + } + NSString *prefix = [normalizedWanted stringByAppendingString:@"/"]; + if ([normalizedEntry hasPrefix:prefix]) { + return YES; + } + } + return NO; +} + +- (BOOL)isSafeExtractPath:(NSString *)entryName intoDestination:(NSString *)destinationPath { + if (entryName.length == 0) { + return NO; + } + NSString *fullPath = [destinationPath stringByAppendingPathComponent:entryName]; + NSString *standardizedDest = [[destinationPath stringByStandardizingPath] stringByAppendingString:@"/"]; + NSString *standardizedFull = [fullPath stringByStandardizingPath]; + return [standardizedFull hasPrefix:standardizedDest] || + [standardizedFull isEqualToString:[destinationPath stringByStandardizingPath]]; +} + +- (void)unzipFiles:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + charset:(NSString *)charset + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + (void)charset; + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:nil + resolve:resolve + reject:reject]; +} + +- (void)unzipFilesWithPassword:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [self unzipSelectedEntries:from + destinationPath:destinationPath + entries:entries + password:password + resolve:resolve + reject:reject]; +} + +- (void)unzipSelectedEntries:(NSString *)from + destinationPath:(NSString *)destinationPath + entries:(NSArray *)entries + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (entries.count == 0) { + reject(@"unzip_files_error", @"entries must be a non-empty array", nil); + return; + } + + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSError *dirError = nil; + if (![fileManager fileExistsAtPath:destinationPath]) { + [fileManager createDirectoryAtPath:destinationPath + withIntermediateDirectories:YES + attributes:nil + error:&dirError]; + if (dirError != nil) { + reject(@"unzip_files_error", dirError.localizedDescription, dirError); + return; + } + } + + zipFile zip = unzOpen(from.fileSystemRepresentation); + if (zip == NULL) { + reject(@"unzip_files_error", @"failed to open zip file", nil); + return; + } + + // First pass: compute total uncompressed size of matching entries for progress. + unsigned long long totalSize = 0; + NSUInteger matchCount = 0; + int ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + unz_file_info fileInfo; + memset(&fileInfo, 0, sizeof(unz_file_info)); + ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + break; + } + char *filenameBuf = (char *)malloc(fileInfo.size_filename + 1); + if (filenameBuf == NULL) { + break; + } + unzGetCurrentFileInfo(zip, &fileInfo, filenameBuf, fileInfo.size_filename + 1, NULL, 0, NULL, 0); + filenameBuf[fileInfo.size_filename] = '\0'; + NSString *path = [NSString stringWithUTF8String:filenameBuf]; + if (path == nil) { + path = [[NSString alloc] initWithBytes:filenameBuf + length:fileInfo.size_filename + encoding:NSISOLatin1StringEncoding]; + } + free(filenameBuf); + if ([self entry:path matchesSelection:entries]) { + matchCount += 1; + totalSize += fileInfo.uncompressed_size; + } + ret = unzGoToNextFile(zip); + } + + if (matchCount == 0) { + unzClose(zip); + reject(@"unzip_files_error", @"None of the requested entries were found in the archive", nil); + return; + } + + if (totalSize == 0) { + totalSize = 1; + } + + // Second pass: extract matching entries. + unsigned long long extractedBytes = 0; + BOOL success = YES; + NSError *extractError = nil; + ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + unz_file_info fileInfo; + memset(&fileInfo, 0, sizeof(unz_file_info)); + ret = unzGetCurrentFileInfo(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to retrieve info for zip entry"}]; + break; + } + + char *filename = (char *)malloc(fileInfo.size_filename + 1); + if (filename == NULL) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"out of memory while extracting"}]; + break; + } + unzGetCurrentFileInfo(zip, &fileInfo, filename, fileInfo.size_filename + 1, NULL, 0, NULL, 0); + filename[fileInfo.size_filename] = '\0'; + + NSString *strPath = [NSString stringWithUTF8String:filename]; + if (strPath == nil) { + strPath = [[NSString alloc] initWithBytes:filename + length:fileInfo.size_filename + encoding:NSISOLatin1StringEncoding]; + } + BOOL isDirectory = NO; + if (fileInfo.size_filename > 0 && + (filename[fileInfo.size_filename - 1] == '/' || filename[fileInfo.size_filename - 1] == '\\')) { + isDirectory = YES; + } + free(filename); + + if (strPath == nil || ![self entry:strPath matchesSelection:entries]) { + ret = unzGoToNextFile(zip); + continue; + } + + if ([strPath hasPrefix:@"__MACOSX/"]) { + ret = unzGoToNextFile(zip); + continue; + } + + if (![self isSafeExtractPath:strPath intoDestination:destinationPath]) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Found Zip Path Traversal Vulnerability with %@", strPath]}]; + break; + } + + self.processedFilePath = strPath; + NSString *fullPath = [destinationPath stringByAppendingPathComponent:strPath]; + + if (isDirectory) { + [fileManager createDirectoryAtPath:fullPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + extractedBytes += fileInfo.uncompressed_size; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + continue; + } + + NSString *parentDir = [fullPath stringByDeletingLastPathComponent]; + [fileManager createDirectoryAtPath:parentDir + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + if (password.length > 0) { + ret = unzOpenCurrentFilePassword(zip, [password cStringUsingEncoding:NSUTF8StringEncoding]); + } else { + ret = unzOpenCurrentFile(zip); + } + if (ret != UNZ_OK) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to open file in zip archive"}]; + break; + } + + FILE *out = fopen(fullPath.fileSystemRepresentation, "wb"); + if (out == NULL) { + unzCloseCurrentFile(zip); + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } + + unsigned char buffer[4096]; + int readBytes; + do { + readBytes = unzReadCurrentFile(zip, buffer, sizeof(buffer)); + if (readBytes < 0) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to read zip entry"}]; + break; + } + if (readBytes > 0) { + fwrite(buffer, 1, readBytes, out); + } + } while (readBytes > 0); + + fclose(out); + unzCloseCurrentFile(zip); + + if (!success) { + break; + } + + extractedBytes += fileInfo.uncompressed_size; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + } + + unzClose(zip); + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; + reject(@"unzip_files_error", message, extractError); + } +} + - (void)unzipFile:(NSString *)from destinationPath:(NSString *)destinationPath password:(NSString *)password diff --git a/package.json b/package.json index 71a7a799..92c52327 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.0.2", + "version": "9.1.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/playground-expo/app/_layout.tsx b/playground-expo/app/_layout.tsx index 787ba018..5fb87aa7 100644 --- a/playground-expo/app/_layout.tsx +++ b/playground-expo/app/_layout.tsx @@ -10,6 +10,7 @@ export default function RootLayout() { + diff --git a/playground-expo/app/index.tsx b/playground-expo/app/index.tsx index 8fbbb64f..e2aa351e 100644 --- a/playground-expo/app/index.tsx +++ b/playground-expo/app/index.tsx @@ -13,6 +13,7 @@ import { Link } from 'expo-router'; const DEMOS = [ { href: '/zip' as const, title: 'Zip Operations', desc: 'Zip folders & files with compression levels' }, { href: '/unzip' as const, title: 'Unzip Operations', desc: 'Extract archives with charset support' }, + { href: '/list-contents' as const, title: 'List & Selective Extract', desc: 'Inspect entries and extract a subset' }, { href: '/password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { href: '/progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { href: '/benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, diff --git a/playground-expo/app/list-contents.tsx b/playground-expo/app/list-contents.tsx new file mode 100644 index 00000000..8ada5711 --- /dev/null +++ b/playground-expo/app/list-contents.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import * as FileSystem from 'expo-file-system/legacy'; +import { ResultCard } from '../components/ResultCard'; +import { CodePreview } from '../components/CodePreview'; +import { ensureDir, listFiles } from '../utils/fileSystem'; +import { + zip, + listContents, + unzipFiles, + type ZipEntry, +} from 'react-native-zip-archive'; + +const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; + +const entries = await listContents(sourceZip); +await unzipFiles(sourceZip, outputFolder, ['readme.md']); +`; + +export default function ListContentsScreen() { + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + const [extractedFiles, setExtractedFiles] = useState([]); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [zipPath, setZipPath] = useState(''); + + const createDemoZip = async () => { + const dir = FileSystem.documentDirectory + 'demo-list/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await FileSystem.writeAsStringAsync(dir + 'hello.txt', 'Hello from zip!'); + await FileSystem.writeAsStringAsync(dir + 'readme.md', '# Demo'); + await FileSystem.writeAsStringAsync(dir + 'docs/guide.md', '# Guide'); + const target = FileSystem.documentDirectory + 'demo-list-sample.zip'; + try { + await FileSystem.deleteAsync(target, { idempotent: true }); + } catch {} + await zip(dir, target, 0); + return target; + }; + + const handleList = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setResult(''); + try { + const path = await createDemoZip(); + setZipPath(path); + const listed = await listContents(path); + setEntries(listed); + setResult(`Listed ${listed.length} entries`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + const handleSelective = async () => { + if (!zipPath) return; + setLoading(true); + setError(''); + setExtractedFiles([]); + try { + const out = FileSystem.documentDirectory + 'selective/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const files = await listFiles(out); + setExtractedFiles(files); + setResult(`Selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + return ( + + Demo: List Contents + + {loading ? ( + + ) : ( + Create Zip & List Contents + )} + + + Demo: Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md + docs/ + )} + + + {result ? ( + + {result} + {entries.length > 0 && ( + <> + Entries: + {entries.map((entry) => ( + + • {entry.path} + {entry.isDirectory ? '/' : ''} ({entry.size} B) + + ))} + + )} + {extractedFiles.length > 0 && ( + <> + Extracted: + {extractedFiles.map((f) => ( + + • {f} + + ))} + + )} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingTop: 16, + }, + section: { + fontSize: 15, + fontWeight: '700', + marginTop: 16, + marginBottom: 8, + }, + actionBtn: { + backgroundColor: '#007AFF', + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + disabledBtn: { + backgroundColor: '#A1A1AA', + }, + actionText: { + color: '#fff', + fontWeight: '700', + }, + mono: { + fontFamily: 'Courier', + fontSize: 12, + color: '#1C1C1E', + }, + subHeading: { + marginTop: 8, + fontWeight: '700', + fontSize: 13, + }, + fileItem: { + fontSize: 13, + color: '#3A3A3C', + marginLeft: 4, + }, +}); diff --git a/playground-rn/src/App.tsx b/playground-rn/src/App.tsx index 2c0223b3..a37d29e8 100644 --- a/playground-rn/src/App.tsx +++ b/playground-rn/src/App.tsx @@ -5,6 +5,7 @@ import { SafeAreaProvider } from 'react-native-safe-area-context'; import HomeScreen from './screens/HomeScreen'; import ZipScreen from './screens/ZipScreen'; import UnzipScreen from './screens/UnzipScreen'; +import ListContentsScreen from './screens/ListContentsScreen'; import PasswordScreen from './screens/PasswordScreen'; import ProgressScreen from './screens/ProgressScreen'; import BenchmarkScreen from './screens/BenchmarkScreen'; @@ -14,6 +15,7 @@ export type RootStackParamList = { Home: undefined; Zip: undefined; Unzip: undefined; + ListContents: undefined; Password: undefined; Progress: undefined; Benchmark: undefined; @@ -30,6 +32,7 @@ function App(): React.JSX.Element { + diff --git a/playground-rn/src/screens/HomeScreen.tsx b/playground-rn/src/screens/HomeScreen.tsx index e3f0ab39..df8cebe5 100644 --- a/playground-rn/src/screens/HomeScreen.tsx +++ b/playground-rn/src/screens/HomeScreen.tsx @@ -15,6 +15,7 @@ type RootStackParamList = { Home: undefined; Zip: undefined; Unzip: undefined; + ListContents: undefined; Password: undefined; Progress: undefined; Benchmark: undefined; @@ -26,6 +27,7 @@ type NavigationProp = NativeStackNavigationProp; const DEMOS = [ { screen: 'Zip' as const, title: 'Zip Operations', desc: 'Zip folders & files with compression levels' }, { screen: 'Unzip' as const, title: 'Unzip Operations', desc: 'Extract archives with charset support' }, + { screen: 'ListContents' as const, title: 'List & Selective Extract', desc: 'Inspect entries and extract a subset' }, { screen: 'Password' as const, title: 'Password Protection', desc: 'AES & standard encryption demos' }, { screen: 'Progress' as const, title: 'Progress Events', desc: 'Real-time zip/unzip progress' }, { screen: 'Benchmark' as const, title: 'Benchmarks', desc: 'Compare compression levels & speed' }, diff --git a/playground-rn/src/screens/ListContentsScreen.tsx b/playground-rn/src/screens/ListContentsScreen.tsx new file mode 100644 index 00000000..d9364574 --- /dev/null +++ b/playground-rn/src/screens/ListContentsScreen.tsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import RNFS from 'react-native-fs'; +import { ResultCard } from '../components/ResultCard'; +import { CodePreview } from '../components/CodePreview'; +import { ensureDir, listFiles } from '../utils/fileSystem'; +import { + zip, + listContents, + unzipFiles, + type ZipEntry, +} from 'react-native-zip-archive'; + +const SOURCE_CODE = `import { listContents, unzipFiles } from 'react-native-zip-archive'; + +const entries = await listContents(sourceZip); +await unzipFiles(sourceZip, outputFolder, ['readme.md']); +`; + +export default function ListContentsScreen() { + const [loading, setLoading] = useState(false); + const [entries, setEntries] = useState([]); + const [extractedFiles, setExtractedFiles] = useState([]); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + const [zipPath, setZipPath] = useState(''); + + const createDemoZip = async () => { + const dir = RNFS.DocumentDirectoryPath + '/demo-list/'; + await ensureDir(dir); + await ensureDir(dir + 'docs/'); + await RNFS.writeFile(dir + 'hello.txt', 'Hello from zip!', 'utf8'); + await RNFS.writeFile(dir + 'readme.md', '# Demo', 'utf8'); + await RNFS.writeFile(dir + 'docs/guide.md', '# Guide', 'utf8'); + const target = RNFS.DocumentDirectoryPath + '/demo-list-sample.zip'; + try { + if (await RNFS.exists(target)) await RNFS.unlink(target); + } catch {} + await zip(dir, target, 0); + return target; + }; + + const handleList = async () => { + setLoading(true); + setError(''); + setEntries([]); + setExtractedFiles([]); + setResult(''); + try { + const path = await createDemoZip(); + setZipPath(path); + const listed = await listContents(path); + setEntries(listed); + setResult(`Listed ${listed.length} entries`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + const handleSelective = async () => { + if (!zipPath) return; + setLoading(true); + setError(''); + setExtractedFiles([]); + try { + const out = RNFS.DocumentDirectoryPath + '/selective/' + Date.now() + '/'; + await ensureDir(out); + const path = await unzipFiles(zipPath, out, ['readme.md', 'docs']); + const files = await listFiles(out); + setExtractedFiles(files); + setResult(`Selective extract to ${path}`); + } catch (e: any) { + setError(e?.message || String(e)); + } finally { + setLoading(false); + } + }; + + return ( + + Demo: List Contents + + {loading ? ( + + ) : ( + Create Zip & List Contents + )} + + + Demo: Selective Extract + + {loading ? ( + + ) : ( + Extract readme.md + docs/ + )} + + + {result ? ( + + {result} + {entries.length > 0 && ( + <> + Entries: + {entries.map((entry) => ( + + • {entry.path} + {entry.isDirectory ? '/' : ''} ({entry.size} B) + + ))} + + )} + {extractedFiles.length > 0 && ( + <> + Extracted: + {extractedFiles.map((f) => ( + + • {f} + + ))} + + )} + + ) : null} + + {error ? ( + + {error} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + paddingHorizontal: 16, + paddingTop: 16, + }, + section: { + fontSize: 15, + fontWeight: '700', + marginTop: 16, + marginBottom: 8, + }, + actionBtn: { + backgroundColor: '#007AFF', + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + }, + disabledBtn: { + backgroundColor: '#A1A1AA', + }, + actionText: { + color: '#fff', + fontWeight: '700', + }, + mono: { + fontFamily: 'Courier', + fontSize: 12, + color: '#1C1C1E', + }, + subHeading: { + marginTop: 8, + fontWeight: '700', + fontSize: 13, + }, + fileItem: { + fontSize: 13, + color: '#3A3A3C', + marginLeft: 4, + }, +}); diff --git a/specs/NativeZipArchive.ts b/specs/NativeZipArchive.ts index 5d653722..b5580604 100644 --- a/specs/NativeZipArchive.ts +++ b/specs/NativeZipArchive.ts @@ -1,6 +1,14 @@ import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; +export type ZipEntry = { + path: string; + size: number; + compressedSize: number; + isDirectory: boolean; + isEncrypted: boolean; +}; + export interface Spec extends TurboModule { isPasswordProtected(file: string): Promise; unzip(from: string, destinationPath: string, charset: string): Promise; @@ -9,6 +17,19 @@ export interface Spec extends TurboModule { destinationPath: string, password: string ): Promise; + unzipFiles( + from: string, + destinationPath: string, + entries: string[], + charset: string + ): Promise; + unzipFilesWithPassword( + from: string, + destinationPath: string, + entries: string[], + password: string + ): Promise; + listContents(source: string, charset: string): Promise; zipFolder( from: string, destinationPath: string, From 527d3e3f5cd2581a38879e753536c2221f65f25c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 01:57:55 +0000 Subject: [PATCH 2/4] fix: restore CI for selective extract PR Co-authored-by: Perry --- .maestro/flows/_home-check.yaml | 8 ++++++++ RNZipArchive.podspec | 3 +++ 2 files changed, 11 insertions(+) diff --git a/.maestro/flows/_home-check.yaml b/.maestro/flows/_home-check.yaml index dcd7204d..ed300a52 100644 --- a/.maestro/flows/_home-check.yaml +++ b/.maestro/flows/_home-check.yaml @@ -3,7 +3,15 @@ appId: ${APP_ID} - assertVisible: "Zip Operations" - assertVisible: "Unzip Operations" - assertVisible: "List & Selective Extract" +- scrollUntilVisible: + element: + text: "Password Protection" + direction: DOWN - assertVisible: "Password Protection" +- scrollUntilVisible: + element: + text: "Progress Events" + direction: DOWN - assertVisible: "Progress Events" - scrollUntilVisible: element: diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index 78135b42..2caa1704 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -19,6 +19,9 @@ Pod::Spec.new do |s| s.dependency 'React-Core' end s.dependency 'SSZipArchive', '~>2.5.5' + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip" "${PODS_TARGET_SRCROOT}/../SSZipArchive/SSZipArchive/minizip"' + } s.source_files = 'ios/*.{h,m,mm}' s.public_header_files = ['ios/RNZipArchive.h'] From 70132d87f9894eb29fadb6842996f45926ddb552 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 02:04:22 +0000 Subject: [PATCH 3/4] fix: import SSZipArchive minizip compatibility header Co-authored-by: Perry --- RNZipArchive.podspec | 7 ++++--- ios/RNZipArchive.mm | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index 2caa1704..f7703225 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -19,9 +19,10 @@ Pod::Spec.new do |s| s.dependency 'React-Core' end s.dependency 'SSZipArchive', '~>2.5.5' - s.pod_target_xcconfig = { - 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip" "${PODS_TARGET_SRCROOT}/../SSZipArchive/SSZipArchive/minizip"' - } + s.pod_target_xcconfig = (s.pod_target_xcconfig || {}).merge({ + 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip" "${PODS_TARGET_SRCROOT}/../SSZipArchive/SSZipArchive/minizip"', + 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) HAVE_INTTYPES_H HAVE_PKCRYPT HAVE_STDINT_H HAVE_WZAES HAVE_ZLIB ZLIB_COMPAT' + }) s.source_files = 'ios/*.{h,m,mm}' s.public_header_files = ['ios/RNZipArchive.h'] diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 55adee00..c20acafa 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,7 +7,7 @@ // #import "RNZipArchive.h" -#import "unzip.h" +#import "mz_compat.h" #import #if __has_include() From ea994a4efacd08fc903de44ff86a0fe02c480d50 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 25 Jul 2026 03:14:42 +0000 Subject: [PATCH 4/4] fix: set iOS minizip header path before RN config Co-authored-by: Perry --- RNZipArchive.podspec | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index f7703225..f2be6c29 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -12,6 +12,9 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/mockingbot/react-native-zip-archive.git', :tag => "#{s.version}"} s.platform = :ios, '15.5' s.preserve_paths = '*.js' + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"' + } if defined?(install_modules_dependencies) != nil install_modules_dependencies(s) @@ -19,10 +22,6 @@ Pod::Spec.new do |s| s.dependency 'React-Core' end s.dependency 'SSZipArchive', '~>2.5.5' - s.pod_target_xcconfig = (s.pod_target_xcconfig || {}).merge({ - 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip" "${PODS_TARGET_SRCROOT}/../SSZipArchive/SSZipArchive/minizip"', - 'GCC_PREPROCESSOR_DEFINITIONS' => '$(inherited) HAVE_INTTYPES_H HAVE_PKCRYPT HAVE_STDINT_H HAVE_WZAES HAVE_ZLIB ZLIB_COMPAT' - }) s.source_files = 'ios/*.{h,m,mm}' s.public_header_files = ['ios/RNZipArchive.h']