Skip to content

Commit c40407b

Browse files
committed
Make the site installable and add an Install app button
A web app manifest with 192 and 512 px icons (drawn from the logo) makes browsers offer "Install app" / "Add to Home screen". The homepage shows an Install app button while a Chromium browser offers its install prompt; a client module catches that event early, because it fires before React hydrates. Safari and Firefox never fire it, so no button appears there. No service worker: the installed app needs a connection, like the site.
1 parent 94c6a95 commit c40407b

9 files changed

Lines changed: 140 additions & 0 deletions

File tree

‎docusaurus.config.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ const config: Config = {
4646
},
4747
],
4848

49+
// Makes the site installable ("Add to Home screen" / "Install app"): the manifest names
50+
// the app and its icons. There is deliberately no service worker, so the installed app
51+
// needs a connection, like the site itself.
52+
headTags: [
53+
{ tagName: 'link', attributes: { rel: 'manifest', href: '/manifest.webmanifest' } },
54+
{ tagName: 'meta', attributes: { name: 'theme-color', content: '#1b1b1d' } },
55+
{ tagName: 'link', attributes: { rel: 'apple-touch-icon', href: '/img/app/icon-192.png' } },
56+
],
57+
58+
// Catches the browser's install offer for the homepage's "Install app" button.
59+
clientModules: ['./src/clientModules/installPrompt.ts'],
60+
4961
presets: [
5062
[
5163
'classic',

‎e2e/install.spec.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('links a web app manifest with installable icons', async ({ page, request }) => {
4+
await page.goto('/');
5+
6+
await expect(page.locator('link[rel="manifest"]')).toHaveAttribute('href', '/manifest.webmanifest');
7+
8+
const manifest = await (await request.get('/manifest.webmanifest')).json();
9+
expect(manifest.display).toBe('standalone');
10+
for (const size of ['192x192', '512x512']) {
11+
const icon = manifest.icons.find((candidate: { sizes: string }) => candidate.sizes === size);
12+
expect((await request.get(icon.src)).status()).toBe(200);
13+
}
14+
});
15+
16+
test('shows the install button only while the browser offers to install', async ({ page }) => {
17+
await page.goto('/');
18+
const button = page.getByRole('button', { name: 'Install app' });
19+
await expect(button).toHaveCount(0);
20+
21+
// Browsers only fire the real event for a site they consider installable, so stand in for it.
22+
await page.evaluate(() => {
23+
const offer = new Event('beforeinstallprompt', { cancelable: true });
24+
Object.assign(offer, {
25+
prompt: () => {
26+
document.body.dataset.installPrompted = 'yes';
27+
return Promise.resolve();
28+
},
29+
});
30+
window.dispatchEvent(offer);
31+
});
32+
await expect(button).toBeVisible();
33+
34+
await button.click();
35+
await expect(page.locator('body')).toHaveAttribute('data-install-prompted', 'yes');
36+
await expect(button).toHaveCount(0);
37+
});

‎src/clientModules/installPrompt.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
2+
3+
/**
4+
* Chromium browsers announce that the site can be installed through a single
5+
* `beforeinstallprompt` event, usually right after the page loads and before React has
6+
* hydrated. This client module runs as soon as the bundle does, keeps the event, and lets
7+
* the InstallButton component ask for it later, on any page and after any navigation.
8+
* Safari and Firefox never fire the event, so nothing is ever offered there.
9+
*/
10+
export interface BeforeInstallPromptEvent extends Event {
11+
prompt(): Promise<void>;
12+
}
13+
14+
type Listener = (available: boolean) => void;
15+
16+
let deferred: BeforeInstallPromptEvent | null = null;
17+
const listeners = new Set<Listener>();
18+
19+
function notify(): void {
20+
listeners.forEach((listener) => listener(deferred !== null));
21+
}
22+
23+
/** Calls `listener` now and whenever the install offer appears or goes away. */
24+
export function onInstallAvailable(listener: Listener): () => void {
25+
listeners.add(listener);
26+
listener(deferred !== null);
27+
return () => {
28+
listeners.delete(listener);
29+
};
30+
}
31+
32+
/** Raises the browser's install dialog. An offer can be used once. */
33+
export function promptInstall(): void {
34+
const prompt = deferred;
35+
deferred = null;
36+
notify();
37+
prompt?.prompt().catch(() => {
38+
// Already used or blocked: the browser's own menu still offers installing.
39+
});
40+
}
41+
42+
if (ExecutionEnvironment.canUseDOM) {
43+
window.addEventListener('beforeinstallprompt', (event) => {
44+
// The button replaces the browser's own mini-infobar.
45+
event.preventDefault();
46+
deferred = event as BeforeInstallPromptEvent;
47+
notify();
48+
});
49+
window.addEventListener('appinstalled', () => {
50+
deferred = null;
51+
notify();
52+
});
53+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useEffect, useState } from "react";
2+
import { onInstallAvailable, promptInstall } from "../../clientModules/installPrompt";
3+
4+
/**
5+
* "Install app": rendered only while the browser offers to install the site (see
6+
* clientModules/installPrompt.ts), so it never shows in Safari or Firefox, or once the
7+
* site is installed.
8+
*/
9+
export default function InstallButton(): JSX.Element | null {
10+
const [available, setAvailable] = useState(false);
11+
useEffect(() => onInstallAvailable(setAvailable), []);
12+
13+
if (!available) {
14+
return null;
15+
}
16+
return (
17+
<button type="button" className="button button--secondary button--lg" onClick={promptInstall}>
18+
📲 Install app
19+
</button>
20+
);
21+
}

‎src/pages/index.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Link from "@docusaurus/Link";
33
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
44
import Layout from "@theme/Layout";
55
import HomepageFeatures from "../components/HomepageFeatures";
6+
import InstallButton from "../components/InstallButton";
67
import Heading from "@theme/Heading";
78

89
import styles from "./index.module.css";
@@ -17,6 +18,7 @@ function HomepageHeader() {
1718
</Heading>
1819
<p className="hero__subtitle">{siteConfig.tagline}</p>
1920
<div className={styles.buttons}>
21+
<InstallButton />
2022
{/* <Link
2123
className="button button--secondary button--lg"
2224
to="/docs/intro">

‎static/img/app/icon-192.png‎

863 Bytes
Loading

‎static/img/app/icon-512.png‎

2.08 KB
Loading
1.85 KB
Loading

‎static/manifest.webmanifest‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "codeuctivity",
3+
"short_name": "codeuctivity",
4+
"description": "Turning Coffee into Code: A Journey of Joyful Creation",
5+
"start_url": "/",
6+
"scope": "/",
7+
"display": "standalone",
8+
"theme_color": "#1b1b1d",
9+
"background_color": "#1b1b1d",
10+
"icons": [
11+
{ "src": "/img/app/icon-192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
12+
{ "src": "/img/app/icon-512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" },
13+
{ "src": "/img/app/icon-maskable-512.png", "type": "image/png", "sizes": "512x512", "purpose": "maskable" }
14+
]
15+
}

0 commit comments

Comments
 (0)