feat(pwa): add base
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
All checks were successful
/ build-web (push) Successful in 5m35s
/ build-desktop (linux) (push) Successful in 9m41s
/ build-mobile (push) Successful in 20m12s
/ release (push) Successful in 1m51s
Dependency builds / Build web (pull_request) Has been skipped
Dependency builds / Build desktop (pull_request) Has been skipped
Dependency builds / Build mobile (pull_request) Has been skipped
This commit is contained in:
parent
6e977bca7e
commit
7b36218ffa
40 changed files with 4014 additions and 922 deletions
237
apps/pwa/src/runtime.tsx
Normal file
237
apps/pwa/src/runtime.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { setDatabaseEntry } from "@tensamin/shared/indexedDb";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
import "./style.css";
|
||||
|
||||
type BeforeInstallPromptEvent = Event & {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
};
|
||||
|
||||
const launchedFiles: File[] = [];
|
||||
const fileListeners = new Set<(file: File) => void>();
|
||||
|
||||
function emitLaunchedFile(file: File) {
|
||||
if (fileListeners.size === 0) launchedFiles.push(file);
|
||||
else for (const listener of fileListeners) listener(file);
|
||||
}
|
||||
|
||||
export function subscribeTuFileLaunch(listener: (file: File) => void) {
|
||||
fileListeners.add(listener);
|
||||
for (const file of launchedFiles.splice(0)) listener(file);
|
||||
return () => {
|
||||
fileListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function isStandalone() {
|
||||
return (
|
||||
window.matchMedia("(display-mode: standalone)").matches ||
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function applicationServerKey(value: string) {
|
||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
|
||||
return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function enablePush() {
|
||||
if (!("Notification" in window))
|
||||
throw new Error("Notifications are not supported by this browser.");
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted")
|
||||
throw new Error("Notification permission was not granted.");
|
||||
|
||||
const publicKey = import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY;
|
||||
if (
|
||||
!publicKey ||
|
||||
!("serviceWorker" in navigator) ||
|
||||
!("PushManager" in window)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription =
|
||||
(await registration.pushManager.getSubscription()) ??
|
||||
(await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey(publicKey),
|
||||
}));
|
||||
await setDatabaseEntry("keys", "push-subscription", subscription.toJSON());
|
||||
}
|
||||
|
||||
export default function PwaRuntime() {
|
||||
const [installPrompt, setInstallPrompt] =
|
||||
useState<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isTauri() ||
|
||||
!("serviceWorker" in navigator) ||
|
||||
!["http:", "https:"].includes(window.location.protocol)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let reloading = false;
|
||||
const handleControllerChange = () => {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
// A newly activated service worker must reload the document it controls.
|
||||
// eslint-disable-next-line tensamin/no-window-location-reload
|
||||
window.location.reload();
|
||||
};
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
void navigator.serviceWorker
|
||||
.register(
|
||||
import.meta.env.DEV ? "/dev-sw.js?dev-sw" : "/serviceWorker.js",
|
||||
{
|
||||
type: "module",
|
||||
},
|
||||
)
|
||||
.then((registration) => {
|
||||
const watchWorker = (worker: ServiceWorker) => {
|
||||
worker.addEventListener("statechange", () => {
|
||||
if (worker.state !== "installed") return;
|
||||
if (!navigator.serviceWorker.controller) {
|
||||
toast.success("Tensamin is ready for offline startup");
|
||||
return;
|
||||
}
|
||||
toast("A Tensamin update is ready", {
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "Update",
|
||||
onClick: () => worker.postMessage({ type: "SKIP_WAITING" }),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
if (registration.installing) watchWorker(registration.installing);
|
||||
registration.addEventListener("updatefound", () => {
|
||||
if (registration.installing) watchWorker(registration.installing);
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Failed to register the Tensamin service worker", error);
|
||||
});
|
||||
return () => {
|
||||
navigator.serviceWorker.removeEventListener(
|
||||
"controllerchange",
|
||||
handleControllerChange,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleInstallPrompt = (event: Event) => {
|
||||
event.preventDefault();
|
||||
setInstallPrompt(event as BeforeInstallPromptEvent);
|
||||
};
|
||||
window.addEventListener("beforeinstallprompt", handleInstallPrompt);
|
||||
return () =>
|
||||
window.removeEventListener("beforeinstallprompt", handleInstallPrompt);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const launchQueue = (
|
||||
window as Window & {
|
||||
launchQueue?: {
|
||||
setConsumer: (
|
||||
consumer: (params: {
|
||||
files?: Array<{ getFile: () => Promise<File> }>;
|
||||
}) => void,
|
||||
) => void;
|
||||
};
|
||||
}
|
||||
).launchQueue;
|
||||
launchQueue?.setConsumer((params) => {
|
||||
for (const handle of params.files ?? []) {
|
||||
void handle.getFile().then(emitLaunchedFile);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!installPrompt) return;
|
||||
toast("Install Tensamin for a native app experience", {
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "Install",
|
||||
onClick: () => {
|
||||
void installPrompt.prompt().then(() => installPrompt.userChoice);
|
||||
setInstallPrompt(null);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [installPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
const isAppleMobile = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
if (
|
||||
isTauri() ||
|
||||
!isAppleMobile ||
|
||||
isStandalone() ||
|
||||
localStorage.getItem("pwa-ios-install-hint")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("pwa-ios-install-hint", "shown");
|
||||
toast(
|
||||
"Install Tensamin from Safari's Share menu to enable background notifications.",
|
||||
{
|
||||
duration: 12_000,
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isStandalone() ||
|
||||
!("Notification" in window) ||
|
||||
Notification.permission !== "default" ||
|
||||
localStorage.getItem("pwa-push-hint")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem("pwa-push-hint", "shown");
|
||||
toast("Enable message notifications", {
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "Enable",
|
||||
onClick: () => {
|
||||
void enablePush()
|
||||
.then(() => toast.success("Notifications enabled"))
|
||||
.catch((error: unknown) =>
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Could not enable notifications",
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isStandalone() &&
|
||||
"Notification" in window &&
|
||||
Notification.permission === "granted" &&
|
||||
import.meta.env.VITE_WEB_PUSH_PUBLIC_KEY
|
||||
) {
|
||||
void enablePush().catch((error: unknown) => {
|
||||
console.error("Failed to refresh the Web Push subscription", error);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Reference in a new issue