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 name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
<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" />
<title>Native Vue Messenger</title>
</head>

View File

@@ -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,

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; }
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%;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import AppHeader from '../components/AppHeader.vue'
import { useDemoStore } from '../data'
import { pwaEnvironment } from '../pwa'
const store = useDemoStore()
</script>
@@ -12,6 +13,14 @@ const store = useDemoStore()
<span>60</span>
<div><strong>FPS baseline</strong><p>Interactive layers use transform and opacity only.</p></div>
</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">
<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>