`, не знает ни `placeholder`, ни `maxLength`, ни `disabled`, а `innerHTML` в этом клиенте запрещён (ADR-001, CSP): сообщения — пользовательские данные.
+
+## Решение
+
+- Блоком становится только строка сообщения в чате. Вход, регистрация, смена пароля и «новый чат» остаются на настоящих `input`: там нужен `type="password"` для менеджеров паролей, а полоса на разовом экране не мешает.
+- Блок открывается значением `plaintext-only`: в нём браузер кладёт внутрь только текст, чем бы ни был буфер обмена. Значение не назначается, а проверяется чтением — незнакомое значение атрибута делает блок нередактируемым вовсе, то есть строка ввода перестала бы работать молча. Не применилось — остаётся `true`, и тогда вставку, перетаскивание и перенос строки разбирает сам клиент: текст берётся из `text/plain` и вписывается узлом текста.
+- Значение читается и пишется только `textContent`. `innerHTML` не появляется ни здесь, ни где-либо ещё.
+- Подсказка — правило CSS `:empty::before` с `content: attr(...)`; текст кладётся атрибутом `data-*`. Опустевший блок очищается от `
`, который оставляет в нём браузер: с лишним узлом `:empty` не срабатывает и подсказки не видно.
+- Клавиши прежние: на десктопе Enter отправляет, Shift+Enter переносит; на мобильном Enter переносит, отправляет кнопка «>». `enterkeyhint` — `enter`: подпись клавиши обещает то, что клавиша делает, а `send` обещал бы отправку, которой по нажатию не будет.
+- `autocapitalize="sentences"` и `autocorrect="on"`: сообщение — обычная речь, и заглавная в начале предложения с исправлением опечаток тут к месту. В форме входа и в «новом чате» они, наоборот, выключены — там ник и пароль.
+- Предел 4000 символов и счётчик после 3500 держит сам блок: длина считается по `textContent`, лишнее обрезается у того, что приходит в блок, — так же, как считал `maxLength` у `textarea` (ADR-071).
+- Заблокированный ввод (предупреждение о ключе, уход из комнаты) перестаёт быть редактируемым и говорит об этом `aria-disabled`: `disabled` у блока нет.
+- Доступность: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка; цель нажатия на мобильном не меньше 44 px; фокус при открытии чата на десктопе — как был.
+- `web/js/zoom.js`: блок добавлен в перечень элементов управления. Гашение второго тапа уносит с собой `click`, а с ним и курсор — второй тап подряд не ставил бы курсор в строку ввода.
+- `web/js/pwa.js`: проверка «есть ли черновик» (ADR-068) смотрит и в редактируемый блок — `value` у него нет, набранное лежит в `textContent`. Считается и заблокированный блок: недописанное в нём остаётся.
+
+## Следствия
+
+- Полоса помощника форм в чате исчезает. На экранах входа и «нового чата» она остаётся — там она и не мешает.
+- Проверить это можно только на iPhone: Chrome такой полосы не рисует вовсе, и никакой прогон её не увидит.
+- Строка ввода перестала быть элементом формы. Отправку это не меняет: Enter и раньше обрабатывал клиент, а кнопка «>» отправляет по `click` — `type="button"` и обработчик из [ADR-064](064-composer-not-form.md).
+- В запасном пути (браузер без `plaintext-only`) отмена набранного (cmd+z) не помнит нашей вставки: она делается руками, а не `execCommand` — тот в Chrome разбирает перенос строки в `
`, чего в блоке быть не должно. Там же держится `
`-заполнитель последней строки: перенос в самом конце браузер не рисует, и без заполнителя курсор оставался бы на прежней строке.
+- Автозаполнение и менеджеры паролей блока не касаются — в чате им нечего заполнять.
+- Записано в `docs/ui.md`, «Чат» и «Доступность».
diff --git a/docs/decisions/070-update-compares-shell-version.md b/docs/decisions/070-update-compares-shell-version.md
new file mode 100644
index 0000000..19bc59e
--- /dev/null
+++ b/docs/decisions/070-update-compares-shell-version.md
@@ -0,0 +1,29 @@
+# ADR-070: Обновление сверяется с версией оболочки, а не со счётчиком перезагрузок
+
+Заменяет второй замок из [ADR-068](068-pwa-self-update.md): растущая пауза уходит, различение петли и релиза остаётся.
+
+## Контекст
+
+ADR-068 закрыл петлю перезагрузок двумя замками. Второй — пауза, которая удваивается с каждой перезагрузкой ради обновления: 30 секунд, минута, две, четыре. Считает её счётчик `bare-updates` в `sessionStorage`; он только растёт и обнуляется лишь смертью вкладки.
+
+Счётчик не различает петлю и обычный релиз. Каждая законная перезагрузка удваивает паузу перед следующей, и вкладка, которая живёт долго, обновляться перестаёт: после десятого релиза ждать четыре часа, после двенадцатого — сутки. Долгоживущая вкладка — это ровно установленное на «Домой» приложение, которое не закрывают неделями, то есть тот самый случай, ради которого ADR-068 и написан. Защита от петли отменяла фичу, которую защищала.
+
+Различие между петлёй и релизом есть, и оно не в числе перезагрузок: **при петле версия оболочки после перезагрузки не меняется, при релизе меняется**. Сервер, отдающий новый файл воркера на каждый запрос, отдаёт ту же оболочку — версия в `sw.js` пишется руками при релизе (`docs/deploy.md`), и сама собой она не меняется.
+
+## Решение
+
+- `sw.js` отвечает на сообщение `version` своей константой `VERSION`. Ответ уходит каналом вопроса (`MessageChannel`): вопросов бывает два подряд и к разным воркерам.
+- Страница спрашивает версию у своего контроллёра при запуске. Это версия оболочки, код которой она исполняет: модули пришли из кэша именно этого воркера.
+- Смена контроллёра ведёт к перезагрузке, только если версия включившейся оболочки другая. Та же версия означает, что сервер отдал другой файл воркера при неизменной оболочке: перезагрузка вернула бы ту же самую страницу, и следующий запрос за `sw.js` начал бы всё сначала. Петля обрывается здесь и целиком — перезагрузок в ней не случается вовсе.
+- Версия, которой не назвали (воркер прежнего выпуска такого вопроса не знает, ответа нет за три секунды), считается другой: обновление до выпуска, который отвечает, важнее. Повторяться этому не с чего — после перезагрузки отвечают оба.
+- Первая установка запоминает версию нового контроллёра: страница пришла из сети, и оболочка, которую она исполняет, — та, что только что встала.
+- Пауза между перезагрузками остаётся, но постоянная — 30 секунд, и `bare-updates` уходит. Замком от петли она больше не служит; это предел частоты на случай сервера, который отдаёт разные версии на каждый запрос. Отметка времени `bare-updated-at` остаётся в `sessionStorage`.
+- Отложенная паузой перезагрузка возвращается сама, по таймеру: другого повода может и не быть — поля пусты, вкладка открыта, а вкладка спрашивает сервер об обновлении только при запуске и при возвращении в приложение (ADR-068).
+
+## Следствия
+
+- Десять релизов подряд за жизнь одной вкладки доезжают все и через одну и ту же паузу: замер — 29–30 секунд на релиз. Прежний замок на тех же релизах давал 30, 60, 120, 240 секунд и дальше вдвое.
+- Сервер, отдающий новый `sw.js` на каждый запрос при неизменной версии, перезагрузок не вызывает ни одной. Воркер он при этом меняет — это дело браузера, и стоит оно одной установки в минуту, не чаще: чаще страница сервер не спрашивает.
+- Релиз без смены `VERSION` страницу не перезагружает. Это не новое ограничение: ADR-023 обещал такому релизу только обновление статики по ETag.
+- В `sw.js` появился второй вопрос от страницы. Протокола (`docs/protocol.md`) это не касается: разговор идёт внутри браузера.
+- `docs/storage.md`: у клиента остаётся один ключ в `sessionStorage` вместо двух.
diff --git a/docs/decisions/071-input-limit-cuts-what-arrives.md b/docs/decisions/071-input-limit-cuts-what-arrives.md
new file mode 100644
index 0000000..56ee04d
--- /dev/null
+++ b/docs/decisions/071-input-limit-cuts-what-arrives.md
@@ -0,0 +1,35 @@
+# ADR-071: Предел строки сообщения держится до вставки
+
+Уточняет [ADR-069](069-message-input-is-editable-block.md): предел 4000 символов держит по-прежнему сам блок, но режет он приходящее, а не набранное.
+
+## Контекст
+
+Предел проверялся после вставки: блок переписывался значением, обрезанным по пределу. Резался при этом хвост всего содержимого, а не то, что только что пришло. Замер: в блоке 3950 «A», курсор на позиции 10, вставка 200 символов из буфера — в блоке 4000 символов, из них 3800 «A»: полтораста набранных символов исчезли. Отмена их не вернула: присваивание `textContent` сносит стек отмены браузера. `maxLength` у прежней `textarea` вёл себя обратно — резал вставляемое и не трогал ни одного уже набранного символа.
+
+Тем же переписыванием блока стояли рядом ещё три расхождения с прежним полем:
+
+- обрезка шла по единицам UTF-16 и разрывала суррогатную пару. 3999 «q» и набранное «😀» оставляли в блоке одинокий старший суррогат, и он доезжал собеседнику. `maxLength` эмодзи на границе просто не принимал.
+- проверка срабатывала во время композиции IME. У предела композиция обрывалась на полуслове, а подтверждённый ввод не попадал в блок вовсе.
+- курсор уезжал в конец на каждом нажатии. Править середину полного сообщения было нельзя; `maxLength` лишний символ не пускал и курсора не трогал.
+
+И два счёта мимо: заполнитель последней строки, который Chrome держит в `plaintext-only` вторым `\n` в самом конце, попадал и в счётчик, и в предел — перенос в конце стоил двух символов из четырёх тысяч. А в запасном пути (браузер без `plaintext-only`) перетаскивание текста внутри самого блока раздваивало его: вставку там делаем мы, отменяя действие браузера, и уносить исходное браузеру после этого нечем.
+
+## Решение
+
+- Предел держится до вставки, а не после. `beforeinput` знает и что придёт (`data`, для буфера и мыши — `dataTransfer`), и что заменится (`getTargetRanges`). Влезающее вставляет браузер сам — тогда и отмена остаётся его. Не влезающее отменяется и вписывается обрезанным по свободному месту.
+- Свободное место — предел минус то, что останется от набранного, когда заменяемое уйдёт. Набранное не режется никогда: сверх предела не проходит только приходящее.
+- Резать по границе суррогатной пары нельзя: не влезла старшая половина — не вставляется и она. Эмодзи, которому не хватило одного символа, не попадает в блок вовсе — так же, как не пускал его `maxLength`.
+- Композиция IME не трогается ничем: ни проверкой до вставки, ни обрезкой после. Лишнее снимается один раз, на `compositionend`; пока идёт композиция, счётчик стоит на месте.
+- Обрезка после вставки остаётся последней страховкой — для того, что приехало мимо `beforeinput`. Она снимает ровно хвост сверх предела правкой текстовых узлов, а не переписывает блок: курсор остаётся там, где стоял, стек отмены цел.
+- Считается и режется то, что уедет собеседнику: заполнитель последней строки в счёт не идёт. В `plaintext-only` это лишний `\n` в самом конце — в `textContent` он виден, а в сообщении его нет, его снимает `trim` при отправке. В запасном пути заполнитель — `
`, которого в `textContent` нет вовсе, и перенос в конце там настоящий.
+- Перетаскивание изнутри блока в запасном пути объявляется копированием (`effectAllowed = "copy"`). Переноса при отменённом действии по умолчанию не выйдет, и молчаливое удвоение становится честной копией.
+
+## Следствия
+
+- Вставка в середину длинного сообщения больше не стирает набранное, и отмена работает везде, где вставку делает браузер.
+- Отмена по-прежнему не помнит нашу вставку там, где её делаем мы: при переполнении и в запасном пути (ADR-069). Терять там уже нечего — часть вставленного и так не влезла.
+- Одинокого суррогата в сообщении не бывает.
+- Счётчик у порога говорит правду: перенос в конце стоит одного символа, а не двух.
+- Курсор на пределе остаётся на месте: полное сообщение можно править с середины.
+- Перетаскивание изнутри блока прогоном не проверяется: самодельный `DataTransfer` присвоения не принимает, а настоящее перетаскивание браузер без окна из страницы не начинает. Проверка — руками, в браузере без `plaintext-only`.
+- Записано в `docs/ui.md`, «Чат».
diff --git a/docs/decisions/072-deferred-update-returns-by-timer.md b/docs/decisions/072-deferred-update-returns-by-timer.md
new file mode 100644
index 0000000..11e9895
--- /dev/null
+++ b/docs/decisions/072-deferred-update-returns-by-timer.md
@@ -0,0 +1,24 @@
+# ADR-072: Отложенное обновление возвращается по таймеру
+
+Уточняет [ADR-068](068-pwa-self-update.md): поводов вернуться к отложенному было два, и оба мимо самого частого случая.
+
+## Контекст
+
+Обновление ждёт, пока в полях вкладки набранное: перезагрузка унесла бы его. Вернуться к отложенному могли только событие `input` и возвращение в приложение.
+
+Строка ввода пустеет и без того, и без другого. Отправка сообщения чистит блок присваиванием — события `input` это не порождает. Экран, с которого ушли, выбывает из документа вместе со своим полем — тем более.
+
+Замер: во вкладке с черновиком соседняя вкладка включила новый выпуск, контроллёр сменился, перезагрузка отложена. Enter отправляет сообщение, поле пусто, вкладка на виду — за полторы минуты перезагрузки нет, оболочка прежняя. Один цикл смены видимости — перезагрузка через две секунды. То есть после самого обычного действия, «написал и отправил», приложение остаётся на старой оболочке до следующего возвращения в него, хотя платить за обновление уже нечем. Ради этого случая ADR-068 и написан.
+
+## Решение
+
+- Отложенное набранным возвращается само, по таймеру: полминуты, тем же таймером, которым возвращается отложенное паузой между перезагрузками (ADR-070). Поля пусты — обновление идёт дальше; набранное на месте — таймер заводится снова.
+- Таймер заводится только тогда, когда обновление уже ждёт: оболочка сменилась или установленная версия ждёт включения. Иначе проверка полей крутилась бы вхолостую всю жизнь вкладки.
+- Событие `input` остаётся: опустевшее поле оно замечает сразу, а таймер — не позже чем через полминуты.
+
+## Следствия
+
+- Отправленное сообщение больше не держит вкладку на старой оболочке: обновление доезжает в течение полуминуты после того, как поле опустело.
+- Уход с экрана — то же самое: поле пропадает вместе с экраном, и заметить это иначе нечем.
+- Пока обновление ждёт, а в полях набранное, вкладка раз в полминуты обходит свои поля. Это дешевле, чем протокол между экранами и обновлением.
+- Записано в `docs/ui.md`, «Сеть и состояния».
diff --git a/docs/decisions/073-push-failure-reason-in-log.md b/docs/decisions/073-push-failure-reason-in-log.md
new file mode 100644
index 0000000..caf639c
--- /dev/null
+++ b/docs/decisions/073-push-failure-reason-in-log.md
@@ -0,0 +1,24 @@
+# ADR-073: Код причины отказа push-сервиса в журнале
+
+Уточняет [ADR-047](047-push-endpoint.md).
+
+## Контекст
+
+ADR-047 убрал из журнала адрес подписки: отказ отправки пишется классом, текст ошибки транспорта не печатается вовсе. Под то же правило попал и ответ push-сервиса — в журнал уходил один статус: `пуш: push-сервис ответил 403`.
+
+Цена выяснилась на работающем сервере. APNs отвечал `403` на каждый пуш, потому что в VAPID-токен уезжал `sub = "mailto:mailto:admin@xmatic.team"`: webpush-go приписывает `mailto:` всему, что не начинается с `https:`, а в окружении субъект записан правильным URI. Сутки не уходило ни одного пуша, и по журналу это выглядело как «что-то с пушами»: просроченный ключ, чужой субъект, лимит вендора и мёртвая подписка дают один и тот же `403`. Причина лежала в теле ответа: `{"reason":"BadJwtToken"}`.
+
+Код причины от push-сервиса — диагностика вендора, а не данные пользователя. Но тело ответа приходит снаружи, и класть его в журнал целиком нельзя: в нём может оказаться адрес подписки.
+
+## Решение
+
+- В журнал уходит статус и, если он разобран, короткий код причины: `пуш: push-сервис ответил 403 (BadJwtToken)`.
+- Читается не больше 200 байт тела; остаток дочитывается ради переиспользования соединения. Код берётся из полей JSON `reason`, `error`, `message` — в этом порядке — либо из тела целиком, если оно само короткая строка.
+- В журнал идёт только первая строка не длиннее 64 символов из латиницы, цифр, `_`, `-` и пробелов. Точка, `:`, `/` и `@` встречаются в адресах подписки и именах хостов, поэтому строка с ними отбрасывается целиком и остаётся один статус.
+- Правило ADR-047 про транспорт не меняется: ошибка соединения по-прежнему сводится к классу, её текст не печатается, адреса подписки в журнале нет.
+
+## Следствия
+
+- Отказ push-сервиса читается по журналу и чинится по нему же.
+- Часть вендорских кодов не разберётся: FCM отвечает вложенным объектом, а коды с точкой в имени не проходят алфавит. Тогда в журнале остаётся статус, как раньше. Осознанно: молчание безопаснее догадок о чужом теле.
+- Ответы `404` и `410` пишутся не строкой, а снятием подписки — так было и остаётся (ADR-011).
diff --git a/docs/decisions/074-version-and-commit-time.md b/docs/decisions/074-version-and-commit-time.md
new file mode 100644
index 0000000..30440ee
--- /dev/null
+++ b/docs/decisions/074-version-and-commit-time.md
@@ -0,0 +1,29 @@
+# ADR-074: Версия — короткая ревизия, «время сборки» — время коммита
+
+Уточняет [ADR-022](022-deploy-nginx-systemd.md) (подлинность бинаря проверяется сверкой хеша со сборкой из тега) и [ADR-057](057-version-marks-dirty-tree.md) (`+dirty` у сборки из изменённого дерева).
+
+Доведён до интерфейса [ADR-067](067-version-in-sidebar-foot.md): версия и время коммита стоят в подвале сайдбара.
+
+## Контекст
+
+Интерфейс должен называть версию, которая сейчас работает: иначе разговор о поломке начинается с угадывания, обновилось ли то, что человек видит.
+
+Ревизия у сервера уже есть — `revision()` в `cmd/bare` читает `vcs.revision` и `vcs.modified` (ADR-057), — но живёт в `main` и снаружи недоступна. `internal/api` до неё не дотягивается.
+
+Второй вопрос — «время сборки». Естественный ответ, штамп момента компиляции через `-ldflags -X`, ломает ADR-022: артефакт деплоя — один файл, и его подлинность проверяется тем, что сборка из тега даёт тот же хеш. Со временем компиляции внутри каждая пересборка одного и того же коммита даёт другой файл, и сверка перестаёт что-либо значить. Рядом с ревизией лежит `vcs.time` — время коммита, приходящее из git вместе с ней. Оно воспроизводимо и отвечает на настоящий вопрос: какой код работает, а не когда его компилировали.
+
+## Решение
+
+- Чтение build info переезжает в `internal/build`: полная ревизия, признак изменённого дерева, время коммита. `cmd/bare` и `internal/api` берут версию оттуда — двух форматов у одной величины не заводится.
+- Версия — семь символов ревизии. У сборки из изменённого дерева к ним дописывается `+dirty` (ADR-057), без ревизии вовсе — `unknown`.
+- «Время сборки» — это `vcs.time`, время коммита. Штампа момента компиляции в бинаре нет и не появится.
+- `GET /api/config` отдаёт `version` (строка) и `commitAt` (миллисекунды Unix, `0` если времени нет). Отдельного `/api/version` нет: конфигурацию клиент читает до входа и так, а второй эндпоинт ради двух полей — лишний маршрут. В других ответах версии тоже нет.
+- `bare version` печатает то же самое: короткую ревизию и время коммита в UTC одной строкой.
+
+## Следствия
+
+- Сверка «хеш файла на сервере против сборки из тега» остаётся проверяемой: в бинаре не осталось ничего, что менялось бы от пересборки.
+- `bare version` больше не печатает полный хеш. Семи символов хватает и человеку, и `git show`; `docs/deploy.md` говорит то же.
+- Время в интерфейсе отстаёт от момента деплоя ровно настолько, насколько деплой отстал от коммита. Это и спрашивают.
+- Сборка не из git — `go run`, `go build -buildvcs=false`, тестовый бинарь — показывает `unknown` и `0`. Тесты видят именно её: `vcs.*` тестовому бинарю не проставляются, и это состояние проверяется ими прямо.
+- Клиенту остаётся показать два поля; где именно и какими словами — `docs/ui.md`.
diff --git a/docs/decisions/075-no-zoom-app-feel.md b/docs/decisions/075-no-zoom-app-feel.md
new file mode 100644
index 0000000..ccdc7d8
--- /dev/null
+++ b/docs/decisions/075-no-zoom-app-feel.md
@@ -0,0 +1,32 @@
+# ADR-075: Масштабирования нет, размеры шрифтов прежние
+
+Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться.
+
+Уточнён [ADR-069](069-message-input-is-editable-block.md): строка сообщения стала редактируемым блоком, и она в перечне элементов управления, по которым второй тап не гасится.
+
+## Контекст
+
+На iPhone интерфейс жил в постоянных микрозумах. Тап по строке ввода приближал страницу: iOS Safari обязан приблизить поле мельче 16 px и обратно масштаб не возвращает. Двойной тап приближал её же, щипок — тем более. Ощущения приложения не было: страница ездила под пальцами.
+
+Лечится это двумя способами, и они друг друга исключают. Первый — вырастить поля до 16 px: причина фокус-зума уходит, масштаб остаётся человеку. Второй — запретить масштабирование целиком. Владелец выбрал второй и отклонил первый прямо: «интерфейс должен быть как в нативном приложении, это нужно запретить», «с 16 шрифтом всё станет огромное».
+
+Образец рядом — sixlines.ru той же команды: там `maximum-scale=1, user-scalable=no`, и на устройствах владельца страница не масштабируется.
+
+## Решение
+
+- Масштабирование запрещено, включая щипок. `viewport` — `width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover`.
+- Размеры шрифтов не меняются: поля ввода остаются 14 px, остальное — как в `docs/identity/brief.md`. 16 px на полях владельцем отклонены.
+- Установленное приложение объявляется двумя мета-строками сразу: `mobile-web-app-capable` и его исторический двойник `apple-mobile-web-app-capable` — iOS до сих пор смотрит на второй. Имя на экране «Домой» — `apple-mobile-web-app-title` со значением `bare`.
+- `apple-mobile-web-app-status-bar-style` — `default`. Тема одна и светлая (ADR-024): `black-translucent` пустил бы страницу под системную полосу и написал бы её часы и значки белым по bone, то есть по светлому. `default` оставляет полосу над страницей, тёмными знаками по светлому фону.
+- `touch-action: manipulation` на `html`: снимает зум по двойному тапу и задержку 300 мс, которую браузер держит, ожидая второго касания. Двойной тап — жест документа, поэтому правило на корне действует на всю страницу.
+- Страховка на JS — `web/js/zoom.js`: мета-строке Safari верен не всегда. Слушатели непассивные, иначе `preventDefault` не действует. `gesturestart`, `gesturechange`, `gestureend` — события щипка, они бывают только в Safari — гасятся целиком. Второй тап подряд, ближе 350 мс и 40 px к первому, гасится, если он не по элементу управления: `preventDefault` на `touchend` уносит с собой `click`, а нажать кнопку дважды подряд — обычное дело. Прокрутка, свайпы и одиночные нажатия не трогаются вовсе.
+- `overscroll-behavior: none` у страницы и `contain` у лент: резинового отскока и «потянуть для обновления» нет, прокрутка внутри лент прежняя.
+- Выделение текста снято с шапки, сайдбара, кнопок и разделителей: долгое нажатие по ним показывало лупу и «копировать», а копировать там нечего. С текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара (ADR-067) не снято — их копируют; версию на iPhone взять больше неоткуда, там нет ни строки адреса, ни консоли.
+- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Верхняя при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
+
+## Следствия
+
+- Человек, которому нужно увеличить мелкое, средства лишается. Это сознательная цена ощущения приложения, и платят её все. Записана в `docs/ui.md`, «Доступность», рядом с контрастом и целями нажатия — чтобы её видели, а не находили.
+- Safari вправе не послушаться: `user-scalable=no` он игнорирует с десятой версии, и `maximum-scale` однажды может пойти тем же путём. Тогда вернётся и фокус-зум на поле в 14 px — лечить его 16-м шрифтом отклонено, а другого лекарства нет.
+- Двойной тап по кнопке не гасится страховкой: масштаба он и так не даёт, а второе нажатие важнее.
+- Клиент вырос на один модуль — `web/js/zoom.js`. Он в оболочке service worker и в раскладке `docs/plan.md`.
diff --git a/docs/deploy.md b/docs/deploy.md
index 16b7331..ba67f25 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -8,7 +8,7 @@
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bare ./cmd/bare
```
-Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`; у сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057). `/healthz` отвечает только `ok`.
+Версия бинаря — `vcs.revision` и `vcs.time` из `debug.ReadBuildInfo()`. `bare version` печатает семь символов ревизии и время коммита в UTC: `cfd0ec0 2026-08-23T04:54:16Z`. У сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057); у сборки не из git печатается `unknown`. Времени компиляции в бинаре нет: оно делало бы каждую пересборку одного коммита новым файлом (ADR-074). Те же значения отдаёт `GET /api/config` полями `version` и `commitAt`. `/healthz` отвечает только `ok`.
## Первичная настройка сервера (один раз)
@@ -35,6 +35,8 @@ BARE_INVITE_CODE=<пусто или код>
`bare vapid` печатает пару ключей; выполняется локально один раз, результат вписывается в файл.
+`BARE_VAPID_SUBJECT` — URI по RFC 8292: `mailto:<адрес>` или `https://<хост>`. Форма проверяется при старте: с пустым при заданных ключах или с голым адресом без схемы сервер не поднимается. Отдавать субъект библиотеке приходится без схемы `mailto:` — она приписывает её сама, и готовый URI превратился бы в `mailto:mailto:…`, который push-сервис отвергает; нормализация живёт в `internal/push`, запись в этом файле верна и не меняется.
+
`/etc/systemd/system/bare.service`:
```ini
@@ -119,7 +121,7 @@ sudo systemctl daemon-reload && sudo systemctl enable --now bare
## Обновление — `scripts/deploy.sh`
-Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag.
+Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Смена версии делает файл воркера другим, и установленные приложения обновляются сами: клиент замечает новую оболочку при запуске или при возвращении в приложение, включает её и перезагружает страницу (ADR-068). Без смены версии новая статика доедет только по ETag, вторым открытием, — и во вкладке, и в установленном приложении одинаково: обработчик `fetch` у них один. Но воркер и предзагруженный список оболочки останутся прежними, а самообновление не запустится вовсе.
```sh
#!/bin/sh
@@ -136,6 +138,7 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
- `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff.
- `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации.
- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории.
+- `curl -s https://bare.xmatic.team/api/config` — `version` равен `git rev-parse --short=7 HEAD` задеплоенного коммита и без `+dirty`. Длина задана явно: сервер режет ревизию ровно до семи символов, а `--short` без числа берёт её из `core.abbrev` и растит по мере роста репозитория.
- `journalctl -u bare -f` — старт, применённые миграции, нет ошибок.
## Бэкап
@@ -144,6 +147,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
## Логи
-Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
+Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). Ответ push-сервиса пишется статусом и коротким кодом причины из тела: «пуш: push-сервис ответил 403 (BadJwtToken)». Код — диагностика вендора; всё, что на короткий код не похоже, отбрасывается целиком, и остаётся один статус (ADR-073). journald хранит по своим правилам.
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.
diff --git a/docs/identity/brief.md b/docs/identity/brief.md
index 7f1fc74..40d8a22 100644
--- a/docs/identity/brief.md
+++ b/docs/identity/brief.md
@@ -37,10 +37,12 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos
Размеры: текст сообщений 14 px / 1.55; автор и время 12 px; заголовки секций 10 px, разрядка 0.14em, uppercase; подписи 11 px; имя чата в шапке 15 px. Шрифты не загружаются.
+Поля ввода — те же 14 px, исключений нет: масштабирование в клиенте запрещено, и растить шрифт ради обхода фокус-зума iOS не приходится (ADR-075).
+
## Компоновка
- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px.
-- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px.
+- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят (ADR-075).
- Сообщения — поток на обеих ширинах, автор и время над текстом; свои — у правого края ленты, чужие — у левого, ширина блока не больше `min(640px, 85%)` (ADR-065).
- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева.
- Активный элемент списка — инверсия: фон ink, текст bone.
diff --git a/docs/open-questions.md b/docs/open-questions.md
index 9c28610..ace6161 100644
--- a/docs/open-questions.md
+++ b/docs/open-questions.md
@@ -5,4 +5,4 @@
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
-Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 — панель над клавиатурой в строке ввода, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец».
+Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — полоса помощника форм над клавиатурой iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…072 и ADR-073…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей.
diff --git a/docs/plan.md b/docs/plan.md
index bc73ee3..9a45012 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -18,6 +18,7 @@
```
embed.go //go:embed web в корне модуля (ADR-025)
cmd/bare/main.go подкоманды: serve, vapid, version
+internal/build/ ревизия и время коммита из build info (ADR-074)
internal/config/ переменные BARE_*
internal/store/ SQLite, migrations/*.sql (embed), запросы
internal/auth/ argon2id, сессии, cookie
@@ -33,8 +34,9 @@ web/
js/crypto.js всё из crypto.md
js/db.js IndexedDB из storage.md
js/sync.js устройство, поток событий, приём и отправка
- js/pwa.js service worker, подписка на пуши, установка
+ js/pwa.js service worker, самообновление (ADR-068), пуши, установка
js/ulid.js ULID
+ js/zoom.js запрет масштабирования (ADR-075)
js/ui/*.js экраны из ui.md
js/export.js .bare
scripts/deploy.sh
diff --git a/docs/protocol.md b/docs/protocol.md
index 95652a1..305912e 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -42,7 +42,9 @@ WrappedKey { to: nick, iv: string, ct: string }
## Публичные
-`GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}`
+`GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000, version: string, commitAt: number}`
+
+`version` — короткая ревизия сборки: семь символов хеша коммита, с суффиксом `+dirty` у бинаря из изменённого дерева (ADR-057) и `unknown` у сборки не из git. `commitAt` — время коммита в миллисекундах Unix, `0` если оно неизвестно. Это время коммита, а не момент компиляции: штамп времени сборки лишил бы смысла сверку хеша бинаря со сборкой из тега (ADR-022, ADR-074). Те же значения печатает `bare version`. Отдельного эндпоинта у них нет.
`GET /api/kdf?nick=
` → `200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. Скрытием существования ника ответ не занимается: у аккаунта, не входившего после повышения цели, число итераций своё (ADR-062), а сам факт, что ник существует, публичен (ADR-019).
diff --git a/docs/storage.md b/docs/storage.md
index cee9154..f99fe10 100644
--- a/docs/storage.md
+++ b/docs/storage.md
@@ -147,6 +147,7 @@ peers key: nick
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
- При старте: `navigator.storage.persist()`; в настройках — `storage.estimate()`.
+- Кроме IndexedDB клиент держит один ключ в `sessionStorage` — `bare-updated-at`, время последней перезагрузки ради обновления оболочки. Он живёт не дольше вкладки и задаёт предел частоты: между двумя такими перезагрузками не меньше 30 секунд. Петлю закрывает не он, а сверка версии оболочки (ADR-070).
## Экспорт `.bare`
diff --git a/docs/ui.md b/docs/ui.md
index f0019d2..61ead22 100644
--- a/docs/ui.md
+++ b/docs/ui.md
@@ -8,6 +8,8 @@
Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные.
+Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone.
+
## Вход и регистрация
Одна страница, два режима переключателем «вход / регистрация». Логотип-знак и `bare` сверху.
@@ -24,6 +26,10 @@
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка `+ новый чат`.
+В подвале рядом с «ты: @nick» — версия и время коммита из `GET /api/config`: `версия · дд.мм чч:мм`, время в местной зоне, цветом `stone`, размером подписи. Не кнопка, но выделяется: её копируют. Времени нет — остаётся версия; конфигурации ещё нет — строки нет. В сайдбаре 224 px рядом они не помещаются: версия занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило места, обрезается многоточием (ADR-067).
+
+Версия — серверная. Оболочка в браузере бывает старше: она приезжает из кэша, а обновление применяется отдельно и ждёт пустых полей (ADR-068).
+
## Новый чат (`#/new`)
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
@@ -36,7 +42,9 @@
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
-Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
+Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; лишнее обрезается и при наборе, и при вставке: режется приходящее, набранное остаётся на месте (ADR-071).
+
+Строка сообщения — редактируемый блок, а не поле формы: над клавиатурой iOS рисует полосу помощника форм, и бывает она только у `input` и `textarea` (ADR-069). Блок принимает и отдаёт только текст: вставка приходит текстом, значение читается и пишется `textContent`, `innerHTML` не появляется. Подсказку рисует правило CSS для пустого блока. Растёт под содержимое, дальше семи строк — прокрутка. Заблокированный ввод перестаёт быть редактируемым. Остальные строки ввода — вход, регистрация, смена пароля, «новый чат», участники — остаются полями формы: там нужны менеджеры паролей, а полоса на разовом экране не мешает.
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
@@ -85,6 +93,8 @@
- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035).
- Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`.
- `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут».
+- Приложение обновляется само: клиент спрашивает сервер о новой оболочке при запуске и при возвращении в приложение, не чаще раза в минуту, включает установленную версию и перезагружает страницу. Перезагрузка вкладки ждёт, пока пусты все её поля и строка сообщения: набранное она бы унесла. Опустевшее поле она замечает и там, где о нём не сказало ни одно событие — отправленное сообщение, уход с экрана: к отложенному вкладка возвращается по таймеру (ADR-072). Вкладок бывает несколько (ADR-035), и каждая ждёт своих полей: соседняя может включить новую оболочку раньше, но перезагрузиться под набранным текстом не заставит. Без сети проверка проходит молча (ADR-068).
+- Перезагружается вкладка только тогда, когда версия оболочки и правда сменилась. Тот же файл воркера, отданный сервером заново, её не трогает: перезагрузка вернула бы ту же страницу. Между перезагрузками — не меньше 30 секунд; сколько бы релизов ни вышло за жизнь вкладки, доезжают все (ADR-070).
- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет.
## Тексты состояний
@@ -107,3 +117,7 @@
## Доступность
Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px.
+
+Строка сообщения — редактируемый блок (ADR-069), и семантику ему задают руками: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка. Заблокированный ввод перестаёт быть редактируемым и говорит об этом `aria-disabled`.
+
+Масштабирование запрещено — и щипок, и двойной тап (ADR-075). Это сознательный размен: интерфейс ведёт себя как приложение, а человек, которому нужно увеличить мелкое, средства лишается. Размеры шрифтов от запрета не меняются: поля ввода остаются 14 px, как в `docs/identity/brief.md`. Браузер вправе запрет проигнорировать.
diff --git a/internal/api/account.go b/internal/api/account.go
index 3220a78..c21013a 100644
--- a/internal/api/account.go
+++ b/internal/api/account.go
@@ -8,22 +8,33 @@ import (
"time"
"github.com/xmatic-squad/bare/internal/auth"
+ "github.com/xmatic-squad/bare/internal/build"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/store"
)
// GET /api/config — то, что клиенту нужно знать до входа.
+//
+// version и commitAt отвечают на вопрос «какой код сейчас работает»:
+// короткая ревизия сборки и время коммита, а не момент компиляции
+// (ADR-074). Отдельного эндпоинта им не заводится — конфигурацию клиент
+// читает до входа и так.
func (s *server) config(w http.ResponseWriter, r *http.Request) {
+ info := build.Current()
writeJSON(w, http.StatusOK, struct {
InviteRequired bool `json:"inviteRequired"`
VAPIDPublicKey string `json:"vapidPublicKey"`
KDFIterations int `json:"kdfIterations"`
MaxMessageChars int `json:"maxMessageChars"`
+ Version string `json:"version"`
+ CommitAt int64 `json:"commitAt"`
}{
InviteRequired: s.cfg.InviteCode != "",
VAPIDPublicKey: s.cfg.VAPIDPublic,
KDFIterations: config.KDFIterations,
MaxMessageChars: config.MaxMessageChars,
+ Version: info.Version(),
+ CommitAt: info.CommitMilli(),
})
}
diff --git a/internal/api/account_test.go b/internal/api/account_test.go
index 55ae91d..439287b 100644
--- a/internal/api/account_test.go
+++ b/internal/api/account_test.go
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/xmatic-squad/bare/internal/api"
+ "github.com/xmatic-squad/bare/internal/build"
"github.com/xmatic-squad/bare/internal/config"
)
@@ -80,8 +81,24 @@ func TestConfig(t *testing.T) {
VAPIDPublicKey string `json:"vapidPublicKey"`
KDFIterations int `json:"kdfIterations"`
MaxMessageChars int `json:"maxMessageChars"`
+ Version string `json:"version"`
+ CommitAt int64 `json:"commitAt"`
}
decodeBody(t, rec, &got)
+ // Имена полей — часть протокола (docs/protocol.md). Сравнение значений
+ // их не закрепляет: у половины полей нулевое значение законно, и ответ
+ // без поля разбирается в тот же ноль — переименованный тег прошёл бы
+ // незамеченным. Поэтому сначала перечень ключей, потом значения.
+ var keys map[string]json.RawMessage
+ decodeBody(t, rec, &keys)
+ for _, name := range []string{
+ "inviteRequired", "vapidPublicKey", "kdfIterations",
+ "maxMessageChars", "version", "commitAt",
+ } {
+ if _, ok := keys[name]; !ok {
+ t.Errorf("в ответе нет поля %q", name)
+ }
+ }
if got.InviteRequired {
t.Error("inviteRequired: получено true, ожидалось false")
}
@@ -94,6 +111,40 @@ func TestConfig(t *testing.T) {
if got.MaxMessageChars != 4000 {
t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars)
}
+ // Версия — то же самое, что печатает `bare version`: одно место,
+ // один формат (ADR-074). Тестовому бинарю vcs.* не проставляются,
+ // поэтому здесь проверяется в том числе поведение без build info —
+ // «unknown» и 0.
+ if !validVersion(got.Version) {
+ t.Errorf("version: получено %q, ожидались до семи hex-символов, «+dirty» или «unknown»", got.Version)
+ }
+ if want := build.Current().Version(); got.Version != want {
+ t.Errorf("version: получено %q, ожидалось %q", got.Version, want)
+ }
+ if want := build.Current().CommitMilli(); got.CommitAt != want {
+ t.Errorf("commitAt: получено %d, ожидалось %d", got.CommitAt, want)
+ }
+ if got.CommitAt < 0 {
+ t.Errorf("commitAt: получено %d, неизвестное время — это 0", got.CommitAt)
+ }
+}
+
+// validVersion — формат поля version: «unknown» либо до семи символов
+// хеша, у сборки из изменённого дерева с суффиксом «+dirty».
+func validVersion(v string) bool {
+ if v == "unknown" {
+ return true
+ }
+ rev, _ := strings.CutSuffix(v, "+dirty")
+ if rev == "" || len(rev) > 7 {
+ return false
+ }
+ for _, c := range rev {
+ if !strings.ContainsRune("0123456789abcdef", c) {
+ return false
+ }
+ }
+ return true
}
func TestRegisterAndLogin(t *testing.T) {
diff --git a/internal/build/build.go b/internal/build/build.go
new file mode 100644
index 0000000..12c4da7
--- /dev/null
+++ b/internal/build/build.go
@@ -0,0 +1,85 @@
+// Package build отвечает на вопрос «какой код сейчас работает»: ревизия
+// и время коммита, которые git оставляет в бинаре при сборке (ADR-074).
+// Момента компиляции здесь нет и не будет: штамп времени сборки делал бы
+// каждую пересборку одного коммита новым файлом, а подлинность бинаря
+// проверяется сравнением хеша со сборкой из тега (ADR-022).
+package build
+
+import (
+ "runtime/debug"
+ "time"
+)
+
+// short — сколько символов ревизии показываются человеку и клиенту.
+// Полный хеш не добавляет ничего: коммит опознаётся и по семи.
+const short = 7
+
+// unknown — ревизии нет вовсе (ADR-057).
+const unknown = "unknown"
+
+// Info — что бинарь знает о своём происхождении. Нулевое значение —
+// сборка не из git: так выглядят `go run`, `go build -buildvcs=false`
+// и тестовый бинарь.
+type Info struct {
+ Revision string // vcs.revision — полный хеш коммита; пусто, если неизвестен
+ Modified bool // vcs.modified — дерево при сборке было изменено (ADR-057)
+ CommitAt time.Time // vcs.time — время коммита; нулевое, если неизвестно
+}
+
+// current читается один раз: у собранного бинаря это неизменная величина.
+var current = read(debug.ReadBuildInfo())
+
+// Current — сведения о текущей сборке.
+func Current() Info { return current }
+
+// Version — ревизия строкой: семь символов хеша, «+dirty» у сборки
+// из изменённого дерева (ADR-057), «unknown» без ревизии вовсе.
+func (i Info) Version() string {
+ if i.Revision == "" {
+ return unknown
+ }
+ v := i.Revision
+ if len(v) > short {
+ v = v[:short]
+ }
+ if i.Modified {
+ v += "+dirty"
+ }
+ return v
+}
+
+// CommitMilli — время коммита в миллисекундах Unix, 0 если его нет.
+// Проверка отдельная: UnixMilli нулевого времени даёт не ноль, а большое
+// отрицательное число.
+func (i Info) CommitMilli() int64 {
+ if i.CommitAt.IsZero() {
+ return 0
+ }
+ return i.CommitAt.UnixMilli()
+}
+
+// read разбирает build info. Отдельная функция принимает результат
+// debug.ReadBuildInfo как есть: только так поведение без build info
+// и с неразбираемыми значениями проверяется тестом — тестовому бинарю
+// vcs.* не проставляются (ADR-057).
+func read(info *debug.BuildInfo, ok bool) Info {
+ if !ok || info == nil {
+ return Info{}
+ }
+ var out Info
+ for _, s := range info.Settings {
+ switch s.Key {
+ case "vcs.revision":
+ out.Revision = s.Value
+ case "vcs.modified":
+ out.Modified = s.Value == "true"
+ case "vcs.time":
+ // Неразобранное время равносильно его отсутствию: соврать
+ // о нём хуже, чем промолчать.
+ if t, err := time.Parse(time.RFC3339, s.Value); err == nil {
+ out.CommitAt = t
+ }
+ }
+ }
+ return out
+}
diff --git a/internal/build/build_test.go b/internal/build/build_test.go
new file mode 100644
index 0000000..a2b177b
--- /dev/null
+++ b/internal/build/build_test.go
@@ -0,0 +1,111 @@
+package build
+
+import (
+ "runtime/debug"
+ "testing"
+ "time"
+)
+
+const hash = "9f2c1ab7d3e4c5061728394a5b6c7d8e9f001122"
+
+// Формат версии: семь символов ревизии, «+dirty» у изменённого дерева,
+// «unknown» без ревизии (ADR-057, ADR-074).
+func TestVersion(t *testing.T) {
+ cases := []struct {
+ info Info
+ want string
+ }{
+ {Info{Revision: hash}, "9f2c1ab"},
+ {Info{Revision: hash, Modified: true}, "9f2c1ab+dirty"},
+ // Ревизия короче семи символов остаётся как есть.
+ {Info{Revision: "9f2c"}, "9f2c"},
+ {Info{Revision: "9f2c", Modified: true}, "9f2c+dirty"},
+ // Сборка не из git: «unknown» и без «+dirty» — помечать нечего.
+ {Info{}, unknown},
+ {Info{Modified: true}, unknown},
+ }
+ for _, c := range cases {
+ if got := c.info.Version(); got != c.want {
+ t.Errorf("Version() для %+v: получено %q, ожидалось %q", c.info, got, c.want)
+ }
+ }
+}
+
+// Время коммита — миллисекунды Unix; неизвестное время даёт ноль,
+// а не большое отрицательное число.
+func TestCommitMilli(t *testing.T) {
+ at := time.Date(2026, 8, 22, 9, 14, 3, 0, time.UTC)
+ if got := (Info{CommitAt: at}).CommitMilli(); got != at.UnixMilli() {
+ t.Errorf("CommitMilli(): получено %d, ожидалось %d", got, at.UnixMilli())
+ }
+ if got := (Info{}).CommitMilli(); got != 0 {
+ t.Errorf("CommitMilli() без времени: получено %d, ожидался 0", got)
+ }
+}
+
+func TestRead(t *testing.T) {
+ at := time.Date(2026, 8, 22, 9, 14, 3, 0, time.UTC)
+ cases := []struct {
+ name string
+ settings []debug.BuildSetting
+ want Info
+ }{
+ {
+ name: "чистое дерево",
+ settings: []debug.BuildSetting{
+ {Key: "-compiler", Value: "gc"},
+ {Key: "vcs", Value: "git"},
+ {Key: "vcs.revision", Value: hash},
+ {Key: "vcs.time", Value: "2026-08-22T09:14:03Z"},
+ {Key: "vcs.modified", Value: "false"},
+ },
+ want: Info{Revision: hash, CommitAt: at},
+ },
+ {
+ name: "изменённое дерево",
+ settings: []debug.BuildSetting{
+ {Key: "vcs.revision", Value: hash},
+ {Key: "vcs.time", Value: "2026-08-22T09:14:03Z"},
+ {Key: "vcs.modified", Value: "true"},
+ },
+ want: Info{Revision: hash, Modified: true, CommitAt: at},
+ },
+ {
+ // Тестовый бинарь и `go build -buildvcs=false` выглядят так.
+ name: "build info без vcs",
+ settings: []debug.BuildSetting{{Key: "-compiler", Value: "gc"}, {Key: "GOOS", Value: "linux"}},
+ want: Info{},
+ },
+ {
+ // Время не по RFC 3339 — то же, что его отсутствие.
+ name: "время не разбирается",
+ settings: []debug.BuildSetting{
+ {Key: "vcs.revision", Value: hash},
+ {Key: "vcs.time", Value: "вчера"},
+ },
+ want: Info{Revision: hash},
+ },
+ }
+ for _, c := range cases {
+ got := read(&debug.BuildInfo{Settings: c.settings}, true)
+ if got.Revision != c.want.Revision || got.Modified != c.want.Modified || !got.CommitAt.Equal(c.want.CommitAt) {
+ t.Errorf("read(%s): получено %+v, ожидалось %+v", c.name, got, c.want)
+ }
+ }
+}
+
+// Build info недоступен вовсе: ревизия — «unknown», время — 0.
+// Это же состояние у любой сборки не из git, включая тестовую.
+func TestReadWithoutBuildInfo(t *testing.T) {
+ for _, got := range []Info{read(nil, false), read(nil, true), read(&debug.BuildInfo{}, true)} {
+ if got != (Info{}) {
+ t.Errorf("получено %+v, ожидалось нулевое", got)
+ }
+ if v := got.Version(); v != unknown {
+ t.Errorf("Version(): получено %q, ожидалось %q", v, unknown)
+ }
+ if ms := got.CommitMilli(); ms != 0 {
+ t.Errorf("CommitMilli(): получено %d, ожидался 0", ms)
+ }
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 67579ad..848cca5 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -68,9 +68,47 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key)
}
}
+ if err := checkVAPIDSubject(c); err != nil {
+ return nil, err
+ }
return c, nil
}
+// checkVAPIDSubject проверяет форму VAPID-субъекта при старте. Ошибка
+// в нём иначе не видна вовсе: сервер поднимается, подписки ставятся,
+// а push-сервис отвергает каждый токен — «пуши не приходят» без единой
+// строки о причине. Отказ на старте дешевле.
+//
+// Годится ровно то, что допускает RFC 8292: «mailto:<адрес>» или
+// «https://<хост>» (docs/deploy.md).
+func checkVAPIDSubject(c *Config) error {
+ if c.VAPIDSubject == "" {
+ // Без ключей пуши и так выключены — это рабочий локальный запуск.
+ // А вот ключи без субъекта означают, что настроить пуши хотели
+ // и не настроили.
+ if c.VAPIDPublic == "" && c.VAPIDPrivate == "" {
+ return nil
+ }
+ return fmt.Errorf("BARE_VAPID_SUBJECT пуст при заданных VAPID-ключах: нужен mailto:<адрес> или https://<хост>")
+ }
+ bad := fmt.Errorf("BARE_VAPID_SUBJECT=%q не годится: нужен mailto:<адрес> или https://<хост>", c.VAPIDSubject)
+ if addr, ok := strings.CutPrefix(c.VAPIDSubject, "mailto:"); ok {
+ local, domain, at := strings.Cut(addr, "@")
+ if !at || local == "" || domain == "" || strings.ContainsAny(addr, " \t") {
+ return bad
+ }
+ return nil
+ }
+ if rest, ok := strings.CutPrefix(c.VAPIDSubject, "https://"); ok {
+ host, _, _ := strings.Cut(rest, "/")
+ if host == "" || strings.ContainsAny(rest, " \t") {
+ return bad
+ }
+ return nil
+ }
+ return bad
+}
+
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return strings.TrimSpace(v)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 0000000..0f308b4
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,58 @@
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+// Форма VAPID-субъекта проверяется на старте: с мусором в нём сервер
+// поднимается, подписки ставятся, а push-сервис отвергает каждый токен —
+// поломка без единой строки в журнале (docs/deploy.md).
+func TestVAPIDSubject(t *testing.T) {
+ cases := []struct {
+ subject string
+ keys bool
+ ok bool
+ }{
+ {"mailto:admin@xmatic.team", true, true},
+ {"https://bare.xmatic.team", true, true},
+ {"https://bare.xmatic.team/", true, true},
+ // Голый адрес — не URI: RFC 8292 требует схему, и APNs отвергает
+ // токен без неё.
+ {"admin@xmatic.team", true, false},
+ {"mailto:", true, false},
+ {"mailto:admin", true, false},
+ {"mailto:@xmatic.team", true, false},
+ {"mailto:admin@xmatic.team, second@xmatic.team", true, false},
+ {"https://", true, false},
+ {"http://bare.xmatic.team", true, false},
+ {"bare.xmatic.team", true, false},
+ // Ключи заданы, субъекта нет: пуши настроить хотели и не настроили.
+ {"", true, false},
+ // Ни ключей, ни субъекта — локальный запуск без пушей.
+ {"", false, true},
+ }
+ for _, c := range cases {
+ t.Setenv("BARE_VAPID_SUBJECT", c.subject)
+ if c.keys {
+ t.Setenv("BARE_VAPID_PUBLIC", "public")
+ t.Setenv("BARE_VAPID_PRIVATE", "private")
+ } else {
+ t.Setenv("BARE_VAPID_PUBLIC", "")
+ t.Setenv("BARE_VAPID_PRIVATE", "")
+ }
+ _, err := Load()
+ if c.ok && err != nil {
+ t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): %v", c.subject, c.keys, err)
+ }
+ if !c.ok {
+ if err == nil {
+ t.Errorf("BARE_VAPID_SUBJECT=%q (ключи: %v): принят", c.subject, c.keys)
+ continue
+ }
+ if !strings.Contains(err.Error(), "BARE_VAPID_SUBJECT") {
+ t.Errorf("ошибка не называет переменную: %v", err)
+ }
+ }
+ }
+}
diff --git a/internal/push/push.go b/internal/push/push.go
index c758adb..891ac7f 100644
--- a/internal/push/push.go
+++ b/internal/push/push.go
@@ -15,6 +15,7 @@ import (
"net"
"net/http"
"net/netip"
+ "strings"
"sync"
"syscall"
"time"
@@ -69,6 +70,16 @@ const (
dropEvery = time.Minute
)
+// Чтение тела ответа push-сервиса ради кода причины (ADR-073).
+const (
+ // maxReasonBody — сколько байт тела читаем. Код причины стоит в начале
+ // ответа; остальное дочитывается в никуда, ради переиспользования
+ // соединения.
+ maxReasonBody = 200
+ // maxReason — предел длины кода причины в журнале.
+ maxReason = 64
+)
+
// Devices — что отправителю нужно от хранилища. Правило «одно молчащее
// устройство — один пуш» держится на атомарном захвате (ADR-023).
type Devices interface {
@@ -110,9 +121,11 @@ type Sender struct {
connected func(device string) bool
public string
private string
- subject string
- client *http.Client
- logw io.Writer
+ // subject — VAPID-субъект в той форме, которую ждёт webpush-go:
+ // у «mailto:» схема снята, см. vapidSubscriber.
+ subject string
+ client *http.Client
+ logw io.Writer
jobs chan job
done chan struct{}
@@ -149,7 +162,7 @@ func New(cfg *config.Config, devices Devices, connected func(device string) bool
connected: connected,
public: cfg.VAPIDPublic,
private: cfg.VAPIDPrivate,
- subject: cfg.VAPIDSubject,
+ subject: vapidSubscriber(cfg.VAPIDSubject),
client: &http.Client{
Timeout: requestTimeout,
// Push-сервисы редиректов не шлют. Следование за ними
@@ -180,6 +193,35 @@ func (s *Sender) on() bool {
return s.public != "" && s.private != "" && s.subject != ""
}
+// vapidSubscriber приводит BARE_VAPID_SUBJECT к форме, которую ждёт
+// webpush-go. Нормализация здесь не косметика: без неё пуши не уходят
+// вовсе, ни на одной платформе.
+//
+// По RFC 8292 поле sub в VAPID-токене — это URI: «mailto:<адрес>» или
+// «https://<хост>». Именно так субъект и записан в окружении
+// (docs/deploy.md), и менять запись нельзя — она верна. Но webpush-go
+// в getVAPIDAuthorizationHeader считает субъектом голый адрес и сам
+// приписывает схему всему, что не начинается с «https:»:
+//
+// if !strings.HasPrefix(subscriber, "https:") {
+// subscriber = "mailto:" + subscriber
+// }
+//
+// Готовый «mailto:admin@example.org» превращается в
+// «mailto:mailto:admin@example.org», и push-сервис отвергает токен:
+// APNs отвечает 403 BadJwtToken на каждый пуш. Обойти это настройкой
+// нельзя — голый адрес в sub тот же APNs тоже отвергает 403. Поэтому
+// схему снимаем ровно перед вызовом библиотеки: в токен она вернётся,
+// а конфигурация остаётся правильной по спецификации.
+//
+// «https:» отдаётся как есть: его библиотека узнаёт и не трогает.
+func vapidSubscriber(subject string) string {
+ if strings.HasPrefix(subject, "https:") {
+ return subject
+ }
+ return strings.TrimPrefix(subject, "mailto:")
+}
+
// Send ставит пуш каждому из устройств в очередь отправки и возвращается
// сразу: конверт уже в очереди устройства, ответ на POST /api/messages
// пуша не ждёт (ADR-023).
@@ -289,8 +331,9 @@ func (s *Sender) deliver(j job) {
return
}
defer resp.Body.Close()
- // Тело ответа push-сервиса нам не нужно, но дочитать его стоит:
- // иначе соединение не переиспользуется.
+ // Начало тела нужно ради кода причины (ADR-073), остаток дочитывается
+ // в никуда: иначе соединение не переиспользуется.
+ head, _ := io.ReadAll(io.LimitReader(resp.Body, maxReasonBody))
io.Copy(io.Discard, resp.Body)
switch {
@@ -305,7 +348,7 @@ func (s *Sender) deliver(j job) {
// Подписки больше нет — чистим мёртвую (ADR-011).
s.drop(j.device)
default:
- s.report("push-сервис ответил %d", resp.StatusCode)
+ s.report("push-сервис ответил %s", status(resp.StatusCode, head))
s.release(j.device)
}
}
@@ -388,6 +431,59 @@ func (s *Sender) report(format string, args ...any) {
fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...))
}
+// status — ответ push-сервиса для журнала: код и, если он разобран,
+// короткий код причины из тела (ADR-073).
+func status(code int, body []byte) string {
+ if r := serviceReason(body); r != "" {
+ return fmt.Sprintf("%d (%s)", code, r)
+ }
+ return fmt.Sprintf("%d", code)
+}
+
+// serviceReason достаёт из тела ответа короткий код причины: APNs отвечает
+// {"reason":"BadJwtToken"}, Mozilla — {"errno":…,"error":"Not Found"}.
+// Код — диагностика вендора, а не данные пользователя, и без него отказ
+// не читается: голый «403» сутки выглядел как «что-то с пушами» (ADR-073).
+//
+// Тело всё же приходит снаружи, поэтому в журнал идёт не оно, а то, что
+// прошло safeReason.
+func serviceReason(body []byte) string {
+ var fields map[string]json.RawMessage
+ if json.Unmarshal(body, &fields) == nil {
+ for _, key := range []string{"reason", "error", "message"} {
+ var s string
+ if json.Unmarshal(fields[key], &s) == nil && s != "" {
+ return safeReason(s)
+ }
+ }
+ }
+ return safeReason(string(body))
+}
+
+// safeReason пропускает только то, что заведомо является кодом причины:
+// одна строка не длиннее maxReason из латиницы, цифр, «_», «-» и пробелов.
+// Точка, «:», «/» и «@» встречаются в адресах подписки и именах хостов,
+// поэтому строка с ними отбрасывается целиком — в журнале остаётся один
+// статус (docs/deploy.md, «Логи»).
+func safeReason(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexAny(s, "\r\n"); i >= 0 {
+ s = strings.TrimSpace(s[:i])
+ }
+ if s == "" || len(s) > maxReason {
+ return ""
+ }
+ for _, r := range s {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ case r == ' ', r == '_', r == '-':
+ default:
+ return ""
+ }
+ }
+ return s
+}
+
// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047).
var errLocalAddress = errors.New("push: адрес не публичный")
diff --git a/internal/push/push_test.go b/internal/push/push_test.go
index 1a0e9cf..5dbfe42 100644
--- a/internal/push/push_test.go
+++ b/internal/push/push_test.go
@@ -2,15 +2,22 @@ package push
import (
"context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
+ "io"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
+ "sync"
"testing"
+ "time"
"github.com/xmatic-squad/bare/internal/config"
)
@@ -143,3 +150,246 @@ func TestReasonWithoutEndpoint(t *testing.T) {
}
}
}
+
+// В VAPID-токен уходит субъект ровно в той форме, которую требует
+// RFC 8292: у «mailto:» одна схема, а не две. webpush-go приписывает
+// «mailto:» всему, что не начинается с «https:», поэтому готовый URI
+// приходится отдавать ему без схемы — см. vapidSubscriber. Без этого
+// APNs отвечает 403 BadJwtToken на каждый пуш.
+func TestVAPIDSubjectInToken(t *testing.T) {
+ cases := []struct{ subject, want string }{
+ {"mailto:admin@xmatic.team", "mailto:admin@xmatic.team"},
+ {"https://bare.xmatic.team", "https://bare.xmatic.team"},
+ {"admin@xmatic.team", "mailto:admin@xmatic.team"},
+ }
+ for _, c := range cases {
+ t.Run(c.subject, func(t *testing.T) {
+ got := make(chan string, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(io.Discard, r.Body)
+ select {
+ case got <- r.Header.Get("Authorization"):
+ default:
+ }
+ w.WriteHeader(http.StatusCreated)
+ }))
+ defer srv.Close()
+
+ s, _ := sender(t, c.subject, srv.URL, nil)
+ defer s.Close()
+ s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"})
+
+ var header string
+ select {
+ case header = <-got:
+ case <-time.After(wait):
+ t.Fatal("push-сервис не получил запроса")
+ }
+ if sub := subClaim(t, header); sub != c.want {
+ t.Errorf("sub: получено %q, ожидалось %q", sub, c.want)
+ }
+ })
+ }
+}
+
+// Отказ push-сервиса читается по журналу: статус и код причины из тела
+// (ADR-073). Без кода 403 от APNs неотличим от любого другого отказа.
+func TestServiceStatusInLog(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ io.Copy(io.Discard, r.Body)
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, `{"reason":"BadJwtToken"}`)
+ }))
+ defer srv.Close()
+
+ log := &logbuf{}
+ s, d := sender(t, "mailto:admin@xmatic.team", srv.URL, log)
+ defer s.Close()
+ s.Send([]Target{{Device: "d1", Owner: "marta"}}, Payload{Title: "@marta", Chat: "dm:marta"})
+
+ const want = "пуш: push-сервис ответил 403 (BadJwtToken)"
+ deadline := time.Now().Add(wait)
+ for !strings.Contains(log.String(), want) && time.Now().Before(deadline) {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if line := log.String(); !strings.Contains(line, want) {
+ t.Errorf("журнал: %q, ожидалась строка %q", line, want)
+ }
+ if strings.Contains(log.String(), srv.URL) || strings.Contains(log.String(), "d1") {
+ t.Errorf("в журнал попал адрес подписки или устройство: %q", log.String())
+ }
+ if d.drops() != 0 {
+ t.Error("подписка снята по ответу 403")
+ }
+}
+
+// Код причины берётся из тела ответа, но телом распоряжается чужая
+// сторона: всё, что на короткий код не похоже, в журнал не идёт вовсе
+// (ADR-073, docs/deploy.md, «Логи»).
+func TestServiceReason(t *testing.T) {
+ const endpoint = "web.push.apple.com"
+ cases := []struct{ body, want string }{
+ {`{"reason":"BadJwtToken"}`, "BadJwtToken"},
+ {`{"reason":"TooManyRequests"}`, "TooManyRequests"},
+ {`{"code":404,"errno":103,"error":"Not Found"}`, "Not Found"},
+ {"Unauthorized registration", "Unauthorized registration"},
+ {`{"error":{"code":403,"status":"UNAUTHENTICATED"}}`, ""},
+ {`{"reason":"unknown push endpoint https://` + endpoint + `/QK"}`, ""},
+ {"gone: " + endpoint, ""},
+ {`{"message":"subscription ` + endpoint + ` expired"}`, ""},
+ {strings.Repeat("A", maxReason+1), ""},
+ {"", ""},
+ // Многострочное тело: в журнал идёт первая строка, остальное
+ // отбрасывается вместе с адресом.
+ {"BadJwtToken\nendpoint: " + endpoint, "BadJwtToken"},
+ }
+ for _, c := range cases {
+ got := serviceReason([]byte(c.body))
+ if got != c.want {
+ t.Errorf("serviceReason(%q): получено %q, ожидалось %q", c.body, got, c.want)
+ }
+ if strings.Contains(got, endpoint) {
+ t.Errorf("адрес подписки попал в журнал: %q", got)
+ }
+ }
+}
+
+// Статус без разобранного кода причины остаётся статусом.
+func TestStatusWithoutReason(t *testing.T) {
+ if got := status(500, nil); got != "500" {
+ t.Errorf("status: получено %q, ожидалось %q", got, "500")
+ }
+ if got := status(403, []byte(`{"reason":"BadJwtToken"}`)); got != "403 (BadJwtToken)" {
+ t.Errorf("status: получено %q, ожидалось %q", got, "403 (BadJwtToken)")
+ }
+}
+
+// wait — сколько ждём отправку. Всё локально, задержек быть не должно.
+const wait = 5 * time.Second
+
+// sender — отправитель с настоящей парой VAPID-ключей и одной подпиской
+// на подменный push-сервис. Без ключей отправщики не заводятся.
+func sender(t *testing.T, subject, endpoint string, logw io.Writer) (*Sender, *devices) {
+ t.Helper()
+ key, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("vapid: %v", err)
+ }
+ d := &devices{subscription: subscription(t, endpoint)}
+ cfg := &config.Config{
+ VAPIDPublic: base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()),
+ VAPIDPrivate: base64.RawURLEncoding.EncodeToString(key.Bytes()),
+ VAPIDSubject: subject,
+ // Подменный push-сервис живёт на 127.0.0.1; в работе отправщик
+ // ходит только по публичным адресам (ADR-047).
+ PushLocal: true,
+ }
+ return New(cfg, d, nil, logw), d
+}
+
+// subscription — подписка устройства с настоящими ключами: webpush-go
+// шифрует ими нагрузку, случайных байт ему мало.
+func subscription(t *testing.T, endpoint string) string {
+ t.Helper()
+ key, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatalf("ключ подписки: %v", err)
+ }
+ auth := make([]byte, 16)
+ if _, err := rand.Read(auth); err != nil {
+ t.Fatalf("секрет подписки: %v", err)
+ }
+ raw, err := json.Marshal(map[string]any{
+ "endpoint": endpoint + "/push",
+ "keys": map[string]string{
+ "p256dh": base64.RawURLEncoding.EncodeToString(key.PublicKey().Bytes()),
+ "auth": base64.RawURLEncoding.EncodeToString(auth),
+ },
+ })
+ if err != nil {
+ t.Fatalf("подписка: %v", err)
+ }
+ return string(raw)
+}
+
+// subClaim достаёт sub из VAPID-заголовка: «vapid t=, k=<ключ>».
+// JWT разбирается руками — библиотека здесь и проверяется.
+func subClaim(t *testing.T, header string) string {
+ t.Helper()
+ rest, ok := strings.CutPrefix(header, "vapid t=")
+ if !ok {
+ t.Fatalf("заголовок не vapid: %q", header)
+ }
+ token, _, ok := strings.Cut(rest, ",")
+ if !ok {
+ t.Fatalf("в заголовке нет ключа: %q", header)
+ }
+ parts := strings.Split(token, ".")
+ if len(parts) != 3 {
+ t.Fatalf("не JWT: %q", token)
+ }
+ raw, err := base64.RawURLEncoding.DecodeString(parts[1])
+ if err != nil {
+ t.Fatalf("claims: %v", err)
+ }
+ var claims struct {
+ Sub string `json:"sub"`
+ }
+ if err := json.Unmarshal(raw, &claims); err != nil {
+ t.Fatalf("claims: %v", err)
+ }
+ return claims.Sub
+}
+
+// devices — хранилище устройств в тесте: право на пуш даётся всегда,
+// возвраты и снятия считаются.
+type devices struct {
+ subscription string
+
+ mu sync.Mutex
+ released int
+ dropcount int
+}
+
+func (d *devices) ClaimPush(context.Context, string) (string, bool, error) {
+ return d.subscription, true, nil
+}
+
+func (d *devices) ReleasePush(context.Context, string) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.released++
+ return nil
+}
+
+func (d *devices) DropPush(context.Context, string) error {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ d.dropcount++
+ return nil
+}
+
+func (d *devices) drops() int {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ return d.dropcount
+}
+
+// logbuf — журнал теста. Пишет в него отправщик, читает тест, поэтому
+// с замком.
+type logbuf struct {
+ mu sync.Mutex
+ b strings.Builder
+}
+
+func (l *logbuf) Write(p []byte) (int, error) {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.Write(p)
+}
+
+func (l *logbuf) String() string {
+ l.mu.Lock()
+ defer l.mu.Unlock()
+ return l.b.String()
+}
diff --git a/web/app.css b/web/app.css
index d37e0d9..b5d6e30 100644
--- a/web/app.css
+++ b/web/app.css
@@ -25,16 +25,63 @@ body {
height: 100%;
}
+/* масштабирования нет (ADR-075). manipulation снимает зум по двойному тапу
+ и задержку 300 мс, которую браузер держит, ожидая второго касания;
+ прокрутку и щипок правило не трогает — щипок запрещён в viewport.
+ Правило стоит на html: двойной тап — жест документа, и браузер берёт
+ пересечение значений всех предков */
+
+html {
+ touch-action: manipulation;
+ overscroll-behavior: none;
+}
+
body {
margin: 0;
+ /* виден вырез: viewport-fit=cover пускает страницу под скруглённые углы,
+ под боковой вырез в альбомной ориентации и под верхнюю системную
+ полосу. Полосу над страницей держит мета-строка о стиле полосы
+ (ADR-075), поэтому верхняя вставка обычно нулевая и не делает ничего;
+ там, где она не нулевая, шапка не уезжает под часы */
+ padding-top: env(safe-area-inset-top);
+ padding-left: env(safe-area-inset-left);
+ padding-right: env(safe-area-inset-right);
background: var(--bone);
color: var(--ink);
font-family: var(--mono);
font-size: 14px;
line-height: 1.55;
+ overscroll-behavior: none;
-webkit-text-size-adjust: 100%;
}
+/* интерфейс не выделяется: долгое нажатие по шапке, списку и кнопкам
+ вызывает лупу и «копировать», а копировать там нечего. Текст сообщений,
+ отпечатки и поля ввода выделяются как обычно — их именно копируют
+ (ADR-075) */
+
+.side,
+.head,
+.brand,
+.divider,
+.input .p,
+.counter,
+.enter,
+button {
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+/* прокрутка внутренних лент не уходит в страницу: отскок и «потянуть
+ для обновления» ощущения приложения не добавляют */
+
+.list,
+.body,
+.feed,
+.auth {
+ overscroll-behavior: contain;
+}
+
/* #app — колонка ровно в высоту окна: от неё считают высоту экраны,
поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается
только содержимое */
@@ -360,16 +407,34 @@ input[type="password"] {
color: var(--mute);
}
+/* подвал сайдбара: «ты: @nick» и версия с временем коммита (ADR-067).
+ Версия не кнопка: нажимать в ней нечего, поэтому она стоит рядом
+ с кнопкой, а не внутри неё. В сайдбаре 224 px они рядом не помещаются
+ никогда — версия занимает вторую строку и остаётся справа; на мобильном,
+ где сайдбар во всю ширину, обе стоят в одной строке */
+
+.foot {
+ flex: none;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 2px 8px;
+ margin-top: auto;
+ /* нижняя системная полоса iPhone проходит поверх страницы: подвал
+ отступает от неё, а не прячется под ней (ADR-075) */
+ padding: 6px 20px calc(6px + env(safe-area-inset-bottom));
+ border-top: 1px solid var(--line);
+}
+
.me {
display: flex;
align-items: center;
gap: 8px;
- width: 100%;
+ flex: 1 1 auto;
+ min-width: 0;
min-height: 44px;
- margin-top: auto;
- padding: 16px 20px;
+ padding: 0;
border: 0;
- border-top: 1px solid var(--line);
background: none;
color: var(--mute);
font: inherit;
@@ -385,6 +450,30 @@ input[type="password"] {
background: var(--ink);
}
+/* ник бывает в 32 символа (ADR-019): он обрезается, а версия остаётся
+ целой — читать половину хеша незачем */
+
+.me__nick {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* версия выделяется: с неё начинается разговор о поломке (ADR-067),
+ а на iPhone скопировать её иначе нечем — ни строки адреса, ни консоли
+ там нет. Общий запрет выделения снят с неё так же, как с текста
+ сообщений и отпечатков (ADR-075) */
+
+.build {
+ flex: none;
+ margin-left: auto;
+ color: var(--stone);
+ font-size: 11px;
+ -webkit-user-select: text;
+ user-select: text;
+}
+
.main {
flex: 1;
min-width: 0;
@@ -782,7 +871,8 @@ input[type="password"] {
.compose {
flex: none;
- padding: 0 16px 20px;
+ /* то же, что у подвала: строка ввода не уезжает под системную полосу */
+ padding: 0 16px calc(20px + env(safe-area-inset-bottom));
}
.input {
@@ -815,7 +905,10 @@ input[type="password"] {
flex: 1;
min-width: 0;
min-height: 0;
- padding: 12px 0;
+ /* отступ назван: высота блока считается вместе с ним (box-sizing —
+ border-box), и предел роста строки сообщения меряется от него */
+ --field-pad: 12px;
+ padding: var(--field-pad) 0;
border: 0;
border-radius: 0;
background: none;
@@ -825,15 +918,26 @@ input[type="password"] {
line-height: 1.5;
}
-/* строка ввода перебивает общее правило для input: рамка здесь одна,
- у всей строки */
+/* строка сообщения — редактируемый блок, а не поле формы (ADR-069):
+ растёт под текст сама, дальше семи строк — прокрутка. Переносы строк
+ в ней настоящие символы, поэтому pre-wrap; длинное слово рвётся,
+ иначе строка ввода уезжает вбок */
-.input textarea.input__field {
- resize: none;
+.input .input__field--text {
overflow-y: auto;
- max-height: 7em;
- /* растёт под текст там, где браузер это умеет; где нет — прокрутка */
- field-sizing: content;
+ /* семь строк — это семь высот строки плюс вертикальные отступы:
+ без них предел вышел бы вдвое ниже обещанного */
+ max-height: calc(7 * 1.5em + 2 * var(--field-pad));
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+/* подсказка пустой строки: placeholder у блока не бывает, текст приходит
+ атрибутом data-hint (ADR-069) */
+
+.input .input__field--text:empty::before {
+ content: attr(data-hint);
+ color: var(--stone);
}
.input .input__field::placeholder {
@@ -929,7 +1033,7 @@ input[type="password"] {
}
.compose {
- padding: 0 32px 28px;
+ padding: 0 32px calc(28px + env(safe-area-inset-bottom));
}
.input {
@@ -938,7 +1042,7 @@ input[type="password"] {
}
.input .input__field {
- padding: 13px 0;
+ --field-pad: 13px;
}
/* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в
@@ -979,4 +1083,11 @@ input[type="password"] {
.enter {
display: none;
}
+
+ /* строка сообщения — тоже цель нажатия не меньше 44 px (docs/ui.md,
+ «Доступность»): своей высоты у блока нет, её задаёт содержимое */
+
+ .input .input__field--text {
+ min-height: 44px;
+ }
}
diff --git a/web/index.html b/web/index.html
index 94c876e..d47acb6 100644
--- a/web/index.html
+++ b/web/index.html
@@ -2,8 +2,12 @@
-
+
+
+
+
+
bare
diff --git a/web/js/main.js b/web/js/main.js
index 669f1d7..da52450 100644
--- a/web/js/main.js
+++ b/web/js/main.js
@@ -32,6 +32,7 @@ import { renderMembers } from "./ui/members.js";
import { renderNew } from "./ui/new.js";
import { renderSettings } from "./ui/settings.js";
import { frame } from "./ui/shell.js";
+import { lock } from "./zoom.js";
// Минимальная длина пароля — ADR-013.
const MIN_PASSWORD = 12;
@@ -481,10 +482,18 @@ function errorText(err) {
// --- старт -------------------------------------------------------------
async function boot() {
+ // Масштабирования нет: интерфейс ведёт себя как приложение, а не как
+ // страница (ADR-075). Строке viewport Safari верит не всегда, поэтому
+ // страховка ставится до первого экрана.
+ lock();
db.persist();
// Service worker ставится с первой секунды: кэш оболочки нужен и до
// входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает.
pwa.register();
+ // Новая версия оболочки включается сама и перезагружает страницу:
+ // в установленном на «Домой» приложении перезагрузить её нечем
+ // (ADR-068).
+ pwa.watch();
// Первое успешно отправленное сообщение за всю историю устройства —
// единственный повод спросить разрешение на уведомления (ADR-011);
// «один раз» считает pwa.js.
diff --git a/web/js/pwa.js b/web/js/pwa.js
index 633dc3a..4955b5f 100644
--- a/web/js/pwa.js
+++ b/web/js/pwa.js
@@ -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 вне
diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js
index 25c96c1..1816492 100644
--- a/web/js/ui/chat.js
+++ b/web/js/ui/chat.js
@@ -49,6 +49,10 @@ export function renderChat(root, ctx, chatId) {
roomId: sync.roomIdOf(chatId),
limit: ctx.config?.maxMessageChars ?? LIMIT,
alive: true,
+ // Каким значением открыт редактируемый блок строки ввода:
+ // "plaintext-only" или "true" (ADR-069). От него зависит, разбирает ли
+ // браузер вставку и перенос строки сам или это делаем мы.
+ edit: "true",
// Имя комнаты; до чтения записи чата вместо него идентификатор,
// как в db.blankChat.
name: sync.roomIdOf(chatId),
@@ -185,12 +189,11 @@ async function refreshRoom(view, known) {
}
view.name = record?.title || view.roomId;
view.title.textContent = titleText(view);
- view.field.placeholder = `сообщение в #${shortName(view.name)}`;
+ hint(view, `сообщение в #${shortName(view.name)}`);
// Скрытая запись комнаты — это room_left или собственный выход:
// отправлять больше некуда, и сервер ответил бы not_member.
view.gone = record?.hidden === true;
- view.field.disabled = view.gone;
- view.send.disabled = view.gone;
+ allow(view, !view.gone);
paintBar(view);
}
@@ -198,12 +201,10 @@ async function refreshRoom(view, known) {
// цветом mark. Enter отправляет только на десктопе; на мобильном он делает
// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»).
//
-// div, не form: поле формы на iOS Safari/Chrome поднимает над клавиатурой
-// системную панель навигации между полями («‹ ›» и «готово») — лишние
-// ~50 px ради одного поля, которому переходить некуда. role="form" держит
-// ту же семантику для скринридера (docs/ui.md, «Доступность»), без form-а;
-// autocomplete="off" на поле — по той же причине, на случай если панель
-// зависит ещё и от него.
+// Строка сообщения — редактируемый блок, а не поле формы (ADR-069): над
+// клавиатурой iOS рисует полосу помощника форм, и бывает она только
+// у input и textarea. Со страницы её не убрать, а в чате она занимает место
+// и ничего не делает: поле на экране одно, переходить стрелками некуда.
function composer(view) {
const form = el("div", "compose");
form.setAttribute("role", "form");
@@ -216,11 +217,22 @@ function composer(view) {
const prompt = el("span", "p", ">");
prompt.setAttribute("aria-hidden", "true");
- view.field = el("textarea", "input__field");
- view.field.rows = 1;
- view.field.placeholder = "сообщение";
- view.field.maxLength = view.limit;
- view.field.autocomplete = "off";
+ view.field = el("div", "input__field input__field--text");
+ view.edit = editing(view.field);
+ // Поле ввода без input: экранному диктору о нём говорят роль и подпись,
+ // подпись же стоит подсказкой в пустом блоке (docs/ui.md, «Доступность»).
+ view.field.setAttribute("role", "textbox");
+ view.field.setAttribute("aria-multiline", "true");
+ hint(view, "сообщение");
+ // Клавиша Enter на мобильном переносит строку, а не отправляет: отправка
+ // там на кнопке «>» (docs/ui.md, «Чат»). Поэтому «enter», а не «send»:
+ // подпись клавиши обещает то, что клавиша делает.
+ view.field.enterKeyHint = "enter";
+ // Сообщение — обычная речь, а не ник и не пароль: заглавная в начале
+ // предложения и исправление опечаток тут к месту (в формах входа
+ // и «нового чата» они, наоборот, выключены).
+ view.field.autocapitalize = "sentences";
+ view.field.setAttribute("autocorrect", "on");
view.counter = el("span", "counter");
view.counter.hidden = true;
@@ -232,34 +244,296 @@ function composer(view) {
row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send);
form.append(view.bar, row);
- view.field.addEventListener("input", () => count(view));
- view.field.addEventListener("keydown", (event) => {
- if (event.key !== "Enter" || event.shiftKey || event.isComposing) {
+ // Предел держится до вставки, а не после: обрезается приходящее,
+ // а набранное остаётся на месте (ADR-071).
+ view.field.addEventListener("beforeinput", (event) => cap(view, event));
+ view.field.addEventListener("input", (event) => {
+ if (event.isComposing) {
+ // Пока идёт композиция IME, содержимое не трогаем: правка оборвала
+ // бы её на полуслове, а подтверждённое не попало бы в блок вовсе.
+ // Подтверждённое разберёт compositionend (ADR-071).
return;
}
- if (!wide()) {
- return;
- }
- event.preventDefault();
- submit(view);
+ fit(view);
+ tail(view);
+ count(view);
});
-
+ view.field.addEventListener("compositionend", () => {
+ fit(view);
+ tail(view);
+ count(view);
+ });
+ view.field.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" || event.isComposing) {
+ return;
+ }
+ if (wide() && !event.shiftKey) {
+ event.preventDefault();
+ submit(view);
+ return;
+ }
+ if (view.edit !== "plaintext-only") {
+ // Блок без plaintext-only ставит на Enter или
, а значение
+ // читается textContent: перенос вписываем сами (ADR-069).
+ event.preventDefault();
+ insert(view, "\n");
+ }
+ });
+ if (view.edit !== "plaintext-only") {
+ // plaintext-only приносит из буфера и мыши только текст сам. Без него
+ // в блок приезжает чужая разметка, поэтому вставка и перенос — наши.
+ view.field.addEventListener("paste", (event) => {
+ event.preventDefault();
+ insert(view, event.clipboardData?.getData("text/plain") ?? "");
+ });
+ view.field.addEventListener("drop", (event) => {
+ event.preventDefault();
+ insert(view, event.dataTransfer?.getData("text/plain") ?? "");
+ });
+ // Перетаскивание изнутри блока браузер сделал бы переносом: вписал бы
+ // текст на новом месте и убрал со старого. Вставку мы делаем сами,
+ // отменяя действие по умолчанию, — уносить исходное браузеру нечем,
+ // и перенос молча оборачивался бы удвоением. Объявляем копирование:
+ // тогда это честная копия (ADR-071).
+ view.field.addEventListener("dragstart", (event) => {
+ if (event.dataTransfer !== null) {
+ event.dataTransfer.effectAllowed = "copy";
+ }
+ });
+ }
return form;
}
+// editing включает редактирование блока и отдаёт значение, которое
+// применилось. Нужен plaintext-only: в нём браузер кладёт в блок только
+// текст, чем бы ни был буфер обмена, и разметке взяться неоткуда.
+//
+// Знают его не все браузеры, и незнакомое значение атрибута делает блок
+// нередактируемым вовсе — то есть строка ввода перестала бы работать молча.
+// Поэтому оно не назначается, а проверяется чтением: не применилось —
+// остаётся "true", а вставку, перетаскивание и перенос строки разбирает
+// composer (ADR-069).
+function editing(field) {
+ try {
+ field.contentEditable = "plaintext-only";
+ } catch {
+ // Браузер, которому это значение незнакомо, бросает SyntaxError.
+ }
+ if (field.contentEditable === "plaintext-only") {
+ return "plaintext-only";
+ }
+ field.contentEditable = "true";
+ return "true";
+}
+
+// hint — подсказка в пустой строке ввода (docs/ui.md, «Чат»). placeholder
+// у редактируемого блока не бывает: текст кладётся атрибутом, а рисует его
+// правило CSS через :empty::before. Тот же текст — подпись для экранного
+// диктора: другой у строки ввода нет.
+function hint(view, text) {
+ view.field.dataset.hint = text;
+ view.field.setAttribute("aria-label", text);
+}
+
+// allow открывает и закрывает ввод: предупреждение о ключе и уход
+// из комнаты гасят и блок, и кнопку «>» (ADR-016, ADR-044). У блока нет
+// disabled — закрытый перестаёт быть редактируемым и говорит об этом
+// экранному диктору.
+function allow(view, on) {
+ view.field.contentEditable = on ? view.edit : "false";
+ if (on) {
+ view.field.removeAttribute("aria-disabled");
+ } else {
+ view.field.setAttribute("aria-disabled", "true");
+ }
+ view.send.disabled = !on;
+}
+
+// cap держит предел до вставки: обрезается то, что приходит в блок,
+// а набранное остаётся на месте — так же, как считал maxLength у textarea
+// (ADR-071). Влезающее браузер вставляет сам: тогда и отмена (cmd+z)
+// остаётся его, а не нашей.
+//
+// Композицию IME не трогаем вовсе: отменённая на полуслове, она уносит
+// с собой и подтверждённый ввод. Лишнее в ней снимет fit на compositionend.
+function cap(view, event) {
+ if (event.isComposing || event.inputType === "insertCompositionText") {
+ return;
+ }
+ if (!event.inputType.startsWith("insert")) {
+ return;
+ }
+ // Перенос строки и прочее без текста считаем одним символом: тогда cap
+ // вмешивается только тогда, когда места не осталось совсем.
+ const text = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
+ const range = target(view, event);
+ if ((text === "" ? 1 : text.length) <= free(view, range)) {
+ return;
+ }
+ event.preventDefault();
+ insert(view, text, range);
+}
+
+// target — куда пойдёт вставка: диапазон, который браузер собирается
+// заменить (beforeinput знает его точно — это не всегда выделение:
+// вставка мышью идёт туда, куда отпустили), иначе выделение в блоке.
+function target(view, event = null) {
+ const ranges = event?.getTargetRanges?.() ?? [];
+ if (ranges.length > 0) {
+ const range = document.createRange();
+ range.setStart(ranges[0].startContainer, ranges[0].startOffset);
+ range.setEnd(ranges[0].endContainer, ranges[0].endOffset);
+ return view.field.contains(range.commonAncestorContainer) ? range : null;
+ }
+ const selection = getSelection();
+ const now = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
+ return now !== null && view.field.contains(now.commonAncestorContainer) ? now : null;
+}
+
+// free — сколько символов ещё влезет туда, куда идёт вставка: предел минус
+// то, что останется от набранного, когда заменяемое уйдёт.
+function free(view, range) {
+ return view.limit - (value(view).length - (range === null ? 0 : range.toString().length));
+}
+
+// value — набранное так, как оно уедет собеседнику. В plaintext-only
+// браузер держит последнюю строку вторым «\n» в самом конце: в textContent
+// он виден, а в сообщении его нет — его снимает trim в sync.send. Считаем
+// и режем по тому же, что отправляем (ADR-071). В запасном пути последнюю
+// строку держит
, которого в textContent нет вовсе, и перенос в конце
+// там настоящий.
+function value(view) {
+ const text = view.field.textContent;
+ return view.edit === "plaintext-only" && text.endsWith("\n") ? text.slice(0, -1) : text;
+}
+
+// cut режет текст по пределу, не разрывая суррогатную пару: половина пары
+// уехала бы собеседнику битым символом.
+function cut(text, limit) {
+ if (limit <= 0) {
+ return "";
+ }
+ if (text.length <= limit) {
+ return text;
+ }
+ const last = text.charCodeAt(limit - 1);
+ return text.slice(0, last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit);
+}
+
+// fit — последняя страховка предела (docs/ui.md, «Чат»): лишнее в блок
+// попадает мимо cap — композицией IME или вставкой, о которой браузер
+// не рассказал. Снимается ровно хвост сверх предела, а не переписывается
+// блок целиком: правка узлов оставляет курсор на месте и не сносит стек
+// отмены. Опустевший блок остаётся без узлов: подсказка стоит правилом
+// :empty, а браузер оставляет в опустевшем блоке
, и с ним подсказки
+// не видно.
+function fit(view) {
+ const raw = view.field.textContent;
+ const text = value(view);
+ const keep = cut(text, view.limit);
+ if (keep.length < text.length) {
+ // Вместе с лишним уходит и заполнитель последней строки, если он есть:
+ // он в самом конце, а браузер ставит его снова, когда понадобится.
+ trim(view.field, raw.length - keep.length);
+ return;
+ }
+ if (raw === "" && view.field.firstChild !== null) {
+ clear(view.field);
+ }
+}
+
+// trim снимает с хвоста блока лишние единицы текста, не трогая остального:
+// textContent = … переписал бы блок целиком и унёс бы и курсор, и отмену.
+function trim(field, extra) {
+ const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT);
+ const nodes = [];
+ while (walker.nextNode() !== null) {
+ nodes.push(walker.currentNode);
+ }
+ let left = extra;
+ for (let i = nodes.length - 1; i >= 0 && left > 0; i -= 1) {
+ const node = nodes[i];
+ const take = Math.min(left, node.length);
+ node.deleteData(node.length - take, take);
+ left -= take;
+ }
+}
+
+// insert вписывает текст туда, куда идёт вставка, обрезая его по свободному
+// месту. Своими руками, а не execCommand: тот в Chrome разбирает перенос
+// строки в
, а в блоке живёт только текст.
+function insert(view, text, where) {
+ // where — диапазон, который назвал beforeinput. Его не передали — берём
+ // выделение; выделения в блоке нет — вписываем в конец.
+ const range = where === undefined ? target(view) : where;
+ const fitted = cut(text, free(view, range));
+ if (fitted === "") {
+ return;
+ }
+ const node = document.createTextNode(fitted);
+ const selection = getSelection();
+ if (range === null) {
+ view.field.append(node);
+ } else {
+ range.deleteContents();
+ range.insertNode(node);
+ }
+ if (selection !== null) {
+ const at = document.createRange();
+ at.setStartAfter(node);
+ at.collapse(true);
+ selection.removeAllRanges();
+ selection.addRange(at);
+ }
+ // Вставка делит текст на два-три соседних узла; курсор normalize
+ // переставляет сам.
+ view.field.normalize();
+ // Своё изменение блока события input не порождает: счётчик, предел
+ // и последнюю строку трогаем руками.
+ fit(view);
+ tail(view);
+ count(view);
+}
+
+// tail держит последнюю пустую строку видимой. Перенос в самом конце
+// браузер не рисует — строки после него нет, — и курсор оставался бы
+// на прежней строке, а набранное дальше уезжало бы перед переносом.
+// Пустую строку держит : тот самый заполнитель, который браузер сам
+// ставит в опустевший блок. В textContent его нет, и на отправляемый текст
+// он не влияет.
+//
+// В plaintext-only это не нужно: там последнюю строку браузер ведёт сам,
+// своим переносом, и лишний заполнитель дал бы вторую пустую строку.
+function tail(view) {
+ if (view.edit === "plaintext-only") {
+ return;
+ }
+ const last = view.field.lastChild;
+ const wants = view.field.textContent.endsWith("\n");
+ if (wants && (last === null || last.nodeName !== "BR")) {
+ view.field.append(document.createElement("br"));
+ return;
+ }
+ if (!wants && last !== null && last.nodeName === "BR") {
+ last.remove();
+ }
+}
+
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
+// Считается то, что уедет: заполнитель последней строки в счёт не идёт
+// (ADR-071).
function count(view) {
- const length = view.field.value.length;
+ const length = value(view).length;
view.counter.textContent = String(view.limit - length);
view.counter.hidden = length <= COUNTER_AT;
}
function submit(view) {
- const text = view.field.value;
+ const text = view.field.textContent;
if (view.blocked || view.gone || text.trim() === "") {
return;
}
- view.field.value = "";
+ view.field.textContent = "";
count(view);
run(view, () => sync.send(view.chatId, text));
}
@@ -615,9 +889,8 @@ async function checkPeer(view) {
return;
}
view.blocked = !!record?.pending;
- // Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном.
- view.field.disabled = view.blocked;
- view.send.disabled = view.blocked;
+ // Ввод заблокирован целиком: и блок, и кнопка «>» на мобильном.
+ allow(view, !view.blocked);
paintBar(view);
}
diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js
index 10a9652..3e26af8 100644
--- a/web/js/ui/shell.js
+++ b/web/js/ui/shell.js
@@ -5,6 +5,12 @@ import * as pwa from "../pwa.js";
import { INSTALL_IOS, clear, el, mark } from "./dom.js";
import { mount } from "./chats.js";
+// Время коммита — в местной зоне и коротко: день с месяцем и часы
+// с минутами (ADR-067). Год не показывается: версия отвечает на вопрос
+// «что сейчас работает», а не ведёт летопись.
+const BUILD_DAY = new Intl.DateTimeFormat("ru-RU", { day: "2-digit", month: "2-digit" });
+const BUILD_TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" });
+
// frame отдаёт корень, место под экран и отписку списка чатов.
// screen — что показывать на мобильном, где виден один экран за раз:
// "list" или "screen". active — чат, который сейчас открыт.
@@ -38,17 +44,39 @@ function side(ctx, active) {
list.append(add, items);
nav.append(list);
+ const foot = el("div", "foot");
const me = el("button", "me");
me.type = "button";
const dot = el("i");
dot.setAttribute("aria-hidden", "true");
- me.append(dot, el("span", null, `ты: @${ctx.me.nick}`));
+ me.append(dot, el("span", "me__nick", `ты: @${ctx.me.nick}`));
me.addEventListener("click", () => ctx.go("#/settings"));
- nav.append(me);
+ foot.append(me);
+ const version = build(ctx.config);
+ if (version !== null) {
+ foot.append(el("span", "build", version));
+ }
+ nav.append(foot);
return { nav, dispose: mount(items, ctx, active) };
}
+// build — версия и время коммита из GET /api/config (ADR-074, ADR-067).
+// Конфигурации нет — офлайн-старт до первого ответа сервера — значит,
+// и строки нет: выдумывать версию не из чего. Время без версии не бывает:
+// её сервер отдаёт всегда, хотя бы как «unknown».
+function build(config) {
+ const version = config?.version;
+ if (typeof version !== "string" || version === "") {
+ return null;
+ }
+ const at = config?.commitAt;
+ if (!Number.isFinite(at) || at <= 0) {
+ return version;
+ }
+ return `${version} · ${BUILD_DAY.format(at)} ${BUILD_TIME.format(at)}`;
+}
+
// banner — баннер установки на iOS: пуши там работают только
// у установленного приложения (ADR-011). Крестик закрывает его насовсем.
async function banner(place) {
diff --git a/web/js/zoom.js b/web/js/zoom.js
new file mode 100644
index 0000000..2b6c0b3
--- /dev/null
+++ b/web/js/zoom.js
@@ -0,0 +1,69 @@
+// Запрет масштабирования — страховка к строке viewport (ADR-075).
+//
+// Основное средство — `maximum-scale=1, user-scalable=no` в index.html
+// и `touch-action: manipulation` в app.css. Safari вправе не послушаться:
+// он и раньше игнорировал `user-scalable=no` целиком. Здесь то, что
+// работает независимо от его настроения, — и ничего больше: прокрутка,
+// свайпы и обычные нажатия проходят как проходили.
+
+// Щипок в Safari — это gesturestart/gesturechange/gestureend. В других
+// браузерах их не бывает вовсе, и подписка ничего не стоит.
+const GESTURES = ["gesturestart", "gesturechange", "gestureend"];
+
+// Два тапа подряд ближе этого срока и этого расстояния друг к другу —
+// двойной тап, то есть масштабирование. Пороги браузерные: свои цифры
+// здесь всё равно ничем не проверить.
+const TAP_APART = 350;
+const TAP_NEAR = 40;
+
+// Прошлый тап. До первого касания его не было вовсе: иначе тап в углу
+// в первые триста миллисекунд жизни страницы сошёл бы за второй.
+const last = { at: -Infinity, x: 0, y: 0 };
+
+// lock вешает страховку. Слушатели непассивные: пассивному
+// preventDefault не даёт ничего.
+export function lock() {
+ for (const name of GESTURES) {
+ document.addEventListener(name, stop, { passive: false });
+ }
+ document.addEventListener("touchend", tap, { passive: false });
+}
+
+function stop(event) {
+ event.preventDefault();
+}
+
+// tap гасит второй тап подряд — он и масштабирует. Первый доходит
+// до страницы целиком, поэтому нажатия, свайпы и прокрутка не меняются.
+function tap(event) {
+ // Второй палец на экране — это щипок или прокрутка двумя, не тап.
+ if (event.touches.length > 0 || event.changedTouches.length !== 1) {
+ last.at = -Infinity;
+ return;
+ }
+ const touch = event.changedTouches[0];
+ const quick = event.timeStamp - last.at <= TAP_APART;
+ const near = Math.abs(touch.clientX - last.x) <= TAP_NEAR
+ && Math.abs(touch.clientY - last.y) <= TAP_NEAR;
+ last.at = event.timeStamp;
+ last.x = touch.clientX;
+ last.y = touch.clientY;
+ if (!quick || !near || control(event.target)) {
+ return;
+ }
+ event.preventDefault();
+}
+
+// control — элемент управления. Второй тап по нему не гасится:
+// preventDefault на touchend уносит с собой click, а нажать кнопку
+// дважды подряд — обычное дело. Цена мала: до элементов управления зум
+// по двойному тапу не доходит и без страховки — `touch-action:
+// manipulation` стоит на документе.
+//
+// Строка сообщения — редактируемый блок, а не textarea (ADR-069), и она
+// в этом перечне: погашенный тап не доносит до неё ни click, ни фокус,
+// то есть второй тап подряд не ставил бы курсор в поле ввода.
+function control(target) {
+ return typeof target?.closest === "function"
+ && target.closest("button, input, textarea, select, label, a, [contenteditable]") !== null;
+}
diff --git a/web/sw.js b/web/sw.js
index 1f8f0c0..ca5076d 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
-const VERSION = "v5";
+const VERSION = "v6";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
@@ -25,6 +25,7 @@ const SHELL = [
"/js/pwa.js",
"/js/sync.js",
"/js/ulid.js",
+ "/js/zoom.js",
"/js/ui/auth.js",
"/js/ui/chat.js",
"/js/ui/chats.js",
@@ -54,6 +55,25 @@ self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
});
+// Установленная версия ждёт очереди, пока страница не попросит её включить
+// (ADR-068). Просит именно страница: в установленном на «Домой»
+// приложении перезагрузить оболочку больше нечем, а терять набранное
+// в строке ввода нельзя — знает об этом только она.
+//
+// На «version» воркер называет свою версию. По ней страница отличает новую
+// оболочку от того же файла воркера, отданного сервером заново: первая
+// стоит перезагрузки, второй — нет (ADR-070). Ответ уходит каналом
+// вопроса: вопросов бывает два подряд, к разным воркерам.
+self.addEventListener("message", (event) => {
+ if (event.data === "skip-waiting") {
+ self.skipWaiting();
+ return;
+ }
+ if (event.data === "version") {
+ event.ports[0]?.postMessage(VERSION);
+ }
+});
+
// activate уносит кэши прежних версий: имя кэша содержит версию, и всё,
// что названо иначе, — прошлый релиз. clients.claim берёт под контроль
// уже открытую страницу: без этого первый запуск остался бы без кэша