Pwa support, maybe
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
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; }
|
* { 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%;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
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(),
|
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}'],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user