diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 56f97a4..bdb77ab 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -46,6 +46,18 @@ const config: Config = { }, ], + // Makes the site installable ("Add to Home screen" / "Install app"): the manifest names + // the app and its icons. There is deliberately no service worker, so the installed app + // needs a connection, like the site itself. + headTags: [ + { tagName: 'link', attributes: { rel: 'manifest', href: '/manifest.webmanifest' } }, + { tagName: 'meta', attributes: { name: 'theme-color', content: '#1b1b1d' } }, + { tagName: 'link', attributes: { rel: 'apple-touch-icon', href: '/img/app/icon-192.png' } }, + ], + + // Catches the browser's install offer for the homepage's "Install app" button. + clientModules: ['./src/clientModules/installPrompt.ts'], + presets: [ [ 'classic', diff --git a/e2e/install.spec.ts b/e2e/install.spec.ts new file mode 100644 index 0000000..8ec4e3a --- /dev/null +++ b/e2e/install.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +test('links a web app manifest with installable icons', async ({ page, request }) => { + await page.goto('/'); + + await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest'); + + const manifest = await (await request.get('/manifest.webmanifest')).json(); + expect(manifest.display).toBe('standalone'); + for (const size of ['192x192', '512x512']) { + const icon = manifest.icons.find((candidate: { sizes: string }) => candidate.sizes === size); + expect((await request.get(icon.src)).status()).toBe(200); + } +}); + +test('shows the install button only while the browser offers to install', async ({ page }) => { + await page.goto('/'); + const button = page.getByRole('button', { name: 'Install app' }); + await expect(button).toHaveCount(0); + + // Browsers only fire the real event for a site they consider installable, so stand in for it. + await page.evaluate(() => { + const offer = new Event('beforeinstallprompt', { cancelable: true }); + Object.assign(offer, { + prompt: () => { + document.body.dataset.installPrompted = 'yes'; + return Promise.resolve(); + }, + }); + window.dispatchEvent(offer); + }); + await expect(button).toBeVisible(); + + await button.click(); + await expect(page.locator('body')).toHaveAttribute('data-install-prompted', 'yes'); + await expect(button).toHaveCount(0); +}); diff --git a/src/clientModules/installPrompt.ts b/src/clientModules/installPrompt.ts new file mode 100644 index 0000000..fa10d96 --- /dev/null +++ b/src/clientModules/installPrompt.ts @@ -0,0 +1,53 @@ +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; + +/** + * Chromium browsers announce that the site can be installed through a single + * `beforeinstallprompt` event, usually right after the page loads and before React has + * hydrated. This client module runs as soon as the bundle does, keeps the event, and lets + * the InstallButton component ask for it later, on any page and after any navigation. + * Safari and Firefox never fire the event, so nothing is ever offered there. + */ +export interface BeforeInstallPromptEvent extends Event { + prompt(): Promise; +} + +type Listener = (available: boolean) => void; + +let deferred: BeforeInstallPromptEvent | null = null; +const listeners = new Set(); + +function notify(): void { + listeners.forEach((listener) => listener(deferred !== null)); +} + +/** Calls `listener` now and whenever the install offer appears or goes away. */ +export function onInstallAvailable(listener: Listener): () => void { + listeners.add(listener); + listener(deferred !== null); + return () => { + listeners.delete(listener); + }; +} + +/** Raises the browser's install dialog. An offer can be used once. */ +export function promptInstall(): void { + const prompt = deferred; + deferred = null; + notify(); + prompt?.prompt().catch(() => { + // Already used or blocked: the browser's own menu still offers installing. + }); +} + +if (ExecutionEnvironment.canUseDOM) { + window.addEventListener('beforeinstallprompt', (event) => { + // The button replaces the browser's own mini-infobar. + event.preventDefault(); + deferred = event as BeforeInstallPromptEvent; + notify(); + }); + window.addEventListener('appinstalled', () => { + deferred = null; + notify(); + }); +} diff --git a/src/components/InstallButton/index.tsx b/src/components/InstallButton/index.tsx new file mode 100644 index 0000000..3d265e1 --- /dev/null +++ b/src/components/InstallButton/index.tsx @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { onInstallAvailable, promptInstall } from "../../clientModules/installPrompt"; + +/** + * "Install app": rendered only while the browser offers to install the site (see + * clientModules/installPrompt.ts), so it never shows in Safari or Firefox, or once the + * site is installed. + */ +export default function InstallButton(): JSX.Element | null { + const [available, setAvailable] = useState(false); + useEffect(() => onInstallAvailable(setAvailable), []); + + if (!available) { + return null; + } + return ( + + ); +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index d6fef05..39eca08 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -3,6 +3,7 @@ import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import HomepageFeatures from "../components/HomepageFeatures"; +import InstallButton from "../components/InstallButton"; import Heading from "@theme/Heading"; import styles from "./index.module.css"; @@ -17,6 +18,7 @@ function HomepageHeader() {

{siteConfig.tagline}

+ {/* diff --git a/static/img/app/icon-192.png b/static/img/app/icon-192.png new file mode 100644 index 0000000..07f4932 Binary files /dev/null and b/static/img/app/icon-192.png differ diff --git a/static/img/app/icon-512.png b/static/img/app/icon-512.png new file mode 100644 index 0000000..b850929 Binary files /dev/null and b/static/img/app/icon-512.png differ diff --git a/static/img/app/icon-maskable-512.png b/static/img/app/icon-maskable-512.png new file mode 100644 index 0000000..2881ca2 Binary files /dev/null and b/static/img/app/icon-maskable-512.png differ diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..e46523e --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,15 @@ +{ + "name": "codeuctivity", + "short_name": "codeuctivity", + "description": "Turning Coffee into Code: A Journey of Joyful Creation", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#1b1b1d", + "background_color": "#1b1b1d", + "icons": [ + { "src": "/img/app/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" }, + { "src": "/img/app/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }, + { "src": "/img/app/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" } + ] +}