diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cf3f799..a077405 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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" diff --git a/waiter_pwa/.dockerignore b/waiter_pwa/.dockerignore index 9c97bbd..f58a328 100644 --- a/waiter_pwa/.dockerignore +++ b/waiter_pwa/.dockerignore @@ -1,3 +1,7 @@ node_modules dist .env +# Local-only build outputs — never part of the image build context +android +releases +dist-native diff --git a/waiter_pwa/Dockerfile b/waiter_pwa/Dockerfile index d9e11e2..1569bb3 100644 --- a/waiter_pwa/Dockerfile +++ b/waiter_pwa/Dockerfile @@ -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-.zip +RUN npm run build && npx vite build --mode native && APP_VERSION=$APP_VERSION node scripts/pack-bundle.mjs FROM nginx:alpine diff --git a/waiter_pwa/nginx.conf b/waiter_pwa/nginx.conf index 1000995..53c0dbf 100644 --- a/waiter_pwa/nginx.conf +++ b/waiter_pwa/nginx.conf @@ -27,9 +27,17 @@ server { # Native app APK for sideloading (bundled by scripts/build-apk.mjs release). # Real 404 when absent - never fall through to index.html. + # Also the per-venue UI bundle for the native app (plan step 8): + # waiter-bundle.json (manifest, never cached) + waiter-bundle-.zip. location /downloads/ { - types { application/vnd.android.package-archive apk; } + types { + application/vnd.android.package-archive apk; + application/json json; + application/zip zip; + } add_header Cache-Control "no-cache"; + # The native app (origin http://localhost) reads the manifest via fetch + add_header Access-Control-Allow-Origin "*"; try_files $uri =404; } diff --git a/waiter_pwa/scripts/pack-bundle.mjs b/waiter_pwa/scripts/pack-bundle.mjs new file mode 100644 index 0000000..ef627d7 --- /dev/null +++ b/waiter_pwa/scripts/pack-bundle.mjs @@ -0,0 +1,70 @@ +// Pack the native UI bundle that venue servers hand to phones (plan step 8). +// +// node scripts/pack-bundle.mjs (after `vite build --mode native`) +// +// Zips dist-native/ into dist/downloads/waiter-bundle-.zip and writes +// dist/downloads/waiter-bundle.json: +// { format, version, file, sha256, size, min_shell_build, app_version, built_at } +// The app downloads the zip only if its SHA-256 (hex, lowercase) matches the +// manifest, which it fetches over the pinned TLS link. The version is derived +// from the content hash, so identical code always yields the same version and +// any change yields a new one — no manual version bumping. +import { createHash } from 'node:crypto' +import { mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { zipSync } from 'fflate' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const src = join(root, 'dist-native') +const outDir = join(root, 'dist', 'downloads') + +function walk(dir) { + return readdirSync(dir).flatMap(name => { + const p = join(dir, name) + return statSync(p).isDirectory() ? walk(p) : [p] + }) +} + +const files = walk(src).filter(f => !relative(src, f).startsWith('downloads')).sort() +if (!files.some(f => relative(src, f) === 'index.html')) { + console.error('dist-native/index.html missing — run `vite build --mode native` first') + process.exit(1) +} + +// Deterministic zip: sorted paths, fixed timestamps → same code, same bytes, same hash +const entries = {} +for (const f of files) { + entries[relative(src, f).split('\\').join('/')] = [readFileSync(f), { mtime: new Date('2000-01-01T00:00:00Z') }] +} +const zip = zipSync(entries, { level: 9 }) +const sha256 = createHash('sha256').update(zip).digest('hex') + +const minShell = Number(readFileSync(join(root, 'src', 'native', 'shell.js'), 'utf8') + .match(/MIN_SHELL_BUILD\s*=\s*(\d+)/)?.[1]) +if (!minShell) { + console.error('MIN_SHELL_BUILD not found in src/native/shell.js') + process.exit(1) +} + +const appVersion = process.env.APP_VERSION || JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version +const version = `${appVersion}-${sha256.slice(0, 12)}` +const file = `waiter-bundle-${sha256.slice(0, 12)}.zip` + +mkdirSync(outDir, { recursive: true }) +for (const old of readdirSync(outDir)) { + if (/^waiter-bundle-.*\.zip$/.test(old)) unlinkSync(join(outDir, old)) +} +writeFileSync(join(outDir, file), zip) +const manifest = { + format: 1, + version, + file, + sha256, + size: zip.length, + min_shell_build: minShell, + app_version: appVersion, + built_at: new Date().toISOString(), +} +writeFileSync(join(outDir, 'waiter-bundle.json'), JSON.stringify(manifest, null, 2) + '\n') +console.log(`bundle ${version}: ${files.length} files, ${(zip.length / 1024).toFixed(0)} KiB → dist/downloads/${file}`) diff --git a/waiter_pwa/src/native/shell.js b/waiter_pwa/src/native/shell.js new file mode 100644 index 0000000..28d28c8 --- /dev/null +++ b/waiter_pwa/src/native/shell.js @@ -0,0 +1,13 @@ +/** + * Native shell compatibility (plan step 8). + * + * Venue servers hand phones their own copy of this UI (a "bundle"). A bundle + * can only run inside an APK that has the native pieces it calls (plugins, + * TrustStore, …). MIN_SHELL_BUILD is the lowest APK versionCode this bundle + * works with — scripts/pack-bundle.mjs copies it into the bundle manifest, and + * phones with an older APK skip the bundle and show "update the app" instead. + * + * Bump it ONLY when this web code starts depending on something new in the + * native project (android/…), in the same commit that bumps versionCode. + */ +export const MIN_SHELL_BUILD = 1 diff --git a/waiter_pwa/vite.config.js b/waiter_pwa/vite.config.js index 82c8696..cc22d53 100644 --- a/waiter_pwa/vite.config.js +++ b/waiter_pwa/vite.config.js @@ -1,3 +1,5 @@ +import { rmSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' @@ -30,6 +32,14 @@ export default defineConfig(({ mode }) => { }, plugins: [ react(), + // public/downloads/ holds the APK + UI bundle that venue servers hand out. + // It belongs in the web build only — never inside the app or its bundle. + native && { + name: 'xenia-strip-downloads', + closeBundle() { + rmSync(fileURLToPath(new URL('./dist-native/downloads', import.meta.url)), { recursive: true, force: true }) + }, + }, VitePWA({ disable: native, registerType: 'prompt',