Files

733 lines
34 KiB
JavaScript
Raw Permalink 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, ADR-068).
//
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
// 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";
// Слово, по которому service worker включает установленную версию
// (ADR-068). Решение принимает страница, а не воркер.
const SKIP = "skip-waiting";
// Слово, которым страница спрашивает воркера, какую версию оболочки он
// держит. Этим версии и различаются: новый выпуск меняет её, а тот же файл
// воркера, отданный сервером заново, — нет (ADR-070).
const ASK = "version";
// Сколько ждать ответа о версии. Отвечают сразу — вопрос стоит одного
// сообщения; молчит воркер прежнего выпуска, который такого вопроса
// не знает, и тогда версия остаётся неизвестной.
const ASK_WAIT = 3 * 1000;
// Как часто спрашивать сервер об обновлении: при запуске и при каждом
// возвращении в приложение, но не чаще раза в минуту. Проверка — один
// условный запрос за /sw.js, и дёргать сеть на каждое переключение
// приложений незачем (ADR-068).
const CHECK_EVERY = 60 * 1000;
// Сколько ждать между двумя перезагрузками ради обновления в одной
// вкладке. Пауза постоянная и не растёт: петлю закрывает сверка версии
// оболочки (ADR-070), а это предел частоты — на случай сервера, который
// отдаёт разные версии на каждый запрос. Отметка живёт в sessionStorage:
// она переживает перезагрузку и умирает вместе с вкладкой.
const RELOAD_KEY = "bare-updated-at";
const RELOAD_APART = 30 * 1000;
// Как часто возвращаться к отложенному, пока в полях набранное. Событие
// input ловит набор, но опустеть поле умеет и молча: отправленное
// сообщение чистит строку присваиванием, а уход с экрана уносит её
// из документа вместе с текстом — событий не случается ни там, ни там.
// Обход полей раз в полминуты ничего не стоит и заводится только тогда,
// когда обновление уже ждёт (ADR-072).
const DRAFT_AGAIN = 30 * 1000;
// Поля, куда набирают текст. Проверяется то, что считается набранным,
// а не список исключений: у скрытого поля, переключателя и кнопки
// значение непусто по определению, и одно такое поле молча запретило бы
// обновление навсегда (ADR-068).
const TYPED = new Set(["text", "password", "search", "email", "url", "tel", "number"]);
// 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,
// Браузер отказал в самой подписке: приватное окно, политика,
// недоступный push-сервис. Кнопкой это не включить, поэтому раздел
// показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке:
// перезагрузка пробует снова — причина могла уйти.
refused: false,
// Обновление (ADR-068). controlled — управляет ли страницей service
// worker. У такой страницы смену контроллёра разбирает fresh: другая
// версия оболочки стоит перезагрузки, та же — нет (ADR-070). У первого
// захода контроллёр появляется и без обновления: его приход сменой
// не считается, но запоминается — дальше страница управляемая.
controlled: "serviceWorker" in navigator && navigator.serviceWorker.controller !== null,
// Новую версию попросили включиться мы сами. Тогда смена контроллёра —
// ответ на нашу просьбу, и перезагружаться надо, даже если при загрузке
// воркера у страницы ещё не было: в первый заход он ставится тут же,
// а обновление может приехать в тот же сеанс. Имя своё: у вопроса про
// уведомления рядом живёт свой флаг, и один на двоих означал бы, что
// обновление закрывает вопрос про уведомления, а вопрос — обновление.
requested: false,
// Оболочка сменилась, а перезагрузка отложена набранным текстом.
// Просить включения могла и соседняя вкладка с пустыми полями
// (ADR-035): контроллёр меняется у всех сразу, а платить за это
// черновиком этой вкладки не должен никто. Перезагрузимся, когда
// терять станет нечего.
changed: false,
// Перезагрузка началась: controllerchange приходит один раз, но цена
// ошибки — бесконечный цикл.
reloading: false,
// Новая версия установлена и ждёт включения. Ждать её заставляет
// набранный текст: перезагрузка унесла бы его.
waiting: null,
// Когда последний раз спрашивали сервер об обновлении.
checked: 0,
// Версия оболочки, код которой исполняет эта страница, — обещание
// ответа воркера. С ней сравнивается версия включившейся: совпали —
// перезагружаться незачем, оболочка та же (ADR-070).
shell: Promise.resolve(null),
// Отложенная до конца паузы попытка: другого повода вернуться к ней
// может и не быть.
timer: 0,
};
// Приглашение установки ловится с первой секунды: браузер показывает его
// сам и только раз. Предотвращённое событие оживает кнопкой в настройках.
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;
}
}
// --- обновление ---------------------------------------------------------
// watch включает самообновление: проверку при запуске и при возвращении
// в приложение, включение установленной версии и перезагрузку страницы
// (ADR-068). Установленное на «Домой» приложение обновить иначе нечем:
// ни строки адреса, ни кнопки перезагрузки в нём нет.
export function watch() {
if (!("serviceWorker" in navigator)) {
return;
}
// Версия оболочки, код которой исполняет эта страница. Спрашивается
// сразу: воркер, который её держит, скоро сменится, а сравнивать надо
// с тем, чей код уже загружен (ADR-070).
state.shell = ask(navigator.serviceWorker.controller);
// Контроллёр сменился — работает другая версия оболочки, а страница
// исполняет прежнюю. Перезагрузка одна за жизнь страницы.
//
// Первая установка её не считается: там контроллёр появляется впервые
// (clients.claim), а страница и так свежая. Эту смену замок съедает
// и запоминает — вместе с версией, которую новый контроллёр держит:
// она и есть версия загруженной оболочки.
//
// Перезагружаемся не отсюда: включить новую версию могла соседняя
// вкладка, у которой поля пусты, а у этой в них набранное. Отмечаем
// и ждём — apply перезагрузит, когда терять станет нечего.
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (state.reloading) {
return;
}
if (!state.requested && !state.controlled) {
state.controlled = true;
state.shell = ask(navigator.serviceWorker.controller);
return;
}
fresh();
});
// Возвращение в приложение — единственный момент, когда о нём вспоминают
// на телефоне: вкладка не закрывается неделями. Событие приходит
// документу, ему и слушаем.
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
check();
}
});
// Набранное стёрли — терять стало нечего, и отложенное обновление
// можно включать.
addEventListener("input", () => apply(), true);
register().then((registration) => {
if (registration === null) {
return;
}
registration.addEventListener("updatefound", () => track(registration.installing));
// Версия, установившаяся в прошлый заход и не дождавшаяся своей
// очереди: вкладку закрыли раньше.
track(registration.waiting);
check();
});
}
// check спрашивает сервер, нет ли новой версии. Сети нет — молча:
// офлайн не сбой, спросим при следующем возвращении (ADR-068).
async function check() {
if (Date.now() - state.checked < CHECK_EVERY) {
// Спрашивать рано, но отложенное обновление могло дождаться своего
// часа: набранное стёрли или отправили.
apply();
return;
}
state.checked = Date.now();
const registration = await register();
if (registration === null) {
return;
}
try {
await registration.update();
} catch {
// Сервер молчит или сети нет.
}
// Ждущая версия берётся и отсюда, а не только из updatefound: событие
// случается один раз, а включить её могло не выйти — страница была
// занята набранным текстом или сообщение до воркера не дошло.
track(registration.waiting);
apply();
}
// track следит за устанавливающейся версией: как только она встала
// и ждёт, её можно включать.
function track(worker) {
if (!worker || worker === state.waiting) {
return;
}
const look = () => {
if (worker.state === "installed") {
state.waiting = worker;
apply();
}
};
worker.addEventListener("statechange", look);
look();
}
// fresh разбирает смену контроллёра: включилась другая версия оболочки
// или тот же файл воркера, отданный сервером заново. Перезагрузка нужна
// только в первом случае: во втором она вернула бы ту же самую страницу,
// и следующий запрос за sw.js начал бы всё сначала — это и есть петля
// (ADR-070).
//
// Неизвестная версия считается другой: молчит воркер прежнего выпуска,
// и обновление до выпуска, который отвечает, важнее. Повторяться этому
// не с чего — после перезагрузки отвечают оба.
async function fresh() {
const [was, now] = await Promise.all([state.shell, ask(navigator.serviceWorker.controller)]);
if (state.reloading) {
return;
}
if (was !== null && now !== null && was === now) {
return;
}
state.changed = true;
apply();
}
// apply двигает обновление вперёд — если сейчас есть чем платить.
// Набранное откладывает и просьбу включиться, и перезагрузку: и то,
// и другое кончается новой оболочкой, а она уносит поля (ADR-068).
//
// Первая установка до просьбы не доходит: страницей ещё никто
// не управляет, включать нечего, а воркер и так активируется сам.
// Через «installed» он при этом проходит — без проверки контроллёра
// первый же запуск перезагружал бы себя сам.
function apply() {
if (state.reloading) {
return;
}
if (typed()) {
// Набранное откладывает обновление, а поводов вернуться к нему мало:
// событие input случается при наборе, но не при отправке сообщения
// и не при уходе с экрана — там поле пустеет и пропадает молча.
// Поэтому ждём ещё и по таймеру (ADR-072).
if (state.changed || state.waiting !== null) {
later(DRAFT_AGAIN);
}
return;
}
if (state.changed) {
// Оболочка уже сменилась — просить больше нечего, осталось
// перезагрузиться. Слишком часто подряд не перезагружаемся:
// пауза кончится, и apply вернётся сюда сам.
const wait = pause();
if (wait > 0) {
later(wait);
return;
}
state.reloading = true;
markReload();
location.reload();
return;
}
const worker = state.waiting;
if (worker === null || navigator.serviceWorker.controller === null) {
return;
}
state.waiting = null;
state.requested = true;
worker.postMessage(SKIP);
}
// typed — есть ли на экране набранное. Проверка общая на весь документ,
// а не на строку ввода: пароль в форме входа теряется от перезагрузки
// так же, как черновик сообщения. Пустое поле под курсором ничего
// не стоит: оно и после перезагрузки пустое.
//
// Набранным считается только то, куда набирают: textarea и поля из
// TYPED. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе,
// а обновление они бы запретили насовсем.
function typed() {
for (const node of document.querySelectorAll("input, textarea")) {
if (node.value === "") {
continue;
}
if (node.tagName === "TEXTAREA" || TYPED.has(node.type)) {
return true;
}
}
return false;
}
// ask спрашивает у воркера версию оболочки, которую он держит. Ответ идёт
// своим каналом: вопросов бывает два подряд, а перепутать ответы нельзя.
//
// Молчание — не сбой: так отвечает воркер прежнего выпуска, который такого
// вопроса не знает. Тогда версия неизвестна (null), и решение принимается
// в пользу обновления (ADR-070).
function ask(worker) {
return new Promise((done) => {
if (!worker) {
done(null);
return;
}
const channel = new MessageChannel();
const timer = setTimeout(() => {
channel.port1.close();
done(null);
}, ASK_WAIT);
channel.port1.onmessage = (event) => {
clearTimeout(timer);
channel.port1.close();
done(typeof event.data === "string" && event.data !== "" ? event.data : null);
};
try {
worker.postMessage(ASK, [channel.port2]);
} catch {
clearTimeout(timer);
done(null);
}
});
}
// pause — сколько ещё ждать до перезагрузки ради обновления. Петлю она
// не закрывает — это делает сверка версии оболочки (ADR-070); здесь только
// предел частоты на случай сервера, который отдаёт разные версии на каждый
// запрос. Отметки нет — значит, и повода ждать нет.
function pause() {
const at = Number(session(RELOAD_KEY));
if (!Number.isFinite(at) || at <= 0) {
return 0;
}
const left = RELOAD_APART - (Date.now() - at);
return left > 0 ? Math.min(left, RELOAD_APART) : 0;
}
// later возвращается к отложенному сам: другого повода может и не быть —
// вкладка открыта, обновление ждёт, а ждать его заставляет то пауза между
// перезагрузками (ADR-070), то набранное в полях (ADR-072).
function later(ms) {
if (state.timer !== 0) {
return;
}
state.timer = setTimeout(() => {
state.timer = 0;
apply();
}, ms);
}
function markReload() {
try {
sessionStorage.setItem(RELOAD_KEY, String(Date.now()));
} catch {
// Хранилище закрыто политикой браузера: обойдёмся без страховки.
}
}
function session(key) {
try {
return sessionStorage.getItem(key);
} 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" || state.refused) {
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-сервис
// подпишет заново.
//
// Отказ самой подписки — не сбой сервера, а браузер, который её не даёт:
// приватное окно, политика, недоступный push-сервис. Кнопкой это
// не включить, поэтому false, а раздел настроек скажет «запрещены
// в браузере» и уберёт кнопку (ADR-046).
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) {
try {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: unb64url(key),
});
} catch {
state.refused = true;
return false;
}
}
state.refused = false;
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(() => {});
}