Browse Source

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.
maziggy 5 days ago
parent
commit
17e39921bb

File diff suppressed because it is too large
+ 0 - 0
CHANGELOG.md


+ 4 - 4
backend/app/main.py

@@ -5135,11 +5135,11 @@ async def security_headers_middleware(request, call_next):
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "script-src 'self' 'unsafe-eval'; "
-            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+            "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'self'")
@@ -5163,11 +5163,11 @@ async def security_headers_middleware(request, call_next):
         response.headers["Content-Security-Policy"] = (
             "default-src 'self'; "
             "script-src 'self'; "
-            "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+            "style-src 'self' 'unsafe-inline'; "
             "img-src 'self' data: blob:; "
             "media-src 'self' blob:; "
             "connect-src 'self' ws: wss:; "
-            "font-src 'self' data: https://fonts.gstatic.com; "
+            "font-src 'self' data:; "
             "object-src 'none'; "
             "base-uri 'self'; "
             "frame-src 'self' http: https:; " + _frame_ancestors("'none'")

BIN
frontend/public/fonts/inter-latin-ext.woff2


BIN
frontend/public/fonts/inter-latin.woff2


+ 16 - 3
frontend/public/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v26';
-const STATIC_CACHE = 'bambuddy-static-v25';
+const CACHE_NAME = 'bambuddy-v27';
+const STATIC_CACHE = 'bambuddy-static-v26';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [
@@ -13,6 +13,9 @@ const STATIC_ASSETS = [
   '/img/android-chrome-512x512.png',
   '/img/apple-touch-icon.png',
   '/img/bambuddy_logo_dark.png',
+  // Self-hosted Inter font (#1460) - cached so the UI renders offline.
+  '/fonts/inter-latin.woff2',
+  '/fonts/inter-latin-ext.woff2',
 ];
 
 // Install event - cache static assets
@@ -57,6 +60,14 @@ self.addEventListener('fetch', (event) => {
     return;
   }
 
+  // Skip cross-origin requests - let the browser handle them directly.
+  // Without this the catch-all HTML branch below would answer a failed
+  // cross-origin request with our cached index.html, so e.g. a blocked
+  // Google Fonts request came back as text/html (#1460).
+  if (url.origin !== self.location.origin) {
+    return;
+  }
+
   // Skip WebSocket connections
   if (url.protocol === 'ws:' || url.protocol === 'wss:') {
     return;
@@ -88,10 +99,12 @@ self.addEventListener('fetch', (event) => {
   if (
     url.pathname.startsWith('/img/') ||
     url.pathname.startsWith('/icons/') ||
+    url.pathname.startsWith('/fonts/') ||
     url.pathname.endsWith('.png') ||
     url.pathname.endsWith('.jpg') ||
     url.pathname.endsWith('.svg') ||
-    url.pathname.endsWith('.ico')
+    url.pathname.endsWith('.ico') ||
+    url.pathname.endsWith('.woff2')
   ) {
     event.respondWith(
       caches.match(request).then((cached) => {

+ 85 - 0
frontend/src/__tests__/components/InstallAppButton.test.tsx

@@ -0,0 +1,85 @@
+/**
+ * Tests for InstallAppButton - the in-app PWA install trigger (#1460).
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { act } from 'react';
+import { render, screen, waitFor } from '../utils';
+import userEvent from '@testing-library/user-event';
+import { InstallAppButton } from '../../components/InstallAppButton';
+
+// Scoped so the assertion ignores unrelated buttons (e.g. a toast's dismiss).
+const INSTALL_BUTTON = { name: 'Install app' } as const;
+
+/** Build a fake beforeinstallprompt event with a controllable userChoice. */
+function makeInstallPromptEvent(outcome: 'accepted' | 'dismissed') {
+  const event = new Event('beforeinstallprompt') as Event & {
+    prompt: () => Promise<void>;
+    userChoice: Promise<{ outcome: string; platform: string }>;
+  };
+  event.prompt = vi.fn().mockResolvedValue(undefined);
+  event.userChoice = Promise.resolve({ outcome, platform: 'web' });
+  return event;
+}
+
+describe('InstallAppButton', () => {
+  it('renders nothing until the browser fires beforeinstallprompt', () => {
+    render(<InstallAppButton />);
+    expect(screen.queryByRole('button', INSTALL_BUTTON)).toBeNull();
+  });
+
+  it('shows the install button once beforeinstallprompt fires', async () => {
+    render(<InstallAppButton />);
+    await act(async () => {
+      window.dispatchEvent(makeInstallPromptEvent('accepted'));
+    });
+    expect(await screen.findByRole('button', INSTALL_BUTTON)).toBeInTheDocument();
+  });
+
+  it('fires the captured prompt on click and hides itself afterwards', async () => {
+    const user = userEvent.setup();
+    const event = makeInstallPromptEvent('accepted');
+    render(<InstallAppButton />);
+
+    await act(async () => {
+      window.dispatchEvent(event);
+    });
+    await user.click(await screen.findByRole('button', INSTALL_BUTTON));
+
+    expect(event.prompt).toHaveBeenCalledTimes(1);
+    // A captured prompt can only be used once, so the button must disappear.
+    await waitFor(() =>
+      expect(screen.queryByRole('button', INSTALL_BUTTON)).toBeNull()
+    );
+  });
+
+  it('hides itself even when the user dismisses the prompt', async () => {
+    const user = userEvent.setup();
+    const event = makeInstallPromptEvent('dismissed');
+    render(<InstallAppButton />);
+
+    await act(async () => {
+      window.dispatchEvent(event);
+    });
+    await user.click(await screen.findByRole('button', INSTALL_BUTTON));
+
+    await waitFor(() =>
+      expect(screen.queryByRole('button', INSTALL_BUTTON)).toBeNull()
+    );
+  });
+
+  it('hides the button when the app reports it was installed', async () => {
+    render(<InstallAppButton />);
+    await act(async () => {
+      window.dispatchEvent(makeInstallPromptEvent('accepted'));
+    });
+    expect(await screen.findByRole('button', INSTALL_BUTTON)).toBeInTheDocument();
+
+    await act(async () => {
+      window.dispatchEvent(new Event('appinstalled'));
+    });
+    await waitFor(() =>
+      expect(screen.queryByRole('button', INSTALL_BUTTON)).toBeNull()
+    );
+  });
+});

+ 69 - 0
frontend/src/components/InstallAppButton.tsx

@@ -0,0 +1,69 @@
+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>
+  );
+}

+ 3 - 0
frontend/src/components/Layout.tsx

@@ -4,6 +4,7 @@ import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, C
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
+import { InstallAppButton } from './InstallAppButton';
 import { SwitchbarPopover } from './SwitchbarPopover';
 import { useQuery, useQueries } from '@tanstack/react-query';
 import { api, supportApi, pendingUploadsApi, type Permission } from '../api/client';
@@ -732,6 +733,7 @@ export function Layout() {
                     <Info className="w-5 h-5" />
                   </span>
                 )}
+                <InstallAppButton />
                 <a
                   href="https://github.com/maziggy/bambuddy"
                   target="_blank"
@@ -836,6 +838,7 @@ export function Layout() {
                   <Info className="w-5 h-5" />
                 </span>
               )}
+              <InstallAppButton />
               <a
                 href="https://github.com/maziggy/bambuddy"
                 target="_blank"

+ 2 - 0
frontend/src/i18n/locales/de.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Zum dunklen Modus wechseln',
     smartSwitches: 'Smart Switches',
     logout: 'Abmelden',
+    installApp: 'App installieren',
+    installAppSuccess: 'Bambuddy wurde installiert',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/en.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Switch to dark mode',
     smartSwitches: 'Smart Switches',
     logout: 'Logout',
+    installApp: 'Install app',
+    installAppSuccess: 'Bambuddy was installed',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/es.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Cambiar a modo oscuro',
     smartSwitches: 'Interruptores inteligentes',
     logout: 'Cerrar sesión',
+    installApp: 'Instalar app',
+    installAppSuccess: 'Bambuddy se ha instalado',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/fr.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Passer au mode sombre',
     smartSwitches: 'Interrupteurs intelligents',
     logout: 'Déconnexion',
+    installApp: "Installer l'application",
+    installAppSuccess: 'Bambuddy a été installé',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/it.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Passa a tema scuro',
     smartSwitches: 'Interruttori Smart',
     logout: 'Esci',
+    installApp: 'Installa app',
+    installAppSuccess: 'Bambuddy è stato installato',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/ja.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'ダークモードに切替',
     smartSwitches: 'スマートスイッチ',
     logout: 'ログアウト',
+    installApp: 'アプリをインストール',
+    installAppSuccess: 'Bambuddyをインストールしました',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: 'Mudar para modo escuro',
     smartSwitches: 'Interruptores inteligentes',
     logout: 'Sair',
+    installApp: 'Instalar app',
+    installAppSuccess: 'Bambuddy foi instalado',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: '切换到深色模式',
     smartSwitches: '智能开关',
     logout: '退出登录',
+    installApp: '安装应用',
+    installAppSuccess: 'Bambuddy 已安装',
   },
 
   // Common

+ 2 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -26,6 +26,8 @@ export default {
     switchToDark: '切換到深色模式',
     smartSwitches: '智慧開關',
     logout: '登出',
+    installApp: '安裝應用程式',
+    installAppSuccess: 'Bambuddy 已安裝',
   },
 
   // Common

+ 23 - 1
frontend/src/index.css

@@ -1,5 +1,27 @@
 @import "tailwindcss";
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
+
+/* Inter, self-hosted (#1460).  Bambuddy is a local-first, offline-capable PWA;
+   pulling the font from fonts.googleapis.com at runtime broke the install on
+   Android (CSP-blocked cross-origin fetch) and meant the UI couldn't render
+   offline.  These are the variable woff2 files Google Fonts serves — one file
+   covers every weight via the variable axis, so font-weight is a 100-900 range.
+   Files live in frontend/public/fonts/ and are served same-origin. */
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url('/fonts/inter-latin.woff2') format('woff2');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+  font-family: 'Inter';
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url('/fonts/inter-latin-ext.woff2') format('woff2');
+  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
 
 /* Enable class-based dark mode for Tailwind v4 */
 @custom-variant dark (&:where(.dark, .dark *));

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-CBuiHfeb.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-DQCawz3_.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-QwBJJHPy.css


BIN
static/fonts/inter-latin-ext.woff2


BIN
static/fonts/inter-latin.woff2


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-DG9hmpJq.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-QwBJJHPy.css">
+    <script type="module" crossorigin src="/assets/index-DQCawz3_.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CBuiHfeb.css">
   </head>
   <body>
     <div id="root"></div>

+ 16 - 3
static/sw.js

@@ -1,6 +1,6 @@
 // Bambuddy Service Worker
-const CACHE_NAME = 'bambuddy-v26';
-const STATIC_CACHE = 'bambuddy-static-v25';
+const CACHE_NAME = 'bambuddy-v27';
+const STATIC_CACHE = 'bambuddy-static-v26';
 
 // Static assets to cache on install
 const STATIC_ASSETS = [
@@ -13,6 +13,9 @@ const STATIC_ASSETS = [
   '/img/android-chrome-512x512.png',
   '/img/apple-touch-icon.png',
   '/img/bambuddy_logo_dark.png',
+  // Self-hosted Inter font (#1460) - cached so the UI renders offline.
+  '/fonts/inter-latin.woff2',
+  '/fonts/inter-latin-ext.woff2',
 ];
 
 // Install event - cache static assets
@@ -57,6 +60,14 @@ self.addEventListener('fetch', (event) => {
     return;
   }
 
+  // Skip cross-origin requests - let the browser handle them directly.
+  // Without this the catch-all HTML branch below would answer a failed
+  // cross-origin request with our cached index.html, so e.g. a blocked
+  // Google Fonts request came back as text/html (#1460).
+  if (url.origin !== self.location.origin) {
+    return;
+  }
+
   // Skip WebSocket connections
   if (url.protocol === 'ws:' || url.protocol === 'wss:') {
     return;
@@ -88,10 +99,12 @@ self.addEventListener('fetch', (event) => {
   if (
     url.pathname.startsWith('/img/') ||
     url.pathname.startsWith('/icons/') ||
+    url.pathname.startsWith('/fonts/') ||
     url.pathname.endsWith('.png') ||
     url.pathname.endsWith('.jpg') ||
     url.pathname.endsWith('.svg') ||
-    url.pathname.endsWith('.ico')
+    url.pathname.endsWith('.ico') ||
+    url.pathname.endsWith('.woff2')
   ) {
     event.respondWith(
       caches.match(request).then((cached) => {

Some files were not shown because too many files changed in this diff