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"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
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'
|
include ':capacitor-app'
|
||||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
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",
|
"webDir": "dist-native",
|
||||||
"server": {
|
"server": {
|
||||||
"androidScheme": "http"
|
"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/android": "^8.5.2",
|
||||||
"@capacitor/app": "^8.1.1",
|
"@capacitor/app": "^8.1.1",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"@capgo/capacitor-updater": "^8.51.25",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"vite": "^8.0.9"
|
"vite": "^8.0.9"
|
||||||
}
|
}
|
||||||
@@ -1584,6 +1586,15 @@
|
|||||||
"tslib": "^2.1.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.9.2",
|
"version": "1.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
|
"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": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"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/android": "^8.5.2",
|
||||||
"@capacitor/app": "^8.1.1",
|
"@capacitor/app": "^8.1.1",
|
||||||
"@capacitor/core": "^8.5.2",
|
"@capacitor/core": "^8.5.2",
|
||||||
|
"@capgo/capacitor-updater": "^8.51.25",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"axios": "^1.15.1",
|
"axios": "^1.15.1",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.2",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
"vite": "^8.0.9"
|
"vite": "^8.0.9"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import UpdatePrompt from './components/UpdatePrompt'
|
|||||||
import InstallAppBanner from './components/InstallAppBanner'
|
import InstallAppBanner from './components/InstallAppBanner'
|
||||||
import VenuesPage from './pages/VenuesPage'
|
import VenuesPage from './pages/VenuesPage'
|
||||||
import ServerRediscovery from './components/ServerRediscovery'
|
import ServerRediscovery from './components/ServerRediscovery'
|
||||||
|
import BundleSync from './components/BundleSync'
|
||||||
import { deliveryMode, getActiveVenue } from './config/server'
|
import { deliveryMode, getActiveVenue } from './config/server'
|
||||||
|
|
||||||
// ─── Utility ─────────────────────────────────────────────────────────────────
|
// ─── Utility ─────────────────────────────────────────────────────────────────
|
||||||
@@ -448,6 +449,7 @@ export default function App() {
|
|||||||
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
{deliveryMode() !== 'native' && <UpdatePrompt />}
|
||||||
<InstallAppBanner />
|
<InstallAppBanner />
|
||||||
{deliveryMode() === 'native' && <ServerRediscovery />}
|
{deliveryMode() === 'native' && <ServerRediscovery />}
|
||||||
|
{deliveryMode() === 'native' && <BundleSync />}
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
<Route path="/login" element={<LoginGuard><LoginPage /></LoginGuard>} />
|
||||||
<Route path="/offline" element={<OfflinePage />} />
|
<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'
|
const VENUES_KEY = 'xenia.venues'
|
||||||
|
export const APPLY_BUNDLE_NOW_KEY = 'xenia.applyBundleNow'
|
||||||
|
|
||||||
function normalizeBaseUrl(url) {
|
function normalizeBaseUrl(url) {
|
||||||
const trimmed = String(url || '').trim().replace(/\/+$/, '')
|
const trimmed = String(url || '').trim().replace(/\/+$/, '')
|
||||||
@@ -122,17 +123,21 @@ export function deliveryMode() {
|
|||||||
// ── Writing (used by pairing / venue switcher in the native app) ─────────────
|
// ── 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).
|
* `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')
|
if (!siteId) throw new Error('saveVenue: siteId is required')
|
||||||
const state = readState()
|
const state = readState()
|
||||||
|
const prev = state.venues[siteId] || {}
|
||||||
state.venues[siteId] = {
|
state.venues[siteId] = {
|
||||||
|
...prev,
|
||||||
siteId,
|
siteId,
|
||||||
name: name || siteId,
|
name: name || prev.name || siteId,
|
||||||
baseUrl: normalizeBaseUrl(baseUrl),
|
baseUrl: normalizeBaseUrl(baseUrl || prev.baseUrl),
|
||||||
...(spki ? { spki } : {}),
|
...(spki ? { spki } : {}),
|
||||||
|
...(bundleVersion ? { bundleVersion } : {}),
|
||||||
}
|
}
|
||||||
writeState(state)
|
writeState(state)
|
||||||
return state.venues[siteId]
|
return state.venues[siteId]
|
||||||
@@ -144,6 +149,13 @@ export function switchVenue(siteId) {
|
|||||||
if (!state.venues[siteId]) throw new Error(`switchVenue: unknown venue ${siteId}`)
|
if (!state.venues[siteId]) throw new Error(`switchVenue: unknown venue ${siteId}`)
|
||||||
state.active = siteId
|
state.active = siteId
|
||||||
writeState(state)
|
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('/')
|
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