diff --git a/waiter_pwa/android/app/capacitor.build.gradle b/waiter_pwa/android/app/capacitor.build.gradle
index 1005ebc..fded3bd 100644
--- a/waiter_pwa/android/app/capacitor.build.gradle
+++ b/waiter_pwa/android/app/capacitor.build.gradle
@@ -10,6 +10,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
+ implementation project(':capgo-capacitor-updater')
}
diff --git a/waiter_pwa/android/capacitor.settings.gradle b/waiter_pwa/android/capacitor.settings.gradle
index 2085c86..94cddac 100644
--- a/waiter_pwa/android/capacitor.settings.gradle
+++ b/waiter_pwa/android/capacitor.settings.gradle
@@ -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')
diff --git a/waiter_pwa/capacitor.config.json b/waiter_pwa/capacitor.config.json
index c605f4e..1a5b14e 100644
--- a/waiter_pwa/capacitor.config.json
+++ b/waiter_pwa/capacitor.config.json
@@ -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
+ }
}
}
diff --git a/waiter_pwa/package-lock.json b/waiter_pwa/package-lock.json
index 4957bc2..bab28ea 100644
--- a/waiter_pwa/package-lock.json
+++ b/waiter_pwa/package-lock.json
@@ -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",
diff --git a/waiter_pwa/package.json b/waiter_pwa/package.json
index ab562f8..0fafa8e 100644
--- a/waiter_pwa/package.json
+++ b/waiter_pwa/package.json
@@ -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"
}
diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx
index bf7ad9a..1429b0a 100644
--- a/waiter_pwa/src/App.jsx
+++ b/waiter_pwa/src/App.jsx
@@ -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' && }
{deliveryMode() === 'native' && }
+ {deliveryMode() === 'native' && }
} />
} />
diff --git a/waiter_pwa/src/components/BundleSync.jsx b/waiter_pwa/src/components/BundleSync.jsx
new file mode 100644
index 0000000..015406b
--- /dev/null
+++ b/waiter_pwa/src/components/BundleSync.jsx
@@ -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 (
+
+
+ Ο server του καταστήματος χρειάζεται νεότερη έκδοση της εφαρμογής.
+
+
+ Ενημέρωση
+
+
+ )
+}
diff --git a/waiter_pwa/src/config/server.js b/waiter_pwa/src/config/server.js
index d6b0bd9..7345388 100644
--- a/waiter_pwa/src/config/server.js
+++ b/waiter_pwa/src/config/server.js
@@ -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('/')
}
diff --git a/waiter_pwa/src/native/bundles.js b/waiter_pwa/src/native/bundles.js
new file mode 100644
index 0000000..cea9ccd
--- /dev/null
+++ b/waiter_pwa/src/native/bundles.js
@@ -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-.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 }
+ }
+}