feat(waiter): native app runs each venue's own UI bundle (self-hosted updater)

@capgo/capacitor-updater (MPL-2.0) in manual mode: autoUpdate off,
stats/update/channel URLs empty (nothing contacts Capgo), appReadyTimeout
15s (auto rollback), resetWhenUpdate (APK update → built-in bundle, venue
bundle re-fetched), autoDeletePrevious off (we keep one per venue).

src/native/bundles.js syncVenueBundle():
- manifest fetched over the pinned TLS link; zip downloaded natively over
  the LAN HTTP port with the MANDATORY sha256 check - a tampered zip never runs
- applied at once only when nobody is mid-service (after pairing / venue
  switch, or no one logged in); otherwise staged with next() → applied at the
  next background/restart; never a mid-service reload
- APK too old for the bundle (min_shell_build) → skipped + update banner
  linking to the venue's /downloads/xenia-waiter.apk
- a rolled-back version is blocklisted (attempt tracking + grace window) -
  without it the app re-applied the still-advertised broken bundle in a loop
- server without bundles (404) → built-in UI; bundles no venue needs deleted
BundleSync: notifyAppReady on start; sync at start, on resume, every 30 min.
saveVenue merges fields (bundleVersion); switchVenue flags immediate apply.

E2E (emulator vs two isolated venue stacks): fresh pairing applies the venue
bundle and it stays healthy; logged-in update staged, applied after
background, session kept; tampered zip refused; shell-too-old banner;
broken bundle rolled back automatically and not retried (80s + resume);
two venues each run their own bundle, round trips served from cache with 0
downloads, stale bundles cleaned; no *.capgo.app traffic. Step 5/7-era
rediscovery + cold-start suites and web modes still pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 18:24:48 +03:00
co-authored by Claude Opus 5.5
parent a1e415ac05
commit fe264f0a23
9 changed files with 320 additions and 4 deletions
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capgo-capacitor-updater')
}
@@ -4,3 +4,6 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capgo-capacitor-updater'
project(':capgo-capacitor-updater').projectDir = new File('../node_modules/@capgo/capacitor-updater/android')
+12
View File
@@ -4,5 +4,17 @@
"webDir": "dist-native",
"server": {
"androidScheme": "http"
},
"plugins": {
"CapacitorUpdater": {
"autoUpdate": false,
"statsUrl": "",
"updateUrl": "",
"channelUrl": "",
"appReadyTimeout": 15000,
"resetWhenUpdate": true,
"autoDeletePrevious": false,
"autoDeleteFailed": true
}
}
}
+18
View File
@@ -11,6 +11,7 @@
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2",
"@capgo/capacitor-updater": "^8.51.25",
"@tanstack/react-query": "^5.100.11",
"axios": "^1.15.1",
"dexie": "^4.4.2",
@@ -31,6 +32,7 @@
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fflate": "^0.8.3",
"globals": "^17.5.0",
"vite": "^8.0.9"
}
@@ -1584,6 +1586,15 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capgo/capacitor-updater": {
"version": "8.51.25",
"resolved": "https://registry.npmjs.org/@capgo/capacitor-updater/-/capacitor-updater-8.51.25.tgz",
"integrity": "sha512-lznyAv9Ktl1CD6/OKZY+lQZl9ke+dbadLEB2XQKP7VbQO8/5D8yOHS+pysQgVnNkiZYEC3v1AxgY4a5Z+DnxXw==",
"license": "MPL-2.0",
"peerDependencies": {
"@capacitor/core": "^8.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -3852,6 +3863,13 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+2
View File
@@ -16,6 +16,7 @@
"@capacitor/android": "^8.5.2",
"@capacitor/app": "^8.1.1",
"@capacitor/core": "^8.5.2",
"@capgo/capacitor-updater": "^8.51.25",
"@tanstack/react-query": "^5.100.11",
"axios": "^1.15.1",
"dexie": "^4.4.2",
@@ -36,6 +37,7 @@
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"fflate": "^0.8.3",
"globals": "^17.5.0",
"vite": "^8.0.9"
}
+2
View File
@@ -27,6 +27,7 @@ import UpdatePrompt from './components/UpdatePrompt'
import InstallAppBanner from './components/InstallAppBanner'
import VenuesPage from './pages/VenuesPage'
import ServerRediscovery from './components/ServerRediscovery'
import BundleSync from './components/BundleSync'
import { deliveryMode, getActiveVenue } from './config/server'
// ─── Utility ─────────────────────────────────────────────────────────────────
@@ -448,6 +449,7 @@ export default function App() {
{deliveryMode() !== 'native' && <UpdatePrompt />}
<InstallAppBanner />
{deliveryMode() === 'native' && <ServerRediscovery />}
{deliveryMode() === 'native' && <BundleSync />}
<Routes>
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
<Route path="/offline" element={<OfflinePage />} />
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react'
import { markBundleReady, syncVenueBundle } from '../native/bundles'
const FIRST_SYNC_DELAY_MS = 3000 // let a TLS upgrade / rediscovery reload settle first
const PERIODIC_SYNC_MS = 30 * 60_000
/**
* Native app: keep running the active venue's own UI bundle (plan step 8).
* Syncs on start, when the app returns to the foreground and every 30 minutes;
* see src/native/bundles.js for when a new bundle is applied. Shows a banner
* only if the venue's UI needs a newer APK than the one installed.
*/
export default function BundleSync() {
const [apkUpdate, setApkUpdate] = useState(null) // APK download URL when the shell is too old
useEffect(() => {
markBundleReady()
let cancelled = false
let resumeHandle = null
async function sync() {
const result = await syncVenueBundle()
if (!cancelled) setApkUpdate(result.status === 'shell-too-old' ? result.apk : null)
}
const first = setTimeout(sync, FIRST_SYNC_DELAY_MS)
const periodic = setInterval(sync, PERIODIC_SYNC_MS)
import('@capacitor/app').then(({ App }) => App.addListener('resume', sync)).then(h => {
if (cancelled) h.remove()
else resumeHandle = h
})
return () => {
cancelled = true
clearTimeout(first)
clearInterval(periodic)
resumeHandle?.remove()
}
}, [])
if (!apkUpdate) return null
return (
<div style={{
position: 'fixed', bottom: 80, left: 12, right: 12, zIndex: 9998,
background: 'var(--bg2)', border: '1px solid var(--accent)', borderRadius: 16,
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 12,
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
}}>
<span style={{ flex: 1, fontSize: 13, color: 'var(--text)', lineHeight: 1.35 }}>
Ο server του καταστήματος χρειάζεται νεότερη έκδοση της εφαρμογής.
</span>
<a
href={apkUpdate}
target="_blank"
rel="noreferrer"
style={{
background: 'var(--accent)', color: '#0f172a', borderRadius: 10, padding: '8px 14px',
fontSize: 14, fontWeight: 700, textDecoration: 'none', whiteSpace: 'nowrap',
}}
>
Ενημέρωση
</a>
</div>
)
}
+16 -4
View File
@@ -19,6 +19,7 @@
*/
const VENUES_KEY = 'xenia.venues'
export const APPLY_BUNDLE_NOW_KEY = 'xenia.applyBundleNow'
function normalizeBaseUrl(url) {
const trimmed = String(url || '').trim().replace(/\/+$/, '')
@@ -122,17 +123,21 @@ export function deliveryMode() {
// ── Writing (used by pairing / venue switcher in the native app) ─────────────
/**
* Add or update a paired venue without switching to it.
* Add or update a paired venue without switching to it. Fields not passed are
* kept (e.g. `bundleVersion`, remembered by src/native/bundles.js).
* `spki` = the server's TLS public-key pin (native app, plan step 7).
*/
export function saveVenue({ siteId, name, baseUrl, spki }) {
export function saveVenue({ siteId, name, baseUrl, spki, bundleVersion }) {
if (!siteId) throw new Error('saveVenue: siteId is required')
const state = readState()
const prev = state.venues[siteId] || {}
state.venues[siteId] = {
...prev,
siteId,
name: name || siteId,
baseUrl: normalizeBaseUrl(baseUrl),
name: name || prev.name || siteId,
baseUrl: normalizeBaseUrl(baseUrl || prev.baseUrl),
...(spki ? { spki } : {}),
...(bundleVersion ? { bundleVersion } : {}),
}
writeState(state)
return state.venues[siteId]
@@ -144,6 +149,13 @@ export function switchVenue(siteId) {
if (!state.venues[siteId]) throw new Error(`switchVenue: unknown venue ${siteId}`)
state.active = siteId
writeState(state)
// Nobody is mid-service right after a switch: the venue's own UI bundle may be
// applied immediately on the next start (src/native/bundles.js)
try {
sessionStorage.setItem(APPLY_BUNDLE_NOW_KEY, '1')
} catch {
// storage unavailable — the bundle is then applied at the next background/restart
}
window.location.replace('/')
}
+201
View File
@@ -0,0 +1,201 @@
/**
* Per-venue UI bundles (native app, plan step 8).
*
* Every venue server builds this UI for the app and publishes it as
* /downloads/waiter-bundle.json (manifest) + waiter-bundle-<hash>.zip
* (scripts/pack-bundle.mjs). The app runs the bundle of the venue it's paired
* with, so a server update reaches the phones with no APK to hand out, and each
* venue's phones always match that venue's server.
*
* Engine: @capgo/capacitor-updater in manual mode (capacitor.config.json):
* autoUpdate off, stats/update/channel URLs empty — nothing talks to Capgo;
* appReadyTimeout 15s — a bundle that doesn't call notifyAppReady() is rolled
* back automatically; resetWhenUpdate — an APK update returns to the built-in
* bundle and the venue's bundle is fetched again; autoDeletePrevious off —
* several venues can each need a bundle, cleanup() handles old ones.
*
* Integrity: the manifest is fetched through the WebView over the pinned TLS
* link (step 7). The zip is downloaded natively over the LAN's plain-HTTP port
* (the native downloader can't use the WebView's pin) and the updater rejects it
* unless its SHA-256 equals the manifest's — so a tampered zip never runs.
*
* When: a new bundle is applied at once only when nobody is mid-service (right
* after pairing / a venue switch, or nobody logged in). Otherwise it is staged
* and applied the next time the app goes to the background or restarts.
*
* Failed bundles: if a bundle is rolled back (it never reported ready), the
* server still advertises it — retrying would loop forever. Every activation is
* recorded; finding ourselves on another version later (and it isn't merely
* queued for the next background) means it failed, and that version is never
* tried again. A newer server version is tried normally.
*/
import { CapacitorUpdater } from '@capgo/capacitor-updater'
import { App } from '@capacitor/app'
import { APPLY_BUNDLE_NOW_KEY, getActiveVenue, listVenues, saveVenue, storageKey } from '../config/server'
const MANIFEST_TIMEOUT_MS = 8000
const ATTEMPT_KEY = 'xenia.bundleAttempt' // { version, at } of the last activation
const FAILED_KEY = 'xenia.bundleFailed' // versions that were rolled back
const ATTEMPT_GRACE_MS = 60_000 // > the updater's 15-30s ready window
function readJson(key, fallback) {
try {
return JSON.parse(localStorage.getItem(key) || 'null') ?? fallback
} catch {
return fallback
}
}
function writeJson(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value))
} catch {
// storage unavailable — worst case a failed bundle is retried once more
}
}
const isFailed = version => readJson(FAILED_KEY, []).includes(version)
function markFailed(version) {
writeJson(FAILED_KEY, [...readJson(FAILED_KEY, []).filter(v => v !== version), version].slice(-20))
console.warn(`[bundles] ${version} was rolled back — not retrying it`)
}
/** Tell the updater this bundle started fine (otherwise it rolls back after 15s). */
export async function markBundleReady() {
try {
await CapacitorUpdater.notifyAppReady()
} catch (e) {
console.warn('[bundles] notifyAppReady failed', e)
}
}
async function fetchJson(url) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), MANIFEST_TIMEOUT_MS)
try {
const res = await fetch(url, { signal: controller.signal, cache: 'no-store' })
return { status: res.status, body: res.ok ? await res.json() : null }
} catch {
return { status: 0, body: null } // unreachable — keep whatever runs now
} finally {
clearTimeout(timer)
}
}
function validManifest(m) {
return m?.format === 1 && typeof m.version === 'string' && /^[\w.+-]+\.zip$/.test(m.file || '')
&& /^[0-9a-f]{64}$/.test(m.sha256 || '') && Number.isInteger(m.min_shell_build)
}
/** Plain-HTTP origin of the venue for the native zip download. */
async function httpOrigin(venue) {
const url = new URL(venue.baseUrl)
if (url.protocol === 'http:') return url.origin
const { body } = await fetchJson(`${venue.baseUrl}/api/system/identity`)
const port = Number(body?.http_port) || 80
return `http://${url.hostname}${port === 80 ? '' : `:${port}`}`
}
function applyNowAllowed() {
try {
if (sessionStorage.getItem(APPLY_BUNDLE_NOW_KEY)) return true
return !localStorage.getItem(storageKey('token')) // nobody logged in at this venue
} catch {
return false
}
}
/** Delete downloaded bundles no paired venue needs any more. */
async function cleanup(keepIds) {
const needed = new Set(listVenues().map(v => v.bundleVersion).filter(Boolean))
const { bundles } = await CapacitorUpdater.list()
for (const b of bundles) {
if (b.id === 'builtin' || keepIds.includes(b.id) || needed.has(b.version)) continue
try {
await CapacitorUpdater.delete({ id: b.id })
} catch {
// current/next bundles can't be deleted — fine
}
}
}
let running = null
/**
* Make the active venue's UI bundle the one this app runs.
* Resolves to { status, version? } — status is one of:
* no-venue | unreachable | no-bundle | shell-too-old | current | applied | staged
* | pending (just activated, ready window open) | failed (download/checksum)
* | failed-before (was rolled back, not retried)
*/
export function syncVenueBundle() {
if (!running) running = doSync().finally(() => { running = null })
return running
}
async function doSync() {
const venue = getActiveVenue()
if (!venue) return { status: 'no-venue' }
const { status, body: manifest } = await fetchJson(`${venue.baseUrl}/downloads/waiter-bundle.json`)
if (status === 0) return { status: 'unreachable' }
const { bundle: current } = await CapacitorUpdater.current()
if (!validManifest(manifest)) {
// Server predates per-venue bundles: run the app's own built-in UI
if (status === 404 && current?.id !== 'builtin' && applyNowAllowed()) {
sessionStorage.removeItem(APPLY_BUNDLE_NOW_KEY)
await CapacitorUpdater.reset()
}
return { status: 'no-bundle' }
}
const { build } = await App.getInfo()
if (Number(build) < manifest.min_shell_build) {
return { status: 'shell-too-old', version: manifest.version, apk: `${await httpOrigin(venue)}/downloads/xenia-waiter.apk` }
}
if (current?.version === manifest.version) {
if (venue.bundleVersion !== manifest.version) saveVenue({ ...venue, bundleVersion: manifest.version })
sessionStorage.removeItem(APPLY_BUNDLE_NOW_KEY)
localStorage.removeItem(ATTEMPT_KEY) // it started fine
return { status: 'current', version: manifest.version }
}
// Already activated this version, yet we're running something else:
// still queued for the next background → wait; activated moments ago → wait
// (never re-activate inside the ready window); otherwise it was rolled back.
const attempt = readJson(ATTEMPT_KEY, null)
if (attempt?.version === manifest.version) {
const queued = await CapacitorUpdater.getNextBundle().catch(() => null)
if (queued?.version === manifest.version) return { status: 'staged', version: manifest.version }
if (Date.now() - attempt.at < ATTEMPT_GRACE_MS) return { status: 'pending', version: manifest.version }
markFailed(manifest.version)
localStorage.removeItem(ATTEMPT_KEY)
}
if (isFailed(manifest.version)) return { status: 'failed-before', version: manifest.version }
try {
const { bundles } = await CapacitorUpdater.list()
let target = bundles.find(b => b.version === manifest.version && b.status !== 'error')
if (!target) {
target = await CapacitorUpdater.download({
url: `${await httpOrigin(venue)}/downloads/${manifest.file}`,
version: manifest.version,
checksum: manifest.sha256,
})
}
saveVenue({ ...venue, bundleVersion: manifest.version })
await cleanup([target.id, current?.id])
writeJson(ATTEMPT_KEY, { version: manifest.version, at: Date.now() })
if (applyNowAllowed()) {
sessionStorage.removeItem(APPLY_BUNDLE_NOW_KEY)
await CapacitorUpdater.set({ id: target.id }) // reloads into the new bundle
return { status: 'applied', version: manifest.version }
}
await CapacitorUpdater.next({ id: target.id }) // applied at next background/restart
return { status: 'staged', version: manifest.version }
} catch (e) {
console.warn('[bundles] update failed', e)
return { status: 'failed', version: manifest.version }
}
}