feat(pwa): installable app — manifest + SW + Settings install button
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
The captive-portal Step-2 QR opens pictureframe.edholm.me in Safari, which is the perfect moment to also offer "pin this to your home screen" so the recipient gets one-tap access without typing the URL again. Two pieces: * Service worker at /sw.js (document root, scope "/"). Minimal — install/activate calls skipWaiting + clients.claim, fetch is passthrough. Real offline caching is intentionally out of scope; we only need the SW to exist so Chrome's PWA-install heuristic fires. * Settings → Install app section, hidden when display-mode standalone. Android Chrome path: native beforeinstallprompt button. iOS Safari (and any other non-prompt browser): button opens a modal with step-by-step Share → Add to Home Screen instructions. usePwaInstall composable handles the singleton lifecycle — beforeinstallprompt fires once per page load and may fire before the user navigates to Settings, so we register on module import and stash the event for later. Tests cover: install button rendered when not standalone, modal opens on click without a native prompt, modal close button works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Wraps the browser PWA-install lifecycle. Three reactive flags drive the
|
||||
* Settings UI:
|
||||
*
|
||||
* isStandalone — already installed (display-mode standalone or iOS
|
||||
* navigator.standalone). UI hides the install row
|
||||
* when true.
|
||||
* isIOS — iOS Safari, where there is no programmatic install.
|
||||
* UI shows a "show me how" button that opens an
|
||||
* instructions modal.
|
||||
* canPromptInstall — Android Chrome captured a beforeinstallprompt
|
||||
* event we can fire. UI shows a native "Install"
|
||||
* button that calls install().
|
||||
*
|
||||
* Module-level singleton: beforeinstallprompt fires once per page load and
|
||||
* may fire before the user navigates to Settings, so we register the
|
||||
* listener immediately on first import and cache the event.
|
||||
*/
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>
|
||||
}
|
||||
|
||||
const installEvent: Ref<BeforeInstallPromptEvent | null> = ref(null)
|
||||
const isStandalone = ref(false)
|
||||
|
||||
function detectStandalone(): boolean {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (window.matchMedia?.('(display-mode: standalone)').matches) return true
|
||||
// iOS Safari uses a non-standard navigator.standalone
|
||||
return (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
}
|
||||
|
||||
function detectIOS(): boolean {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
const ua = navigator.userAgent
|
||||
// iPad on iOS 13+ reports as Macintosh; check for touch points.
|
||||
const iPadOS = ua.includes('Mac') && navigator.maxTouchPoints > 1
|
||||
return /iPhone|iPod/.test(ua) || iPadOS
|
||||
}
|
||||
|
||||
let registered = false
|
||||
function registerOnce() {
|
||||
if (registered || typeof window === 'undefined') return
|
||||
registered = true
|
||||
|
||||
isStandalone.value = detectStandalone()
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e: Event) => {
|
||||
// Stash the event so we can fire it later from the install button.
|
||||
// preventDefault stops Chrome's default mini-info bar so we control
|
||||
// the timing.
|
||||
e.preventDefault()
|
||||
installEvent.value = e as BeforeInstallPromptEvent
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installEvent.value = null
|
||||
isStandalone.value = true
|
||||
})
|
||||
|
||||
window.matchMedia?.('(display-mode: standalone)').addEventListener('change', (e) => {
|
||||
isStandalone.value = e.matches
|
||||
})
|
||||
}
|
||||
|
||||
registerOnce()
|
||||
|
||||
export function usePwaInstall() {
|
||||
const isIOS = detectIOS()
|
||||
const canPromptInstall = computed(() => installEvent.value !== null)
|
||||
|
||||
/**
|
||||
* Trigger the native install prompt (Android Chrome). Resolves to true if
|
||||
* the user accepted, false if they dismissed or no prompt was available.
|
||||
* The event is one-shot; canPromptInstall flips false after a call.
|
||||
*/
|
||||
async function install(): Promise<boolean> {
|
||||
const e = installEvent.value
|
||||
if (!e) return false
|
||||
await e.prompt()
|
||||
const choice = await e.userChoice
|
||||
installEvent.value = null
|
||||
return choice.outcome === 'accepted'
|
||||
}
|
||||
|
||||
return {
|
||||
isStandalone,
|
||||
isIOS,
|
||||
canPromptInstall,
|
||||
install,
|
||||
}
|
||||
}
|
||||
|
||||
// Test-only — reset the singleton state so each test starts clean.
|
||||
export function __resetPwaInstallForTests() {
|
||||
installEvent.value = null
|
||||
isStandalone.value = false
|
||||
registered = false
|
||||
}
|
||||
@@ -10,3 +10,15 @@ app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(VueKonva)
|
||||
app.mount('#app')
|
||||
|
||||
// Register the PWA service worker. Done after mount so the SW
|
||||
// register call doesn't block first paint. /sw.js (not /build/sw.js)
|
||||
// so the SW's scope is "/" and covers the whole SPA.
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {
|
||||
// SW registration failure is non-fatal — PWA install just won't be
|
||||
// available, but the app still works.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { setActivePinia, createPinia } from 'pinia'
|
||||
import SettingsView from '@/views/SettingsView.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { THEMES } from '@/composables/useTheme'
|
||||
import { __resetPwaInstallForTests } from '@/composables/usePwaInstall'
|
||||
|
||||
describe('SettingsView', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
__resetPwaInstallForTests()
|
||||
})
|
||||
|
||||
it('renders one swatch per theme and the user email', () => {
|
||||
@@ -62,4 +64,32 @@ describe('SettingsView', () => {
|
||||
expect(logout.text()).toBe('Sign out')
|
||||
expect(logout.attributes('href')).toBe('/logout')
|
||||
})
|
||||
|
||||
it('shows the install button by default (no native prompt yet, not standalone)', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
const btn = wrapper.find('.settings__install')
|
||||
expect(btn.exists()).toBe(true)
|
||||
expect(btn.text()).toBe('Add to Home Screen')
|
||||
})
|
||||
|
||||
it('opens the iOS-style instructions modal when install button is clicked without a native prompt', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||
await wrapper.find('.settings__install').trigger('click')
|
||||
expect(wrapper.find('.install-modal').exists()).toBe(true)
|
||||
expect(wrapper.find('.install-modal__title').text()).toMatch(/home screen/i)
|
||||
})
|
||||
|
||||
it('closes the modal via the close button', async () => {
|
||||
const auth = useAuthStore()
|
||||
auth.setUser({ id: 1, email: 'm@e', roles: [], theme: 'warm-craft', timezone: 'UTC' })
|
||||
const wrapper = mount(SettingsView)
|
||||
await wrapper.find('.settings__install').trigger('click')
|
||||
await wrapper.find('.install-modal__close').trigger('click')
|
||||
expect(wrapper.find('.install-modal').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
<main class="settings">
|
||||
<h1 class="settings__title">Settings</h1>
|
||||
|
||||
<section v-if="!isStandalone" class="settings__section">
|
||||
<h2 class="settings__section-title">Install app</h2>
|
||||
<p class="settings__hint">
|
||||
Pin pictureFrame to your home screen so it opens like a native app.
|
||||
</p>
|
||||
<button
|
||||
v-if="canPromptInstall"
|
||||
type="button"
|
||||
class="settings__install"
|
||||
@click="onNativeInstall"
|
||||
>
|
||||
Install pictureFrame
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="settings__install"
|
||||
@click="showIosInstructions = true"
|
||||
>
|
||||
Add to Home Screen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="settings__section">
|
||||
<h2 class="settings__section-title">Theme</h2>
|
||||
<div class="theme-grid" role="radiogroup" aria-label="Choose theme">
|
||||
@@ -34,22 +57,77 @@
|
||||
</div>
|
||||
<a href="/logout" class="settings__logout">Sign out</a>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="showIosInstructions"
|
||||
class="install-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="install-modal-title"
|
||||
@click.self="showIosInstructions = false"
|
||||
>
|
||||
<div class="install-modal__card">
|
||||
<button
|
||||
type="button"
|
||||
class="install-modal__close"
|
||||
aria-label="Close"
|
||||
@click="showIosInstructions = false"
|
||||
>×</button>
|
||||
<h2 id="install-modal-title" class="install-modal__title">
|
||||
{{ isIOS ? 'Add to your iPhone home screen' : 'Add to your home screen' }}
|
||||
</h2>
|
||||
<ol class="install-modal__steps">
|
||||
<li v-if="isIOS">
|
||||
Tap the <strong>Share</strong> icon at the bottom of Safari
|
||||
(the square with an up-arrow).
|
||||
</li>
|
||||
<li v-else>
|
||||
Open your browser's menu (usually the three dots
|
||||
<strong>⋮</strong> in the top right).
|
||||
</li>
|
||||
<li>
|
||||
Scroll down and tap <strong>Add to Home Screen</strong>
|
||||
<span v-if="!isIOS">or <strong>Install app</strong></span>.
|
||||
</li>
|
||||
<li>
|
||||
Tap <strong>Add</strong> in the top right to confirm.
|
||||
</li>
|
||||
</ol>
|
||||
<p class="install-modal__footer">
|
||||
The app will appear on your home screen and open without
|
||||
browser chrome the next time you launch it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useTheme, THEMES } from '@/composables/useTheme'
|
||||
import { usePwaInstall } from '@/composables/usePwaInstall'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { saveTheme } = useTheme()
|
||||
const { isStandalone, isIOS, canPromptInstall, install } = usePwaInstall()
|
||||
|
||||
const currentTheme = computed(() => auth.user?.theme ?? 'warm-craft')
|
||||
const showIosInstructions = ref(false)
|
||||
|
||||
function select(themeId: string) {
|
||||
saveTheme(themeId)
|
||||
}
|
||||
|
||||
async function onNativeInstall() {
|
||||
const accepted = await install()
|
||||
// If the native prompt failed for any reason (rare — e.g. the event
|
||||
// expired between render and click), fall back to the same modal we
|
||||
// show on iOS. Less abrupt than a silent no-op.
|
||||
if (!accepted && !canPromptInstall.value) {
|
||||
showIosInstructions.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -99,6 +177,89 @@ function select(themeId: string) {
|
||||
text-decoration: none;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
&__hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
&__install {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill, 9999px);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.install-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgb(0 0 0 / 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-4);
|
||||
z-index: 100;
|
||||
|
||||
&__card {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-radius: var(--radius-lg, 16px);
|
||||
padding: var(--space-5);
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
box-shadow: 0 20px 60px rgb(0 0 0 / 0.25);
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-3);
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
padding-right: var(--space-5);
|
||||
}
|
||||
|
||||
&__steps {
|
||||
margin: 0 0 var(--space-3) var(--space-4);
|
||||
padding: 0;
|
||||
line-height: 1.5;
|
||||
|
||||
li {
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
margin-top: var(--space-3);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-3);
|
||||
}
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
|
||||
Reference in New Issue
Block a user