From 48baf08d0cbd9fbdb283e42ae65c80e9adc7da85 Mon Sep 17 00:00:00 2001 From: Perry Date: Sat, 15 Aug 2026 22:57:32 +0800 Subject: [PATCH 1/4] fix(ios): zip interoperability for server-side unzippers (#367) (#371) * fix(ios): zip interoperability for server-side unzippers (#367) Rebased onto #370 after #369 was squash-merged. Co-authored-by: Perry * docs: file-array zipWithPassword honors encryptionType on iOS The interoperability change writes ZipCrypto vs AES based on encryptionType for file arrays; drop the outdated README callout. Co-authored-by: Perry * docs: note iOS file-array zipWithPassword default is now ZipCrypto Omitting encryptionType used to always write WinZip-AES for iOS file arrays. Callers who need AES must pass AES-128 or AES-256. Co-authored-by: Perry --------- Co-authored-by: Cursor Agent --- .github/workflows/e2e.yml | 38 ++-------- CHANGELOG.md | 14 ++++ README.md | 25 +++++-- RNZipArchive.podspec | 6 +- .../com/rnziparchive/RNZipArchiveModule.java | 2 +- ios/RNZipArchive.mm | 71 +++++++++++++++++-- package.json | 2 +- scripts/validate-zip-header.js | 64 +++++++++++++++++ 8 files changed, 170 insertions(+), 52 deletions(-) create mode 100755 scripts/validate-zip-header.js diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ce11f1e3..7d8c40c0 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,23 +46,8 @@ jobs: - name: Install Maestro run: | - set -euo pipefail - for attempt in 1 2 3; do - echo "Installing Maestro (attempt $attempt)..." - if curl -fsSL "https://get.maestro.mobile.dev" | bash \ - && test -x "$HOME/.maestro/bin/maestro"; then - break - fi - echo "Maestro install failed on attempt $attempt" - rm -rf "$HOME/.maestro" - if [ "$attempt" -eq 3 ]; then - echo "Maestro install failed after 3 attempts" - exit 1 - fi - sleep $((attempt * 5)) - done - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - "$HOME/.maestro/bin/maestro" --version + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Cache node_modules uses: actions/cache@v4 @@ -174,23 +159,8 @@ jobs: - name: Install Maestro run: | - set -euo pipefail - for attempt in 1 2 3; do - echo "Installing Maestro (attempt $attempt)..." - if curl -fsSL "https://get.maestro.mobile.dev" | bash \ - && test -x "$HOME/.maestro/bin/maestro"; then - break - fi - echo "Maestro install failed on attempt $attempt" - rm -rf "$HOME/.maestro" - if [ "$attempt" -eq 3 ]; then - echo "Maestro install failed after 3 attempts" - exit 1 - fi - sleep $((attempt * 5)) - done - echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" - "$HOME/.maestro/bin/maestro" --version + curl -fsSL "https://get.maestro.mobile.dev" | bash + echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Cache node_modules uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e96864..3de9498d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [9.3.0] - 2026-07-25 + +### Changed +- iOS: `zipWithPassword` with a files array now honors `encryptionType`. Omitting it (JS default, treated as `'STANDARD'`) writes ZipCrypto instead of the previous always-AES (WinZip-AES) default. ZipCrypto is weaker encryption than AES; pass `'AES-128'` or `'AES-256'` to keep AES. This matches Android's default and common server unzippers (#367). + +### Fixed +- iOS: `zipFilesWithPassword` now honors `encryptionType` — `'STANDARD'` uses ZipCrypto instead of always writing WinZip-AES (improves server-side unzip with Node/Java tools) (#367, #333, #323) +- iOS: fsync zip output after successful `zip` / `zipWithPassword` so immediate uploads/reads see full bytes (#367) +- iOS: file-array `zip` / `zipWithPassword` now apply the requested compression level (previously always `Z_DEFAULT_COMPRESSION`) + +### Added +- `scripts/validate-zip-header.js` — checks local-file and EOCD signatures for interoperability smoke tests +- README guidance for server-side unzip compatibility + ## [9.2.0] - 2026-07-25 ### Added diff --git a/README.md b/README.md index b5874553..c99e0bf7 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Zip with password protection. - `'AES-128'` — AES 128-bit - `'AES-256'` — AES 256-bit -> **iOS:** Both AES-128 and AES-256 use AES-256 internally. AES encryption is **not supported** for file arrays on iOS — only `STANDARD` works. +> **iOS:** Both AES-128 and AES-256 use AES-256 internally. File arrays honor `encryptionType` the same as folders. The default is ZipCrypto (`'STANDARD'`), including when the 4th argument is omitted — file arrays previously always wrote WinZip-AES. Pass `'AES-128'` or `'AES-256'` if you need AES. Prefer `'STANDARD'` when the archive will be unzipped by Node, Java, or other non-WinZip tools. ```js const sourcePath = DocumentDirectoryPath @@ -262,9 +262,9 @@ useEffect(() => { | Feature | iOS | Android | Notes | |---------|-----|---------|-------| | `zip` (folder) | ✅ | ✅ | — | -| `zip` (files array) | ✅ | ✅ | Compression level ignored on iOS | -| `zipWithPassword` (folder) | ✅ | ✅ | AES encryption supported | -| `zipWithPassword` (files array) | ⚠️ | ✅ | iOS: only `STANDARD` encryption | +| `zip` (files array) | ✅ | ✅ | — | +| `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | +| `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | | `unzip` | ✅ | ✅ | Optional `entries` for selective extract; charset ignored on iOS | | `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | | `listContents` | ✅ | ✅ | Charset ignored on iOS | @@ -276,11 +276,24 @@ useEffect(() => { ### Cross-Platform Notes -- **Compression levels:** Android supports 0–9 for all operations. iOS supports them only for folder operations. -- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. iOS supports AES and Standard for folders, but only Standard for file arrays. +- **Compression levels:** Android supports 0–9 for all operations. iOS supports 0–9 for folder and file-array zips. +- **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. On iOS, pass `'STANDARD'` (default) for ZipCrypto archives that Node `unzipper` / Java `ZipInputStream` can read; `'AES-128'` / `'AES-256'` produce WinZip-AES archives that many server tools cannot open. - **Charset:** Android supports custom charsets (default UTF-8). iOS always uses UTF-8. - **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS. +### Server-side unzip interoperability + +Plain (non-AES) zips created on iOS and Android are intended to open with common server unzippers (`unzip`, Node `unzipper`, Java `ZipInputStream`). Practical tips: + +- Prefer `zip(...)` or `zipWithPassword(..., 'STANDARD')` when the archive will be extracted off-device. +- Avoid AES password zips if the consumer is stock Java/`unzipper` — use `'STANDARD'` instead. +- Decode URL-encoded paths (`decodeURIComponent`) before passing them in; `%20` in paths has been mistaken for corrupt archives (#333). +- After upgrading, you can sanity-check a produced file with: + +```bash +node scripts/validate-zip-header.js /path/to/archive.zip +``` + ## Expo This library **requires an Expo Development Build** and does not work in Expo Go because it includes custom native code. See [playground-expo](./playground-expo/) for a working Expo Development Build example. diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index f2be6c29..9e12d730 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -12,9 +12,6 @@ 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) @@ -22,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"' + } s.source_files = 'ios/*.{h,m,mm}' s.public_header_files = ['ios/RNZipArchive.h'] diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 057495bf..3937bd4b 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -574,7 +574,7 @@ private void zipWithPassword(final List filesOrDirectory, final String d } } else if ("STANDARD".equals(encryptionMethod)) { // ZipCrypto (ZIP_STANDARD). ZIP_STANDARD_VARIANT_STRONG is write-only in zip4j - // and fails extract with "encryption method is not supported". + // and fails create/extract with "encryption method is not supported". parameters.setEncryptionMethod(EncryptionMethod.ZIP_STANDARD); Log.d(TAG, "Standard Encryption"); } else { diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 5db22f4d..eb2e6517 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -7,8 +7,14 @@ // #import "RNZipArchive.h" +#if __has_include() +#import +#else #import "mz_compat.h" +#endif #import +#import +#import #if __has_include() #import @@ -636,10 +642,13 @@ - (void)zipFolder:(NSString *)from success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:nil AES:NO progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -686,6 +695,37 @@ - (void)zipFolder:(NSString *)from return entries; } +- (int)zlibCompressionLevel:(double)compressionLevel { + // Map JS compression constants onto zlib levels. Negative values mean default. + if (compressionLevel < 0) { + return Z_DEFAULT_COMPRESSION; + } + if (compressionLevel > 9) { + return Z_BEST_COMPRESSION; + } + return (int)compressionLevel; +} + +- (BOOL)usesAESForEncryptionType:(NSString *)encryptionType { + // Empty / STANDARD → traditional ZipCrypto for maximum server-side compatibility. + // AES-128 / AES-256 → WinZip AES (many Java/Node unzippers cannot read this). + return encryptionType.length > 0 && ![encryptionType isEqualToString:@"STANDARD"]; +} + +/** + * Flush zip bytes to durable storage before resolving. Callers that upload or hash + * the archive immediately after `zip(...)` otherwise risk reading a partial file + * (see #323 / #355-class races). + */ +- (void)synchronizeZipFileAtPath:(NSString *)path { + int fd = open(path.fileSystemRepresentation, O_RDONLY); + if (fd < 0) { + return; + } + fsync(fd); + close(fd); +} + - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath paths:(NSArray *)paths compressionLevel:(int)compressionLevel @@ -711,6 +751,9 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath } } success &= [zipArchive close]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } } return success; } @@ -729,7 +772,13 @@ - (void)zipFiles:(NSArray *)from BOOL success; [self setProgressHandler]; - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:nil AES:NO]; + // Honor the requested compression level (previously ignored for file arrays) and + // never enable AES for plaintext zips — both matter for Node/Java unzippers (#333, #323). + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:nil + AES:NO]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -759,14 +808,17 @@ - (void)zipFolderWithPassword:(NSString *)from BOOL success; [self setProgressHandler]; - BOOL useAES = encryptionType && [encryptionType length] > 0 && ![encryptionType isEqualToString:@"STANDARD"]; + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; success = [SSZipArchive createZipFileAtPath:destinationPath withContentsOfDirectory:from keepParentDirectory:NO - compressionLevel:compressionLevel + compressionLevel:[self zlibCompressionLevel:compressionLevel] password:password AES:useAES progressHandler:self.progressHandler]; + if (success) { + [self synchronizeZipFileAtPath:destinationPath]; + } self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% @@ -796,9 +848,14 @@ - (void)zipFilesWithPassword:(NSArray *)from BOOL success; [self setProgressHandler]; - // Note: entries are written with AES:YES, matching the previous behavior of - // createZipFileAtPath:withFilesAtPaths: (which routes through AES:YES writes) - success = [self writeZipEntriesToPath:destinationPath paths:from compressionLevel:Z_DEFAULT_COMPRESSION password:password AES:YES]; + // Prefer STANDARD (ZipCrypto) unless the caller explicitly requests AES. + // Always-on AES was a common source of "works on device, fails on server" reports. + BOOL useAES = [self usesAESForEncryptionType:encryptionType]; + success = [self writeZipEntriesToPath:destinationPath + paths:from + compressionLevel:[self zlibCompressionLevel:compressionLevel] + password:password + AES:useAES]; self.progress = 1.0; [self zipArchiveProgressEvent:1 total:1]; // force 100% diff --git a/package.json b/package.json index e918b076..d68ed07b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.2.0", + "version": "9.3.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/scripts/validate-zip-header.js b/scripts/validate-zip-header.js new file mode 100755 index 00000000..93ad91af --- /dev/null +++ b/scripts/validate-zip-header.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Lightweight ZIP interoperability check for archives produced by this library. + * + * Validates: + * - local file header signature 0x04034b50 + * - end-of-central-directory signature 0x06054b50 + * + * Usage: node scripts/validate-zip-header.js [more.zip ...] + */ +const fs = require('fs'); + +const LOCAL_FILE_HEADER = 0x04034b50; +const END_OF_CENTRAL_DIR = 0x06054b50; + +function readUInt32LE(buf, offset) { + return buf.readUInt32LE(offset); +} + +function validateZip(filePath) { + const buf = fs.readFileSync(filePath); + if (buf.length < 22) { + throw new Error(`${filePath}: file too small to be a zip (${buf.length} bytes)`); + } + + const localSig = readUInt32LE(buf, 0); + if (localSig !== LOCAL_FILE_HEADER) { + throw new Error( + `${filePath}: bad local header signature 0x${localSig.toString(16)} (expected 0x04034b50)` + ); + } + + // EOCD is at the end; comment can make it earlier. Scan last 64KiB. + const scanFrom = Math.max(0, buf.length - 65557); + let eocd = -1; + for (let i = buf.length - 22; i >= scanFrom; i--) { + if (readUInt32LE(buf, i) === END_OF_CENTRAL_DIR) { + eocd = i; + break; + } + } + if (eocd < 0) { + throw new Error(`${filePath}: end-of-central-directory signature not found`); + } + + console.log(`OK ${filePath} (local=0x04034b50, eocd@${eocd})`); +} + +const files = process.argv.slice(2); +if (files.length === 0) { + console.error('Usage: node scripts/validate-zip-header.js ...'); + process.exit(2); +} + +let failed = false; +for (const file of files) { + try { + validateZip(file); + } catch (err) { + console.error(String(err.message || err)); + failed = true; + } +} +process.exit(failed ? 1 : 0); From 150dd45857594d13fea8204d16433efbd4b2bb9e Mon Sep 17 00:00:00 2001 From: Perry Date: Sat, 15 Aug 2026 22:57:48 +0800 Subject: [PATCH 2/4] feat: close iOS/Android platform parity gaps (#368) (#372) Rebased onto #371 after #369 was squash-merged. Co-authored-by: Cursor Agent --- .maestro/flows/_assets-test.yaml | 33 ++--- CHANGELOG.md | 10 ++ README.md | 24 ++-- RNZipArchive.podspec | 2 +- ios/RNZipArchive.mm | 122 +++++++++++++++--- package.json | 2 +- playground-expo/app/assets.tsx | 23 +--- playground-expo/app/index.tsx | 2 +- playground-expo/assets/sample.zip | Bin 0 -> 398 bytes .../project.pbxproj | 4 + .../ios/RNZipArchivePlayground/sample.zip | Bin 0 -> 398 bytes .../PlaygroundRN.xcodeproj/project.pbxproj | 4 + playground-rn/ios/PlaygroundRN/sample.zip | Bin 0 -> 398 bytes playground-rn/src/screens/AssetsScreen.tsx | 23 +--- playground-rn/src/screens/HomeScreen.tsx | 2 +- 15 files changed, 159 insertions(+), 92 deletions(-) create mode 100644 playground-expo/assets/sample.zip create mode 100644 playground-expo/ios/RNZipArchivePlayground/sample.zip create mode 100644 playground-rn/ios/PlaygroundRN/sample.zip diff --git a/.maestro/flows/_assets-test.yaml b/.maestro/flows/_assets-test.yaml index 731bcdcf..b07726b2 100644 --- a/.maestro/flows/_assets-test.yaml +++ b/.maestro/flows/_assets-test.yaml @@ -2,29 +2,20 @@ appId: ${APP_ID} --- - runFlow: when: - notVisible: "Assets (Android)" + notVisible: "Bundled Assets" commands: - tapOn: "Playground" - scrollUntilVisible: element: - text: "Assets (Android)" + text: "Bundled Assets" direction: DOWN -- tapOn: "Assets (Android)" -- runFlow: - when: - visible: "Not Supported" - commands: - - assertVisible: "Not Supported" -- runFlow: - when: - visible: "Android Assets Demo" - commands: - - assertVisible: "Android Assets Demo" - - tapOn: "Unzip Assets" - - waitForAnimationToEnd: - timeout: 10000 - - assertVisible: "Extracted To" - - extendedWaitUntil: - visible: "Files:" - timeout: 10000 - - assertVisible: "Files:" +- tapOn: "Bundled Assets" +- assertVisible: "Bundled Assets Demo" +- tapOn: "Unzip Assets" +- waitForAnimationToEnd: + timeout: 10000 +- assertVisible: "Extracted To" +- extendedWaitUntil: + visible: "Files:" + timeout: 10000 +- assertVisible: "Files:" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3de9498d..ba35f567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [9.4.0] - 2026-07-25 + +### Added +- iOS: `unzipAssets` reads archives from the main app bundle (parity with Android `assets/`) (#368) +- iOS: preserve empty directories when zipping directory items in a files array (#368) + +### Changed +- iOS: non-UTF-8 `charset` arguments now reject with `ERR_UNSUPPORTED` instead of being silently ignored (#368) +- iOS: `getUncompressedSize` rejects on failure (previously resolved `-1`) for parity with Android + ## [9.3.0] - 2026-07-25 ### Changed diff --git a/README.md b/README.md index c99e0bf7..bbdd515d 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ Or with an explicit charset: unzip(sourcePath, targetPath, 'UTF-8', ['readme.md', 'docs']) ``` -> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored. +> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`. ```js const sourcePath = `${DocumentDirectoryPath}/myFile.zip` @@ -162,7 +162,7 @@ type ZipEntry = { } ``` -> The `charset` parameter is only supported on Android (default: `UTF-8`). On iOS it is ignored. +> The `charset` parameter defaults to `UTF-8`. On Android, other charsets are supported. On iOS, non-UTF-8 values reject with `ERR_UNSUPPORTED`. ```js listContents(sourcePath) @@ -176,9 +176,12 @@ listContents(sourcePath) ### `unzipAssets(assetPath: string, target: string): Promise` -Unzip a file from the Android `assets` folder. **Android only.** +Unzip a bundled archive. -`assetPath` is the relative path inside the pre-bundled assets folder (e.g. `folder/myFile.zip`). Do not pass an absolute path. +- **Android:** relative path inside the APK `assets/` folder (also accepts `content://` URIs). +- **iOS:** relative path inside the main app bundle (e.g. a file copied with Xcode “Copy Bundle Resources”). + +Do not pass an absolute filesystem path. ```js unzipAssets('./myFile.zip', DocumentDirectoryPath) @@ -265,21 +268,22 @@ useEffect(() => { | `zip` (files array) | ✅ | ✅ | — | | `zipWithPassword` (folder) | ✅ | ✅ | Prefer `STANDARD` for server unzip | | `zipWithPassword` (files array) | ✅ | ✅ | iOS honors `STANDARD` vs AES | -| `unzip` | ✅ | ✅ | Optional `entries` for selective extract; charset ignored on iOS | +| `unzip` | ✅ | ✅ | Optional `entries`; non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | | `unzipWithPassword` | ✅ | ✅ | Optional `entries` for selective extract | -| `listContents` | ✅ | ✅ | Charset ignored on iOS | -| `unzipAssets` | ❌ | ✅ | Android only | +| `listContents` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | +| `unzipAssets` | ✅ | ✅ | Android `assets/` (+ `content://`); iOS main bundle | | `cancel` | ✅ | ✅ | Best-effort mid-operation abort | | `isPasswordProtected` | ✅ | ✅ | — | -| `getUncompressedSize` | ✅ | ✅ | Charset ignored on iOS | +| `getUncompressedSize` | ✅ | ✅ | Non-UTF-8 charset → `ERR_UNSUPPORTED` on iOS | | Progress Events | ✅ | ✅ | File path empty on iOS for zip | ### Cross-Platform Notes - **Compression levels:** Android supports 0–9 for all operations. iOS supports 0–9 for folder and file-array zips. - **Encryption:** Android supports AES-128, AES-256, and Standard ZIP encryption for all operations. On iOS, pass `'STANDARD'` (default) for ZipCrypto archives that Node `unzipper` / Java `ZipInputStream` can read; `'AES-128'` / `'AES-256'` produce WinZip-AES archives that many server tools cannot open. -- **Charset:** Android supports custom charsets (default UTF-8). iOS always uses UTF-8. -- **unzipAssets:** Supports `assets/` folder and `content://` URIs on Android. Not supported on iOS. +- **Charset:** Android supports custom charsets (default UTF-8). iOS accepts only UTF-8; other values reject with `ERR_UNSUPPORTED`. +- **unzipAssets:** Android reads `assets/` (and `content://`). iOS reads from the main app bundle using the same relative path. +- **Empty directories:** Preserved when zipping directory contents via a files/folders array on both platforms. ### Server-side unzip interoperability diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index 9e12d730..88efbd78 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -20,7 +20,7 @@ Pod::Spec.new do |s| end s.dependency 'SSZipArchive', '~>2.5.5' s.pod_target_xcconfig = { - 'HEADER_SEARCH_PATHS' => '$(inherited) "${PODS_ROOT}/SSZipArchive" "${PODS_ROOT}/SSZipArchive/SSZipArchive/minizip"' + 'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive" "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"' } s.source_files = 'ios/*.{h,m,mm}' diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index eb2e6517..5d9d6a53 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -9,8 +9,10 @@ #import "RNZipArchive.h" #if __has_include() #import -#else +#elif __has_include("mz_compat.h") #import "mz_compat.h" +#else +#import "unzip.h" #endif #import #import @@ -127,13 +129,35 @@ - (void)isPasswordProtected:(NSString *)file }]; } +- (BOOL)isUtf8Charset:(NSString *)charset { + if (charset == nil || charset.length == 0) { + return YES; + } + NSString *normalized = [[charset stringByReplacingOccurrencesOfString:@"-" withString:@""] + lowercaseString]; + return [normalized isEqualToString:@"utf8"]; +} + +- (BOOL)rejectIfUnsupportedCharset:(NSString *)charset + reject:(RCTPromiseRejectBlock)reject { + if ([self isUtf8Charset:charset]) { + return NO; + } + reject(kZipErrUnsupported, + [NSString stringWithFormat:@"charset '%@' is not supported on iOS (UTF-8 only)", charset], + nil); + return YES; +} + - (void)unzip:(NSString *)from destinationPath:(NSString *)destinationPath charset:(NSString *)charset entries:(NSArray *)entries resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)charset; + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self beginOperation]; [self runAsync:^{ if (entries != nil && entries.count > 0) { @@ -174,7 +198,9 @@ - (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 + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self beginOperation]; [self runAsync:^{ zipFile zip = unzOpen(source.fileSystemRepresentation); @@ -663,10 +689,11 @@ - (void)zipFolder:(NSString *)from }]; } -// Expands `paths` into (full path, entry name) pairs. Files keep their base -// name; directory contents are added recursively with entry names relative to -// the listed directory (e.g. "a.txt", "sub/b.txt"), matching Android's -// zip(string[]) behavior (#339). Directory entries themselves are not written. +// Expands `paths` into (full path, entry name, kind) triples. +// kind is @"file" or @"dir". Files keep their base name; directory contents are +// added recursively with entry names relative to the listed directory +// (e.g. "a.txt", "sub/b.txt"), matching Android's zip(string[]) behavior (#339). +// Empty directories are preserved as directory entries for Android parity (#368). // Returns nil if any path does not exist. - (NSArray *> *)expandedZipEntries:(NSArray *)paths { NSFileManager *fileManager = [[NSFileManager alloc] init]; @@ -677,7 +704,7 @@ - (void)zipFolder:(NSString *)from return nil; } if (!isDirectory) { - [entries addObject:@[path, path.lastPathComponent]]; + [entries addObject:@[path, path.lastPathComponent, @"file"]]; continue; } NSDirectoryEnumerator *enumerator = [fileManager enumeratorAtPath:path]; @@ -687,9 +714,13 @@ - (void)zipFolder:(NSString *)from BOOL childIsDirectory = NO; [fileManager fileExistsAtPath:fullPath isDirectory:&childIsDirectory]; if (childIsDirectory) { + NSArray *children = [fileManager contentsOfDirectoryAtPath:fullPath error:nil]; + if (children.count == 0) { + [entries addObject:@[fullPath, relativePath, @"dir"]]; + } continue; } - [entries addObject:@[fullPath, relativePath]]; + [entries addObject:@[fullPath, relativePath, @"file"]]; } } return entries; @@ -744,7 +775,16 @@ - (BOOL)writeZipEntriesToPath:(NSString *)destinationPath success = NO; break; } - success &= [zipArchive writeFileAtPath:entry[0] withFileName:entry[1] compressionLevel:compressionLevel password:password AES:aes]; + NSString *kind = entry.count > 2 ? entry[2] : @"file"; + if ([kind isEqualToString:@"dir"]) { + success &= [zipArchive writeFolderAtPath:entry[0] withFolderName:entry[1] withPassword:password]; + } else { + success &= [zipArchive writeFileAtPath:entry[0] + withFileName:entry[1] + compressionLevel:compressionLevel + password:password + AES:aes]; + } if (self.progressHandler) { complete++; self.progressHandler(complete, total); @@ -874,17 +914,19 @@ - (void)getUncompressedSize:(NSString *)path charset:(NSString *)charset resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - (void)charset; + if ([self rejectIfUnsupportedCharset:charset reject:reject]) { + return; + } [self beginOperation]; [self runAsync:^{ - NSError *error = nil; - NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; + NSError *error = nil; + NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; - if (error == nil) { - resolve(wantedFileSize); - } else { - resolve(@-1); - } + if (error == nil) { + resolve(wantedFileSize); + } else { + reject(kZipErrCorruptArchive, error.localizedDescription ?: @"Failed to get uncompressed size", error); + } }]; } @@ -892,9 +934,47 @@ - (void)unzipAssets:(NSString *)source target:(NSString *)target resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - // iOS doesn't have assets like Android, return error - NSError *error = [NSError errorWithDomain:@"RNZipArchive" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"unzipAssets is not supported on iOS"}]; - reject(kZipErrUnsupported, @"unzipAssets is not supported on iOS", error); + // Android reads from the APK assets/ folder. On iOS, map the same relative + // path onto the main app bundle so playground/docs can share one API (#368). + if (source.length == 0) { + reject(kZipErrInvalidArgs, @"asset path must not be empty", nil); + return; + } + + NSString *normalized = source; + while ([normalized hasPrefix:@"./"]) { + normalized = [normalized substringFromIndex:2]; + } + if ([normalized hasPrefix:@"/"]) { + normalized = [normalized substringFromIndex:1]; + } + + NSString *bundleRoot = [[NSBundle mainBundle] bundlePath]; + NSString *assetPath = [bundleRoot stringByAppendingPathComponent:normalized]; + if (![[NSFileManager defaultManager] fileExistsAtPath:assetPath]) { + // Also try pathForResource for files copied as bundle resources without folders. + NSString *resourceName = normalized.stringByDeletingPathExtension.lastPathComponent; + NSString *resourceExt = normalized.pathExtension; + NSString *resourceDir = normalized.stringByDeletingLastPathComponent; + if (resourceDir.length == 0) { + resourceDir = nil; + } + assetPath = [[NSBundle mainBundle] pathForResource:resourceName + ofType:resourceExt.length ? resourceExt : nil + inDirectory:resourceDir]; + } + + if (assetPath.length == 0 || ![[NSFileManager defaultManager] fileExistsAtPath:assetPath]) { + reject(kZipErrFileNotFound, + [NSString stringWithFormat:@"Asset file `%@` could not be opened from the app bundle", source], + nil); + return; + } + + [self beginOperation]; + [self runAsync:^{ + [self unzipFile:assetPath destinationPath:target password:nil resolve:resolve reject:reject]; + }]; } - (void)addListener:(NSString *)eventName { diff --git a/package.json b/package.json index d68ed07b..10f2ab3b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.3.0", + "version": "9.4.0", "description": "A TurboModule wrapper on ZipArchive for React Native's New Architecture", "main": "index.js", "scripts": { diff --git a/playground-expo/app/assets.tsx b/playground-expo/app/assets.tsx index ea555ebe..04103b96 100644 --- a/playground-expo/app/assets.tsx +++ b/playground-expo/app/assets.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - View, Text, StyleSheet, ScrollView, @@ -44,26 +43,14 @@ export default function AssetsScreen() { } }; - if (Platform.OS !== 'android') { - return ( - - - - unzipAssets is only available on Android. iOS apps should use the main bundle or other asset mechanisms. - - - - - ); - } - return ( - Android Assets Demo + Bundled Assets Demo - Unzip a pre-bundled asset file (sample.zip) from the Android assets folder. - Make sure sample.zip exists in{' '} - android/app/src/main/assets/. + Unzip a pre-bundled sample.zip + {Platform.OS === 'android' + ? ' from android/app/src/main/assets/.' + : ' from the iOS app bundle (Copy Bundle Resources).'} diff --git a/playground-expo/app/index.tsx b/playground-expo/app/index.tsx index e2aa351e..c7bd72f0 100644 --- a/playground-expo/app/index.tsx +++ b/playground-expo/app/index.tsx @@ -17,7 +17,7 @@ const DEMOS = [ { 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' }, - { href: '/assets' as const, title: 'Assets (Android)', desc: 'Unzip bundled assets on Android' }, + { href: '/assets' as const, title: 'Bundled Assets', desc: 'Unzip Android assets / iOS bundle resources' }, ]; export default function HomeScreen() { diff --git a/playground-expo/assets/sample.zip b/playground-expo/assets/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj b/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj index 4c3440ff..494f9ff8 100644 --- a/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj +++ b/playground-expo/ios/RNZipArchivePlayground.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; DBAA7EA6CD08B42E8510EF79 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = EFB54E4CD17F3FB2EC627D4B /* PrivacyInfo.xcprivacy */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; + A1B2C3D45E6F708192A3B4C5 /* sample.zip in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D35E6F708192A3B4C5 /* sample.zip */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -30,6 +31,7 @@ F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = RNZipArchivePlayground/AppDelegate.swift; sourceTree = ""; }; F11748442D0722820044C1D9 /* RNZipArchivePlayground-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "RNZipArchivePlayground-Bridging-Header.h"; path = "RNZipArchivePlayground/RNZipArchivePlayground-Bridging-Header.h"; sourceTree = ""; }; F3CD0FA6E823EC0EFCAB415A /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-RNZipArchivePlayground/ExpoModulesProvider.swift"; sourceTree = ""; }; + A1B2C3D35E6F708192A3B4C5 /* sample.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; name = sample.zip; path = RNZipArchivePlayground/sample.zip; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +64,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, EFB54E4CD17F3FB2EC627D4B /* PrivacyInfo.xcprivacy */, + A1B2C3D35E6F708192A3B4C5 /* sample.zip */, ); name = RNZipArchivePlayground; sourceTree = ""; @@ -198,6 +201,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, DBAA7EA6CD08B42E8510EF79 /* PrivacyInfo.xcprivacy in Resources */, + A1B2C3D45E6F708192A3B4C5 /* sample.zip in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/playground-expo/ios/RNZipArchivePlayground/sample.zip b/playground-expo/ios/RNZipArchivePlayground/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj b/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj index 3ecbe55a..22699e98 100644 --- a/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj +++ b/playground-rn/ios/PlaygroundRN.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 97523AD4E6E87C2182825509 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = E5EBC47BA2EB7F034C1ACD13 /* main.m */; }; DB69DB97B85F813FDF0E001E /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = B21341DD1A1109A08737F2E2 /* AppDelegate.mm */; }; E0B8E392EC6F22AC59BCBB21 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + F7C3A1B25E9D4A0F8C2B6D11 /* sample.zip in Resources */ = {isa = PBXBuildFile; fileRef = F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +29,7 @@ D55102F37F12E8BE1D6159A2 /* libPods-PlaygroundRN.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-PlaygroundRN.a"; sourceTree = BUILT_PRODUCTS_DIR; }; E5EBC47BA2EB7F034C1ACD13 /* main.m */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.objc; name = main.m; path = PlaygroundRN/main.m; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; name = sample.zip; path = PlaygroundRN/sample.zip; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -49,6 +51,7 @@ 13B07FB61A68108700A75B9A /* Info.plist */, 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */, 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */, + F7C3A1B15E9D4A0F8C2B6D11 /* sample.zip */, ); name = PlaygroundRN; sourceTree = ""; @@ -166,6 +169,7 @@ 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, E0B8E392EC6F22AC59BCBB21 /* PrivacyInfo.xcprivacy in Resources */, + F7C3A1B25E9D4A0F8C2B6D11 /* sample.zip in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/playground-rn/ios/PlaygroundRN/sample.zip b/playground-rn/ios/PlaygroundRN/sample.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6b085575ba507e32369ee1b7ca89ce84556cd4d GIT binary patch literal 398 zcmWIWW@h1H009@<%`spGln`N%VJJ?_EyzjLt;#IWP0r6NNzE%M)(;KgWMICqm^%xE zODnh;7+JnDGBB`!v<0A;mSA+MM+_(m!m>C_%Sg@1$=55XD8Xl}2S|}ZT2X$k0>njn zKo@ZZcr!A|G2?Q)1js!=Ai(g}5kzD63M<4b7~ViN8Py{YlYw4lSkmZ+!(=3H;j)62 Q4dg5)AlwV2cY`<#02(+~asU7T literal 0 HcmV?d00001 diff --git a/playground-rn/src/screens/AssetsScreen.tsx b/playground-rn/src/screens/AssetsScreen.tsx index fe85fc80..11c4a758 100644 --- a/playground-rn/src/screens/AssetsScreen.tsx +++ b/playground-rn/src/screens/AssetsScreen.tsx @@ -1,6 +1,5 @@ import React, { useState } from 'react'; import { - View, Text, StyleSheet, ScrollView, @@ -44,26 +43,14 @@ export default function AssetsScreen() { } }; - if (Platform.OS !== 'android') { - return ( - - - - unzipAssets is only available on Android. iOS apps should use the main bundle or other asset mechanisms. - - - - - ); - } - return ( - Android Assets Demo + Bundled Assets Demo - Unzip a pre-bundled asset file (sample.zip) from the Android assets folder. - Make sure sample.zip exists in{' '} - android/app/src/main/assets/. + Unzip a pre-bundled sample.zip + {Platform.OS === 'android' + ? ' from android/app/src/main/assets/.' + : ' from the iOS app bundle (Copy Bundle Resources).'} diff --git a/playground-rn/src/screens/HomeScreen.tsx b/playground-rn/src/screens/HomeScreen.tsx index df8cebe5..ebf65148 100644 --- a/playground-rn/src/screens/HomeScreen.tsx +++ b/playground-rn/src/screens/HomeScreen.tsx @@ -31,7 +31,7 @@ const DEMOS = [ { 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' }, - { screen: 'Assets' as const, title: 'Assets (Android)', desc: 'Unzip bundled assets on Android' }, + { screen: 'Assets' as const, title: 'Bundled Assets', desc: 'Unzip Android assets / iOS bundle resources' }, ]; export default function HomeScreen() { From d47d3ca915ddc7adeb4193230d3f93c4139be1ea Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 15:08:25 +0000 Subject: [PATCH 3/4] fix: restore Maestro install retries and align unzip failure progress Keep the hardened E2E Maestro install from master (retry + version check). Emit 0% progress when iOS unzip/unzipAssets fails, matching Android. Document the iOS file-array ZipCrypto default in MIGRATION.md. Co-authored-by: Perry --- .github/workflows/e2e.yml | 38 ++++++++++++++++++++++++++++++++++---- CHANGELOG.md | 3 +++ MIGRATION.md | 29 +++++++++++++++++++++++++++++ ios/RNZipArchive.mm | 9 ++++++--- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7d8c40c0..ce11f1e3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -46,8 +46,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 @@ -159,8 +174,23 @@ jobs: - name: Install Maestro run: | - curl -fsSL "https://get.maestro.mobile.dev" | bash - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + set -euo pipefail + for attempt in 1 2 3; do + echo "Installing Maestro (attempt $attempt)..." + if curl -fsSL "https://get.maestro.mobile.dev" | bash \ + && test -x "$HOME/.maestro/bin/maestro"; then + break + fi + echo "Maestro install failed on attempt $attempt" + rm -rf "$HOME/.maestro" + if [ "$attempt" -eq 3 ]; then + echo "Maestro install failed after 3 attempts" + exit 1 + fi + sleep $((attempt * 5)) + done + echo "$HOME/.maestro/bin" >> "$GITHUB_PATH" + "$HOME/.maestro/bin/maestro" --version - name: Cache node_modules uses: actions/cache@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index ba35f567..100cfbc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ - iOS: non-UTF-8 `charset` arguments now reject with `ERR_UNSUPPORTED` instead of being silently ignored (#368) - iOS: `getUncompressedSize` rejects on failure (previously resolved `-1`) for parity with Android +### Fixed +- iOS: `unzip` / `unzipAssets` emit 0% progress on failure (matches Android) instead of a 100% event before reject + ## [9.3.0] - 2026-07-25 ### Changed diff --git a/MIGRATION.md b/MIGRATION.md index 84307b3e..e267eaeb 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,34 @@ # Migration Guide +## v9.2 / v9.3 / v9.4 + +These releases add APIs and align iOS with Android. Most JavaScript call sites keep working; the notes below are the native/default changes that existing apps may observe. + +### iOS file-array `zipWithPassword` default is ZipCrypto (9.3.0) + +On iOS, `zipWithPassword([files], target, password)` used to always write **WinZip-AES**, even when `encryptionType` was omitted. It now follows `encryptionType` the same way folders and Android do: + +| Call | Before 9.3 (iOS file array) | 9.3+ | +|---|---|---| +| `zipWithPassword(files, dest, password)` | WinZip-AES | ZipCrypto (`STANDARD`) | +| `zipWithPassword(files, dest, password, 'STANDARD')` | WinZip-AES | ZipCrypto | +| `zipWithPassword(files, dest, password, 'AES-256')` | WinZip-AES | WinZip-AES | + +ZipCrypto is **weaker encryption** than AES. It is the default so Node `unzipper`, Java `ZipInputStream`, and stock `unzip` can open the archive. Pass `'AES-128'` or `'AES-256'` if you need AES. + +Existing AES archives are unchanged; only newly created file-array zips on iOS pick up the new default. + +### Other 9.2–9.4 notes + +- **9.2:** `cancel()` and stable `ErrorCodes` (`ERR_CANCELLED`, `ERR_WRONG_PASSWORD`, …). +- **9.2:** Android `'STANDARD'` encryption is ZipCrypto (`ZIP_STANDARD`), not PKWARE Strong Encryption. +- **9.4:** iOS `unzipAssets` reads from the app bundle; non-UTF-8 `charset` rejects with `ERR_UNSUPPORTED`; `getUncompressedSize` rejects on failure instead of resolving `-1`. + +```bash +npm install react-native-zip-archive@^9.4.0 +cd ios && pod install && cd .. +``` + ## v8.x to v9.0 ### What's Changed diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index 5d9d6a53..d939eb7a 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -631,14 +631,15 @@ - (void)unzipFile:(NSString *)from } completionHandler:nil]; - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - if (self.cancelled) { reject(kZipErrCancelled, @"Operation cancelled", nil); } else if (success) { + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; // force 100% resolve(destinationPath); } else { + self.progress = 0.0; + [self zipArchiveProgressEvent:0 total:1]; NSString *errorMessage = error ? [error localizedDescription] : @"unable to unzip"; NSString *code = kZipErrUnzip; NSString *lower = errorMessage.lowercaseString; @@ -937,6 +938,7 @@ - (void)unzipAssets:(NSString *)source // Android reads from the APK assets/ folder. On iOS, map the same relative // path onto the main app bundle so playground/docs can share one API (#368). if (source.length == 0) { + [self zipArchiveProgressEvent:0 total:1]; reject(kZipErrInvalidArgs, @"asset path must not be empty", nil); return; } @@ -965,6 +967,7 @@ - (void)unzipAssets:(NSString *)source } if (assetPath.length == 0 || ![[NSFileManager defaultManager] fileExistsAtPath:assetPath]) { + [self zipArchiveProgressEvent:0 total:1]; reject(kZipErrFileNotFound, [NSString stringWithFormat:@"Asset file `%@` could not be opened from the app bundle", source], nil); From c713a5e18933a8dfdc6961cb958ac50fc36e56d1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 15:08:38 +0000 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20point=20README=20migrating=20sectio?= =?UTF-8?q?n=20at=20v9.2=E2=80=93v9.4=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Perry --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bbdd515d..3caaf7ba 100644 --- a/README.md +++ b/README.md @@ -311,9 +311,9 @@ Two fully-featured playground apps are included to demonstrate every API method: Both apps consume the local library via `file:..` and include Maestro E2E tests. -## Migrating from v7 +## Migrating -See [MIGRATION.md](./MIGRATION.md) for detailed migration instructions. +See [MIGRATION.md](./MIGRATION.md) for v7 → v8, v8 → v9.0, and v9.2–v9.4 notes. ## Testing