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 0000000..ae2b2df Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/pwa-192.png b/public/pwa-192.png new file mode 100644 index 0000000..a280d06 Binary files /dev/null and b/public/pwa-192.png differ diff --git a/public/pwa-512.png b/public/pwa-512.png new file mode 100644 index 0000000..3c84774 Binary files /dev/null and b/public/pwa-512.png differ