Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,10 @@ useHead({
</script>

<template>
<RouterView />
<div>
<RouterView />
<ClientOnly>
<PWABadge />
</ClientOnly>
</div>
</template>
6 changes: 6 additions & 0 deletions src/auto-imports.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ declare global {
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const installPwa: typeof import('./composables/pwa')['installPwa']
const isDark: typeof import('./composables/dark')['isDark']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
Expand Down Expand Up @@ -221,6 +222,7 @@ declare global {
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const usePwa: typeof import('./composables/pwa')['usePwa']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
Expand Down Expand Up @@ -337,6 +339,7 @@ declare module 'vue' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly installPwa: UnwrapRef<typeof import('./composables/pwa')['installPwa']>
readonly isDark: UnwrapRef<typeof import('./composables/dark')['isDark']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
Expand Down Expand Up @@ -518,6 +521,7 @@ declare module 'vue' {
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly usePwa: UnwrapRef<typeof import('./composables/pwa')['usePwa']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
Expand Down Expand Up @@ -627,6 +631,7 @@ declare module '@vue/runtime-core' {
readonly ignorableWatch: UnwrapRef<typeof import('@vueuse/core')['ignorableWatch']>
readonly inject: UnwrapRef<typeof import('vue')['inject']>
readonly injectLocal: UnwrapRef<typeof import('@vueuse/core')['injectLocal']>
readonly installPwa: UnwrapRef<typeof import('./composables/pwa')['installPwa']>
readonly isDark: UnwrapRef<typeof import('./composables/dark')['isDark']>
readonly isDefined: UnwrapRef<typeof import('@vueuse/core')['isDefined']>
readonly isProxy: UnwrapRef<typeof import('vue')['isProxy']>
Expand Down Expand Up @@ -808,6 +813,7 @@ declare module '@vue/runtime-core' {
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
readonly usePwa: UnwrapRef<typeof import('./composables/pwa')['usePwa']>
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
Expand Down
1 change: 1 addition & 0 deletions src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
PWABadge: typeof import('./components/PWABadge.vue')['default']
README: typeof import('./components/README.md')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Expand Down
57 changes: 57 additions & 0 deletions src/components/PWABadge.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
</script>

<template>
<div
v-if="usePwa()?.showInstallPrompt"
class="pwa-toast"
aria-labelledby="toast-message"
role="alert"
>
<div class="message">
<span id="toast-message">
Install PWA
</span>
</div>
<div class="buttons">
<button type="button" class="reload" @click="usePwa()?.install()">
Install
</button>
<button type="button" @click="usePwa()?.cancelInstall()">
Don't show again
</button>
</div>
</div>
</template>

<style scoped>
.pwa-toast {
position: fixed;
right: 0;
bottom: 0;
margin: 16px;
padding: 12px;
border: 1px solid #8885;
border-radius: 4px;
z-index: 1;
text-align: left;
box-shadow: 3px 4px 5px 0 #8885;
display: grid;
}
.pwa-toast .message {
margin-bottom: 8px;
}
.pwa-toast .buttons {
display: flex;
}
.pwa-toast button {
border: 1px solid #8885;
outline: none;
margin-right: 5px;
border-radius: 2px;
padding: 3px 10px;
}
.pwa-toast button.reload {
display: block;
}
</style>
188 changes: 188 additions & 0 deletions src/composables/pwa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { useRegisterSW } from 'virtual:pwa-register/vue'
import type { App, UnwrapNestedRefs } from 'vue-demi'

export interface UserChoice {
outcome: 'accepted' | 'dismissed'
platform: string
}

export type BeforeInstallPromptEvent = Event & {
prompt: () => void
userChoice: Promise<UserChoice>
}

export interface InstallPwaOptions {
/**
* @default 'vite-pwa:hide-install'
*/
installPrompt?: string
/**
* @default 0
*/
periodicSyncForUpdates?: number
/**
* @default 'standalone'
*/
display?: 'fullscreen' | 'standalone' | 'minimal-ui' | 'browser'
}

export interface PwaInjection {
/**
* @deprecated use `isPWAInstalled` instead
*/
isInstalled: boolean
isPWAInstalled: Ref<boolean>
showInstallPrompt: Ref<boolean>
cancelInstall: () => void
install: () => Promise<UserChoice | undefined>
swActivated: Ref<boolean>
registrationError: Ref<boolean>
offlineReady: Ref<boolean>
needRefresh: Ref<boolean>
updateServiceWorker: (reloadPage?: boolean | undefined) => Promise<void>
cancelPrompt: () => Promise<void>
getSWRegistration: () => ServiceWorkerRegistration | undefined
}

const pwaSymbol = Symbol.for('pwa')

export function usePwa() {
return inject<UnwrapNestedRefs<PwaInjection>>(pwaSymbol)!
}

export function installPwa(app: App, options: InstallPwaOptions = {}) {
const {
installPrompt = 'vite-pwa:hide-install',
display = 'standalone',
periodicSyncForUpdates = 0,
} = options

const registrationError = ref(false)
const swActivated = ref(false)
const showInstallPrompt = ref(false)
const hideInstall = ref(!installPrompt ? true : localStorage.getItem(installPrompt) === 'true')

// https://thomashunter.name/posts/2021-12-11-detecting-if-pwa-twa-is-installed
const ua = navigator.userAgent
const ios = ua.match(/iPhone|iPad|iPod/)
const useDisplay = display === 'standalone' || display === 'minimal-ui' ? `${display}` : 'standalone'
const standalone = window.matchMedia(`(display-mode: ${useDisplay})`).matches
const isInstalled = ref(!!(standalone || (ios && !ua.match(/Safari/))))
const isPWAInstalled = ref(isInstalled.value)

window.matchMedia(`(display-mode: ${useDisplay})`).addEventListener('change', (e) => {
// PWA on fullscreen mode will not match standalone nor minimal-ui
if (!isPWAInstalled.value && e.matches)
isPWAInstalled.value = true
})

let swRegistration: ServiceWorkerRegistration | undefined

const getSWRegistration = () => swRegistration

const registerPeriodicSync = (swUrl: string, r: ServiceWorkerRegistration, timeout: number) => {
setInterval(async () => {
if (('connection' in navigator) && !navigator.onLine)
return

const resp = await fetch(swUrl, {
cache: 'no-store',
headers: {
'cache': 'no-store',
'cache-control': 'no-cache',
},
})

if (resp?.status === 200)
await r.update()
}, timeout)
}

const {
offlineReady,
needRefresh,
updateServiceWorker,
} = useRegisterSW({
immediate: true,
onRegisterError() {
registrationError.value = true
},
onRegisteredSW(swUrl, r) {
swRegistration = r
const timeout = periodicSyncForUpdates
if (timeout > 0) {
// should add support in pwa plugin
if (r?.active?.state === 'activated') {
swActivated.value = true
registerPeriodicSync(swUrl, r, timeout * 1000)
}
else if (r?.installing) {
r.installing.addEventListener('statechange', (e) => {
const sw = e.target as ServiceWorker
swActivated.value = sw.state === 'activated'
if (swActivated.value)
registerPeriodicSync(swUrl, r, timeout * 1000)
})
}
}
},
})

const cancelPrompt = async () => {
offlineReady.value = false
needRefresh.value = false
}

let install: () => Promise<UserChoice | undefined> = () => Promise.resolve(undefined)
let cancelInstall: () => void = () => {}

if (!hideInstall.value) {
let deferredPrompt: BeforeInstallPromptEvent | undefined

const beforeInstallPrompt = (e: Event) => {
e.preventDefault()
deferredPrompt = e as BeforeInstallPromptEvent
showInstallPrompt.value = true
}
window.addEventListener('beforeinstallprompt', beforeInstallPrompt)
window.addEventListener('appinstalled', () => {
deferredPrompt = undefined
showInstallPrompt.value = false
})

cancelInstall = () => {
deferredPrompt = undefined
showInstallPrompt.value = false
window.removeEventListener('beforeinstallprompt', beforeInstallPrompt)
hideInstall.value = true
localStorage.setItem(installPrompt!, 'true')
}

install = async () => {
if (!showInstallPrompt.value || !deferredPrompt) {
showInstallPrompt.value = false
return undefined
}

showInstallPrompt.value = false
await nextTick()
deferredPrompt.prompt()
return await deferredPrompt.userChoice
}
}

app.provide(pwaSymbol, reactive({
registrationError,
swActivated,
showInstallPrompt,
isInstalled,
isPWAInstalled,
offlineReady,
needRefresh,
updateServiceWorker,
getSWRegistration,
cancelPrompt,
install,
cancelInstall,
}))
}
3 changes: 3 additions & 0 deletions src/layouts/home.vue
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
<script setup lang="ts">
</script>

<template>
<main
px-4 py-10
Expand Down
6 changes: 3 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export const createApp = ViteSSG(
routes: setupLayouts(routes),
base: import.meta.env.BASE_URL,
},
(ctx) => {
async (ctx) => {
// install all modules under `modules/`
Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true }))
.forEach(i => i.install?.(ctx))
await Promise.all(Object.values(import.meta.glob<{ install: UserModule }>('./modules/*.ts', { eager: true }))
.map(i => i.install?.(ctx)))
// ctx.app.use(Previewer)
},
)
11 changes: 4 additions & 7 deletions src/modules/pwa.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import type { UserModule } from '~/types'

// https://github.com/antfu/vite-plugin-pwa#automatic-reload-when-new-content-available
export const install: UserModule = ({ isClient, router }) => {
export const install: UserModule = async ({ app, isClient }) => {
if (!isClient)
return

router.isReady()
.then(async () => {
const { registerSW } = await import('virtual:pwa-register')
registerSW({ immediate: true })
})
.catch(() => {})
const { installPwa } = await import('../composables/pwa')

installPwa(app)
}
10 changes: 10 additions & 0 deletions src/shims.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type {
ComponentCustomOptions as _ComponentCustomOptions,
ComponentCustomProperties as _ComponentCustomProperties,
} from 'vue-demi'

declare interface Window {
// extend the window
}
Expand All @@ -16,3 +21,8 @@ declare module '*.vue' {
const component: DefineComponent<object, object, any>
export default component
}

declare module '@vue/runtime-core' {
interface ComponentCustomProperties extends _ComponentCustomProperties {}
interface ComponentCustomOptions extends _ComponentCustomOptions {}
}
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import type { ViteSSGContext } from 'vite-ssg'

export type UserModule = (ctx: ViteSSGContext) => void
export type UserModule = (ctx: ViteSSGContext) => void | Promise<void>
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"vitest",
"vite/client",
"vite-plugin-vue-layouts/client",
"vite-plugin-pwa/client",
"vite-plugin-pwa/vue",
"unplugin-vue-macros/macros-global",
"unplugin-vue-router/client"
],
Expand Down