Skip to content

Add an update app with a Bundle model and the update endpoint - #1299

Open
johnpooch wants to merge 2 commits into
mainfrom
claude/capacitor-ota-updates-qvj57l
Open

Add an update app with a Bundle model and the update endpoint#1299
johnpooch wants to merge 2 commits into
mainfrom
claude/capacitor-ota-updates-qvj57l

Conversation

@johnpooch

@johnpooch johnpooch commented Aug 29, 2026

Copy link
Copy Markdown
Owner

What this PR does

Adds service/update/ — a Bundle model and POST /update/check/, implementing the @capgo/capacitor-updater self-hosted update protocol so the app has something to point updateUrl at. Closes #1262.

Bundle records the version, platform, checksum, R2 object key, minimum native version and whether it is active. Bundle.url (service/update/models.py:38) is built from R2_PUBLIC_BASE_URL, added in #1292. BundleManager.latest_for returns the highest-versioned active bundle for the caller's platform whose minimum_native_version the installed binary satisfies; parse_version (service/update/utils.py:1) compares dotted components so 1.5.10 outranks 1.5.9.

The endpoint is a CreateAPIView with AllowAny, matching the login/ app's shape for a POST that carries no credentials. UpdateCheckSerializer.to_representation delegates to UpdateCheckResponseSerializer, whose fields are all optional — so {"version", "url", "checksum"} is emitted when there is a bundle to install and the url key is simply absent when there is not.

Two deviations from the issue, both deliberate:

  • The "nothing to do" body also carries kind: "up_to_date". The issue specified a body with no url key. I read the plugin source (@capgo/capacitor-updater@8.51.15): a 2xx body with neither an error nor a kind key falls through to if (!jsRes.has("url")) { logger.error("Error no url or wrong format"); ... } in CapacitorUpdaterPlugin.java — i.e. every no-op check would be logged as a failure and end the background task with error = true. normalizedUpdateResponseKind accepts up_to_date, blocked, failed, and iOS has the same branch, so {"kind": "up_to_date", "message": ...} is the plugin's own clean "no new version available" path. It still carries no url key, so it satisfies the issue's contract as well.
  • The response is 201, not 200. That is DRF's CreateAPIView default and the repo's existing shape for POST-that-is-not-really-a-create (login/views.py), and avoids a view body override. Both native implementations accept any 2xx (response.isSuccessful() on Android, statusCode < 200 || statusCode >= 300 on iOS), so 201 is safe for the plugin.

Wire casing is as the issue predicted and is now pinned by tests: CamelCaseJSONParser leaves platform / version_build / version_name unchanged on the way in, and version / url / checksum / kind / message camelize to themselves on the way out. test_snake_case_request_and_response_keys_survive_camel_case_wiring posts raw JSON bytes and asserts the rendered key set, rather than going through response.data.

Schemas and both generated clients are regenerated. npx tsc -b --noEmit in packages/web is clean.

Review fixes (f39b2a1)

/code-review found three real defects in the first commit, fixed here and covered by a regression test each:

  • parse_version gated on str.isdigit() but converted with int(), which disagree for Unicode digit characters — a version_build of "1.0.²" raised ValueError and 500'd this unauthenticated endpoint.
  • parse_version compared variable-length tuples, so (1, 6, 0) <= (1, 6) is False and a device reporting a two-component "1.6" was permanently denied a bundle with minimum_native_version: "1.6.0". Trailing zero components are now stripped, matching what UpdateGate.tsx does with currentParts[i] ?? 0.
  • R2_PUBLIC_BASE_URL defaults to "", so unconfigured the endpoint advertised a bundle at a relative /bundles/ios/1.5.10.zip that no client can download. latest_for now serves nothing until the setting is configured.

One finding is raised in a comment rather than fixed — the stale-bundle guard never matches clients reporting version_name: "builtin", which lets a fresh store install be downgraded below its own bundled web assets. The obvious fix conflicts with the deliberate rollback path in test_older_bundle_still_offered_to_a_client_ahead_of_it, so it needs your call.

Checklist

  • This PR does one thing — no unrelated fixes, refactors, or drive-by cleanups bundled in
  • For PRs of any significant complexity: I ran /review-pr against this PR in Claude Code and addressed (or responded to) its findings
  • Tests cover the change — 17 tests in service/update/tests.py covering newest-runnable selection, numeric (not lexical) version ordering, a bundle whose minimum native version the installed binary does not meet, exact-minimum match, a two-component native version against a three-component minimum, a non-decimal version component, platform and active filtering, no bundles at all, an unset public base URL, already running the newest bundle, an older bundle still offered to a client ahead of it (the rollback path), rejected and missing platform, and the wire casing in both response shapes. Full backend suite: 2345 passed, 8 skipped.
  • Screenshots embedded in the PR description for any visual changes — none, backend only

claude added 2 commits August 29, 2026 13:30
POST /update/check/ implements the @capgo/capacitor-updater self-hosted
protocol: it returns the newest active Bundle for the caller's platform
whose minimum native version the installed binary satisfies, or a body
with no url key when there is nothing to install.

Bundle records the version, platform, checksum, R2 object key, minimum
native version and whether it is active; the served url is built from
R2_PUBLIC_BASE_URL. Version comparison parses the dotted components so
1.5.10 outranks 1.5.9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMxVHZUhpRht949vkWiDD3
parse_version gated on isdigit() but converted with int(), which disagree
for Unicode digit characters, so a version_build of "1.0.²" raised
ValueError and 500ed an unauthenticated endpoint. It also compared
variable-length tuples, so "1.6" did not satisfy a minimum_native_version
of "1.6.0" and a device reporting a two-component version was permanently
denied the bundle it can run. Convert only decimal parts and strip
trailing zero components so dotted versions of different lengths compare
correctly.

R2_PUBLIC_BASE_URL defaults to empty, and Bundle.url built a relative
"/bundles/..." URL from it, which the plugin cannot download — every
client would fail silently. Serve no bundle at all until the setting is
configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CMxVHZUhpRht949vkWiDD3

Copy link
Copy Markdown
Owner Author

Ran /code-review against this PR (the unticked checklist item). Five findings; three were real and are fixed in f39b2a1, one I'm raising here rather than acting on, one I'm standing down on.

Fixed

  • parse_version gated on str.isdigit() but converted with int(). They disagree for Unicode digit characters, so version_build: "1.0.²" raised ValueError and 500'd this AllowAny endpoint. Now converts only isdecimal() parts.
  • parse_version compared variable-length tuples, so (1, 6, 0) <= (1, 6) is False — a device reporting a two-component version_build of "1.6" was permanently denied a bundle with minimum_native_version: "1.6.0". Trailing zero components are now stripped, so dotted versions of different lengths compare correctly. This matches what UpdateGate.tsx already does on the frontend with currentParts[i] ?? 0.
  • R2_PUBLIC_BASE_URL defaults to "" (service/project/settings.py:322), and Bundle.url built a relative /bundles/ios/1.5.10.zip from it. Unconfigured, the endpoint advertised a bundle every client would silently fail to download. latest_for now serves nothing until the setting is configured.

Three regression tests added, one per fix. Full backend suite: 2345 passed, 8 skipped.

Raising rather than fixing: the builtin downgrade

The stale-bundle guard is bundle.version == version_name, but capgo sends version_name: "builtin" whenever the app is running the assets baked into the binary, so that comparison never matches for those clients. A user who installs native 1.6.0 from the store while the newest active bundle is 1.5.10 (minimum native 1.5.0) is served 1.5.10 — a downgrade of the web layer below what shipped inside their own binary.

I did not change this because the obvious fix ("only serve a bundle strictly newer than what the client runs") would break test_older_bundle_still_offered_to_a_client_ahead_of_it, which looks like a deliberate rollback path: deactivate a bad bundle and clients ahead of it come back down. Those two behaviours are in tension and picking between them is a design call, not a bug fix.

The narrow version, if you want it: when version_name is builtin, treat the client's current web version as version_build rather than as unknown, and skip any bundle at or below it. That closes the store-install downgrade while leaving the explicit rollback path intact for clients running a named bundle. Happy to push it if you agree.

Standing down: CreateAPIView

The review flagged that a pure lookup modelled as CreateAPIView emits Location: <bundle url> on every 201 (DRF's URL_FIELD_NAME is "url"), and cited .claude/rules/backend/views.md — "if a serializer's create() starts with a .get(), the view is the wrong generic". That rule is about mutating a row that already exists, which should be an UpdateAPIView; nothing is mutated here. Every view in login/ is exactly this shape — CreateAPIView + AllowAny + a serializer whose create() does non-create work — and there is no DRF generic for a POST that reads. Suppressing the Location header would cost a view body, which the same rule discourages. The header is cosmetic; the plugin reads the JSON body.

One thing for #1292 rather than this PR: the R2_* settings aren't in .example.env, which is where the other optional-credential blocks are documented.


Generated by Claude Code

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.

Add an update Django app with a Bundle model and the update endpoint

2 participants