Compare commits

...
29 Commits
Author SHA1 Message Date
bonaminandClaude Opus 5.5 0ec3d4735b feat(manager): license banners for offline licensing (KI-006)
Expiry reminders from 14 days (was 5) with a 'connect the server to the
internet to renew' hint while offline; banners for a clock set back and for
a not-yet-verified license; a quiet note after 7+ days without cloud contact
stating the system keeps working until the license date. Grace period
banner respects grace_over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:16:32 +03:00
bonaminandClaude Opus 5.5 13a451a756 feat(backend): offline-capable signed licensing; fix expiry grace; state in data dir (KI-006)
- services/license.py: verifies the cloud's Ed25519-signed license token
  (public key built in) and decides purely: valid → licensed until expiry,
  then a 5-day grace, then blocked - never mid-service (deferred while a
  workday is open, applied at close). Works offline for as long as the
  license lasts: the "unlicensed after 72h without heartbeat" rule is gone.
- Tamper resistance: an edited token fails the signature ("unverified");
  a clock earlier than the latest provable time (token issued_at, newest
  order in the DB, stored high-water mark; 1 day tolerance) → "clock".
- apply_license() re-evaluates from the stored token at startup, after
  every heartbeat attempt and when a workday closes. Cloud lock/unlock from
  the token keeps the workday-deferred behaviour. Transition: a cloud
  without tokens is trusted 72h per successful heartbeat.
- FIX: the promised 5-day grace after expiry never happened - the cloud's
  licensed=false was applied immediately (402 on everything).
- FIX: license_state.json lived inside the container and was lost on every
  re-creation; it now lives in the data volume (old path read once).
- /api/system/status: offline_days, license_verified, license_problem,
  grace_over; lock_reason "clock"/"unverified"; grace days from the license
  module (rounded up).

Tests: 18 unit checks (signature, tamper, other site/key, 364 days
offline, grace ±workday, inactive, clock rollback, transition) + 17 E2E
checks with a real cloud + site process (400 days offline, tampered file →
402, clock behind newest order, expiry deferred until workday close,
renewal, remote lock/unlock).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:16:32 +03:00
bonaminandClaude Opus 5.5 a349043abb feat(waiter): install-app dialog (first visit) + "Εγκατάσταση εφαρμογής" in the side drawer
Replaces the thin bottom banner with a proper dialog (taller than wide):
app icon, four reasons to install (starts instantly, survives WiFi drops,
encrypted link, finds the server + updates itself), the three install
steps, "Λήψη εφαρμογής" and "Όχι τώρα". It opens by itself once, on the
first browser visit; closing it or tapping download marks it seen for good
(previously a 7-day dismissal, and once gone there was no way back).
Afterwards it is reachable from the side drawer, right above Settings.

Offered only where it makes sense: a browser on an Android phone (never in
the native app, on iPhones or desktops) and only if this venue's server
actually serves the APK (HEAD /downloads/xenia-waiter.apk, checked once).

Verified in Edge emulating Android / iPhone / desktop against the prod
build: auto-opens once, "Όχι τώρα" persists across reloads, drawer item
above Settings reopens it, not offered on iPhone/desktop or when the
server has no APK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:01:05 +03:00
bonaminandClaude Opus 5.5 251cac9807 chore: retire the per-client domain setup (plan step 9); addresses → bonamin.net
- remove setup-ssl.sh (mkcert IP certs) - superseded by install.sh's
  self-signed cert and the backend-managed TLS identity
- manager: "Waiter Domain (παλιό σύστημα)" - shown only for sites that
  still have one
- .env.example: REGISTRY=registry.bonamin.net and
  CLOUD_URL=https://xenia-api.bonamin.net (it pointed at the admin panel,
  xenia-admin, even before the domain move); install.sh points to the
  sysadmin panel at xenia-admin.bonamin.net
- pack README registry example updated
Proxy config unchanged (HTTPS domain blocks stay so not-yet-migrated sites
keep working until their visit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:06:45 +03:00
bonaminandClaude Opus 5.5 d82a254cdb docs: pack README - UI bundles and rule CS-10 (MIN_SHELL_BUILD)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:25:27 +03:00
bonaminandClaude Opus 5.5 fe264f0a23 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>
2026-09-28 18:24:48 +03:00
bonaminandClaude Opus 5.5 a1e415ac05 feat(waiter): venue servers publish the native UI bundle (manifest + zip)
- scripts/pack-bundle.mjs zips dist-native deterministically (sorted paths,
  fixed mtimes) into dist/downloads/waiter-bundle-<hash>.zip and writes
  waiter-bundle.json {format, version "<APP_VERSION>-<hash12>", file,
  sha256 (hex), size, min_shell_build, app_version, built_at}
- src/native/shell.js: MIN_SHELL_BUILD - lowest APK versionCode a bundle
  runs in (bump with native changes)
- Dockerfile: node 22; builds web + native bundle + pack (APP_VERSION build
  arg, from ${VERSION} in docker-compose.dev.yml)
- vite native mode strips public/downloads from dist-native - the 10.8 MB
  APK was being copied into the native build (and would have ended up inside
  the next APK); bundle 15 MB → 5 MB
- nginx /downloads/: json/zip types, CORS * (the app reads the manifest from
  origin http://localhost), no-cache
- .dockerignore: android/, releases/, dist-native/ out of the build context

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:24:48 +03:00
bonaminandClaude Opus 5.5 41250b7fc7 feat(backend): identity advertises http_port (HTTP_PORT, default 80)
The native app downloads per-venue UI bundles from the plain-HTTP LAN port
(integrity via the sha256 in the manifest it fetched over pinned TLS), so it
needs to know the published port. compose passes HTTP_PORT to the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:24:48 +03:00
bonaminandClaude Opus 5.5 43f21dbf6c docs: pack README - proxy :8443 and TLS key files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:42:06 +03:00
bonaminandClaude Opus 5.5 18f13f12dd feat(waiter): encrypted, key-pinned connection to the venue server (native app)
- Android: MainActivity installs a WebViewClient whose onReceivedSslError
  proceeds only when the server's SPKI SHA-256 is in TrustStore (pins of all
  paired venues, SharedPreferences); XeniaTlsPlugin lets JS set that list.
  Spike showed this one callback covers fetch/XHR, images AND WebSockets.
  Pins are per key, not per address: IP changes, renewals and expiry never
  need action; a different key is always refused.
- Pairing: QR key (k=) must equal the server's advertised pin, else refused;
  typed addresses trust the advertised key on first use. Then the venue moves
  to https://<ip>:<tls.port> (stays on HTTP if that port isn't reachable yet).
- upgradeToTls(): on every start, a plain-HTTP venue moves onto TLS once the
  server offers it (never accepting a key different from the stored one).
- main.jsx pushes the venues' pins to native before the first request.
- Rediscovery made proactive: checks the saved address at start and every
  minute while the live connection is down, instead of waiting for requests
  to an unanswered IP to time out (minutes). HTTPS probes get 5s: the first
  TLS connection in a fresh process takes ~2s (measured).

E2E on the emulator vs an isolated stack: wrong-key QR refused; pairing on
TLS; tables + wss live; impostor server with another key refused natively;
HTTP venue upgraded on start; server cert renewed (same key) - app keeps
working without re-pairing; rediscovery over TLS (11s). Step 5 HTTP
rediscovery (now 2.7s/4.8s) and cold-start login tests, and web modes, pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:41:25 +03:00
bonaminandClaude Opus 5.5 7811bf5dcb feat(manager): pairing QR carries the server's TLS key pin (&k=)
The native app refuses a server whose key differs from the QR, then talks
to it encrypted on :8443.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:41:25 +03:00
bonaminandClaude Opus 5.5 2b9f841bbd feat(proxy): https://<LAN IP>:8443 for the native app (backend-managed cert, LAN only)
- new :8443 server (both copies byte-identical): TLS with the backend's
  data/tls key+cert, LAN-only allow list, proxies the whole waiter
  origin incl. /api/ws/ upgrades
- compose: backend healthcheck; proxy waits for backend healthy (TLS files
  exist) and mounts ${DATA_PATH}/tls read-only; publishes 8443;
  TLS_PORT passed to the backend so it advertises the published port

Verified on an isolated stack: served key == advertised pin, identity and
WebSocket over TLS, proxy starts only after the backend is healthy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:41:25 +03:00
bonaminandClaude Opus 5.5 8924a16747 feat(backend): self-managed TLS identity for the native app's encrypted LAN link
services/tls_identity.py creates an EC P-256 key + self-signed cert (10y,
SAN localhost/127.0.0.1/HOST_IP) under <data dir>/tls at startup, and
re-issues the cert with the SAME key when < 2 years remain. A cert that
doesn't belong to the key is replaced. Nothing to renew by hand; a backup of
the data directory keeps the identity. Runs in lifespan before the app is
healthy, so the proxy (which waits for healthy) always finds the files.

/api/system/identity and /api/system/status now include
tls: {port: TLS_PORT (default 8443), spki_sha256} - the base64 SHA-256 of the
public key that phones pin. Adds cryptography==46.0.4.

Tests: create / restart (no change) / renewal 8 years later keeps the key
and pin / foreign cert replaced; pin equals openssl's SPKI sha256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:41:24 +03:00
bonaminandClaude Opus 5.5 ab82598859 feat(waiter): native app finds the venue server again after its IP changes
When the saved server address stops answering, the native app re-checks it
once and then probes every address in the same /24 (same scheme and port,
nearest first, 48 in parallel, 1.5s timeout) for /api/system/identity,
switching only to the server that reports THIS venue's site_id - never to
"any Xenia server". A found address is saved on the venue and the app
reloads onto the same screen (/offline → start page) after a short
"Ο server άλλαξε διεύθυνση" notice; the offline queue syncs after reload.

- src/native/rediscovery.js: subnet candidates + scan (pure, injectable)
- src/native/autoRediscover.js: re-check first, one scan at a time,
  automatic attempts at most once a minute; skipped for dev / site-less venues
- ServerRediscovery: triggers when the connection is confirmed offline
  (retry every 2 min) or any request fails with a network error
- Offline page: manual "Αναζήτηση server στο δίκτυο" with progress and
  "not found → scan the QR" guidance

Tests: 11 unit checks (ordering, port kept, non-IP hosts, other venue never
chosen, early stop, progress). Emulator E2E with a real address change
(.99 → .2): logged in → found in ~3.5s, back on /tables with live WS;
logged out → found in ~6.5s, waiter list loads; server really down →
address untouched, manual search reports not found. Web modes unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:00:44 +03:00
bonaminandClaude Opus 5.5 cae5d75005 fix(waiter): don't log the waiter out when the server is unreachable at startup
AuthRehydrator called logout() on ANY failure of /api/auth/me, including a
network error. So opening the app while WiFi was flaky, while the server was
restarting, or right after its IP changed silently threw the waiter back to
the login screen and offline mode could not survive an app restart. Now a
network error keeps the token and retries every 5s; a real server answer
(401 and other errors) still ends the session as before.

Verified on the Android emulator: cold start with the backend stopped keeps
the token; starting the backend brings the app to /tables on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 17:00:33 +03:00
bonaminandClaude Opus 5.5 ee74f84c9f docs: pack README key files - LAN-IP resolver and netinfo helper
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:14:00 +03:00
bonaminandClaude Opus 5.5 676a725807 feat(manager): "Τοπικό δίκτυο" card - server IP source, override editor, DHCP-change warning
App Info gets its own LAN card: the IP phones use and where it comes from
(manual / automatic detection / .env / browser address), an inline editor
to pin it or return to automatic, a note when detection is unavailable
(Docker Desktop, helper stopped/stale), and an amber warning with a one-click
fix when the pinned address is no longer this machine's. Waiter URL + pairing
QR and manager URL moved here from the System grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:13:17 +03:00
bonaminandClaude Opus 5.5 76aac203d6 feat(backend): runtime LAN-IP detection (netinfo helper) + manual override
The backend's bridge network can't see the host's NICs, so HOST_IP from
install.sh went stale silently after a DHCP change. Now:

- services/netinfo_helper.py runs as a new `netinfo` service (same backend
  image, network_mode: host): every 60s it picks the PHYSICAL LAN address
  (main-table default-route NIC if real hardware, else first real NIC with
  IPv4; never WireGuard/Tailscale/ZeroTier/bridges/veths, ignores 169.254)
  and writes it to the shared `netinfo` volume. Stdlib only. On Docker
  Desktop (linuxkit/WSL2 kernel) it reports "unsupported" instead of the
  VM's meaningless address.
- services/lan_ip.py: one resolver used by /api/system/status (lan_ip +
  lan_ip_info), the pairing QR and the cloud heartbeat's local_ip:
  override (pos_settings network.lan_ip_override) → live detection (ignored
  when older than 5 min) → HOST_IP. Flags `mismatch` when a pinned address
  is no longer on any of the machine's NICs.
- PUT /api/system/lan-ip-override (manager): set a private IPv4 or null to
  return to automatic; public/loopback/link-local/IPv6 rejected (422).
- cloud_sync._get_local_ip uses the resolver (no more socket trick that
  returned the container IP).

Tested: helper selection on a fake sysfs/route table (8 cases incl. VPN
default routes) + real ioctl/route parsing on a Linux kernel; resolver
priority/staleness/mismatch/validation (18 cases); isolated full stack:
HOST_IP fallback on Docker Desktop, override save/validate/auth, simulated
Linux detection incl. DHCP change and dead helper, heartbeat IP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:13:17 +03:00
bonaminandClaude Opus 5.5 034106918d fix(install): detect HOST_IP from physical NICs only, never a VPN tunnel
`ip route get 1.1.1.1` returns the tunnel address on servers running a
full-tunnel WireGuard/Tailscale/ZeroTier client, so phones got a QR code and
URL pointing at an address they can't reach. Detection now uses the
main-table default route's interface if it is real hardware
(/sys/class/net/<if>/device), else the first physical NIC with an IPv4,
else `hostname -I` as a last resort (the installer shows it for
confirmation). .env.example says HOST_IP must be the physical LAN address.

Tested with a stubbed `ip` + fake sysfs in Debian: wg-quick full tunnel,
tunnel owning the default route, no default route and WiFi-only all pick
the physical address; full install.sh scenarios unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:46:51 +03:00
bonaminandClaude Opus 5.5 0f3237f125 docs: pack README - native Android app section (build, signing, versioning, distribution) and rule CS-9
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:32:34 +03:00
bonaminandClaude Opus 5.5 a486e3ff63 feat(waiter): native pairing, venue switcher, QR scanner, Android back handling
- First run in the native app shows only the pairing screen (VenuesPage):
  scan the manager's pairing QR or type the server address; the server must
  answer /api/system/identity as xenia-pos with a supported api_version, and
  a QR's ?pair=<site_id> must match - a code from another venue is refused
- /venues (native only): list of paired venues, switch (reloads into that
  venue's own token/IndexedDB), remove (data kept - may hold unsynced
  orders). Reachable from the login screen, the user menu and the offline page
- QrScannerModal: WebView camera via qr-scanner (no Google Play Services
  dependency, carries over to iOS); requests the camera once up front so the
  Android prompt appears a single time and "denied" gets its own message
- Hardware back: @capacitor/app listener - no-op on root screens (/, /tables,
  /login), otherwise history back. Path-based because Chrome's history
  intervention makes the WebView report canGoBack=false for the sentinel
  entry AndroidBackGuard pushes
- Before pairing no IndexedDB is opened, so nothing is created under the
  un-namespaced name
- InstallAppBanner now shows in plain-HTTP browser mode only when the venue
  server actually serves the APK (HEAD /downloads/xenia-waiter.apk)

Verified on an Android 15 emulator (API 35) with real taps via adb and state
via WebView DevTools: wrong address -> error; other venue's QR -> refused;
pairing -> venue-namespaced storage, live WebSocket; back on /tables keeps the
app open; restart keeps venue + session; second venue pairs logged-out and
switching back keeps venue 1's session; camera deny -> one prompt + message;
allow -> live preview. Signed release APK installed and paired. Web modes
(same-origin, plain-HTTP LAN IP, VITE_SERVER_URL) re-verified in Edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:23 +03:00
bonaminandClaude Opus 5.5 0b0f5c60c3 feat(waiter): Capacitor 8 Android project, native build mode, signed APK tooling
- capacitor.config.json: appId gr.bonamin.xenia, webDir dist-native,
  androidScheme http (origin http://localhost - no mixed-content block when
  calling venue servers over plain HTTP on the LAN)
- vite: `--mode native` builds to dist-native with the PWA plugin disabled
  (no service worker inside the app)
- Android: minSdk 24 / target 36; CAMERA permission for QR pairing;
  cleartext allowed via network_security_config (LAN IPs can't be listed
  per-domain); allowBackup=false so backups never carry login tokens
- Release signing reads ~/.xenia/keystore.properties (override with
  XENIA_KEYSTORE_PROPS) - the key never enters the repo; versionName 1.0.0 /
  versionCode 1 with a bump-both rule for sideloaded updates
- npm scripts build:native, apk:debug, apk:release (scripts/build-apk.mjs);
  APKs land in releases/ (gitignored); release APK is also copied to
  public/downloads/ so venue servers serve it at /downloads/xenia-waiter.apk
- waiter nginx: /downloads/ served as application/vnd.android.package-archive,
  real 404 when missing (never falls through to index.html)
- launcher icons + splash generated from the app icon (assets/ is the source)
- .gitattributes: gradlew LF, *.bat CRLF

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:23 +03:00
bonaminandClaude Opus 5.5 5230606144 feat(manager): show LAN waiter/manager addresses and pairing QR in App Info
App Info now lists 'Σερβιτόροι (WiFi)' http://<LAN IP> with a QR code
encoding http://<LAN IP>/?pair=<site_id> (phone camera opens the waiter app;
the native app will read the same code) and 'Διαχείριση (WiFi)'
http://<LAN IP>/manager. The LAN IP comes from HOST_IP, else from the
dashboard's own address when opened by IP; otherwise a hint to set HOST_IP.
QRModal takes an optional caption; the legacy waiter-domain QR is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:50:29 +03:00
bonaminandClaude Opus 5.5 2d47530069 feat(backend): public /api/system/identity for pairing + site_id/lan_ip in status
GET /api/system/identity (unauthenticated, license-exempt) returns
{app: 'xenia-pos', site_id, venue_name, version, api_version: 1} so a phone
can confirm which venue a server is when pairing and when rediscovering the
server after an IP change. No secrets: SITE_KEY never leaves the server.
/api/system/status now also returns site_id and lan_ip (HOST_IP only - in
Docker any auto-detected address is the unreachable bridge IP).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:50:29 +03:00
bonaminandClaude Opus 5.5 9fcb4df30e feat(proxy): plain-HTTP LAN entry for waiter (:80) and manager (:8081), no certs required
Phones can now open the waiter app at http://<LAN IP> with no domain, DNS
record or certificate (works around DNS-rebinding failures, KI-001). The
manager gets http://<LAN IP>:8081, with http://<LAN IP>/manager redirecting
there. waiter.*/manager.* hostnames on :80 still redirect to https, so
legacy domain sites behave as before.

- Both plain-HTTP servers are LAN-only (allow RFC1918/loopback/ULA/link-local,
  deny all -> 403), so a router port-forward can't expose an unencrypted POS
- nginx-proxy/nginx.conf and the install.sh heredoc are now byte-identical
  (one canonical config, routing map in its header)
- install.sh generates a 10-year self-signed cert when certs/ is empty (nginx
  won't start its TLS listeners without one), detects HOST_IP via
  'ip route get', prompts for it on fresh installs and backfills it into an
  existing .env, always starts the stack, prints the LAN URLs
- docker-compose publishes 8081; .env.example documents HOST_IP
- pack README: ports/request path updated, CS-5 byte-identical check

Verified: nginx -t; install.sh in Debian (fresh / upgrade without HOST_IP /
re-run - no duplicate HOST_IP, cert SAN includes HOST_IP, key 600); full
stack from freshly built images: every entry point returns the expected
200/301/302, and removing the gateway's range from the allow list yields 403
on :80 and :8081.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:50:29 +03:00
bonaminandClaude Opus 5.5 18012c2c95 feat(waiter): configurable server/venue layer for native app and plain-HTTP browser mode
New waiter_pwa/src/config/server.js is the single place that knows where the
backend is. Served by the venue's server (https domain or http://<LAN IP>)
nothing changes: same-origin URLs and the original storage keys, so existing
installs keep their token and unsynced offline queue. With an active venue
(native app, or dev builds with VITE_SERVER_URL) URLs become absolute and all
venue data is namespaced by siteId: token/savedUsername keys, the Dexie DB
(pos_snapshot__<siteId>, which also covers the WS cursor), favorites and
table-view prefs. Switching venue reloads the app.

- api client baseURL, WebSocket and SSE URLs routed through the layer
- product images / waiter avatars rendered via assetUrl()
- service-worker update prompt skipped in native builds
- InstallAppBanner: shown only in plain-HTTP browser mode and only when
  VITE_APP_DOWNLOAD_URL is set at build time (dismiss for 7 days)
- VITE_SERVER_URL override is DEV-only (a URL-controlled server in prod would
  let a crafted link capture PINs)
- pack README: rule CS-8 on never assuming same-origin

Verified with Playwright/Edge against a local backend: prod build same-origin,
prod build via LAN IP over plain HTTP (insecure context, no SW, banner shown),
and dev build pointed at the backend by URL - all three log in, reach /tables
and receive the WebSocket 'ready' frame; storage keys and IndexedDB names are
as expected. Lint: no new problems (103 before/after). Build passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:38:42 +03:00
bonaminandClaude Opus 5.5 678b54dac7 chore: pin LF line endings for shell scripts, nginx/compose configs, Dockerfiles
install.sh is copied from the Windows dev machine to Linux client boxes; with
core.autocrlf=true a fresh checkout would give it CRLF endings and bash would
fail on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:28:43 +03:00
bonaminandClaude Opus 5.5 0f9946e6ca fix(proxy): forward WebSocket upgrades and disable buffering in proxy configs
The nginx config written by install.sh proxied waiter.*, manager.* and the
IP default_server without proxy_http_version 1.1 or Upgrade/Connection
headers, so /api/ws/connect never upgraded and live events (new orders,
KDS status, chat, phone calls) never reached waiters or the manager. The
repo's nginx-proxy/nginx.conf had the same gap on the manager block.

Both configs now use a $connection_upgrade map, 1h read/send timeouts and
proxy_buffering off (SSE) on every proxied location. Verified with nginx -t
and a header-echo upstream: old config strips Upgrade, new one forwards it.

Existing sites: copy the new install.sh, re-run it, restart the proxy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:28:27 +03:00
bonaminandClaude Opus 5.5 1c8e245603 docs: add pack README with on-site rules, service map and commands
Documents the request path through proxy -> waiter nginx -> backend,
the _run_migrations requirement, real-time event handling, offline
expectations and the duplicated nginx config in install.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:52:55 +03:00
146 changed files with 5433 additions and 345 deletions
+8 -2
View File
@@ -1,14 +1,20 @@
# Registry
REGISTRY=registry.bonamin.gr
REGISTRY=registry.bonamin.net
VERSION=0.1.0
# Backend runtime secrets (get SITE_ID and SITE_KEY from the sysadmin panel)
SITE_ID=your-site-id
SITE_KEY=your-site-key
CLOUD_URL=https://xenia-admin.bonamin.gr
CLOUD_URL=https://xenia-api.bonamin.net
SECRET_KEY=generate-with-openssl-rand-hex-32
LICENSE_GRACE_HOURS=24
# This machine's LAN IP on the PHYSICAL network (Ethernet/WiFi) — the address
# phones open (http://<HOST_IP>) and the pairing QR code encodes. Never a VPN
# address (WireGuard/Tailscale/ZeroTier). install.sh detects it; reserve it in
# the router's DHCP.
HOST_IP=
# Break-glass support account (leave blank to disable)
MASTER_USERNAME=
MASTER_PASSWORD=
+9
View File
@@ -0,0 +1,9 @@
# Files that run or are read on Linux client machines must keep LF endings,
# even when checked out on Windows with core.autocrlf=true (install.sh is scp'd as-is).
*.sh text eol=lf
*.conf text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
.env.example text eol=lf
gradlew text eol=lf
*.bat text eol=crlf
+4
View File
@@ -0,0 +1,4 @@
# client-services
Read [docs/README.md](docs/README.md) (pack rules) and the global [../docs/README.md](../docs/README.md) (Working Rules) before making changes.
This is its own git repo. Commit here first, then bump the pointer in the parent `xenia-pos` repo.
+2
View File
@@ -10,6 +10,8 @@ services:
waiter_pwa:
build:
context: ./waiter_pwa
args:
APP_VERSION: ${VERSION:-dev}
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
ports:
- "5173:80"
+35 -2
View File
@@ -11,12 +11,35 @@ services:
- DATABASE_URL=sqlite:////app/data/pos.db
- VERSION=${VERSION:-0.0.0}
- HOST_IP=${HOST_IP:-}
- TLS_PORT=${TLS_PORT:-8443} # published port of the proxy's TLS entry (advertised to the app)
- HTTP_PORT=${HTTP_PORT:-80} # published plain-HTTP LAN port (app downloads UI bundles from it)
- MASTER_USERNAME=${MASTER_USERNAME:-}
- MASTER_PASSWORD=${MASTER_PASSWORD:-}
volumes:
- ${DATA_PATH}:/app/data
- ${LOGO_PATH}:/app/logo.png:ro
- ${FISCAL_PATH}:/mnt/fiscal
- netinfo:/netinfo:ro
# "Healthy" = app started, which also means the TLS key/cert for the
# proxy's :8443 exist (created at startup by services/tls_identity.py)
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/system/health', timeout=3)"]
interval: 5s
timeout: 5s
retries: 12
start_period: 20s
# Finds the server's LAN IP on the PHYSICAL network (never a VPN) every minute
# and shares it with the backend (pairing QR, manager, heartbeat). Needs the
# host's network namespace because the backend's bridge network can't see
# real NICs. Same image as the backend — see services/netinfo_helper.py.
netinfo:
image: ${REGISTRY}/pos-backend:${VERSION:-latest}
restart: unless-stopped
network_mode: host
command: ["python", "-m", "services.netinfo_helper"]
volumes:
- netinfo:/netinfo
waiter_pwa:
image: ${REGISTRY}/pos-waiter:${VERSION:-latest}
@@ -36,10 +59,20 @@ services:
- "80:80"
- "443:443"
- "4443:4443"
- "8081:8081" # manager over plain HTTP (LAN only)
- "8443:8443" # native app over TLS with a pinned key (LAN only)
volumes:
- ./nginx-proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
- ${DATA_PATH}/tls:/etc/nginx/xenia-tls:ro
depends_on:
- waiter_pwa
- manager_dashboard
backend:
condition: service_healthy
waiter_pwa:
condition: service_started
manager_dashboard:
condition: service_started
restart: unless-stopped
volumes:
netinfo:
+144
View File
@@ -0,0 +1,144 @@
# client-services — On-site Pack
Everything that runs **inside the restaurant**, on the local server box.
This folder is its **own git repo**, nested inside the `xenia-pos` parent repo.
> **Read the global rules first:** [../../docs/README.md](../../docs/README.md). This file adds only the rules specific to this pack.
> The feature catalog, the cloud contract, known issues and site records all live in the parent's `docs/`.
---
## Services
| Service | Stack | Dev port | Prod exposure | Image |
|---|---|---|---|---|
| `local_backend` | FastAPI + SQLAlchemy + SQLite (`/app/data/pos.db`) | 8000 | only through proxy / inner nginx | `pos-backend` |
| `waiter_pwa` | React + Vite + vite-plugin-pwa, axios, zustand, react-query, Dexie (IndexedDB) | 5173 | **`http://<LAN IP>`** (80, LAN only) · `https://waiter.<domain>` / `https://<IP>` (443) | `pos-waiter` |
| `manager_dashboard` | React + Vite | 5174 | **`http://<LAN IP>:8081`** (LAN only; `http://<IP>/manager` redirects there) · `https://manager.<domain>` (443) · `https://<IP>:4443` | `pos-manager` |
| `proxy` | nginx:alpine | — | 80, 443, 4443, 8081, 8443. See the header of `nginx-proxy/nginx.conf` for the full routing map. Waits for the backend to be healthy | stock |
### Request path in production
Phones normally use **plain HTTP by LAN IP** (`http://<HOST_IP>` → proxy:80 default_server). Only private source IPs are allowed; everything else gets 403.
Legacy domain sites use `https://waiter.<domain>` (proxy:443), and `waiter.*`/`manager.*` on port 80 still redirect to https.
```
phone ──http(s)──▶ proxy:80/443 ──http──▶ waiter_pwa nginx:80 ──┬─ / → static SPA
├─ /api/ → backend:8000
├─ /api/ws/ → backend:8000 (WebSocket upgrade)
└─ /static/ → backend:8000/static/
```
Every hop must forward WebSocket upgrades: `proxy_http_version 1.1`, plus `Upgrade` and `Connection` headers, plus a long `proxy_read_timeout`.
The manager calls the API **same-origin, with relative paths**. The waiter app resolves every backend URL through `waiter_pwa/src/config/server.js`: same-origin when served by the venue's server, absolute when running in the native app (see CS-8). Don't hardcode hosts.
## Key files
| File | Why it matters |
|---|---|
| `local_backend/main.py` | App setup, router registration, CORS, **`_run_migrations()`** |
| `local_backend/services/lan_ip.py`, `services/netinfo_helper.py` | Which LAN IP phones get (override → live detection → `HOST_IP`); the `netinfo` host-network helper |
| `local_backend/services/tls_identity.py`, `waiter_pwa/android/.../TrustStore.java`, `src/native/tls.js` | Encrypted LAN link: the server's self-managed key and cert, and the app's key pinning |
| `local_backend/services/cloud_sync.py` | Every call to the cloud (see the parent's `docs/reference/cloud-contract.md`) |
| `local_backend/roles.py`, `routers/deps.py` | Roles, permission checks, auth dependencies |
| `local_backend/routers/ws.py` | Real-time event stream (seq + cursor replay) |
| `waiter_pwa/src/config/server.js` | **Where the backend is**: active venue, `apiBase()`, `wsUrl()`, `assetUrl()`, `storageKey()`, `dbName()`, `deliveryMode()` |
| `waiter_pwa/android/`, `capacitor.config.json` | Native Android app (Capacitor 8). See "Native Android app" below |
| `waiter_pwa/src/native/pairing.js`, `src/pages/VenuesPage.jsx` | Pairing with a venue (QR / typed address) and the venue switcher |
| `waiter_pwa/src/api/client.js` | axios instance: auth header, 401 → logout, network error → offline |
| `waiter_pwa/src/context/SSEContext.jsx` | Real-time lifecycle, event → store/cache updates, visibility refresh |
| `waiter_pwa/src/db/posdb`, `src/services/offlineOrders` | Offline cache and queued orders |
| `nginx-proxy/nginx.conf` | Proxy config used in dev / by hand |
| `install.sh` | Site installer. **Writes its own copy of the proxy config.** |
| `docker-compose.yml` / `docker-compose.dev.yml` | Prod (pull images) / dev override (build + expose ports) |
---
## Pack-specific rules
**CS-1. Schema changes go through `_run_migrations()`.**
A new table: `create_all` handles it. A new column on an existing table: append an `ALTER TABLE <t> ADD COLUMN ...` to the `migrations` list in `local_backend/main.py`, with a SQLite-safe default for `NOT NULL`.
Add the column to the SQLAlchemy model **and** the Pydantic schema in the same commit. Never drop or rename columns.
**CS-2. New real-time events need both ends.**
If the backend emits a new event type, handle it in `waiter_pwa/src/context/SSEContext.jsx` (and in the manager, if relevant), or state explicitly that it's ignored.
Events carry IDs; clients re-fetch the full object (`/api/orders/{id}`) instead of trusting partial payloads.
**CS-3. The waiter app must survive bad networks.**
Waiters walk in and out of WiFi range and phones sleep. Any new waiter flow must:
- work from the IndexedDB cache when offline, or clearly block with an offline state
- never lose an order that was entered (queue it)
- tolerate duplicate or replayed events
**CS-4. Money and prices are snapshotted.**
Prices, costs and discounts are copied onto order items and logs when the action happens (snapshot pattern, `PriceEventLog`). Reports read the snapshots, never the live catalogue.
**CS-5. The proxy config lives in two places and must stay byte-identical.**
`install.sh` writes the heredoc on client sites, and `nginx-proxy/nginx.conf` is the repo copy. Edit both together (global Rule 9) and check with:
`sed -n "/<< 'EOF'/,/^EOF/p" install.sh | sed '1d;$d' | diff - nginx-proxy/nginx.conf`
The plain-HTTP servers (80 default_server, 8081) must keep their LAN-only `allow`/`deny` block.
**CS-6. Permissions are checked on the backend.**
Hiding a button in the UI is not access control. Every new endpoint declares its role or `perm_*` requirement through `routers/deps.py`.
**CS-8. The waiter app never assumes it's served by the backend.**
The same waiter build runs same-origin (https domain, or plain `http://<LAN IP>`) **and** inside the native app talking to a remote venue server. So in `waiter_pwa`:
- REST goes through `api/client.js` (its `baseURL` is `apiBase()`). Any other fetch, `EventSource` or `WebSocket` must build its URL with `apiBase()` or `wsUrl()`.
- Backend-served files (`/static/...` images, avatars) are rendered through `assetUrl()`.
- Persistent **venue-specific** data (token, IDs of products/tables/zones) uses `storageKey()` for localStorage keys and zustand `persist` names. The IndexedDB name comes from `dbName()`. Device preferences (theme, payment safety…) stay global.
- Features that need a secure context (service worker, camera, notifications, clipboard) must degrade gracefully when `deliveryMode()` is `'plain-web'`.
**CS-7. Greek-market realities.**
Staff UIs are used by Greek staff and must render Greek correctly, including on thermal printers (codepage). Money uses €.
## Common commands
```powershell
# Dev: backend with reload
cd local_backend; uvicorn main:app --reload --port 8000
# Dev: frontends (Vite proxies /api and /api/ws to :8000)
cd waiter_pwa; npm run dev # :5173
cd manager_dashboard; npm run dev # :5174
cd waiter_pwa; npm run lint
# Build release images (VERSION comes from .env). See the parent's DEPLOYMENT_GUIDE.md.
docker compose -f docker-compose.yml -f docker-compose.dev.yml build
docker push registry.bonamin.net/pos-backend:<ver> # + pos-waiter, pos-manager
```
## Native Android app (waiter_pwa)
The same waiter code, bundled into an APK with Capacitor 8. It is sideloaded, not on Play Store yet.
```powershell
cd waiter_pwa
npm run apk:debug # releases/xenia-waiter-<ver>-debug.apk (WebView debuggable via chrome://inspect)
npm run apk:release # releases/xenia-waiter-<ver>.apk, signed, AND copied to public/downloads/
```
- **Signing key:** `%USERPROFILE%\.xenia\xenia-release.jks` + `keystore.properties`. It lives **outside the repo; back it up.** A lost key means no phone can install updates (uninstall and reinstall on every phone). Cert SHA-256 `98:A2:60:25:…:61:7A`.
- **Versioning:** bump **both** `versionCode` and `versionName` in `android/app/build.gradle` for every APK handed out. Android refuses an update whose versionCode isn't higher.
- **Distribution:** `apk:release` copies the APK into `public/downloads/`, so the next web build (and Docker image) serves it at `http://<IP>/downloads/xenia-waiter.apk`. The browser-mode install banner links there. **Order: `apk:release` → `docker compose … build`.**
- **Native specifics:**
- `androidScheme: http` (origin `http://localhost`)
- cleartext allowed via `network_security_config.xml`
- no service worker (`vite build --mode native` → `dist-native/`)
- back button handled in `src/native/backButton.js`
- first run shows only the pairing screen
- `android/local.properties` (SDK path) is gitignored. Recreate it on a new machine: `sdk.dir=C:/Users/<you>/AppData/Local/Android/Sdk`.
- **UI bundles (plan step 8):** every waiter image also publishes the native UI bundle (`scripts/pack-bundle.mjs` → `/downloads/waiter-bundle.json` + zip). Phones run their venue's bundle via `@capgo/capacitor-updater` (manual mode, no Capgo cloud), driven by `src/native/bundles.js`.
**CS-10. `MIN_SHELL_BUILD` guards native compatibility.**
When web code starts relying on something new in `android/` (a plugin, a Java class, a manifest change), bump `MIN_SHELL_BUILD` in `src/native/shell.js` **and** `versionCode`, in the same commit. Phones with an older APK then keep their current UI and show "update the app", instead of loading a UI that calls missing native code.
Every bundle must call `markBundleReady()` at start (`BundleSync` does this); otherwise it is rolled back after 15 s.
**CS-9. Anything a native build needs must survive `npx cap sync`.**
`android/app/src/main/assets/public` and the generated configs are rebuilt on every sync, so never edit them. Native changes go in `android/app/src/main/**` (manifest, res, java) or `capacitor.config.json`.
## Testing
There is no automated test suite yet. Before committing:
- the backend starts cleanly against an **existing** `pos.db` (this catches missing migrations)
- `npm run build` passes for any frontend you touched
- the flow was exercised by hand (the parent's `PLANS AND STRATEGIES/TESTING_CHECKLIST.md`)
- printing was checked against real hardware or `esc-pos-emulator`
Say in the commit or hand-off what was and wasn't verified.
+256 -40
View File
@@ -7,6 +7,40 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# LAN IP of this machine on the PHYSICAL network (the address phones use:
# http://<HOST_IP>). Must never be a VPN address: with a full-tunnel WireGuard /
# Tailscale / ZeroTier client, "the interface that reaches the internet" is the
# tunnel, which phones on the restaurant WiFi can't reach.
# Real NICs (Ethernet, WiFi) have /sys/class/net/<if>/device; VPN tunnels,
# Docker bridges, veths and loopback don't.
# XENIA_SYS_NET overrides the sysfs path (tests only).
detect_host_ip() {
local sys_net="${XENIA_SYS_NET:-/sys/class/net}" ifc ip=""
command -v ip >/dev/null 2>&1 || { hostname -I 2>/dev/null | awk '{print $1}'; return; }
first_ipv4() { ip -4 -o addr show dev "$1" 2>/dev/null | awk '{split($4, a, "/"); print a[1]; exit}'; }
# 1) Interface of the main-table default route, if it's real hardware.
# (wg-quick full-tunnel routing uses policy rules + its own table, so the
# main table's default route still points at the physical uplink.)
ifc=$(ip route show default 2>/dev/null | awk '{for (i=1;i<=NF;i++) if ($i=="dev") {print $(i+1); exit}}')
if [ -n "$ifc" ] && [ -e "$sys_net/$ifc/device" ]; then
ip=$(first_ipv4 "$ifc")
fi
# 2) Otherwise the first real-hardware interface that has an IPv4 address.
if [ -z "$ip" ]; then
for path in "$sys_net"/*; do
ifc=$(basename "$path")
[ -e "$path/device" ] || continue
ip=$(first_ipv4 "$ifc")
[ -n "$ip" ] && break
done
fi
# 3) Last resort — the admin confirms it at the prompt anyway.
[ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
echo "$ip"
}
echo "=== Xenia POS Install ==="
echo ""
@@ -27,13 +61,16 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then
echo " A .env file has been created from .env.example."
echo " You must fill in SITE_ID, SITE_KEY, and SECRET_KEY before starting."
echo ""
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.gr"
echo " Get SITE_ID and SITE_KEY from: https://xenia-admin.bonamin.net"
echo " Generate SECRET_KEY with: openssl rand -hex 32"
echo ""
read -rp " Enter SITE_ID: " INPUT_SITE_ID
read -rp " Enter SITE_KEY: " INPUT_SITE_KEY
read -rp " Enter SECRET_KEY (leave blank to auto-generate): " INPUT_SECRET_KEY
DETECTED_IP=$(detect_host_ip)
read -rp " Enter this machine's LAN IP [${DETECTED_IP}]: " INPUT_HOST_IP
INPUT_HOST_IP=${INPUT_HOST_IP:-$DETECTED_IP}
if [ -z "$INPUT_SECRET_KEY" ]; then
INPUT_SECRET_KEY=$(openssl rand -hex 32)
@@ -43,22 +80,120 @@ if [ ! -f "$SCRIPT_DIR/.env" ]; then
sed -i "s/^SITE_ID=.*/SITE_ID=${INPUT_SITE_ID}/" "$SCRIPT_DIR/.env"
sed -i "s/^SITE_KEY=.*/SITE_KEY=${INPUT_SITE_KEY}/" "$SCRIPT_DIR/.env"
sed -i "s/^SECRET_KEY=.*/SECRET_KEY=${INPUT_SECRET_KEY}/" "$SCRIPT_DIR/.env"
sed -i "s/^HOST_IP=.*/HOST_IP=${INPUT_HOST_IP}/" "$SCRIPT_DIR/.env"
echo ""
echo " .env written. Review it at: $SCRIPT_DIR/.env"
echo ""
else
echo " .env already exists — skipping."
echo " .env already exists — keeping it."
if ! grep -q '^HOST_IP=.\+' "$SCRIPT_DIR/.env"; then
DETECTED_IP=$(detect_host_ip)
sed -i '/^HOST_IP=/d' "$SCRIPT_DIR/.env"
echo "HOST_IP=${DETECTED_IP}" >> "$SCRIPT_DIR/.env"
echo " HOST_IP was not set — added HOST_IP=${DETECTED_IP} (edit .env if wrong)."
fi
fi
# ── 3. Write nginx-proxy/nginx.conf ──────────────────────────────────────────
echo "[ 3/5 ] Writing nginx proxy config..."
cat > "$SCRIPT_DIR/nginx-proxy/nginx.conf" << 'EOF'
# Xenia POS — on-site proxy config.
# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the
# repo is a byte-identical copy. Change both together (global Working Rule 9).
#
# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup)
# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only
# /manager → redirect to :8081
# :8081 → manager dashboard over plain HTTP, LAN only
# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem)
# :4443 → manager over https
# :8443 → native app over TLS (backend-managed key, pinned), LAN only
#
# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and
# disables buffering (SSE), otherwise live events never reach phones / KDS.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ── Plain HTTP ────────────────────────────────────────────────────────────────
server {
listen 80;
server_name waiter.* manager.*;
return 301 https://$host$request_uri;
}
server {
listen 80 default_server;
server_name _;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location ~ ^/manager/?$ {
return 302 http://$host:8081/;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 8081 default_server;
server_name _;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
# ── HTTPS ─────────────────────────────────────────────────────────────────────
server {
listen 443 ssl;
server_name waiter.*;
@@ -70,10 +205,16 @@ server {
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
@@ -88,33 +229,116 @@ server {
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 8443 ssl default_server;
server_name _;
# Encrypted LAN entry for the native app (plan step 7). Key + self-signed cert
# are created and renewed by the backend (services/tls_identity.py) under
# ${DATA_PATH}/tls; phones pin the public key, so renewals need no action.
ssl_certificate /etc/nginx/xenia-tls/cert.pem;
ssl_certificate_key /etc/nginx/xenia-tls/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 4443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
EOF
@@ -122,34 +346,24 @@ EOF
# ── 4. SSL certificates ───────────────────────────────────────────────────────
echo "[ 4/5 ] Checking SSL certificates..."
# Phones normally use plain HTTP on the LAN (http://<HOST_IP>), which needs no
# certificate. nginx still needs *a* cert to start its HTTPS listeners, so when
# none is present we generate a self-signed one. A real certificate (legacy
# domain setup) can replace certs/cert.pem + certs/key.pem at any time.
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
echo " Certificates already exist — skipping."
echo " Certificates already exist — keeping them."
else
echo ""
echo " No certificates found in certs/"
echo ""
echo " DNS requirement:"
echo " Two subdomains must point to this machine's IP:"
echo " waiter.YOURDOMAIN → this machine's IP"
echo " manager.YOURDOMAIN → this machine's IP"
echo " The waiter domain should also be registered in the sysadmin"
echo " panel as the 'Waiter Domain' so phones get the QR code."
echo ""
echo " Option A — Let's Encrypt (recommended):"
echo " sudo apt install certbot"
echo " sudo certbot certonly --manual --preferred-challenges dns \\"
echo " -d waiter.YOURDOMAIN -d manager.YOURDOMAIN"
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/fullchain.pem certs/cert.pem"
echo " sudo cp /etc/letsencrypt/live/waiter.YOURDOMAIN/privkey.pem certs/key.pem"
echo ""
echo " Option B — Self-signed / mkcert (local testing only):"
echo " sudo apt install mkcert libnss3-tools"
echo " mkcert -install"
echo " mkcert -cert-file certs/cert.pem -key-file certs/key.pem \\"
echo " waiter.YOURDOMAIN manager.YOURDOMAIN"
echo ""
echo " Add certs then run: docker compose up -d"
echo ""
CERT_IP=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2)
CERT_SAN="DNS:localhost,IP:127.0.0.1"
[ -n "$CERT_IP" ] && CERT_SAN="$CERT_SAN,IP:$CERT_IP"
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout "$SCRIPT_DIR/certs/key.pem" \
-out "$SCRIPT_DIR/certs/cert.pem" \
-subj "/CN=xenia-pos" \
-addext "subjectAltName=$CERT_SAN" >/dev/null 2>&1
chmod 600 "$SCRIPT_DIR/certs/key.pem"
echo " No certificates found — generated a self-signed one (valid 10 years)."
echo " Phones use plain HTTP on the LAN, so this is only for the HTTPS ports."
fi
# ── 5. Logo ───────────────────────────────────────────────────────────────────
@@ -161,18 +375,20 @@ if [ ! -s "$SCRIPT_DIR/logo.png" ]; then
fi
# ── Done ─────────────────────────────────────────────────────────────────────
HOST_IP_NOW=$(grep '^HOST_IP=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2)
HOST_IP_NOW="${HOST_IP_NOW:-SERVER-IP}"
echo ""
echo "=== Setup complete ==="
echo ""
if [ -f "$SCRIPT_DIR/certs/cert.pem" ] && [ -f "$SCRIPT_DIR/certs/key.pem" ]; then
echo "Starting stack..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
echo ""
echo "Done! Services running."
echo " Waiter app: https://waiter.YOURDOMAIN"
echo " Manager app: https://manager.YOURDOMAIN"
else
echo "Add SSL certificates to certs/ then run:"
echo " docker compose up -d"
fi
echo "Starting stack..."
docker compose -f "$SCRIPT_DIR/docker-compose.yml" up -d
echo ""
echo "Done! Services running. From any phone or PC on this network:"
echo " Waiter app: http://${HOST_IP_NOW}"
echo " Manager app: http://${HOST_IP_NOW}/manager (→ port 8081)"
echo ""
echo "Tip: reserve ${HOST_IP_NOW} for this machine in the router's DHCP settings"
echo " so the address never changes."
echo ""
echo "If the proxy config changed, restart it: docker compose restart proxy"
+3
View File
@@ -771,6 +771,9 @@ async def lifespan(app: FastAPI):
ws_init_loop(loop)
Base.metadata.create_all(bind=engine)
_run_migrations()
# Before the app reports healthy: the proxy's :8443 needs these files to start
from services.tls_identity import ensure_tls_identity
ensure_tls_identity()
start_print_retry_thread()
pruned = prune_old_events(hours=24)
if pruned:
+2 -2
View File
@@ -24,8 +24,8 @@ license_state: dict = {
"latest_version": None,
}
# Paths that bypass all license checks (health probe)
EXEMPT_PATHS = {"/api/system/health"}
# Paths that bypass all license checks (health probe, venue identity for pairing)
EXEMPT_PATHS = {"/api/system/health", "/api/system/identity"}
# Paths that are always allowed so the frontend can read license status
# and managers can still log in / close the workday when restricted
+1
View File
@@ -9,3 +9,4 @@ bcrypt==4.2.0
pyjwt==2.9.0
httpx==0.27.2
python-multipart==0.0.9
cryptography==46.0.4
+4 -2
View File
@@ -168,8 +168,10 @@ def close_business_day(
if license_state.get("lock_pending"):
license_state["lock_pending"] = False
license_state["locked"] = True
from services.cloud_sync import _persist_state
_persist_state()
# Re-evaluate the license now that no workday is open (an expiry whose grace
# ended mid-service takes effect here) — also persists the state
from services.cloud_sync import apply_license
apply_license()
return day
+86 -25
View File
@@ -1,10 +1,12 @@
import asyncio
import ipaddress
import json
import os
import socket
import time
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import List
@@ -13,10 +15,13 @@ from models.printer import Printer
from schemas.printer import PrinterCreate, PrinterUpdate, PrinterOut
from routers.deps import get_current_user, require_manager, require_sysadmin
from models.user import User
from models.settings import PosSettings
from models.product import Category, Product
from models.table import Table, TableGroup
from services import printer_service
from services.cloud_sync import _sync_once, _push_menu_snapshot
from services.lan_ip import OVERRIDE_KEY, resolve_lan_ip, validate_lan_ip
from services.tls_identity import tls_info
from middleware.license_check import license_state
from config import settings
@@ -30,15 +35,56 @@ def health():
return {"status": "ok", "version": settings.VERSION}
# Bump when the waiter app <-> backend contract changes incompatibly, so native
# apps (which ship their own UI) can tell they're talking to a server they don't support.
API_VERSION = 1
@router.get("/identity")
def identity(db: Session = Depends(get_db)):
"""Public, unauthenticated. Lets a phone confirm which venue a server is
(pairing, and rediscovery after the server's IP changes). No secrets here:
site_id is an identifier — the secret is SITE_KEY, which never leaves the server."""
venue = db.query(PosSettings).filter(PosSettings.key == "venue.name").first()
return {
"app": "xenia-pos",
"site_id": settings.SITE_ID or None,
"venue_name": (venue.value if venue and venue.value else None),
"version": settings.VERSION,
"api_version": API_VERSION,
# Encrypted LAN endpoint for the native app: https://<ip>:<port>, pin spki_sha256
"tls": tls_info(),
# Plain-HTTP LAN port: the app downloads UI bundles from it (integrity via
# the sha256 in the manifest it fetched over TLS)
"http_port": int(os.environ.get("HTTP_PORT", "80")),
}
def _lock_reason() -> str | None:
"""Why the site is (or will be) blocked, for the manager's banner:
"admin" (locked/lock pending) · "clock" (system clock set back) ·
"unverified" (no genuine license yet) · "expired" (expiry grace over /
site deactivated) · None."""
if license_state.get("locked") or license_state.get("lock_pending"):
return "admin"
problem = license_state.get("license_problem")
if problem in ("clock", "unverified"):
return problem
if problem in ("expired", "inactive") or not license_state.get("licensed", True):
return "expired"
return None
@router.get("/status")
def system_status(db: Session = Depends(get_db), user: User = Depends(get_current_user)):
from datetime import datetime, timezone
printers = db.query(Printer).filter(Printer.is_active == True).all()
printer_statuses = []
for p in printers:
reachable = printer_service.check_printer(p.ip_address, p.port)
printer_statuses.append({"id": p.id, "name": p.name, "reachable": reachable})
lan = resolve_lan_ip(db)
licensed = license_state.get("licensed", True)
locked = license_state.get("locked", False)
lock_pending = license_state.get("lock_pending", False)
@@ -46,26 +92,10 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
days_until_expiry = license_state.get("days_until_expiry")
grace_expires_at = license_state.get("grace_expires_at")
# Determine lock_reason for the frontend banner logic
# "admin" — locked by sysadmin (immediately or deferred)
# "expired" — license grace period over, site is blocked
# None — all good
lock_reason = None
if locked or lock_pending:
lock_reason = "admin"
elif not licensed:
lock_reason = "expired"
lock_reason = _lock_reason()
# Grace days remaining (only meaningful while in expiry grace period)
grace_days_remaining = None
if grace_expires_at:
try:
grace_dt = datetime.fromisoformat(grace_expires_at)
if grace_dt.tzinfo is None:
grace_dt = grace_dt.replace(tzinfo=timezone.utc)
grace_days_remaining = max(0, (grace_dt - datetime.now(timezone.utc)).days)
except ValueError:
pass
# Computed by services/license.py (only set during the expiry grace period)
grace_days_remaining = license_state.get("grace_days_remaining")
return {
"uptime_seconds": int(time.time() - _start_time),
@@ -81,11 +111,46 @@ def system_status(db: Session = Depends(get_db), user: User = Depends(get_curren
"grace_days_remaining": grace_days_remaining,
"sync_failed": license_state.get("sync_failed", False),
"last_sync": license_state.get("last_sync"),
# Offline licensing (KI-006): days without cloud contact; the signed license
# keeps the site running until expiry regardless
"offline_days": license_state.get("offline_days"),
"license_verified": license_state.get("license_verified", False),
"license_problem": license_state.get("license_problem"),
"grace_over": license_state.get("grace_over", False),
"waiter_domain": license_state.get("waiter_domain"),
"site_id": settings.SITE_ID or None,
"lan_ip": lan.get("effective"),
"lan_ip_info": lan,
"tls": tls_info(),
"printers": printer_statuses,
}
class LanIpOverride(BaseModel):
ip: str | None = None # null / empty → clear the override (back to automatic)
@router.put("/lan-ip-override")
def set_lan_ip_override(body: LanIpOverride, db: Session = Depends(get_db), user: User = Depends(require_manager)):
"""Pin the server's LAN IP (what phones and the pairing QR use). Clearing it
returns to automatic detection / HOST_IP."""
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
if body.ip and body.ip.strip():
try:
value = validate_lan_ip(body.ip)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
if row:
row.value = value
row.updated_by_id = user.id or None
else:
db.add(PosSettings(key=OVERRIDE_KEY, value=value, updated_by_id=user.id or None))
elif row:
db.delete(row)
db.commit()
return resolve_lan_ip(db)
@router.post("/sync-license")
async def sync_license_now(user: User = Depends(require_manager)):
"""Trigger an immediate cloud heartbeat and return the fresh license state."""
@@ -94,11 +159,7 @@ async def sync_license_now(user: User = Depends(require_manager)):
"licensed": license_state.get("licensed", True),
"locked": license_state.get("locked", False),
"lock_pending": license_state.get("lock_pending", False),
"lock_reason": (
"admin" if (license_state.get("locked") or license_state.get("lock_pending"))
else "expired" if not license_state.get("licensed", True)
else None
),
"lock_reason": _lock_reason(),
"expires_at": license_state.get("expires_at"),
"days_until_expiry": license_state.get("days_until_expiry"),
"sync_failed": license_state.get("sync_failed", False),
+98 -93
View File
@@ -1,29 +1,28 @@
"""
Periodic cloud check-in. Runs every 5 minutes as an asyncio background task.
Grace period: 72 hours (3 days) before marking unlicensed on connectivity failure.
Lock behaviour:
- cloud sets locked=true → set lock_pending=true in state
- lock_pending is enforced at workday-close time (see business_day router)
- while a workday is open, the site keeps running; lock applies once it closes
Licensing (KI-006, see services/license.py): the cloud is needed to RENEW a
license, not to RUN one. Each heartbeat returns a signed license token; the
site stores it and enforces it offline — until expiry (+5 days grace), however
long it has no internet. apply_license() re-evaluates the stored token without
network: at startup, after every heartbeat attempt and when a workday closes.
License expiry behaviour:
- 5 days before expiry → warning only (days_until_expiry in state)
- on expiry → 5-day grace period begins (grace_expires_at in state)
- after grace + no open workday → licensed=False enforced by business_day router
Lock behaviour (unchanged):
- cloud sets locked=true → lock_pending while a workday is open, locked once it closes
- expiry past grace / site deactivated → likewise never mid-service
"""
import asyncio
import json
import logging
import os
import socket
from datetime import datetime, timedelta, timezone
from datetime import datetime, timezone
from pathlib import Path
import httpx
from config import settings
from middleware.license_check import license_state
from services.license import evaluate, parse_dt, verify_token
ORDER_POLL_INTERVAL = settings.CONNECT_SYNC_INTERVAL_SECONDS
@@ -31,20 +30,30 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SYNC_INTERVAL_SECONDS = 5 * 60 # 5 minutes
GRACE_HOURS = 72 # 3 days offline grace
EXPIRY_GRACE_DAYS = 5 # days after expiry before blocking
EXPIRY_WARNING_DAYS = 5 # days before expiry to show warning
STATE_FILE = Path(__file__).parent.parent / "license_state.json"
def _data_dir() -> Path:
"""Directory of the SQLite database — the persistent data volume in Docker."""
from services.tls_identity import tls_dir
return tls_dir().parent
# In the data volume: it must survive container re-creation (image updates,
# compose changes), or an offline site would lose its license. Earlier versions
# kept it inside the container at local_backend/license_state.json.
STATE_FILE = _data_dir() / "license_state.json"
LEGACY_STATE_FILE = Path(__file__).parent.parent / "license_state.json"
def _load_persisted_state():
if STATE_FILE.exists():
for path in (STATE_FILE, LEGACY_STATE_FILE):
if path.exists():
try:
data = json.loads(STATE_FILE.read_text())
license_state.update(data)
logger.info("Loaded persisted license state: %s", data)
license_state.update(json.loads(path.read_text()))
logger.info("Loaded persisted license state from %s", path)
return
except Exception as e:
logger.warning("Could not load license state file: %s", e)
logger.warning("Could not load license state file %s: %s", path, e)
def _persist_state():
@@ -54,44 +63,69 @@ def _persist_state():
logger.warning("Could not persist license state: %s", e)
def _compute_expiry_fields(expires_at_str: str | None) -> dict:
"""Return days_until_expiry and grace_expires_at derived from expires_at."""
if not expires_at_str:
return {"days_until_expiry": None, "grace_expires_at": None}
def _latest_activity(db) -> datetime | None:
"""Newest order timestamp in the database — the clock can't be set before it."""
from sqlalchemy import func
from models.order import Order, OrderItem
stamps = [db.query(func.max(Order.opened_at)).scalar(), db.query(func.max(OrderItem.added_at)).scalar()]
stamps = [parse_dt(x) for x in stamps if x]
return max(stamps, default=None)
def apply_license(now: datetime | None = None) -> None:
"""Re-evaluate the license from the stored signed token (no network)."""
if not settings.SITE_ID:
return # dev / unregistered install: licensing off, as before
from database import SessionLocal
from models.business_day import BusinessDay
now = now or datetime.now(timezone.utc)
payload = verify_token(license_state.get("license_token"), settings.SITE_ID)
db = SessionLocal()
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except ValueError:
return {"days_until_expiry": None, "grace_expires_at": None}
workday_open = db.query(BusinessDay).filter(BusinessDay.status == "open").first() is not None
latest_activity = _latest_activity(db)
finally:
db.close()
now = datetime.now(timezone.utc)
days_until = (expires_at - now).days # negative once expired
floors = [parse_dt(license_state.get("time_high_water")), latest_activity,
parse_dt(payload["issued_at"]) if payload else None]
floor = max((f for f in floors if f), default=None)
result = evaluate(payload, now, floor, workday_open, parse_dt(license_state.get("legacy_ok_at")))
if result["license_problem"] != "clock":
license_state["time_high_water"] = max(floor or now, now).isoformat()
grace_expires_at = None
if days_until < 0:
grace_expires_at = (expires_at + timedelta(days=EXPIRY_GRACE_DAYS)).isoformat()
if payload: # lock requested by the cloud — deferred while a workday is open
if payload.get("locked"):
if workday_open and not license_state.get("locked"):
license_state["lock_pending"] = True
else:
license_state["lock_pending"] = False
license_state["locked"] = True
else:
license_state["lock_pending"] = False
license_state["locked"] = False
return {
"days_until_expiry": days_until,
"grace_expires_at": grace_expires_at,
}
last_sync = parse_dt(license_state.get("last_sync"))
license_state.update({**result, "offline_days": (now - last_sync).days if last_sync else None})
_persist_state()
if result["license_problem"]:
logger.warning("License problem: %s (licensed=%s)", result["license_problem"], result["licensed"])
def _get_local_ip() -> str | None:
"""Best-effort detection of the host machine's LAN IP address.
When running inside Docker the socket trick returns the container/bridge IP,
so we honour HOST_IP if it is explicitly provided via the environment."""
import os
if host_ip := os.environ.get("HOST_IP", "").strip():
return host_ip
"""The server's LAN IP as phones see it — same resolver as the manager's
pairing QR (override → live detection → HOST_IP), see services/lan_ip.py.
No socket tricks: inside Docker they return the unreachable bridge IP."""
from database import SessionLocal
from services.lan_ip import resolve_lan_ip
db = SessionLocal()
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
return resolve_lan_ip(db)["effective"]
except Exception:
return None
finally:
db.close()
async def _sync_once():
@@ -113,69 +147,39 @@ async def _sync_once():
resp.raise_for_status()
data = resp.json()
licensed = data.get("licensed", True)
cloud_locked = data.get("locked", False)
expires_at = data.get("expires_at")
expiry_fields = _compute_expiry_fields(expires_at)
# If cloud says locked, check whether a workday is currently open.
# No open workday → lock immediately.
# Open workday → defer to workday close (business_day router enforces it).
if cloud_locked:
from database import SessionLocal
from models.business_day import BusinessDay
db = SessionLocal()
try:
open_day = db.query(BusinessDay).filter(BusinessDay.status == "open").first()
finally:
db.close()
if open_day:
if not license_state.get("lock_pending"):
token = data.get("license_token")
if token:
if verify_token(token, settings.SITE_ID):
license_state["license_token"] = token
else:
logger.error("Cloud sent a license token that does not verify - keeping the previous one")
else:
# Cloud without license signing (transition): trust this answer for 72h,
# keep the old immediate lock handling
if data.get("licensed", True):
license_state["legacy_ok_at"] = datetime.now(timezone.utc).isoformat()
license_state["expires_at"] = data.get("expires_at")
if data.get("locked"):
license_state["lock_pending"] = True
logger.info("Cloud requested lock — workday open, deferring to workday close")
else:
license_state["lock_pending"] = False
license_state["locked"] = True
logger.info("Cloud requested lock — no open workday, locking immediately")
# If cloud lifts the lock, clear pending too
if not cloud_locked:
license_state["lock_pending"] = False
license_state["locked"] = False
license_state.update({
"licensed": licensed,
"expires_at": expires_at,
"latest_version": data.get("latest_version"),
"waiter_domain": data.get("waiter_domain"),
"site_numeric_id": data.get("site_numeric_id"),
"last_sync": datetime.now(timezone.utc).isoformat(),
"sync_failed": False,
**expiry_fields,
})
_persist_state()
logger.info("Cloud sync OK: licensed=%s locked=%s expires_at=%s", licensed, cloud_locked, expires_at)
logger.info("Cloud sync OK (signed license: %s)", bool(token))
except Exception as e:
logger.warning("Cloud sync failed: %s", e)
license_state["sync_failed"] = True
last_sync_str = license_state.get("last_sync")
if last_sync_str:
try:
last_sync = datetime.fromisoformat(last_sync_str)
grace_expires = last_sync + timedelta(hours=GRACE_HOURS)
if datetime.now(timezone.utc) > grace_expires:
logger.error("72-hour offline grace period expired — marking unlicensed")
license_state["licensed"] = False
except ValueError:
pass
# Recompute expiry fields from cached expires_at even when offline
expiry_fields = _compute_expiry_fields(license_state.get("expires_at"))
license_state.update(expiry_fields)
# Online or not: the stored signed license decides (no more 72h offline rule)
apply_license()
IMAGE_DIR = Path("/app/data/product_images")
@@ -398,6 +402,7 @@ async def _pull_pending_orders():
async def _sync_loop():
_load_persisted_state()
apply_license() # decide from the stored license before the first network attempt
while True:
await _sync_once()
await asyncio.sleep(SYNC_INTERVAL_SECONDS)
+91
View File
@@ -0,0 +1,91 @@
"""
The server's LAN address — the one phones use (http://<ip>), the pairing QR
encodes and the cloud heartbeat reports. One resolver, used everywhere.
Priority:
1. override — set once in the manager (pos_settings 'network.lan_ip_override')
2. detected — live, from the netinfo helper container (services/netinfo_helper.py)
3. env — HOST_IP from .env (written by install.sh)
→ None: the manager falls back to the address it was opened with.
When the address in use (override / env) differs from what the helper sees
right now, `mismatch` is set so the manager can warn before phones break
(typical cause: the router's DHCP handed the server a new address).
"""
import ipaddress
import json
import os
from datetime import datetime, timezone
OVERRIDE_KEY = "network.lan_ip_override"
NETINFO_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
DETECTION_MAX_AGE_SECONDS = 300 # helper writes every 60s; older means it stopped
def validate_lan_ip(value: str) -> str:
"""Normalise and check an override: a private, non-loopback IPv4 address."""
try:
addr = ipaddress.ip_address(value.strip())
except ValueError:
raise ValueError("Μη έγκυρη διεύθυνση IP (π.χ. 192.168.1.50)")
if addr.version != 4 or not addr.is_private or addr.is_loopback or addr.is_link_local:
raise ValueError("Η διεύθυνση πρέπει να είναι IPv4 τοπικού δικτύου (π.χ. 192.168.x.x ή 10.x.x.x)")
return str(addr)
def read_detected(path: str = NETINFO_FILE, now: datetime | None = None) -> dict | None:
"""Latest helper result, or None if the helper isn't running / file is stale."""
try:
with open(path) as f:
data = json.load(f)
detected_at = datetime.fromisoformat(data["detected_at"])
except (OSError, ValueError, KeyError, TypeError):
return None
now = now or datetime.now(timezone.utc)
data["age_seconds"] = int((now - detected_at).total_seconds())
data["stale"] = data["age_seconds"] > DETECTION_MAX_AGE_SECONDS
return data
def resolve_lan_ip(db=None, override: str | None = None, detected: dict | None = None,
env: str | None = None) -> dict:
"""Effective LAN IP + where it came from. Pass db to read the override setting;
the explicit arguments exist for tests."""
if db is not None and override is None:
from models.settings import PosSettings
row = db.query(PosSettings).filter(PosSettings.key == OVERRIDE_KEY).first()
override = row.value.strip() if row and row.value and row.value.strip() else None
if detected is None:
detected = read_detected()
if env is None:
env = os.environ.get("HOST_IP", "").strip() or None
live = detected if detected and not detected.get("stale") and detected.get("ip") else None
live_ip = live["ip"] if live else None
live_all = {c["ip"] for c in (live or {}).get("candidates", [])} | ({live_ip} if live_ip else set())
if override:
effective, source = override, "override"
elif live_ip:
effective, source = live_ip, "detected"
elif env:
effective, source = env, "env"
else:
effective, source = None, None
return {
"effective": effective,
"source": source, # override | detected | env | None
"override": override,
"detected": live_ip,
"detected_candidates": (live or {}).get("candidates", []),
"detection": (
"unsupported" if detected and detected.get("unsupported")
else "stale" if detected and detected.get("stale")
else "ok" if live_ip
else "unavailable"
),
"env": env,
# Using a fixed address that this machine no longer has on any NIC
"mismatch": bool(effective and source in ("override", "env") and live_ip and effective not in live_all),
}
+106
View File
@@ -0,0 +1,106 @@
"""
Offline-capable licensing (KI-006).
The cloud is needed to RENEW a license, not to RUN one. Every heartbeat
brings a license token signed by the cloud (Ed25519). The site stores it and
enforces it by itself, so a venue that paid for a year keeps working for that
year even if it never goes online again. The old rule ("unlicensed after 72h
without a heartbeat") is gone.
Tamper resistance:
- Editing the stored token breaks the signature → treated as no license.
- Turning the clock back: "now" may not be earlier than the latest time this
system has provably seen — the token's signed cloud time, the newest order
in the database, and a stored high-water mark (1 day tolerance).
Limits (documented): someone with root on the server could still patch the
code itself, and a remote lock only reaches a site when it next goes online.
Rules (evaluate()):
valid token, clock OK:
active and not expired → licensed
expired: 5-day grace → licensed, warnings in the manager
grace over / site deactivated → unlicensed, but never mid-service:
deferred while a workday is open
no valid token:
old cloud without tokens, heartbeat said licensed < 72h ago → licensed (transition)
otherwise → unlicensed ("unverified")
Locks from the cloud (token.locked) keep the existing workday-deferred behaviour.
"""
import base64
import json
import math
from datetime import datetime, timedelta, timezone
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
# Matches LICENSE_SIGNING_KEY in the cloud's .env (generated 2026-09-28).
# Built into the code on purpose: a key taken from configuration could simply
# be replaced together with a self-made token.
LICENSE_PUBLIC_KEY = "2oeFHV6hgAlJsx/ZBvG6fqmWYn5tjSW5hURrrPhLoOw="
EXPIRY_GRACE = timedelta(days=5)
CLOCK_TOLERANCE = timedelta(days=1)
LEGACY_UNSIGNED_OK = timedelta(hours=72) # only while the cloud sends no tokens
def _b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def parse_dt(value) -> datetime | None:
if not value:
return None
try:
dt = datetime.fromisoformat(value) if isinstance(value, str) else value
except ValueError:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def verify_token(token: str | None, site_id: str, public_key_b64: str = LICENSE_PUBLIC_KEY) -> dict | None:
"""Payload of a genuine token for this site, else None."""
if not token or "." not in token:
return None
body, _, sig = token.partition(".")
try:
Ed25519PublicKey.from_public_bytes(base64.b64decode(public_key_b64)).verify(_b64url_decode(sig), body.encode())
payload = json.loads(_b64url_decode(body))
except (InvalidSignature, ValueError, TypeError):
return None
if payload.get("v") != 1 or payload.get("site_id") != site_id:
return None
if not parse_dt(payload.get("expires_at")) or not parse_dt(payload.get("issued_at")):
return None
return payload
def evaluate(payload: dict | None, now: datetime, floor: datetime | None, workday_open: bool,
legacy_ok_at: datetime | None = None) -> dict:
"""Pure license decision. `floor` = latest time this system has provably seen."""
base = {"license_verified": payload is not None, "license_problem": None, "grace_over": False,
"days_until_expiry": None, "grace_expires_at": None, "grace_days_remaining": None}
if payload is None:
legacy = legacy_ok_at is not None and now - legacy_ok_at <= LEGACY_UNSIGNED_OK
return {**base, "licensed": legacy, "license_problem": None if legacy else "unverified"}
if floor is not None and now < floor - CLOCK_TOLERANCE:
return {**base, "licensed": False, "license_problem": "clock"}
expires = parse_dt(payload["expires_at"])
grace_end = expires + EXPIRY_GRACE
days_until = (expires - now).days # negative once expired
grace_over = now > grace_end
fields = {
**base,
"expires_at": expires.isoformat(),
"days_until_expiry": days_until,
"grace_expires_at": grace_end.isoformat() if days_until < 0 else None,
# Rounded up: 2 days 23 hours left reads as "3 days", as people count it
"grace_days_remaining": math.ceil((grace_end - now) / timedelta(days=1)) if days_until < 0 and not grace_over else None,
"grace_over": grace_over,
}
problem = "inactive" if not payload.get("active", True) else "expired" if grace_over else None
# Never cut a restaurant off mid-service: an open workday finishes first
return {**fields, "licensed": problem is None or workday_open, "license_problem": problem}
+134
View File
@@ -0,0 +1,134 @@
"""
Host LAN-IP detector — runs as its own container with `network_mode: host`.
The backend lives in a Docker bridge network and can only see its container
address, never the machine's real network cards. This helper shares the host's
network namespace, finds the server's address on the PHYSICAL network
(Ethernet/WiFi — never a WireGuard/Tailscale/ZeroTier tunnel, Docker bridge or
veth) and writes it to a small JSON file on a volume the backend reads
(services/lan_ip.py). It refreshes every minute, so a DHCP change shows up
without anyone re-running install.sh.
Selection (same rules as install.sh's detect_host_ip):
1. the interface of the main-table default route, if it is real hardware
(real NICs have /sys/class/net/<if>/device; tunnels and bridges don't);
2. otherwise the first real-hardware interface that has an IPv4 address.
On Docker Desktop (Windows/Mac dev machines) "the host network" is Docker's own
Linux VM, whose address means nothing to phones — the helper detects that and
reports no IP instead of a wrong one.
Standalone on purpose: standard library only, no app imports, no database.
Run: python -m services.netinfo_helper
"""
import fcntl
import json
import os
import platform
import socket
import struct
import time
from datetime import datetime, timezone
OUT_FILE = os.environ.get("NETINFO_FILE", "/netinfo/host_ip.json")
INTERVAL_SECONDS = int(os.environ.get("NETINFO_INTERVAL", "60"))
SIOCGIFADDR = 0x8915
def ipv4_of(ifname: str) -> str | None:
"""Primary IPv4 address of an interface, or None if it has none."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
try:
packed = fcntl.ioctl(s.fileno(), SIOCGIFADDR, struct.pack("256s", ifname[:15].encode()))
except OSError:
return None
return socket.inet_ntoa(packed[20:24])
def default_route_iface(route_file: str = "/proc/net/route") -> str | None:
"""Interface of the main routing table's default route (lowest metric)."""
best = None
try:
with open(route_file) as f:
next(f) # header
for line in f:
cols = line.split()
if len(cols) < 7:
continue
iface, dest, flags, metric = cols[0], cols[1], int(cols[3], 16), int(cols[6])
if dest == "00000000" and flags & 0x1: # default route, RTF_UP
if best is None or metric < best[1]:
best = (iface, metric)
except OSError:
return None
return best[0] if best else None
def detect(sys_net: str = "/sys/class/net", route_file: str = "/proc/net/route", ipv4=ipv4_of) -> dict:
"""Pick the physical LAN address. Pure apart from the injected lookups (testable)."""
try:
names = sorted(os.listdir(sys_net))
except OSError:
names = []
candidates = []
for name in names:
if not os.path.exists(os.path.join(sys_net, name, "device")):
continue # tunnel, bridge, veth, loopback
addr = ipv4(name)
if addr and not addr.startswith("169.254."):
candidates.append({"iface": name, "ip": addr})
default_if = default_route_iface(route_file)
chosen = next((c for c in candidates if c["iface"] == default_if), None)
if chosen is None and candidates:
chosen = candidates[0]
return {
"ip": chosen["ip"] if chosen else None,
"iface": chosen["iface"] if chosen else None,
"candidates": candidates,
"default_iface": default_if,
}
def docker_desktop() -> bool:
"""Docker Desktop's VM kernel identifies itself; its 'host' network isn't the LAN."""
release = platform.release().lower()
return "linuxkit" in release or "microsoft" in release
def write_atomic(path: str, data: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.tmp"
with open(tmp, "w") as f:
json.dump(data, f)
os.replace(tmp, path)
def run_once() -> dict:
if docker_desktop():
result = {"ip": None, "iface": None, "candidates": [], "default_iface": None,
"unsupported": "docker-desktop"}
else:
result = detect()
result["detected_at"] = datetime.now(timezone.utc).isoformat()
write_atomic(OUT_FILE, result)
return result
def main() -> None:
last_ip = object()
while True:
try:
result = run_once()
if result["ip"] != last_ip:
print(f"netinfo: LAN IP {result['ip']} via {result['iface']} "
f"(candidates: {result['candidates']}{', ' + result['unsupported'] if result.get('unsupported') else ''})",
flush=True)
last_ip = result["ip"]
except Exception as e: # never die — the backend treats a stale file as "unknown"
print(f"netinfo: detection failed: {e}", flush=True)
time.sleep(INTERVAL_SECONDS)
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
"""
The server's own TLS identity for the native app's encrypted LAN connection
(https://<LAN IP>:8443, plan step 7). Zero maintenance by design:
- Created automatically at backend startup if missing: an EC P-256 key and a
self-signed certificate valid for 10 years, stored next to the database
(<data dir>/tls/), so a backup of the data directory keeps the same identity.
- Re-issued automatically at startup when less than 2 years of validity remain
— always with the SAME key.
- Phones pin the SHA-256 of the public key (SPKI), not the certificate, so
renewals, expiry and IP changes never require touching a phone. Only a lost
key (new machine without a backup) needs the waiters to re-scan the QR.
The proxy serves :8443 with these files (nginx-proxy/nginx.conf) and starts only
after the backend is healthy, i.e. after this ran.
"""
import base64
import hashlib
import ipaddress
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID
logger = logging.getLogger(__name__)
VALIDITY = timedelta(days=3650)
RENEW_BEFORE = timedelta(days=730)
TLS_PORT = int(os.environ.get("TLS_PORT", "8443"))
_cached_spki: str | None = None
def tls_dir() -> Path:
"""<directory of the SQLite database>/tls, overridable with TLS_DIR."""
if os.environ.get("TLS_DIR"):
return Path(os.environ["TLS_DIR"])
from config import settings
db_url = settings.DATABASE_URL
db_path = db_url.split("sqlite:///", 1)[1] if db_url.startswith("sqlite:///") else "./pos.db"
return Path(db_path).resolve().parent / "tls"
def spki_sha256(public_key) -> str:
"""Base64 SHA-256 of the DER SubjectPublicKeyInfo — what phones pin."""
der = public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
return base64.b64encode(hashlib.sha256(der).digest()).decode()
def _issue_cert(key, host_ip: str | None, now: datetime) -> x509.Certificate:
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "xenia-pos")])
sans: list[x509.GeneralName] = [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))]
if host_ip:
try:
sans.append(x509.IPAddress(ipaddress.ip_address(host_ip)))
except ValueError:
pass
return (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(days=1))
.not_valid_after(now + VALIDITY)
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(key, hashes.SHA256())
)
def ensure_tls_identity(directory: Path | None = None, now: datetime | None = None) -> dict:
"""Create the key/cert if missing, renew the cert (same key) if it expires
within RENEW_BEFORE. Returns {spki_sha256, not_after, created, renewed}."""
global _cached_spki
directory = directory or tls_dir()
now = now or datetime.now(timezone.utc)
directory.mkdir(parents=True, exist_ok=True)
key_file, cert_file = directory / "key.pem", directory / "cert.pem"
created = renewed = False
if key_file.exists():
key = serialization.load_pem_private_key(key_file.read_bytes(), password=None)
else:
key = ec.generate_private_key(ec.SECP256R1())
tmp = key_file.with_suffix(".tmp")
tmp.write_bytes(key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
serialization.NoEncryption()))
os.chmod(tmp, 0o600)
os.replace(tmp, key_file)
created = True
cert = None
if cert_file.exists() and not created:
cert = x509.load_pem_x509_certificate(cert_file.read_bytes())
if cert.public_key().public_numbers() != key.public_key().public_numbers():
cert = None # cert from another key — never serve a mismatched pair
if cert is None or cert.not_valid_after_utc - now < RENEW_BEFORE:
renewed = cert is not None
cert = _issue_cert(key, os.environ.get("HOST_IP", "").strip() or None, now)
tmp = cert_file.with_suffix(".tmp")
tmp.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
os.replace(tmp, cert_file)
_cached_spki = spki_sha256(key.public_key())
if created or renewed:
logger.info("TLS identity %s (key pin %s, valid until %s)",
"created" if created else "renewed", _cached_spki, cert.not_valid_after_utc.date())
return {"spki_sha256": _cached_spki, "not_after": cert.not_valid_after_utc.isoformat(),
"created": created, "renewed": renewed}
def tls_info() -> dict | None:
"""What /api/system/identity advertises, or None if TLS isn't set up."""
if _cached_spki is None:
try:
ensure_tls_identity()
except Exception:
logger.exception("TLS identity unavailable")
return None
return {"port": TLS_PORT, "spki_sha256": _cached_spki}
@@ -15,11 +15,18 @@ import client from '../api/client'
* grace_expires_at string | null (ISO)
* grace_days_remaining number | null
* sync_failed bool
* offline_days number | null days since the last successful cloud contact
* license_problem "clock" | "unverified" | "expired" | "inactive" | null
* grace_over bool
*
* The site enforces its signed license offline (KI-006): it keeps running
* until expiry however long it has no internet — offline_days is informational.
*
* Derived helpers:
* showExpiryWarning bool — true when 0 < days_until_expiry <= 5
* showExpiryWarning bool — true when 0 <= days_until_expiry <= 14
* inGracePeriod bool — true when expired but grace not yet over
* isBlocked bool — locked=true OR (unlicensed AND grace over)
* isBlocked bool — locked=true OR unlicensed
* longOffline bool — no cloud contact for 7+ days (renewal needs it)
*/
export default function useLicenseStatus() {
const { data } = useQuery({
@@ -41,9 +48,13 @@ export default function useLicenseStatus() {
grace_expires_at: null,
grace_days_remaining: null,
sync_failed: false,
offline_days: null,
license_problem: null,
grace_over: false,
showExpiryWarning: false,
inGracePeriod: false,
isBlocked: false,
longOffline: false,
}
}
@@ -57,16 +68,21 @@ export default function useLicenseStatus() {
grace_expires_at = null,
grace_days_remaining = null,
sync_failed = false,
offline_days = null,
license_problem = null,
grace_over = false,
} = data
const showExpiryWarning =
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 5
days_until_expiry != null && days_until_expiry >= 0 && days_until_expiry <= 14
const inGracePeriod =
days_until_expiry != null && days_until_expiry < 0 && licensed
days_until_expiry != null && days_until_expiry < 0 && licensed && !grace_over
const isBlocked = locked || !licensed
const longOffline = offline_days != null && offline_days >= 7
return {
licensed,
locked,
@@ -77,8 +93,12 @@ export default function useLicenseStatus() {
grace_expires_at,
grace_days_remaining,
sync_failed,
offline_days,
license_problem,
grace_over,
showExpiryWarning,
inGracePeriod,
isBlocked,
longOffline,
}
}
+35 -4
View File
@@ -83,7 +83,10 @@ function PageTitle() {
function LicenseBanner({ license }) {
const { lock_reason, locked, lock_pending, days_until_expiry, expires_at,
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked } = license
grace_days_remaining, showExpiryWarning, inGracePeriod, isBlocked,
sync_failed, offline_days, longOffline } = license
// Renewal needs the cloud: say so whenever the site can't reach it right now
const connectHint = sync_failed ? ' Συνδέστε τον server στο internet για να ανανεωθεί.' : ''
function fmtDate(iso) {
if (!iso) return ''
@@ -103,12 +106,30 @@ function LicenseBanner({ license }) {
)
}
if (lock_reason === 'clock') {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" />
Η ώρα του server έχει γυρίσει πίσω. Διορθώστε την ημερομηνία/ώρα του server ή συνδέστε τον στο internet.
</div>
)
}
if (lock_reason === 'unverified') {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" />
Δεν υπάρχει επιβεβαιωμένη άδεια χρήσης. Συνδέστε τον server στο internet για να ενεργοποιηθεί.
</div>
)
}
if (isBlocked && lock_reason === 'expired') {
const daysAgo = days_until_expiry != null ? Math.abs(days_until_expiry) : '?'
return (
<div className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white text-[13px] font-medium">
<ShieldAlert className="h-4 w-4 shrink-0" />
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.
Η άδεια χρήσης έληξε πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε την άδεια ή επικοινωνήστε με την υποστήριξη.{connectHint}
</div>
)
}
@@ -119,7 +140,7 @@ function LicenseBanner({ license }) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.
Η άδεια χρήσης έληξε στις {fmtDate(expires_at)} (πριν {daysAgo} {daysAgo === 1 ? 'μέρα' : 'μέρες'}). Απομένουν {remaining} {remaining === 1 ? 'μέρα' : 'μέρες'} περιόδου χάριτος. Ανανεώστε την άδεια για να αποφύγετε το κλείδωμα.{connectHint}
</div>
)
}
@@ -129,7 +150,17 @@ function LicenseBanner({ license }) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-amber-50 border-b border-amber-200 text-amber-700 text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.
Η άδεια χρήσης λήγει σε {days} {days === 1 ? 'μέρα' : 'μέρες'} ({fmtDate(expires_at)}). Ανανεώστε έγκαιρα.{connectHint}
</div>
)
}
// Informational: the license keeps working offline, but renewal needs the cloud
if (longOffline) {
return (
<div className="flex items-center gap-2 px-4 py-2 bg-slate-50 border-b border-slate-200 text-slate-600 text-[13px] font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
Χωρίς σύνδεση με το cloud εδώ και {offline_days} μέρες. Το σύστημα λειτουργεί κανονικά — η άδεια ισχύει έως {fmtDate(expires_at)}.
</div>
)
}
@@ -302,7 +302,7 @@ function DataTransferSection() {
}
function QRModal({ url, onClose }) {
function QRModal({ url, caption = 'Σαρώστε με το κινητό για σύνδεση στο σύστημα.', onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
@@ -314,15 +314,156 @@ function QRModal({ url, onClose }) {
<div className="p-3 bg-white border border-gray-200 rounded-xl">
<QRCodeSVG value={url} size={220} includeMargin={false} />
</div>
<p className="text-xs text-gray-400 text-center">
Σαρώστε με το κινητό για σύνδεση στο σύστημα.
</p>
<p className="text-xs text-gray-400 text-center">{caption}</p>
<button onClick={onClose} className="btn w-full text-sm">Κλείσιμο</button>
</div>
</div>
)
}
const IPV4 = /^\d{1,3}(\.\d{1,3}){3}$/
// Address phones use on the LAN. The server resolves it (manual override →
// live detection → HOST_IP, see local_backend/services/lan_ip.py); if it knows
// nothing and this dashboard was itself opened by IP, that IP is the server.
function resolveLanIp(status) {
if (status?.lan_ip) return { ip: status.lan_ip, source: status.lan_ip_info?.source ?? 'env' }
const host = window.location.hostname
return IPV4.test(host) && !host.startsWith('127.') ? { ip: host, source: 'browser' } : { ip: null, source: null }
}
const LAN_SOURCE_LABEL = {
override: 'Χειροκίνητη ρύθμιση',
detected: 'Αυτόματη ανίχνευση',
env: 'Από το .env (HOST_IP)',
browser: 'Από τη διεύθυνση του browser',
}
const DETECTION_NOTE = {
unsupported: 'Η αυτόματη ανίχνευση λειτουργεί μόνο σε Linux server (όχι σε Docker Desktop).',
stale: 'Η αυτόματη ανίχνευση σταμάτησε να ενημερώνεται — ελέγξτε το container netinfo.',
unavailable: 'Η αυτόματη ανίχνευση δεν είναι διαθέσιμη (το container netinfo δεν τρέχει).',
}
const SMALL_BTN = 'flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0 disabled:opacity-50'
function LanAccessSection({ status, onShowQr }) {
const qc = useQueryClient()
const info = status?.lan_ip_info
const { ip, source } = resolveLanIp(status)
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const saveMut = useMutation({
mutationFn: newIp => client.put('/api/system/lan-ip-override', { ip: newIp }).then(r => r.data),
onSuccess: (_, newIp) => {
toast.success(newIp ? 'Η IP αποθηκεύτηκε' : 'Επαναφορά σε αυτόματη ανίχνευση')
setEditing(false)
qc.invalidateQueries({ queryKey: ['system-status'] })
},
onError: err => toast.error(err.response?.data?.detail || 'Αποτυχία αποθήκευσης'),
})
function startEdit() {
setValue(info?.override || ip || '')
setEditing(true)
}
return (
<div className="card p-5 space-y-3">
<h2 className="font-semibold text-gray-700">Τοπικό δίκτυο</h2>
{info?.mismatch && (
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 space-y-2">
<p>
Χρησιμοποιείται η IP <span className="font-mono font-semibold">{ip}</span>, όμως ο server έχει τώρα
την <span className="font-mono font-semibold">{info.detected}</span>. Τα κινητά και το QR δεν θα
συνδέονται μέχρι να διορθωθεί.
</p>
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
Χρήση αυτόματης ανίχνευσης ({info.detected})
</button>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="text-gray-500">IP server</div>
<div className="space-y-1">
{editing ? (
<div className="flex items-center gap-2 flex-wrap">
<input
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && value.trim() && saveMut.mutate(value.trim())}
placeholder={info?.detected || '192.168.1.50'}
className="input h-7 text-xs font-mono w-36"
autoFocus
/>
<button className={SMALL_BTN} disabled={saveMut.isPending || !value.trim()} onClick={() => saveMut.mutate(value.trim())}>
Αποθήκευση
</button>
{info?.override && (
<button className={SMALL_BTN} disabled={saveMut.isPending} onClick={() => saveMut.mutate(null)}>
Αυτόματα
</button>
)}
<button className={SMALL_BTN} onClick={() => setEditing(false)}>Ακύρωση</button>
</div>
) : (
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono">{ip ?? '—'}</span>
{source && (
<span className="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{LAN_SOURCE_LABEL[source]}</span>
)}
<button className={SMALL_BTN} onClick={startEdit}>Αλλαγή</button>
</div>
)}
{!editing && info && info.detection !== 'ok' && (
<p className="text-xs text-gray-400">{DETECTION_NOTE[info.detection]}</p>
)}
{!ip && !editing && (
<p className="text-xs text-amber-700">
Άγνωστη IP — ορίστε την εδώ (π.χ. 192.168.1.50) ή <span className="font-mono">HOST_IP</span> στο .env.
</p>
)}
</div>
{ip && (
<>
<div className="text-gray-500">Σερβιτόροι (WiFi)</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}</span>
<button
onClick={() => onShowQr({
url: waiterPairUrl(ip, status?.site_id, status?.tls?.spki_sha256),
caption: 'Σαρώστε με την κάμερα του κινητού — ανοίγει την εφαρμογή σερβιτόρου. Το κινητό πρέπει να είναι στο ίδιο WiFi.',
})}
className={SMALL_BTN}
>
QR Code
</button>
</div>
<div className="text-gray-500">Διαχείριση (WiFi)</div>
<div className="font-medium text-gray-800 text-xs font-mono break-all">http://{ip}/manager</div>
</>
)}
</div>
</div>
)
}
// Pairing URL: opens the waiter app when scanned with the phone camera, and the
// native app reads the same code (?pair=<site_id>) to pair with this venue.
// k = the server's TLS public-key pin: the app refuses a server whose key
// differs, then talks to it encrypted on :8443 (plan step 7).
function waiterPairUrl(lanIp, siteId, spki) {
const params = new URLSearchParams()
if (siteId) params.set('pair', siteId)
if (siteId && spki) params.set('k', spki)
const query = params.toString()
return `http://${lanIp}/` + (query ? `?${query}` : '')
}
function formatUptime(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
@@ -334,7 +475,7 @@ export default function AppInfoTab() {
const user = useAuthStore(s => s.user)
const qc = useQueryClient()
const [refreshing, setRefreshing] = useState(false)
const [qrOpen, setQrOpen] = useState(false)
const [qr, setQr] = useState(null) // { url, caption } | null
const { data: status, isLoading } = useQuery({
queryKey: ['system-status'],
queryFn: () => client.get('/api/system/status').then(r => r.data),
@@ -416,11 +557,12 @@ export default function AppInfoTab() {
)}
{status?.waiter_domain && (
<>
<div className="text-gray-500">Waiter Domain</div>
{/* Retired per-client domain setup (plan step 9): shown only for sites that still have one */}
<div className="text-gray-500">Waiter Domain (παλιό σύστημα)</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 text-xs font-mono break-all">{status.waiter_domain}</span>
<button
onClick={() => setQrOpen(true)}
onClick={() => setQr({ url: status.waiter_domain })}
className="flex items-center gap-1 h-6 px-2 rounded-md border border-gray-300 bg-white text-gray-600 text-xs font-medium hover:bg-gray-50 transition-colors flex-shrink-0"
>
QR Code
@@ -431,6 +573,8 @@ export default function AppInfoTab() {
</div>
</div>
<LanAccessSection status={status} onShowQr={setQr} />
<TimezoneSection />
<StatsSection />
@@ -450,9 +594,7 @@ export default function AppInfoTab() {
</div>
)}
{qrOpen && status?.waiter_domain && (
<QRModal url={status.waiter_domain} onClose={() => setQrOpen(false)} />
)}
{qr && <QRModal url={qr.url} caption={qr.caption} onClose={() => setQr(null)} />}
</div>
)
}
+204 -5
View File
@@ -1,11 +1,102 @@
# Xenia POS — on-site proxy config.
# install.sh writes this exact file on client sites; nginx-proxy/nginx.conf in the
# repo is a byte-identical copy. Change both together (global Working Rule 9).
#
# :80 waiter.* / manager.* hostnames → redirect to https (legacy domain setup)
# :80 anything else (bare LAN IP) → waiter app over plain HTTP, LAN only
# /manager → redirect to :8081
# :8081 → manager dashboard over plain HTTP, LAN only
# :443 waiter.* / manager.* / bare IP → https (needs certs/cert.pem + key.pem)
# :4443 → manager over https
# :8443 → native app over TLS (backend-managed key, pinned), LAN only
#
# Every proxied location forwards WebSocket upgrades (/api/ws/connect) and
# disables buffering (SSE), otherwise live events never reach phones / KDS.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ── Plain HTTP ────────────────────────────────────────────────────────────────
server {
listen 80;
server_name waiter.* manager.*;
return 301 https://$host$request_uri;
}
server {
listen 80 default_server;
server_name _;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location ~ ^/manager/?$ {
return 302 http://$host:8081/;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 8081 default_server;
server_name _;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
# ── HTTPS ─────────────────────────────────────────────────────────────────────
server {
listen 443 ssl;
server_name waiter.* _;
server_name waiter.*;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -16,18 +107,20 @@ server {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 4443 ssl;
server_name manager.* _;
listen 443 ssl;
server_name manager.*;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
@@ -36,9 +129,115 @@ server {
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location /api/ {
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 8443 ssl default_server;
server_name _;
# Encrypted LAN entry for the native app (plan step 7). Key + self-signed cert
# are created and renewed by the backend (services/tls_identity.py) under
# ${DATA_PATH}/tls; phones pin the public key, so renewals need no action.
ssl_certificate /etc/nginx/xenia-tls/cert.pem;
ssl_certificate_key /etc/nginx/xenia-tls/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# LAN only: plain HTTP must never be reachable from the internet, even if the
# client forwards ports on their router. Relies on Docker preserving the real
# client IP (default iptables port publishing on Linux).
allow 10.0.0.0/8;
allow 172.16.0.0/12;
allow 192.168.0.0/16;
allow 127.0.0.0/8;
allow fc00::/7;
allow fe80::/10;
allow ::1;
deny all;
location / {
proxy_pass http://waiter_pwa:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
server {
listen 4443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://manager_dashboard:80;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
}
-67
View File
@@ -1,67 +0,0 @@
#!/bin/bash
# Run this once on the server machine to generate SSL certificates.
# Requires mkcert: https://github.com/FiloSottile/mkcert
#
# After running this script, install the CA on each device that needs
# to access the system (phones, tablets, other PCs).
#
# Usage: bash setup-ssl.sh [SERVER_IP]
# Example: bash setup-ssl.sh 192.168.1.50
set -e
SERVER_IP="${1:-$(hostname -I | awk '{print $1}')}"
CERT_DIR="$(dirname "$0")/certs"
echo "Setting up SSL for IP: $SERVER_IP"
echo "Certificates will be saved to: $CERT_DIR"
# Install mkcert if not present
if ! command -v mkcert &> /dev/null; then
echo "Installing mkcert..."
if command -v apt-get &> /dev/null; then
sudo apt-get update -q && sudo apt-get install -y mkcert libnss3-tools
elif command -v brew &> /dev/null; then
brew install mkcert nss
else
echo "ERROR: Please install mkcert manually: https://github.com/FiloSottile/mkcert"
exit 1
fi
fi
# Install the local CA (makes this machine trust its own certs)
mkcert -install
# Generate the certificate for this machine's IP (and localhost for dev)
mkdir -p "$CERT_DIR"
mkcert \
-cert-file "$CERT_DIR/cert.pem" \
-key-file "$CERT_DIR/key.pem" \
"$SERVER_IP" \
"localhost" \
"127.0.0.1"
echo ""
echo "Done! Certificates saved to $CERT_DIR"
echo ""
echo "======================================================"
echo " NEXT STEP: Install the CA on each device"
echo "======================================================"
echo ""
echo "The CA certificate is at:"
mkcert -CAROOT
echo ""
echo "On Android phones:"
echo " 1. Copy the 'rootCA.pem' file from the path above to the phone"
echo " 2. Settings > Security > Install certificate > CA certificate"
echo " 3. Select the rootCA.pem file"
echo ""
echo "On Windows PCs:"
echo " 1. Copy rootCA.pem and rename to rootCA.crt"
echo " 2. Double-click it > Install Certificate > Local Machine"
echo " 3. Place in: Trusted Root Certification Authorities"
echo ""
echo "The apps will then be accessible at:"
echo " Waiter PWA: https://$SERVER_IP"
echo " Manager Dashboard: https://$SERVER_IP:4443"
echo "======================================================"
+4
View File
@@ -1,3 +1,7 @@
node_modules
dist
.env
# Local-only build outputs — never part of the image build context
android
releases
dist-native
+7
View File
@@ -22,3 +22,10 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Native (Capacitor) web bundle — rebuilt by `npm run build:native`
dist-native
# Built APKs (npm run apk:debug / apk:release) — distribute, never commit
releases/
# Release APK bundled into the web build by scripts/build-apk.mjs — never commit
public/downloads/
+7 -2
View File
@@ -1,4 +1,7 @@
FROM node:20-slim AS builder
FROM node:22-slim AS builder
# Shown in the UI bundle manifest (plan step 8); the bundle's identity is its content hash
ARG APP_VERSION=0.0.0
WORKDIR /app
@@ -6,7 +9,9 @@ COPY package.json package-lock.json* ./
RUN npm install --legacy-peer-deps
COPY . .
RUN npm run build
# Web build (served to browsers) + native UI bundle that phones download from
# this server: dist/downloads/waiter-bundle.json + waiter-bundle-<hash>.zip
RUN npm run build && npx vite build --mode native && APP_VERSION=$APP_VERSION node scripts/pack-bundle.mjs
FROM nginx:alpine
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+81
View File
@@ -0,0 +1,81 @@
apply plugin: 'com.android.application'
// Release signing key lives OUTSIDE the repo (never commit it):
// %USERPROFILE%\.xenia\keystore.properties (storeFile, storePassword, keyAlias, keyPassword)
// Override the location with the XENIA_KEYSTORE_PROPS env var. Every APK handed to
// a venue must be signed with this same key, or phones refuse to install updates.
def keystorePropsFile = file(System.getenv('XENIA_KEYSTORE_PROPS') ?: "${System.getProperty('user.home')}/.xenia/keystore.properties")
def keystoreProps = new Properties()
if (keystorePropsFile.exists()) {
keystorePropsFile.withInputStream { keystoreProps.load(it) }
}
android {
namespace = "gr.bonamin.xenia"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "gr.bonamin.xenia"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Bump BOTH for every APK handed out: Android only installs an update over
// an existing install when versionCode is higher.
versionCode 1
versionName "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
if (keystoreProps['storeFile']) {
storeFile file(keystoreProps['storeFile'])
storePassword keystoreProps['storePassword']
keyAlias keystoreProps['keyAlias']
keyPassword keystoreProps['keyPassword']
}
}
}
buildTypes {
release {
if (keystoreProps['storeFile']) {
signingConfig signingConfigs.release
} else {
logger.warn("Xenia: no keystore at ${keystorePropsFile} - release APK will be UNSIGNED")
}
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
@@ -0,0 +1,20 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capgo-capacitor-updater')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- allowBackup=false: device backups must not carry login tokens to another phone.
networkSecurityConfig: venue servers are reached over plain HTTP on the LAN
(http://<LAN IP>), see res/xml/network_security_config.xml. -->
<application
android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Camera: scanning the pairing QR code (requested only when the scanner opens) -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
</manifest>
@@ -0,0 +1,36 @@
package gr.bonamin.xenia;
import android.net.http.SslError;
import android.os.Bundle;
import android.util.Log;
import android.webkit.SslErrorHandler;
import android.webkit.WebView;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.BridgeWebViewClient;
public class MainActivity extends BridgeActivity {
private static final String TAG = "XeniaTLS";
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(XeniaTlsPlugin.class);
super.onCreate(savedInstanceState);
// Venue servers present a self-signed certificate on https://<ip>:8443.
// Accept it only if its public key is pinned (TrustStore) — this callback
// covers fetch/XHR, images AND WebSockets, and the WebView remembers the
// decision for that host for the rest of the session.
bridge.setWebViewClient(new BridgeWebViewClient(bridge) {
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
String pin = TrustStore.spkiSha256(error.getCertificate());
if (pin != null && TrustStore.get(getApplicationContext()).contains(pin)) {
handler.proceed();
} else {
Log.w(TAG, "Refused TLS connection to " + error.getUrl() + " (key " + pin + " not pinned)");
handler.cancel();
}
}
});
}
}
@@ -0,0 +1,67 @@
package gr.bonamin.xenia;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.http.SslCertificate;
import android.os.Build;
import android.os.Bundle;
import android.util.Base64;
import java.io.ByteArrayInputStream;
import java.security.MessageDigest;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
/**
* Public-key pins of every paired venue server (plan step 7).
*
* Venue servers use self-signed certificates, so the WebView always reports an
* SSL error for them. We accept such a connection only when the server's public
* key (SHA-256 of its SubjectPublicKeyInfo, base64) belongs to a venue this
* phone paired with. Pins are per key, not per address: IP changes, certificate
* renewals and expiry never break the connection; a different key always does.
* The JS side keeps the set in sync through XeniaTlsPlugin.
*/
final class TrustStore {
private static final String PREFS = "xenia_tls";
private static final String KEY = "trusted_spki_sha256";
private static Set<String> cache;
private TrustStore() {}
static synchronized Set<String> get(Context ctx) {
if (cache == null) {
SharedPreferences prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
cache = new HashSet<>(prefs.getStringSet(KEY, Collections.emptySet()));
}
return cache;
}
static synchronized void set(Context ctx, Set<String> pins) {
cache = new HashSet<>(pins);
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putStringSet(KEY, cache).apply();
}
/** Base64 SHA-256 of the certificate's SubjectPublicKeyInfo, or null if unreadable. */
static String spkiSha256(SslCertificate sslCert) {
try {
X509Certificate x509;
if (Build.VERSION.SDK_INT >= 29) {
x509 = sslCert.getX509Certificate();
} else {
// API 24-28: the DER bytes are only reachable through the saved state
Bundle state = SslCertificate.saveState(sslCert);
byte[] der = state.getByteArray("x509-certificate");
x509 = (X509Certificate) CertificateFactory.getInstance("X.509")
.generateCertificate(new ByteArrayInputStream(der));
}
if (x509 == null) return null;
byte[] hash = MessageDigest.getInstance("SHA-256").digest(x509.getPublicKey().getEncoded());
return Base64.encodeToString(hash, Base64.NO_WRAP);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,34 @@
package gr.bonamin.xenia;
import com.getcapacitor.JSArray;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.util.HashSet;
import java.util.Set;
import org.json.JSONException;
/** JS → native: the set of venue-server key pins the WebView may trust (see TrustStore). */
@CapacitorPlugin(name = "XeniaTls")
public class XeniaTlsPlugin extends Plugin {
@PluginMethod
public void setTrustedKeys(PluginCall call) {
JSArray keys = call.getArray("keys");
Set<String> pins = new HashSet<>();
try {
if (keys != null) {
for (int i = 0; i < keys.length(); i++) {
String k = keys.getString(i);
if (k != null && !k.isEmpty()) pins.add(k);
}
}
} catch (JSONException e) {
call.reject("keys must be an array of strings");
return;
}
TrustStore.set(getContext(), pins);
call.resolve();
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 899 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Xenia</string>
<string name="title_activity_main">Xenia</string>
<string name="package_name">gr.bonamin.xenia</string>
<string name="custom_url_scheme">gr.bonamin.xenia</string>
</resources>
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Venue servers are local machines reached by LAN IP over plain HTTP
(http://192.168.x.x). IP ranges can't be listed in a domain-config, so
cleartext is allowed app-wide. The app only ever talks to servers the user
explicitly paired (QR code / typed address) and validated via
/api/system/identity. Plan step 7 replaces this with a pinned self-signed cert.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}
+29
View File
@@ -0,0 +1,29 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
}
}
apply from: "variables.gradle"
allprojects {
repositories {
google()
mavenCentral()
}
}
task clean(type: Delete) {
delete rootProject.buildDir
}
@@ -0,0 +1,9 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/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')
+22
View File
@@ -0,0 +1,22 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx1536m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More