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