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 } + } +}