From 49cecad06d34084d9f4828860e19fde37cd51678 Mon Sep 17 00:00:00 2001 From: Harvey Zuccon Date: Tue, 21 Jul 2026 15:03:34 +1000 Subject: [PATCH] Pwa support, maybe --- apps/demo/index.html | 5 ++ apps/demo/src/main.ts | 6 +- apps/demo/src/pwa.ts | 112 +++++++++++++++++++++++++++ apps/demo/src/style.css | 8 +- apps/demo/src/views/SettingsView.vue | 9 +++ package.json | 1 + scripts/generate-pwa-icons.mjs | 18 +++++ vite.config.ts | 9 ++- 8 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 apps/demo/src/pwa.ts create mode 100644 scripts/generate-pwa-icons.mjs diff --git a/apps/demo/index.html b/apps/demo/index.html index 1dc4cdf..5d88c6e 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -4,6 +4,11 @@ + + + + + Native Vue Messenger diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index b451647..91ab17c 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -3,13 +3,17 @@ import { createNativeRouter } from '@native-vue-router/core' import { createCapacitorAdapter } from '@native-vue-router/capacitor' import { createElectronRendererAdapter } from '@native-vue-router/electron' import App from './App.vue' +import { createPwaAdapter } from './pwa' import { router } from './router' import './style.css' const isElectron = navigator.userAgent.toLowerCase().includes('electron') +const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true }) const platform = isElectron ? createElectronRendererAdapter() - : createCapacitorAdapter({ haptics: true, exitAtRoot: true }) + : capacitorPlatform.name !== 'capacitor-web' + ? capacitorPlatform + : createPwaAdapter() const nativeRouter = createNativeRouter({ router, diff --git a/apps/demo/src/pwa.ts b/apps/demo/src/pwa.ts new file mode 100644 index 0000000..22e7510 --- /dev/null +++ b/apps/demo/src/pwa.ts @@ -0,0 +1,112 @@ +import { reactive, readonly } from 'vue' +import type { NativePlatformAdapter } from '@native-vue-router/core' + +interface StandaloneNavigator extends Navigator { + standalone?: boolean +} + +type ServiceWorkerState = 'unsupported' | 'installing' | 'ready' + +const state = reactive({ + ios: false, + standalone: false, + edgeGuard: false, + edgeClaims: 0, + serviceWorker: 'installing' as ServiceWorkerState, +}) + +export const pwaEnvironment = readonly(state) + +export function isIOSWebKit() { + const navigatorWithTouch = navigator as Navigator & { maxTouchPoints?: number } + return /iPad|iPhone|iPod/.test(navigator.userAgent) + || (/Macintosh/.test(navigator.userAgent) && (navigatorWithTouch.maxTouchPoints ?? 0) > 1) +} + +export function isStandalonePwa() { + return Boolean((navigator as StandaloneNavigator).standalone) + || window.matchMedia('(display-mode: standalone)').matches + || window.matchMedia('(display-mode: fullscreen)').matches +} + +function updateEnvironment() { + state.ios = isIOSWebKit() + state.standalone = isStandalonePwa() + state.edgeGuard = state.ios && state.standalone + document.documentElement.dataset.pwaPlatform = state.ios ? 'ios' : 'other' + document.documentElement.dataset.pwaDisplayMode = state.standalone ? 'standalone' : 'browser' + document.documentElement.dataset.pwaEdgeGuard = state.edgeGuard ? 'active' : 'inactive' +} + +export interface PwaAdapterOptions { + edgeWidth?: number +} + +/** + * Reserves the leading edge in an installed iOS Home Screen app before + * WebKit can turn the touch into back/forward history navigation. + * + * Safari does not expose WKWebView's native gesture switch to web content, + * so this is deliberately scoped to standalone mode and reinforced by CSS. + */ +export function createPwaAdapter(options: PwaAdapterOptions = {}): NativePlatformAdapter { + const edgeWidth = options.edgeWidth ?? 32 + return { + name: 'pwa', + install() { + updateEnvironment() + const displayMode = window.matchMedia('(display-mode: standalone)') + const update = () => updateEnvironment() + displayMode.addEventListener('change', update) + document.addEventListener('visibilitychange', update) + + if (!('serviceWorker' in navigator)) state.serviceWorker = 'unsupported' + else { + state.serviceWorker = navigator.serviceWorker.controller ? 'ready' : 'installing' + void navigator.serviceWorker.ready.then(() => { state.serviceWorker = 'ready' }) + navigator.serviceWorker.addEventListener('controllerchange', updateServiceWorkerState) + } + + let reservedTouch: number | null = null + const touchAtLeadingEdge = (event: TouchEvent) => { + if (!state.edgeGuard || event.touches.length !== 1) return undefined + if (!(event.target instanceof Element) || !event.target.closest('.nvr-navigator')) return undefined + const touch = event.touches[0] + const rtl = getComputedStyle(document.documentElement).direction === 'rtl' + const atEdge = rtl ? window.innerWidth - touch.clientX <= edgeWidth : touch.clientX <= edgeWidth + return atEdge ? touch : undefined + } + const reserveEdge = (event: TouchEvent) => { + const touch = touchAtLeadingEdge(event) + if (!touch) return + reservedTouch = touch.identifier + state.edgeClaims += 1 + event.preventDefault() + } + const holdEdge = (event: TouchEvent) => { + if (reservedTouch === null) return + if ([...event.touches].some((touch) => touch.identifier === reservedTouch)) event.preventDefault() + } + const releaseEdge = () => { reservedTouch = null } + + document.addEventListener('touchstart', reserveEdge, { capture: true, passive: false }) + document.addEventListener('touchmove', holdEdge, { capture: true, passive: false }) + document.addEventListener('touchend', releaseEdge, { capture: true, passive: true }) + document.addEventListener('touchcancel', releaseEdge, { capture: true, passive: true }) + + return () => { + displayMode.removeEventListener('change', update) + document.removeEventListener('visibilitychange', update) + if ('serviceWorker' in navigator) navigator.serviceWorker.removeEventListener('controllerchange', updateServiceWorkerState) + document.removeEventListener('touchstart', reserveEdge, true) + document.removeEventListener('touchmove', holdEdge, true) + document.removeEventListener('touchend', releaseEdge, true) + document.removeEventListener('touchcancel', releaseEdge, true) + } + }, + } +} + +function updateServiceWorkerState() { + state.serviceWorker = 'ready' +} diff --git a/apps/demo/src/style.css b/apps/demo/src/style.css index a63a49a..32cb004 100644 --- a/apps/demo/src/style.css +++ b/apps/demo/src/style.css @@ -15,7 +15,7 @@ } * { box-sizing: border-box; } -html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; } +html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; overscroll-behavior: none; } button, input { font: inherit; } button, a { -webkit-tap-highlight-color: transparent; } button { color: inherit; } @@ -27,6 +27,12 @@ body { #050608; } +html[data-pwa-edge-guard="active"], +html[data-pwa-edge-guard="active"] body { + overscroll-behavior-x: none; + -webkit-user-select: none; +} + .app-frame { position: relative; width: 100%; diff --git a/apps/demo/src/views/SettingsView.vue b/apps/demo/src/views/SettingsView.vue index 1f27f28..fa1f52c 100644 --- a/apps/demo/src/views/SettingsView.vue +++ b/apps/demo/src/views/SettingsView.vue @@ -1,6 +1,7 @@ @@ -12,6 +13,14 @@ const store = useDemoStore() 60
FPS baseline

Interactive layers use transform and opacity only.

+
+

PWA environment

+
Display modeHome Screen installation state{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}
+
Offline workerCached application shell{{ pwaEnvironment.serviceWorker }}
+
iOS edge ownershipLeading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}
+

In Safari, choose Share → Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.

+

The leading edge is reserved before WebKit navigation begins. Open a conversation and drag from the extreme left edge to verify the live back preview.

+

Simulation

diff --git a/package.json b/package.json index e78dab8..14da7ce 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:watch": "vitest", "test:e2e": "playwright test", "preview": "vite preview", + "pwa:preview": "npm run build && vite preview --host 0.0.0.0 --port 4173", "electron": "npm run build && electron apps/electron/main.mjs", "cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync" }, diff --git a/scripts/generate-pwa-icons.mjs b/scripts/generate-pwa-icons.mjs new file mode 100644 index 0000000..ac64b8a --- /dev/null +++ b/scripts/generate-pwa-icons.mjs @@ -0,0 +1,18 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { chromium } from '@playwright/test' + +const root = path.resolve(import.meta.dirname, '..') +const source = await readFile(path.join(root, 'public/app-icon.svg'), 'utf8') +const browser = await chromium.launch({ headless: true }) + +try { + for (const [file, size] of [['apple-touch-icon.png', 180], ['pwa-192.png', 192], ['pwa-512.png', 512]]) { + const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 }) + await page.setContent(`${source}`) + await page.screenshot({ path: path.join(root, 'public', file), omitBackground: false }) + await page.close() + } +} finally { + await browser.close() +} diff --git a/vite.config.ts b/vite.config.ts index 44eeff7..a5cec7f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,22 +13,27 @@ export default defineConfig({ tailwindcss(), VitePWA({ registerType: 'prompt', - includeAssets: ['favicon.svg', 'app-icon.svg'], + includeAssets: ['favicon.svg', 'app-icon.svg', 'apple-touch-icon.png', 'pwa-192.png', 'pwa-512.png'], manifest: { + id: '/', name: 'Native Vue Messenger', short_name: 'NVR Messenger', description: 'Gesture-first navigation for Vue applications', theme_color: '#0b0d12', background_color: '#0b0d12', display: 'standalone', + scope: '/', orientation: 'any', start_url: '/', icons: [ + { src: '/pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, + { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, { src: '/app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, - { src: '/app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' }, ], }, workbox: { + cleanupOutdatedCaches: true, navigateFallback: '/index.html', globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], },