Pwa support, maybe
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
112
apps/demo/src/pwa.ts
Normal 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'
|
||||
}
|
||||
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
18
scripts/generate-pwa-icons.mjs
Normal file
18
scripts/generate-pwa-icons.mjs
Normal 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()
|
||||
}
|
||||
@@ -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}'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user