feat(ios): apply binary patch updates - #155
Draft
floyd-soomgo wants to merge 2 commits into
Draft
Conversation
An update published with a binary patch offers two archives of the same contents, and iOS now installs from the smaller one: the patch archive is downloaded first, the JS bundle it carries only a patch of is rebuilt against the bundle inside the app binary, and what is left is byte for byte what the full archive would have delivered - so the folder hash check that follows the install is unchanged and stays the last line of defence. Nothing about a patch archive is trusted. Its manifest is read before anything is applied and refused when it is not the format this client can apply, when its paths reach outside the archive, or when it promises a bundle larger than a bundle can be. Neither the diff format nor the zstd streams inside it carry a checksum of the data they produce, so the base bundle is hashed before the patch is applied and the restored bundle afterwards, and the restored bytes only reach the update contents once both checks have passed. Every failure downloads the full archive instead, exactly once and with nothing stored to count it: the full archive is fetched by a call that cannot take the patch path, so it has no failure of its own to fall back from. A patch URL that answers with something other than an archive is one of those failures rather than a bare JS bundle to move into place, which would install bytes no hash was ever checked against. The applier is the C code the other platform and the host build compile as well, referenced where it lives rather than copied here, and the pod builds it - which is what keeps the appliers of the platforms from drifting apart.
The iOS client installs an update from its patch archive too, so the paragraph about what a released patch means for a client is no longer about one platform, and the pod section says what that adds to a consumer's iOS build: nothing. CocoaPods compiles the applier along with the rest of the pod, where Android needs the NDK and CMake for the same sources.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fifth PR of the binary differential OTA series (stacked on
feat(android): apply binary patch updates— merge that first).This PR gives iOS the same capability the previous PR gave Android: when
binaryPatchDownloadUrlis present in the update metadata, the client downloads the patch archive, applies the patch against the bundle embedded in the app binary, verifies the result at every step, and installs the reconstructed contents. Any failure at any patch stage falls back to the full archive exactly once, structurally: the full-path invocation cannot reach the patch branch by construction — no counters, no persisted state. A package without the field takes the untouched legacy path.The implementation is a deliberate line-for-line counterpart of the Android applier, because the two platforms share only contracts, not artifacts:
algorithm/formatVersion→ path confinement inside the archive → size bound → available disk space → embedded-base read +baseBundleHash→ apply (with the patch header's compression type and old/new sizes cross-checked against the manifest before the patch runs) → reconstructed-bundle size +targetBundleHash→ move into place → existing folder-hash verification. The post-hash is the only defense against corrupted patch bodies (the compressed stream carries no content checksum), so it is never skipped, and unverified bytes never reach the final package folder.base_hash_mismatch,invalid_manifest,unsupported_format,patch_apply_failed,target_verification_failed,base_bundle_unavailable), logged through the existing channel with apply duration on success — the same staging-validation signal as Android.packageHash; the manifest is resolved inside it, not at the unzip root.invalid_manifestand falls back — it cannot flow into the pre-existing raw-bundle path.Changes
ios/CodePush/CodePushBinaryPatch.{h,m}— the validation/apply pipeline as a self-contained unit, mirroring the Android class of the same name. The C sources are called directly (no bridge layer): the samegetCompressedDiffInfo→ cross-check → sequential-write apply sequence as the desktop harness and the Android JNI wrapper, with the same result codes mapped to the same reasons. The base bundle is memory-mapped (NSDataReadingMappedIfSafe) rather than copied, and all reads use error-returning APIs so allocation or I/O failures become normal fallbacks instead of crashes. C headers are confined to the implementation file — the public header is pure Foundation.ios/CodePush/CodePushPackage.m— exactly two branch points: download-URL selection at the entry of the download flow, and patch application right after the SSZipArchive unzip, beside the existing diff-manifest detection; downstreamcopyEntriesInFolder→ folder-hash flow is untouched. The full path is invoked with the patch branch disabled, which is what makes the single fallback structural. Temp files live under<CodePushPath>/binary-patch/and are cleaned on success, fallback, and failure, including stale leftovers from a killed process._methodQueuethatCodePush.mmalready hands toCodePushPackage(inside the download completion callback). No new queues or threads; nothing runs on the main queue.CodePush.podspec— compiles the shared C sources atcpp/binarypatch/(HDiffPatch apply path, zstd decompress set, and the project's zstd adapter) as an explicit file list, which structurally excludes the host harness and all assembly files (ZSTD_DISABLE_ASM=1,_IS_USED_MULTITHREAD=0). Vendor header roots go throughHEADER_SEARCH_PATHSwith the headers aspreserve_paths, so ~40 vendor headers don't flatten into the pod's header directory where they could collide with other pods. The New Architecture branch overwritespod_target_xcconfigwholesale, so the vendor settings are merged into both branches — easy to miss and verified in both. Existing settings are only appended to. No extra requirement lands on consumers; CocoaPods handles everything.Note for JS consumers
A release that falls back downloads two archives, so download-progress can report
receivedBytes == totalBytestwice (once for the patch archive, then again for the full archive, restarting from zero). Promise semantics are unchanged. Android has the same shape.Test plan
Pod-level verification (both architecture branches):
RCT_NEW_ARCH_ENABLED=1 pod install+xcodebuildof the CodePush pod target on the RN 0.84 example: BUILD SUCCEEDED, all 12 shared C sources compiled,libCodePush.acarries the hpatch/zstd symbols, no warnings from new files.use_react_native!, so the flag cannot reach the old branch on the RN 0.84 example by design. Verified instead with a realRCT_NEW_ARCH_ENABLED=0 pod install+ build on the RN 0.81.6 example (the newest one that honors the flag), plus a direct evaluation of both podspec branches confirming the vendor settings in each.On-device end-to-end scenarios (real patch bytes, fallback drills on both platforms) land with the E2E PR later in the series.