Pwa support, maybe
This commit is contained in:
@@ -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
85
apps/demo/e2e/pwa.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
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
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
BIN
public/pwa-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
Reference in New Issue
Block a user