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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+18
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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('/')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user