From 90e78671c232478c05f0bdcbe8d082030bbb7b4d Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Sun, 31 May 2026 22:25:01 -0700 Subject: [PATCH 1/4] Add self-hosted OTA live updates for native app 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. --- .dockerignore | 5 + .github/workflows/docker-publish.yml | 57 +++- CHANGELOG.md | 4 + CLAUDE.md | 9 + Dockerfile | 15 + MIN_NATIVE_VERSION | 1 + backend/app/api/v1/api.py | 3 +- backend/app/api/v1/endpoints/native.py | 61 ++++ backend/app/api/v1/endpoints/native_test.py | 69 ++++ backend/app/core/version.py | 20 ++ frontend/android/app/capacitor.build.gradle | 1 + frontend/android/capacitor.settings.gradle | 3 + frontend/capacitor.config.ts | 12 + frontend/ios/App/CapApp-SPM/Package.swift | 6 +- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 12 + frontend/public/locales/en/guilds.json | 5 +- frontend/public/locales/es/guilds.json | 5 +- frontend/public/locales/fr/guilds.json | 5 +- .../api/generated/initiativeAPI.schemas.ts | 2 + frontend/src/api/generated/native/native.ts | 321 ++++++++++++++++++ .../components/NativeUpdateRequiredDialog.tsx | 47 +++ frontend/src/components/VersionDialog.tsx | 10 + frontend/src/hooks/useNativeUpdate.test.ts | 77 +++++ frontend/src/hooks/useNativeUpdate.tsx | 191 +++++++++++ frontend/src/main.tsx | 10 + frontend/src/routes/_serverRequired.tsx | 31 +- scripts/promote.sh | 40 +++ 28 files changed, 1013 insertions(+), 10 deletions(-) create mode 100644 MIN_NATIVE_VERSION create mode 100644 backend/app/api/v1/endpoints/native.py create mode 100644 backend/app/api/v1/endpoints/native_test.py create mode 100644 frontend/src/api/generated/native/native.ts create mode 100644 frontend/src/components/NativeUpdateRequiredDialog.tsx create mode 100644 frontend/src/hooks/useNativeUpdate.test.ts create mode 100644 frontend/src/hooks/useNativeUpdate.tsx diff --git a/.dockerignore b/.dockerignore index 48c631b7..0df696e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,11 @@ backend/.coveragerc **/*.pyc backend/.venv +# Node / frontend build artifacts — installed fresh inside the image. Copying the host's +# (macOS) node_modules over the container's would corrupt the install and bloat the context. +**/node_modules +frontend/dist + # Version control .git .gitignore diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f1d96492..c1df2f4d 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,7 +16,44 @@ env: IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/initiative jobs: + decide: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + build_android: ${{ steps.decide.outputs.build_android }} + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Decide whether to build the native app + id: decide + run: | + # The Android/iOS app only needs rebuilding when the native shell changed — which + # promote.sh records by moving MIN_NATIVE_VERSION forward. Compare this ref's value + # against the previous release tag; web-only releases reuse the existing APK and + # update over the air. The committed file is the single source of truth, so both the + # workflow_dispatch and manual tag-push paths behave identically. + cur=$(cat MIN_NATIVE_VERSION 2>/dev/null || echo "") + prev=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$prev" ]; then + old=$(git show "$prev:MIN_NATIVE_VERSION" 2>/dev/null || echo "") + else + old="" + fi + if [ -z "$old" ] || [ "$old" != "$cur" ]; then + echo "build_android=true" >> "$GITHUB_OUTPUT" + echo "Native min-version changed ('$old' -> '$cur') or no prior value — building the app." + else + echo "build_android=false" >> "$GITHUB_OUTPUT" + echo "Native min-version unchanged ('$cur') — web-only release, skipping the app build." + fi + build-android: + needs: decide + if: ${{ needs.decide.outputs.build_android == 'true' }} runs-on: ubuntu-latest permissions: contents: read @@ -248,7 +285,10 @@ jobs: docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }} release: - needs: [build-android, merge-docker-public] + needs: [decide, build-android, merge-docker-public] + # Run even when build-android was skipped (web-only release). Only the Docker manifest + # must have succeeded; the APK is attached opportunistically below. + if: always() && needs.merge-docker-public.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -258,6 +298,7 @@ jobs: uses: actions/checkout@v6 - name: Download Android APK + if: needs.build-android.result == 'success' uses: actions/download-artifact@v8 with: name: android-apk @@ -281,6 +322,7 @@ jobs: env: IMAGE_NAME: ${{ env.IMAGE_NAME }} DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + APK_BUILT: ${{ needs.build-android.result == 'success' }} run: | VERSION="${{ steps.version.outputs.version }}" echo "Extracting changelog for version $VERSION" @@ -304,6 +346,16 @@ jobs: CHANGELOG="See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details." fi + # The native app is only rebuilt when the native shell changed (see the `decide` + # job). Web-only releases ship no new APK — existing installs update over the air. + if [ "$APK_BUILT" = "true" ]; then + ANDROID_SECTION="### Android App + Download the APK from the assets below and install on your Android device." + else + ANDROID_SECTION="### Android App + No new app build this release — installed apps update automatically over the air on next launch." + fi + # Build full release body with changelog + download info cat > release_body.md << ENDOFBODY $CHANGELOG @@ -312,8 +364,7 @@ jobs: ## Downloads - ### Android App - Download the APK from the assets below and install on your Android device. + $ANDROID_SECTION ### Docker Image \`\`\`bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 1de98f34..64db06d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Self-hosted over-the-air (OTA) app updates.** The native mobile app now downloads the web bundle that matches the backend it's connected to, so the frontend and backend stay in sync without reinstalling the APK for every release — no paid live-update service required. Each server build ships the matching Capacitor bundle; on launch and when returning to the foreground the app checks the server version and, if it differs, downloads the bundle and prompts "Reload now" (with a "Later" option). A failed update automatically rolls back to the previous bundle. When a release changes native code (not just web assets), the app detects that its installed shell is too old and asks you to update from the store/APK instead. Releases that only change web assets no longer rebuild the APK — they update entirely over the air. + ## [0.48.1] - 2026-05-31 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 7da2fce5..93ff4f16 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,15 @@ This project uses **semantic versioning** (semver) with a single source of truth - **Frontend**: Vite injects VERSION as `__APP_VERSION__` constant, displayed in the sidebar footer - **Docker**: VERSION is copied into the image and set as OCI labels +#### `MIN_NATIVE_VERSION` (OTA native-compatibility floor) + +The native (Capacitor) app receives web-bundle updates over the air: each Docker image ships the matching Capacitor bundle under `/app/ota`, served via `/api/v1/native/bundle/{manifest,download}`, and the app downloads it when the served version differs (see `useNativeUpdate`). The `MIN_NATIVE_VERSION` file at the project root is the **minimum native app (APK/IPA) version** the current web bundle requires — an OTA can only swap web assets, never native code. + +- `scripts/promote.sh` bumps `MIN_NATIVE_VERSION` to the release version automatically when it detects a native change between `main` and `dev` (a `frontend/capacitor.config.ts` change, a committed change under `frontend/android`/`frontend/ios`, or an added/removed/bumped `@capacitor*`/`@capgo` dependency in `frontend/package.json`). Web-only releases leave it untouched. +- CI (`docker-publish.yml` `decide` job) compares `MIN_NATIVE_VERSION` against the previous tag: if it moved, it builds and attaches a fresh APK; if not, the **Android build is skipped** and the release ships Docker-only — existing installs update over the air. +- The app refuses a bundle whose `minNativeVersion` exceeds the installed native app version and prompts the user to update from the store/APK instead. +- Edge case the detector can't see: a native-affecting change that lands **only** via `pnpm-lock.yaml` (no `package.json` range change). Force a rebuild by editing `MIN_NATIVE_VERSION` manually that release. + ### Releasing a Version Releases are managed by `scripts/promote.sh`, which creates a PR from `dev` to `main` with the version bump and changelog stamp. Only code owners (@jordandrako, @LeeJMorel) can run this script. diff --git a/Dockerfile b/Dockerfile index 57314652..3559946f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM node:24-alpine AS frontend-build WORKDIR /frontend ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN apk add --no-cache zip COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ RUN corepack enable && pnpm install --frozen-lockfile COPY frontend . @@ -9,7 +10,18 @@ ARG VITE_API_URL=/api/v1 ARG VITE_VERSION_SUFFIX= ENV VITE_API_URL=$VITE_API_URL ENV VITE_VERSION_SUFFIX=$VITE_VERSION_SUFFIX +# Browser SPA build (base "/") served by the backend at /app/static. RUN pnpm run build +# Capacitor-flavored OTA bundle (base "", __IS_CAPACITOR__=true) shipped at /app/ota so the +# native app can download the web bundle matching this backend version. build:capacitor +# overwrites dist/, so stash the browser build first, then zip the capacitor build with +# index.html at the zip root (cd dist before zipping — do NOT nest under dist/). +RUN cp -r dist /tmp/browser-dist \ + && pnpm build:capacitor \ + && mkdir -p /ota \ + && (cd dist && zip -qr /ota/bundle.zip .) \ + && sha256sum /ota/bundle.zip | cut -d' ' -f1 > /ota/bundle.sha256 \ + && rm -rf dist && mv /tmp/browser-dist dist FROM python:3.12-slim AS backend-runtime ARG VERSION=0.1.0 @@ -23,8 +35,11 @@ RUN pip install --no-cache-dir --upgrade pip \ && pip install --no-cache-dir -r requirements.txt COPY backend/ . COPY VERSION ./VERSION +COPY MIN_NATIVE_VERSION ./MIN_NATIVE_VERSION COPY CHANGELOG.md ./CHANGELOG.md COPY --from=frontend-build /frontend/dist ./static +COPY --from=frontend-build /ota/bundle.zip ./ota/bundle.zip +COPY --from=frontend-build /ota/bundle.sha256 ./ota/bundle.sha256 RUN apt-get update && apt-get install -y --no-install-recommends gosu && rm -rf /var/lib/apt/lists/* \ && mkdir -p /app/uploads COPY backend/entrypoint.sh /entrypoint.sh diff --git a/MIN_NATIVE_VERSION b/MIN_NATIVE_VERSION new file mode 100644 index 00000000..a758a09a --- /dev/null +++ b/MIN_NATIVE_VERSION @@ -0,0 +1 @@ +0.48.0 diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index b8608ed6..99c9f6bd 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -1,9 +1,10 @@ from fastapi import APIRouter -from app.api.v1.endpoints import access_grants, admin, ai_settings, attachments, auth, auto_subscriptions, calendar_events, collaboration, comments, config, counters, documents, events, guilds, imports, initiatives, notifications, projects, property_definitions, push, queues, recents, settings, tags, task_statuses, tasks, trash, user_view_preferences, users, version +from app.api.v1.endpoints import access_grants, admin, ai_settings, attachments, auth, auto_subscriptions, calendar_events, collaboration, comments, config, counters, documents, events, guilds, imports, initiatives, native, notifications, projects, property_definitions, push, queues, recents, settings, tags, task_statuses, tasks, trash, user_view_preferences, users, version api_router = APIRouter() api_router.include_router(version.router, tags=["version"]) +api_router.include_router(native.router, tags=["native"]) api_router.include_router(config.router, tags=["config"]) api_router.include_router(auto_subscriptions.router, prefix="/auto", tags=["auto"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) diff --git a/backend/app/api/v1/endpoints/native.py b/backend/app/api/v1/endpoints/native.py new file mode 100644 index 00000000..39d401de --- /dev/null +++ b/backend/app/api/v1/endpoints/native.py @@ -0,0 +1,61 @@ +"""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.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="OTA bundle is not available in this build") + 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="OTA bundle is not available in this build") + return FileResponse( + _BUNDLE_PATH, + media_type="application/zip", + filename=f"initiative-{__version__}.zip", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) diff --git a/backend/app/api/v1/endpoints/native_test.py b/backend/app/api/v1/endpoints/native_test.py new file mode 100644 index 00000000..cc390f17 --- /dev/null +++ b/backend/app/api/v1/endpoints/native_test.py @@ -0,0 +1,69 @@ +"""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.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 + + +@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 + + +@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 diff --git a/backend/app/core/version.py b/backend/app/core/version.py index ae04d51e..16398414 100644 --- a/backend/app/core/version.py +++ b/backend/app/core/version.py @@ -18,4 +18,24 @@ def get_version() -> str: return "0.0.0" +def get_min_native_version() -> str: + """Read the minimum native app version from the MIN_NATIVE_VERSION file at project root. + + This is the semver of the release in which the native shell last changed (Capacitor + plugins or config). The OTA flow refuses a web bundle whose ``minNativeVersion`` exceeds + the installed native app version, prompting a store/APK update instead — because a newer + web bundle may call a native API the older shell lacks. Resolution mirrors ``get_version`` + (Docker path first). + """ + # Try Docker path first: /app/app/core/version.py -> /app/MIN_NATIVE_VERSION + min_version_file = Path(__file__).parent.parent.parent / "MIN_NATIVE_VERSION" + if not min_version_file.exists(): + # Fall back to development path: -> repo_root/MIN_NATIVE_VERSION + min_version_file = Path(__file__).parent.parent.parent.parent / "MIN_NATIVE_VERSION" + try: + return min_version_file.read_text().strip() + except FileNotFoundError: + return "0.0.0" + + __version__ = get_version() diff --git a/frontend/android/app/capacitor.build.gradle b/frontend/android/app/capacitor.build.gradle index fb89fdcb..713d8857 100644 --- a/frontend/android/app/capacitor.build.gradle +++ b/frontend/android/app/capacitor.build.gradle @@ -18,6 +18,7 @@ dependencies { implementation project(':capacitor-network') implementation project(':capacitor-preferences') implementation project(':capacitor-push-notifications') + implementation project(':capgo-capacitor-updater') } diff --git a/frontend/android/capacitor.settings.gradle b/frontend/android/capacitor.settings.gradle index e4e6a266..6eed0173 100644 --- a/frontend/android/capacitor.settings.gradle +++ b/frontend/android/capacitor.settings.gradle @@ -28,3 +28,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/.pnpm/@ include ':capacitor-push-notifications' project(':capacitor-push-notifications').projectDir = new File('../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications/android') + +include ':capgo-capacitor-updater' +project(':capgo-capacitor-updater').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-updater@8.47.5_@capacitor+core@8.3.4/node_modules/@capgo/capacitor-updater/android') diff --git a/frontend/capacitor.config.ts b/frontend/capacitor.config.ts index 7c32d9a0..f13ee574 100644 --- a/frontend/capacitor.config.ts +++ b/frontend/capacitor.config.ts @@ -16,6 +16,18 @@ const config: CapacitorConfig = { // allowMixedContent: true, }, plugins: { + // Self-hosted OTA live updates. We drive download/set entirely from JS (manual mode): + // the backend serves the web bundle matching its version, and useNativeUpdate downloads + // it then prompts the user to reload. autoUpdate/directUpdate stay off so the plugin + // never swaps the bundle on its own; appReadyTimeout arms the auto-rollback safety net + // if a swapped-in bundle fails to call notifyAppReady(). + CapacitorUpdater: { + autoUpdate: false, + directUpdate: false, + resetWhenUpdate: true, + appReadyTimeout: 10000, + responseTimeout: 20, + }, // Disable built-in SystemBars insets handling - safe-area plugin handles it SystemBars: { insetsHandling: "disable", diff --git a/frontend/ios/App/CapApp-SPM/Package.swift b/frontend/ios/App/CapApp-SPM/Package.swift index dd7bb5d0..db56567a 100644 --- a/frontend/ios/App/CapApp-SPM/Package.swift +++ b/frontend/ios/App/CapApp-SPM/Package.swift @@ -20,7 +20,8 @@ let package = Package( .package(name: "CapacitorHaptics", path: "../../../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.3.4/node_modules/@capacitor/haptics"), .package(name: "CapacitorNetwork", path: "../../../node_modules/.pnpm/@capacitor+network@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/network"), .package(name: "CapacitorPreferences", path: "../../../node_modules/.pnpm/@capacitor+preferences@8.0.1_@capacitor+core@8.3.4/node_modules/@capacitor/preferences"), - .package(name: "CapacitorPushNotifications", path: "../../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications") + .package(name: "CapacitorPushNotifications", path: "../../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.4/node_modules/@capacitor/push-notifications"), + .package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/.pnpm/@capgo+capacitor-updater@8.47.5_@capacitor+core@8.3.4/node_modules/@capgo/capacitor-updater") ], targets: [ .target( @@ -36,7 +37,8 @@ let package = Package( .product(name: "CapacitorHaptics", package: "CapacitorHaptics"), .product(name: "CapacitorNetwork", package: "CapacitorNetwork"), .product(name: "CapacitorPreferences", package: "CapacitorPreferences"), - .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications") + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications"), + .product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater") ] ) ] diff --git a/frontend/package.json b/frontend/package.json index f1be01d8..87d9297d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "@capacitor/network": "^8.0.1", "@capacitor/preferences": "^8.0.1", "@capacitor/push-notifications": "^8.1.1", + "@capgo/capacitor-updater": "^8.47.5", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 9f3b13e2..89cd307f 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@capacitor/push-notifications': specifier: ^8.1.1 version: 8.1.1(@capacitor/core@8.3.4) + '@capgo/capacitor-updater': + specifier: ^8.47.5 + version: 8.47.5(@capacitor/core@8.3.4) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -641,6 +644,11 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' + '@capgo/capacitor-updater@8.47.5': + resolution: {integrity: sha512-EPhikDtnVRCiYZdluqykuNWOhKnOKS1qf7h2JLY8NxokMIOUU5qiNSslINRlwrhqeURuex/aVojCR5h13WOXew==} + peerDependencies: + '@capacitor/core': ^8.0.0 + '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} @@ -6990,6 +6998,10 @@ snapshots: dependencies: '@capacitor/core': 8.3.4 + '@capgo/capacitor-updater@8.47.5(@capacitor/core@8.3.4)': + dependencies: + '@capacitor/core': 8.3.4 + '@chevrotain/cst-dts-gen@11.0.3': dependencies: '@chevrotain/gast': 11.0.3 diff --git a/frontend/public/locales/en/guilds.json b/frontend/public/locales/en/guilds.json index 813178de..f81a2e9a 100644 --- a/frontend/public/locales/en/guilds.json +++ b/frontend/public/locales/en/guilds.json @@ -231,7 +231,10 @@ "viewAllChanges": "View all changes", "later": "Later", "reloadNow": "Reload Now", - "version": "Version {{version}}" + "version": "Version {{version}}", + "nativeUpdateRequiredTitle": "App update required", + "nativeUpdateRequiredDescription": "Version {{version}} is available, but it needs a newer version of the app than the one installed. Please update Initiative from your app store or install the latest APK.", + "nativeUpdateRequiredAcknowledge": "Got it" }, "temporaryAccess": "Temporary access", "expiresInMinutes": "expires in {{minutes}}m", diff --git a/frontend/public/locales/es/guilds.json b/frontend/public/locales/es/guilds.json index cf2738dd..4fffa4f1 100644 --- a/frontend/public/locales/es/guilds.json +++ b/frontend/public/locales/es/guilds.json @@ -231,7 +231,10 @@ "viewAllChanges": "Ver todos los cambios", "later": "Más tarde", "reloadNow": "Recargar ahora", - "version": "Versión {{version}}" + "version": "Versión {{version}}", + "nativeUpdateRequiredTitle": "Actualización de la aplicación requerida", + "nativeUpdateRequiredDescription": "La versión {{version}} está disponible, pero requiere una versión de la aplicación más reciente que la instalada. Actualiza Initiative desde tu tienda de aplicaciones o instala el APK más reciente.", + "nativeUpdateRequiredAcknowledge": "Entendido" }, "temporaryAccess": "Acceso temporal", "expiresInMinutes": "caduca en {{minutes}} min", diff --git a/frontend/public/locales/fr/guilds.json b/frontend/public/locales/fr/guilds.json index 630b58ab..ce1a0e3b 100644 --- a/frontend/public/locales/fr/guilds.json +++ b/frontend/public/locales/fr/guilds.json @@ -231,7 +231,10 @@ "viewAllChanges": "Voir toutes les modifications", "later": "Plus tard", "reloadNow": "Recharger maintenant", - "version": "Version {{version}}" + "version": "Version {{version}}", + "nativeUpdateRequiredTitle": "Mise à jour de l'application requise", + "nativeUpdateRequiredDescription": "La version {{version}} est disponible, mais elle nécessite une version de l'application plus récente que celle installée. Veuillez mettre à jour Initiative depuis votre magasin d'applications ou installer le dernier APK.", + "nativeUpdateRequiredAcknowledge": "Compris" }, "temporaryAccess": "Accès temporaire", "expiresInMinutes": "expire dans {{minutes}} min", diff --git a/frontend/src/api/generated/initiativeAPI.schemas.ts b/frontend/src/api/generated/initiativeAPI.schemas.ts index 522c199c..bb3829f9 100644 --- a/frontend/src/api/generated/initiativeAPI.schemas.ts +++ b/frontend/src/api/generated/initiativeAPI.schemas.ts @@ -3520,6 +3520,8 @@ export type GetChangelogApiV1ChangelogGet200 = { [key: string]: GetChangelogApiV1ChangelogGet200Item[]; }; +export type GetBundleManifestApiV1NativeBundleManifestGet200 = { [key: string]: unknown }; + export type RegisterUserApiV1AuthRegisterPostParams = { invite_code?: string | null; }; diff --git a/frontend/src/api/generated/native/native.ts b/frontend/src/api/generated/native/native.ts new file mode 100644 index 00000000..ff44fcff --- /dev/null +++ b/frontend/src/api/generated/native/native.ts @@ -0,0 +1,321 @@ +/** + * Generated by orval v8.12.3 🍺 + * Do not edit manually. + * Initiative API + * OpenAPI spec version: 0.48.1 + */ +import { useQuery } from "@tanstack/react-query"; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; + +import type { GetBundleManifestApiV1NativeBundleManifestGet200 } from "../initiativeAPI.schemas"; + +import { apiMutator } from "../../mutator"; +import type { ErrorType } from "../../mutator"; + +type SecondParameter unknown> = Parameters[1]; + +/** + * 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. + * @summary Get Bundle Manifest + */ +export const getBundleManifestApiV1NativeBundleManifestGet = ( + options?: SecondParameter, + signal?: AbortSignal +) => { + return apiMutator( + { url: `/api/v1/native/bundle/manifest`, method: "GET", signal }, + options + ); +}; + +export const getGetBundleManifestApiV1NativeBundleManifestGetQueryKey = () => { + return [`/api/v1/native/bundle/manifest`] as const; +}; + +export const getGetBundleManifestApiV1NativeBundleManifestGetQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getGetBundleManifestApiV1NativeBundleManifestGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => getBundleManifestApiV1NativeBundleManifestGet(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type GetBundleManifestApiV1NativeBundleManifestGetQueryResult = NonNullable< + Awaited> +>; +export type GetBundleManifestApiV1NativeBundleManifestGetQueryError = ErrorType; + +export function useGetBundleManifestApiV1NativeBundleManifestGet< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useGetBundleManifestApiV1NativeBundleManifestGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useGetBundleManifestApiV1NativeBundleManifestGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Get Bundle Manifest + */ + +export function useGetBundleManifestApiV1NativeBundleManifestGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getGetBundleManifestApiV1NativeBundleManifestGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + +/** + * Serve the Capacitor web bundle zip (immutable per version). + * @summary Download Bundle + */ +export const downloadBundleApiV1NativeBundleDownloadGet = ( + options?: SecondParameter, + signal?: AbortSignal +) => { + return apiMutator( + { url: `/api/v1/native/bundle/download`, method: "GET", signal }, + options + ); +}; + +export const getDownloadBundleApiV1NativeBundleDownloadGetQueryKey = () => { + return [`/api/v1/native/bundle/download`] as const; +}; + +export const getDownloadBundleApiV1NativeBundleDownloadGetQueryOptions = < + TData = Awaited>, + TError = ErrorType, +>(options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {}; + + const queryKey = + queryOptions?.queryKey ?? getDownloadBundleApiV1NativeBundleDownloadGetQueryKey(); + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => downloadBundleApiV1NativeBundleDownloadGet(requestOptions, signal); + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag }; +}; + +export type DownloadBundleApiV1NativeBundleDownloadGetQueryResult = NonNullable< + Awaited> +>; +export type DownloadBundleApiV1NativeBundleDownloadGetQueryError = ErrorType; + +export function useDownloadBundleApiV1NativeBundleDownloadGet< + TData = Awaited>, + TError = ErrorType, +>( + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): DefinedUseQueryResult & { queryKey: DataTag }; +export function useDownloadBundleApiV1NativeBundleDownloadGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + >, + "initialData" + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +export function useDownloadBundleApiV1NativeBundleDownloadGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag }; +/** + * @summary Download Bundle + */ + +export function useDownloadBundleApiV1NativeBundleDownloadGet< + TData = Awaited>, + TError = ErrorType, +>( + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + >; + request?: SecondParameter; + }, + queryClient?: QueryClient +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getDownloadBundleApiV1NativeBundleDownloadGetQueryOptions(options); + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { + queryKey: DataTag; + }; + + return { ...query, queryKey: queryOptions.queryKey }; +} diff --git a/frontend/src/components/NativeUpdateRequiredDialog.tsx b/frontend/src/components/NativeUpdateRequiredDialog.tsx new file mode 100644 index 00000000..9aed070b --- /dev/null +++ b/frontend/src/components/NativeUpdateRequiredDialog.tsx @@ -0,0 +1,47 @@ +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface NativeUpdateRequiredDialogProps { + open: boolean; + /** The server version that requires a newer native app than the one installed. */ + version: string; + onClose: () => void; +} + +/** + * Shown on native when the server's web bundle requires a newer native shell (APK/IPA) than + * the one installed — an OTA update can't add native code, so the user must update the app + * itself. See {@link useNativeUpdate}. + */ +export const NativeUpdateRequiredDialog = ({ + open, + version, + onClose, +}: NativeUpdateRequiredDialogProps) => { + const { t } = useTranslation("guilds"); + + return ( + !next && onClose()}> + + + {t("version.nativeUpdateRequiredTitle")} + + {t("version.nativeUpdateRequiredDescription", { version })} + + + + + + + + ); +}; diff --git a/frontend/src/components/VersionDialog.tsx b/frontend/src/components/VersionDialog.tsx index fc4d6c11..5e172e12 100644 --- a/frontend/src/components/VersionDialog.tsx +++ b/frontend/src/components/VersionDialog.tsx @@ -30,6 +30,11 @@ interface VersionDialogProps { open?: boolean; onClose?: () => void; newVersion?: string; + /** + * Override the "Reload Now" action. Defaults to a plain page reload (web). The native OTA + * flow passes a handler that swaps in the downloaded Capacitor bundle before reloading. + */ + onReload?: () => void; } export const VersionDialog = ({ @@ -42,6 +47,7 @@ export const VersionDialog = ({ open, onClose, newVersion, + onReload, }: VersionDialogProps) => { const { t } = useTranslation("guilds"); @@ -63,6 +69,10 @@ export const VersionDialog = ({ }); const handleReload = () => { + if (onReload) { + onReload(); + return; + } window.location.reload(); }; diff --git a/frontend/src/hooks/useNativeUpdate.test.ts b/frontend/src/hooks/useNativeUpdate.test.ts new file mode 100644 index 00000000..19993103 --- /dev/null +++ b/frontend/src/hooks/useNativeUpdate.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { buildBundleDownloadUrl, decideNativeUpdate } from "./useNativeUpdate"; + +/** + * These pure helpers back the OTA flow in {@link useNativeUpdate}. The load-bearing details: + * the download URL must join to the server *origin* (not `serverUrl`, which already carries + * `/api/v1`), and the decision must treat any version difference — including a downgrade — as + * "not up to date", while refusing bundles that need a newer native shell. + */ +describe("buildBundleDownloadUrl", () => { + it("joins the manifest path to the origin, ignoring the /api/v1 suffix on serverUrl", () => { + expect( + buildBundleDownloadUrl("https://app.example.com/api/v1", "/api/v1/native/bundle/download") + ).toBe("https://app.example.com/api/v1/native/bundle/download"); + }); + + it("preserves a non-standard port and http scheme (LAN self-hosting)", () => { + expect( + buildBundleDownloadUrl("http://192.168.1.10:8173/api/v1", "/api/v1/native/bundle/download") + ).toBe("http://192.168.1.10:8173/api/v1/native/bundle/download"); + }); + + it("does not double up the /api/v1 segment", () => { + const url = buildBundleDownloadUrl("https://host/api/v1", "/api/v1/native/bundle/download"); + expect(url.match(/\/api\/v1/g)).toHaveLength(1); + }); +}); + +describe("decideNativeUpdate", () => { + const base = { currentVersion: "0.48.0", nativeVersion: "0.48.0", minNativeVersion: "0.48.0" }; + + it("is up-to-date when the server matches the running bundle", () => { + expect(decideNativeUpdate({ ...base, manifestVersion: "0.48.0" })).toBe("up-to-date"); + }); + + it("downloads when the server is newer", () => { + expect(decideNativeUpdate({ ...base, manifestVersion: "0.49.0" })).toBe("download"); + }); + + it("downloads when the server is older (downgrade to match is desired)", () => { + expect(decideNativeUpdate({ ...base, manifestVersion: "0.47.0" })).toBe("download"); + }); + + it("requires a native update when the bundle needs a newer shell than installed", () => { + expect( + decideNativeUpdate({ + manifestVersion: "0.50.0", + currentVersion: "0.48.0", + nativeVersion: "0.48.0", // installed APK predates the native change + minNativeVersion: "0.50.0", + }) + ).toBe("native-required"); + }); + + it("downloads when the installed shell is new enough for the bundle", () => { + expect( + decideNativeUpdate({ + manifestVersion: "0.50.0", + currentVersion: "0.48.0", + nativeVersion: "0.50.0", + minNativeVersion: "0.49.0", + }) + ).toBe("download"); + }); + + it("prefers up-to-date over native-required when already running the served version", () => { + expect( + decideNativeUpdate({ + manifestVersion: "0.48.0", + currentVersion: "0.48.0", + nativeVersion: "0.48.0", + minNativeVersion: "0.99.0", + }) + ).toBe("up-to-date"); + }); +}); diff --git a/frontend/src/hooks/useNativeUpdate.tsx b/frontend/src/hooks/useNativeUpdate.tsx new file mode 100644 index 00000000..a8b7ef7f --- /dev/null +++ b/frontend/src/hooks/useNativeUpdate.tsx @@ -0,0 +1,191 @@ +import { App } from "@capacitor/app"; +import { CapacitorUpdater } from "@capgo/capacitor-updater"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { compareVersions } from "@/hooks/useDockerHubVersion"; +import { useServer } from "@/hooks/useServer"; + +const CURRENT_VERSION = __APP_VERSION__; + +interface NativeBundleManifest { + version: string; + /** Absolute path (e.g. "/api/v1/native/bundle/download") joined to the server origin. */ + url: string; + /** sha256 hex of the bundle zip; the updater verifies it after download. */ + checksum: string; + /** Minimum native app (APK/IPA) version the bundle requires. */ + minNativeVersion: string; +} + +interface PromptState { + show: boolean; + version: string; +} + +const HIDDEN: PromptState = { show: false, version: "" }; + +/** + * Build the absolute bundle download URL. The manifest's `url` is an absolute path rooted at + * `/api/v1/...`, so it must be joined to the server *origin* — not to `serverUrl`, which + * carries the `/api/v1` suffix and would yield `/api/v1/api/v1/...`. + */ +export const buildBundleDownloadUrl = (serverUrl: string, manifestUrl: string): string => + new URL(manifestUrl, new URL(serverUrl).origin).toString(); + +/** + * Decide what to do with a served bundle, given the running web bundle version, the installed + * native shell version, and the bundle's requirements. Pure so it can be unit-tested. + * + * - `up-to-date`: the running bundle already matches the server (any version difference, + * including a downgrade, is "not up to date" and triggers a download). + * - `native-required`: the bundle needs a newer native app than the one installed → the user + * must update from the store; an OTA can't add native code. + * - `download`: fetch and offer the new bundle. + */ +export const decideNativeUpdate = (args: { + manifestVersion: string; + currentVersion: string; + nativeVersion: string; + minNativeVersion: string; +}): "up-to-date" | "native-required" | "download" => { + if (compareVersions(args.manifestVersion, args.currentVersion) === 0) { + return "up-to-date"; + } + if (compareVersions(args.nativeVersion, args.minNativeVersion) < 0) { + return "native-required"; + } + return "download"; +}; + +/** + * Self-hosted OTA live updates for the native (Capacitor) app. + * + * Each backend serves the web bundle matching its own version (see backend `native.py`). + * On launch and whenever the app returns to the foreground, this hook asks the configured + * server for its bundle manifest and, when the served web version differs from the bundle + * currently running, silently downloads it via `@capgo/capacitor-updater` and prompts the + * user to reload. Applying the update swaps the WebView to the new bundle and reloads. + * + * Two guards keep this safe: + * - Native compatibility: if the bundle needs a newer native shell than the installed + * APK/IPA (`minNativeVersion` > `current().native`), we skip the OTA and surface a + * "update from the store" prompt instead — a web bundle can't add native code. + * - Rollback: `notifyAppReady()` (called in `main.tsx`) lets the updater revert a bundle + * that fails to boot. + * + * No-op on web (`Capacitor.isNativePlatform()` is false) and until a server is configured. + */ +export const useNativeUpdate = () => { + const { serverUrl, isNativePlatform } = useServer(); + + const [updateReady, setUpdateReady] = useState(HIDDEN); + const [nativeUpdateRequired, setNativeUpdateRequired] = useState(HIDDEN); + + // Prevent overlapping checks and re-download/re-prompt of a version already handled this + // session. Refs (not state) so they survive re-renders without retriggering effects. + const checkingRef = useRef(false); + const handledVersionRef = useRef(null); + const bundleIdRef = useRef(null); + + const checkForUpdate = useCallback(async () => { + if (!isNativePlatform || !serverUrl || checkingRef.current) { + return; + } + checkingRef.current = true; + try { + // serverUrl already ends in "/api/v1"; the manifest lives at "/api/v1/native/bundle/...". + const manifestRes = await fetch(`${serverUrl}/native/bundle/manifest`, { + headers: { Accept: "application/json" }, + }); + if (!manifestRes.ok) { + return; // older server without OTA support, or no bundle in this build + } + const manifest = (await manifestRes.json()) as NativeBundleManifest; + + // Already downloaded + prompted this version this session. + if (handledVersionRef.current === manifest.version) { + return; + } + + const { native } = await CapacitorUpdater.current(); + const decision = decideNativeUpdate({ + manifestVersion: manifest.version, + currentVersion: CURRENT_VERSION, + nativeVersion: native, + minNativeVersion: manifest.minNativeVersion, + }); + if (decision === "up-to-date") { + return; + } + if (decision === "native-required") { + setNativeUpdateRequired({ show: true, version: manifest.version }); + return; + } + + // Reuse a previously-downloaded bundle for this version if present (avoids a + // "already exists" error from download() across launches); otherwise download it. + const downloadUrl = buildBundleDownloadUrl(serverUrl, manifest.url); + const existing = (await CapacitorUpdater.list()).bundles.find( + (b) => b.version === manifest.version + ); + const bundle = + existing ?? + (await CapacitorUpdater.download({ + url: downloadUrl, + version: manifest.version, + checksum: manifest.checksum, + })); + + handledVersionRef.current = manifest.version; + bundleIdRef.current = bundle.id; + setUpdateReady({ show: true, version: manifest.version }); + } catch (error) { + // Network/plugin failures are non-critical — the app keeps running the current bundle. + console.debug("Native update check failed:", error); + } finally { + checkingRef.current = false; + } + }, [isNativePlatform, serverUrl]); + + useEffect(() => { + if (!isNativePlatform || !serverUrl) { + return; + } + void checkForUpdate(); + const listenerPromise = App.addListener("appStateChange", ({ isActive }) => { + if (isActive) { + void checkForUpdate(); + } + }); + return () => { + void listenerPromise.then((listener) => listener.remove()); + }; + }, [isNativePlatform, serverUrl, checkForUpdate]); + + /** Swap to the downloaded bundle and reload the WebView. */ + const applyUpdate = useCallback(async () => { + if (!bundleIdRef.current) { + return; + } + setUpdateReady(HIDDEN); + await CapacitorUpdater.set({ id: bundleIdRef.current }); + // set() reloads the WebView into the new bundle; nothing after this runs. + }, []); + + /** Dismiss the reload prompt for this session (re-checked on next cold start). */ + const dismissUpdate = useCallback(() => { + setUpdateReady(HIDDEN); + }, []); + + const dismissNativeUpdateRequired = useCallback(() => { + setNativeUpdateRequired(HIDDEN); + }, []); + + return { + updateReady, + applyUpdate, + dismissUpdate, + nativeUpdateRequired, + dismissNativeUpdateRequired, + }; +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 065a8076..81079123 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import "./styles.css"; import "./i18n"; import { Capacitor } from "@capacitor/core"; +import { CapacitorUpdater } from "@capgo/capacitor-updater"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import React, { Suspense } from "react"; @@ -52,6 +53,15 @@ async function bootstrap() { // reach the real backend before React effects run (avoids race condition // where child provider effects fire before ServerProvider's useEffect). if (Capacitor.isNativePlatform()) { + // Confirm this web bundle booted so the OTA updater doesn't roll it back. If an applied + // bundle never reaches this point within appReadyTimeout, Capgo reverts to the last-good + // bundle on next launch (see capacitor.config.ts → CapacitorUpdater). Best-effort. + try { + await CapacitorUpdater.notifyAppReady(); + } catch (error) { + console.debug("notifyAppReady failed (updater unavailable):", error); + } + const storedUrl = getStoredServerUrl(); if (storedUrl) { setApiBaseUrl(storedUrl); diff --git a/frontend/src/routes/_serverRequired.tsx b/frontend/src/routes/_serverRequired.tsx index 5f538f64..1eb96573 100644 --- a/frontend/src/routes/_serverRequired.tsx +++ b/frontend/src/routes/_serverRequired.tsx @@ -1,6 +1,9 @@ import { createFileRoute, Navigate, Outlet, redirect, useSearch } from "@tanstack/react-router"; import { Loader2 } from "lucide-react"; +import { NativeUpdateRequiredDialog } from "@/components/NativeUpdateRequiredDialog"; +import { VersionDialog } from "@/components/VersionDialog"; +import { useNativeUpdate } from "@/hooks/useNativeUpdate"; import { useServer } from "@/hooks/useServer"; /** @@ -29,6 +32,15 @@ export const Route = createFileRoute("/_serverRequired")({ function ServerRequiredLayout() { const { loading, isNativePlatform, isServerConfigured } = useServer(); const search = useSearch({ strict: false }) as { connected?: string }; + // OTA live updates (native only). Mounted here — once a server is configured but before + // auth is required — so a fresh install can update its web bundle even from the login screen. + const { + updateReady, + applyUpdate, + dismissUpdate, + nativeUpdateRequired, + dismissNativeUpdateRequired, + } = useNativeUpdate(); // Check if we just connected from the connect page (search param passed via navigation) const justConnected = search?.connected === "1"; @@ -47,5 +59,22 @@ function ServerRequiredLayout() { return ; } - return ; + return ( + <> + + void applyUpdate()} + /> + + + ); } diff --git a/scripts/promote.sh b/scripts/promote.sh index 93cca16d..2feef8b8 100755 --- a/scripts/promote.sh +++ b/scripts/promote.sh @@ -132,6 +132,41 @@ parse_version() { V_PATCH="${BASH_REMATCH[3]}" } +# Detect whether the native shell changed between two refs. OTA can only ship web assets, +# so when Capacitor plugins/config or the committed native projects change, the minimum +# native app version must move forward (old installs can't run the new web bundle) and CI +# must build a fresh APK/IPA. Used as an `if` condition, so it is exempt from `set -e`. +detect_native_change() { + local base="$1" head="$2" + # capacitor.config.ts changed? + git diff --quiet "$base" "$head" -- frontend/capacitor.config.ts || return 0 + # committed native project files changed (Android/iOS source, Gradle, SPM, manifests)? + git diff --quiet "$base" "$head" -- frontend/android frontend/ios || return 0 + # any @capacitor / @capacitor-community / @capgo dependency added/removed/bumped? + local re='"@(capacitor|capacitor-community|capgo)/' + local old new + old=$(git show "$base:frontend/package.json" 2>/dev/null | grep -E "$re" | sort || true) + new=$(git show "$head:frontend/package.json" 2>/dev/null | grep -E "$re" | sort || true) + [[ "$old" != "$new" ]] && return 0 + return 1 +} + +# Bump MIN_NATIVE_VERSION to the release version when the native shell changed since `base`. +# Stages the file so it lands in the version-bump commit. The committed value is the single +# signal CI keys off to decide whether to build the APK (see docker-publish.yml `decide` job). +stamp_min_native_version() { + local base="$1" head="$2" new_version="$3" + local current_min + current_min=$(cat MIN_NATIVE_VERSION 2>/dev/null | tr -d '[:space:]' || echo "unknown") + if detect_native_change "$base" "$head"; then + echo "$new_version" > MIN_NATIVE_VERSION + git add MIN_NATIVE_VERSION + warn " Native surface changed → MIN_NATIVE_VERSION $current_min → $new_version (new APK/IPA REQUIRED; CI will build it)" + else + info " No native changes → MIN_NATIVE_VERSION stays $current_min (web-only OTA release, no APK build)" + fi +} + calc_new_version() { local current="$1" parse_version "$current" @@ -374,6 +409,9 @@ do_release() { # Stamp changelog stamp_changelog "$new_version" "$DATE" + # Move the native min-version forward if the native shell changed since main. + stamp_min_native_version "origin/main" "origin/dev" "$new_version" + git add VERSION CHANGELOG.md git commit -m "bump version to $new_version" @@ -480,6 +518,8 @@ do_cherry_pick() { else git add VERSION fi + # Move the native min-version forward if the cherry-picked changes touch the shell. + stamp_min_native_version "origin/main" "HEAD" "$new_version" git commit -m "bump version to $new_version" fi From 5fb568331284d2cf03c72982ff89b464e56c97c9 Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Sun, 31 May 2026 22:40:14 -0700 Subject: [PATCH 2/4] Address Greptile review on OTA PR - 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. --- .github/workflows/docker-publish.yml | 8 +++++--- backend/app/api/v1/endpoints/native.py | 5 +++-- backend/app/api/v1/endpoints/native_test.py | 3 +++ backend/app/core/messages.py | 4 ++++ frontend/public/locales/en/errors.json | 3 ++- frontend/public/locales/es/errors.json | 3 ++- frontend/public/locales/fr/errors.json | 3 ++- frontend/src/hooks/useNativeUpdate.tsx | 3 +++ 8 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c1df2f4d..1e682d7e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -286,9 +286,11 @@ jobs: release: needs: [decide, build-android, merge-docker-public] - # Run even when build-android was skipped (web-only release). Only the Docker manifest - # must have succeeded; the APK is attached opportunistically below. - if: always() && needs.merge-docker-public.result == 'success' + # Run even when build-android was *skipped* (web-only release). Only the Docker manifest + # must have succeeded; the APK is attached opportunistically below. But a build-android + # *failure* on a native-change release is a hard error — publishing then would advertise + # "updates over the air" while the bumped minNativeVersion blocks OTA and no new APK exists. + if: always() && needs.merge-docker-public.result == 'success' && needs.build-android.result != 'failure' runs-on: ubuntu-latest permissions: contents: write diff --git a/backend/app/api/v1/endpoints/native.py b/backend/app/api/v1/endpoints/native.py index 39d401de..b43d77b2 100644 --- a/backend/app/api/v1/endpoints/native.py +++ b/backend/app/api/v1/endpoints/native.py @@ -15,6 +15,7 @@ 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() @@ -39,7 +40,7 @@ def get_bundle_manifest() -> dict[str, object]: native app version is older. """ if not _BUNDLE_PATH.is_file() or not _CHECKSUM_PATH.is_file(): - raise HTTPException(status_code=404, detail="OTA bundle is not available in this build") + raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE) return { "version": __version__, "url": f"{settings.API_V1_STR}/native/bundle/download", @@ -52,7 +53,7 @@ def get_bundle_manifest() -> dict[str, object]: 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="OTA bundle is not available in this build") + raise HTTPException(status_code=404, detail=NativeMessages.OTA_BUNDLE_NOT_AVAILABLE) return FileResponse( _BUNDLE_PATH, media_type="application/zip", diff --git a/backend/app/api/v1/endpoints/native_test.py b/backend/app/api/v1/endpoints/native_test.py index cc390f17..e3e187f6 100644 --- a/backend/app/api/v1/endpoints/native_test.py +++ b/backend/app/api/v1/endpoints/native_test.py @@ -14,6 +14,7 @@ from httpx import AsyncClient from app.api.v1.endpoints import native +from app.core.messages import NativeMessages from app.core.version import __version__ @@ -22,12 +23,14 @@ 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 diff --git a/backend/app/core/messages.py b/backend/app/core/messages.py index 418d0d6b..cf13a120 100644 --- a/backend/app/core/messages.py +++ b/backend/app/core/messages.py @@ -382,3 +382,7 @@ class WebhookSubscriptionMessages: class AIMessages: INVALID_BASE_URL = "AI_INVALID_BASE_URL" PROVIDER_NOT_ALLOWED = "AI_PROVIDER_NOT_ALLOWED" + + +class NativeMessages: + OTA_BUNDLE_NOT_AVAILABLE = "NATIVE_OTA_BUNDLE_NOT_AVAILABLE" diff --git a/frontend/public/locales/en/errors.json b/frontend/public/locales/en/errors.json index e314caee..5ad618d0 100644 --- a/frontend/public/locales/en/errors.json +++ b/frontend/public/locales/en/errors.json @@ -264,5 +264,6 @@ "ADMIN_CANNOT_DEMOTE_LAST_OWNER": "Cannot demote the last platform owner.", "ADMIN_CANNOT_DELETE_LAST_OWNER": "Cannot delete the last platform owner.", "DOCUMENT_GRANT_CANNOT_MANAGE_MEMBERS": "Temporary access can't manage document members", - "COUNTER_GRANT_CANNOT_MANAGE": "Temporary access can't manage counter group access" + "COUNTER_GRANT_CANNOT_MANAGE": "Temporary access can't manage counter group access", + "NATIVE_OTA_BUNDLE_NOT_AVAILABLE": "This server build has no over-the-air update bundle available" } diff --git a/frontend/public/locales/es/errors.json b/frontend/public/locales/es/errors.json index 2c28e3fe..e2ca2b7d 100644 --- a/frontend/public/locales/es/errors.json +++ b/frontend/public/locales/es/errors.json @@ -264,5 +264,6 @@ "ADMIN_CANNOT_DEMOTE_LAST_OWNER": "No se puede degradar al último propietario de la plataforma.", "ADMIN_CANNOT_DELETE_LAST_OWNER": "No se puede eliminar al último propietario de la plataforma.", "DOCUMENT_GRANT_CANNOT_MANAGE_MEMBERS": "El acceso temporal no puede gestionar miembros del documento", - "COUNTER_GRANT_CANNOT_MANAGE": "El acceso temporal no puede gestionar el acceso al grupo de contadores" + "COUNTER_GRANT_CANNOT_MANAGE": "El acceso temporal no puede gestionar el acceso al grupo de contadores", + "NATIVE_OTA_BUNDLE_NOT_AVAILABLE": "Esta versión del servidor no tiene un paquete de actualización por aire disponible" } diff --git a/frontend/public/locales/fr/errors.json b/frontend/public/locales/fr/errors.json index 39beebb7..2c576134 100644 --- a/frontend/public/locales/fr/errors.json +++ b/frontend/public/locales/fr/errors.json @@ -264,5 +264,6 @@ "ADMIN_CANNOT_DEMOTE_LAST_OWNER": "Impossible de rétrograder le dernier propriétaire de la plateforme.", "ADMIN_CANNOT_DELETE_LAST_OWNER": "Impossible de supprimer le dernier propriétaire de la plateforme.", "DOCUMENT_GRANT_CANNOT_MANAGE_MEMBERS": "L'accès temporaire ne peut pas gérer les membres du document", - "COUNTER_GRANT_CANNOT_MANAGE": "L'accès temporaire ne peut pas gérer l'accès au groupe de compteurs" + "COUNTER_GRANT_CANNOT_MANAGE": "L'accès temporaire ne peut pas gérer l'accès au groupe de compteurs", + "NATIVE_OTA_BUNDLE_NOT_AVAILABLE": "Cette version du serveur n'a pas de paquet de mise à jour à distance disponible" } diff --git a/frontend/src/hooks/useNativeUpdate.tsx b/frontend/src/hooks/useNativeUpdate.tsx index a8b7ef7f..da1ea3cd 100644 --- a/frontend/src/hooks/useNativeUpdate.tsx +++ b/frontend/src/hooks/useNativeUpdate.tsx @@ -118,6 +118,9 @@ export const useNativeUpdate = () => { return; } if (decision === "native-required") { + // Mark handled so we don't re-prompt on every foreground resume this session + // (re-checked on the next cold start). + handledVersionRef.current = manifest.version; setNativeUpdateRequired({ show: true, version: manifest.version }); return; } From 64738748e4c439595557a64ace0823c9e98725e6 Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Sun, 31 May 2026 23:07:39 -0700 Subject: [PATCH 3/4] Keep OTA reload prompt open if set() fails 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. --- frontend/src/hooks/useNativeUpdate.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/hooks/useNativeUpdate.tsx b/frontend/src/hooks/useNativeUpdate.tsx index da1ea3cd..b91fce68 100644 --- a/frontend/src/hooks/useNativeUpdate.tsx +++ b/frontend/src/hooks/useNativeUpdate.tsx @@ -167,12 +167,19 @@ export const useNativeUpdate = () => { /** Swap to the downloaded bundle and reload the WebView. */ const applyUpdate = useCallback(async () => { - if (!bundleIdRef.current) { + const bundleId = bundleIdRef.current; + if (!bundleId) { return; } - setUpdateReady(HIDDEN); - await CapacitorUpdater.set({ id: bundleIdRef.current }); - // set() reloads the WebView into the new bundle; nothing after this runs. + try { + await CapacitorUpdater.set({ id: bundleId }); + // set() reloads the WebView into the new bundle; nothing after this runs. On success + // the dialog disappears with the old context, so there's no need to hide it first. + } catch (error) { + // set() failed (e.g. the OS evicted the downloaded bundle between download and tap). + // Leave the prompt open so the user can retry rather than silently no-op. + console.debug("Failed to apply OTA bundle:", error); + } }, []); /** Dismiss the reload prompt for this session (re-checked on next cold start). */ From 4a21dcb01b392b530ff8ae5485c9926db0818812 Mon Sep 17 00:00:00 2001 From: Jordan Janzen Date: Sun, 31 May 2026 23:47:43 -0700 Subject: [PATCH 4/4] Only reuse successfully-downloaded OTA bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- frontend/src/hooks/useNativeUpdate.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/hooks/useNativeUpdate.tsx b/frontend/src/hooks/useNativeUpdate.tsx index b91fce68..5914eb71 100644 --- a/frontend/src/hooks/useNativeUpdate.tsx +++ b/frontend/src/hooks/useNativeUpdate.tsx @@ -128,8 +128,11 @@ export const useNativeUpdate = () => { // Reuse a previously-downloaded bundle for this version if present (avoids a // "already exists" error from download() across launches); otherwise download it. const downloadUrl = buildBundleDownloadUrl(serverUrl, manifest.url); + // Only reuse a fully-downloaded bundle; a retained error/partial entry would make + // set() throw and, since we'd keep "finding" it, never re-download — leaving the user + // stuck on this version. Filtering to status "success" forces a fresh download instead. const existing = (await CapacitorUpdater.list()).bundles.find( - (b) => b.version === manifest.version + (b) => b.version === manifest.version && b.status === "success" ); const bundle = existing ??