Pwa support, maybe

This commit is contained in:
2026-07-21 15:03:34 +10:00
parent e79c793b9c
commit 49cecad06d
8 changed files with 164 additions and 4 deletions

View File

@@ -4,6 +4,11 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<meta name="theme-color" content="#0b0d12" /> <meta name="theme-color" content="#0b0d12" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="NVR Messenger" />
<meta name="format-detection" content="telephone=no" />
<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>
</head> </head>

View File

@@ -3,13 +3,17 @@ import { createNativeRouter } from '@native-vue-router/core'
import { createCapacitorAdapter } from '@native-vue-router/capacitor' import { createCapacitorAdapter } from '@native-vue-router/capacitor'
import { createElectronRendererAdapter } from '@native-vue-router/electron' import { createElectronRendererAdapter } from '@native-vue-router/electron'
import App from './App.vue' import App from './App.vue'
import { createPwaAdapter } from './pwa'
import { router } from './router' import { router } from './router'
import './style.css' import './style.css'
const isElectron = navigator.userAgent.toLowerCase().includes('electron') const isElectron = navigator.userAgent.toLowerCase().includes('electron')
const capacitorPlatform = createCapacitorAdapter({ haptics: true, exitAtRoot: true })
const platform = isElectron const platform = isElectron
? createElectronRendererAdapter() ? createElectronRendererAdapter()
: createCapacitorAdapter({ haptics: true, exitAtRoot: true }) : capacitorPlatform.name !== 'capacitor-web'
? capacitorPlatform
: createPwaAdapter()
const nativeRouter = createNativeRouter({ const nativeRouter = createNativeRouter({
router, router,

112
apps/demo/src/pwa.ts Normal file
View File

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

View File

@@ -15,7 +15,7 @@
} }
* { box-sizing: border-box; } * { 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, input { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; } button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; } button { color: inherit; }
@@ -27,6 +27,12 @@ body {
#050608; #050608;
} }
html[data-pwa-edge-guard="active"],
html[data-pwa-edge-guard="active"] body {
overscroll-behavior-x: none;
-webkit-user-select: none;
}
.app-frame { .app-frame {
position: relative; position: relative;
width: 100%; width: 100%;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import AppHeader from '../components/AppHeader.vue' import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data' import { useDemoStore } from '../data'
import { pwaEnvironment } from '../pwa'
const store = useDemoStore() const store = useDemoStore()
</script> </script>
@@ -12,6 +13,14 @@ const store = useDemoStore()
<span>60</span> <span>60</span>
<div><strong>FPS baseline</strong><p>Interactive layers use transform and opacity only.</p></div> <div><strong>FPS baseline</strong><p>Interactive layers use transform and opacity only.</p></div>
</section> </section>
<section class="settings-group">
<h2>PWA environment</h2>
<div><span><strong>Display mode</strong><small>Home Screen installation state</small></span><b :class="{ offline: !pwaEnvironment.standalone }">{{ pwaEnvironment.standalone ? 'Standalone' : 'Browser tab' }}</b></div>
<div><span><strong>Offline worker</strong><small>Cached application shell</small></span><b :class="{ offline: pwaEnvironment.serviceWorker !== 'ready' }">{{ pwaEnvironment.serviceWorker }}</b></div>
<div><span><strong>iOS edge ownership</strong><small>Leading-edge touches claimed by this app: {{ pwaEnvironment.edgeClaims }}</small></span><b :class="{ offline: !pwaEnvironment.edgeGuard }">{{ pwaEnvironment.edgeGuard ? 'App reserved' : pwaEnvironment.ios ? 'Install required' : 'Not iOS' }}</b></div>
<p v-if="pwaEnvironment.ios && !pwaEnvironment.standalone">In Safari, choose Share Add to Home Screen, then launch the new icon. Edge interception is intentionally disabled inside a normal browser tab.</p>
<p v-else-if="pwaEnvironment.ios">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.</p>
</section>
<section class="settings-group"> <section class="settings-group">
<h2>Simulation</h2> <h2>Simulation</h2>
<label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label> <label><span><strong>Network latency</strong><small>{{ store.settings.simulatedLatency }} ms</small></span><input v-model.number="store.settings.simulatedLatency" type="range" min="0" max="1200" step="20" /></label>

View File

@@ -15,6 +15,7 @@
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"preview": "vite preview", "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", "electron": "npm run build && electron apps/electron/main.mjs",
"cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync" "cap:sync": "npm run build && npm --prefix apps/capacitor exec cap sync"
}, },

View File

@@ -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(`<style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#0b0d12}svg{display:block;width:100%;height:100%}</style>${source}`)
await page.screenshot({ path: path.join(root, 'public', file), omitBackground: false })
await page.close()
}
} finally {
await browser.close()
}

View File

@@ -13,22 +13,27 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
VitePWA({ VitePWA({
registerType: 'prompt', 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: { manifest: {
id: '/',
name: 'Native Vue Messenger', name: 'Native Vue Messenger',
short_name: 'NVR Messenger', short_name: 'NVR Messenger',
description: 'Gesture-first navigation for Vue applications', description: 'Gesture-first navigation for Vue applications',
theme_color: '#0b0d12', theme_color: '#0b0d12',
background_color: '#0b0d12', background_color: '#0b0d12',
display: 'standalone', display: 'standalone',
scope: '/',
orientation: 'any', orientation: 'any',
start_url: '/', start_url: '/',
icons: [ 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: 'any' },
{ src: '/app-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
], ],
}, },
workbox: { workbox: {
cleanupOutdatedCaches: true,
navigateFallback: '/index.html', navigateFallback: '/index.html',
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'], globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
}, },