// 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(() => {}); }