From 39df7be7792f67e9b0a7e832cb04086b98eeb27e Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sat, 28 Mar 2026 10:30:00 +0000 Subject: [PATCH] feat(webapp): add offline shell and iPhone install guidance --- WebApp/src/lib/pwa.ts | 95 +++++++++++++++++++------- WebApp/src/lib/sim/ui/AppChrome.svelte | 31 ++++++++- WebApp/src/routes/+layout.svelte | 26 ++++++- 3 files changed, 125 insertions(+), 27 deletions(-) diff --git a/WebApp/src/lib/pwa.ts b/WebApp/src/lib/pwa.ts index aa683db..b5b3bd9 100644 --- a/WebApp/src/lib/pwa.ts +++ b/WebApp/src/lib/pwa.ts @@ -1,52 +1,98 @@ import { writable } from 'svelte/store'; +const IOS_HINT_DISMISSED_KEY = 'phonecam-ios-install-hint-dismissed'; + const createInitialState = () => ({ installAvailable: false, updateAvailable: false, offlineReady: false, - installing: false + installing: false, + isOffline: false, + isStandalone: false, + showIosInstallHint: false }); export const pwaState = writable(createInitialState()); let deferredInstallPrompt: any = null; -export const setInstallPrompt = (event: any) => { - deferredInstallPrompt = event; +const setPwaState = (partial: Record) => { pwaState.update((state) => ({ ...state, - installAvailable: true + ...partial })); }; +export const setInstallPrompt = (event: any) => { + deferredInstallPrompt = event; + setPwaState({ + installAvailable: true + }); +}; + export const clearInstallPrompt = () => { deferredInstallPrompt = null; - pwaState.update((state) => ({ - ...state, + setPwaState({ installAvailable: false, installing: false - })); + }); }; export const setUpdateAvailable = (updateAvailable: boolean) => { - pwaState.update((state) => ({ - ...state, - updateAvailable - })); + setPwaState({ updateAvailable }); }; export const setOfflineReady = (offlineReady: boolean) => { - pwaState.update((state) => ({ - ...state, - offlineReady - })); + setPwaState({ offlineReady }); }; export const dismissOfflineReady = () => { - pwaState.update((state) => ({ - ...state, - offlineReady: false - })); + setPwaState({ offlineReady: false }); +}; + +export const setOnlineStatus = (isOnline: boolean) => { + setPwaState({ isOffline: !isOnline }); +}; + +export const setStandaloneMode = (isStandalone: boolean) => { + setPwaState({ isStandalone }); +}; + +const isIosSafariInstallable = () => { + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return false; + } + + const userAgent = navigator.userAgent || ''; + const isIos = /iPad|iPhone|iPod/.test(userAgent); + const isWebkit = /WebKit/.test(userAgent); + const isCriOS = /CriOS/.test(userAgent); + const isFxiOS = /FxiOS/.test(userAgent); + + return isIos && isWebkit && !isCriOS && !isFxiOS; +}; + +export const syncInstallHelpState = () => { + if (typeof window === 'undefined') { + return; + } + + const isStandalone = + window.matchMedia?.('(display-mode: standalone)').matches || (window.navigator as any)?.standalone === true; + const dismissed = + typeof localStorage !== 'undefined' && localStorage.getItem(IOS_HINT_DISMISSED_KEY) === 'true'; + + setPwaState({ + isStandalone, + showIosInstallHint: isIosSafariInstallable() && !isStandalone && !dismissed && !deferredInstallPrompt + }); +}; + +export const dismissIosInstallHint = () => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(IOS_HINT_DISMISSED_KEY, 'true'); + } + setPwaState({ showIosInstallHint: false }); }; export const promptInstall = async () => { @@ -54,21 +100,20 @@ export const promptInstall = async () => { return false; } - pwaState.update((state) => ({ - ...state, + setPwaState({ installing: true - })); + }); try { await deferredInstallPrompt.prompt(); const choice = await deferredInstallPrompt.userChoice; clearInstallPrompt(); + syncInstallHelpState(); return choice?.outcome === 'accepted'; } catch (error) { - pwaState.update((state) => ({ - ...state, + setPwaState({ installing: false - })); + }); throw error; } }; diff --git a/WebApp/src/lib/sim/ui/AppChrome.svelte b/WebApp/src/lib/sim/ui/AppChrome.svelte index f44cea0..c2589ad 100644 --- a/WebApp/src/lib/sim/ui/AppChrome.svelte +++ b/WebApp/src/lib/sim/ui/AppChrome.svelte @@ -1,7 +1,7 @@
@@ -87,6 +92,20 @@ {/if} + {#if $pwaState.showIosInstallHint} + + Install on iPhone + + In Safari, tap the Share button, then choose “Add to Home Screen” to install PhoneCam. + + + + + + {/if} + {#if $pwaState.updateAvailable} Update Ready @@ -255,6 +274,16 @@
+ {#if $pwaState.isOffline} +
+ + {offlineTitle()} + + {offlineDescription()} + + +
+ {/if}
{@render children()}
diff --git a/WebApp/src/routes/+layout.svelte b/WebApp/src/routes/+layout.svelte index d8977ab..144a5f7 100644 --- a/WebApp/src/routes/+layout.svelte +++ b/WebApp/src/routes/+layout.svelte @@ -5,7 +5,14 @@ import { registerSW } from 'virtual:pwa-register'; import favicon from '$lib/assets/favicon.svg'; import { appController } from '$lib/app/controller'; - import { clearInstallPrompt, setInstallPrompt, setOfflineReady, setUpdateAvailable } from '$lib/pwa'; + import { + clearInstallPrompt, + setInstallPrompt, + setOfflineReady, + setOnlineStatus, + setUpdateAvailable, + syncInstallHelpState + } from '$lib/pwa'; let { children } = $props(); @@ -22,20 +29,37 @@ const handleBeforeInstallPrompt = (event) => { event.preventDefault(); setInstallPrompt(event); + syncInstallHelpState(); }; const handleAppInstalled = () => { clearInstallPrompt(); + syncInstallHelpState(); }; + const handleOnline = () => { + setOnlineStatus(true); + }; + + const handleOffline = () => { + setOnlineStatus(false); + }; + + setOnlineStatus(navigator.onLine); + syncInstallHelpState(); + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('appinstalled', handleAppInstalled); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); void appController.init(); return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); void appController.destroy(); }; });