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
+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 {