Этап 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:
+52
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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(() => {});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user