diff --git a/apps/antalmanac/src/components/Header/Header.tsx b/apps/antalmanac/src/components/Header/Header.tsx
index 279d26b0c..2d4c89cc5 100644
--- a/apps/antalmanac/src/components/Header/Header.tsx
+++ b/apps/antalmanac/src/components/Header/Header.tsx
@@ -3,6 +3,7 @@ import { withStyles } from '@material-ui/core/styles';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import Import from './Import';
+import InstallPWAButton from './InstallPWAButton';
import LoadSaveScheduleFunctionality from './LoadSaveFunctionality';
import { Logo } from './Logo';
import AppDrawer from './SettingsMenu';
@@ -43,6 +44,7 @@ const Header = ({ classes }: CustomAppBarProps) => {
diff --git a/apps/antalmanac/src/components/Header/InstallPWAButton.tsx b/apps/antalmanac/src/components/Header/InstallPWAButton.tsx
new file mode 100644
index 000000000..597b019e3
--- /dev/null
+++ b/apps/antalmanac/src/components/Header/InstallPWAButton.tsx
@@ -0,0 +1,69 @@
+import { Tooltip, IconButton, Button } from '@material-ui/core';
+import BrowserUpdatedIcon from '@mui/icons-material/BrowserUpdated';
+import { useMediaQuery, useTheme } from '@mui/material';
+import { useEffect } from 'react';
+import { shallow } from 'zustand/shallow';
+
+import { BeforeInstallPromptEvent, usePWAStore } from '$stores/PWAStore';
+
+function InstallPWAButton() {
+ const [setInstallPrompt, setCanInstall, canInstall, installPrompt] = usePWAStore(
+ (state) => [state.setInstallPrompt, state.setCanInstall, state.canInstall, state.installPrompt],
+ shallow
+ );
+
+ const theme = useTheme();
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
+
+ useEffect(() => {
+ const beforeInstallHandler = (e: BeforeInstallPromptEvent) => {
+ e.preventDefault();
+ setCanInstall(true);
+ setInstallPrompt(e);
+ };
+
+ const disableInstallHandler = () => {
+ setCanInstall(false);
+ window.removeEventListener('beforeinstallprompt', beforeInstallHandler);
+ };
+
+ window.addEventListener('beforeinstallprompt', beforeInstallHandler);
+ window.addEventListener('appinstalled', disableInstallHandler);
+
+ return () => {
+ window.removeEventListener('beforeinstallprompt', beforeInstallHandler);
+ window.removeEventListener('appinstalled', disableInstallHandler);
+ };
+ }, []);
+
+ const handleInstall = (e: React.MouseEvent) => {
+ if (installPrompt) {
+ e.preventDefault();
+ if (!installPrompt) return;
+ installPrompt.prompt();
+ }
+ };
+
+ if (!canInstall) return null;
+
+ return (
+
+ {isMobile ? (
+
+
+
+ ) : (
+ }
+ onClick={handleInstall}
+ >
+ Install
+
+ )}
+
+ );
+}
+
+export default InstallPWAButton;
diff --git a/apps/antalmanac/src/stores/PWAStore.ts b/apps/antalmanac/src/stores/PWAStore.ts
new file mode 100644
index 000000000..8885728c4
--- /dev/null
+++ b/apps/antalmanac/src/stores/PWAStore.ts
@@ -0,0 +1,43 @@
+import { create } from 'zustand';
+
+// Adapted from https://stackoverflow.com/questions/51503754/typescript-type-beforeinstallpromptevent
+type UserChoice = Promise<{
+ outcome: 'accepted' | 'dismissed';
+ platform: string;
+}>;
+
+export interface BeforeInstallPromptEvent extends Event {
+ readonly platforms: string[];
+ readonly userChoice: UserChoice;
+ prompt(): Promise;
+}
+
+declare global {
+ interface WindowEventMap {
+ beforeinstallprompt: BeforeInstallPromptEvent;
+ }
+}
+
+export interface PWAStore {
+ canInstall: boolean;
+ installPrompt: BeforeInstallPromptEvent | null;
+ setInstallPrompt: (e: BeforeInstallPromptEvent) => void;
+ setCanInstall: (canInstall: boolean) => void;
+}
+
+export const usePWAStore = create((set) => {
+ return {
+ canInstall: false,
+ installPrompt: null,
+ setInstallPrompt: (e: BeforeInstallPromptEvent) => {
+ set({
+ installPrompt: e,
+ });
+ },
+ setCanInstall: (canInstall: boolean) => {
+ set({
+ canInstall,
+ });
+ },
+ };
+});