Skip to content

feat(ios): apply binary patch updates - #155

Draft
floyd-soomgo wants to merge 2 commits into
feature/binary-patch-android-applyfrom
feature/binary-patch-ios-apply
Draft

feat(ios): apply binary patch updates#155
floyd-soomgo wants to merge 2 commits into
feature/binary-patch-android-applyfrom
feature/binary-patch-ios-apply

Conversation

@floyd-soomgo

Copy link
Copy Markdown
Member

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 binaryPatchDownloadUrl is 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:

  • Identical validation order — manifest presence/schema/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.
  • Identical fallback reason strings (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.
  • The same archive-layout rule: CLI archives nest all contents under a single root directory that participates in packageHash; the manifest is resolved inside it, not at the unzip root.
  • A patch download that turns out not to be a zip archive (e.g. an error page served with HTTP 200) is rejected as invalid_manifest and 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 same getCompressedDiffInfo → 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; downstream copyEntriesInFolder → 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.
  • Threading — base hash, apply, and target hash all run on the _methodQueue that CodePush.mm already hands to CodePushPackage (inside the download completion callback). No new queues or threads; nothing runs on the main queue.
  • CodePush.podspec — compiles the shared C sources at cpp/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 through HEADER_SEARCH_PATHS with the headers as preserve_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 overwrites pod_target_xcconfig wholesale, 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.
  • Docs — README section for the iOS integration; the CLI READMEs no longer describe patch support as Android-only.

Note for JS consumers

A release that falls back downloads two archives, so download-progress can report receivedBytes == totalBytes twice (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

npm run typecheck
npm run jest

Pod-level verification (both architecture branches):

  • New ArchitectureRCT_NEW_ARCH_ENABLED=1 pod install + xcodebuild of the CodePush pod target on the RN 0.84 example: BUILD SUCCEEDED, all 12 shared C sources compiled, libCodePush.a carries the hpatch/zstd symbols, no warnings from new files.
  • Old Architecture — RN ≥ 0.82 forces the New Architecture flag inside use_react_native!, so the flag cannot reach the old branch on the RN 0.84 example by design. Verified instead with a real RCT_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.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant