-
Notifications
You must be signed in to change notification settings - Fork 8
Self-hosted OTA live updates for the native app #560
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
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
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
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| 0.48.0 |
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| """Native (Capacitor) over-the-air bundle endpoints. | ||
|
|
||
| Each Docker image bundles the Capacitor-flavored web build (a zip with ``index.html`` at | ||
| its root) under ``/app/ota`` so the native app can download the web bundle that matches the | ||
| backend it is talking to. The native app polls ``/native/bundle/manifest`` and, when the | ||
| served version differs from the bundle it is running, downloads ``/native/bundle/download`` | ||
| and swaps it in via ``@capgo/capacitor-updater`` (manual mode). | ||
|
|
||
| See ``backend/app/main.py`` for the analogous static/upload ``FileResponse`` patterns. | ||
| """ | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| from fastapi import APIRouter, HTTPException | ||
| from fastapi.responses import FileResponse | ||
|
|
||
| from app.core.config import settings | ||
| from app.core.messages import NativeMessages | ||
| from app.core.version import __version__, get_min_native_version | ||
|
|
||
| router = APIRouter() | ||
|
|
||
| # Docker layout: /app/app/api/v1/endpoints/native.py -> parents[4] == /app | ||
| # The Dockerfile copies the OTA artifacts to /app/ota (see Dockerfile stage 2). | ||
| # In a local dev checkout this directory does not exist, so the endpoints 404 — the OTA | ||
| # flow is only exercised against a built image. | ||
| _OTA_DIR = Path(__file__).resolve().parents[4] / "ota" | ||
| _BUNDLE_PATH = _OTA_DIR / "bundle.zip" | ||
| _CHECKSUM_PATH = _OTA_DIR / "bundle.sha256" | ||
|
|
||
|
|
||
| @router.get("/native/bundle/manifest") | ||
| def get_bundle_manifest() -> dict[str, object]: | ||
| """Describe the OTA bundle this backend serves. | ||
|
|
||
| Returns the bundle ``version`` (equal to the app version), a ``url`` the client joins to | ||
| its server origin to download the zip, the ``checksum`` (sha256 hex) the updater verifies, | ||
| and ``minNativeVersion`` — the minimum native app (APK/IPA) version the bundle requires. | ||
| The client refuses the update (and prompts to update from the store) when its installed | ||
| native app version is older. | ||
| """ | ||
| if not _BUNDLE_PATH.is_file() or not _CHECKSUM_PATH.is_file(): | ||
| raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE) | ||
| return { | ||
| "version": __version__, | ||
| "url": f"{settings.API_V1_STR}/native/bundle/download", | ||
| "checksum": _CHECKSUM_PATH.read_text().strip(), | ||
| "minNativeVersion": get_min_native_version(), | ||
| } | ||
|
|
||
|
|
||
| @router.get("/native/bundle/download") | ||
| def download_bundle() -> FileResponse: | ||
| """Serve the Capacitor web bundle zip (immutable per version).""" | ||
| if not _BUNDLE_PATH.is_file(): | ||
| raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE) | ||
| return FileResponse( | ||
| _BUNDLE_PATH, | ||
| media_type="application/zip", | ||
| filename=f"initiative-{__version__}.zip", | ||
| headers={"Cache-Control": "public, max-age=31536000, immutable"}, | ||
| ) | ||
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| """Integration tests for the native OTA bundle endpoints. | ||
|
|
||
| The native (Capacitor) app polls these to decide whether to download a matching web bundle. | ||
| A local dev/test checkout has no ``/app/ota`` directory, so the endpoints must 404 cleanly; | ||
| when an image ships the artifacts, the manifest must advertise a checksum that actually | ||
| matches the served zip. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import hashlib | ||
|
|
||
| import pytest | ||
| from httpx import AsyncClient | ||
|
|
||
| from app.api.v1.endpoints import native | ||
| from app.core.messages import NativeMessages | ||
| from app.core.version import __version__ | ||
|
|
||
|
|
||
| @pytest.mark.integration | ||
| async def test_manifest_404_when_bundle_absent(client: AsyncClient): | ||
| """No OTA artifacts present (the default outside a built image) → 404, not a 500.""" | ||
| response = await client.get("/api/v1/native/bundle/manifest") | ||
| assert response.status_code == 404 | ||
| assert response.json()["detail"] == NativeMessages.OTA_BUNDLE_NOT_AVAILABLE | ||
|
|
||
|
|
||
| @pytest.mark.integration | ||
| async def test_download_404_when_bundle_absent(client: AsyncClient): | ||
| response = await client.get("/api/v1/native/bundle/download") | ||
| assert response.status_code == 404 | ||
| assert response.json()["detail"] == NativeMessages.OTA_BUNDLE_NOT_AVAILABLE | ||
|
|
||
|
|
||
| @pytest.mark.integration | ||
| async def test_manifest_advertises_matching_checksum( | ||
| client: AsyncClient, tmp_path, monkeypatch | ||
| ): | ||
| """The checksum in the manifest must be the sha256 of the exact zip the download | ||
| endpoint serves — the updater rejects the bundle otherwise.""" | ||
| bundle = tmp_path / "bundle.zip" | ||
| bundle.write_bytes(b"PK\x03\x04 fake zip payload") | ||
| digest = hashlib.sha256(bundle.read_bytes()).hexdigest() | ||
| checksum = tmp_path / "bundle.sha256" | ||
| checksum.write_text(f"{digest}\n") | ||
|
|
||
| monkeypatch.setattr(native, "_BUNDLE_PATH", bundle) | ||
| monkeypatch.setattr(native, "_CHECKSUM_PATH", checksum) | ||
|
|
||
| response = await client.get("/api/v1/native/bundle/manifest") | ||
| assert response.status_code == 200 | ||
| body = response.json() | ||
| assert body["version"] == __version__ | ||
| assert body["url"] == "/api/v1/native/bundle/download" | ||
| assert body["checksum"] == digest | ||
| assert isinstance(body["minNativeVersion"], str) | ||
|
|
||
|
|
||
| @pytest.mark.integration | ||
| async def test_download_serves_zip(client: AsyncClient, tmp_path, monkeypatch): | ||
| bundle = tmp_path / "bundle.zip" | ||
| payload = b"PK\x03\x04 fake zip payload" | ||
| bundle.write_bytes(payload) | ||
|
|
||
| monkeypatch.setattr(native, "_BUNDLE_PATH", bundle) | ||
|
|
||
| response = await client.get("/api/v1/native/bundle/download") | ||
| assert response.status_code == 200 | ||
| assert response.headers["content-type"] == "application/zip" | ||
| assert "immutable" in response.headers["cache-control"] | ||
| assert response.content == payload |
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
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
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
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
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
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.