// Системные уведомления (ТЗ 3.21): подписка устройства на Web Push.
//
// Здесь только «браузерная» половина: разрешение, PushManager, регистрация
// подписки на сервере. Тексты уведомлений собирает сервер (у воркера нет
// локалей), а показ и подавление при видимой вкладке — src/sw.ts.
//
// Ключ VAPID приходит с сервера: без него PushManager.subscribe бросает
// исключение, и узнать об этом можно только здесь.
import { api, type PushConfig } from './api'
/** Состояние push на этом устройстве. */
export type PushState = 'unsupported' | 'off' | 'on' | 'denied' | 'unconfigured'
export function pushSupported(): boolean {
// serviceWorker — только в secure context: по http (кроме localhost) его нет
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
function base64ToBytes(base64: string): Uint8Array {
// applicationServerKey — сырая точка P-256, а не текст: base64url → байты
const padded = base64.replace(/-/g, '+').replace(/_/g, '/')
const raw = atob(padded.padEnd(padded.length + ((4 - (padded.length % 4)) % 4), '='))
return Uint8Array.from(raw, (char) => char.charCodeAt(0))
}
async function registration(): Promise<ServiceWorkerRegistration> {
// ready ждёт активации воркера: регистрация в dev может ещё идти
return navigator.serviceWorker.ready
}
/** Есть ли уже подписка этого устройства на сервере — по endpoint из PushManager. */
async function hasLocalSubscription(): Promise<boolean> {
const sub = await (await registration()).pushManager.getSubscription()
return sub !== null
}
export async function pushState(): Promise<PushState> {
if (!pushSupported()) return 'unsupported'
const config = await api.getPushConfig()
if (!config.enabled) return 'unconfigured'
if (Notification.permission === 'denied') return 'denied'
return (await hasLocalSubscription()) ? 'on' : 'off'
}
/**
* Разрешение → подписка в браузере → регистрация на сервере (общая часть обоих входов).
*
* Если разрешение уже выдано, `requestPermission` возвращается сразу и ничего не
* спрашивает; существующую подписку переиспользуем, поэтому повторный вызов —
* не ошибка, а лечение: строка на сервере могла пропасть, а браузерная — нет.
*/
async function subscribe(config: PushConfig): Promise<PushState> {
const permission = await Notification.requestPermission()
if (permission !== 'granted') return permission === 'denied' ? 'denied' : 'off'
const reg = await registration()
const existing = await reg.pushManager.getSubscription()
const sub =
existing ??
(await reg.pushManager.subscribe({
// userVisibleOnly обязателен: Chrome не принимает «тихие» подписки
userVisibleOnly: true,
applicationServerKey: base64ToBytes(config.public_key || ''),
}))
const json = sub.toJSON()
if (!json.endpoint || !json.keys?.p256dh || !json.keys.auth) return 'off'
await api.subscribePush(json.endpoint, { p256dh: json.keys.p256dh, auth: json.keys.auth })
return 'on'
}
/**
* Включить уведомления (переключатель в Настройках).
*
* Возвращает итоговое состояние, а не бросает: отказ в разрешении — обычный
* ответ пользователя, и интерфейс должен про него рассказать, а не упасть.
*/
export async function enablePush(): Promise<PushState> {
if (!pushSupported()) return 'unsupported'
const config = await api.getPushConfig()
if (!config.enabled || !config.public_key) return 'unconfigured'
return subscribe(config)
}
/** Выключить: отписка в браузере и снятие подписки на сервере. */
export async function disablePush(): Promise<PushState> {
if (!pushSupported()) return 'unsupported'
const reg = await registration()
const sub = await reg.pushManager.getSubscription()
if (sub) {
const endpoint = sub.endpoint
await sub.unsubscribe()
// Сначала браузер, потом сервер: если сервер не ответит, мёртвая подписка
// всё равно будет вычищена лениво при первой отправке (404/410)
await api.unsubscribePush(endpoint).catch(() => undefined)
}
return 'off'
}
/**
* Запрос разрешения при входе (ТЗ 3.21, п. 4).
*
* Браузеры требуют жеста пользователя, а возврат из SSO — это загрузка страницы
* без клика: Safari и Firefox молча проигнорируют запрос, Chrome может ответить
* «тихим» отказом. Поэтому состояние останется `default`, и надёжный путь —
* переключатель в Настройках, где клик есть всегда.
*/
export async function ensurePushOnLogin(): Promise<void> {
try {
if (!pushSupported() || Notification.permission === 'denied') return
const config = await api.getPushConfig()
if (!config.enabled || !config.public_key) return
await subscribe(config)
} catch {
// Push — не условие входа: молчание лучше, чем сломанная загрузка приложения
}
}