feat(webapp): add installable PWA support

This commit is contained in:
2026-03-19 16:40:00 +00:00
parent 65d113046a
commit 22608b80f1
12 changed files with 868 additions and 1 deletions

View File

@@ -4,6 +4,11 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0c" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="PhoneCam" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
%sveltekit.head%
</head>
<body

74
WebApp/src/lib/pwa.ts Normal file
View File

@@ -0,0 +1,74 @@
import { writable } from 'svelte/store';
const createInitialState = () => ({
installAvailable: false,
updateAvailable: false,
offlineReady: false,
installing: false
});
export const pwaState = writable(createInitialState());
let deferredInstallPrompt: any = null;
export const setInstallPrompt = (event: any) => {
deferredInstallPrompt = event;
pwaState.update((state) => ({
...state,
installAvailable: true
}));
};
export const clearInstallPrompt = () => {
deferredInstallPrompt = null;
pwaState.update((state) => ({
...state,
installAvailable: false,
installing: false
}));
};
export const setUpdateAvailable = (updateAvailable: boolean) => {
pwaState.update((state) => ({
...state,
updateAvailable
}));
};
export const setOfflineReady = (offlineReady: boolean) => {
pwaState.update((state) => ({
...state,
offlineReady
}));
};
export const dismissOfflineReady = () => {
pwaState.update((state) => ({
...state,
offlineReady: false
}));
};
export const promptInstall = async () => {
if (!deferredInstallPrompt) {
return false;
}
pwaState.update((state) => ({
...state,
installing: true
}));
try {
await deferredInstallPrompt.prompt();
const choice = await deferredInstallPrompt.userChoice;
clearInstallPrompt();
return choice?.outcome === 'accepted';
} catch (error) {
pwaState.update((state) => ({
...state,
installing: false
}));
throw error;
}
};

View File

@@ -1,6 +1,7 @@
<script lang="ts">
// @ts-nocheck
import { appController } from '$lib/app/controller';
import { dismissOfflineReady, promptInstall, pwaState } from '$lib/pwa';
import { appState, unreadNotificationsCount } from '$lib/app/store';
import { Alert, AlertAction, AlertDescription, AlertTitle } from '$lib/components/ui/alert/index.js';
import { Avatar, AvatarFallback } from '$lib/components/ui/avatar/index.js';
@@ -36,6 +37,9 @@
const userInitial = () => ($appState.session?.user?.name?.[0] || '?').toUpperCase();
const navVariant = (active: boolean) => (active ? 'secondary' : 'ghost');
const toastTitle = (type: string) => (type ? `${type[0].toUpperCase()}${type.slice(1)}` : 'Notice');
const reloadForUpdate = () => {
window.location.reload();
};
</script>
<div data-sim-page={pageKey} class="flex h-full w-full">
@@ -62,6 +66,54 @@
</AlertAction>
</Alert>
{/each}
{#if $pwaState.installAvailable}
<Alert class="glass-card border-white/10 bg-background/95 shadow-lg">
<AlertTitle class="text-xs">Install App</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">
Add PhoneCam to your home screen for a more native dashboard experience.
</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="sm"
class="border-white/10 text-gray-200"
disabled={$pwaState.installing}
onclick={() => void promptInstall()}
>
{$pwaState.installing ? 'Installing...' : 'Install'}
</Button>
</AlertAction>
</Alert>
{/if}
{#if $pwaState.updateAvailable}
<Alert class="glass-card border-blue-500/20 bg-background/95 shadow-lg">
<AlertTitle class="text-xs">Update Ready</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">
A newer version of PhoneCam is ready. Reload to use the latest app shell.
</AlertDescription>
<AlertAction>
<Button variant="outline" size="sm" class="border-blue-500/30 text-gray-200" onclick={reloadForUpdate}>
Reload
</Button>
</AlertAction>
</Alert>
{/if}
{#if $pwaState.offlineReady}
<Alert class="glass-card border-emerald-500/20 bg-background/95 shadow-lg">
<AlertTitle class="text-xs">Offline Shell Ready</AlertTitle>
<AlertDescription class="pr-8 text-xs text-gray-300">
The app shell is now cached for quicker relaunches, but live camera features still need connectivity.
</AlertDescription>
<AlertAction>
<Button variant="ghost" size="sm" class="text-gray-300" onclick={dismissOfflineReady}>
Dismiss
</Button>
</AlertAction>
</Alert>
{/if}
</div>
<aside

View File

@@ -1,15 +1,41 @@
<script lang="ts">
// @ts-nocheck
import '../app.css';
import { onMount } from 'svelte';
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';
let { children } = $props();
onMount(() => {
registerSW({
onNeedRefresh() {
setUpdateAvailable(true);
},
onOfflineReady() {
setOfflineReady(true);
}
});
const handleBeforeInstallPrompt = (event) => {
event.preventDefault();
setInstallPrompt(event);
};
const handleAppInstalled = () => {
clearInstallPrompt();
};
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.addEventListener('appinstalled', handleAppInstalled);
void appController.init();
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled);
void appController.destroy();
};
});