feat(client): add PWA support with installability and cover caching

Add vite-plugin-pwa with Workbox in generateSW mode. Includes a web app
manifest (standalone display, shortcuts for Dashboard/Libraries/Settings),
iOS meta tags, and a service worker that pre-caches the app shell and uses
CacheFirst for book cover and thumbnail endpoints (max 200 entries, 30-day TTL).

Add useInstallPrompt composable that captures the beforeinstallprompt event
and exposes isInstallable/installApp. Wire an install button into SidebarFooter
that appears only when the browser signals the app is installable.

Generate PWA icons (64, 192, 512, maskable-512, apple-touch-icon-180) from a
new pwa-icon-source.svg using @vite-pwa/assets-generator with minimal2023Preset.
Add dev-dist to .gitignore to exclude the dev-mode SW output.
This commit is contained in:
neon
2026-05-12 20:53:21 +00:00
parent 431053e510
commit 22ac06ecd7
16 changed files with 4136 additions and 3 deletions
+3
View File
@@ -34,3 +34,6 @@ test-results/
# IDE
.idea/
# vite-plugin-pwa dev service worker output
dev-dist
+6 -1
View File
@@ -2,8 +2,13 @@
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/pwa-icon-source.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="BookOrbit" />
<title>BookOrbit</title>
<!-- Blocking theme init — must run before first paint to prevent FOUC -->
<script src="/theme-init.js"></script>
+4 -1
View File
@@ -20,7 +20,8 @@
"lint:eslint:check": "eslint src --cache",
"lint:eslint:fix": "eslint src --fix --cache",
"format": "prettier --write --experimental-cli src/",
"format:check": "prettier --check src/"
"format:check": "prettier --check src/",
"generate-pwa-icons": "pwa-assets-generator --config pwa-assets.config.ts"
},
"dependencies": {
"@bookorbit/types": "workspace:*",
@@ -44,6 +45,7 @@
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.4",
"vite-plugin-pwa": "^1.3.0",
"vue": "^3.5.33",
"vue-draggable-plus": "^0.6.1",
"vue-echarts": "8.1.0-beta.2",
@@ -56,6 +58,7 @@
"@types/dompurify": "^3.2.0",
"@types/jsdom": "^28.0.1",
"@types/node": "^25.6.0",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/coverage-v8": "^4.1.5",
"@vitest/eslint-plugin": "^1.6.16",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#2c2c22"/>
<stop offset="100%" stop-color="#1a1a10"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="96" fill="url(#bg)"/>
<!-- Orbit icon (Lucide), 24x24 viewBox scaled to fill ~252x252 of safe zone, centered -->
<!-- transform: scale(14), translate(88,88) => visual extent ~130-382 per axis -->
<g
transform="translate(88, 88) scale(14)"
fill="none"
stroke="rgba(255,255,255,0.9)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20.341 6.484A10 10 0 0 1 10.266 21.85"/>
<path d="M3.659 17.516A10 10 0 0 1 13.74 2.152"/>
<circle cx="12" cy="12" r="3"/>
<circle cx="19" cy="5" r="2"/>
<circle cx="5" cy="19" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 938 B

+6
View File
@@ -0,0 +1,6 @@
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'
export default defineConfig({
preset: minimal2023Preset,
images: ['public/pwa-icon-source.svg'],
})
+9
View File
@@ -6,6 +6,7 @@ import { VueDraggable } from 'vue-draggable-plus'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
@@ -28,6 +29,7 @@ import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmar
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
import LibraryCreatorModal from '@/features/library/components/LibraryCreatorModal.vue'
import { useLibraryCreationRedirect } from '@/features/library/composables/useLibraryCreationRedirect'
import { useInstallPrompt } from '@/features/pwa/composables/useInstallPrompt'
import { useThemeStore } from '@/stores/theme'
function resolveIcon(name: string | null | undefined, fallback: Component): Component {
@@ -54,6 +56,7 @@ const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const { handleLibraryCreated } = useLibraryCreationRedirect()
const themeStore = useThemeStore()
const { isInstallable, installApp } = useInstallPrompt()
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
@@ -430,6 +433,12 @@ onUnmounted(() => stopLibraryUploadListener())
</SidebarGroup>
</SidebarContent>
<SidebarFooter v-if="isInstallable" class="border-t border-sidebar-border/60 pb-2">
<SidebarMenu>
<SidebarNavItem :is-active="false" tooltip="Install App" :icon="Icons.Download" label="Install App" @click="installApp" />
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
</template>
@@ -0,0 +1,108 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { useInstallPrompt, _resetInstallPromptForTest } from '../useInstallPrompt'
// The composable uses module-level singleton state for the deferred prompt.
// Each test resets that state via the exported helper before running.
function makePromptEvent(outcome: 'accepted' | 'dismissed' = 'accepted'): Event & {
prompt: ReturnType<typeof vi.fn>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
preventDefault: ReturnType<typeof vi.fn>
} {
let resolveChoice!: (v: { outcome: 'accepted' | 'dismissed' }) => void
const userChoice = new Promise<{ outcome: 'accepted' | 'dismissed' }>((res) => {
resolveChoice = res
})
const evt = new Event('beforeinstallprompt') as Event & {
prompt: ReturnType<typeof vi.fn>
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
preventDefault: ReturnType<typeof vi.fn>
}
evt.preventDefault = vi.fn<() => void>()
evt.prompt = vi.fn<() => Promise<void>>(() => {
resolveChoice({ outcome })
return Promise.resolve()
})
Object.defineProperty(evt, 'userChoice', { get: () => userChoice })
return evt
}
describe('useInstallPrompt', () => {
beforeEach(() => {
_resetInstallPromptForTest()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('isInstallable is false before beforeinstallprompt fires', () => {
const { isInstallable } = useInstallPrompt()
expect(isInstallable.value).toBe(false)
})
it('isInstallable becomes true after beforeinstallprompt fires', () => {
const { isInstallable } = useInstallPrompt()
const evt = makePromptEvent()
window.dispatchEvent(evt)
expect(isInstallable.value).toBe(true)
})
it('preventDefault is called on beforeinstallprompt', () => {
const evt = makePromptEvent()
window.dispatchEvent(evt)
expect(evt.preventDefault).toHaveBeenCalledOnce()
})
it('installApp calls prompt() on the deferred event', async () => {
const { installApp } = useInstallPrompt()
const evt = makePromptEvent('accepted')
window.dispatchEvent(evt)
await installApp()
expect(evt.prompt).toHaveBeenCalledOnce()
})
it('isInstallable resets to false after user accepts', async () => {
const { isInstallable, installApp } = useInstallPrompt()
const evt = makePromptEvent('accepted')
window.dispatchEvent(evt)
expect(isInstallable.value).toBe(true)
await installApp()
expect(isInstallable.value).toBe(false)
})
it('isInstallable stays true after user dismisses', async () => {
const { isInstallable, installApp } = useInstallPrompt()
const evt = makePromptEvent('dismissed')
window.dispatchEvent(evt)
expect(isInstallable.value).toBe(true)
await installApp()
expect(isInstallable.value).toBe(true)
})
it('isInstallable resets to false on appinstalled event', () => {
const { isInstallable } = useInstallPrompt()
const evt = makePromptEvent()
window.dispatchEvent(evt)
expect(isInstallable.value).toBe(true)
window.dispatchEvent(new Event('appinstalled'))
expect(isInstallable.value).toBe(false)
})
it('installApp is a no-op when isInstallable is false', async () => {
const { isInstallable, installApp } = useInstallPrompt()
expect(isInstallable.value).toBe(false)
await expect(installApp()).resolves.toBeUndefined()
})
it('prompt is not called again after app was accepted and installed', async () => {
const { installApp } = useInstallPrompt()
const evt = makePromptEvent('accepted')
window.dispatchEvent(evt)
await installApp()
// prompt was cleared - second call is a no-op
await installApp()
expect(evt.prompt).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,45 @@
import { computed, ref } from 'vue'
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
}
const _deferredPrompt = ref<BeforeInstallPromptEvent | null>(null)
function _onBeforeInstallPrompt(e: Event) {
e.preventDefault()
_deferredPrompt.value = e as BeforeInstallPromptEvent
}
function _onAppInstalled() {
_deferredPrompt.value = null
}
if (typeof window !== 'undefined') {
window.addEventListener('beforeinstallprompt', _onBeforeInstallPrompt)
window.addEventListener('appinstalled', _onAppInstalled)
}
export function useInstallPrompt() {
const isInstallable = computed(() => _deferredPrompt.value !== null)
async function installApp(): Promise<void> {
const prompt = _deferredPrompt.value
if (!prompt) return
await prompt.prompt()
const { outcome } = await prompt.userChoice
if (outcome === 'accepted') {
_deferredPrompt.value = null
}
}
return { isInstallable, installApp }
}
/** Resets module-level singleton state. For use in tests only. */
export function _resetInstallPromptForTest() {
_deferredPrompt.value = null
}
+112 -1
View File
@@ -4,10 +4,121 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools(), tailwindcss()],
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
injectRegister: 'auto',
includeAssets: [
'favicon.ico',
'pwa-icon-source.svg',
'apple-touch-icon-180x180.png',
'pwa-64x64.png',
'pwa-192x192.png',
'pwa-512x512.png',
'maskable-icon-512x512.png',
],
manifest: {
name: 'BookOrbit',
short_name: 'BookOrbit',
description: 'Your personal book library and reading space',
theme_color: '#1e1e18',
background_color: '#fafaf8',
display: 'standalone',
start_url: '/',
scope: '/',
orientation: 'any',
icons: [
{
src: 'pwa-64x64.png',
sizes: '64x64',
type: 'image/png',
},
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'maskable-icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{
name: 'Dashboard',
short_name: 'Dashboard',
url: '/dashboard',
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
},
{
name: 'Libraries',
short_name: 'Libraries',
url: '/libraries',
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
},
{
name: 'Settings',
short_name: 'Settings',
url: '/settings',
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
navigateFallback: 'index.html',
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^.*\/api\/v1\/books\/\d+\/cover(\?.*)?$/,
handler: 'CacheFirst',
options: {
cacheName: 'book-covers',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
{
urlPattern: /^.*\/api\/v1\/books\/\d+\/thumbnail(\?.*)?$/,
handler: 'CacheFirst',
options: {
cacheName: 'book-thumbnails',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
cacheableResponse: {
statuses: [0, 200],
},
},
},
],
},
devOptions: {
enabled: true,
type: 'module',
},
}),
],
resolve: {
dedupe: ['vue'],
alias: {
+3818
View File
File diff suppressed because it is too large Load Diff