diff --git a/waiter_pwa/src/App.jsx b/waiter_pwa/src/App.jsx index 99d93a8..bf7ad9a 100644 --- a/waiter_pwa/src/App.jsx +++ b/waiter_pwa/src/App.jsx @@ -26,6 +26,7 @@ import AppShell from './components/AppShell' import UpdatePrompt from './components/UpdatePrompt' import InstallAppBanner from './components/InstallAppBanner' import VenuesPage from './pages/VenuesPage' +import ServerRediscovery from './components/ServerRediscovery' import { deliveryMode, getActiveVenue } from './config/server' // ─── Utility ───────────────────────────────────────────────────────────────── @@ -446,6 +447,7 @@ export default function App() { {/* Native builds ship their UI in the app bundle — no service worker to update */} {deliveryMode() !== 'native' && } + {deliveryMode() === 'native' && } } /> } /> diff --git a/waiter_pwa/src/components/ServerRediscovery.jsx b/waiter_pwa/src/components/ServerRediscovery.jsx new file mode 100644 index 0000000..ca175fb --- /dev/null +++ b/waiter_pwa/src/components/ServerRediscovery.jsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import useConnectionStore from '../store/connectionStore' +import { canRediscover, rediscover } from '../native/autoRediscover' + +const RETRY_WHILE_OFFLINE_MS = 2 * 60_000 +const RELOAD_DELAY_MS = 1500 + +/** + * Native app: when the venue server stops answering, look for it at a new + * address on the same network and reconnect automatically (plan step 5). + * + * Triggers: the connection is confirmed offline (logged in), or any request + * fails with a network error (login / offline screens). Retries every 2 minutes + * while offline. On success it shows a short notice and reloads into the new + * address — the offline queue lives in IndexedDB and syncs after the reload. + */ +export default function ServerRediscovery() { + const status = useConnectionStore(s => s.status) + const [movedTo, setMovedTo] = useState(null) + const enabled = canRediscover() + + useEffect(() => { + if (!enabled) return + let cancelled = false + + async function attempt() { + const result = await rediscover() + if (cancelled || result.status !== 'moved') return + setMovedTo(result.baseUrl) + // Stay on the waiter's current screen — except /offline, which only + // exists because the server was unreachable; start over from there. + const target = window.location.pathname === '/offline' ? '/' : window.location.href + setTimeout(() => window.location.replace(target), RELOAD_DELAY_MS) + } + + window.addEventListener('backend-offline', attempt) + let timer = null + if (status === 'offline') { + attempt() + timer = setInterval(attempt, RETRY_WHILE_OFFLINE_MS) + } + return () => { + cancelled = true + window.removeEventListener('backend-offline', attempt) + clearInterval(timer) + } + }, [enabled, status]) + + if (!movedTo) return null + return ( + + + 🔄 + + Ο server άλλαξε διεύθυνση + + {movedTo} + Επανασύνδεση… + + + ) +} diff --git a/waiter_pwa/src/native/autoRediscover.js b/waiter_pwa/src/native/autoRediscover.js new file mode 100644 index 0000000..e61e11e --- /dev/null +++ b/waiter_pwa/src/native/autoRediscover.js @@ -0,0 +1,48 @@ +/** + * When to look for the venue server at a new address (native app only). + * + * rediscover() first re-checks the saved address — a server that's just + * restarting shouldn't trigger a scan result — and only then scans the subnet + * (./rediscovery.js). One scan at a time; automatic attempts at most once a + * minute. A found server is saved on the venue; the caller reloads the app so + * every URL picks up the new address. + */ +import { deliveryMode, getActiveVenue, saveVenue } from '../config/server' +import { probeIdentity, scanForVenue } from './rediscovery' + +const MIN_INTERVAL_MS = 60_000 + +let running = null +let lastRun = 0 + +/** Only native venues with a real site id can be matched safely. */ +export function canRediscover() { + const venue = getActiveVenue() + return deliveryMode() === 'native' && !!venue && venue.siteId !== 'dev' && !venue.siteId.startsWith('local-') +} + +/** + * Resolves to { status: 'reachable' | 'moved' | 'not-found' | 'skipped', baseUrl? }. + * `force` bypasses the once-a-minute throttle (manual "search" button). + */ +export function rediscover({ force = false, onProgress } = {}) { + if (!canRediscover()) return Promise.resolve({ status: 'skipped' }) + if (running) return running + if (!force && Date.now() - lastRun < MIN_INTERVAL_MS) return Promise.resolve({ status: 'skipped' }) + lastRun = Date.now() + + running = (async () => { + const venue = getActiveVenue() + const current = await probeIdentity(venue.baseUrl) + if (current?.site_id === venue.siteId) return { status: 'reachable' } + + const baseUrl = await scanForVenue(venue.baseUrl, venue.siteId, { onProgress }) + if (!baseUrl) return { status: 'not-found' } + + saveVenue({ ...venue, baseUrl }) + return { status: 'moved', baseUrl } + })().finally(() => { + running = null + }) + return running +} diff --git a/waiter_pwa/src/native/rediscovery.js b/waiter_pwa/src/native/rediscovery.js new file mode 100644 index 0000000..e937c0f --- /dev/null +++ b/waiter_pwa/src/native/rediscovery.js @@ -0,0 +1,85 @@ +/** + * Finding the venue server again after its IP changed (native app). + * + * The phone stores http://: at pairing time. If the router later hands + * the server a new address, the app would be stranded until someone re-scans + * the QR. Instead: when the saved address stops answering, probe every address + * in the same /24 (same scheme and port) for /api/system/identity and switch to + * the one reporting THIS venue's site_id — never to "any Xenia server". + * + * A DHCP change almost always stays inside the same /24; anything bigger + * (new router, different subnet) still has the manual fallback: scan the QR. + * Contract: docs/reference/lan-access.md + */ +import { SUPPORTED_API_VERSIONS } from './pairing' + +const PROBE_TIMEOUT_MS = 1500 +const CONCURRENCY = 48 + +/** Same-/24 candidate origins for a base URL, nearest addresses first. [] if not an IPv4 URL. */ +export function subnetCandidates(baseUrl) { + let url + try { + url = new URL(baseUrl) + } catch { + return [] + } + const m = url.hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (!m) return [] + const [a, b, c, d] = m.slice(1).map(Number) + const port = url.port ? `:${url.port}` : '' + const hosts = [] + for (let i = 1; i <= 254; i++) if (i !== d) hosts.push(i) + // Nearest first: DHCP pools hand out neighbouring addresses more often than not + hosts.sort((x, y) => Math.abs(x - d) - Math.abs(y - d) || x - y) + return hosts.map(h => `${url.protocol}//${a}.${b}.${c}.${h}${port}`) +} + +/** Identity of a server, or null (unreachable / not Xenia / unsupported API). */ +export async function probeIdentity(origin, { timeoutMs = PROBE_TIMEOUT_MS, signal } = {}) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + const onAbort = () => controller.abort() + signal?.addEventListener('abort', onAbort) + try { + const res = await fetch(`${origin}/api/system/identity`, { signal: controller.signal }) + if (!res.ok) return null + const identity = await res.json() + return identity?.app === 'xenia-pos' && SUPPORTED_API_VERSIONS.includes(identity.api_version) ? identity : null + } catch { + return null + } finally { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + } +} + +/** + * Scan the old address's /24 for the server reporting `siteId`. + * Resolves to the new base URL, or null. Stops as soon as it's found. + * `probe` is injectable for tests. + */ +export async function scanForVenue(baseUrl, siteId, { probe = probeIdentity, concurrency = CONCURRENCY, onProgress } = {}) { + const queue = subnetCandidates(baseUrl) + const total = queue.length + if (!siteId || total === 0) return null + + const stop = new AbortController() + let found = null + let done = 0 + + async function worker() { + while (!found && queue.length) { + const origin = queue.shift() + const identity = await probe(origin, { signal: stop.signal }) + done++ + onProgress?.(done, total) + if (!found && identity?.site_id === siteId) { + found = origin + stop.abort() + } + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker)) + return found +} diff --git a/waiter_pwa/src/pages/OfflinePage.jsx b/waiter_pwa/src/pages/OfflinePage.jsx index b625ae2..0011676 100644 --- a/waiter_pwa/src/pages/OfflinePage.jsx +++ b/waiter_pwa/src/pages/OfflinePage.jsx @@ -1,9 +1,11 @@ import client from '../api/client' import { deliveryMode } from '../config/server' +import { canRediscover, rediscover } from '../native/autoRediscover' import { useState } from 'react' export default function OfflinePage() { const [checking, setChecking] = useState(false) + const [search, setSearch] = useState(null) // null | { done, total } | 'not-found' async function retry() { setChecking(true) @@ -15,6 +17,19 @@ export default function OfflinePage() { } } + // Native app: look for this venue's server at a new address on the same network + async function searchNetwork() { + setSearch({ done: 0, total: 253 }) + const result = await rediscover({ force: true, onProgress: (done, total) => setSearch({ done, total }) }) + if (result.status === 'moved' || result.status === 'reachable') { + window.location.href = '/' + } else { + setSearch('not-found') + } + } + + const searching = search && search !== 'not-found' + return ( @@ -23,14 +38,30 @@ export default function OfflinePage() { Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi. - + {checking ? 'Έλεγχος…' : 'Επανάληψη'} + {canRediscover() && ( + + {searching ? `Αναζήτηση… ${search.done}/${search.total}` : 'Αναζήτηση server στο δίκτυο'} + + )} + {search === 'not-found' && ( + + Ο server δεν βρέθηκε στο δίκτυο. Ελέγξτε ότι είναι αναμμένος, ή σαρώστε ξανά το QR από τον διαχειριστή. + + )} {deliveryMode() === 'native' && ( { window.location.href = '/venues' }} + disabled={searching} > Αλλαγή καταστήματος
+ Ο server άλλαξε διεύθυνση +
{movedTo}
Επανασύνδεση…
Δεν είναι δυνατή η επικοινωνία με το σύστημα. Ελέγξτε τη σύνδεση WiFi.
+ Ο server δεν βρέθηκε στο δίκτυο. Ελέγξτε ότι είναι αναμμένος, ή σαρώστε ξανά το QR από τον διαχειριστή. +