feat(pwa): installable app — manifest + SW + Settings install button
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:
2026-05-09 13:49:12 -04:00
parent 2be153a103
commit 00121aaec9
25 changed files with 347 additions and 16 deletions
+103
View File
@@ -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
}
+12
View File
@@ -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)
})
})
+162 -1
View File
@@ -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 {