Self-hosted OTA live updates for the native app#560
Conversation
Serve the matching Capacitor web bundle from the backend so the native
app stays in sync with whatever server it connects to, replacing the
need to reinstall the APK every release (and avoiding paid live-update
services like Capgo/Capawesome cloud).
Backend:
- New /api/v1/native/bundle/{manifest,download} endpoints serving the
bundle zipped into the image at /app/ota, with a sha256 checksum.
- MIN_NATIVE_VERSION file (read like VERSION) gates web bundles that
need a newer native shell than the installed APK/IPA.
Native:
- @capgo/capacitor-updater (manual/self-hosted mode) downloads the
bundle and prompts Reload Now / Later; notifyAppReady() arms the
failed-boot auto-rollback. The native-compat gate compares
current().native to minNativeVersion (no custom native plugin).
Release/CI:
- promote.sh auto-bumps MIN_NATIVE_VERSION when the native shell
changes (capacitor config, android/ios, or @capacitor/@Capgo deps).
- docker-publish.yml skips the Android build for web-only releases;
they ship Docker-only and update over the air.
Also fixes the root .dockerignore to exclude node_modules/dist so
local image builds don't overlay host modules.
Greptile SummaryThis PR implements self-hosted OTA live updates for the Capacitor native app. Each Docker image ships a Capacitor-flavored web bundle under
Confidence Score: 4/5Safe to merge — the OTA core paths are well-guarded and the two previously flagged regressions are fixed; one edge case around errored cached bundles is the only remaining concern and it is non-blocking. The implementation is solid end-to-end: backend endpoints, Dockerfile artifact pipeline, CI gate logic, promote-script detection, and JS orchestration hook all look correct. The native-required repetition bug and the silent set() failure are both resolved in this commit. The one remaining rough edge is that CapacitorUpdater.list() may surface bundles that failed to download, and reusing them blocks subsequent fresh-download attempts — but it only surfaces in a narrow failure scenario and is non-data-loss. frontend/src/hooks/useNativeUpdate.tsx — the existing bundle reuse path around line 1261 Important Files Changed
Sequence DiagramsequenceDiagram
participant App as Native App (JS)
participant Updater as CapacitorUpdater
participant Backend as Backend API
Note over App: Launch / foreground resume
App->>App: notifyAppReady() [arms rollback]
App->>Backend: GET /api/v1/native/bundle/manifest
alt Bundle not in image (dev/old server)
Backend-->>App: 404
App->>App: No-op (keep current bundle)
else Bundle present
Backend-->>App: "{version, url, checksum, minNativeVersion}"
App->>App: decideNativeUpdate()
alt Already up-to-date
App->>App: return (no-op)
else "minNativeVersion > installed native"
App->>App: show NativeUpdateRequiredDialog
else Download needed
App->>Updater: list() — check for existing bundle
alt Bundle already cached
Updater-->>App: existing bundle
else Not cached
App->>Backend: GET /api/v1/native/bundle/download
Backend-->>App: bundle.zip (immutable, 1yr cache)
App->>Updater: "download({url, version, checksum})"
Updater-->>App: bundle object
end
App->>App: show VersionDialog (Reload Now / Later)
alt User taps Reload Now
App->>Updater: "set({id}) → WebView reloads"
App->>App: notifyAppReady() [new bundle armed]
else User taps Later
App->>App: dismiss (re-prompts on next cold start)
else set() throws
App->>App: keep dialog open for retry
end
end
end
Prompt To Fix All With AIFix the following 1 code review issue. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 1
frontend/src/hooks/useNativeUpdate.tsx:131-133
Errored bundles are reused without checking their status. If `@capgo/capacitor-updater` retains a failed or partially-downloaded bundle in the list (with a non-success status), `set({ id: bundle.id })` will throw, the dialog stays open, the user can dismiss it, but on the next cold start the same broken `existing` entry is found again and `download()` is never retried — leaving the user unable to receive this OTA version until the bundle is evicted from the plugin's cache. Filtering to only reuse bundles that completed successfully avoids the stuck state.
```suggestion
const existing = (await CapacitorUpdater.list()).bundles.find(
(b) => b.version === manifest.version && b.status === "success"
);
```
Reviews (4): Last reviewed commit: "Keep OTA reload prompt open if set() fai..." | Re-trigger Greptile |
- native.py: use NativeMessages.OTA_BUNDLE_NOT_AVAILABLE constant instead of inline HTTPException detail strings; assert it in tests; map the code in errors.json (en/es/fr). - useNativeUpdate: set handledVersionRef in the native-required branch so the 'App update required' dialog doesn't re-fire on every foreground. - docker-publish.yml: release job now also requires needs.build-android.result != 'failure', so a broken native build is a hard failure rather than a publish with misleading OTA messaging.
|
Thanks — all three findings addressed in 5fb5683:
@greptile |
|
@greptile |
If CapacitorUpdater.set() throws (e.g. the OS evicted the downloaded bundle), leave the reload dialog open so the user can retry instead of silently hiding it with no reload.
|
@greptile |
Filter CapacitorUpdater.list() to status 'success' so a retained error/partial bundle isn't reused (which would make set() throw and, since we'd keep finding it, never re-download — leaving the user stuck on that version).
Problem
Cutting a release publishes a Docker image and an APK. The web bundle is frozen inside the APK, so a user's mobile app only matches the backend if they reinstall the exact APK version for every release. Hosted live-update services (Capgo Cloud, Capawesome) solve this but cost a monthly fee.
What this does
The native app now downloads the web bundle that matches the backend it's connected to, fully self-hosted ($0/month). Each Docker image already contains that version's frontend — we additionally ship a Capacitor-flavored copy, expose it over the API, and teach the app to fetch + swap it.
Flow: on launch / foreground the app checks the server's bundle version; if it differs it silently downloads the bundle and prompts Reload Now / Later. A bundle that fails to boot auto-rolls-back to the previous one.
Changes
Backend
GET /api/v1/native/bundle/manifest({version, url, checksum, minNativeVersion}) andGET /api/v1/native/bundle/download(the zip), reading artifacts shipped at/app/ota—backend/app/api/v1/endpoints/native.py(+ tests, registered inapi.py).MIN_NATIVE_VERSIONfile (read likeVERSIONviaversion.py): the minimum native app version a web bundle requires. An OTA can only swap web assets, never native code, so the app refuses a bundle needing a newer shell and prompts a store/APK update instead.Image —
Dockerfileruns a secondbuild:capacitorpass, zips it (index.htmlat root) with a sha256, and copies it +MIN_NATIVE_VERSIONinto the image.Native —
@capgo/capacitor-updater(open-source, manual/self-hosted mode — not their cloud) synced into both Android and iOS projects.useNativeUpdateorchestrates the check/download/prompt;notifyAppReady()inmain.tsxarms the rollback. The native-compat gate comparesCapacitorUpdater.current().nativetominNativeVersion— no custom native code.Release/CI
scripts/promote.shauto-bumpsMIN_NATIVE_VERSIONto the release version when it detects a native change (capacitor.config.ts, committedfrontend/android·frontend/ios, or an added/removed/bumped@capacitor*/@capgodep).docker-publish.ymlgains adecidejob: web-only releases skip the Android build and ship Docker-only (apps update OTA); releases that change the native shell still build a fresh APK.Also — fixed the root
.dockerignore(it didn't excludenode_modules/dist, soCOPY frontend .overlaid the host's macOS modules and broke local image builds + bloated the context).Schema / artifact notes
MIN_NATIVE_VERSION(single source of truth, mirrorsVERSION).frontend/src/api/generated/native/+initiativeAPI.schemas.ts) for the new endpoints — CI'scheck-generated-typeswill pass.@capgo/capacitor-updater@^8;cap syncupdatedcapacitor.build.gradle,capacitor.settings.gradle, and the iOS SPMPackage.swift.en/es/frguilds.json.Testing
native_test.pycovers 404-when-absent, checksum-matches-zip, zip serving; + core)._OTA_DIRresolves to/app/ota, bundle hasindex.htmlat root (496 entries),bundle.sha256matches the computed digest exactly, and the manifest returns{version: 0.48.1, minNativeVersion: 0.48.0, url, checksum}.Not covered here (follow-ups)
bundle.zipto the GitHub Release; bundle signing beyond sha256; an xcconfig that readsMIN_NATIVE_VERSIONat iOS build time. There is no iOS CI build pipeline yet, so the iOS gate is wired but only exercised by manual builds.