Pwa support, maybe

This commit is contained in:
2026-07-21 15:13:16 +10:00
parent 49cecad06d
commit ee25b9a94d
8 changed files with 109 additions and 2 deletions

View File

@@ -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 run build # packages, declarations, demo, and service worker
npm test # core transaction tests npm test # core transaction tests
npm run test:e2e # desktop and mobile Playwright projects 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 electron # build and launch the Electron host
npm run cap:sync # build and synchronize iOS and Android projects 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 <platform>`. 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 <platform>`.
### 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 ## Minimal integration
```ts ```ts

85
apps/demo/e2e/pwa.spec.ts Normal file
View File

@@ -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)
})

View File

@@ -8,6 +8,15 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="NVR Messenger" /> <meta name="apple-mobile-web-app-title" content="NVR Messenger" />
<meta name="format-detection" content="telephone=no" /> <meta name="format-detection" content="telephone=no" />
<script>
// Vite emits relative assets for the packaged Electron file:// build.
// Web/PWA deep links need those same URLs rooted at the HTTPS origin.
if (location.protocol !== 'file:') {
const base = document.createElement('base')
base.href = '/'
document.head.append(base)
}
</script>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
<title>Native Vue Messenger</title> <title>Native Vue Messenger</title>

View File

@@ -36,6 +36,7 @@ function updateEnvironment() {
document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other' document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other'
document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser' document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser'
document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive' document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive'
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
} }
export interface PwaAdapterOptions { export interface PwaAdapterOptions {
@@ -81,6 +82,7 @@ export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatfor
if (!touch) return if (!touch) return
reservedTouch = touch.identifier reservedTouch = touch.identifier
state.edgeClaims += 1 state.edgeClaims += 1
document.documentElement.dataset.pwaEdgeClaims = String(state.edgeClaims)
event.preventDefault() event.preventDefault()
} }
const holdEdge = (event: TouchEvent) => { const holdEdge = (event: TouchEvent) => {

View File

@@ -2,9 +2,11 @@
## PWA and browser ## 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 ## Electron

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/pwa-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/pwa-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB