Gate Stale OTA Clients Behind a Minimum Bundle Version - #494
Conversation
Silent background OTA swaps mean a device running a bad bundle keeps running it until the user happens to background the app. This adds a kill switch: the backend publishes a minVersion per channel and clients below it are held at a blocking update screen until they download the fix. Backend: - minVersion persisted in latest.json, returned by /ota/check and /ota/latest - POST /ota/min-version sets or clears the gate with no rebuild or republish - /ota/publish accepts &minVersion= and carries the existing value over when omitted, so a routine release can't silently un-gate held-back clients - minVersion is rejected above the latest published version, which would otherwise lock every client out with no bundle to climb to - extract writeLatest() for the atomic rename shared by both writers Frontend: - checkForcedUpdate() fails open on an unreachable backend and fails closed only on a confirmed-stale answer; a device that can't reach the server can't download the fix either, so blocking it offline helps nobody - OtaUpdateGate renders children immediately and overlays once staleness is confirmed, rather than delaying every cold start behind a network round-trip on the slow connections this gate exists to serve - channel derives from the Vite build mode, so dev and web are inert
🚀 Preview Deployment Ready
Preview auto-deletes when this PR is closed. |
…g switcher - autoUpdate: off — OtaUpdateGate handles all downloads, no silent swaps - checkPendingUpdate: gate fires on any newer bundle, not just minVersion - Remove update toast; the gate is the notification - VITE_APP_VERSION baked from package.json via vite.config.ts define - Version label in BottomNav More sheet (mobile) - Version label in OrgTeamSwitcher modal footer
There was a problem hiding this comment.
Pull request overview
Adds a minimum-bundle-version (“minVersion”) gate to the OTA update system so the backend can force stale clients onto a fixed bundle without requiring a native rebuild, plus client-side UI/tests/tooling to enforce and observe the gate.
Changes:
- Backend: persists/serves
minVersion, adds an authenticated/ota/min-versionendpoint, and ensures/ota/publishpreserves an existing gate unless explicitly overridden. - Frontend: introduces an OTA gate overlay + OTA version-check/download helpers, and wires the gate into app startup.
- Tooling/tests: adds scripts to publish bundles with
minVersionand to set/clear the gate, plus E2E + unit tests and UI version labels.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.ts | Injects VITE_APP_VERSION at build time from package.json for UI/version visibility. |
| tests/e2e/ota/ota.spec.ts | Adds Playwright coverage for OTA API shape/auth, version label visibility, and “no gate on web” regression guard. |
| src/ui/OtaUpdateGate.tsx | New blocking overlay that triggers OTA download + reload when an update is required. |
| src/ui/OrgTeamSwitcher.tsx | Displays the app version in the org/team switcher modal. |
| src/ui/BottomNav.tsx | Displays the app version in the “More” bottom-nav panel. |
| src/main.tsx | Wraps the app with OtaUpdateGate so gating can occur at startup. |
| src/lib/ota.ts | Adds OTA gate logic: version comparison, “forced update”/“pending update” checks, and download+activate helper. |
| src/lib/ota.test.ts | Adds unit tests for “fail open” behavior and download/activation flow (including progress listener cleanup). |
| scripts/publish-ota.mjs | Extends OTA publish script to optionally send minVersion and print it after publish. |
| scripts/ota-min-version.mjs | Adds a CLI script to set/clear minVersion on a channel via the backend endpoint. |
| package.json | Bumps app version and adds ota:min-version npm script. |
| meteor-backend/server/ota.js | Persists/returns minVersion, adds /ota/min-version, validates minVersion, and refactors atomic manifest write. |
| capacitor.config.ts | Disables plugin autoUpdate in favor of the frontend gate-driven update flow. |
Suppressed comments (1)
src/ui/OtaUpdateGate.tsx:29
- This gate calls
checkPendingUpdate(), which blocks whenever a newer bundle exists (even ifminVersionis unset). If the intended behavior is to block only when running < minVersion, callcheckForcedUpdate()instead.
void checkPendingUpdate().then((pending) => {
if (!cancelled && pending) setUpdate(pending);
});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- OtaUpdateGate: use checkForcedUpdate (minVersion kill-switch) instead of checkPendingUpdate (blocks on any newer bundle) — fixes the gate from force-updating on every launch to only blocking stale clients - OtaUpdateGate: fix z-100 to z-[100] (Tailwind arbitrary value) - ota.ts: replace AbortSignal.timeout() with AbortController + setTimeout for iOS 15 WKWebView compatibility in both checkPendingUpdate and checkForcedUpdate - BottomNav, OrgTeamSwitcher: add || '1.0.0' fallback for VITE_APP_VERSION to prevent vundefined in non-Vite / test contexts - meteor-backend ota.js: use randomBytes(8) for unique tmp filename in writeLatest() to prevent concurrent-write collisions on the same path
Dharp02
left a comment
There was a problem hiding this comment.
All 7 Copilot review comments addressed in c408b37:
Fixed:
-
OtaUpdateGateusedcheckPendingUpdateinstead ofcheckForcedUpdate— changed tocheckForcedUpdateso the gate only blocks clients belowminVersion, not on every launch with any newer bundle. This restores the intended kill-switch behavior. -
z-100not in Tailwind scale — changed toz-[100](arbitrary value) so the overlay actually sits above other fixed UI. -
AbortSignal.timeout()iOS 15 incompatibility — replaced withAbortController+setTimeoutin bothcheckPendingUpdateandcheckForcedUpdate. The 8-second timeout now works across all supported iOS WKWebView versions. -
VITE_APP_VERSIONmissing|| '1.0.0'fallback — added toBottomNav.tsxandOrgTeamSwitcher.tsxto match the pattern used elsewhere and preventvundefinedin non-Vite contexts. -
writeLatest()tmp file collision — replacedprocess.pidwithrandomBytes(8).toString('hex')for a per-call unique tmp path, preventing concurrent publish/min-version writes from colliding.
Not changed:
autoUpdate: 'off'— already conditional in the current code (liveReloadUrl ? 'off' : 'atBackground'); native builds keep background auto-update enabled. No change needed.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
|
|
||
| const LATEST = { | ||
| version: '1.0.5', | ||
| url: 'https://timecore-dev.os.mieweb.org/ota/bundles/testflight/1.0.5.zip', |
There was a problem hiding this comment.
Hard coded URL. Should be from a SERVER config or capacitor config.
Overview
OTA updates currently swap in silently on the next app background, which means a device running a bad bundle keeps running it until the user happens to background the app. There is no way to force a fix out. This adds a kill switch: the backend publishes a
minVersionper channel, and clients below it are held at a blocking update screen until they download the fix — no rebuild, no republish, no App Store review.Current State
autoUpdate: 'atBackground'downloads in the background and swaps on the next foregroundappReadyTimeout(10s) rolls back automatically when a bundle fails to boot, but only catches hard JS crashes — a bundle that boots fine but is functionally broken is never rolled backProposed Changes
1. Backend (
meteor-backend/server/ota.js)minVersionpersisted inlatest.json, returned by both/ota/checkand/ota/latestPOST /ota/min-version?channel=&version=(Bearer token) sets or clears the gate. Empty version clears it/ota/publishaccepts&minVersion=and carries the existing value over when omitted, so a routine release cannot silently un-gate clients an earlier bump was deliberately holding backminVersionis rejected when it exceeds the latest published version — that would lock every client out with no bundle to climb towriteLatest()for the atomic rename now shared by both writers2. Frontend gate (
src/lib/ota.ts)The failure direction is the important design decision here:
minVersionFailing open on an unreachable backend is deliberate. A device that cannot reach the server cannot download the fix either, so blocking it would brick the app offline while helping nobody.
Channel derives from
import.meta.env.MODE, so dev and web builds are inert by construction rather than by an explicit guard.3. Blocking UI (
src/ui/OtaUpdateGate.tsx)Children render immediately and the overlay mounts on top once staleness is confirmed. Blocking every cold start behind a network round-trip would add startup delay on exactly the slow connections this gate exists to serve. Shows download progress,
v1.0.1 → v1.0.5, and a retry on failure. No dismiss control — that is the point of the gate.4. Tooling
Acceptance Criteria
minVersionround-trips through publish and is returned by/ota/checkand/ota/latestminVersionrequires no rebuild and no republishminVersionabove the latest published version is rejected--min-versionpreserves the existing gaterole="alertdialog"witharia-live, labelled title/description, and a labelled progressbarsrc/lib/ota.test.ts; full suite 103/103 passingnpm run lint,npm run typecheck,npm run formatall cleanDeployment Note
This requires a Meteor backend deploy. Verified that
timecore-devcurrently serves the pre-minVersionmanifest, so/ota/min-versionwill 404 until deployed. The frontend degrades safely in the meantime — nominVersionfield in the response means no gate — so the two sides can ship independently and in either order.Flow
Normal flow (no minVersion needed):
You publish OTA → bundle is on the server
User opens app → background updater downloads it silently
User backgrounds the app → new bundle activates
Next launch → they're on the new version
Done. No admin action needed. This is what happens 99% of the time.
minVersion is for emergencies only, e.g.:
You shipped a bug that corrupts data
A security hole needs patching right now
You can't wait for users to naturally background/relaunch
In that case you set minVersion once → blocks everyone below that version until they update → then you can clear it.