iOS: запрет масштабирования, версия в подвале, самообновление, строка ввода

Отзыв с iPhone после первого живого прогона.

Масштабирование запрещено (ADR-066): viewport как в соседнем sixlines плюс
viewport-fit=cover, мета-теги установленного приложения, touch-action против
двойного тапа и страховка на JS — гашение жестов iOS, потому что мета-строку
Safari вправе проигнорировать. Размеры шрифтов не тронуты: 14 px из брифа
остаются, поднимать поля до 16 px владелец отклонил. Цена — доступность
для тех, кому зум нужен — записана в ADR и в ui.md честно.

Версия в подвале (ADR-065, 067): GET /api/config отдаёт короткую ревизию
и время коммита. Именно коммита, а не сборки: штамп компиляции в бинаре
means каждая пересборка даёт новый хеш, и обещание ADR-022 «бинарь собирается
из тега байт в байт» перестаёт проверяться.

Самообновление PWA (ADR-068, 070): в режиме приложения на iPhone перезагрузки
нет, и старая версия жила бы вечно. Проверка при возврате в приложение,
включение ждущей оболочки, перезагрузка — но не тогда, когда в строке набран
текст. Замок от петли сравнивает версию оболочки, а не считает перезагрузки:
счётчик отменял саму фичу, долгоживущая вкладка переставала обновляться.
Замерено: было 30, 60, 120, 240 секунд между релизами, стало ровно 30.

Строка ввода — редактируемый блок (ADR-069): iOS рисует над клавиатурой
полосу со стрелками и «готово» для полей формы, и убрать её со страницы
нельзя. Для contenteditable не рисует. Режим plaintext-only с проверкой,
что он применился, и запасным путём с разбором вставки: невалидное значение
атрибута в части браузеров делает блок нередактируемым вовсе. Чтение
и запись только через textContent, innerHTML в клиенте по-прежнему нет.

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 15:11:13 +03:00
co-authored by Claude Opus 5
parent cfd0ec0ce9
commit 383e374f0f
31 changed files with 1462 additions and 79 deletions
+348 -2
View File
@@ -1,5 +1,5 @@
// PWA: service worker, подписка на пуши и установка приложения
// (ADR-011, ADR-023, ADR-046).
// PWA: service worker, самообновление оболочки, подписка на пуши
// и установка приложения (ADR-011, ADR-023, ADR-046, ADR-068).
//
// Экраны спрашивают отсюда состояние и сюда же отдают действия; в
// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает
@@ -17,6 +17,48 @@ 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.
@@ -40,6 +82,40 @@ const state = {
// показывает «запрещены в браузере» (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,
};
// Приглашение установки ловится с первой секунды: браузер показывает его
@@ -79,6 +155,276 @@ async function ready() {
}
}
// --- обновление ---------------------------------------------------------
// 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. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе,
// а обновление они бы запретили насовсем.
//
// Строка сообщения в чате — редактируемый блок, а не поле формы
// (ADR-069): value у него нет, набранное лежит в textContent. Считается
// и закрытый блок: ввод бывает заблокирован предупреждением о ключе,
// а недописанное в нём остаётся.
function typed() {
for (const node of document.querySelectorAll("input, textarea")) {
if (node.value === "") {
continue;
}
if (node.tagName === "TEXTAREA" || TYPED.has(node.type)) {
return true;
}
}
for (const node of document.querySelectorAll("[contenteditable]")) {
if (node.textContent !== "") {
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 вне