From 00121aaec9407e8ff8aa36d76ae44037c9267604 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Sat, 9 May 2026 13:49:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(pwa):=20installable=20app=20=E2=80=94=20ma?= =?UTF-8?q?nifest=20+=20SW=20+=20Settings=20install=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/composables/usePwaInstall.ts | 103 +++++++++++ frontend/src/main.ts | 12 ++ frontend/src/test/views/SettingsView.test.ts | 30 ++++ frontend/src/views/SettingsView.vue | 163 +++++++++++++++++- .../build/assets/BaseBottomSheet-BMI-Oljh.js | 1 + .../build/assets/BaseBottomSheet-D5oDq9XR.js | 1 - public/build/assets/DevicePicker-C6ucVR6N.js | 1 + public/build/assets/DevicePicker-DqqJJ3eI.js | 1 - public/build/assets/HomeView-BDJhYpJV.js | 1 + public/build/assets/HomeView-DqxEih8v.js | 1 - public/build/assets/LibraryView-CFFrQSvN.js | 1 - public/build/assets/LibraryView-E5I_Q1_A.js | 1 + public/build/assets/PullToRefresh-BEXU4J3A.js | 1 + public/build/assets/PullToRefresh-D9_uAhZQ.js | 1 - public/build/assets/SettingsView-BBsU7iD5.js | 1 - public/build/assets/SettingsView-BGXX7ONa.css | 1 - public/build/assets/SettingsView-BuwptVaB.js | 1 + public/build/assets/SettingsView-Ce_UCsin.css | 1 + public/build/assets/UploadView-B61ve1Vr.js | 1 - public/build/assets/UploadView-CiBmd_8U.js | 1 + .../_plugin-vue_export-helper-DRLwVS0w.js | 1 - .../_plugin-vue_export-helper-eepT72yB.js | 1 + .../{index-CCy85pot.js => index-BO5caB_f.js} | 8 +- public/build/index.html | 4 +- public/sw.js | 25 +++ 25 files changed, 347 insertions(+), 16 deletions(-) create mode 100644 frontend/src/composables/usePwaInstall.ts create mode 100644 public/build/assets/BaseBottomSheet-BMI-Oljh.js delete mode 100644 public/build/assets/BaseBottomSheet-D5oDq9XR.js create mode 100644 public/build/assets/DevicePicker-C6ucVR6N.js delete mode 100644 public/build/assets/DevicePicker-DqqJJ3eI.js create mode 100644 public/build/assets/HomeView-BDJhYpJV.js delete mode 100644 public/build/assets/HomeView-DqxEih8v.js delete mode 100644 public/build/assets/LibraryView-CFFrQSvN.js create mode 100644 public/build/assets/LibraryView-E5I_Q1_A.js create mode 100644 public/build/assets/PullToRefresh-BEXU4J3A.js delete mode 100644 public/build/assets/PullToRefresh-D9_uAhZQ.js delete mode 100644 public/build/assets/SettingsView-BBsU7iD5.js delete mode 100644 public/build/assets/SettingsView-BGXX7ONa.css create mode 100644 public/build/assets/SettingsView-BuwptVaB.js create mode 100644 public/build/assets/SettingsView-Ce_UCsin.css delete mode 100644 public/build/assets/UploadView-B61ve1Vr.js create mode 100644 public/build/assets/UploadView-CiBmd_8U.js delete mode 100644 public/build/assets/_plugin-vue_export-helper-DRLwVS0w.js create mode 100644 public/build/assets/_plugin-vue_export-helper-eepT72yB.js rename public/build/assets/{index-CCy85pot.js => index-BO5caB_f.js} (57%) create mode 100644 public/sw.js diff --git a/frontend/src/composables/usePwaInstall.ts b/frontend/src/composables/usePwaInstall.ts new file mode 100644 index 0000000..9600672 --- /dev/null +++ b/frontend/src/composables/usePwaInstall.ts @@ -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 + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }> +} + +const installEvent: Ref = 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 { + 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 +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 683b4fa..1e476f4 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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. + }) + }) +} diff --git a/frontend/src/test/views/SettingsView.test.ts b/frontend/src/test/views/SettingsView.test.ts index 1b3b2a8..ce8ac7f 100644 --- a/frontend/src/test/views/SettingsView.test.ts +++ b/frontend/src/test/views/SettingsView.test.ts @@ -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) + }) }) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index c642f41..44878f0 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -2,6 +2,29 @@

Settings

+
+

Install app

+

+ Pin pictureFrame to your home screen so it opens like a native app. +

+ + +
+

Theme

@@ -34,22 +57,77 @@
Sign out
+ +