Files
bambuddy/frontend/src/components/InstallAppButton.tsx
T
maziggy 17e39921bb fix(pwa): add in-app install button and self-host the Inter font (#1460)
Bambuddy installed as a PWA on desktop but not on Android. Two causes:

  - Chrome for Android removed the automatic install banner in Chrome 108.
    With no beforeinstallprompt handler, Android had no install path. New
    InstallAppButton captures the event and re-fires it from the sidebar.
  - index.css pulled Inter from fonts.googleapis.com: breaks offline, trips
    CSP, and the service worker answered the failed cross-origin request
    with cached index.html. Inter is now self-hosted; the SW skips all
    cross-origin requests and caches the font; CSP drops the Google hosts.
2026-05-22 07:40:44 +02:00

70 lines
2.5 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useToast } from '../contexts/ToastContext';
// The beforeinstallprompt event is not in the standard TS DOM lib.
interface BeforeInstallPromptEvent extends Event {
readonly platforms: string[];
prompt: () => Promise<void>;
readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
}
/**
* Sidebar-footer button that installs Bambuddy as a PWA.
*
* Chrome for Android removed the automatic install banner in Chrome 108, so
* without an in-app trigger the only install path on Android is a buried
* browser-menu item (#1460). This button captures the `beforeinstallprompt`
* event and re-fires it on click. It renders nothing when the browser has no
* pending prompt - already installed, unsupported browser, or iOS Safari
* (which has no programmatic install at all).
*/
export function InstallAppButton() {
const { t } = useTranslation();
const { showToast } = useToast();
const [promptEvent, setPromptEvent] = useState<BeforeInstallPromptEvent | null>(null);
useEffect(() => {
const onBeforeInstallPrompt = (e: Event) => {
// Suppress Chrome's own mini-infobar (desktop) so this button is the
// single, predictable install entry point.
e.preventDefault();
setPromptEvent(e as BeforeInstallPromptEvent);
};
const onInstalled = () => setPromptEvent(null);
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.addEventListener('appinstalled', onInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
window.removeEventListener('appinstalled', onInstalled);
};
}, []);
if (!promptEvent) {
return null;
}
const handleInstall = async () => {
await promptEvent.prompt();
const { outcome } = await promptEvent.userChoice;
// A captured prompt can only be used once; drop it either way so the
// button hides until the browser fires a fresh beforeinstallprompt.
setPromptEvent(null);
if (outcome === 'accepted') {
showToast(t('nav.installAppSuccess'), 'success');
}
};
return (
<button
onClick={handleInstall}
className="p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
title={t('nav.installApp')}
aria-label={t('nav.installApp')}
>
<Download className="w-5 h-5" />
</button>
);
}