diff --git a/cmd/bare/main.go b/cmd/bare/main.go index b165156..95e6577 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -2,7 +2,7 @@ // // bare serve запустить http-сервер // bare vapid напечатать пару vapid-ключей -// bare version напечатать ревизию сборки +// bare version напечатать ревизию и время коммита package main import ( @@ -16,11 +16,11 @@ import ( "net/http" "os" "os/signal" - "runtime/debug" "syscall" "time" "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/build" "github.com/xmatic-squad/bare/internal/config" "github.com/xmatic-squad/bare/internal/store" "github.com/xmatic-squad/bare/internal/web" @@ -56,7 +56,7 @@ func usage() { использование: bare serve запустить http-сервер bare vapid напечатать пару vapid-ключей - bare version напечатать ревизию сборки + bare version напечатать ревизию и время коммита настройка — переменные окружения BARE_*, см. docs/deploy.md `) @@ -159,33 +159,17 @@ func vapid() error { return nil } +// version печатает, какой код собран в этот бинарь: короткую ревизию +// и время коммита. Время коммита, а не компиляции: штамп момента сборки +// делал бы каждую пересборку одного коммита новым файлом, и сверка хеша +// со сборкой из тега перестала бы что-либо значить (ADR-022, ADR-074). +// Ревизию, которой нет, и время, которого нет, бинарь не выдумывает +// (ADR-057). func version() { - fmt.Println(revision()) -} - -// revision — ревизия сборки. У бинаря из изменённого рабочего дерева -// к ней дописывается «+dirty»: сверка хеша со сборкой из тега — единственное -// смягчение против подмены клиента (docs/threat-model.md), и чистый хеш -// коммита у бинаря с чужими правками сводил бы её на нет (ADR-057). -func revision() string { - info, ok := debug.ReadBuildInfo() - if !ok { - return "unknown" + info := build.Current() + line := info.Version() + if !info.CommitAt.IsZero() { + line += " " + info.CommitAt.UTC().Format(time.RFC3339) } - var vcs, modified string - for _, s := range info.Settings { - switch s.Key { - case "vcs.revision": - vcs = s.Value - case "vcs.modified": - modified = s.Value - } - } - if vcs == "" { - return "unknown" - } - if modified == "true" { - return vcs + "+dirty" - } - return vcs + fmt.Println(line) } diff --git a/docs/decisions/022-deploy-nginx-systemd.md b/docs/decisions/022-deploy-nginx-systemd.md index 66eb5a6..1f8e300 100644 --- a/docs/decisions/022-deploy-nginx-systemd.md +++ b/docs/decisions/022-deploy-nginx-systemd.md @@ -1,6 +1,6 @@ # ADR-022: Деплой — nginx, systemd, кросс-сборка -Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева). +Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`), [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева) и [ADR-074](074-version-and-commit-time.md) (версия и время коммита в `GET /api/config`, времени компиляции в бинаре нет). ## Контекст diff --git a/docs/decisions/023-push-and-service-worker.md b/docs/decisions/023-push-and-service-worker.md index 98d1b45..b2d4d3b 100644 --- a/docs/decisions/023-push-and-service-worker.md +++ b/docs/decisions/023-push-and-service-worker.md @@ -2,6 +2,8 @@ Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md). +Изменён [ADR-068](068-pwa-self-update.md): установленная версия оболочки больше не ждёт закрытия всех вкладок — её включает страница, и она же перезагружается. + ## Контекст ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker. diff --git a/docs/decisions/024-identity-and-ui.md b/docs/decisions/024-identity-and-ui.md index 5e90d44..036f93e 100644 --- a/docs/decisions/024-identity-and-ui.md +++ b/docs/decisions/024-identity-and-ui.md @@ -1,5 +1,7 @@ # ADR-024: Айдентика «Скобы», интерфейс и язык +Уточнён [ADR-075](075-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара) и [ADR-069](069-message-input-is-editable-block.md) (строка сообщения — редактируемый блок). + ## Контекст Исследование айдентики (Claude Design, «Исследование айдентики Bare») дало шесть направлений и две мини-айдентики; мок чата построен на варианте 1h «Скобы» и использует только моноширинный шрифт, без «пузырей». Открытые вопросы: язык интерфейса и i18n, визуальная айдентика. Мок содержит элементы, которых в scope v1 нет. diff --git a/docs/decisions/047-push-endpoint.md b/docs/decisions/047-push-endpoint.md index 94e6db2..351b563 100644 --- a/docs/decisions/047-push-endpoint.md +++ b/docs/decisions/047-push-endpoint.md @@ -1,6 +1,6 @@ # ADR-047: Исходящий запрос к push-сервису -Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). +Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). Код причины из ответа push-сервиса в журнале — [ADR-073](073-push-failure-reason-in-log.md). ## Контекст diff --git a/docs/decisions/057-version-marks-dirty-tree.md b/docs/decisions/057-version-marks-dirty-tree.md index 2ca91e4..d9a88a8 100644 --- a/docs/decisions/057-version-marks-dirty-tree.md +++ b/docs/decisions/057-version-marks-dirty-tree.md @@ -2,6 +2,8 @@ Уточняет [ADR-022](022-deploy-nginx-systemd.md): проверка подлинности бинаря опирается на ревизию, значит ревизия обязана быть честной. +Уточнён [ADR-074](074-version-and-commit-time.md): чтение build info переехало в `internal/build`, ревизия печатается короткой и рядом с временем коммита; `+dirty` и `unknown` — как здесь. Формат стал проверяемым тестом — разбор build info отделён от `debug.ReadBuildInfo`; сама простановка `vcs.*` по-прежнему проверяется руками. + ## Контекст `docs/threat-model.md` называет единственное смягчение против активно-злонамеренного оператора: «статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить». `docs/deploy.md` доводит это до двух проверок после деплоя — `sha256sum` на сервере и `bare version`. diff --git a/docs/decisions/064-composer-not-form.md b/docs/decisions/064-composer-not-form.md index 172c7b2..ab4856e 100644 --- a/docs/decisions/064-composer-not-form.md +++ b/docs/decisions/064-composer-not-form.md @@ -2,6 +2,8 @@ Уточняет `docs/ui.md`, «Доступность»: `form` в списке семантики держит `role="form"`, а не элемент `
`, — для строки ввода чата. +Пересмотрен [ADR-069](069-message-input-is-editable-block.md). Диагноз здесь неверен: полосу помощника форм iOS рисует не из-за ``, а для любых `input` и `textarea`, — вынос поля из формы её не убрал. Контейнер `div` с `role="form"` и кнопка `type="button"` остаются, `textarea` заменён редактируемым блоком — тем самым «следующим шагом», который записан ниже в «Следствиях». + ## Контекст Тест с реальными пользователями (iPhone, Safari и Chrome): над клавиатурой при фокусе в строке ввода всплывает системная панель навигации между полями — «‹ ›» и «готово», около 50 px. Полю переходить некуда — оно на экране одно, — а место съедено. diff --git a/docs/decisions/067-version-in-sidebar-foot.md b/docs/decisions/067-version-in-sidebar-foot.md new file mode 100644 index 0000000..59b2bfd --- /dev/null +++ b/docs/decisions/067-version-in-sidebar-foot.md @@ -0,0 +1,26 @@ +# ADR-067: Версия и время коммита в подвале сайдбара + +Доводит до интерфейса [ADR-074](074-version-and-commit-time.md): сервер отдаёт `version` и `commitAt`, а где их показывать, там не решено. + +## Контекст + +`GET /api/config` называет работающую версию и время коммита (ADR-074). Клиент читает конфигурацию при запуске и так — ради `kdfIterations` и `vapidPublicKey`. Показать их негде: `docs/ui.md` такого элемента не знает. + +Спрашивают об этом при каждой поломке: обновилось ли то, что человек видит. В установленном на «Домой» приложении вопрос острее — там нет ни адресной строки, ни консоли, и другого способа узнать версию у человека нет. + +## Решение + +- Версия стоит в подвале сайдбара, рядом с «ты: @nick»: это единственное постоянное место интерфейса, где уже написано, кто и где мы. +- Текст — `версия · дд.мм чч:мм`, время коммита в местной зоне. Год не показывается: вопрос «что сейчас работает», а не летопись. Времени нет (`commitAt = 0`) — остаётся одна версия; конфигурации нет вовсе — строки нет. +- Цвет `stone`, размер подписи 11 px, без рамок и фона. Акцентом версия не бывает: `mark` — один смысловой элемент на экран (ADR-024), и это непрочитанные, а не служебная строка. +- Не кнопка и не ссылка: нажимать в ней нечего. Стоит рядом с кнопкой «ты: @nick», а не внутри неё — иначе экранный диктор зачитывал бы хеш как часть названия кнопки. Выделяется, в отличие от остального сайдбара (ADR-075): на iPhone скопировать её иначе нечем. +- В сайдбаре 224 px строка и версия рядом не помещаются никогда: `версия · дд.мм чч:мм` — это 179 px при внутренней ширине 184, а «ты: @nick» просит ещё восемьдесят с лишним. Значит, на десктопе версия всегда занимает вторую строку и стоит справа; на мобильном, где сайдбар во всю ширину, обе стоят в одной строке. Ник, которому не хватило и целой строки, обрезается многоточием; версия — никогда: половина хеша бесполезна. +- Текст ради одной строки не режется: вариант «в подвале ревизия, время в `title`» отклонён — `title` не показывается на телефоне, а телефон и есть место, ради которого строка заведена. + +## Следствия + +- Версия видна с любого экрана, где виден сайдбар, и на мобильном — в списке чатов. +- Разговор о поломке начинается с версии, а не с «попробуйте обновиться». +- Подвал сайдбара стал двухстрочным и вырос с 47 px мока до 76. Это принятое отклонение от `docs/identity/screens.html`, блок 2a: там подвал — одна строка «ты: @nick» без версии и без 44 px цели нажатия. Мок остаётся снимком айдентики на момент ADR-024 и за интерфейсом не идёт: в нём нет и строки `+ новый чат`, и баннера установки. +- Названа версия сервера, а не оболочки, которую исполняет браузер: `version` приезжает с `GET /api/config`, а страница и её модули — из кэша service worker. После релиза первое открытие показывает новую версию под старой оболочкой; расходятся они до применения обновления (ADR-068), а с недописанным сообщением в поле — сколь угодно долго. Показывать рядом версию кэша — отдельное решение, здесь его нет. +- Записано в `docs/ui.md`, «Список чатов (сайдбар)». Отдельного запроса ради этого нет: поля приезжают с конфигурацией (ADR-074). diff --git a/docs/decisions/068-pwa-self-update.md b/docs/decisions/068-pwa-self-update.md new file mode 100644 index 0000000..07d6451 --- /dev/null +++ b/docs/decisions/068-pwa-self-update.md @@ -0,0 +1,33 @@ +# ADR-068: Приложение обновляется само + +Меняет политику обновления из [ADR-023](023-push-and-service-worker.md): новая оболочка забирает управление, не дожидаясь закрытия всех вкладок. + +Изменён [ADR-070](070-update-compares-shell-version.md): второй замок — растущая пауза и счётчик `bare-updates` — заменён сверкой версии оболочки. + +Уточнён [ADR-072](072-deferred-update-returns-by-timer.md): к отложенному набранным вкладка возвращается ещё и по таймеру — поле пустеет и молча. + +## Контекст + +`sw.js` обновлялся по умолчанию браузера: новая версия устанавливается, ждёт, и берёт управление, когда закрыты все вкладки приложения. Для сайта это правильно — страница не перезагружается под руками. + +Для установленного на «Домой» приложения это означает «никогда». Его не закрывают: iOS держит его в списке приложений неделями. Строки адреса и кнопки перезагрузки в нём нет — обновить оболочку человеку нечем вовсе. Он остаётся на версии, которая была при установке, и починенное на сервере до него не доезжает. + +Перезагрузка при этом не бесплатна: она уносит набранное в строке ввода и в формах. Пароль в форме входа теряется так же, как черновик сообщения. + +## Решение + +- Клиент спрашивает сервер об обновлении (`registration.update()`) при запуске и при каждом возвращении в приложение (`visibilitychange` → `visible`), но не чаще раза в минуту. Проверка — один условный запрос за `/sw.js`. Без сети запрос падает молча: офлайн не сбой. +- Установленная версия не включается сама. Её включает страница: `postMessage("skip-waiting")` воркеру, который ждёт. Воркер отвечает `skipWaiting()`, `activate` забирает клиентов (`clients.claim` там уже есть), браузер шлёт `controllerchange`, страница перезагружается. Просьба уходит только тогда, когда страницей уже кто-то управляет: первая установка проходит через то же состояние «установлен», но включать там нечего — воркер активируется сам, а страница и так свежая. +- Набранное откладывает всё: и просьбу включиться, и перезагрузку. Проверяется весь документ, а не строка ввода: клиент — одна страница, и знать про экраны здесь незачем. Набранным считается непустое `textarea` и непустое поле, куда набирают текст (`text`, `password`, `search`, `email`, `url`, `tel`, `number`); чекбокс, файл, скрытое поле и кнопка не в счёт — у них значение непусто по определению, и одно такое поле запретило бы обновление насовсем. Отложенное трогается с места, когда поля опустели (событие `input`) или когда человек вернулся в приложение. +- Ждёт каждая вкладка за себя. `skipWaiting()` меняет контроллёра у всех клиентов профиля сразу, а вкладок одного профиля бывает несколько (ADR-035): вкладка с пустыми полями включает новую оболочку, и соседняя с недописанным сообщением остаётся под ней на старой странице — до своей перезагрузки, которой она ждёт по своим полям. Договариваться вкладкам не о чем: терять нечего никому, а пересчёт голосов стоил бы протокола между ними. +- Петля закрыта двумя замками. Первый: `controllerchange` перезагружает страницу один раз за её жизнь. Первую установку он отсекает — там контроллёр появляется впервые, `clients.claim` меняет его и без обновления, а страница и так свежая; замок эту смену съедает и запоминает, что страница стала управляемой. Дальше смена контроллёра означает новую оболочку, чью бы просьбу воркер ни исполнял — свою или соседней вкладки. Второй: в `sessionStorage` лежат время последней перезагрузки ради обновления (`bare-updated-at`) и их число в этой вкладке (`bare-updates`), а пауза перед следующей удваивается — 30 секунд, минута, две, четыре и дальше. Второй замок нужен против сервера, отдающего новый `sw.js` на каждый запрос: постоянная пауза задавала бы такой петле только темп, а растущая гасит её — после десятой перезагрузки ждать четыре часа, после двенадцатой сутки, дальше пауза перестаёт расти за ненадобностью. Обычные релизы её не замечают: между ними проходит больше. +- Версия кэша в `sw.js` по-прежнему меняется при релизе, и по-прежнему руками (`docs/deploy.md`): именно её смена делает файл воркера другим и запускает всё описанное. + +## Следствия + +- Установленное приложение доезжает до новой версии само — при следующем открытии или в течение минуты после того, как его открыли. +- Перезагрузка случается посреди сеанса. Терять ей нечего: набранное её откладывает, история в IndexedDB её переживает, открытый чат задан адресом и открывается снова. +- Человек, оставивший недописанное сообщение, остаётся на старой версии, пока не допишет или не сотрёт. Это правильный размен: версия важнее черновика только на словах. +- Вкладка с недописанным сообщением какое-то время исполняет старую оболочку под новым воркером: соседняя вкладка включила его, не спросив. Это ничего не стоит — модули страницы уже загружены, догружать их некому (`import()` в клиенте нет), а `/api/` мимо кэша идёт всегда. +- У клиента появилось хранилище кроме IndexedDB — два ключа в `sessionStorage`, живущие не дольше вкладки. Записано в `docs/storage.md`. +- Отката к «ждать закрытия всех вкладок» не предвидится: на iOS это состояние недостижимо. diff --git a/docs/decisions/069-message-input-is-editable-block.md b/docs/decisions/069-message-input-is-editable-block.md new file mode 100644 index 0000000..bdde49f --- /dev/null +++ b/docs/decisions/069-message-input-is-editable-block.md @@ -0,0 +1,38 @@ +# ADR-069: Строка сообщения — редактируемый блок, а не поле формы + +Уточняет [ADR-024](024-identity-and-ui.md) («Чат» и «Доступность» в `docs/ui.md`) и [ADR-075](075-no-zoom-app-feel.md): в перечне элементов управления, по которым второй тап не гасится, прибавился редактируемый блок. + +Уточнён [ADR-071](071-input-limit-cuts-what-arrives.md): предел держится до вставки и режет приходящее, а не хвост блока. + +Пересматривает [ADR-064](064-composer-not-form.md): там ту же полосу пробовали убрать выносом поля из ``, считая триггером форму. Не помогло — полоса бывает у `input` и `textarea` всегда. Контейнер `div` с `role="form"` и кнопка `type="button"` из ADR-064 сохраняются: событие `submit` в строке ввода больше не участвует. + +## Контекст + +На iPhone при фокусе в строке сообщения над клавиатурой висит системная полоса помощника форм: две стрелки перехода между полями и «готово». Стрелки неактивны — поле на экране одно, переходить некуда, — а полоса занимает место и ломает ощущение приложения, ради которого принят ADR-075. + +Убрать её со страницы нечем: полосу рисует система, и для `input` и `textarea` она бывает всегда. Единственный работающий приём — перестать быть полем формы: у редактируемого блока (`contenteditable`) iOS полосы не показывает. + +Цена приёма известна заранее. Блок принимает вставку с разметкой, ставит на Enter `
` и `
`, не знает ни `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 берёт под контроль // уже открытую страницу: без этого первый запуск остался бы без кэша