feat(pwa): свой сервис-воркер и включение уведомлений

Воркер теперь собирается через injectManifest: generateSW умеет только кэш, а
уведомление показывает именно воркер — страница при этом может быть закрыта.
Нажатие на уведомление переиспользует уже открытое окно, а не плодит второе.

Включение — строкой на главном экране, по нажатию. Не всплывающим окном на
входе: разрешение спрашивается один раз за установку, отказ браузер запоминает
навсегда, и потраченная впустую попытка закрывает канал. Если сервер уведомления
не настроил — разрешение не запрашиваем вовсе.

Отдельные тексты вместо молчания: на iPhone без установки на домашний экран
уведомления невозможны в принципе, и об этом сказано прямо.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 10:29:43 +10:00
co-authored by Claude Opus 5
parent 0a09570d98
commit 562a6d35fe
10 changed files with 345 additions and 5 deletions
+87
View File
@@ -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";
}