codepush

Ship Dart fixes to installed Flutter apps without an app-store review.

Your app gets one file and one dependency line. Everything else — which bucket serves your patches, which key your releases trust, what has already been released — is resolved from the app id in that file.

# codepush.yaml
app_id: cp_7f3a9c2e5b1d4860
# pubspec.yaml
dependencies:
  codepush: ^0.1.0

That is the whole integration. No main.dart changes, no Android code, no pubspec asset entry.

What a patch can change

Dart. Not native code, not plugins, not assets, not permissions. A Dart-level bug fix or a Dart-level feature; anything else still needs a store build.

Android and iOS work differently

Android replaces libapp.so — all of your compiled Dart. The stock Flutter engine already knows how to boot from a different one, so Android needs no forked engine. A patch is a few megabytes.

iOS cannot load native code that was not signed into the bundle, so a patch there is Dart bytecode, run by the VM's interpreter, replacing function bodies in place. That needs a Flutter engine built with --dart-dynamic-modules, and the applier lives in a separate package:

dependencies:
  codepush: ^0.1.0
  codepush_ios: ^0.1.0   # only if you patch iOS

It is separate because it calls dart:_internal.applyCodePushPatch, which only the forked engine has — and a direct call to it fails to compile against a stock SDK. Keeping it out of this package is what lets an Android-only app, and every developer's debug build, use a stock Flutter.

Every patch is signed

The signing key is generated on your machine and never leaves it. The server holds only the public half, and your app has that half compiled in.

So a compromised server can withhold a patch or serve an old one. It cannot forge one — the signature will not verify against the key baked into the binary. The same is true of a stolen API key.

A device checks, in this order: signature, then app id, then revocation, then engine, then release, then rollout, then the artifact hash. The signature is first because every check after it reads a field out of the manifest, and those are attacker-controlled until it passes.

If a patch breaks something

A patch is only ever attempted. It stays live only if the app reaches a first frame; a crash before that quarantines it on the next launch, with no server involved.

You can also stop one everywhere:

codepush patches revoke 7 --reason "crash loop on Android 12"

Devices running it return to the shipped build on their next cold start. This is the one command that needs no signing key — requiring one to stop a bad patch would mean being unable to stop it from a machine that lacks it.

Showing a download

Optional, and off by default. Add the strip if you want one:

Stack(children: [child, const CodePushStatusStrip(bottomOffset: 92)])

It appears while a patch downloads and disappears when it is done. It says nothing on success, deliberately: the patch takes effect the next time the app is opened, so "update ready" would be an interruption offering no action.

Getting started

Install the CLI and register the app:

dart pub global activate codepush_cli
codepush login --token <key from your dashboard>
codepush init
codepush keys generate      # before your first release, always
codepush release android

See codepush_cli for the full workflow.

Libraries

codepush
iOS release-mode CodePush: patch released Dart code without an App Store review.