Этап 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
This commit is contained in:
2026-08-23 00:50:58 +03:00
co-authored by Claude Opus 5
parent 05586218e1
commit 8f67f4aa4d
42 changed files with 3047 additions and 92 deletions
+52
View File
@@ -473,6 +473,58 @@ input[type="password"] {
color: var(--mute);
}
/* уведомления и установка — docs/ui.md, «Настройки» */
.state {
margin: 0 0 12px;
font-size: 13px;
color: var(--text2);
}
.install {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--mute);
}
/* баннер установки — docs/ui.md, «Баннер установки (iOS)».
Цель нажатия у крестика — 44 px, отрицательные поля не дают ей
растянуть сам баннер */
.banner-slot {
flex: none;
}
.banner {
display: flex;
align-items: flex-start;
padding: 12px 20px;
border-bottom: 1px solid var(--line);
}
.banner__text {
margin: 0;
font-size: 11px;
line-height: 1.5;
color: var(--mute);
}
.banner__close {
flex: none;
width: 44px;
height: 44px;
margin: -12px -14px -12px auto;
padding: 0;
border: 0;
background: none;
color: var(--stone);
font: inherit;
font-size: 15px;
line-height: 1;
cursor: pointer;
}
/* участники комнаты — docs/ui.md, «Участники» */
.members {
+11
View File
@@ -165,6 +165,17 @@ export function removeDevice(id) {
return request("DELETE", `/api/devices/${encodeURIComponent(id)}`);
}
// setPush и clearPush — push-подписка устройства (ADR-023). Подписка
// принадлежит устройству, поэтому устройство идёт и в пути, и в заголовке:
// чужому подписку не поставить (docs/protocol.md, «Устройства»).
export function setPush(device, subscription) {
return request("PUT", `/api/devices/${encodeURIComponent(device)}/push`, { subscription }, { device });
}
export function clearPush(device) {
return request("DELETE", `/api/devices/${encodeURIComponent(device)}/push`, undefined, { device });
}
// --- контакты ----------------------------------------------------------
export function contacts() {
+56 -6
View File
@@ -5,7 +5,9 @@
// и отдаётся экранам через ctx.
import * as api from "./api.js";
import { NetworkError } from "./api.js";
import * as db from "./db.js";
import * as pwa from "./pwa.js";
import * as sync from "./sync.js";
import {
deriveAccountKeys,
@@ -183,12 +185,20 @@ async function ensureConfig() {
// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи
// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт.
//
// Запрос, который не дошёл, — это «нет соединения», а не «мы не вошли»
// (docs/ui.md, «Сеть и состояния»): офлайн-старт установленного
// приложения поднимается из кэша с ключами и историей устройства,
// а полосу «нет соединения» рисует sync. Если сессии и правда нет,
// первый дошедший запрос ответит 401 unauthenticated и уведёт на вход.
async function restore() {
let who;
let who = null;
try {
who = await api.me();
} catch {
return null;
} catch (err) {
if (!(err instanceof NetworkError)) {
return null;
}
}
let meta;
try {
@@ -196,7 +206,10 @@ async function restore() {
} catch {
return null;
}
if (!meta.privateKey || meta.nick !== who.nick) {
if (!meta.privateKey || !meta.nick) {
return null;
}
if (who !== null && meta.nick !== who.nick) {
return null;
}
return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint };
@@ -320,8 +333,12 @@ async function adopt(nick, priv, secret) {
// connect поднимает поток событий и синхронизацию. Отказы разбирает сам
// sync: экран входа их уже не касается.
//
// Подписка на пуши переставляется на текущее устройство сразу после
// того, как оно завелось: она живёт в браузерном профиле и про смену
// deviceId сама не узнаёт (ADR-046).
function connect() {
sync.start().catch(() => {});
sync.start().then(() => pwa.refresh(state.config?.vapidPublicKey)).catch(() => {});
}
// raise — автоматическое повышение итераций сразу после входа, молча
@@ -380,6 +397,11 @@ async function deleteAccount(password) {
}
async function signOut() {
// Подписка снимается на сервере, пока сессия ещё жива: устройство
// остаётся у аккаунта, и живая строка в базе слала бы пуши прежнего
// аккаунта человеку, который вошёл на этом устройстве под другим
// (ADR-046).
await dropPush();
try {
await api.dropSession();
} catch {
@@ -388,10 +410,29 @@ async function signOut() {
await forget();
}
// dropPush снимает подписку у сервера. Отказ ничего не меняет: 401
// означает, что сессии и так нет, а всё прочее чинится отпиской
// у push-сервиса и правилом 404/410 (ADR-011).
async function dropPush() {
const device = sync.deviceId();
if (!device) {
return;
}
try {
await api.clearPush(device);
} catch {
// Не сняли — снимет push-сервис и правило мёртвых подписок.
}
}
// forget уносит историю: она на этом устройстве единственная копия
// (docs/storage.md, docs/ui.md).
// (docs/storage.md, docs/ui.md). Подписка на пуши уходит вместе с ней:
// она принадлежала устройству этого аккаунта (ADR-046).
async function forget() {
sync.stop();
// Подписка снимается своим ходом: выход ждёт стирания истории,
// а не push-сервиса.
pwa.detach().catch(() => {});
await db.destroy();
state.me = null;
}
@@ -407,6 +448,15 @@ function errorText(err) {
async function boot() {
db.persist();
// Service worker ставится с первой секунды: кэш оболочки нужен и до
// входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает.
pwa.register();
// Первое успешно отправленное сообщение за всю историю устройства —
// единственный повод спросить разрешение на уведомления (ADR-011);
// «один раз» считает pwa.js.
sync.onSent(() => {
pwa.askOnce(state.config?.vapidPublicKey).catch(() => {});
});
// Обработчик ставится раньше первого запроса: 401 unauthenticated
// на любом из них — на экран входа, IndexedDB цела.
api.onSessionExpired(() => {
+380
View File
@@ -0,0 +1,380 @@
// 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(() => {});
}
+18
View File
@@ -338,6 +338,17 @@ export function deviceId() {
return state.device;
}
// onSent ставит обработчик успешной отправки: по первой из них клиент
// один раз просит разрешение на уведомления (docs/ui.md, «Уведомления»).
// «Первой за всю историю устройства» это делает не здесь: транспорт
// не знает ни про разрешения, ни про то, о чём уже спрашивали. Ставит
// обработчик main.js.
let sentHandler = () => {};
export function onSent(handler) {
sentHandler = handler;
}
// --- устройство ---------------------------------------------------------
// ensureDevice — deviceId устройства: 16 случайных байт base64url,
@@ -1518,6 +1529,13 @@ async function post(message, peer, roomId) {
const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts };
await db.saveMessages({ messages: [sent], me: state.nick });
notify([sent]);
// Сообщение ушло: обработчик решает, спрашивать ли разрешение
// на уведомления. Отправку он не задерживает и сорвать не может.
try {
sentHandler();
} catch {
// Дело обработчика; отправка состоялась.
}
return null;
} catch (err) {
// Ответ с кодом — то же доказательство, что запрос дошёл, что и 202:
+5
View File
@@ -12,6 +12,11 @@ export function wide() {
return matchMedia(DESKTOP).matches;
}
// INSTALL_IOS — текст про установку на iOS. Он один и тот же в баннере
// над списком чатов и в настройках (docs/ui.md), поэтому и живёт в одном
// месте.
export const INSTALL_IOS = "уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»";
export function el(tag, className, text) {
const node = document.createElement(tag);
if (className) {
+110 -4
View File
@@ -1,15 +1,30 @@
// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы,
// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта.
// Уведомления, устройства, история и установка приложения — дальше по плану.
// которые уже работают: кто ты, уведомления, установка приложения, смена
// пароля, выход, удаление аккаунта. Устройства и история — дальше по плану.
import { ApiError } from "../api.js";
import { fingerprintGroups } from "../crypto.js";
import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js";
import * as pwa from "../pwa.js";
import { INSTALL_IOS, button, confirmPanel, el, field, message, setError, setNote } from "./dom.js";
// Состояния уведомлений и инструкция установки — docs/ui.md, «Настройки».
const NOTIFICATIONS = {
on: "включены",
off: "выключены",
denied: "запрещены в браузере",
};
const INSTALL_HINT = "поделиться → на экран «домой»";
export function renderSettings(root, ctx) {
root.append(head(ctx));
const body = el("div", "body settings");
body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
body.append(identity(ctx), notificationsBlock(ctx));
const install = installBlock();
if (install !== null) {
body.append(install);
}
body.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx));
root.append(body);
}
@@ -31,6 +46,97 @@ function identity(ctx) {
return box;
}
// notificationsBlock — «уведомления»: состояние и одна кнопка
// (docs/ui.md, «Настройки»). Состояний три; «запрещены в браузере» —
// это и отклонённое разрешение, и браузер без уведомлений, и сервер без
// VAPID-ключа: включать нечем, кнопки нет (ADR-046).
function notificationsBlock(ctx) {
const box = block("уведомления");
const status = el("p", "state", "");
// На iOS вне установленного приложения кнопки нет: там пуши работают
// только у приложения на экране «Домой» (ADR-011).
const ios = pwa.iosBrowser();
const action = button("включить");
action.hidden = true;
const note = message();
if (ios) {
box.append(status, el("p", "install", INSTALL_IOS), note);
} else {
box.append(status, action, note);
}
let mode = "denied";
const paint = async () => {
if (ios) {
status.textContent = NOTIFICATIONS.off;
return;
}
mode = await pwa.notifications(await vapidKey(ctx));
status.textContent = NOTIFICATIONS[mode];
action.textContent = mode === "on" ? "выключить" : "включить";
action.hidden = mode === "denied";
};
action.addEventListener("click", async () => {
if (action.disabled) {
return;
}
action.disabled = true;
setNote(note, "");
try {
if (mode === "on") {
await pwa.disable();
} else {
await pwa.enable(await vapidKey(ctx));
}
} catch (err) {
setError(note, ctx.errorText(err));
} finally {
action.disabled = false;
await paint();
}
});
paint();
return box;
}
// vapidKey — публичный ключ сервера для подписки (docs/protocol.md,
// «Публичные»). Конфигурации нет — подписаться нечем.
async function vapidKey(ctx) {
try {
return (await ctx.ensureConfig()).vapidPublicKey ?? "";
} catch {
return "";
}
}
// installBlock — «установить приложение» (docs/ui.md, «Настройки»).
// Кнопка есть, если поймано beforeinstallprompt; на iOS вместо неё
// инструкция. Устанавливать нечего — раздела нет.
function installBlock() {
const ios = pwa.iosBrowser();
if (!ios && !pwa.installable()) {
return null;
}
const box = block("установить приложение");
if (ios) {
box.append(el("p", "install", INSTALL_HINT));
return box;
}
const action = button("установить");
action.addEventListener("click", () => {
// Приглашение одноразовое: показали — устанавливать этим разделом
// больше нечего, и раздела нет (docs/ui.md, «Настройки»). Раздел
// уходит сразу: дальше человек отвечает браузеру, а не нам, и ждать
// его ответа кнопке незачем.
pwa.install();
box.remove();
});
box.append(action);
return box;
}
function passwordBlock(ctx) {
const box = block("сменить пароль");
const form = el("form", "form");
+27 -1
View File
@@ -1,7 +1,8 @@
// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас»
// и «Список чатов».
import { el, mark } from "./dom.js";
import * as pwa from "../pwa.js";
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
import { mount } from "./chats.js";
// frame отдаёт корень, место под экран и отписку списка чатов.
@@ -23,6 +24,12 @@ function side(ctx, active) {
brand.append(mark(), el("span", null, "bare"));
nav.append(brand);
// Баннер установки — над списком чатов (docs/ui.md, «Баннер установки»).
// Место под него занимается сразу, содержимое приезжает из meta.
const place = el("div", "banner-slot");
nav.append(place);
banner(place);
const list = el("div", "list");
const add = el("button", "item item--new", "+ новый чат");
add.type = "button";
@@ -41,3 +48,22 @@ function side(ctx, active) {
return { nav, dispose: mount(items, ctx, active) };
}
// banner — баннер установки на iOS: пуши там работают только
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
async function banner(place) {
if (!pwa.iosBrowser() || await pwa.bannerHidden()) {
return;
}
const box = el("div", "banner");
box.append(el("p", "banner__text", INSTALL_IOS));
const close = el("button", "banner__close", "×");
close.type = "button";
close.setAttribute("aria-label", "закрыть");
close.addEventListener("click", () => {
clear(place);
pwa.hideBanner();
});
box.append(close);
place.append(box);
}
+210 -5
View File
@@ -1,8 +1,213 @@
// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023).
// версия кэша меняется при релизе.
// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023).
//
// Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом —
// проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы,
// ни ошибки — это чужая почта, а не оболочка.
//
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
const CACHE = "bare-v1";
const VERSION = "v2";
const CACHE = `bare-${VERSION}`;
self.addEventListener("install", () => {});
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
// у Cache API нет масок, а угадывать нечего — файлов немного и они
// перечислены в docs/plan.md (ADR-001).
const SHELL = [
"/",
"/app.css",
"/manifest.json",
"/js/api.js",
"/js/crypto.js",
"/js/db.js",
"/js/main.js",
"/js/pwa.js",
"/js/sync.js",
"/js/ulid.js",
"/js/ui/auth.js",
"/js/ui/chat.js",
"/js/ui/chats.js",
"/js/ui/contact.js",
"/js/ui/dom.js",
"/js/ui/members.js",
"/js/ui/new.js",
"/js/ui/settings.js",
"/js/ui/shell.js",
"/icons/icon.svg",
"/icons/mark.svg",
"/icons/icon-180.png",
"/icons/icon-192.png",
"/icons/icon-512.png",
];
self.addEventListener("activate", () => {});
// Иконка уведомления — знак из /icons/ (ADR-023).
const ICON = "/icons/icon-192.png";
// Идентификатор чата из нагрузки пуша (ADR-023) и маршруты клиента
// (docs/ui.md, «Каркас») — разные формы одного и того же; перевод
// одной в другую — ADR-046.
const DM = /^dm:([a-z0-9_]{2,32})$/;
const ROOM = /^room:([A-Za-z0-9_-]{22})$/;
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});
// activate уносит кэши прежних версий: имя кэша содержит версию, и всё,
// что названо иначе, — прошлый релиз. clients.claim берёт под контроль
// уже открытую страницу: без этого первый запуск остался бы без кэша
// и без перехода по уведомлению.
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
for (const name of await caches.keys()) {
if (name !== CACHE) {
await caches.delete(name);
}
}
await self.clients.claim();
})());
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin || !shell(url.pathname)) {
return;
}
event.respondWith(revalidate(event, request, url.origin + url.pathname + url.search));
});
// shell — что относится к оболочке. Всё прочее идёт в сеть мимо кэша:
// /api/ — потому что это данные (ADR-023), /sw.js — потому что его
// обновление ведёт браузер, /healthz — потому что он про сервер.
function shell(path) {
return path === "/"
|| path === "/app.css"
|| path === "/manifest.json"
|| path.startsWith("/js/")
|| path.startsWith("/icons/");
}
// revalidate — stale-while-revalidate. Ответ из кэша уходит сразу, запрос
// к серверу идёт своим ходом и обновляет кэш. Сервер отдаёт статику
// с ETag и Cache-Control: no-cache (docs/protocol.md), поэтому обычный
// fetch — это условный запрос: неизменившийся файл стоит одного 304.
//
// Ключ кэша — адрес без фрагмента: у навигационного запроса в url лежит
// маршрут (`/#/dm/marta`), и по самому запросу запись `/` подменялась бы
// адресом последней перезагрузки. Сопоставление фрагмент и так
// игнорирует, а вот caches.keys() должен говорить правду о том, что
// лежит в оболочке (ADR-001).
function revalidate(event, request, key) {
return caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(key);
const network = fetch(request).then((response) => {
// Кладём только цельный свой ответ: чужие и частичные в оболочке
// не бывают.
if (response.ok && response.type === "basic") {
cache.put(key, response.clone());
}
return response;
});
if (cached) {
// Проверка переживёт ответ: без waitUntil браузер вправе усыпить
// service worker сразу после отдачи страницы.
event.waitUntil(network.catch(() => {}));
return cached;
}
return network;
});
}
// push → уведомление. Содержимого сообщения в нагрузке нет и быть
// не может: сервер его не знает (ADR-011). tag — идентификатор чата:
// новое уведомление заменяет старое в том же чате (ADR-023).
self.addEventListener("push", (event) => {
const data = payload(event);
if (data === null) {
return;
}
event.waitUntil(self.registration.showNotification(data.title, {
body: data.body,
tag: data.chat,
data: { chat: data.chat },
icon: ICON,
lang: "ru",
}));
});
// payload разбирает нагрузку пуша: {title, body, chat} (ADR-023).
// Чужого здесь не бывает — пуш подписан ключом сервера, — но показывать
// неразобранное всё равно нечем.
function payload(event) {
let data = null;
try {
data = event.data ? event.data.json() : null;
} catch {
return null;
}
if (data === null || typeof data !== "object") {
return null;
}
const { title, body, chat } = data;
if (typeof title !== "string" || typeof body !== "string" || typeof chat !== "string") {
return null;
}
if (title === "" || body === "" || chat === "") {
return null;
}
return { title, body, chat };
}
// notificationclick — фокус уже открытого окна с переходом на нужный чат
// либо открытие нового (ADR-023).
self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(open(route(event.notification.data?.chat)));
});
// route переводит идентификатор чата в маршрут клиента (ADR-046).
// Идентификатор не той формы открывает список.
function route(chat) {
if (typeof chat === "string") {
const dm = DM.exec(chat);
if (dm) {
return `/#/dm/${dm[1]}`;
}
const room = ROOM.exec(chat);
if (room) {
return `/#/room/${room[1]}`;
}
}
return "/#/";
}
async function open(path) {
const target = new URL(path, self.location.origin);
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
if (new URL(client.url).origin !== target.origin) {
continue;
}
try {
await client.focus();
} catch {
// Поднять окно не дали. Чат всё равно откроем: человек вернётся
// в приложение сам и увидит его открытым там, где нужно.
}
try {
// Маршрут живёт в hash: переход к нему — не перезагрузка,
// а событие hashchange, которое разбирает роутер клиента.
if (new URL(client.url).hash !== target.hash && typeof client.navigate === "function") {
await client.navigate(target.href);
}
} catch {
// Окно не наше или им уже распоряжаются: останется, где было.
}
return;
}
await self.clients.openWindow(target.href);
}