Files
bare/web/js/pwa.js
T
mayatnikovandClaude Opus 5 8f67f4aa4d Этап 4: PWA и пуши — service worker, Web Push с VAPID, баннер установки
Сервер: пакет push на webpush-go (третья и последняя прямая зависимость),
подписка устройства, правило ADR-023 «пуш только молчащему устройству и только
один» через атомарный захват push_pending, удаление подписки на 404 и 410,
TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело
собирается из константы, плейнтекст туда не попадает даже по ошибке.

Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*,
push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения
после первого отправленного сообщения, разделы настроек «уведомления»
и «установить приложение», баннер установки на iOS.

ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему
устройству отправителя с бессмысленным заголовком из собственного ника.
ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка
«только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь
запрет редиректов и проверка разрешённого адреса на уровне сокета.
ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше
не съедает пуши всего сервера.
ADR-049: явно выключенные уведомления сами не включаются обратно.

Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка
уходила всем устройствам сообщения — гонка, пойманная go test -race.
Теперь у каждого задания своя копия.

Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка
из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов
и endpoint на 3 КиБ — все отбиты.

Чеклист ручной проверки на iPhone и Android — в docs/plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 00:50:58 +03:00

381 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PWA: service worker, подписка на пуши и установка приложения
// (ADR-011, ADR-023, ADR-046).
//
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает
// один модуль.
//
// Пуш — сигнал: он говорит, что для устройства что-то есть, а содержимое
// приезжает очередью при подключении (ADR-011). Поэтому здесь нет ни
// сообщений, ни ключей — только подписка и разрешение.
import * as api from "./api.js";
import { NetworkError } from "./api.js";
import * as db from "./db.js";
import { b64url, unb64url } from "./crypto.js";
import { deviceId } from "./sync.js";
const WORKER = "/sw.js";
// iOS: пуши работают только у приложения, установленного на экран «Домой»
// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad
// и navigator.standalone !== true.
const IOS = /iPhone|iPad/;
const state = {
// Регистрация service worker: одна на страницу, ждут её все.
registering: null,
// beforeinstallprompt приходит один раз и ждёт кнопки в настройках
// (docs/ui.md, «Настройки»).
prompt: null,
// Разрешение спрашивается один раз за всю историю устройства
// (docs/ui.md, «Уведомления»); флаг в памяти закрывает вкладку от
// повторного вопроса, флаг в meta — устройство.
asked: false,
// Вопрос идёт прямо сейчас: два сообщения подряд не должны дать
// два запроса разрешения.
asking: false,
};
// Приглашение установки ловится с первой секунды: браузер показывает его
// сам и только раз. Предотвращённое событие оживает кнопкой в настройках.
addEventListener("beforeinstallprompt", (event) => {
event.preventDefault();
state.prompt = event;
});
// --- service worker -----------------------------------------------------
// register ставит service worker. Отдаёт регистрацию или null: браузер
// без service worker — это просто клиент без кэша оболочки и пушей,
// а не сломанный клиент.
export function register() {
if (!("serviceWorker" in navigator)) {
return Promise.resolve(null);
}
if (state.registering === null) {
state.registering = navigator.serviceWorker.register(WORKER).catch(() => null);
}
return state.registering;
}
// ready — регистрация с работающим service worker: подписка ставится
// только на неё. navigator.serviceWorker.ready ждёт вечно, если
// регистрации нет, — поэтому сначала register.
async function ready() {
const registration = await register();
if (registration === null) {
return null;
}
try {
return await navigator.serviceWorker.ready;
} catch {
return null;
}
}
// --- уведомления --------------------------------------------------------
// supported — есть ли в браузере то, из чего складывается пуш. iOS вне
// установленного приложения сюда не проходит: там нет ни Notification,
// ни PushManager.
function supported() {
return "serviceWorker" in navigator
&& "PushManager" in self
&& "Notification" in self;
}
// notifications — состояние раздела «уведомления» (docs/ui.md):
// "on" — «включены», "off" — «выключены», "denied" — «запрещены
// в браузере». В "denied" сходится всё, чего кнопкой не включить:
// отклонённое разрешение, браузер без уведомлений, сервер без
// VAPID-ключа (ADR-046).
export async function notifications(key) {
if (!supported() || !key || Notification.permission === "denied") {
return "denied";
}
if (await turnedOff()) {
return "off";
}
const subscription = await current();
if (subscription === null) {
return "off";
}
// Подписка под прежней парой VAPID-ключей не работает и не починится
// сама: push-сервис отвечает на неё 403, а это не 404 и не 410, и
// сервер её не снимет. Для человека это «выключены», а кнопка
// «включить» подпишет заново под текущим ключом (ADR-049).
return sameKey(subscription, key) ? "on" : "off";
}
// turnedOff — уведомления выключены кнопкой в настройках. Явный отказ
// сильнее любой оставшейся подписки: сама она больше не включается
// (ADR-049).
async function turnedOff() {
try {
return (await db.meta(["notificationsOff"])).notificationsOff === true;
} catch {
return false;
}
}
// enable — «включить». Разрешение спрашивается по нажатию, подписка
// ставится после granted (docs/ui.md, «Уведомления»). Отдаёт новое
// состояние.
export async function enable(key) {
if (!supported() || !key) {
return "denied";
}
let permission;
try {
permission = await Notification.requestPermission();
} catch {
return "denied";
}
if (permission !== "granted") {
return permission === "denied" ? "denied" : "off";
}
// Человек решил всё сам: отказа больше нет, и спрашивать после первого
// сообщения не о чем (ADR-049).
state.asked = true;
await db.putMeta({ notificationsAsked: true, notificationsOff: false }).catch(() => {});
return (await attach(key)) ? "on" : "denied";
}
// disable — «выключить». Подписка снимается у push-сервиса и у сервера:
// первое действует сразу, второе убирает мёртвую строку из базы.
//
// Отказ запоминается раньше всего остального: без него первое же
// отправленное сообщение вернуло бы подписку через askOnce, а запуск
// приложения — через refresh (ADR-049).
export async function disable() {
await db.putMeta({ notificationsOff: true }).catch(() => {});
const subscription = await current();
if (subscription !== null) {
try {
await subscription.unsubscribe();
} catch {
// Отписаться не дали: сервер уберёт подписку по 404/410 (ADR-011).
}
}
const device = deviceId();
if (device) {
await api.clearPush(device);
}
}
// askOnce — запрос разрешения после первого успешно отправленного
// сообщения за всю историю устройства, один раз (ADR-011, docs/ui.md,
// «Уведомления»). Отказ — молча: включить можно в настройках.
//
// На iOS вне установленного приложения вопроса нет вовсе: там вместо
// него баннер установки (ADR-023), а флаг не ставится — установят,
// спросим после следующего сообщения.
export async function askOnce(key) {
if (state.asked || state.asking) {
return;
}
state.asking = true;
try {
let meta;
try {
meta = await db.meta(["notificationsAsked", "notificationsOff"]);
} catch {
return;
}
// Выключенные в настройках уведомления сами не включаются: вопрос
// закрыт человеком, а не нами (ADR-049).
if (meta.notificationsOff === true) {
return;
}
if (meta.notificationsAsked === true) {
state.asked = true;
return;
}
if (iosBrowser() || !supported() || !key) {
return;
}
// Спрашивать нечего: разрешение уже дано или уже отклонено. Данное —
// повод поставить подписку, если её нет.
if (Notification.permission !== "default") {
await remember();
if (Notification.permission === "granted") {
await attach(key).catch(() => {});
}
return;
}
let permission;
try {
permission = await Notification.requestPermission();
} catch {
// Браузер требует нажатия, а между отправкой и ответом сервера оно
// истекло. Вопроса не было — значит, «один раз» ещё не потрачено:
// попробуем после следующего сообщения.
return;
}
await remember();
if (permission === "granted") {
await attach(key).catch(() => {});
}
} finally {
state.asking = false;
}
}
// remember — вопрос задан, второй раз не спрашиваем ни в этой вкладке,
// ни на этом устройстве (docs/ui.md, «Уведомления»).
async function remember() {
state.asked = true;
await db.putMeta({ notificationsAsked: true }).catch(() => {});
}
// refresh переставляет подписку на текущее устройство. Подписка живёт
// в браузерном профиле, а принадлежит устройству (ADR-023): deviceId
// меняется при конфликте идентификаторов и после чистки IndexedDB,
// и запуск приложения это чинит (ADR-046).
//
// Выключенные уведомления запуск не включает, а подписку под прежним
// ключом сервера не переставляет: она всё равно не работает, и место ей
// не в базе, а в кнопке «включить» (ADR-049).
export async function refresh(key) {
if (await turnedOff()) {
return;
}
const subscription = await current();
if (subscription === null) {
return;
}
if (key && !sameKey(subscription, key)) {
return;
}
try {
await put(subscription);
} catch {
// Не переставили — переставим при следующем запуске.
}
}
// detach снимает подписку у push-сервиса при выходе из аккаунта
// и при его удалении: подписка принадлежит устройству, а устройство —
// аккаунту (ADR-046). Сервер не спрашиваем: сессии к этому моменту
// уже нет, а мёртвую подписку он уберёт сам по 404/410.
export async function detach() {
const subscription = await current();
if (subscription === null) {
return;
}
try {
await subscription.unsubscribe();
} catch {
// Не отписались — пуши всё равно некуда доставлять: истории на
// устройстве больше нет.
}
}
// current — подписка этого браузера или null.
async function current() {
const registration = await ready();
if (registration === null || !registration.pushManager) {
return null;
}
try {
return await registration.pushManager.getSubscription();
} catch {
return null;
}
}
// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён
// в подписку: сменился ключ — прежняя подписка не годится, push-сервис
// подпишет заново.
async function attach(key) {
const registration = await ready();
if (registration === null || !registration.pushManager) {
return false;
}
let subscription = await registration.pushManager.getSubscription();
if (subscription !== null && !sameKey(subscription, key)) {
try {
await subscription.unsubscribe();
} catch {
// Старая подписка останется у push-сервиса; сервер её не знает.
}
subscription = null;
}
if (subscription === null) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: unb64url(key),
});
}
await put(subscription);
return true;
}
// put отдаёт подписку серверу. Без устройства запрос невозможен:
// подписка принадлежит устройству, а его заводит подключение
// (ADR-017). Это то же состояние, что и не дошедший запрос.
async function put(subscription) {
const device = deviceId();
if (!device) {
throw new NetworkError();
}
await api.setPush(device, subscription.toJSON());
}
// sameKey — та ли пара VAPID-ключей, под которую выдана подписка.
function sameKey(subscription, key) {
const applied = subscription.options?.applicationServerKey;
if (!applied) {
return false;
}
try {
return b64url(new Uint8Array(applied)) === key;
} catch {
return false;
}
}
// --- установка ----------------------------------------------------------
// iosBrowser — iPhone или iPad вне установленного приложения. Ровно этот
// признак показывает баннер установки (docs/ui.md).
export function iosBrowser() {
return IOS.test(navigator.userAgent) && navigator.standalone !== true;
}
// installable — поймано ли приглашение установки.
export function installable() {
return state.prompt !== null;
}
// install показывает приглашение установки. Оно одноразовое: показали —
// кнопки больше нет.
export async function install() {
const prompt = state.prompt;
if (prompt === null) {
return;
}
state.prompt = null;
try {
await prompt.prompt();
await prompt.userChoice;
} catch {
// Приглашение протухло: браузер покажет своё, когда сочтёт нужным.
}
}
// bannerHidden — баннер установки уже закрывали (docs/ui.md).
export async function bannerHidden() {
try {
return (await db.meta(["installBannerDismissed"])).installBannerDismissed === true;
} catch {
return true;
}
}
// hideBanner — крестик: повтор не показывается.
export async function hideBanner() {
await db.putMeta({ installBannerDismissed: true }).catch(() => {});
}