import { getPushKey, subscribePush, unsubscribePush } from "@/api/driver"; /** * Включение уведомлений в приложении. * * Главное правило экрана вокруг этого кода: разрешение спрашивается ОДИН раз за * всю жизнь установки. Отказ браузер запоминает, переспросить нельзя — только * руками в настройках сайта, куда водитель не пойдёт. Поэтому запрос идёт * строго по нажатию и только когда сервер подтвердил, что уведомления настроены. */ export type PushState = | "unsupported" // браузер не умеет (или iOS без установки на домашний экран) | "needs-install" // iOS: умеет, но только когда приложение добавлено на экран | "denied" // разрешение отклонено — переспросить нечем | "off" // можно включить | "on"; // подписка есть /** Приложение открыто как установленное (с домашнего экрана), а не вкладкой. */ export function installed(): boolean { return isStandalone(); } 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 { 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 { 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 { const reg = await navigator.serviceWorker.getRegistration(); const sub = await reg?.pushManager.getSubscription(); if (sub) { // Сначала сервер: не сумеем достучаться — подписка останется живой, и // уведомления продолжат приходить, а это лучше «выключил, а они идут». await unsubscribePush(sub.endpoint); await sub.unsubscribe(); } return "off"; }