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 baselineInteractive 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}'],
},