feat(webapp): add offline shell and iPhone install guidance

This commit is contained in:
2026-03-28 10:30:00 +00:00
parent 22608b80f1
commit 39df7be779
3 changed files with 125 additions and 27 deletions

View File

@@ -1,52 +1,98 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
const IOS_HINT_DISMISSED_KEY = 'phonecam-ios-install-hint-dismissed';
const createInitialState = () => ({ const createInitialState = () => ({
installAvailable: false, installAvailable: false,
updateAvailable: false, updateAvailable: false,
offlineReady: false, offlineReady: false,
installing: false installing: false,
isOffline: false,
isStandalone: false,
showIosInstallHint: false
}); });
export const pwaState = writable(createInitialState()); export const pwaState = writable(createInitialState());
let deferredInstallPrompt: any = null; let deferredInstallPrompt: any = null;
export const setInstallPrompt = (event: any) => { const setPwaState = (partial: Record<string, unknown>) => {
deferredInstallPrompt = event;
pwaState.update((state) => ({ pwaState.update((state) => ({
...state, ...state,
installAvailable: true ...partial
})); }));
}; };
export const setInstallPrompt = (event: any) => {
deferredInstallPrompt = event;
setPwaState({
installAvailable: true
});
};
export const clearInstallPrompt = () => { export const clearInstallPrompt = () => {
deferredInstallPrompt = null; deferredInstallPrompt = null;
pwaState.update((state) => ({ setPwaState({
...state,
installAvailable: false, installAvailable: false,
installing: false installing: false
})); });
}; };
export const setUpdateAvailable = (updateAvailable: boolean) => { export const setUpdateAvailable = (updateAvailable: boolean) => {
pwaState.update((state) => ({ setPwaState({ updateAvailable });
...state,
updateAvailable
}));
}; };
export const setOfflineReady = (offlineReady: boolean) => { export const setOfflineReady = (offlineReady: boolean) => {
pwaState.update((state) => ({ setPwaState({ offlineReady });
...state,
offlineReady
}));
}; };
export const dismissOfflineReady = () => { export const dismissOfflineReady = () => {
pwaState.update((state) => ({ setPwaState({ offlineReady: false });
...state, };
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 () => { export const promptInstall = async () => {
@@ -54,21 +100,20 @@ export const promptInstall = async () => {
return false; return false;
} }
pwaState.update((state) => ({ setPwaState({
...state,
installing: true installing: true
})); });
try { try {
await deferredInstallPrompt.prompt(); await deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice; const choice = await deferredInstallPrompt.userChoice;
clearInstallPrompt(); clearInstallPrompt();
syncInstallHelpState();
return choice?.outcome === 'accepted'; return choice?.outcome === 'accepted';
} catch (error) { } catch (error) {
pwaState.update((state) => ({ setPwaState({
...state,
installing: false installing: false
})); });
throw error; throw error;
} }
}; };

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
// @ts-nocheck // @ts-nocheck
import { appController } from '$lib/app/controller'; import { appController } from '$lib/app/controller';
import { dismissOfflineReady, promptInstall, pwaState } from '$lib/pwa'; import { dismissIosInstallHint, dismissOfflineReady, promptInstall, pwaState } from '$lib/pwa';
import { appState, unreadNotificationsCount } from '$lib/app/store'; import { appState, unreadNotificationsCount } from '$lib/app/store';
import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js'; import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js'; import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
@@ -40,6 +40,11 @@
const reloadForUpdate = () => { const reloadForUpdate = () => {
window.location.reload(); window.location.reload();
}; };
const offlineTitle = () => ($appState.session ? 'Offline Shell Mode' : 'Offline');
const offlineDescription = () =>
$appState.session
? 'Cached screens remain available, but sign-in refresh, live monitoring, camera control, and realtime updates need connectivity.'
: 'You can open the cached shell, but sign-in and device setup need an internet connection.';
</script> </script>
<div data-sim-page={pageKey} class="flex h-full w-full"> <div data-sim-page={pageKey} class="flex h-full w-full">
@@ -87,6 +92,20 @@
</Alert> </Alert>
{/if} {/if}
{#if $pwaState.showIosInstallHint}
<Alert class="glass-card border-white/10 bg-background/95 shadow-lg">
<AlertTitle class="text-xs">Install on iPhone</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">
In Safari, tap the Share button, then choose “Add to Home Screen” to install PhoneCam.
</AlertDescription>
<AlertAction>
<Button variant="ghost" size="sm" class="text-gray-300" onclick={dismissIosInstallHint}>
Got it
</Button>
</AlertAction>
</Alert>
{/if}
{#if $pwaState.updateAvailable} {#if $pwaState.updateAvailable}
<Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg"> <Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg">
<AlertTitle class="text-xs">Update Ready</AlertTitle> <AlertTitle class="text-xs">Update Ready</AlertTitle>
@@ -255,6 +274,16 @@
</aside> </aside>
<main class="flex-1 flex flex-col h-full overflow-hidden relative"> <main class="flex-1 flex flex-col h-full overflow-hidden relative">
{#if $pwaState.isOffline}
<div class="px-4 pt-4 md:px-8 md:pt-6 lg:px-10">
<Alert class="glass-card border-amber-500/20 bg-[#141418]/95 shadow-lg">
<AlertTitle class="text-xs uppercase tracking-wide text-amber-300">{offlineTitle()}</AlertTitle>
<AlertDescription class="text-xs text-gray-300">
{offlineDescription()}
</AlertDescription>
</Alert>
</div>
{/if}
<div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative"> <div class="flex-1 overflow-y-auto p-4 md:p-8 lg:p-10 relative">
{@render children()} {@render children()}
</div> </div>

View File

@@ -5,7 +5,14 @@
import { registerSW } from 'virtual:pwa-register'; import { registerSW } from 'virtual:pwa-register';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { appController } from '$lib/app/controller'; 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(); let { children } = $props();
@@ -22,20 +29,37 @@
const handleBeforeInstallPrompt = (event) => { const handleBeforeInstallPrompt = (event) => {
event.preventDefault(); event.preventDefault();
setInstallPrompt(event); setInstallPrompt(event);
syncInstallHelpState();
}; };
const handleAppInstalled = () => { const handleAppInstalled = () => {
clearInstallPrompt(); clearInstallPrompt();
syncInstallHelpState();
}; };
const handleOnline = () => {
setOnlineStatus(true);
};
const handleOffline = () => {
setOnlineStatus(false);
};
setOnlineStatus(navigator.onLine);
syncInstallHelpState();
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled); window.addEventListener('appinstalled', handleAppInstalled);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
void appController.init(); void appController.init();
return () => { return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled); window.removeEventListener('appinstalled', handleAppInstalled);
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
void appController.destroy(); void appController.destroy();
}; };
}); });