feat(pwa): свой сервис-воркер и включение уведомлений
Воркер теперь собирается через injectManifest: generateSW умеет только кэш, а уведомление показывает именно воркер — страница при этом может быть закрыта. Нажатие на уведомление переиспользует уже открытое окно, а не плодит второе. Включение — строкой на главном экране, по нажатию. Не всплывающим окном на входе: разрешение спрашивается один раз за установку, отказ браузер запоминает навсегда, и потраченная впустую попытка закрывает канал. Если сервер уведомления не настроил — разрешение не запрашиваем вовсе. Отдельные тексты вместо молчания: на iPhone без установки на домашний экран уведомления невозможны в принципе, и об этом сказано прямо. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver";
|
||||
|
||||
/**
|
||||
* Включение уведомлений в приложении.
|
||||
*
|
||||
* Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за
|
||||
* всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только
|
||||
* руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт
|
||||
* строго по нажатию и только когда сервер подтвердил, что уведомления настроены.
|
||||
*/
|
||||
export type PushState =
|
||||
| "unsupported" // браузер не умеет (или iOS без установки на домашний экран)
|
||||
| "needs-install" // iOS: умеет, но только когда приложение добавлено на экран
|
||||
| "denied" // разрешение отклонено — переспросить нечем
|
||||
| "off" // можно включить
|
||||
| "on"; // подписка есть
|
||||
|
||||
function isStandalone(): boolean {
|
||||
return window.matchMedia("(display-mode: standalone)").matches ||
|
||||
// iOS до 17 не поддерживает display-mode: standalone в matchMedia
|
||||
(navigator as { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
function isIos(): boolean {
|
||||
return /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
export function supported(): boolean {
|
||||
return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
export async function currentState(): Promise<PushState> {
|
||||
if (!supported()) return isIos() && !isStandalone() ? "needs-install" : "unsupported";
|
||||
if (Notification.permission === "denied") return "denied";
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
return sub ? "on" : "off";
|
||||
}
|
||||
|
||||
/** base64url → байты: ключ VAPID браузер принимает только так.
|
||||
* Возвращаем ArrayBuffer, а не Uint8Array: типы DOM ждут BufferSource. */
|
||||
function keyToBytes(base64: string): ArrayBuffer {
|
||||
const padded = base64.replace(/-/g, "+").replace(/_/g, "/") +
|
||||
"=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const raw = atob(padded);
|
||||
const bytes = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/** Включить уведомления. Возвращает новое состояние. */
|
||||
export async function enable(): Promise<PushState> {
|
||||
if (!supported()) return currentState();
|
||||
const { key, enabled } = await getPushKey();
|
||||
// Ключа нет — сервер уведомления не отправляет. Разрешение не спрашиваем:
|
||||
// потратить единственную попытку впустую нельзя.
|
||||
if (!enabled || !key) return "off";
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return permission === "denied" ? "denied" : "off";
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: keyToBytes(key),
|
||||
});
|
||||
const json = sub.toJSON() as { endpoint?: string; keys?: { p256dh?: string; auth?: string } };
|
||||
await subscribePush({
|
||||
endpoint: json.endpoint ?? sub.endpoint,
|
||||
p256dh: json.keys?.p256dh ?? "",
|
||||
auth: json.keys?.auth ?? "",
|
||||
});
|
||||
return "on";
|
||||
}
|
||||
|
||||
/** Выключить: снимаем подписку и в браузере, и на сервере. */
|
||||
export async function disable(): Promise<PushState> {
|
||||
const reg = await navigator.serviceWorker.getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
// Сначала сервер: не сумеем достучаться — подписка останется живой, и
|
||||
// уведомления продолжат приходить, а это лучше «выключил, а они идут».
|
||||
await unsubscribePush(sub.endpoint);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
return "off";
|
||||
}
|
||||
Reference in New Issue
Block a user