From ee25b9a94d8299133b2b121f0669bac268a70951 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Tue, 21 Jul 2026 15:13:16 +1000 Subject: [PATCH] Pwa support, maybe --- README.md | 9 ++++ apps/demo/e2e/pwa.spec.ts | 85 ++++++++++++++++++++++++++++++++++++ apps/demo/index.html | 9 ++++ apps/demo/src/pwa.ts | 2 + docs/platforms.md | 6 ++- public/apple-touch-icon.png | Bin 0 -> 3255 bytes public/pwa-192.png | Bin 0 -> 3491 bytes public/pwa-512.png | Bin 0 -> 10149 bytes 8 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 apps/demo/e2e/pwa.spec.ts create mode 100644 public/apple-touch-icon.png create mode 100644 public/pwa-192.png create mode 100644 public/pwa-512.png diff --git a/README.md b/README.md index bbae2a7..893a6ec 100644 --- a/README.md +++ b/README.md @@ -29,12 +29,21 @@ The default app is the installable messaging PWA. Other useful commands: npm run build # packages, declarations, demo, and service worker npm test # core transaction tests npm run test:e2e # desktop and mobile Playwright projects +npm run pwa:preview # production PWA on every local network interface npm run electron # build and launch the Electron host npm run cap:sync # build and synchronize iOS and Android projects ``` Native projects live under `apps/capacitor/ios` and `apps/capacitor/android`. Open or run them from `apps/capacitor` with `npx cap open ios`, `npx cap open android`, or `npx cap run `. +### Test the installed iOS PWA + +Build and serve the production app with `npm run pwa:preview`, expose it through an HTTPS URL, and open that URL on the iPhone. Safari requires a secure context for the service worker; a plain LAN `http://` address is not sufficient. Choose **Share → Add to Home Screen**, then launch **NVR Messenger** from its Home Screen icon. + +The Navigation Lab reports `Standalone`, `ready`, and `App reserved` when the correct environment is active. Open a conversation and drag from the extreme left edge. The “Leading-edge touches claimed” counter should increment while the router renders its live predictive-back view. + +An installed web app cannot access `WKWebView.allowsBackForwardNavigationGestures`. The demo therefore reserves leading-edge touch sequences at the web-content boundary as an iOS standalone-only safeguard. Capacitor remains the deterministic option when native-level gesture suppression is required. + ## Minimal integration ```ts diff --git a/apps/demo/e2e/pwa.spec.ts b/apps/demo/e2e/pwa.spec.ts new file mode 100644 index 0000000..b144715 --- /dev/null +++ b/apps/demo/e2e/pwa.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from '@playwright/test' + +const iphoneUserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 Version/18.5 Mobile/15E148 Safari/604.1' + +async function dispatchTouchStart(page: import('@playwright/test').Page, clientX: number) { + return await page.locator('.nvr-navigator').evaluate((element, x) => { + const event = new Event('touchstart', { bubbles: true, cancelable: true }) + Object.defineProperty(event, 'touches', { + value: [{ identifier: 7, clientX: x, clientY: 240 }], + }) + return { dispatched: element.dispatchEvent(event), prevented: event.defaultPrevented } + }, clientX) +} + +test('ships an installable standalone manifest and iOS metadata', async ({ page, request }) => { + await page.goto('/inbox') + await expect(page.locator('meta[name="apple-mobile-web-app-capable"]')).toHaveAttribute('content', 'yes') + await expect(page.locator('meta[name="apple-mobile-web-app-status-bar-style"]')).toHaveAttribute('content', 'black-translucent') + const touchIconPath = await page.locator('link[rel="apple-touch-icon"]').evaluate((link) => + new URL(link.getAttribute('href') ?? '', document.baseURI).pathname, + ) + expect(touchIconPath).toBe('/apple-touch-icon.png') + + const manifestResponse = await request.get('/manifest.webmanifest') + expect(manifestResponse.ok()).toBe(true) + const manifest = await manifestResponse.json() + expect(manifest).toMatchObject({ id: '/', scope: '/', start_url: '/', display: 'standalone' }) + expect(manifest.icons).toEqual(expect.arrayContaining([ + expect.objectContaining({ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' }), + expect.objectContaining({ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' }), + ])) + expect((await request.get('/apple-touch-icon.png')).headers()['content-type']).toContain('image/png') + expect((await request.get('/sw.js')).ok()).toBe(true) +}) + +test('does not interfere with Safari edge touches before Home Screen installation', async ({ page }) => { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: false }) + }, iphoneUserAgent) + await page.goto('/chat/maya') + await expect(page.locator('html')).toHaveAttribute('data-pwa-display-mode', 'browser') + await expect(page.locator('html')).toHaveAttribute('data-pwa-edge-guard', 'inactive') + expect((await dispatchTouchStart(page, 1)).prevented).toBe(false) +}) + +test('reserves only the leading edge in an installed iOS PWA', async ({ page }) => { + await page.addInitScript((userAgent) => { + Object.defineProperty(navigator, 'userAgent', { configurable: true, value: userAgent }) + Object.defineProperty(navigator, 'standalone', { configurable: true, value: true }) + }, iphoneUserAgent) + await page.goto('/chat/maya') + const root = page.locator('html') + await expect(root).toHaveAttribute('data-pwa-platform', 'ios') + await expect(root).toHaveAttribute('data-pwa-display-mode', 'standalone') + await expect(root).toHaveAttribute('data-pwa-edge-guard', 'active') + + expect((await dispatchTouchStart(page, 80)).prevented).toBe(false) + expect((await dispatchTouchStart(page, 1)).prevented).toBe(true) + await expect(root).toHaveAttribute('data-pwa-edge-claims', '1') +}) + +test('registers and activates the offline service worker', async ({ page }) => { + await page.goto('/inbox') + const workerUrl = await page.evaluate(async () => { + const registration = await navigator.serviceWorker.ready + return registration.active?.scriptURL ?? '' + }) + expect(workerUrl).toMatch(/\/sw\.js$/) +}) + +test('precaches lazily split routes for offline navigation', async ({ page }) => { + await page.goto('/inbox') + await page.evaluate(async () => { await navigator.serviceWorker.ready }) + const cachedUrls = await page.evaluate(async () => { + const urls: string[] = [] + for (const name of await caches.keys()) { + const cache = await caches.open(name) + urls.push(...(await cache.keys()).map((request) => request.url)) + } + return urls + }) + expect(cachedUrls.some((url) => /StoriesView-.*\.js$/.test(url))).toBe(true) + expect(cachedUrls.some((url) => /ProfileView-.*\.js$/.test(url))).toBe(true) +}) diff --git a/apps/demo/index.html b/apps/demo/index.html index 5d88c6e..bbdc162 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -8,6 +8,15 @@ + Native Vue Messenger diff --git a/apps/demo/src/pwa.ts b/apps/demo/src/pwa.ts index 22e7510..fc0139e 100644 --- a/apps/demo/src/pwa.ts +++ b/apps/demo/src/pwa.ts @@ -36,6 +36,7 @@ function updateEnvironment() { document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other' document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser' document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive' + document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims) } export interface PwaAdapterOptions { @@ -81,6 +82,7 @@ export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatfor if (!touch) return reservedTouch = touch.identifier state.edgeClaims += 1 + document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims) event.preventDefault() } const holdEdge = (event: TouchEvent) => { diff --git a/docs/platforms.md b/docs/platforms.md index ae97ca0..0cb5e26 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -2,9 +2,11 @@ ## PWA and browser -The demo uses a standalone manifest, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. `overscroll-behavior` suppresses pull-to-refresh and history overscroll where supported; `touch-action` reserves horizontal manipulation only on navigator-owned surfaces. +The demo uses a standalone manifest, Apple Home Screen metadata and PNG icons, safe-area environment variables, and a generated Workbox service worker. Updates are prompted and cannot reload while a gesture is active. The Navigation Lab exposes the live display mode, service-worker state, edge-guard state, and intercepted-touch count. -Mobile operating systems can reserve gestures that web content cannot suppress in every browser mode. The full interaction system targets installed PWAs. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback. +In an installed iOS Home Screen app, the PWA adapter installs non-passive leading-edge touch listeners before the navigator gesture and applies `overscroll-behavior-x: none`. This gives the application the earliest web-content opportunity to claim the sequence. The guard is disabled in normal Safari tabs so the demo does not unexpectedly override browser navigation. + +Mobile operating systems can reserve gestures before web content receives them. A PWA cannot set `WKWebView.allowsBackForwardNavigationGestures`, so absolute native-level suppression cannot be guaranteed from JavaScript. The full interaction system targets installed PWAs; use the Capacitor host when that native switch must be deterministic. Normal tabs retain links, buttons, history, and non-interactive transitions as their fallback. ## Electron diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ae2b2df94934d46740e3942fd53b40c299da5f71 GIT binary patch literal 3255 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 w^Lt;ST86cDDFF8Ns=1eDv707Te@;^hI42=f